diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cea54cae..b97ee026 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,11 +1,7 @@ { "permissions": { "allow": [ - "mcp__dual-graph__graph_continue", - "mcp__dual-graph__graph_read", "Bash(dotnet build:*)", - "mcp__dual-graph__graph_register_edit", - "mcp__dual-graph__graph_scan", "Bash(find /g/Resgrid/Resgrid -type d \\\\\\(-name *mobile* -o -name *Mobile* -o -name *app* -o -name *App* -o -name *apps* -o -name *Apps* \\\\\\))", "Bash(xargs grep:*)", "Bash(grep -r \"mapbox\\\\|react-native\\\\|rnmapbox\" /g/Resgrid/Resgrid --include=package.json --include=*.ts --include=*.tsx)", @@ -13,9 +9,6 @@ "Bash(grep -r \"google.maps\\\\|mapboxgl\\\\|leaflet\\\\|openstreetmap\" G:/Resgrid/Resgrid/Web/Resgrid.Web/Areas/User/Apps/src --include=*.ts)", "Bash(grep -r \"leaflet\\\\|L\\\\.tileLayer\\\\|OpenStreetMap\" /g/Resgrid/Resgrid/Web/Resgrid.Web.Services --include=*.cs)", "Bash(find /g/Resgrid/Resgrid -type f -name *.swift -o -name *.kt -o -name *.java)", - "mcp__graperoot-pro__graph_continue", - "mcp__graperoot-pro__fallback_rg", - "mcp__graperoot-pro__graph_read", "Bash(export PATH=\"$PATH:/usr/local/share/dotnet\")", "Read(//usr/local/**)", "Read(//opt/**)", @@ -24,51 +17,11 @@ "Bash(brew --prefix dotnet)", "Bash(/opt/homebrew/opt/dotnet/bin/dotnet build:*)", "Bash(brew info:*)", - "mcp__graperoot-pro__graph_register_edit", - "mcp__graperoot-pro__graph_grep_all", "Bash(/usr/local/share/dotnet/dotnet build *)", "Bash(awk 'NR>=94 && /HttpGet\\\\\\(\"IncomingMessage\"\\\\\\)/{f=1} f{print NR\": \"$0} f && /^\\\\t\\\\t\\\\}$/{c++; if\\(c==1\\) exit}')", "Bash(dotnet test *)", "Bash(git -C /Volumes/USBSSD/dev/Resgrid/Core log --oneline -1 -- Providers/Resgrid.Providers.Migrations/Migrations/M0094_AddIncidentCommandNameAndLocations.cs)" ] }, - "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "graperoot-pro" - ], - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash|Read", - "hooks": [ - { - "type": "command", - "command": "DG_DATA_DIR=\"/Volumes/USBSSD/dev/Resgrid/Core/.dual-graph-pro\" /Users/shawn/.graperoot-pro/venv/bin/python3 \"/Users/shawn/.graperoot-pro/graph_gate.py\"" - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "DG_DATA_DIR=\"/Volumes/USBSSD/dev/Resgrid/Core/.dual-graph-pro\" /Users/shawn/.graperoot-pro/venv/bin/python3 \"/Users/shawn/.graperoot-pro/graph_sync.py\"" - } - ] - } - ], - "Stop": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "/Users/shawn/.graperoot-pro/venv/bin/python3 \"/Users/shawn/.graperoot-pro/stop_hook.py\"" - } - ] - } - ] - } + "enableAllProjectMcpServers": true } diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 0d56d309..0922ae44 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -31,6 +31,21 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + # Headless browser tests for the web scripts (Tests/Resgrid.Tests/Web/*.test.cjs) run inside + # `dotnet test` through BrowserScriptTests; they need node, the pinned Playwright and a browser. + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: Tests/Resgrid.Tests/Web/package-lock.json + + - name: Install browser test dependencies + working-directory: Tests/Resgrid.Tests/Web + run: | + npm ci --no-audit --no-fund + npx playwright install --with-deps chromium + - name: Restore dependencies run: dotnet restore @@ -38,6 +53,9 @@ jobs: run: dotnet build --no-restore --configuration Release - name: Test + env: + RESGRID_PLAYWRIGHT_CHANNEL: chromium + RESGRID_BROWSER_TESTS: required run: dotnet test --no-build --configuration Release --verbosity normal # ─────────────────────────────────────────────── diff --git a/Core/Resgrid.Config/DataProtectionConfig.cs b/Core/Resgrid.Config/DataProtectionConfig.cs index 9a269c05..64827e91 100644 --- a/Core/Resgrid.Config/DataProtectionConfig.cs +++ b/Core/Resgrid.Config/DataProtectionConfig.cs @@ -141,6 +141,12 @@ public static class DataProtectionConfig /// Operator ceiling on StepUpWindowMinutes; departments cannot exceed it. public static int StepUpMaximumMinutes = 480; + /// + /// Seconds before a Protected Data Grant expires at which the web reveal module warns the user and offers + /// an in-place re-verification, so revealed fields and typed work on an edit page are not lost at expiry. + /// + public static int StepUpExpiryWarningSeconds = 120; + /// /// ADP migration worker: maximum departments whose night runs in one sweep /// (BackOffice-adjustable). Executions are SEQUENTIAL within the sweep — this caps how many diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index 71b7f608..6534225e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -964,4 +964,104 @@ يلزم إدخال بيانات اعتماد جديدة عند تغيير نوع منح الوصول في NERIS. + الإفصاح عن السجلات العامة + الإعدادات الافتراضية لسير عمل الإفصاح: مهلة الرد القانونية، وملف التنقيح الذي يبدأ به الطلب الجديد، ومن يصدر الحزمة المنتجة. + مهلة الرد القانونية (أيام) + عدد الأيام لاستحقاق الطلب الجديد، محسوبة من تاريخ الاستلام. من 1 إلى 365. + ملف التنقيح الافتراضي + يُطبَّق على الطلب الجديد ما لم يختر الضابط ملفاً آخر. + الموافق على الإصدار + العضو الذي يصدر الحزم المنتجة. اتركه فارغاً لأي مسؤول قسم. + أي مسؤول قسم + قياسي - حجب المحتوى المقيد + بدون معرفات شخصية - حجب هويات المشاركين أيضاً + إفصاح كامل - إصدار كل ما هو محفوظ + عرض NFIRS (تاريخي) + عرض NFIRS + حقول وحدة NFIRS الأساسية كما يمكن قراءتها من البيانات التي يحتفظ بها هذا القسم بالفعل، وحقيقة أو قسم NERIS الذي يحمل كلاً منها لاحقاً. + للقراءة فقط. توقف NFIRS في 2026-01-31؛ لا يتم استيراد أو تحرير أو إرسال أي شيء في هذه الصفحة. + حقل NFIRS + حالة الحقل + معبأ + مفقود + غير مسجل + مطلوب في NFIRS + * مطلوب بموجب وحدة NFIRS الأساسية. + المكافئ في NERIS + لا يوجد مكافئ في NERIS (للقسم فقط) + محمول في تقرير NERIS + ليس بعد في تقرير NERIS + ملخص المطابقة + لم يبدأ أي تقرير NERIS لهذه المكالمة. + الحقول المطلوبة المفقودة + الحقول التي لها مكافئ في NERIS + تُعرض بيانات NFIRS للاستمرارية التاريخية فقط. التقارير الجديدة تذهب إلى NERIS. + تقرير الحادث + القيمة + تم الإنشاء + تصدير التقارير + صمّم الملف الذي تستلمه جهة لا تملك واجهة برمجية: اختر الأعمدة والتنسيق والنطاق، ثم أرفق التصدير بخطوة سير عمل أو ضعه في جدول زمني. + تصدير جديد + تعديل التصدير + اسم التصدير + المفتاح + أحرف صغيرة وأرقام وشرطات. تعتمد عليه خطوة سير العمل واستيراد الجهة، لذا لا يتغيّر بعد استخدامه. + الوصف + التنسيق + النطاق + السجل المُطلِق + جميع السجلات المُنهاة ضمن فترة + تعريفات السجلات + اترك جميع التعريفات دون تحديد لتصديرها كلها. + الأعمدة + تُؤخذ الأعمدة من فهرس ثابت ومُراجَع. تحتاج أعمدة السرد والأعمدة المقيّدة إلى مفتاحها أدناه وإقرار بالإخراج. + تضمين السرد والبيانات الشخصية (المستوى 2) + تضمين الأقسام المقيّدة (المستوى 1) + أقرّ بأن هذا التصدير يرسل محتوى سرديًا أو مقيّدًا خارج Resgrid إلى الوجهة التي يحددها سير العمل. + تم الإقرار بالإخراج + اسم الملف + الرموز: {template} و{date} و{record}. يتبع الامتداد التنسيق. + تضمين صف العناوين + الفاصل + الجدول الزمني + بدون جدول زمني (سير العمل فقط) + يوميًا + أسبوعيًا + شهريًا + الساعة (التوقيت المحلي للقسم) + يوم الأسبوع (0 = الأحد) + يوم الشهر (1-28) + الفترة (أيام) + يستخدم 0 فترة الجدول: يوم أو أسبوع أو شهر ينتهي عند التشغيل. + مفعّل + التشغيل التالي + آخر تشغيل + عمليات التشغيل + تشغيل الآن + يُنشئ التصدير لمراجعته قبل أن تراه الجهة. يُسجَّل كل سجل فيه كتصدير في سجل التدقيق. + يغطي هذا التصدير سجلًا مُطلِقًا واحدًا: أدخل معرّف السجل. + تم الإنشاء + السجلات + بايت + حُجبت الحقول المحمية من هذا الملف + تنزيل + المُطلِق + تم حفظ التصدير. + تم حذف التصدير. ستفشل خطوات سير العمل التي كانت تستخدمه حتى يتم تحديثها. + هل تريد حذف هذا التصدير؟ + لم يتم تصميم أي تصدير بعد. + في مصمّم سير العمل، يمكن لخطوة بريد إلكتروني أو رفع ملف على مُطلِق السجلات إرفاق أي تصدير مدرج هنا؛ ويُطلق التصدير المجدول مُطلِقه الخاص عند تشغيله. + الهوية والتواريخ والرموز والأعداد + السرد والبيانات الشخصية (المستوى 2) + الأقسام المقيّدة (المستوى 1) + يُطبَّق نظام حماية البيانات المتقدّمة على هذا القسم. تُحجب الأعمدة المحمية (REDACTED) في كل تشغيل ما لم يُسجَّل إقرار الإخراج للتصدير. + سجل محمي + تقرير حادث محمي + تبقى الحقول المحمية مخفية حتى تتحقق من هويتك. يتطلب حفظ هذا النموذج التحقق أيضًا، لذا تحقق قبل التحرير. + المحتوى المحمي ظاهر لجلسة التحرير هذه. يبقى محميًا عند الحفظ. + السماح بإرسال محتوى الحوادث المحمي إلى NERIS + بموجب حماية البيانات المتقدّمة تكون الحمولة الموضوعة في الطابور مختومة. بهذا الإقرار تفتحها خدمة الإرسال للتسليم فقط عبر قناة الوسيط المخصصة؛ وبدونه يفشل الإرسال المحمي بأمان. + تم الإقرار في + يُطبَّق نظام حماية البيانات المتقدّمة: ستفشل عمليات الإرسال المحمية حتى يُسجَّل هذا الإقرار. + معرّف السجل diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index ea152aab..b137f555 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -964,4 +964,104 @@ Beim Wechsel des NERIS-Grant-Typs sind neue Zugangsdaten erforderlich. + Offenlegung öffentlicher Akten + Vorgaben für den Offenlegungsablauf: die gesetzliche Antwortfrist, das Schwärzungsprofil, mit dem eine neue Anfrage beginnt, und wer ein erstelltes Paket freigibt. + Gesetzliche Antwortfrist (Tage) + Tage, in denen eine neue Anfrage fällig ist, gezählt ab dem Eingangsdatum. 1 bis 365. + Standard-Schwärzungsprofil + Wird auf eine neue Anfrage angewendet, sofern der Sachbearbeiter kein anderes wählt. + Freigabeberechtigte Person + Das Mitglied, das erstellte Pakete freigibt. Leer lassen für jeden Abteilungsadministrator. + Jeder Abteilungsadministrator + Standard - eingeschränkte Inhalte zurückhalten + Keine Personenkennungen - auch Teilnehmeridentitäten zurückhalten + Vollständige Offenlegung - alles Vorhandene freigeben + NFIRS-Ansicht (historisch) + NFIRS-Ansicht + Die Felder des NFIRS-Basismoduls, soweit sie aus den bereits vorhandenen Daten dieser Abteilung gelesen werden können, sowie der NERIS-Fakt oder -Abschnitt, der sie künftig trägt. + Nur lesend. NFIRS wurde am 31.01.2026 eingestellt; nichts auf dieser Seite wird importiert, erfasst oder übermittelt. + NFIRS-Feld + Feldstatus + Befüllt + Fehlt + Nicht erfasst + In NFIRS erforderlich + * Vom NFIRS-Basismodul gefordert. + NERIS-Entsprechung + Keine NERIS-Entsprechung (nur abteilungsintern) + Im NERIS-Bericht enthalten + Noch nicht im NERIS-Bericht + Zuordnungsübersicht + Für diesen Einsatz wurde noch kein NERIS-Bericht begonnen. + Fehlende Pflichtfelder + Felder mit NERIS-Entsprechung + NFIRS-Daten werden nur zur historischen Kontinuität dargestellt. Neue Meldungen gehen an NERIS. + Einsatzbericht + Wert + Erzeugt + Berichtsexporte + Gestalten Sie die Datei, die eine Behörde ohne API erhält: Spalten, Format und Umfang wählen, dann den Export an einen Workflow-Schritt anhängen oder einplanen. + Neuer Export + Export bearbeiten + Exportname + Schlüssel + Kleinbuchstaben, Ziffern und Bindestriche. Workflow-Schritte und Behördenimporte verlassen sich darauf; nach der Nutzung wird er nicht mehr geändert. + Beschreibung + Format + Umfang + Der auslösende Datensatz + Alle in einem Zeitfenster abgeschlossenen Datensätze + Datensatzdefinitionen + Keine Definition auswählen, um alle zu exportieren. + Spalten + Spalten stammen aus einem festen, geprüften Katalog. Freitext- und eingeschränkte Spalten benötigen den Schalter unten und eine Ausgabebestätigung. + Freitext und personenbezogene Angaben einschließen (Stufe 2) + Eingeschränkte Abschnitte einschließen (Stufe 1) + Ich bestätige, dass dieser Export Freitext- oder eingeschränkte Inhalte außerhalb von Resgrid an das im Workflow genannte Ziel sendet. + Ausgabe bestätigt + Dateiname + Platzhalter: {template}, {date}, {record}. Die Endung folgt dem Format. + Kopfzeile einschließen + Trennzeichen + Zeitplan + Kein Zeitplan (nur Workflow) + Täglich + Wöchentlich + Monatlich + Stunde (Ortszeit der Abteilung) + Wochentag (0 = Sonntag) + Tag des Monats (1-28) + Zeitfenster (Tage) + 0 verwendet den Zeitplanzeitraum: ein Tag, eine Woche oder ein Monat bis zum Lauf. + Aktiviert + Nächster Lauf + Letzter Lauf + Läufe + Jetzt ausführen + Erstellt den Export zur Prüfung, bevor eine Behörde ihn sieht. Jeder enthaltene Datensatz wird als Export protokolliert. + Dieser Export umfasst einen auslösenden Datensatz: Geben Sie die Datensatz-ID ein. + Erstellt + Datensätze + Bytes + Geschützte Felder wurden aus dieser Datei ausgelassen + Herunterladen + Auslöser + Der Export wurde gespeichert. + Der Export wurde gelöscht. Workflow-Schritte, die ihn nutzten, schlagen fehl, bis sie aktualisiert werden. + Diesen Export löschen? + Es wurden noch keine Exporte angelegt. + Im Workflow-Designer kann ein E-Mail- oder Datei-Upload-Schritt eines Datensatz-Auslösers jeden hier gelisteten Export anhängen; ein geplanter Export löst beim Lauf seinen eigenen Auslöser aus. + Identität, Daten, Codes und Zähler + Freitext und personenbezogene Angaben (Stufe 2) + Eingeschränkte Abschnitte (Stufe 1) + Erweiterter Datenschutz ist für diese Abteilung erzwungen. Geschützte Spalten werden in jedem Lauf ausgelassen (REDACTED), sofern die Ausgabebestätigung des Exports nicht erfasst ist. + Geschützter Datensatz + Geschützter Einsatzbericht + Geschützte Felder bleiben verborgen, bis Sie sich verifizieren. Auch das Speichern dieses Formulars erfordert eine Verifizierung; verifizieren Sie sich daher vor dem Bearbeiten. + Geschützte Inhalte sind für diese Bearbeitungssitzung sichtbar. Beim Speichern bleiben sie geschützt. + Geschützte Einsatzinhalte an NERIS senden erlauben + Unter dem erweiterten Datenschutz ist die eingereihte Nutzlast versiegelt. Mit dieser Bestätigung öffnet der Übermittlungsdienst sie ausschließlich zur Zustellung über den zweckgebundenen Broker-Kanal; ohne sie schlägt eine geschützte Übermittlung fehl. + Bestätigt am + Erweiterter Datenschutz ist erzwungen: Geschützte Übermittlungen schlagen fehl, bis diese Bestätigung erfasst ist. + Datensatz-ID diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index 413b7c24..5a4ba04c 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -964,4 +964,104 @@ Απαιτούνται νέα διαπιστευτήρια όταν αλλάζει ο τύπος παραχώρησης NERIS. + Γνωστοποίηση δημόσιων αρχείων + Προεπιλογές για τη ροή γνωστοποίησης: η νόμιμη προθεσμία απάντησης, το προφίλ απόκρυψης με το οποίο ξεκινά ένα νέο αίτημα και ποιος εγκρίνει την έκδοση ενός πακέτου. + Νόμιμη προθεσμία απάντησης (ημέρες) + Ημέρες εντός των οποίων λήγει ένα νέο αίτημα, από την ημερομηνία παραλαβής. 1 έως 365. + Προεπιλεγμένο προφίλ απόκρυψης + Εφαρμόζεται σε νέο αίτημα εκτός αν ο υπεύθυνος επιλέξει άλλο. + Εγκρίνων την έκδοση + Το μέλος που εγκρίνει την έκδοση των πακέτων. Αφήστε κενό για οποιονδήποτε διαχειριστή τμήματος. + Οποιοσδήποτε διαχειριστής τμήματος + Τυπικό - απόκρυψη περιορισμένου περιεχομένου + Χωρίς προσωπικά στοιχεία - απόκρυψη και των ταυτοτήτων συμμετεχόντων + Πλήρης γνωστοποίηση - έκδοση όλων των στοιχείων + Προβολή NFIRS (ιστορική) + Προβολή NFIRS + Τα πεδία της βασικής ενότητας NFIRS όπως διαβάζονται από τα δεδομένα που ήδη τηρεί το τμήμα, και το στοιχείο ή η ενότητα NERIS που τα μεταφέρει στο εξής. + Μόνο για ανάγνωση. Το NFIRS καταργήθηκε στις 31/01/2026· τίποτα σε αυτή τη σελίδα δεν εισάγεται, συντάσσεται ή υποβάλλεται. + Πεδίο NFIRS + Κατάσταση πεδίου + Συμπληρωμένο + Λείπει + Δεν καταγράφεται + Απαιτείται στο NFIRS + * Απαιτείται από τη βασική ενότητα NFIRS. + Αντίστοιχο NERIS + Χωρίς αντίστοιχο NERIS (μόνο για το τμήμα) + Περιλαμβάνεται στην αναφορά NERIS + Δεν υπάρχει ακόμη στην αναφορά NERIS + Σύνοψη αντιστοίχισης + Δεν έχει ξεκινήσει αναφορά NERIS για αυτή την κλήση. + Απαιτούμενα πεδία που λείπουν + Πεδία με αντίστοιχο NERIS + Τα δεδομένα NFIRS εμφανίζονται μόνο για ιστορική συνέχεια. Οι νέες αναφορές πηγαίνουν στο NERIS. + Αναφορά συμβάντος + Τιμή + Δημιουργήθηκε + Εξαγωγές αναφορών + Σχεδιάστε το αρχείο που λαμβάνει μια υπηρεσία χωρίς API: επιλέξτε στήλες, μορφή και εύρος και έπειτα επισυνάψτε την εξαγωγή σε βήμα ροής εργασίας ή προγραμματίστε την. + Νέα εξαγωγή + Επεξεργασία εξαγωγής + Όνομα εξαγωγής + Κλειδί + Πεζά γράμματα, ψηφία και παύλες. Ένα βήμα ροής εργασίας και η εισαγωγή της υπηρεσίας βασίζονται σε αυτό, οπότε δεν αλλάζει αφού χρησιμοποιηθεί. + Περιγραφή + Μορφή + Εύρος + Η εγγραφή που ενεργοποιεί + Όλες οι εγγραφές που οριστικοποιήθηκαν σε ένα διάστημα + Ορισμοί εγγραφών + Αφήστε όλους τους ορισμούς ανεπίλεκτους για να εξαχθούν όλοι. + Στήλες + Οι στήλες προέρχονται από σταθερό, ελεγμένο κατάλογο. Οι αφηγηματικές και οι περιορισμένες στήλες απαιτούν τον διακόπτη παρακάτω και επιβεβαίωση εξόδου. + Συμπερίληψη αφήγησης και προσωπικών στοιχείων (Επίπεδο 2) + Συμπερίληψη περιορισμένων ενοτήτων (Επίπεδο 1) + Αναγνωρίζω ότι αυτή η εξαγωγή στέλνει αφηγηματικό ή περιορισμένο περιεχόμενο εκτός Resgrid στον προορισμό που ορίζει η ροή εργασίας. + Η έξοδος επιβεβαιώθηκε + Όνομα αρχείου + Δείκτες: {template}, {date}, {record}. Η επέκταση ακολουθεί τη μορφή. + Συμπερίληψη γραμμής κεφαλίδας + Διαχωριστικό + Πρόγραμμα + Χωρίς πρόγραμμα (μόνο ροή εργασίας) + Καθημερινά + Εβδομαδιαία + Μηνιαία + Ώρα (τοπική ώρα υπηρεσίας) + Ημέρα εβδομάδας (0 = Κυριακή) + Ημέρα μήνα (1-28) + Διάστημα (ημέρες) + Το 0 χρησιμοποιεί την περίοδο του προγράμματος: μία ημέρα, μία εβδομάδα ή έναν μήνα έως την εκτέλεση. + Ενεργό + Επόμενη εκτέλεση + Τελευταία εκτέλεση + Εκτελέσεις + Εκτέλεση τώρα + Δημιουργεί την εξαγωγή για έλεγχο πριν τη δει μια υπηρεσία. Κάθε εγγραφή που περιέχει καταγράφεται ως εξαγωγή. + Αυτή η εξαγωγή καλύπτει μία εγγραφή ενεργοποίησης: εισαγάγετε το αναγνωριστικό της εγγραφής. + Δημιουργήθηκε + Εγγραφές + Byte + Προστατευμένα πεδία αφαιρέθηκαν από αυτό το αρχείο + Λήψη + Έναυσμα + Η εξαγωγή αποθηκεύτηκε. + Η εξαγωγή διαγράφηκε. Τα βήματα ροής εργασίας που τη χρησιμοποιούσαν θα αποτυγχάνουν μέχρι να ενημερωθούν. + Διαγραφή αυτής της εξαγωγής; + Δεν έχει σχεδιαστεί ακόμη καμία εξαγωγή. + Στον σχεδιαστή ροών εργασίας, ένα βήμα e-mail ή μεταφόρτωσης αρχείου σε έναυσμα εγγραφών μπορεί να επισυνάψει οποιαδήποτε εξαγωγή από εδώ· μια προγραμματισμένη εξαγωγή εγείρει δικό της έναυσμα όταν εκτελείται. + Ταυτότητα, ημερομηνίες, κωδικοί και πλήθη + Αφήγηση και προσωπικά στοιχεία (Επίπεδο 2) + Περιορισμένες ενότητες (Επίπεδο 1) + Η Προηγμένη Προστασία Δεδομένων επιβάλλεται για αυτή την υπηρεσία. Οι προστατευμένες στήλες αφαιρούνται (REDACTED) από κάθε εκτέλεση έως ότου καταγραφεί η επιβεβαίωση εξόδου της εξαγωγής. + Προστατευμένη εγγραφή + Προστατευμένη αναφορά συμβάντος + Τα προστατευμένα πεδία παραμένουν κρυφά μέχρι να επαληθευτείτε. Η αποθήκευση αυτής της φόρμας απαιτεί επίσης επαλήθευση, οπότε επαληθευτείτε πριν από την επεξεργασία. + Το προστατευμένο περιεχόμενο εμφανίζεται για αυτή τη συνεδρία επεξεργασίας. Η αποθήκευση το διατηρεί προστατευμένο. + Να επιτρέπεται η αποστολή προστατευμένου περιεχομένου συμβάντων στο NERIS + Με την Προηγμένη Προστασία Δεδομένων το φορτίο στην ουρά είναι σφραγισμένο. Με αυτή την επιβεβαίωση η υπηρεσία υποβολής το ανοίγει μόνο για παράδοση μέσω του ειδικού καναλιού του μεσολαβητή· χωρίς αυτή, μια προστατευμένη υποβολή αποτυγχάνει με ασφάλεια. + Επιβεβαιώθηκε στις + Η Προηγμένη Προστασία Δεδομένων επιβάλλεται: οι προστατευμένες υποβολές θα αποτυγχάνουν μέχρι να καταγραφεί αυτή η επιβεβαίωση. + Αναγνωριστικό εγγραφής diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index e7783e3f..591816a3 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -964,4 +964,104 @@ A new credential is required when the NERIS grant type changes. + Public-records disclosure + Defaults for the disclosure workflow: the statutory response clock, the redaction profile a new request starts with, and who releases a produced packet. + Statutory response clock (days) + Days a new request is due in, counted from the received date. 1 to 365. + Default redaction profile + Applied to a new request unless the officer chooses another. + Release approver + The member who releases produced packets. Leave unset for any department administrator. + Any department administrator + Standard - withhold restricted content + No personal identifiers - also withhold participant identities + Full disclosure - release everything held + NFIRS (historical) view + NFIRS view + The NFIRS Basic Module fields as they can be read from data this department already holds, and the NERIS fact or section that carries each one going forward. + Read-only. NFIRS retired on 2026-01-31; nothing on this page is imported, authored or submitted. + NFIRS field + Field status + Populated + Missing + Not captured + Required in NFIRS + * Required by the NFIRS Basic Module. + NERIS equivalent + No NERIS equivalent (department-only) + Carried in the NERIS report + Not yet in the NERIS report + Crosswalk summary + No NERIS report has been started for this call. + Required fields missing + Fields with a NERIS equivalent + NFIRS data is rendered for historical continuity only. New reporting goes to NERIS. + Incident report + Value + Generated + Report exports + Design the file an agency without an API receives: choose the columns, the format and the scope, then attach the export to a Workflow step or put it on a schedule. + New export + Edit export + Export name + Key + Lower-case letters, digits and hyphens. A Workflow step and an agency import rely on it, so it never changes once in use. + Description + Format + Scope + The triggering record + All records finalized in a window + Record definitions + Leave every definition unchecked to export all of them. + Columns + Columns are taken from a fixed, reviewed catalog. Narrative and restricted columns need their switch below and an egress acknowledgement. + Include narrative and personal detail (Tier 2) + Include restricted sections (Tier 1) + I acknowledge this export sends narrative or restricted record content outside Resgrid to the destination the Workflow names. + Egress acknowledged + File name + Tokens: {template}, {date}, {record}. The extension follows the format. + Include a header row + Delimiter + Schedule + No schedule (Workflow only) + Daily + Weekly + Monthly + Hour (department local time) + Weekday (0 = Sunday) + Day of month (1-28) + Window (days) + 0 uses the schedule period: one day, one week or one month ending at the run. + Enabled + Next run + Last run + Runs + Run now + Renders the export for you to check before an agency sees it. Every record it contains is audited as an export. + This export covers one triggering record: enter the record ID to render. + Generated + Records + Bytes + Protected fields were withheld from this file + Download + Trigger + The export was saved. + The export was deleted. Workflow steps that named it will fail until they are updated. + Delete this export? + No exports have been designed yet. + In the Workflow designer, an email or file-upload step on a Records trigger can attach any export listed here; a scheduled export raises its own trigger when it runs. + Identity, dates, codes and counts + Narrative and personal detail (Tier 2) + Restricted sections (Tier 1) + Advanced Data Protection is enforced for this department. Protected columns are withheld (REDACTED) from every run unless the export's egress acknowledgement is recorded. + Protected record + Protected incident report + Protected fields stay hidden until you verify. Saving this form also needs verification, so verify before you edit. + Protected content is revealed for this editing session. Saving keeps it protected. + Allow protected incident content to be sent to NERIS + Under Advanced Data Protection the queued payload is sealed. With this acknowledgement the submission worker opens it through the broker's purpose-bound lane for delivery only; without it a protected submission fails closed. + Acknowledged on + Advanced Data Protection is enforced: protected submissions will fail until this acknowledgement is recorded. + Record ID diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index bb7f7eb2..49aee750 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -964,4 +964,104 @@ Se necesitan credenciales nuevas al cambiar el tipo de concesión NERIS. + Divulgación de registros públicos + Valores predeterminados del flujo de divulgación: el plazo legal de respuesta, el perfil de redacción con el que empieza una solicitud nueva y quién libera un paquete producido. + Plazo legal de respuesta (días) + Días en los que vence una solicitud nueva, contados desde la fecha de recepción. De 1 a 365. + Perfil de redacción predeterminado + Se aplica a una solicitud nueva salvo que el responsable elija otro. + Aprobador de la liberación + El miembro que libera los paquetes producidos. Déjelo vacío para cualquier administrador del departamento. + Cualquier administrador del departamento + Estándar - retener el contenido restringido + Sin identificadores personales - retener también las identidades de los participantes + Divulgación completa - liberar todo lo que se conserva + Vista NFIRS (histórica) + Vista NFIRS + Los campos del módulo básico NFIRS tal como pueden leerse de los datos que este departamento ya conserva, y el hecho o la sección NERIS que los lleva en adelante. + Solo lectura. NFIRS se retiró el 31/01/2026; nada de esta página se importa, redacta ni envía. + Campo NFIRS + Estado del campo + Rellenado + Falta + No capturado + Obligatorio en NFIRS + * Obligatorio según el módulo básico NFIRS. + Equivalente NERIS + Sin equivalente NERIS (solo del departamento) + Incluido en el informe NERIS + Todavía no está en el informe NERIS + Resumen de correspondencia + No se ha iniciado ningún informe NERIS para esta llamada. + Campos obligatorios que faltan + Campos con equivalente NERIS + Los datos NFIRS se muestran solo por continuidad histórica. Los informes nuevos van a NERIS. + Informe de incidente + Valor + Generado + Exportaciones de informes + Diseñe el archivo que recibe una agencia sin API: elija las columnas, el formato y el alcance, y luego adjunte la exportación a un paso de flujo de trabajo o prográmela. + Nueva exportación + Editar exportación + Nombre de la exportación + Clave + Letras minúsculas, dígitos y guiones. Un paso de flujo de trabajo y una importación de agencia dependen de ella, así que no cambia una vez en uso. + Descripción + Formato + Alcance + El registro desencadenante + Todos los registros finalizados en una ventana + Definiciones de registro + Deje todas las definiciones sin marcar para exportarlas todas. + Columnas + Las columnas proceden de un catálogo fijo y revisado. Las columnas narrativas y restringidas necesitan su interruptor abajo y un reconocimiento de salida. + Incluir narrativa y datos personales (Nivel 2) + Incluir secciones restringidas (Nivel 1) + Reconozco que esta exportación envía contenido narrativo o restringido fuera de Resgrid al destino que indica el flujo de trabajo. + Salida reconocida + Nombre del archivo + Marcadores: {template}, {date}, {record}. La extensión sigue al formato. + Incluir fila de encabezado + Delimitador + Programación + Sin programación (solo flujo de trabajo) + Diario + Semanal + Mensual + Hora (hora local del departamento) + Día de la semana (0 = domingo) + Día del mes (1-28) + Ventana (días) + 0 usa el período de la programación: un día, una semana o un mes que termina en la ejecución. + Habilitado + Próxima ejecución + Última ejecución + Ejecuciones + Ejecutar ahora + Genera la exportación para que la revise antes de que la vea una agencia. Cada registro incluido se audita como exportación. + Esta exportación cubre un registro desencadenante: introduzca el ID del registro. + Generado + Registros + Bytes + Se omitieron campos protegidos de este archivo + Descargar + Desencadenante + La exportación se guardó. + La exportación se eliminó. Los pasos de flujo de trabajo que la usaban fallarán hasta que se actualicen. + ¿Eliminar esta exportación? + Aún no se ha diseñado ninguna exportación. + En el diseñador de flujos de trabajo, un paso de correo o de carga de archivos en un desencadenante de registros puede adjuntar cualquier exportación de esta lista; una exportación programada genera su propio desencadenante al ejecutarse. + Identidad, fechas, códigos y recuentos + Narrativa y datos personales (Nivel 2) + Secciones restringidas (Nivel 1) + La Protección Avanzada de Datos está aplicada en este departamento. Las columnas protegidas se omiten (REDACTED) en cada ejecución salvo que se registre el reconocimiento de salida de la exportación. + Registro protegido + Informe de incidente protegido + Los campos protegidos permanecen ocultos hasta que se verifique. Guardar este formulario también requiere verificación, así que verifíquese antes de editar. + El contenido protegido está visible durante esta sesión de edición. Al guardar seguirá protegido. + Permitir el envío de contenido protegido de incidentes a NERIS + Con la Protección Avanzada de Datos la carga en cola está sellada. Con este reconocimiento el proceso de envío la abre solo para la entrega a través del canal de propósito del intermediario; sin él, un envío protegido falla de forma segura. + Reconocido el + La Protección Avanzada de Datos está aplicada: los envíos protegidos fallarán hasta que se registre este reconocimiento. + ID de registro diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index 75a0da89..9a21b2da 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -964,4 +964,104 @@ De nouvelles informations d'identification sont requises lorsque le type d'octroi NERIS change. + Communication de dossiers publics + Valeurs par défaut du processus de communication : le délai légal de réponse, le profil de caviardage d'une nouvelle demande et la personne qui libère un dossier produit. + Délai légal de réponse (jours) + Nombre de jours pour traiter une nouvelle demande, à compter de la date de réception. De 1 à 365. + Profil de caviardage par défaut + Appliqué à une nouvelle demande sauf si l'agent en choisit un autre. + Approbateur de la diffusion + Le membre qui libère les dossiers produits. Laisser vide pour tout administrateur du service. + Tout administrateur du service + Standard - retenir le contenu restreint + Sans identifiants personnels - retenir aussi l'identité des participants + Communication intégrale - diffuser tout ce qui est détenu + Vue NFIRS (historique) + Vue NFIRS + Les champs du module de base NFIRS tels qu'ils peuvent être lus dans les données que ce service détient déjà, et le fait ou la section NERIS qui les porte désormais. + Lecture seule. NFIRS a été retiré le 31/01/2026 ; rien sur cette page n'est importé, rédigé ni transmis. + Champ NFIRS + État du champ + Renseigné + Manquant + Non saisi + Obligatoire dans NFIRS + * Exigé par le module de base NFIRS. + Équivalent NERIS + Aucun équivalent NERIS (interne au service) + Présent dans le rapport NERIS + Pas encore dans le rapport NERIS + Synthèse de correspondance + Aucun rapport NERIS n'a été commencé pour cet appel. + Champs obligatoires manquants + Champs ayant un équivalent NERIS + Les données NFIRS ne sont affichées que pour la continuité historique. Les nouveaux rapports vont à NERIS. + Rapport d'incident + Valeur + Généré + Exports de rapports + Concevez le fichier reçu par une agence sans API : choisissez les colonnes, le format et la portée, puis rattachez l'export à une étape de workflow ou planifiez-le. + Nouvel export + Modifier l'export + Nom de l'export + Clé + Lettres minuscules, chiffres et tirets. Une étape de workflow et l'import d'une agence en dépendent : elle ne change plus une fois utilisée. + Description + Format + Portée + L'enregistrement déclencheur + Tous les enregistrements finalisés dans une période + Définitions d'enregistrement + Ne cochez aucune définition pour toutes les exporter. + Colonnes + Les colonnes proviennent d'un catalogue fixe et vérifié. Les colonnes narratives et restreintes exigent leur interrupteur ci-dessous et une confirmation de sortie. + Inclure le récit et les données personnelles (niveau 2) + Inclure les sections restreintes (niveau 1) + Je reconnais que cet export envoie du contenu narratif ou restreint hors de Resgrid vers la destination désignée par le workflow. + Sortie confirmée + Nom du fichier + Jetons : {template}, {date}, {record}. L'extension suit le format. + Inclure une ligne d'en-tête + Séparateur + Planification + Aucune planification (workflow seulement) + Quotidien + Hebdomadaire + Mensuel + Heure (heure locale du service) + Jour de la semaine (0 = dimanche) + Jour du mois (1-28) + Période (jours) + 0 utilise la période de la planification : un jour, une semaine ou un mois se terminant à l'exécution. + Activé + Prochaine exécution + Dernière exécution + Exécutions + Exécuter maintenant + Génère l'export pour vérification avant qu'une agence ne le voie. Chaque enregistrement inclus est audité comme export. + Cet export couvre un enregistrement déclencheur : saisissez l'identifiant de l'enregistrement. + Généré + Enregistrements + Octets + Des champs protégés ont été retirés de ce fichier + Télécharger + Déclencheur + L'export a été enregistré. + L'export a été supprimé. Les étapes de workflow qui l'utilisaient échoueront jusqu'à leur mise à jour. + Supprimer cet export ? + Aucun export n'a encore été conçu. + Dans le concepteur de workflow, une étape e-mail ou d'envoi de fichier sur un déclencheur d'enregistrement peut joindre n'importe quel export listé ici ; un export planifié lève son propre déclencheur à l'exécution. + Identité, dates, codes et décomptes + Récit et données personnelles (niveau 2) + Sections restreintes (niveau 1) + La protection avancée des données est appliquée à ce service. Les colonnes protégées sont retirées (REDACTED) de chaque exécution tant que la confirmation de sortie de l'export n'est pas enregistrée. + Enregistrement protégé + Rapport d'intervention protégé + Les champs protégés restent masqués jusqu'à votre vérification. L'enregistrement de ce formulaire nécessite aussi une vérification ; vérifiez-vous avant de modifier. + Le contenu protégé est affiché pour cette session de modification. L'enregistrement le conserve protégé. + Autoriser l'envoi du contenu d'intervention protégé à NERIS + Sous la protection avancée des données, la charge utile en file est scellée. Avec cette confirmation, le service d'envoi l'ouvre uniquement pour la livraison via le canal dédié du courtier ; sans elle, un envoi protégé échoue par sécurité. + Confirmé le + La protection avancée des données est appliquée : les envois protégés échoueront tant que cette confirmation n'est pas enregistrée. + Identifiant d'enregistrement diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index f9ed853f..b91c5517 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -964,4 +964,104 @@ Quando cambia il tipo di concessione NERIS sono necessarie nuove credenziali. + Divulgazione dei registri pubblici + Impostazioni predefinite del flusso di divulgazione: il termine legale di risposta, il profilo di oscuramento con cui inizia una nuova richiesta e chi rilascia un pacchetto prodotto. + Termine legale di risposta (giorni) + Giorni entro cui scade una nuova richiesta, contati dalla data di ricezione. Da 1 a 365. + Profilo di oscuramento predefinito + Applicato a una nuova richiesta a meno che il responsabile ne scelga un altro. + Approvatore del rilascio + Il membro che rilascia i pacchetti prodotti. Lasciare vuoto per qualsiasi amministratore del dipartimento. + Qualsiasi amministratore del dipartimento + Standard - trattenere il contenuto riservato + Senza identificativi personali - trattenere anche le identità dei partecipanti + Divulgazione completa - rilasciare tutto ciò che è conservato + Vista NFIRS (storica) + Vista NFIRS + I campi del modulo base NFIRS così come possono essere letti dai dati che questo dipartimento già conserva, e il fatto o la sezione NERIS che li porta in avanti. + Sola lettura. NFIRS è stato ritirato il 31/01/2026; nulla in questa pagina viene importato, redatto o inviato. + Campo NFIRS + Stato del campo + Compilato + Mancante + Non rilevato + Obbligatorio in NFIRS + * Richiesto dal modulo base NFIRS. + Equivalente NERIS + Nessun equivalente NERIS (solo dipartimento) + Presente nel rapporto NERIS + Non ancora nel rapporto NERIS + Riepilogo della corrispondenza + Nessun rapporto NERIS è stato avviato per questa chiamata. + Campi obbligatori mancanti + Campi con equivalente NERIS + I dati NFIRS sono mostrati solo per continuità storica. Le nuove segnalazioni vanno a NERIS. + Rapporto di incidente + Valore + Generato + Esportazioni di report + Progetta il file che riceve un ente senza API: scegli colonne, formato e ambito, poi allega l'esportazione a un passaggio del flusso di lavoro o pianificala. + Nuova esportazione + Modifica esportazione + Nome esportazione + Chiave + Lettere minuscole, cifre e trattini. Un passaggio del flusso di lavoro e l'importazione dell'ente vi fanno affidamento: non cambia più una volta in uso. + Descrizione + Formato + Ambito + Il record scatenante + Tutti i record finalizzati in una finestra + Definizioni di record + Lascia deselezionate tutte le definizioni per esportarle tutte. + Colonne + Le colonne provengono da un catalogo fisso e verificato. Le colonne narrative e riservate richiedono l'interruttore sottostante e una conferma di uscita. + Includi narrativa e dettagli personali (Livello 2) + Includi sezioni riservate (Livello 1) + Riconosco che questa esportazione invia contenuti narrativi o riservati fuori da Resgrid alla destinazione indicata dal flusso di lavoro. + Uscita confermata + Nome file + Segnaposto: {template}, {date}, {record}. L'estensione segue il formato. + Includi riga di intestazione + Delimitatore + Pianificazione + Nessuna pianificazione (solo flusso di lavoro) + Giornaliero + Settimanale + Mensile + Ora (ora locale del dipartimento) + Giorno della settimana (0 = domenica) + Giorno del mese (1-28) + Finestra (giorni) + 0 usa il periodo della pianificazione: un giorno, una settimana o un mese fino all'esecuzione. + Abilitato + Prossima esecuzione + Ultima esecuzione + Esecuzioni + Esegui ora + Genera l'esportazione per verificarla prima che la veda un ente. Ogni record incluso viene registrato come esportazione. + Questa esportazione copre un record scatenante: inserisci l'ID del record. + Generato + Record + Byte + Campi protetti esclusi da questo file + Scarica + Attivazione + L'esportazione è stata salvata. + L'esportazione è stata eliminata. I passaggi del flusso di lavoro che la usavano falliranno finché non saranno aggiornati. + Eliminare questa esportazione? + Nessuna esportazione è stata ancora progettata. + Nel designer del flusso di lavoro, un passaggio e-mail o di caricamento file su un'attivazione dei record può allegare qualsiasi esportazione qui elencata; un'esportazione pianificata genera la propria attivazione quando viene eseguita. + Identità, date, codici e conteggi + Narrativa e dettagli personali (Livello 2) + Sezioni riservate (Livello 1) + La Protezione avanzata dei dati è applicata a questo dipartimento. Le colonne protette vengono escluse (REDACTED) da ogni esecuzione finché non viene registrata la conferma di uscita dell'esportazione. + Record protetto + Rapporto di intervento protetto + I campi protetti restano nascosti finché non esegui la verifica. Anche il salvataggio di questo modulo richiede la verifica, quindi verifica prima di modificare. + Il contenuto protetto è visibile per questa sessione di modifica. Il salvataggio lo mantiene protetto. + Consenti l'invio di contenuti di intervento protetti a NERIS + Con la Protezione avanzata dei dati il payload in coda è sigillato. Con questa conferma il servizio di invio lo apre solo per la consegna tramite il canale dedicato del broker; senza di essa un invio protetto fallisce in sicurezza. + Confermato il + La Protezione avanzata dei dati è applicata: gli invii protetti falliranno finché non viene registrata questa conferma. + ID record diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index d97eddaf..e67a5aa2 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -964,4 +964,104 @@ Zmiana typu uprawnienia NERIS wymaga podania nowych poświadczeń. + Udostępnianie rejestrów publicznych + Ustawienia domyślne procesu udostępniania: ustawowy termin odpowiedzi, profil anonimizacji, od którego zaczyna nowy wniosek, oraz osoba zwalniająca przygotowany pakiet. + Ustawowy termin odpowiedzi (dni) + Liczba dni na realizację nowego wniosku, liczona od daty otrzymania. Od 1 do 365. + Domyślny profil anonimizacji + Stosowany do nowego wniosku, chyba że funkcjonariusz wybierze inny. + Osoba zatwierdzająca zwolnienie + Członek zwalniający przygotowane pakiety. Pozostaw puste dla dowolnego administratora jednostki. + Dowolny administrator jednostki + Standardowy - zatrzymaj treści zastrzeżone + Bez identyfikatorów osobowych - zatrzymaj także tożsamość uczestników + Pełne udostępnienie - zwolnij wszystko, co jest przechowywane + Widok NFIRS (historyczny) + Widok NFIRS + Pola modułu podstawowego NFIRS odczytane z danych, które jednostka już posiada, oraz fakt lub sekcja NERIS, która je przejmuje. + Tylko do odczytu. NFIRS wycofano 31.01.2026; nic na tej stronie nie jest importowane, redagowane ani wysyłane. + Pole NFIRS + Stan pola + Wypełnione + Brak + Nierejestrowane + Wymagane w NFIRS + * Wymagane przez moduł podstawowy NFIRS. + Odpowiednik NERIS + Brak odpowiednika NERIS (tylko jednostka) + Ujęte w raporcie NERIS + Jeszcze nie w raporcie NERIS + Podsumowanie mapowania + Dla tego zgłoszenia nie rozpoczęto raportu NERIS. + Brakujące pola wymagane + Pola z odpowiednikiem NERIS + Dane NFIRS są wyświetlane wyłącznie dla ciągłości historycznej. Nowe raporty trafiają do NERIS. + Raport ze zdarzenia + Wartość + Wygenerowano + Eksporty raportów + Zaprojektuj plik, który otrzyma instytucja bez API: wybierz kolumny, format i zakres, a następnie dołącz eksport do kroku przepływu pracy lub zaplanuj go. + Nowy eksport + Edytuj eksport + Nazwa eksportu + Klucz + Małe litery, cyfry i myślniki. Krok przepływu pracy i import instytucji polegają na nim, więc nie zmienia się po użyciu. + Opis + Format + Zakres + Rekord wyzwalający + Wszystkie rekordy sfinalizowane w oknie + Definicje rekordów + Pozostaw wszystkie definicje niezaznaczone, aby wyeksportować wszystkie. + Kolumny + Kolumny pochodzą ze stałego, zweryfikowanego katalogu. Kolumny narracyjne i zastrzeżone wymagają przełącznika poniżej oraz potwierdzenia wysyłki. + Uwzględnij narrację i dane osobowe (Poziom 2) + Uwzględnij sekcje zastrzeżone (Poziom 1) + Potwierdzam, że ten eksport wysyła treści narracyjne lub zastrzeżone poza Resgrid do miejsca docelowego wskazanego w przepływie pracy. + Wysyłka potwierdzona + Nazwa pliku + Znaczniki: {template}, {date}, {record}. Rozszerzenie wynika z formatu. + Dołącz wiersz nagłówka + Separator + Harmonogram + Bez harmonogramu (tylko przepływ pracy) + Codziennie + Co tydzień + Co miesiąc + Godzina (czas lokalny jednostki) + Dzień tygodnia (0 = niedziela) + Dzień miesiąca (1-28) + Okno (dni) + 0 używa okresu harmonogramu: dzień, tydzień lub miesiąc kończący się w chwili uruchomienia. + Włączony + Następne uruchomienie + Ostatnie uruchomienie + Uruchomienia + Uruchom teraz + Generuje eksport do sprawdzenia, zanim zobaczy go instytucja. Każdy zawarty rekord jest audytowany jako eksport. + Ten eksport obejmuje jeden rekord wyzwalający: podaj identyfikator rekordu. + Wygenerowano + Rekordy + Bajty + Pola chronione zostały pominięte w tym pliku + Pobierz + Wyzwalacz + Eksport został zapisany. + Eksport został usunięty. Kroki przepływu pracy, które go używały, będą kończyć się błędem do czasu aktualizacji. + Usunąć ten eksport? + Nie zaprojektowano jeszcze żadnego eksportu. + W projektancie przepływów pracy krok e-mail lub przesyłania pliku w wyzwalaczu rekordów może dołączyć dowolny eksport z tej listy; zaplanowany eksport wywołuje własny wyzwalacz podczas uruchomienia. + Tożsamość, daty, kody i liczby + Narracja i dane osobowe (Poziom 2) + Sekcje zastrzeżone (Poziom 1) + W tej jednostce wymuszona jest Zaawansowana ochrona danych. Kolumny chronione są pomijane (REDACTED) w każdym uruchomieniu, dopóki nie zostanie zapisane potwierdzenie wysyłki eksportu. + Rekord chroniony + Chroniony raport ze zdarzenia + Chronione pola pozostają ukryte do czasu weryfikacji. Zapisanie tego formularza również wymaga weryfikacji, dlatego zweryfikuj się przed edycją. + Chroniona treść jest widoczna w tej sesji edycji. Zapis zachowuje jej ochronę. + Zezwól na wysyłanie chronionej treści zdarzeń do NERIS + Przy Zaawansowanej ochronie danych kolejkowany ładunek jest zapieczętowany. Z tym potwierdzeniem proces wysyłki otwiera go wyłącznie do dostarczenia przez dedykowany kanał brokera; bez niego chroniona wysyłka kończy się bezpiecznym błędem. + Potwierdzono dnia + Zaawansowana ochrona danych jest wymuszona: chronione wysyłki będą kończyć się błędem do czasu zapisania tego potwierdzenia. + Identyfikator rekordu diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index c93439bc..c56a12c6 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -964,4 +964,104 @@ Nya inloggningsuppgifter krävs när NERIS-behörighetstypen ändras. + Utlämnande av allmänna handlingar + Standardvärden för utlämnandeflödet: den lagstadgade svarsfristen, den maskeringsprofil en ny begäran börjar med och vem som frisläpper ett framställt paket. + Lagstadgad svarsfrist (dagar) + Antal dagar en ny begäran ska besvaras inom, räknat från mottagningsdatumet. 1 till 365. + Standardmaskeringsprofil + Tillämpas på en ny begäran om inte handläggaren väljer en annan. + Godkännare av frisläppning + Den medlem som frisläpper framställda paket. Lämna tomt för valfri avdelningsadministratör. + Valfri avdelningsadministratör + Standard - undanhåll begränsat innehåll + Inga personuppgifter - undanhåll även deltagarnas identiteter + Fullständigt utlämnande - frisläpp allt som finns + NFIRS-vy (historisk) + NFIRS-vy + Fälten i NFIRS basmodul så som de kan läsas från data avdelningen redan har, och det NERIS-faktum eller den sektion som bär dem framöver. + Skrivskyddad. NFIRS avvecklades 2026-01-31; inget på den här sidan importeras, författas eller skickas in. + NFIRS-fält + Fältstatus + Ifyllt + Saknas + Registreras inte + Krävs i NFIRS + * Krävs av NFIRS basmodul. + NERIS-motsvarighet + Ingen NERIS-motsvarighet (endast avdelningen) + Med i NERIS-rapporten + Ännu inte i NERIS-rapporten + Sammanfattning av mappning + Ingen NERIS-rapport har påbörjats för det här larmet. + Obligatoriska fält som saknas + Fält med NERIS-motsvarighet + NFIRS-data visas endast för historisk kontinuitet. Ny rapportering går till NERIS. + Händelserapport + Värde + Genererad + Rapportexporter + Utforma filen som en myndighet utan API tar emot: välj kolumner, format och omfattning och koppla sedan exporten till ett arbetsflödessteg eller schemalägg den. + Ny export + Redigera export + Exportnamn + Nyckel + Små bokstäver, siffror och bindestreck. Ett arbetsflödessteg och en myndighetsimport förlitar sig på den, så den ändras aldrig när den används. + Beskrivning + Format + Omfattning + Den utlösande posten + Alla poster som slutförts inom ett fönster + Postdefinitioner + Lämna alla definitioner omarkerade för att exportera samtliga. + Kolumner + Kolumnerna hämtas från en fast, granskad katalog. Berättelse- och begränsade kolumner kräver sin brytare nedan och ett utlämningsgodkännande. + Inkludera berättelse och personuppgifter (nivå 2) + Inkludera begränsade avsnitt (nivå 1) + Jag bekräftar att den här exporten skickar berättelse- eller begränsat postinnehåll utanför Resgrid till den destination som arbetsflödet anger. + Utlämning godkänd + Filnamn + Platshållare: {template}, {date}, {record}. Filändelsen följer formatet. + Inkludera rubrikrad + Avgränsare + Schema + Inget schema (endast arbetsflöde) + Dagligen + Veckovis + Månadsvis + Timme (avdelningens lokala tid) + Veckodag (0 = söndag) + Dag i månaden (1-28) + Fönster (dagar) + 0 använder schemaperioden: en dag, en vecka eller en månad som slutar vid körningen. + Aktiverad + Nästa körning + Senaste körning + Körningar + Kör nu + Skapar exporten så att du kan granska den innan en myndighet ser den. Varje ingående post granskas som en export. + Den här exporten omfattar en utlösande post: ange postens ID. + Skapad + Poster + Byte + Skyddade fält undanhölls från den här filen + Ladda ner + Utlösare + Exporten sparades. + Exporten togs bort. Arbetsflödessteg som använde den misslyckas tills de uppdateras. + Ta bort den här exporten? + Inga exporter har utformats ännu. + I arbetsflödesdesignern kan ett e-post- eller filuppladdningssteg på en postutlösare bifoga valfri export här; en schemalagd export utlöser sin egen utlösare när den körs. + Identitet, datum, koder och antal + Berättelse och personuppgifter (nivå 2) + Begränsade avsnitt (nivå 1) + Avancerat dataskydd tillämpas för den här avdelningen. Skyddade kolumner undanhålls (REDACTED) i varje körning tills exportens utlämningsgodkännande registreras. + Skyddad post + Skyddad insatsrapport + Skyddade fält är dolda tills du verifierar dig. Att spara formuläret kräver också verifiering, så verifiera dig innan du redigerar. + Skyddat innehåll visas under denna redigeringssession. Det förblir skyddat när du sparar. + Tillåt att skyddat insatsinnehåll skickas till NERIS + Under avancerat dataskydd är den köade nyttolasten förseglad. Med detta godkännande öppnar sändningstjänsten den enbart för leverans via mäklarens ändamålsbundna kanal; utan det misslyckas en skyddad sändning säkert. + Godkänt den + Avancerat dataskydd tillämpas: skyddade sändningar misslyckas tills detta godkännande registreras. + Post-ID diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index de605e40..86e67e5e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -964,4 +964,104 @@ Після зміни типу надання доступу NERIS потрібні нові облікові дані. + Розкриття публічних записів + Значення за замовчуванням для процесу розкриття: законодавчий строк відповіді, профіль редагування, з якого починається новий запит, і хто випускає підготовлений пакет. + Законодавчий строк відповіді (днів) + Кількість днів на виконання нового запиту, рахуючи від дати отримання. Від 1 до 365. + Профіль редагування за замовчуванням + Застосовується до нового запиту, якщо посадовець не обере інший. + Особа, що затверджує випуск + Учасник, який випускає підготовлені пакети. Залиште порожнім для будь-якого адміністратора підрозділу. + Будь-який адміністратор підрозділу + Стандартний - приховати обмежений вміст + Без персональних ідентифікаторів - також приховати особи учасників + Повне розкриття - випустити все, що зберігається + Перегляд NFIRS (історичний) + Перегляд NFIRS + Поля базового модуля NFIRS, які можна прочитати з даних, що вже є у підрозділу, і факт або розділ NERIS, який їх переносить надалі. + Лише для читання. NFIRS припинено 31.01.2026; ніщо на цій сторінці не імпортується, не створюється й не надсилається. + Поле NFIRS + Стан поля + Заповнено + Відсутнє + Не фіксується + Обов'язкове в NFIRS + * Вимагається базовим модулем NFIRS. + Еквівалент NERIS + Немає еквівалента NERIS (лише для підрозділу) + Внесено до звіту NERIS + Ще не внесено до звіту NERIS + Зведення відповідності + Для цього виклику звіт NERIS ще не розпочато. + Відсутні обов'язкові поля + Поля з еквівалентом NERIS + Дані NFIRS відображаються лише для історичної безперервності. Нова звітність надходить до NERIS. + Звіт про інцидент + Значення + Згенеровано + Експорт звітів + Створіть файл, який отримує установа без API: оберіть стовпці, формат і обсяг, а потім прикріпіть експорт до кроку робочого процесу або заплануйте його. + Новий експорт + Редагувати експорт + Назва експорту + Ключ + Малі літери, цифри та дефіси. На нього покладаються крок робочого процесу та імпорт установи, тому після використання він не змінюється. + Опис + Формат + Обсяг + Запис-ініціатор + Усі записи, завершені за період + Визначення записів + Не позначайте жодного визначення, щоб експортувати всі. + Стовпці + Стовпці беруться з фіксованого, перевіреного каталогу. Стовпці з описом та обмежені стовпці потребують перемикача нижче та підтвердження вивантаження. + Включити опис та персональні дані (рівень 2) + Включити обмежені розділи (рівень 1) + Я підтверджую, що цей експорт надсилає опис або обмежений вміст записів за межі Resgrid до призначення, вказаного в робочому процесі. + Вивантаження підтверджено + Назва файлу + Маркери: {template}, {date}, {record}. Розширення відповідає формату. + Включити рядок заголовка + Роздільник + Розклад + Без розкладу (лише робочий процес) + Щодня + Щотижня + Щомісяця + Година (місцевий час підрозділу) + День тижня (0 = неділя) + День місяця (1-28) + Період (днів) + 0 використовує період розкладу: день, тиждень або місяць, що закінчується на момент запуску. + Увімкнено + Наступний запуск + Останній запуск + Запуски + Запустити зараз + Формує експорт для перевірки, перш ніж його побачить установа. Кожен запис у ньому фіксується в аудиті як експорт. + Цей експорт охоплює один запис-ініціатор: введіть ідентифікатор запису. + Створено + Записи + Байти + Захищені поля вилучено з цього файлу + Завантажити + Тригер + Експорт збережено. + Експорт видалено. Кроки робочого процесу, що його використовували, не працюватимуть до оновлення. + Видалити цей експорт? + Ще не створено жодного експорту. + У конструкторі робочих процесів крок електронної пошти або завантаження файлу на тригері записів може прикріпити будь-який експорт із цього списку; запланований експорт створює власний тригер під час запуску. + Ідентифікатори, дати, коди та лічильники + Опис та персональні дані (рівень 2) + Обмежені розділи (рівень 1) + Для цього підрозділу застосовано Розширений захист даних. Захищені стовпці вилучаються (REDACTED) з кожного запуску, доки не зафіксовано підтвердження вивантаження експорту. + Захищений запис + Захищений звіт про інцидент + Захищені поля приховані, доки ви не пройдете перевірку. Збереження цієї форми також потребує перевірки, тому пройдіть її перед редагуванням. + Захищений вміст відкрито для цього сеансу редагування. Після збереження він залишається захищеним. + Дозволити надсилання захищеного вмісту інцидентів до NERIS + За Розширеного захисту даних вміст у черзі запечатано. З цим підтвердженням служба надсилання відкриває його лише для доставки через цільовий канал брокера; без нього захищене надсилання безпечно завершується помилкою. + Підтверджено + Застосовано Розширений захист даних: захищені надсилання не працюватимуть, доки не зафіксовано це підтвердження. + Ідентифікатор запису diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx index 7b9ec201..4b4e5185 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx @@ -614,4 +614,7 @@ المتخطاة + تصدير تقرير السجلات + لا شيء (إرسال القالب المُعالَج فقط) + تُرفق خطوات البريد الإلكتروني التصدير؛ وترفع خطوات رفع الملفات التصدير باسم ملفه بدلًا من النص المُعالَج. تُصمَّم التصديرات ضمن السجلات > تصدير التقارير. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx index f934cc8d..6fe2ba15 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx @@ -615,4 +615,7 @@ Übersprungen + Berichtsexport (Datensätze) + Keiner (nur die gerenderte Vorlage senden) + E-Mail-Schritte hängen den Export an; Datei-Upload-Schritte laden ihn unter seinem Dateinamen statt des gerenderten Textes hoch. Exporte werden unter Datensätze > Berichtsexporte angelegt. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx index 19dd738c..386bceaf 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx @@ -638,5 +638,8 @@ Παραλειφθείσες + Εξαγωγή αναφοράς εγγραφών + Καμία (αποστολή μόνο του αποδοθέντος προτύπου) + Τα βήματα e-mail επισυνάπτουν την εξαγωγή· τα βήματα μεταφόρτωσης αρχείου τη μεταφορτώνουν με το όνομα αρχείου της αντί για το αποδοθέν κείμενο. Οι εξαγωγές σχεδιάζονται στο Εγγραφές > Εξαγωγές αναφορών. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx index c6bb5fe3..f50edfd4 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx @@ -638,5 +638,8 @@ Skipped + Records report export + None (send the rendered template only) + Email steps attach the export; file-upload steps upload it under its file name instead of the rendered text. Exports are designed under Records > Report exports. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx index ed338081..62cb745d 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx @@ -636,5 +636,8 @@ Omitidos + Exportación de informe de registros + Ninguna (enviar solo la plantilla renderizada) + Los pasos de correo adjuntan la exportación; los pasos de carga de archivos la suben con su nombre de archivo en lugar del texto renderizado. Las exportaciones se diseñan en Registros > Exportaciones de informes. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx index 96e63be1..a3f9def3 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx @@ -615,4 +615,7 @@ Ignorés + Export de rapport (enregistrements) + Aucun (envoyer uniquement le modèle rendu) + Les étapes e-mail joignent l'export ; les étapes d'envoi de fichier le téléversent sous son nom de fichier à la place du texte rendu. Les exports se conçoivent dans Enregistrements > Exports de rapports. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx index 69b6f9f3..03ef777d 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx @@ -615,4 +615,7 @@ Saltati + Esportazione report dei record + Nessuna (invia solo il modello renderizzato) + I passaggi e-mail allegano l'esportazione; i passaggi di caricamento file la caricano con il suo nome file al posto del testo renderizzato. Le esportazioni si progettano in Record > Esportazioni di report. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx index 2a35f811..e8c51378 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx @@ -615,4 +615,7 @@ Pominięte + Eksport raportu rekordów + Brak (wyślij tylko wyrenderowany szablon) + Kroki e-mail dołączają eksport; kroki przesyłania pliku wysyłają go pod jego nazwą pliku zamiast wyrenderowanego tekstu. Eksporty projektuje się w Rekordy > Eksporty raportów. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx index 477972cd..fe348a37 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx @@ -635,5 +635,8 @@ Skipped + Records report export + None (send the rendered template only) + Email steps attach the export; file-upload steps upload it under its file name instead of the rendered text. Exports are designed under Records > Report exports. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx index 3c042681..a1670f93 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx @@ -615,4 +615,7 @@ Överhoppade + Rapportexport (poster) + Ingen (skicka endast den renderade mallen) + E-poststeg bifogar exporten; filuppladdningssteg laddar upp den under sitt filnamn i stället för den renderade texten. Exporter utformas under Poster > Rapportexporter. diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx index df1e019f..60889d78 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx @@ -615,4 +615,7 @@ Пропущені + Експорт звіту записів + Немає (надіслати лише сформований шаблон) + Кроки електронної пошти прикріплюють експорт; кроки завантаження файлу надсилають його під назвою файлу замість сформованого тексту. Експорти створюються в розділі Записи > Експорт звітів. diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index 2017fd8f..54263179 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -283,8 +283,18 @@ فشل الطلب. حاول مرة أخرى. + + تنتهي صلاحية التحقق خلال {0}. أعد التحقق الآن لمواصلة العمل دون فقدان التغييرات. + + + انتهت صلاحية التحقق. أعد التحقق للمتابعة؛ تبقى التغييرات غير المحفوظة في هذه الصفحة حتى ذلك الحين. + + + إعادة التحقق + السجلات ملف القسم + حالات حفظ السجلات diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index 35d8c068..0a837d53 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -716,8 +716,18 @@ Die Anfrage ist fehlgeschlagen. Bitte erneut versuchen. + + Ihre Verifizierung läuft in {0} ab. Verifizieren Sie sich jetzt erneut, um ohne Verlust Ihrer Änderungen weiterzuarbeiten. + + + Ihre Verifizierung ist abgelaufen. Verifizieren Sie sich erneut, um fortzufahren; nicht gespeicherte Änderungen bleiben bis dahin auf dieser Seite. + + + Erneut verifizieren + Akten Abteilungsprofil + Aufbewahrungssperren für Akten diff --git a/Core/Resgrid.Localization/Common.el.resx b/Core/Resgrid.Localization/Common.el.resx index 51bbd41c..10481e48 100644 --- a/Core/Resgrid.Localization/Common.el.resx +++ b/Core/Resgrid.Localization/Common.el.resx @@ -768,8 +768,18 @@ Το αίτημα απέτυχε. Δοκιμάστε ξανά. + + Η επαλήθευσή σας λήγει σε {0}. Επαληθευτείτε ξανά τώρα για να συνεχίσετε χωρίς να χάσετε αλλαγές. + + + Η επαλήθευσή σας έληξε. Επαληθευτείτε ξανά για να συνεχίσετε· οι μη αποθηκευμένες αλλαγές παραμένουν σε αυτή τη σελίδα μέχρι τότε. + + + Επαλήθευση ξανά + Αρχεία Προφίλ υπηρεσίας + Δεσμεύσεις διατήρησης αρχείων diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index 2178cba1..06758055 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -768,8 +768,18 @@ The request failed. Try again. + + Your verification expires in {0}. Re-verify now to keep working without losing changes. + + + Your verification has expired. Re-verify to continue; unsaved changes stay on this page until you do. + + + Re-verify + Records Department Profile + Records preservation holds diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index be581770..3076c44a 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -756,8 +756,18 @@ La solicitud falló. Inténtelo de nuevo. + + Su verificación caduca en {0}. Vuelva a verificarse ahora para seguir trabajando sin perder cambios. + + + Su verificación ha caducado. Vuelva a verificarse para continuar; los cambios sin guardar permanecen en esta página hasta entonces. + + + Volver a verificar + Registros Perfil del departamento + Retenciones de conservación de registros diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index ef665b56..1a533e1f 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -716,8 +716,18 @@ La requête a échoué. Réessayez. + + Votre vérification expire dans {0}. Vérifiez-vous à nouveau maintenant pour continuer sans perdre vos modifications. + + + Votre vérification a expiré. Vérifiez-vous à nouveau pour continuer ; les modifications non enregistrées restent sur cette page jusque-là. + + + Vérifier à nouveau + Dossiers Profil du service + Conservations légales des dossiers diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index 87fe2d16..db82fa81 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -716,8 +716,18 @@ La richiesta non è riuscita. Riprova. + + La verifica scade tra {0}. Esegui di nuovo la verifica ora per continuare senza perdere le modifiche. + + + La verifica è scaduta. Esegui di nuovo la verifica per continuare; le modifiche non salvate restano in questa pagina fino ad allora. + + + Verifica di nuovo + Registri Profilo del dipartimento + Blocchi di conservazione dei registri diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 605bbba7..dfeec09d 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -716,8 +716,18 @@ Żądanie nie powiodło się. Spróbuj ponownie. + + Twoja weryfikacja wygasa za {0}. Zweryfikuj się ponownie teraz, aby kontynuować pracę bez utraty zmian. + + + Twoja weryfikacja wygasła. Zweryfikuj się ponownie, aby kontynuować; niezapisane zmiany pozostają na tej stronie do tego czasu. + + + Zweryfikuj ponownie + Rejestry Profil wydziału + Blokady zachowania rejestrów diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index 4d5b02c0..be5af397 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -716,8 +716,18 @@ Begäran misslyckades. Försök igen. + + Din verifiering går ut om {0}. Verifiera dig igen nu för att fortsätta arbeta utan att förlora ändringar. + + + Din verifiering har gått ut. Verifiera dig igen för att fortsätta; osparade ändringar finns kvar på sidan tills dess. + + + Verifiera igen + Journaler Avdelningsprofil + Bevarandespärrar för journaler diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index c17b5fe1..977e2945 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -716,8 +716,18 @@ Запит не вдався. Спробуйте ще раз. + + Ваша перевірка спливає через {0}. Пройдіть перевірку знову зараз, щоб продовжити роботу без втрати змін. + + + Термін вашої перевірки минув. Пройдіть перевірку знову, щоб продовжити; незбережені зміни залишаються на цій сторінці до того часу. + + + Пройти перевірку знову + Записи Профіль підрозділу + Утримання записів для збереження diff --git a/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs b/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs index 77fbc358..54ea08e5 100644 --- a/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs +++ b/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs @@ -27,6 +27,15 @@ public interface IProtectedDataBrokerClient Task DecryptAsync(int departmentId, string grantToken, string requestId, IReadOnlyList items, CancellationToken cancellationToken = default); + /// + /// Decrypts for a WORKLOAD caller through the broker's purpose-bound lane (no grant): used only where a + /// department has acknowledged a specific egress of protected content, such as a NERIS submission from + /// worker 41 (RMS plan section 5.9.2). The broker records the purpose and refuses purposes it was not + /// configured for; a refusal reads as workload_purpose_denied. + /// + Task DecryptForWorkloadAsync(int departmentId, string purpose, string requestId, + IReadOnlyList items, CancellationToken cancellationToken = default); + /// Encrypts plaintext for an attended, granted caller. Items carry plaintext in Value. Task EncryptAsync(int departmentId, string grantToken, string requestId, IReadOnlyList items, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Providers/WorkflowActionContext.cs b/Core/Resgrid.Model/Providers/WorkflowActionContext.cs index 3919ca38..16d89660 100644 --- a/Core/Resgrid.Model/Providers/WorkflowActionContext.cs +++ b/Core/Resgrid.Model/Providers/WorkflowActionContext.cs @@ -27,5 +27,26 @@ public sealed class WorkflowActionContext /// recipient caps and per-action send limits. /// public bool IsFreePlanDepartment { get; init; } + + /// + /// A file the step carries alongside its rendered content (RMS plan section 5.6: department report + /// exports). Email actions attach it; file-upload actions upload its bytes under its file name instead + /// of the rendered text. Null for every other step. + /// + public WorkflowAttachment Attachment { get; init; } + } + + /// A rendered export handed to an executor: bytes, name and content type; never a path. + public sealed class WorkflowAttachment + { + public string FileName { get; init; } + public string ContentType { get; init; } + public byte[] Data { get; init; } + + /// True when protected fields were withheld from the export (ADP enforcement without an acknowledged egress). + public bool Redacted { get; init; } + + /// The stored RmsExportRun this attachment came from, for the run log. + public string ExportRunId { get; init; } } } diff --git a/Core/Resgrid.Model/Records/IncidentReportContracts.cs b/Core/Resgrid.Model/Records/IncidentReportContracts.cs index e00efd83..e733c9a0 100644 --- a/Core/Resgrid.Model/Records/IncidentReportContracts.cs +++ b/Core/Resgrid.Model/Records/IncidentReportContracts.cs @@ -6,6 +6,10 @@ namespace Resgrid.Model /// The incident report aggregate as the Web and the mapper read it: header plus the working-draft child rows (or a revision's copies). public class IncidentReportAggregate { + /// ADP read outcome for this hydration (RMS plan section 5.9): what was withheld and why. Never part of a snapshot. + [Newtonsoft.Json.JsonIgnore] + public ProtectedReadResult Protection { get; set; } + public RecordUdfSection CustomFields { get; set; } public List Attachments { get; set; } = new List(); public List Evidence { get; set; } = new List(); @@ -219,6 +223,10 @@ public class IncidentExposureInput /// The incident-analysis filing as its authoring surface and the mapper read it (RMS-3). public class IncidentAnalysisAggregate { + /// ADP read outcome for this hydration (RMS plan section 5.9). Never part of a snapshot. + [Newtonsoft.Json.JsonIgnore] + public ProtectedReadResult Protection { get; set; } + public RmsIncidentAnalysis Analysis { get; set; } /// The incident this analysis files against; needed for the destination id and the base block. public RmsIncidentReport Report { get; set; } @@ -299,5 +307,21 @@ public static class NerisFactKeys public const string Point = "base.point"; public const string IncidentType = "incident_types.primary"; public static string UnitTime(int unitId, string field) => $"unit.{unitId}.{field}"; + + // RMS-3 command key-time feed (plan section 4.2): Incident Command facts carry Derived provenance and are + // never a substitute for a dispatcher-entered time; the officer sees where each came from. + public const string CommandPrefix = "command."; + public const string CommandEstablished = "command.established"; + public const string CommandFirstAssignment = "command.first_assignment"; + public const string CommandFirstBenchmark = "command.first_benchmark"; + public const string CommandLastBenchmark = "command.last_benchmark"; + public const string CommandClosed = "command.closed"; + public const string CommandMutualAid = "command.mutual_aid_resources"; + public static string CommandBenchmark(int ordinal) => $"command.benchmark.{ordinal}"; + + // RMS-3 contact/preplan feed (plan section 4.3): identity and place only, never contact detail. + public const string PreplanPrefix = "preplan."; + public const string PreplanPlace = "preplan.place"; + public static string PreplanContact(string contactId) => $"preplan.contact.{contactId}"; } } diff --git a/Core/Resgrid.Model/Records/IncidentSourceFeeds.cs b/Core/Resgrid.Model/Records/IncidentSourceFeeds.cs new file mode 100644 index 00000000..20c80a4a --- /dev/null +++ b/Core/Resgrid.Model/Records/IncidentSourceFeeds.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Command key times for a Call, read from the Incident Command module as a versioned snapshot (RMS plan + /// section 4.2: command-derived times are prefilled with provenance, + /// never treated as a dispatcher's or a device's time). + /// + public class IncidentCommandKeyTimes + { + public int CallId { get; set; } + public string IncidentCommandId { get; set; } + public DateTime? EstablishedOn { get; set; } + public DateTime? FirstResourceAssignedOn { get; set; } + public DateTime? FirstBenchmarkCompletedOn { get; set; } + public DateTime? LastBenchmarkCompletedOn { get; set; } + public DateTime? ClosedOn { get; set; } + public int MutualAidResourceCount { get; set; } + public List Benchmarks { get; set; } = new List(); + public DateTime CapturedOn { get; set; } + } + + public class IncidentCommandBenchmark + { + public string Name { get; set; } + public DateTime? CompletedOn { get; set; } + } + + /// + /// The Contacts/POI facts linked to a Call at the moment a report is started (RMS plan section 4.3: the + /// contacts plan's ContactPreplan is the dispatch-facing view until the RMS occupancy master lands, so + /// this feed is source-neutral and carries identity, role and place — never contact phone, email or + /// other protected-candidate detail). + /// + public class IncidentPreplanSnapshot + { + public int CallId { get; set; } + public List Contacts { get; set; } = new List(); + public IncidentPreplanPlace Place { get; set; } + public DateTime CapturedOn { get; set; } + public bool IsEmpty => Contacts.Count == 0 && Place == null; + } + + public class IncidentPreplanContact + { + public string ContactId { get; set; } + public string DisplayName { get; set; } + /// Person or Company. + public string ContactType { get; set; } + public string CategoryName { get; set; } + /// Primary or Additional, from the Call link. + public string Role { get; set; } + } + + public class IncidentPreplanPlace + { + public int PoiId { get; set; } + public string Name { get; set; } + public string TypeName { get; set; } + public string Address { get; set; } + public double? Latitude { get; set; } + public double? Longitude { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/NfirsLegacyContracts.cs b/Core/Resgrid.Model/Records/NfirsLegacyContracts.cs new file mode 100644 index 00000000..63c1684f --- /dev/null +++ b/Core/Resgrid.Model/Records/NfirsLegacyContracts.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Read-only rendering of the NFIRS Basic Module field set for one Call (RMS plan sections 4.3 and 6, + /// RMS-3: "historical NFIRS read-only rendering and crosswalk reporting ... no import tooling, no NFIRS + /// authoring"). Every value is read from where the department already holds it — the Call, unit state + /// history, the Incident Command timeline and the NERIS profile — and each field names the NERIS fact or + /// section that replaces it, so the page doubles as the per-incident crosswalk report. Nothing here is + /// written back; NFIRS retired on 2026-01-31. + /// + public class NfirsLegacyRendering + { + public const string ProfileName = "NFIRS 5.0 Basic Module (representative field set)"; + + public int DepartmentId { get; set; } + public int CallId { get; set; } + public string CallNumber { get; set; } + public string CallName { get; set; } + public string Profile { get; set; } = ProfileName; + public bool ReadOnly => true; + public DateTime GeneratedOn { get; set; } + + /// The department's NERIS incident report for this Call, when one exists. + public string IncidentReportId { get; set; } + public string IncidentReportNumber { get; set; } + public string IncidentReportState { get; set; } + + public List Fields { get; set; } = new List(); + public NfirsLegacySummary Summary { get; set; } = new NfirsLegacySummary(); + public List Notes { get; set; } = new List(); + } + + public enum NfirsLegacyFieldStatus + { + /// A value was read from an existing source. + Populated = 1, + + /// The source exists but holds no value for this Call. + Missing = 2, + + /// Resgrid never captured this NFIRS field; it is a gap, not an omission. + NotCaptured = 3 + } + + public class NfirsLegacyField + { + /// NFIRS Basic Module section letter (A–M). + public string Section { get; set; } + public string Code { get; set; } + public string Name { get; set; } + public bool Required { get; set; } + public string Value { get; set; } + public NfirsLegacyFieldStatus Status { get; set; } + /// Where the value was read from: Calls, UnitStates, IncidentCommand, NerisProfile, Crosswalk. + public string SourceSystem { get; set; } + /// The NERIS fact key () or section that carries this field going forward; null when NERIS has no equivalent. + public string NerisFactKey { get; set; } + public string NerisSection { get; set; } + /// Whether the department's NERIS report for the Call already carries the crosswalked value; null when no report exists. + public bool? NerisPopulated { get; set; } + } + + public class NfirsLegacySummary + { + public int TotalFields { get; set; } + public int Populated { get; set; } + public int Missing { get; set; } + public int NotCaptured { get; set; } + public int RequiredMissing { get; set; } + /// Fields that have a NERIS equivalent at all. + public int CrosswalkedToNeris { get; set; } + /// Crosswalked fields the department's NERIS report already carries. + public int CrosswalkedAndPopulated { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordOperationalSummaryV1.cs b/Core/Resgrid.Model/Records/RecordOperationalSummaryV1.cs new file mode 100644 index 00000000..aa336c11 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordOperationalSummaryV1.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// RecordOperationalSummaryV1 (RMS plan sections 5.1 and 4.7): the authorized, versioned view of one + /// official revision that Billing, contractor deployment and other downstream consumers read when they + /// need finalized incident facts. Record and revision identity, Call/reporting-entity correlation, safe + /// dates, unit and personnel participation snapshots and the correction/amendment status — and nothing + /// else. Never narrative, never a restricted section, never a protected-candidate value, never a NERIS + /// payload. A consumer pins and ; a later + /// amendment produces a new summary with on the old one set to + /// Superseded, and the RecordAmended trigger tells the consumer to re-read. + /// + public class RecordOperationalSummaryV1 + { + public const int CurrentContractVersion = 1; + + public int ContractVersion { get; set; } = CurrentContractVersion; + + public int DepartmentId { get; set; } + + public string RecordId { get; set; } + + public RmsRecordKind RecordKind { get; set; } + + public string DefinitionKey { get; set; } + + public int DefinitionVersion { get; set; } + + public string RecordNumber { get; set; } + + /// The official revision this summary was built from; the consumer's pin. + public string RevisionId { get; set; } + + public int RevisionNumber { get; set; } + + public string RevisionChecksum { get; set; } + + public DateTime RevisionCreatedOn { get; set; } + + /// Whether the pinned revision is the record's current official revision (see ). + public string CorrectionStatus { get; set; } + + /// The revision that replaced the pinned one, when is Superseded. + public string SupersededByRevisionId { get; set; } + + /// An amendment draft is open against the current revision; a further summary is expected. + public bool AmendmentOpen { get; set; } + + public string State { get; set; } + + public DateTime? VoidedOn { get; set; } + + public int? CallId { get; set; } + + public string CallNumber { get; set; } + + /// Incident reports only: the department incident number sent to the reporting destination. + public string IncidentNumber { get; set; } + + /// Incident reports only: the reporting entity the report was authored under. + public string ReportingEntityId { get; set; } + + /// Incident reports only: the destination-assigned incident id once accepted. + public string ExternalIncidentId { get; set; } + + public int? StationGroupId { get; set; } + + public DateTime? StartedOn { get; set; } + + public DateTime? EndedOn { get; set; } + + public DateTime? FinalizedOn { get; set; } + + public List Units { get; set; } = new List(); + + public List Participants { get; set; } = new List(); + + public DateTime GeneratedOn { get; set; } + } + + public static class RecordOperationalSummaryCorrectionStatus + { + /// The pinned revision is the current official revision. + public const string Current = "Current"; + + /// A later revision (amendment) is now official; re-read. + public const string Superseded = "Superseded"; + + /// The record was voided after the pinned revision; consumers treat the facts as withdrawn. + public const string Voided = "Voided"; + } + + /// Unit response snapshot as it stood in the pinned revision. + public class RecordOperationalSummaryUnit + { + public int? UnitId { get; set; } + public string UnitName { get; set; } + public string UnitType { get; set; } + public int? StationGroupId { get; set; } + public DateTime? Dispatched { get; set; } + public DateTime? Enroute { get; set; } + public DateTime? OnScene { get; set; } + public DateTime? Released { get; set; } + public DateTime? InQuarters { get; set; } + } + + /// Personnel participation snapshot as it stood in the pinned revision. + public class RecordOperationalSummaryParticipant + { + public string UserId { get; set; } + public string DisplayName { get; set; } + public int? UnitId { get; set; } + public string Role { get; set; } + public int? GroupId { get; set; } + public DateTime? ParticipationStart { get; set; } + public DateTime? ParticipationEnd { get; set; } + } + + /// Paged feed of summaries for records changed since a point in time (plan section 4.7, analytics egress). + public class RecordOperationalSummaryPage + { + public List Items { get; set; } = new List(); + public bool HasMore { get; set; } + /// Opaque cursor for the next page; null at the end of the feed. + public string NextCursor { get; set; } + public DateTime GeneratedOn { get; set; } + } + + public class RecordOperationalSummaryQuery + { + public const int MaxTake = 200; + + /// Records whose projection changed after this instant (UTC); null starts from the beginning. + public DateTime? ChangedSince { get; set; } + + /// Cursor from a previous page's . + public string Cursor { get; set; } + + public int Take { get; set; } = 50; + + /// Restrict to one aggregate kind; null returns both operational Records and incident reports. + public RmsRecordKind? RecordKind { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordProtectedContentException.cs b/Core/Resgrid.Model/Records/RecordProtectedContentException.cs new file mode 100644 index 00000000..95505ae8 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordProtectedContentException.cs @@ -0,0 +1,25 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// Thrown when an RMS operation needs protected content it cannot see or write (ADP plan 3.3): the caller + /// presented no grant, the grant expired or its epoch was revoked, the workload purpose is not acknowledged, + /// or the broker is unavailable. is the machine-readable code the clients map onto the + /// step-up flow (step_up_required, grant_expired, grant_revoked, protected_access_denied, + /// workload_purpose_denied, broker_unavailable); it never carries a value. + /// + public class RecordProtectedContentException : InvalidOperationException + { + public RecordProtectedContentException(string reason, string operation) + : base($"Protected record content is unavailable for '{operation}' ({reason}). Verify with a Protected Data grant and try again.") + { + Reason = reason ?? "step_up_required"; + Operation = operation; + } + + public string Reason { get; } + + public string Operation { get; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordsContracts.cs b/Core/Resgrid.Model/Records/RecordsContracts.cs index 9fee8587..8d487330 100644 --- a/Core/Resgrid.Model/Records/RecordsContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsContracts.cs @@ -180,6 +180,10 @@ public class RecordDraftInput /// A hydrated Record: header, working/revision details, participants, units, attachment metadata. public class RecordAggregate { + /// ADP read outcome for this hydration (RMS plan section 5.9): what was withheld and why. Never part of a snapshot. + [Newtonsoft.Json.JsonIgnore] + public ProtectedReadResult Protection { get; set; } + public RecordUdfSection CustomFields { get; set; } public RmsOperationalRecord Record { get; set; } public RmsOperationalRecordDetail Details { get; set; } diff --git a/Core/Resgrid.Model/Records/RecordsExportContracts.cs b/Core/Resgrid.Model/Records/RecordsExportContracts.cs new file mode 100644 index 00000000..34de7987 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordsExportContracts.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + /// How sensitive an export column is; decides which template flags and acknowledgements it needs (RMS plan section 5.9.2). + public enum RmsExportFieldTier + { + /// Identity, dates, codes, counts: safe in any export. + Safe = 0, + + /// Narrative-class free text and personal detail (Tier 2): opt-in, egress acknowledged, withheld under ADP enforcement. + Narrative = 1, + + /// Restricted sections (Tier 1): opt-in, egress acknowledged, RecordRestricted_View to author, withheld under ADP enforcement. + Restricted = 2 + } + + public sealed class RecordsExportField + { + public RecordsExportField(string key, string label, RmsExportFieldTier tier, bool operational = true, bool incident = true) + { + Key = key; + Label = label; + Tier = tier; + Operational = operational; + Incident = incident; + } + + /// Stable column key, e.g. record.number; also the CSV header and JSON property. + public string Key { get; } + public string Label { get; } + public RmsExportFieldTier Tier { get; } + /// Applies to locked operational definitions. + public bool Operational { get; } + /// Applies to NERIS incident reports. + public bool Incident { get; } + } + + /// + /// The finite, reviewed set of columns a department export may carry (RMS plan section 5.6: "the base + /// catalog remains finite"). A template names keys from here; anything else is rejected at save. Keys are + /// stable identifiers, never labels, so a renamed label never breaks an agency's import. + /// + public static class RecordsExportFieldCatalog + { + public static readonly IReadOnlyList Fields = new List + { + new RecordsExportField("record.id", "Record ID", RmsExportFieldTier.Safe), + new RecordsExportField("record.kind", "Record kind", RmsExportFieldTier.Safe), + new RecordsExportField("record.number", "Record number", RmsExportFieldTier.Safe), + new RecordsExportField("record.definition_key", "Definition key", RmsExportFieldTier.Safe), + new RecordsExportField("record.type", "Record type", RmsExportFieldTier.Safe), + new RecordsExportField("record.state", "State", RmsExportFieldTier.Safe), + new RecordsExportField("record.revision_number", "Revision number", RmsExportFieldTier.Safe), + new RecordsExportField("record.revision_checksum", "Revision checksum", RmsExportFieldTier.Safe), + new RecordsExportField("record.station_group_id", "Station/group ID", RmsExportFieldTier.Safe), + new RecordsExportField("record.station_group_name", "Station/group name", RmsExportFieldTier.Safe), + new RecordsExportField("record.author_user_id", "Author user ID", RmsExportFieldTier.Safe), + new RecordsExportField("record.author_name", "Author", RmsExportFieldTier.Safe), + new RecordsExportField("record.started_on", "Start (UTC)", RmsExportFieldTier.Safe), + new RecordsExportField("record.ended_on", "End (UTC)", RmsExportFieldTier.Safe), + new RecordsExportField("record.duration_minutes", "Duration (minutes)", RmsExportFieldTier.Safe), + new RecordsExportField("record.created_on", "Created (UTC)", RmsExportFieldTier.Safe), + new RecordsExportField("record.finalized_on", "Finalized (UTC)", RmsExportFieldTier.Safe), + new RecordsExportField("record.external_id", "External ID", RmsExportFieldTier.Safe), + new RecordsExportField("call.id", "Call ID", RmsExportFieldTier.Safe), + new RecordsExportField("call.number", "Call number", RmsExportFieldTier.Safe), + new RecordsExportField("call.type", "Call type", RmsExportFieldTier.Safe), + new RecordsExportField("call.priority", "Call priority", RmsExportFieldTier.Safe), + new RecordsExportField("call.logged_on", "Call logged (UTC)", RmsExportFieldTier.Safe), + new RecordsExportField("call.name", "Call name", RmsExportFieldTier.Narrative), + new RecordsExportField("call.address", "Call address", RmsExportFieldTier.Narrative), + new RecordsExportField("call.nature", "Nature of call", RmsExportFieldTier.Narrative), + new RecordsExportField("participants.count", "Participant count", RmsExportFieldTier.Safe), + new RecordsExportField("participants.user_ids", "Participant user IDs", RmsExportFieldTier.Safe), + new RecordsExportField("participants.names", "Participants", RmsExportFieldTier.Safe), + new RecordsExportField("units.count", "Unit count", RmsExportFieldTier.Safe), + new RecordsExportField("units.names", "Units", RmsExportFieldTier.Safe), + new RecordsExportField("units.first_dispatched", "First unit dispatched (UTC)", RmsExportFieldTier.Safe), + new RecordsExportField("units.first_on_scene", "First unit on scene (UTC)", RmsExportFieldTier.Safe), + new RecordsExportField("units.last_cleared", "Last unit cleared (UTC)", RmsExportFieldTier.Safe), + new RecordsExportField("attachments.count", "Attachment count", RmsExportFieldTier.Safe), + new RecordsExportField("details.type", "Type", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.course", "Course", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.course_code", "Course code", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.instructors", "Instructors", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.facilitator", "Facilitator", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.other_agencies", "Other agencies", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.other_units", "Other units", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.unit_id", "Activity unit ID", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.activity_on", "Activity time (UTC)", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.narrative", "Narrative", RmsExportFieldTier.Narrative, incident: false), + new RecordsExportField("details.initial_report", "Initial report", RmsExportFieldTier.Narrative, incident: false), + new RecordsExportField("details.cause", "Cause", RmsExportFieldTier.Narrative, incident: false), + new RecordsExportField("details.location", "Location", RmsExportFieldTier.Narrative, incident: false), + new RecordsExportField("details.contact_name", "Contact name", RmsExportFieldTier.Narrative, incident: false), + new RecordsExportField("details.contact_number", "Contact number", RmsExportFieldTier.Narrative, incident: false), + new RecordsExportField("details.investigated_by_user_id", "Investigated by", RmsExportFieldTier.Safe, incident: false), + new RecordsExportField("details.other_personnel", "Other personnel", RmsExportFieldTier.Restricted, incident: false), + new RecordsExportField("details.body_location", "Body location", RmsExportFieldTier.Restricted, incident: false), + new RecordsExportField("details.pronounced_deceased_by", "Pronounced deceased by", RmsExportFieldTier.Restricted, incident: false), + new RecordsExportField("details.case_number", "Case number", RmsExportFieldTier.Restricted, incident: false), + new RecordsExportField("details.destination", "Destination", RmsExportFieldTier.Restricted, incident: false), + new RecordsExportField("incident.number", "Incident number", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.reporting_entity_id", "Reporting entity ID", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.neris_incident_id", "NERIS incident ID", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.dispatch_code", "Dispatch incident code", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.primary_type", "Primary incident type", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.type_codes", "Incident type codes", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.call_created_on", "Call created (UTC)", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.cleared_on", "Incident cleared (UTC)", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.disposition", "Disposition", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.last_submission_state", "Last submission state", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.aid_count", "Aid count", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.tactic_codes", "Action/tactic codes", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.location_use", "Location use", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.address", "Incident address", RmsExportFieldTier.Narrative, operational: false), + new RecordsExportField("incident.narrative", "Narrative", RmsExportFieldTier.Narrative, operational: false), + new RecordsExportField("incident.casualty_count", "Casualty/rescue count", RmsExportFieldTier.Safe, operational: false), + new RecordsExportField("incident.exposure_count", "Exposure count", RmsExportFieldTier.Safe, operational: false) + }; + + private static readonly Dictionary ByKey = Fields.ToDictionary(f => f.Key, StringComparer.Ordinal); + + public static RecordsExportField Get(string key) => key != null && ByKey.TryGetValue(key, out var field) ? field : null; + + public static bool IsKnown(string key) => key != null && ByKey.ContainsKey(key); + + /// Default column set for a new template: the safe identity/time columns any agency report starts from. + public static readonly string[] DefaultColumns = + { + "record.number", "record.kind", "record.type", "record.state", "record.started_on", "record.ended_on", "record.finalized_on", + "record.station_group_name", "record.author_name", "call.number", "call.type", "units.names", "participants.count" + }; + } + + /// Everything a render needs beyond the template; the service fills what the caller leaves null. + public sealed class RecordsExportRequest + { + public RmsExportTrigger Trigger { get; set; } = RmsExportTrigger.Manual; + + /// TriggeringRecord scope: the one record to export. + public string RecordId { get; set; } + + public RmsRecordKind? RecordKind { get; set; } + + /// Window scope: finalized-on window (UTC); defaults to the template's schedule period ending now. + public DateTime? WindowStart { get; set; } + + public DateTime? WindowEnd { get; set; } + + /// The acting member for an attended render; null for the worker. A member sees only records the queue would show them. + public string ActingUserId { get; set; } + + public string WorkflowRunId { get; set; } + + /// Audit purpose text, e.g. "Workflow export {name}". + public string Purpose { get; set; } + } + + /// Template validation outcome; a template with any error is never saved. + public sealed class RecordsExportTemplateValidation + { + public List Errors { get; } = new List(); + public List Warnings { get; } = new List(); + public bool IsValid => Errors.Count == 0; + } + + /// Result of the worker 45 sweep. + public sealed class RecordsExportScheduleSweepResult + { + public int TemplatesEvaluated { get; set; } + public int RunsRendered { get; set; } + public int Errors { get; set; } + public List RunIds { get; } = new List(); + } +} diff --git a/Core/Resgrid.Model/Records/RmsCasualtyExposure.cs b/Core/Resgrid.Model/Records/RmsCasualtyExposure.cs index ff7b5020..0093492a 100644 --- a/Core/Resgrid.Model/Records/RmsCasualtyExposure.cs +++ b/Core/Resgrid.Model/Records/RmsCasualtyExposure.cs @@ -200,6 +200,9 @@ public class RmsExposure : IEntity public decimal? Latitude { get; set; } public decimal? Longitude { get; set; } + /// ADP companion envelopes (catalog v10) for the exposure coordinates. + public string ProtectedLatitudeEnvelope { get; set; } + public string ProtectedLongitudeEnvelope { get; set; } public decimal? EstimatedValue { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsDisclosure.cs b/Core/Resgrid.Model/Records/RmsDisclosure.cs index 5a580f3e..1bd4409b 100644 --- a/Core/Resgrid.Model/Records/RmsDisclosure.cs +++ b/Core/Resgrid.Model/Records/RmsDisclosure.cs @@ -44,6 +44,14 @@ public static class RmsRedactionProfiles /// Releases everything the department holds; used for an internal or litigation production. public const string FullDisclosure = "FullDisclosure"; + + /// Every profile the disclosure workflow accepts, in the order the Records Settings screen offers them. + public static readonly string[] All = { Standard, NoPersonalIdentifiers, FullDisclosure }; + + public static bool IsKnown(string profile) + { + return profile == Standard || profile == NoPersonalIdentifiers || profile == FullDisclosure; + } } /// @@ -106,6 +114,9 @@ public class RmsDisclosureRequest : IEntity /// Why it closed the way it did — the exemption relied on, or the requester's withdrawal. public string DispositionReason { get; set; } + /// ADP row marker (catalog v10): true once the requester identity and scope narrative carry rgdp envelopes. + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } public DateTime CreatedOn { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs b/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs index 5a616fef..d56df2e3 100644 --- a/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs +++ b/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs @@ -130,6 +130,9 @@ public class RmsRecordLegalHold : IEntity public DateTime? ReleasedOn { get; set; } public string ReleaseNotes { get; set; } + /// ADP row marker (catalog v10): true once the reference and notes carry rgdp envelopes. + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } public DateTime CreatedOn { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsExportTemplate.cs b/Core/Resgrid.Model/Records/RmsExportTemplate.cs new file mode 100644 index 00000000..ebca6dc2 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsExportTemplate.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// A department-authored report export (RMS plan sections 4.7 "customer-owned analytics egress", 4.10 and + /// 5.6 "Actions and safety boundary"): which records, which columns, which file format, and optionally a + /// schedule. The template renders through IRecordsExportService into an + /// that a Workflow step attaches to an email or uploads to FTP/SFTP/S3/Blob/Box/Dropbox — the delivery path + /// for the many state, provincial and local agencies that accept files but expose no API. + /// + /// Columns come from RecordsExportFieldCatalog, never from free text. Narrative and restricted + /// sections are opt-in behind an egress acknowledgement, and are still withheld when Advanced Data + /// Protection is enforced, because Workflow egress can never relax ADP (ADP plan, egress policy). + /// + /// + [Table("RmsExportTemplates")] + public class RmsExportTemplate : IEntity + { + public string RmsExportTemplateId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + /// Stable key a workflow condition can test (export.template_key); lower-case slug of the name at creation, never renamed. + public string TemplateKey { get; set; } + + public string Name { get; set; } + + public string Description { get; set; } + + /// . + public int Format { get; set; } + + /// . + public int Scope { get; set; } + + /// Comma-separated definition keys; null = every locked definition plus incident reports. + public string DefinitionKeysCsv { get; set; } + + /// Ordered JSON array of field keys from RecordsExportFieldCatalog. + public string ColumnsJson { get; set; } + + /// Include narrative-class fields (Tier 2 candidates). Requires the egress acknowledgement. + public bool IncludeNarrative { get; set; } + + /// Include restricted-section fields (Tier 1). Requires the egress acknowledgement and RecordRestricted_View at authoring. + public bool IncludeRestricted { get; set; } + + public DateTime? EgressAcknowledgedOn { get; set; } + + public string EgressAcknowledgedByUserId { get; set; } + + /// Scriban file-name template; {{ template.key }}-{{ window.end | date.to_string "%Y%m%d" }} by default. + public string FileNameTemplate { get; set; } + + public bool IncludeHeader { get; set; } + + /// CSV delimiter; "," by default. + public string Delimiter { get; set; } + + /// ; None for record-triggered templates. + public int ScheduleKind { get; set; } + + /// Hour of day, department-local, the scheduled export renders. + public int ScheduleHourLocal { get; set; } + + /// 0 = Sunday; weekly schedules only. + public int ScheduleDayOfWeek { get; set; } + + /// 1-28; monthly schedules only. + public int ScheduleDayOfMonth { get; set; } + + /// Records finalized in the previous N days for a window export (Window scope); the schedule period when 0. + public int WindowDays { get; set; } + + public DateTime? NextRunOn { get; set; } + + public DateTime? LastRunOn { get; set; } + + public bool IsEnabled { get; set; } + + public DateTime CreatedOn { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime ModifiedOn { get; set; } + + public string ModifiedByUserId { get; set; } + + public long RowVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsExportTemplateId; } + set { RmsExportTemplateId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsExportTemplates"; + + [NotMapped] + public string IdName => "RmsExportTemplateId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public enum RmsExportFormat + { + Csv = 1, + Json = 2, + Pdf = 3 + } + + public enum RmsExportScope + { + /// One file per triggering record; rendered when a Records workflow step references the template. + TriggeringRecord = 1, + + /// Every finalized record in a time window; rendered by the schedule (worker 45) or on demand. + Window = 2 + } + + public enum RmsExportScheduleKind + { + None = 0, + Daily = 1, + Weekly = 2, + Monthly = 3 + } + + /// + /// One rendered export (RMS plan section 4.10): the bytes, their checksum and what produced them, retained + /// so the delivery step can fetch it after the render and so an auditor can see exactly what left. The + /// artifact inherits the highest classification of its source (ADP catalog v10 binary field). + /// + [Table("RmsExportRuns")] + public class RmsExportRun : IEntity + { + public string RmsExportRunId { get; set; } + + public int DepartmentId { get; set; } + + public string ProtectionId { get; set; } + + public string TemplateId { get; set; } + + public string TemplateKey { get; set; } + + /// . + public int Trigger { get; set; } + + /// The record that triggered a TriggeringRecord render, when any. + public string RecordId { get; set; } + + public DateTime? WindowStart { get; set; } + + public DateTime? WindowEnd { get; set; } + + public int RecordCount { get; set; } + + public string FileName { get; set; } + + public string ContentType { get; set; } + + public long ByteSize { get; set; } + + public string Checksum { get; set; } + + public byte[] Data { get; set; } + + /// True when protected or restricted fields were withheld from this render. + public bool Redacted { get; set; } + + public string RedactedFieldsJson { get; set; } + + public DateTime GeneratedOn { get; set; } + + /// Null for a worker render. + public string GeneratedByUserId { get; set; } + + public string WorkflowRunId { get; set; } + + public DateTime ExpiresOn { get; set; } + + public bool IsProtected { get; set; } + + public int ProtectedCatalogVersion { get; set; } + + public DateTime? DeletedOn { get; set; } + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return RmsExportRunId; } + set { RmsExportRunId = value?.ToString(); } + } + + [NotMapped] + public string TableName => "RmsExportRuns"; + + [NotMapped] + public string IdName => "RmsExportRunId"; + + [NotMapped] + public int IdType => 1; + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } + + public enum RmsExportTrigger + { + Scheduled = 1, + Record = 2, + Manual = 3 + } +} diff --git a/Core/Resgrid.Model/Records/RmsIncidentModules.cs b/Core/Resgrid.Model/Records/RmsIncidentModules.cs index 3bbfa603..ebb19af5 100644 --- a/Core/Resgrid.Model/Records/RmsIncidentModules.cs +++ b/Core/Resgrid.Model/Records/RmsIncidentModules.cs @@ -264,6 +264,9 @@ public class RmsIncidentResource : IEntity public string ResourceCode { get; set; } public int? Quantity { get; set; } public string Detail { get; set; } + /// ADP row marker (catalog v10): true once Detail carry rgdp envelopes. + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } public int Ordinal { get; set; } public DateTime CreatedOn { get; set; } public DateTime ModifiedOn { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsIncidentReport.cs b/Core/Resgrid.Model/Records/RmsIncidentReport.cs index 537ad91b..172aed8c 100644 --- a/Core/Resgrid.Model/Records/RmsIncidentReport.cs +++ b/Core/Resgrid.Model/Records/RmsIncidentReport.cs @@ -271,6 +271,9 @@ public class RmsSourceFact : IEntity public DateTime ImportedOn { get; set; } public DateTime? CorrectedOn { get; set; } public string CorrectedByUserId { get; set; } + /// ADP row marker (catalog v10): true once SourceValue/CurrentValue carry rgdp envelopes. + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } public DateTime CreatedOn { get; set; } public DateTime ModifiedOn { get; set; } public long RowVersion { get; set; } @@ -424,6 +427,9 @@ public class RmsLocation : IEntity public string CrossStreet2 { get; set; } public decimal? Latitude { get; set; } public decimal? Longitude { get; set; } + /// ADP companion envelope (catalog v10): carries the encrypted coordinate while Latitude is nulled. + public string ProtectedLatitudeEnvelope { get; set; } + public string ProtectedLongitudeEnvelope { get; set; } public string Jurisdiction { get; set; } public int SourceKind { get; set; } public DateTime CreatedOn { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsNerisProfile.cs b/Core/Resgrid.Model/Records/RmsNerisProfile.cs index bfe20117..89e07fbc 100644 --- a/Core/Resgrid.Model/Records/RmsNerisProfile.cs +++ b/Core/Resgrid.Model/Records/RmsNerisProfile.cs @@ -40,6 +40,15 @@ public class RmsNerisProfile : IEntity public string ContractVersion { get; set; } public bool AutoSubmitOnFinalize { get; set; } public bool IsEnabled { get; set; } + + /// + /// ADP egress acknowledgement (RMS plan section 5.9.2, ADP Appendix B item 8): a protected department's + /// narrative, location and casualty facts leave to the reporting destination only after an administrator + /// has acknowledged that egress. Off by default; without it worker 41 fails a protected submission closed. + /// + public bool AllowProtectedContentEgress { get; set; } + public DateTime? ProtectedEgressAcknowledgedOn { get; set; } + public string ProtectedEgressAcknowledgedByUserId { get; set; } public DateTime? LastTokenIssuedOn { get; set; } public DateTime? LastSuccessfulCallOn { get; set; } public string LastError { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsProtectedFields.cs b/Core/Resgrid.Model/Records/RmsProtectedFields.cs new file mode 100644 index 00000000..6f52a701 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsProtectedFields.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// The RMS protected-field candidate registry made executable (RMS plan section 5.9.2, ADP catalog v10): + /// for every RMS entity that holds a cataloged column, the stable catalog field id mapped to the property + /// that carries it. The catalog (Resgrid.Services.ProtectedFieldCatalog) and the migration bindings + /// (AdpTableBindings) name the same ids; a test pins the three in step so a column cannot be cataloged + /// without a write seam, or seamed without a migration binding. + /// + /// Field ids are AAD components and stable forever: {table}.{column} in lower case, the same + /// convention every other family uses. + /// + /// + public static class RmsProtectedFields + { + public const string Family = "Records"; + + private static IReadOnlyDictionary Get, Action Set)> Map(params (string Column, Func Get, Action Set)[] entries) where T : IEntity + { + var result = new Dictionary, Action)>(StringComparer.Ordinal); + var table = TableOf(); + foreach (var entry in entries) + result[FieldId(table, entry.Column)] = (entry.Get, entry.Set); + return result; + } + + public static string FieldId(string table, string column) => $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}"; + + private static string TableOf() where T : IEntity + { + // Every RMS entity exposes TableName as an instance expression; a throwaway instance reads it. + var instance = (T)Activator.CreateInstance(typeof(T)); + return instance.TableName; + } + + /// Locked Logs-parity typed detail (RmsOperationalRecordDetails): the Log catalog v3 set carried forward, plus the Call snapshot columns that mirror the Calls catalog. + public static readonly IReadOnlyDictionary Get, Action Set)> Details = Map( + ("Narrative", d => d.Narrative, (d, v) => d.Narrative = v), + ("InitialReport", d => d.InitialReport, (d, v) => d.InitialReport = v), + ("Cause", d => d.Cause, (d, v) => d.Cause = v), + ("ContactName", d => d.ContactName, (d, v) => d.ContactName = v), + ("ContactNumber", d => d.ContactNumber, (d, v) => d.ContactNumber = v), + ("OtherPersonnel", d => d.OtherPersonnel, (d, v) => d.OtherPersonnel = v), + ("Location", d => d.Location, (d, v) => d.Location = v), + ("BodyLocation", d => d.BodyLocation, (d, v) => d.BodyLocation = v), + ("PronouncedDeceasedBy", d => d.PronouncedDeceasedBy, (d, v) => d.PronouncedDeceasedBy = v), + ("CaseNumber", d => d.CaseNumber, (d, v) => d.CaseNumber = v), + ("Destination", d => d.Destination, (d, v) => d.Destination = v), + ("CallName", d => d.CallName, (d, v) => d.CallName = v), + ("CallAddress", d => d.CallAddress, (d, v) => d.CallAddress = v), + ("CallNature", d => d.CallNature, (d, v) => d.CallNature = v)); + + /// NERIS narrative sections. + public static readonly IReadOnlyDictionary Get, Action Set)> Narratives = Map( + ("Narrative", n => n.Narrative, (n, v) => n.Narrative = v), + ("ImpedimentNarrative", n => n.ImpedimentNarrative, (n, v) => n.ImpedimentNarrative = v), + ("OutcomeNarrative", n => n.OutcomeNarrative, (n, v) => n.OutcomeNarrative = v), + ("SupplementalJson", n => n.SupplementalJson, (n, v) => n.SupplementalJson = v)); + + /// NERIS location text columns; coordinates ride the companion envelopes (see ). + public static readonly IReadOnlyDictionary Get, Action Set)> Locations = Map( + ("AddressText", l => l.AddressText, (l, v) => l.AddressText = v), + ("Number", l => l.Number, (l, v) => l.Number = v), + ("NumberPrefix", l => l.NumberPrefix, (l, v) => l.NumberPrefix = v), + ("NumberSuffix", l => l.NumberSuffix, (l, v) => l.NumberSuffix = v), + ("Street", l => l.Street, (l, v) => l.Street = v), + ("UnitValue", l => l.UnitValue, (l, v) => l.UnitValue = v), + ("CrossStreet1", l => l.CrossStreet1, (l, v) => l.CrossStreet1 = v), + ("CrossStreet2", l => l.CrossStreet2, (l, v) => l.CrossStreet2 = v)); + + public static readonly IReadOnlyDictionary Get, Action Set, Func GetEnvelope, Action SetEnvelope)> LocationCompanions = + new Dictionary, Action, Func, Action)>(StringComparer.Ordinal) + { + [FieldId("RmsLocations", "Latitude")] = (l => l.Latitude, (l, v) => l.Latitude = v, l => l.ProtectedLatitudeEnvelope, (l, v) => l.ProtectedLatitudeEnvelope = v), + [FieldId("RmsLocations", "Longitude")] = (l => l.Longitude, (l, v) => l.Longitude = v, l => l.ProtectedLongitudeEnvelope, (l, v) => l.ProtectedLongitudeEnvelope = v) + }; + + /// Source facts: dispatch comments and every prefilled value are user/dispatcher-authored text. + public static readonly IReadOnlyDictionary Get, Action Set)> SourceFacts = Map( + ("SourceValue", f => f.SourceValue, (f, v) => f.SourceValue = v), + ("CurrentValue", f => f.CurrentValue, (f, v) => f.CurrentValue = v)); + + /// Casualties and rescues: restricted class, every value column (Tier 1). + public static readonly IReadOnlyDictionary Get, Action Set)> Casualties = Map( + ("PersonnelUserId", c => c.PersonnelUserId, (c, v) => c.PersonnelUserId = v), + ("Rank", c => c.Rank, (c, v) => c.Rank = v), + ("JobClassification", c => c.JobClassification, (c, v) => c.JobClassification = v), + ("BirthMonthYear", c => c.BirthMonthYear, (c, v) => c.BirthMonthYear = v), + ("Gender", c => c.Gender, (c, v) => c.Gender = v), + ("Race", c => c.Race, (c, v) => c.Race = v), + ("CasualtyCause", c => c.CasualtyCause, (c, v) => c.CasualtyCause = v), + ("CasualtyAction", c => c.CasualtyAction, (c, v) => c.CasualtyAction = v), + ("CasualtyTimeline", c => c.CasualtyTimeline, (c, v) => c.CasualtyTimeline = v), + ("InjuryDetailJson", c => c.InjuryDetailJson, (c, v) => c.InjuryDetailJson = v), + ("DetailJson", c => c.DetailJson, (c, v) => c.DetailJson = v)); + + /// Exposures: address and free detail; coordinates ride companions. + public static readonly IReadOnlyDictionary Get, Action Set)> Exposures = Map( + ("AddressText", e => e.AddressText, (e, v) => e.AddressText = v), + ("Street", e => e.Street, (e, v) => e.Street = v), + ("DetailJson", e => e.DetailJson, (e, v) => e.DetailJson = v)); + + public static readonly IReadOnlyDictionary Get, Action Set, Func GetEnvelope, Action SetEnvelope)> ExposureCompanions = + new Dictionary, Action, Func, Action)>(StringComparer.Ordinal) + { + [FieldId("RmsExposures", "Latitude")] = (e => e.Latitude, (e, v) => e.Latitude = v, e => e.ProtectedLatitudeEnvelope, (e, v) => e.ProtectedLatitudeEnvelope = v), + [FieldId("RmsExposures", "Longitude")] = (e => e.Longitude, (e, v) => e.Longitude = v, e => e.ProtectedLongitudeEnvelope, (e, v) => e.ProtectedLongitudeEnvelope = v) + }; + + /// Conditional incident sections carry contract-shaped free detail. + public static readonly IReadOnlyDictionary Get, Action Set)> Modules = Map( + ("DetailJson", m => m.DetailJson, (m, v) => m.DetailJson = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Properties = Map( + ("DetailJson", p => p.DetailJson, (p, v) => p.DetailJson = v)); + + /// Vehicle identity (VIN/plate) is personal data; make/model are not. + public static readonly IReadOnlyDictionary Get, Action Set)> Vehicles = Map( + ("Vin", x => x.Vin, (x, v) => x.Vin = v), + ("LicensePlate", x => x.LicensePlate, (x, v) => x.LicensePlate = v), + ("DetailJson", x => x.DetailJson, (x, v) => x.DetailJson = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Resources = Map( + ("Detail", r => r.Detail, (r, v) => r.Detail = v)); + + /// The immutable revision snapshot is the record's whole content; it inherits the highest source classification. + public static readonly IReadOnlyDictionary Get, Action Set)> Revisions = Map( + ("SnapshotJson", r => r.SnapshotJson, (r, v) => r.SnapshotJson = v)); + + /// Destination payload and response artifacts are generated copies of protected content. + public static readonly IReadOnlyDictionary Get, Action Set)> Submissions = Map( + ("PayloadJson", s => s.PayloadJson, (s, v) => s.PayloadJson = v), + ("ResponseJson", s => s.ResponseJson, (s, v) => s.ResponseJson = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> Signatures = Map( + ("StatementText", s => s.StatementText, (s, v) => s.StatementText = v)); + + /// Evidence: the manifest is the captured content; title/reason are author-typed free text. + public static readonly IReadOnlyDictionary Get, Action Set)> Evidence = Map( + ("Title", e => e.Title, (e, v) => e.Title = v), + ("CaptureReason", e => e.CaptureReason, (e, v) => e.CaptureReason = v), + ("ManifestJson", e => e.ManifestJson, (e, v) => e.ManifestJson = v)); + + /// Disclosure requester identity is restricted; the scope narrative is free text. + public static readonly IReadOnlyDictionary Get, Action Set)> DisclosureRequests = Map( + ("RequesterName", r => r.RequesterName, (r, v) => r.RequesterName = v), + ("RequesterOrganization", r => r.RequesterOrganization, (r, v) => r.RequesterOrganization = v), + ("RequesterContact", r => r.RequesterContact, (r, v) => r.RequesterContact = v), + ("ScopeNarrative", r => r.ScopeNarrative, (r, v) => r.ScopeNarrative = v), + ("DispositionReason", r => r.DispositionReason, (r, v) => r.DispositionReason = v)); + + /// The produced packet is a generated copy of released content. + public static readonly IReadOnlyDictionary Get, Action Set)> DisclosureProductions = Map( + ("ArtifactJson", p => p.ArtifactJson, (p, v) => p.ArtifactJson = v)); + + public static readonly IReadOnlyDictionary Get, Action Set)> LegalHolds = Map( + ("ReferenceNumber", h => h.ReferenceNumber, (h, v) => h.ReferenceNumber = v), + ("Notes", h => h.Notes, (h, v) => h.Notes = v), + ("ReleaseNotes", h => h.ReleaseNotes, (h, v) => h.ReleaseNotes = v)); + + /// Attachment text metadata; the payload itself is the binary field . + public static readonly IReadOnlyDictionary Get, Action Set)> Attachments = Map( + ("FileName", a => a.FileName, (a, v) => a.FileName = v), + ("Description", a => a.Description, (a, v) => a.Description = v)); + + public static readonly string AttachmentDataFieldId = FieldId("RmsRecordAttachments", "Data"); + + /// The rendered export artifact (RmsExportRuns.Data) is a generated copy of record content. + public static readonly string ExportRunDataFieldId = FieldId("RmsExportRuns", "Data"); + + /// Every RMS field id the catalog must carry, for the pin test. + public static IEnumerable AllFieldIds() + { + foreach (var k in Details.Keys) yield return k; + foreach (var k in Narratives.Keys) yield return k; + foreach (var k in Locations.Keys) yield return k; + foreach (var k in LocationCompanions.Keys) yield return k; + foreach (var k in SourceFacts.Keys) yield return k; + foreach (var k in Casualties.Keys) yield return k; + foreach (var k in Exposures.Keys) yield return k; + foreach (var k in ExposureCompanions.Keys) yield return k; + foreach (var k in Modules.Keys) yield return k; + foreach (var k in Properties.Keys) yield return k; + foreach (var k in Vehicles.Keys) yield return k; + foreach (var k in Resources.Keys) yield return k; + foreach (var k in Revisions.Keys) yield return k; + foreach (var k in Submissions.Keys) yield return k; + foreach (var k in Signatures.Keys) yield return k; + foreach (var k in Evidence.Keys) yield return k; + foreach (var k in DisclosureRequests.Keys) yield return k; + foreach (var k in DisclosureProductions.Keys) yield return k; + foreach (var k in LegalHolds.Keys) yield return k; + foreach (var k in Attachments.Keys) yield return k; + yield return AttachmentDataFieldId; + yield return ExportRunDataFieldId; + } + } +} diff --git a/Core/Resgrid.Model/Records/RmsSubmission.cs b/Core/Resgrid.Model/Records/RmsSubmission.cs index c4a4f3b6..60032a33 100644 --- a/Core/Resgrid.Model/Records/RmsSubmission.cs +++ b/Core/Resgrid.Model/Records/RmsSubmission.cs @@ -80,6 +80,9 @@ public class RmsSubmission : IEntity public string ExternalStatus { get; set; } /// Normalized, non-sensitive error codes and field paths; what workflows and notifications may see. public string ErrorSummary { get; set; } + /// ADP row marker (catalog v10): true once PayloadJson/ResponseJson carry rgdp envelopes. + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } public DateTime QueuedOn { get; set; } public DateTime? SentOn { get; set; } public DateTime? CompletedOn { get; set; } @@ -129,6 +132,9 @@ public class RmsSignature : IEntity public string IpAddress { get; set; } /// SHA-256 of the revision snapshot the signature covers. public string ArtifactChecksum { get; set; } + /// ADP row marker (catalog v10): true once StatementText carry rgdp envelopes. + public bool IsProtected { get; set; } + public int ProtectedCatalogVersion { get; set; } public DateTime CreatedOn { get; set; } public long RowVersion { get; set; } diff --git a/Core/Resgrid.Model/Repositories/IRmsExportRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsExportRepositories.cs new file mode 100644 index 00000000..23aae0f0 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsExportRepositories.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IRmsExportTemplatesRepository : IRepository + { + Task GetByIdForDepartmentAsync(int departmentId, string templateId); + + Task GetByKeyAsync(int departmentId, string templateKey); + + Task> GetForDepartmentAsync(int departmentId); + + /// Enabled, scheduled templates whose NextRunOn is at or before , across departments, oldest first. + Task> GetDueAsync(DateTime utcNow, int take); + + Task TryBumpRowVersionAsync(int departmentId, string templateId, long expectedVersion, CancellationToken cancellationToken = default); + } + + public interface IRmsExportRunsRepository : IRepository + { + /// Metadata only (no Data column). + Task GetByIdForDepartmentAsync(int departmentId, string runId); + + /// The run with its bytes. + Task GetWithDataAsync(int departmentId, string runId); + + Task> GetForTemplateAsync(int departmentId, string templateId, int take); + + Task DeleteExpiredAsync(int departmentId, DateTime utcNow, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IIncidentSourceFeedService.cs b/Core/Resgrid.Model/Services/IIncidentSourceFeedService.cs new file mode 100644 index 00000000..2c885942 --- /dev/null +++ b/Core/Resgrid.Model/Services/IIncidentSourceFeedService.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// The RMS-3 source feeds that prefill an incident report beside the Call itself: command key times and + /// the contact/preplan snapshot (RMS plan section 6, RMS-3). Each returns a snapshot with provenance and + /// never a live reference; a feed that cannot be read returns null rather than failing the report. + /// + public interface IIncidentSourceFeedService + { + /// Null when no Incident Command was established for the Call. + Task GetCommandKeyTimesAsync(int departmentId, int callId); + + /// Contacts linked to the Call and its destination place; empty (never null) when nothing is linked. + Task GetPreplanSnapshotAsync(int departmentId, Call call); + } +} diff --git a/Core/Resgrid.Model/Services/IProtectedGrantContext.cs b/Core/Resgrid.Model/Services/IProtectedGrantContext.cs new file mode 100644 index 00000000..5f8e3d73 --- /dev/null +++ b/Core/Resgrid.Model/Services/IProtectedGrantContext.cs @@ -0,0 +1,21 @@ +namespace Resgrid.Model.Services +{ + /// + /// The Protected Data Grant the current unit of work is acting under (ADP plan 3.3 / 7.2). Web and API + /// hosts populate it from the X-Resgrid-Protected-Grant header per request; workers and tests carry + /// no grant and identify themselves as workload callers, which the write seam maps onto the broker's + /// encrypt-only lane. RMS services read it instead of threading a token through every command, because + /// the grant is a property of the caller, not of the record being changed. + /// + public interface IProtectedGrantContext + { + /// The caller's grant token, or null when none was presented. + string GrantToken { get; } + + /// The authenticated user behind the call, or null for a workload. + string UserId { get; } + + /// True when no attended user is behind the call (worker, system principal, relay). + bool IsWorkloadCaller { get; } + } +} diff --git a/Core/Resgrid.Model/Services/IProtectedReadService.cs b/Core/Resgrid.Model/Services/IProtectedReadService.cs index d78560d0..19eceefd 100644 --- a/Core/Resgrid.Model/Services/IProtectedReadService.cs +++ b/Core/Resgrid.Model/Services/IProtectedReadService.cs @@ -196,5 +196,33 @@ Task ResolveUnitStatesForReadAsync(int departmentId, /// Task ResolveUdfFieldValuesForReadAsync(int departmentId, IReadOnlyList values, string grantToken, string userId, CancellationToken cancellationToken = default); + + // ---- Records (RMS), catalog v10 ------------------------------------------------------------------ + + /// Resolves cataloged text columns on a batch of RMS rows for a grant-holding caller; REDACTED otherwise. + Task ResolveRecordsEntitiesForReadAsync(int departmentId, IReadOnlyList<(T Entity, string RowKey)> rows, + IReadOnlyDictionary Get, System.Action Set)> accessors, + string grantToken, string userId, CancellationToken cancellationToken = default) where T : class; + + /// Resolves companion-enveloped coordinates back onto their typed columns; null otherwise. + Task ResolveRecordsCompanionsForReadAsync(int departmentId, IReadOnlyList<(T Entity, string RowKey)> rows, + IReadOnlyDictionary Get, System.Action Set, System.Func GetEnvelope, System.Action SetEnvelope)> companions, + string grantToken, string userId, CancellationToken cancellationToken = default) where T : class; + + /// Resolves attachment metadata and, when asked, the bytes; enveloped bytes are stripped rather than served when not resolvable. + /// Resolves one cataloged binary column (RmsExportRuns.Data) for a grant-holding caller; the blob is nulled otherwise. + Task ResolveRecordsBinaryForReadAsync(int departmentId, string fieldId, string rowKey, byte[] data, System.Action apply, + string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveRecordsAttachmentsForReadAsync(int departmentId, IReadOnlyList attachments, + string grantToken, string userId, bool includeData, CancellationToken cancellationToken = default); + + /// + /// Workload decrypt through the broker's purpose-bound lane (no grant). Returns true when every slot + /// resolved; false leaves every slot enveloped and names the reason on the result. + /// + Task ResolveRecordsEntitiesForWorkloadAsync(int departmentId, string purpose, IReadOnlyList<(T Entity, string RowKey)> rows, + IReadOnlyDictionary Get, System.Action Set)> accessors, + CancellationToken cancellationToken = default) where T : class; } } diff --git a/Core/Resgrid.Model/Services/IProtectedWriteService.cs b/Core/Resgrid.Model/Services/IProtectedWriteService.cs index 69747303..c80fa256 100644 --- a/Core/Resgrid.Model/Services/IProtectedWriteService.cs +++ b/Core/Resgrid.Model/Services/IProtectedWriteService.cs @@ -175,5 +175,28 @@ Task PrepareLogWriteAsync(int departmentId, Log log, /// Prepares a user-defined field value (catalog v2). Task PrepareUdfFieldValueWriteAsync(int departmentId, UdfFieldValue value, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + // ---- Records (RMS), catalog v10 ------------------------------------------------------------------ + // RMS entities are numerous and uniform (string PK, DepartmentId, text columns), so they share one + // generic seam driven by RmsProtectedFields accessor maps rather than a method per entity. Coordinates + // and attachment bytes have their own shapes. + + /// Encrypts every cataloged text column named by in place, marking the row protected on success. + Task PrepareRecordsEntityWriteAsync(int departmentId, T entity, T existing, string rowKey, + System.Collections.Generic.IReadOnlyDictionary Get, System.Action Set)> accessors, + System.Action markProtected, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) where T : class; + + /// Encrypts typed coordinate columns into their companion envelopes (RmsLocations, RmsExposures). + Task PrepareRecordsCompanionWriteAsync(int departmentId, T entity, string rowKey, + System.Collections.Generic.IReadOnlyDictionary Get, System.Action Set, System.Func GetEnvelope, System.Action SetEnvelope)> companions, + System.Action markProtected, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) where T : class; + + /// Encrypts an attachment's file name/description and its bytes (rgdpb) in place. + /// One cataloged binary column (RmsExportRuns.Data) for a Records row; the text columns ride . + Task PrepareRecordsBinaryWriteAsync(int departmentId, string fieldId, string rowKey, byte[] data, System.Action apply, System.Action markProtected, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + Task PrepareRecordsAttachmentWriteAsync(int departmentId, RmsRecordAttachment attachment, RmsRecordAttachment existing, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Services/IRecordOperationalSummaryService.cs b/Core/Resgrid.Model/Services/IRecordOperationalSummaryService.cs new file mode 100644 index 00000000..b5008299 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordOperationalSummaryService.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Builds from official revisions (RMS plan sections 5.1 and 4.7). + /// Department-scoped; a draft, a purged record and a deleted record never produce a summary. The feed is + /// unauthorized by design so the API boundary can apply the member rule or a system-principal grant per + /// row, exactly as the delta cursor does. + /// + public interface IRecordOperationalSummaryService + { + /// Member path: null when the record has no official revision, is gone, or the viewer cannot see it. + Task GetAsync(int departmentId, string viewerUserId, string recordId, RmsRecordKind kind, string revisionId = null); + + /// Unauthorized build for a caller that has already applied its own visibility rule (system principals). + Task BuildAsync(int departmentId, string recordId, RmsRecordKind kind, string revisionId = null); + + /// Records with an official revision whose projection changed after the query point, oldest first; the caller filters visibility. + Task QueryAsync(int departmentId, RecordOperationalSummaryQuery query); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsExportService.cs b/Core/Resgrid.Model/Services/IRecordsExportService.cs new file mode 100644 index 00000000..74923f9f --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsExportService.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Department-authored report exports (RMS plan sections 4.7, 4.10 and 5.6): template management, the + /// render itself and the schedule sweep behind worker 45. Renders never mutate a record; every rendered + /// record is audited as an Export against the record, and a render for a Workflow carries the run id. + /// + public interface IRecordsExportService + { + Task> GetTemplatesAsync(int departmentId); + + Task GetTemplateAsync(int departmentId, string templateId); + + Task GetTemplateByKeyAsync(int departmentId, string templateKey); + + /// Validates columns, flags, schedule and acknowledgements without saving. + Task ValidateAsync(int departmentId, string userId, RmsExportTemplate template); + + /// Creates or updates; requires ManageRecordReports and, for narrative/restricted columns, the egress acknowledgement (and RecordRestricted_View for restricted). + Task SaveAsync(int departmentId, string userId, RmsExportTemplate template, bool acknowledgeEgress, CancellationToken cancellationToken = default); + + Task DeleteAsync(int departmentId, string userId, string templateId, CancellationToken cancellationToken = default); + + /// Renders and stores a run. Throws UnauthorizedAccessException for an attended caller who may not export. + Task RenderAsync(int departmentId, RmsExportTemplate template, RecordsExportRequest request, CancellationToken cancellationToken = default); + + /// Convenience for a Workflow step: renders the template for the triggering record (TriggeringRecord scope) or returns the scheduled run named by the event (Window scope). + Task ResolveForWorkflowAsync(int departmentId, string templateId, string recordId, RmsRecordKind? recordKind, string scheduledRunId, string workflowRunId, CancellationToken cancellationToken = default); + + /// The run with its bytes, department-scoped; null when expired or purged. + Task GetRunAsync(int departmentId, string runId, bool includeData); + + Task> GetRunsAsync(int departmentId, string templateId, int take); + + /// Worker 45: renders every enabled template whose schedule is due, emits RecordExportScheduled per run, and advances NextRunOn. + Task RunDueSchedulesAsync(CancellationToken cancellationToken = default); + + /// Removes expired run artifacts (bytes and rows) for the department. + Task PurgeExpiredRunsAsync(int departmentId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsNfirsLegacyService.cs b/Core/Resgrid.Model/Services/IRecordsNfirsLegacyService.cs new file mode 100644 index 00000000..7b964f72 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsNfirsLegacyService.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Read-only NFIRS rendering and crosswalk for a Call (RMS-3). Reads only; there is no import, no + /// authoring and no NFIRS submission path anywhere in Records. + /// + public interface IRecordsNfirsLegacyService + { + /// Null when the Call does not exist in the department; throws UnauthorizedAccessException when the viewer cannot read the source Call. + Task RenderAsync(int departmentId, string viewerUserId, int callId); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsProtectedReadService.cs b/Core/Resgrid.Model/Services/IRecordsProtectedReadService.cs new file mode 100644 index 00000000..f44c0c36 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsProtectedReadService.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Attended read resolution for RMS aggregates under Advanced Data Protection (RMS plan section 5.9; ADP + /// Appendix B). Every method resolves cataloged rgdp envelopes in place for a grant-holding caller and + /// leaves the exact REDACTED sentinel (or null, for binaries and coordinates) otherwise; the result says + /// whether anything was withheld and why, so a page can show the step-up banner. Nothing is written. + /// + public interface IRecordsProtectedReadService + { + Task ResolveAggregateAsync(int departmentId, RecordAggregate aggregate, string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveIncidentAsync(int departmentId, IncidentReportAggregate aggregate, string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveAnalysisAsync(int departmentId, IncidentAnalysisAggregate aggregate, string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveRevisionsAsync(int departmentId, IReadOnlyList revisions, string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveAttachmentsAsync(int departmentId, IReadOnlyList attachments, string grantToken, string userId, bool includeData, CancellationToken cancellationToken = default); + + Task ResolveDisclosureRequestsAsync(int departmentId, IReadOnlyList requests, string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveDisclosureProductionsAsync(int departmentId, IReadOnlyList productions, string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveEvidenceAsync(int departmentId, IReadOnlyList artifacts, string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveLegalHoldsAsync(int departmentId, IReadOnlyList holds, string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveSubmissionsAsync(int departmentId, IReadOnlyList submissions, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// + /// Workload decrypt for a reporting-destination submission (worker 41): allowed only when the department's + /// NERIS profile carries the protected-egress acknowledgement, and only through the broker's purpose-bound + /// workload lane. Returns false, leaving the payload enveloped, when either is missing. + /// + Task ResolveSubmissionForWorkloadAsync(int departmentId, RmsSubmission submission, string purpose, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsProtectionService.cs b/Core/Resgrid.Model/Services/IRecordsProtectionService.cs new file mode 100644 index 00000000..79fcdd42 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsProtectionService.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// The RMS side of Advanced Data Protection (RMS plan section 5.9; ADP catalog v10). One seam for every + /// Records write and read of a cataloged column: + /// + /// Protect* encrypts the cataloged columns of a row in place when the department is in an + /// encrypt-new-writes state, marks the row protected, and throws + /// when the broker refuses (missing grant, epoch revoked, broker down). Unprotected departments pass through + /// untouched. The existing row is the stored copy an update replaces, so a REDACTED placeholder the + /// editor never had revealed restores the stored envelope instead of overwriting it. + /// Reveal* resolves envelopes in place for the ambient caller (): + /// plaintext for a grant-holding member, the exact REDACTED sentinel (or null for binaries and coordinates) + /// otherwise. The returned says what was withheld and why, so a page can show + /// the step-up banner and an operation that needs the content can fail closed + /// (). + /// *ForWorkload is the purpose-bound workload lane (ADP plan 3.4): no grant, no user, decrypt only + /// for a purpose the department has acknowledged — NERIS delivery and agency exports. + /// + /// + public interface IRecordsProtectionService + { + /// The department's pinned catalog version (0 when protection is off). + Task GetCatalogVersionAsync(int departmentId); + + /// True when reads for this department are enforced (envelopes stay concealed without a grant). + Task IsEnforcedAsync(int departmentId); + + Task ProtectDetailsAsync(int departmentId, RmsOperationalRecordDetail row, RmsOperationalRecordDetail existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectAttachmentAsync(int departmentId, RmsRecordAttachment row, RmsRecordAttachment existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectRevisionAsync(int departmentId, RmsRevision row, string userId = null, CancellationToken cancellationToken = default); + Task ProtectLocationAsync(int departmentId, RmsLocation row, RmsLocation existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectNarrativeAsync(int departmentId, RmsNarrative row, RmsNarrative existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectSourceFactAsync(int departmentId, RmsSourceFact row, RmsSourceFact existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectCasualtyAsync(int departmentId, RmsCasualtyRescue row, RmsCasualtyRescue existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectExposureAsync(int departmentId, RmsExposure row, RmsExposure existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectModuleAsync(int departmentId, RmsIncidentModule row, RmsIncidentModule existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectPropertyAsync(int departmentId, RmsIncidentProperty row, RmsIncidentProperty existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectVehicleAsync(int departmentId, RmsIncidentVehicle row, RmsIncidentVehicle existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectResourceAsync(int departmentId, RmsIncidentResource row, RmsIncidentResource existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectSubmissionAsync(int departmentId, RmsSubmission row, string userId = null, CancellationToken cancellationToken = default); + Task ProtectSignatureAsync(int departmentId, RmsSignature row, string userId = null, CancellationToken cancellationToken = default); + Task ProtectEvidenceAsync(int departmentId, RmsEvidenceArtifact row, string userId = null, CancellationToken cancellationToken = default); + Task ProtectDisclosureRequestAsync(int departmentId, RmsDisclosureRequest row, RmsDisclosureRequest existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectDisclosureProductionAsync(int departmentId, RmsDisclosureProduction row, string userId = null, CancellationToken cancellationToken = default); + Task ProtectLegalHoldAsync(int departmentId, RmsRecordLegalHold row, RmsRecordLegalHold existing, string userId = null, CancellationToken cancellationToken = default); + Task ProtectExportRunAsync(int departmentId, RmsExportRun row, string userId = null, CancellationToken cancellationToken = default); + + Task RevealAsync(int departmentId, RecordAggregate aggregate, CancellationToken cancellationToken = default); + Task RevealAsync(int departmentId, IncidentReportAggregate aggregate, CancellationToken cancellationToken = default); + Task RevealAsync(int departmentId, IncidentAnalysisAggregate aggregate, CancellationToken cancellationToken = default); + Task RevealDetailsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task RevealRevisionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task RevealAttachmentsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default); + Task RevealEvidenceAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task RevealDisclosureRequestsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task RevealDisclosureProductionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task RevealLegalHoldsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task RevealSubmissionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default); + Task RevealExportRunsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default); + + /// + /// Workload decrypt of a queued submission's payload for a reporting destination (worker 41): allowed only + /// when the department's NERIS profile carries the protected-egress acknowledgement and only through the + /// broker's purpose-bound lane. Returns false, leaving the payload enveloped, when either is missing. + /// + Task ResolveSubmissionForWorkloadAsync(int departmentId, RmsSubmission submission, string purpose, CancellationToken cancellationToken = default); + + /// Workload reveal of a record aggregate for an acknowledged export purpose (worker 45 / Workflow exports). + Task RevealForWorkloadAsync(int departmentId, RecordAggregate aggregate, string purpose, CancellationToken cancellationToken = default); + + Task RevealForWorkloadAsync(int departmentId, IncidentReportAggregate aggregate, string purpose, CancellationToken cancellationToken = default); + } + + public static class ProtectedReadResultExtensions + { + /// + /// Fails closed: an operation that must see the content (finalize, queue a submission, restore a draft, + /// diff, produce a packet) cannot run on placeholders. Throws with the machine-readable reason the + /// clients map onto the step-up flow. + /// + public static ProtectedReadResult RequireRevealed(this ProtectedReadResult result, string operation) + { + if (result != null && result.RedactedFields != null && result.RedactedFields.Count > 0) + throw new RecordProtectedContentException(result.ProtectedReason ?? "step_up_required", operation); + return result; + } + + /// Folds one result into another (aggregate reveals run several batches). + public static ProtectedReadResult Merge(this ProtectedReadResult target, ProtectedReadResult other) + { + if (target == null) + return other; + if (other == null) + return target; + + target.IsProtected |= other.IsProtected; + if (other.RedactedFields != null) + { + foreach (var field in other.RedactedFields) + { + if (!target.RedactedFields.Contains(field)) + target.RedactedFields.Add(field); + } + } + target.ProtectedReason ??= other.ProtectedReason; + return target; + } + } +} diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index ffc14a3a..889398e1 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -170,6 +170,118 @@ private static List GetCommon() => new TemplateVariableDescriptor("obligation.overdue_count", "How many times this obligation has gone overdue", "int", false), }; + // protection.* (RMS plan section 5.9.3) rides every Records trigger: the department's ADP posture, never a value. + private static readonly List ProtectionVariables = new List + { + new TemplateVariableDescriptor("protection.is_protected", "Whether the department protects record content (Advanced Data Protection)", "bool", false), + new TemplateVariableDescriptor("protection.is_redacted", "Whether any value in this payload was withheld (always false: payloads carry header facts only)", "bool", false), + new TemplateVariableDescriptor("protection.protected_catalog_version", "The department's pinned protection catalog version (0 when unprotected)", "int", false), + }; + + // attachment.* (trigger 115): identity, type, size and scan state of the file just added; never its name, description or bytes. + private static readonly List AttachmentVariables = new List + { + new TemplateVariableDescriptor("attachment.id", "Attachment ID", "string", false), + new TemplateVariableDescriptor("attachment.content_type", "MIME type", "string", false), + new TemplateVariableDescriptor("attachment.byte_size", "Size in bytes", "int", false), + new TemplateVariableDescriptor("attachment.checksum", "SHA-256 of the stored bytes", "string", false), + new TemplateVariableDescriptor("attachment.classification", "Unrestricted or Restricted", "string", false), + new TemplateVariableDescriptor("attachment.scan_state", "Malware scan state (Pending, Clean, Rejected)", "string", false), + new TemplateVariableDescriptor("attachment.uploaded_by_user_id", "Uploader user ID", "string", false), + new TemplateVariableDescriptor("attachment.uploaded_on", "When it was uploaded (UTC)", "datetime", false), + new TemplateVariableDescriptor("attachment.count", "Attachments now on the record", "int", false), + }; + + // disclosure.* (triggers 152-155): the request's clock and profiles plus the production's identity; never the requester or the packet. + private static readonly List DisclosureVariables = new List + { + new TemplateVariableDescriptor("disclosure.request_id", "Disclosure request ID", "string", false), + new TemplateVariableDescriptor("disclosure.request_number", "Department request number", "string", false), + new TemplateVariableDescriptor("disclosure.state", "Request state (Received, Scoping, InReview, Produced, Released, Denied, Withdrawn, Closed)", "string", false), + new TemplateVariableDescriptor("disclosure.received_on", "When the request was received (UTC)", "datetime", false), + new TemplateVariableDescriptor("disclosure.statutory_due_on", "Statutory deadline (UTC)", "datetime", false), + new TemplateVariableDescriptor("disclosure.jurisdiction_profile", "Jurisdiction profile", "string", false), + new TemplateVariableDescriptor("disclosure.redaction_profile", "Redaction profile (Standard, NoPersonalIdentifiers, FullDisclosure)", "string", false), + new TemplateVariableDescriptor("disclosure.assigned_to_user_id", "Assigned custodian user ID", "string", false), + new TemplateVariableDescriptor("disclosure.closed_on", "When the request closed (UTC)", "datetime", false), + new TemplateVariableDescriptor("disclosure.disposition", "Closing disposition (Released, Denied, Withdrawn, Closed)", "string", false), + new TemplateVariableDescriptor("disclosure.production_id", "Production ID (produce/release only)", "string", false), + new TemplateVariableDescriptor("disclosure.production_number", "Production number within the request", "int", false), + new TemplateVariableDescriptor("disclosure.record_count", "Records in the packet", "int", false), + new TemplateVariableDescriptor("disclosure.withheld_field_count", "Fields withheld in the packet", "int", false), + new TemplateVariableDescriptor("disclosure.checksum", "SHA-256 of the packet artifact", "string", false), + new TemplateVariableDescriptor("disclosure.byte_size", "Packet size in bytes", "int", false), + new TemplateVariableDescriptor("disclosure.released_on", "When the packet was released (UTC)", "datetime", false), + new TemplateVariableDescriptor("disclosure.released_by_user_id", "Releasing user ID", "string", false), + new TemplateVariableDescriptor("disclosure.delivery_method", "How the packet was delivered", "string", false), + }; + + // legal_hold.* (triggers 156/157): scope, period, reason and actors; never the reference number or the notes. + private static readonly List LegalHoldVariables = new List + { + new TemplateVariableDescriptor("legal_hold.id", "Hold ID", "string", false), + new TemplateVariableDescriptor("legal_hold.record_id", "Held record ID (empty for a definition/date scope)", "string", false), + new TemplateVariableDescriptor("legal_hold.definition_key", "Held definition (empty for all)", "string", false), + new TemplateVariableDescriptor("legal_hold.period_start", "Scope period start (UTC)", "datetime", false), + new TemplateVariableDescriptor("legal_hold.period_end", "Scope period end (UTC)", "datetime", false), + new TemplateVariableDescriptor("legal_hold.reason", "Hold reason (Litigation, Investigation, Public records request, Other)", "string", false), + new TemplateVariableDescriptor("legal_hold.placed_by_user_id", "Who placed the hold", "string", false), + new TemplateVariableDescriptor("legal_hold.placed_on", "When it was placed (UTC)", "datetime", false), + new TemplateVariableDescriptor("legal_hold.released_by_user_id", "Who released it", "string", false), + new TemplateVariableDescriptor("legal_hold.released_on", "When it was released (UTC)", "datetime", false), + new TemplateVariableDescriptor("legal_hold.is_released", "Whether the hold is released", "bool", false), + }; + + // evidence.* (trigger 158): the captured artifact's identity, source and checksum; never its manifest, title or reason. + private static readonly List EvidenceVariables = new List + { + new TemplateVariableDescriptor("evidence.id", "Evidence artifact ID", "string", false), + new TemplateVariableDescriptor("evidence.record_id", "Record the evidence supports", "string", false), + new TemplateVariableDescriptor("evidence.record_kind", "Operational or IncidentReport", "string", false), + new TemplateVariableDescriptor("evidence.kind", "Evidence source (ReadinessPacket, RunCardActivation, TrackingFix, ChatPromotion, InventoryUsage, CertificationSnapshot)", "string", false), + new TemplateVariableDescriptor("evidence.source_subsystem", "Source subsystem", "string", false), + new TemplateVariableDescriptor("evidence.source_entity_type", "Source entity type", "string", false), + new TemplateVariableDescriptor("evidence.source_entity_id", "Source entity ID", "string", false), + new TemplateVariableDescriptor("evidence.classification", "Unrestricted or Restricted", "string", false), + new TemplateVariableDescriptor("evidence.checksum", "SHA-256 of the manifest", "string", false), + new TemplateVariableDescriptor("evidence.byte_size", "Manifest size in bytes", "int", false), + new TemplateVariableDescriptor("evidence.source_item_count", "Items the manifest covers", "int", false), + new TemplateVariableDescriptor("evidence.coverage_start", "Coverage window start (UTC)", "datetime", false), + new TemplateVariableDescriptor("evidence.coverage_end", "Coverage window end (UTC)", "datetime", false), + new TemplateVariableDescriptor("evidence.captured_by_user_id", "Capturing user ID", "string", false), + new TemplateVariableDescriptor("evidence.captured_on", "When it was captured (UTC)", "datetime", false), + }; + + // purge.* (trigger 159): what retention removed. The content is gone, so nothing else can be carried. + private static readonly List PurgeVariables = new List + { + new TemplateVariableDescriptor("purge.purged_on", "When the content was purged (UTC)", "datetime", false), + new TemplateVariableDescriptor("purge.attachments_purged", "Attachments removed with the record", "int", false), + new TemplateVariableDescriptor("purge.search_erasure_pending", "Whether the search index erasure is still pending", "bool", false), + new TemplateVariableDescriptor("purge.reason", "Retention reason recorded by the sweep", "string", false), + }; + + // export.* (trigger 160): the scheduled export run a Workflow step can carry; never the rendered content. + private static readonly List ExportVariables = new List + { + new TemplateVariableDescriptor("export.run_id", "Export run ID (a step attaches this run)", "string", false), + new TemplateVariableDescriptor("export.template_id", "Export template ID", "string", false), + new TemplateVariableDescriptor("export.template_key", "Export template key", "string", false), + new TemplateVariableDescriptor("export.template_name", "Export template name", "string", false), + new TemplateVariableDescriptor("export.format", "Csv, Json or Pdf", "string", false), + new TemplateVariableDescriptor("export.scope", "TriggeringRecord or Window", "string", false), + new TemplateVariableDescriptor("export.window_start", "Finalized-on window start (UTC)", "datetime", false), + new TemplateVariableDescriptor("export.window_end", "Finalized-on window end (UTC)", "datetime", false), + new TemplateVariableDescriptor("export.record_count", "Records in the export", "int", false), + new TemplateVariableDescriptor("export.file_name", "File name", "string", false), + new TemplateVariableDescriptor("export.content_type", "MIME type", "string", false), + new TemplateVariableDescriptor("export.byte_size", "Size in bytes", "int", false), + new TemplateVariableDescriptor("export.checksum", "SHA-256 of the file", "string", false), + new TemplateVariableDescriptor("export.redacted", "Whether protected fields were withheld from the file", "bool", false), + new TemplateVariableDescriptor("export.generated_on", "When it was rendered (UTC)", "datetime", false), + new TemplateVariableDescriptor("export.expires_on", "When the stored copy expires (UTC)", "datetime", false), + }; + // review.* rides only on the two review-path triggers: review bookkeeping, never record content. private static readonly List ReviewVariables = new List { @@ -655,17 +767,84 @@ public static IReadOnlyList GetVariableCatalog(Workf case WorkflowTriggerEventType.RecordCreated: case WorkflowTriggerEventType.RecordSubmittedForReview: case WorkflowTriggerEventType.RecordReturnedForCorrection: + case WorkflowTriggerEventType.RecordApproved: case WorkflowTriggerEventType.RecordFinalized: case WorkflowTriggerEventType.RecordAmended: case WorkflowTriggerEventType.RecordVoided: case WorkflowTriggerEventType.RecordCancelled: list.AddRange(RecordEventVariables); list.AddRange(RecordVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Record kind (Operational, IncidentReport or IncidentAnalysis)", "string", false)); list.AddRange(RecordChangeVariables); if (eventType == WorkflowTriggerEventType.RecordCancelled) list.Add(new TemplateVariableDescriptor("record_change.number_disposition", "What happened to a reserved record number (none or voided)", "string", false)); if (eventType == WorkflowTriggerEventType.RecordSubmittedForReview || eventType == WorkflowTriggerEventType.RecordReturnedForCorrection) list.AddRange(ReviewVariables); + if (eventType == WorkflowTriggerEventType.RecordApproved) + { + list.Add(new TemplateVariableDescriptor("review.reviewer_user_id", "Reviewer user ID", "string", false)); + list.Add(new TemplateVariableDescriptor("review.approver_user_id", "Approving user ID", "string", false)); + list.Add(new TemplateVariableDescriptor("review.approved_on", "When the record was approved (UTC)", "datetime", false)); + list.Add(new TemplateVariableDescriptor("review.submitted_for_review_on", "When it was submitted for review (UTC)", "datetime", false)); + list.Add(new TemplateVariableDescriptor("review.review_due_on", "When the review was due (UTC)", "datetime", false)); + list.Add(new TemplateVariableDescriptor("review.return_count", "How many times it was returned", "int", false)); + } + list.AddRange(ProtectionVariables); + break; + + case WorkflowTriggerEventType.RecordAttachmentAdded: + list.AddRange(RecordEventVariables); + list.AddRange(RecordVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Record kind (Operational or IncidentReport)", "string", false)); + list.AddRange(RecordChangeVariables); + list.AddRange(AttachmentVariables); + list.AddRange(ProtectionVariables); + break; + + case WorkflowTriggerEventType.RecordDisclosureRequested: + case WorkflowTriggerEventType.RecordDisclosureProduced: + case WorkflowTriggerEventType.RecordDisclosureReleased: + case WorkflowTriggerEventType.RecordDisclosureClosed: + list.AddRange(RecordEventVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Always Disclosure", "string", false)); + list.Add(new TemplateVariableDescriptor("record.department_id", "Department ID", "int", false)); + list.AddRange(DisclosureVariables); + list.AddRange(ProtectionVariables); + break; + + case WorkflowTriggerEventType.RecordLegalHoldPlaced: + case WorkflowTriggerEventType.RecordLegalHoldReleased: + list.AddRange(RecordEventVariables); + list.AddRange(RecordVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Record kind of the held record (empty for a definition/date scope)", "string", false)); + list.AddRange(LegalHoldVariables); + list.AddRange(ProtectionVariables); + break; + + case WorkflowTriggerEventType.RecordEvidenceCaptured: + list.AddRange(RecordEventVariables); + list.AddRange(RecordVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Record kind (Operational or IncidentReport)", "string", false)); + list.AddRange(EvidenceVariables); + list.AddRange(ProtectionVariables); + break; + + case WorkflowTriggerEventType.RecordPurged: + list.AddRange(RecordEventVariables); + list.Add(new TemplateVariableDescriptor("record.id", "Purged record ID", "string", false)); + list.Add(new TemplateVariableDescriptor("record.kind", "Record kind (Operational or IncidentReport)", "string", false)); + list.Add(new TemplateVariableDescriptor("record.department_id", "Department ID", "int", false)); + list.Add(new TemplateVariableDescriptor("record.state", "Always Purged", "string", false)); + list.AddRange(PurgeVariables); + list.AddRange(ProtectionVariables); + break; + + case WorkflowTriggerEventType.RecordExportScheduled: + list.AddRange(RecordEventVariables); + list.Add(new TemplateVariableDescriptor("record.kind", "Always Export", "string", false)); + list.Add(new TemplateVariableDescriptor("record.department_id", "Department ID", "int", false)); + list.AddRange(ExportVariables); + list.AddRange(ProtectionVariables); break; case WorkflowTriggerEventType.RecordSubmissionQueued: @@ -674,11 +853,12 @@ public static IReadOnlyList GetVariableCatalog(Workf case WorkflowTriggerEventType.RecordSubmissionFailed: list.AddRange(RecordEventVariables); list.AddRange(RecordVariables); - list.Add(new TemplateVariableDescriptor("record.kind", "Record kind (Operational or IncidentReport)", "string", false)); + list.Add(new TemplateVariableDescriptor("record.kind", "Record kind (Operational, IncidentReport or IncidentAnalysis)", "string", false)); list.Add(new TemplateVariableDescriptor("record.incident_number", "Department incident number sent to the destination", "string", false)); list.Add(new TemplateVariableDescriptor("record.neris_incident_id", "NERIS incident ID once assigned", "string", false)); list.AddRange(RecordChangeVariables); list.AddRange(SubmissionVariables); + list.AddRange(ProtectionVariables); break; case WorkflowTriggerEventType.RecordOverdue: @@ -686,6 +866,7 @@ public static IReadOnlyList GetVariableCatalog(Workf list.AddRange(RecordVariables); list.Add(new TemplateVariableDescriptor("record.kind", "Record kind (Operational or IncidentReport)", "string", false)); list.AddRange(ObligationVariables); + list.AddRange(ProtectionVariables); break; } diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 6ac02931..93b151d8 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -74,6 +74,9 @@ public enum WorkflowTriggerEventType /// A reviewer returned a Record with a reason code. RecordReturnedForCorrection = 102, + /// An approver completed the Approval/Acknowledgement preset's approve step, before finalization. + RecordApproved = 103, + /// A Record revision was finalized/attested. One-step Records emit Created then Finalized. RecordFinalized = 104, @@ -106,7 +109,42 @@ public enum WorkflowTriggerEventType // can neither double-emit nor silently skip. /// A Record passed the due time of a review, correction or resubmission obligation. - RecordOverdue = 112 + RecordOverdue = 112, + + /// An attachment was added to a Record draft or amendment; carries safe metadata only, never bytes or names. + RecordAttachmentAdded = 115, + + // -- Records (RMS) block 2, 152-163 -- Identifier Allocation Registry section 3.2 (allocated 2026-09-05). The + // first block ran out with 113/114 reserved for RMS-1B definitions; 116-151 belong to Incident Back Office + // and AI Dispatch. These cover the RMS-3 capabilities (disclosures, legal holds, evidence, retention) and the + // department-authored export schedule so every RMS capability has a subscribable outcome. + + /// A public-records request was logged and its statutory clock started. + RecordDisclosureRequested = 152, + + /// An immutable disclosure packet was produced for a request. + RecordDisclosureProduced = 153, + + /// A produced packet was released to the requester. + RecordDisclosureReleased = 154, + + /// A disclosure request was closed with a disposition. + RecordDisclosureClosed = 155, + + /// A legal hold was placed on a record, a definition or a period. + RecordLegalHoldPlaced = 156, + + /// A legal hold was released. + RecordLegalHoldReleased = 157, + + /// An immutable evidence artifact was captured against a record. + RecordEvidenceCaptured = 158, + + /// The retention sweep purged a record's content, leaving a tombstone. + RecordPurged = 159, + + /// A department export template's schedule came due and its file was rendered for delivery. + RecordExportScheduled = 160 } public static class WorkflowTriggerEventTypes @@ -115,10 +153,14 @@ public static class WorkflowTriggerEventTypes public const int RecordsBlockFirst = 100; public const int RecordsBlockLast = 115; + /// The second Records block, 152-163 (registry section 3.2, allocated 2026-09-05); 161-163 are reserved. + public const int RecordsBlock2First = 152; + public const int RecordsBlock2Last = 163; + public static bool IsRecordsTrigger(WorkflowTriggerEventType type) { var value = (int)type; - return value >= RecordsBlockFirst && value <= RecordsBlockLast; + return value >= RecordsBlockFirst && value <= RecordsBlockLast || value >= RecordsBlock2First && value <= RecordsBlock2Last; } } } diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index afe1b838..9c67dacb 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -284,6 +284,105 @@ AdpColumnSpec Companion(string table, string column) => { Text("DistributionLists", "Username"), Text("DistributionLists", "Password") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + // Catalog v10: Records (RMS). Every RMS table carries its own DepartmentId and a string GUID + // primary key (client-compatible ids, RMS plan 5.3), so each is a Direct binding with a + // non-numeric key. Marker columns exist on every table below (M0150-M0171, M0176). + + AdpTableBinding.Direct("RmsOperationalRecordDetails", "RmsOperationalRecordDetailId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsOperationalRecordDetails", "Narrative"), Text("RmsOperationalRecordDetails", "InitialReport"), Text("RmsOperationalRecordDetails", "Cause"), Text("RmsOperationalRecordDetails", "ContactName"), Text("RmsOperationalRecordDetails", "ContactNumber"), Text("RmsOperationalRecordDetails", "OtherPersonnel"), Text("RmsOperationalRecordDetails", "Location"), Text("RmsOperationalRecordDetails", "BodyLocation"), Text("RmsOperationalRecordDetails", "PronouncedDeceasedBy"), Text("RmsOperationalRecordDetails", "CaseNumber"), Text("RmsOperationalRecordDetails", "Destination"), Text("RmsOperationalRecordDetails", "CallName"), Text("RmsOperationalRecordDetails", "CallAddress"), Text("RmsOperationalRecordDetails", "CallNature") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsNarratives", "RmsNarrativeId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsNarratives", "Narrative"), Text("RmsNarratives", "ImpedimentNarrative"), Text("RmsNarratives", "OutcomeNarrative"), Text("RmsNarratives", "SupplementalJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsLocations", "RmsLocationId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsLocations", "AddressText"), Text("RmsLocations", "Number"), Text("RmsLocations", "NumberPrefix"), Text("RmsLocations", "NumberSuffix"), Text("RmsLocations", "Street"), Text("RmsLocations", "UnitValue"), Text("RmsLocations", "CrossStreet1"), Text("RmsLocations", "CrossStreet2"), Companion("RmsLocations", "Latitude"), Companion("RmsLocations", "Longitude") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsSourceFacts", "RmsSourceFactId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsSourceFacts", "SourceValue"), Text("RmsSourceFacts", "CurrentValue") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsCasualtyRescues", "RmsCasualtyRescueId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsCasualtyRescues", "PersonnelUserId"), Text("RmsCasualtyRescues", "Rank"), Text("RmsCasualtyRescues", "JobClassification"), Text("RmsCasualtyRescues", "BirthMonthYear"), Text("RmsCasualtyRescues", "Gender"), Text("RmsCasualtyRescues", "Race"), Text("RmsCasualtyRescues", "CasualtyCause"), Text("RmsCasualtyRescues", "CasualtyAction"), Text("RmsCasualtyRescues", "CasualtyTimeline"), Text("RmsCasualtyRescues", "InjuryDetailJson"), Text("RmsCasualtyRescues", "DetailJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsExposures", "RmsExposureId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsExposures", "AddressText"), Text("RmsExposures", "Street"), Text("RmsExposures", "DetailJson"), Companion("RmsExposures", "Latitude"), Companion("RmsExposures", "Longitude") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsIncidentModules", "RmsIncidentModuleId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsIncidentModules", "DetailJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsIncidentProperties", "RmsIncidentPropertyId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsIncidentProperties", "DetailJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsIncidentVehicles", "RmsIncidentVehicleId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsIncidentVehicles", "Vin"), Text("RmsIncidentVehicles", "LicensePlate"), Text("RmsIncidentVehicles", "DetailJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsIncidentResources", "RmsIncidentResourceId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsIncidentResources", "Detail") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsRevisions", "RmsRevisionId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsRevisions", "SnapshotJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsSubmissions", "RmsSubmissionId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsSubmissions", "PayloadJson"), Text("RmsSubmissions", "ResponseJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsSignatures", "RmsSignatureId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsSignatures", "StatementText") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsEvidenceArtifacts", "RmsEvidenceArtifactId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsEvidenceArtifacts", "Title"), Text("RmsEvidenceArtifacts", "CaptureReason"), Text("RmsEvidenceArtifacts", "ManifestJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsDisclosureRequests", "RmsDisclosureRequestId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsDisclosureRequests", "RequesterName"), Text("RmsDisclosureRequests", "RequesterOrganization"), Text("RmsDisclosureRequests", "RequesterContact"), Text("RmsDisclosureRequests", "ScopeNarrative"), Text("RmsDisclosureRequests", "DispositionReason") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsDisclosureProductions", "RmsDisclosureProductionId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsDisclosureProductions", "ArtifactJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsRecordLegalHolds", "RmsRecordLegalHoldId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsRecordLegalHolds", "ReferenceNumber"), Text("RmsRecordLegalHolds", "Notes"), Text("RmsRecordLegalHolds", "ReleaseNotes") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsRecordAttachments", "RmsRecordAttachmentId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("RmsRecordAttachments", "FileName"), Text("RmsRecordAttachments", "Description"), Binary("RmsRecordAttachments", "Data") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("RmsExportRuns", "RmsExportRunId", pkIsNumeric: false, "DepartmentId", new[] + { + Binary("RmsExportRuns", "Data") }) with { ProtectedMarkerColumn = "IsProtected" } }; } diff --git a/Core/Resgrid.Services/AmbientProtectedGrantContext.cs b/Core/Resgrid.Services/AmbientProtectedGrantContext.cs new file mode 100644 index 00000000..b10325e4 --- /dev/null +++ b/Core/Resgrid.Services/AmbientProtectedGrantContext.cs @@ -0,0 +1,36 @@ +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// The default (ADP plan 3.3 / 7.2): no request is in flight, so the + /// caller is a workload — a worker, a console tool, the composition test. Web hosts override this + /// registration with their request-bound implementation after loading ServicesModule, so RMS services + /// never have to know which host they run in. + /// + public sealed class WorkloadProtectedGrantContext : IProtectedGrantContext + { + public string GrantToken => null; + + public string UserId => null; + + public bool IsWorkloadCaller => true; + } + + /// A fixed grant context for tests and one-off tool runs. + public sealed class FixedProtectedGrantContext : IProtectedGrantContext + { + public FixedProtectedGrantContext(string grantToken, bool isWorkloadCaller, string userId = null) + { + GrantToken = grantToken; + IsWorkloadCaller = isWorkloadCaller; + UserId = userId; + } + + public static FixedProtectedGrantContext Workload { get; } = new FixedProtectedGrantContext(null, true); + + public string GrantToken { get; } + public string UserId { get; } + public bool IsWorkloadCaller { get; } + } +} diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 26869afe..40fbcc00 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -52,6 +52,13 @@ public class ProtectedFieldCatalog : IProtectedFieldCatalog /// private const int RemainingCandidatesCatalogVersion = 9; + /// + /// Catalog version the Records (RMS) family was added in (RMS plan section 5.9.2, ADP Appendix B). The + /// entries mirror RmsProtectedFields, which is the write/read seam; RmsProtectedDataParityTests pins the + /// two and the AdpTableBindings rows together. + /// + public const int RecordsCatalogVersion = 10; + private static readonly IReadOnlyList Entries = BuildV1(); private static readonly Dictionary ById = Entries.ToDictionary(e => e.FieldId, StringComparer.OrdinalIgnoreCase); @@ -436,6 +443,96 @@ void Remaining(string family, string table, string column, ProtectedFieldClassif Remaining(CredentialsFamily, "DistributionLists", "Password", ProtectedFieldClassification.Sensitive, PermissionTypes.ManageDepartmentDataProtection); + // ---- Records (RMS), catalog v10 ----------------------------------------------------- + // The Logs catalog v3 classifications carry forward to the Records fields that replaced them + // (RMS plan section 5.9.2); the NERIS aggregate, evidence, disclosures, legal holds and the + // generated artifacts (revision snapshots, destination payloads, produced packets) inherit the + // highest source classification. View permission follows the operational family; who may + // SEE a restricted section is decided by RecordRestricted_View on top, exactly as for Logs. + void Records(string table, string column, ProtectedFieldClassification classification, + ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) => + list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", + RmsProtectedFields.Family, table, column, kind, classification, + PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, + RecordsCatalogVersion)); + + Records("RmsOperationalRecordDetails", "Narrative", ProtectedFieldClassification.Phi); + Records("RmsOperationalRecordDetails", "InitialReport", ProtectedFieldClassification.Phi); + Records("RmsOperationalRecordDetails", "Cause", ProtectedFieldClassification.Sensitive); + Records("RmsOperationalRecordDetails", "ContactName", ProtectedFieldClassification.Pii); + Records("RmsOperationalRecordDetails", "ContactNumber", ProtectedFieldClassification.Pii); + Records("RmsOperationalRecordDetails", "OtherPersonnel", ProtectedFieldClassification.Sensitive); + Records("RmsOperationalRecordDetails", "Location", ProtectedFieldClassification.Sensitive); + Records("RmsOperationalRecordDetails", "BodyLocation", ProtectedFieldClassification.Phi); + Records("RmsOperationalRecordDetails", "PronouncedDeceasedBy", ProtectedFieldClassification.Phi); + Records("RmsOperationalRecordDetails", "CaseNumber", ProtectedFieldClassification.Sensitive); + Records("RmsOperationalRecordDetails", "Destination", ProtectedFieldClassification.Sensitive); + Records("RmsOperationalRecordDetails", "CallName", ProtectedFieldClassification.Sensitive); + Records("RmsOperationalRecordDetails", "CallAddress", ProtectedFieldClassification.Pii); + Records("RmsOperationalRecordDetails", "CallNature", ProtectedFieldClassification.Phi); + + Records("RmsNarratives", "Narrative", ProtectedFieldClassification.Phi); + Records("RmsNarratives", "ImpedimentNarrative", ProtectedFieldClassification.Phi); + Records("RmsNarratives", "OutcomeNarrative", ProtectedFieldClassification.Phi); + Records("RmsNarratives", "SupplementalJson", ProtectedFieldClassification.Sensitive); + + Records("RmsLocations", "AddressText", ProtectedFieldClassification.Pii); + Records("RmsLocations", "Number", ProtectedFieldClassification.Pii); + Records("RmsLocations", "NumberPrefix", ProtectedFieldClassification.Pii); + Records("RmsLocations", "NumberSuffix", ProtectedFieldClassification.Pii); + Records("RmsLocations", "Street", ProtectedFieldClassification.Pii); + Records("RmsLocations", "UnitValue", ProtectedFieldClassification.Pii); + Records("RmsLocations", "CrossStreet1", ProtectedFieldClassification.Pii); + Records("RmsLocations", "CrossStreet2", ProtectedFieldClassification.Pii); + Records("RmsLocations", "Latitude", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.CompanionColumn); + Records("RmsLocations", "Longitude", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.CompanionColumn); + + Records("RmsSourceFacts", "SourceValue", ProtectedFieldClassification.Sensitive); + Records("RmsSourceFacts", "CurrentValue", ProtectedFieldClassification.Sensitive); + + foreach (var column in new[] { "PersonnelUserId", "Rank", "JobClassification", "BirthMonthYear", "Gender", "Race", "CasualtyCause", "CasualtyAction", "CasualtyTimeline", "InjuryDetailJson", "DetailJson" }) + Records("RmsCasualtyRescues", column, ProtectedFieldClassification.Phi); + + Records("RmsExposures", "AddressText", ProtectedFieldClassification.Pii); + Records("RmsExposures", "Street", ProtectedFieldClassification.Pii); + Records("RmsExposures", "DetailJson", ProtectedFieldClassification.Sensitive); + Records("RmsExposures", "Latitude", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.CompanionColumn); + Records("RmsExposures", "Longitude", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.CompanionColumn); + + Records("RmsIncidentModules", "DetailJson", ProtectedFieldClassification.Sensitive); + Records("RmsIncidentProperties", "DetailJson", ProtectedFieldClassification.Sensitive); + Records("RmsIncidentVehicles", "Vin", ProtectedFieldClassification.Pii); + Records("RmsIncidentVehicles", "LicensePlate", ProtectedFieldClassification.Pii); + Records("RmsIncidentVehicles", "DetailJson", ProtectedFieldClassification.Sensitive); + Records("RmsIncidentResources", "Detail", ProtectedFieldClassification.Sensitive); + + Records("RmsRevisions", "SnapshotJson", ProtectedFieldClassification.Phi); + Records("RmsSubmissions", "PayloadJson", ProtectedFieldClassification.Phi); + Records("RmsSubmissions", "ResponseJson", ProtectedFieldClassification.Sensitive); + Records("RmsSignatures", "StatementText", ProtectedFieldClassification.Sensitive); + + Records("RmsEvidenceArtifacts", "Title", ProtectedFieldClassification.Sensitive); + Records("RmsEvidenceArtifacts", "CaptureReason", ProtectedFieldClassification.Sensitive); + Records("RmsEvidenceArtifacts", "ManifestJson", ProtectedFieldClassification.Phi); + + Records("RmsDisclosureRequests", "RequesterName", ProtectedFieldClassification.Pii); + Records("RmsDisclosureRequests", "RequesterOrganization", ProtectedFieldClassification.Pii); + Records("RmsDisclosureRequests", "RequesterContact", ProtectedFieldClassification.Pii); + Records("RmsDisclosureRequests", "ScopeNarrative", ProtectedFieldClassification.Sensitive); + Records("RmsDisclosureRequests", "DispositionReason", ProtectedFieldClassification.Sensitive); + Records("RmsDisclosureProductions", "ArtifactJson", ProtectedFieldClassification.Phi); + + Records("RmsRecordLegalHolds", "ReferenceNumber", ProtectedFieldClassification.Sensitive); + Records("RmsRecordLegalHolds", "Notes", ProtectedFieldClassification.Sensitive); + Records("RmsRecordLegalHolds", "ReleaseNotes", ProtectedFieldClassification.Sensitive); + + Records("RmsRecordAttachments", "FileName", ProtectedFieldClassification.Sensitive); + Records("RmsRecordAttachments", "Description", ProtectedFieldClassification.Sensitive); + Records("RmsRecordAttachments", "Data", ProtectedFieldClassification.Phi, ProtectedFieldStorageKind.Binary); + + // A rendered department export inherits the highest classification of what it carried. + Records("RmsExportRuns", "Data", ProtectedFieldClassification.Phi, ProtectedFieldStorageKind.Binary); + return list; } } diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs index f6b89ae7..21f6281a 100644 --- a/Core/Resgrid.Services/ProtectedReadService.cs +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -2641,5 +2641,232 @@ private static void RecordRedaction(Slot slot, string reason) slot.Owner.RedactedFields.Add(slot.FieldId); slot.Owner.ProtectedReason ??= reason; } + + #region Records (RMS), catalog v10 + + // RMS entities are uniform (string PK, DepartmentId, text columns), so one generic seam driven by the + // RmsProtectedFields accessor maps replaces a method per entity. Sentinel policy, slot collection and the + // broker calls are the same private helpers every other family uses. + + public async Task PrepareRecordsEntityWriteAsync(int departmentId, T entity, T existing, string rowKey, + IReadOnlyDictionary Get, Action Set)> accessors, Action markProtected, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) where T : class + { + if (entity == null || accessors == null || accessors.Count == 0) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(entity, existing, accessors); + var slots = CollectTextWriteSlots(accessors, entity, rowKey); + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, markProtected, sentinelsHandled, cancellationToken); + } + + public async Task PrepareRecordsCompanionWriteAsync(int departmentId, T entity, string rowKey, + IReadOnlyDictionary Get, Action Set, Func GetEnvelope, Action SetEnvelope)> companions, + Action markProtected, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) where T : class + { + if (entity == null || companions == null || companions.Count == 0) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + foreach (var companion in companions) + { + var value = companion.Value.Get(entity); + if (!value.HasValue || ProtectedDataEnvelope.HasEnvelopePrefix(companion.Value.GetEnvelope(entity))) + continue; + + var set = companion.Value.Set; + var setEnvelope = companion.Value.SetEnvelope; + slots.Add(new WriteSlot + { + FieldId = companion.Key, + RowKey = rowKey, + WireValue = value.Value.ToString(CultureInfo.InvariantCulture), + Apply = envelope => { setEnvelope(entity, envelope); set(entity, null); } + }); + } + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, markProtected, cancellationToken); + } + + public async Task PrepareRecordsAttachmentWriteAsync(int departmentId, RmsRecordAttachment attachment, RmsRecordAttachment existing, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (attachment == null) + return ProtectedWriteResult.Allowed(); + + var rowKey = attachment.RmsRecordAttachmentId; + var sentinelsHandled = ApplySentinelPolicy(attachment, existing, RmsProtectedFields.Attachments); + var slots = CollectTextWriteSlots(RmsProtectedFields.Attachments, attachment, rowKey); + AddBinaryWriteSlot(slots, RmsProtectedFields.AttachmentDataFieldId, rowKey, attachment.Data, bytes => attachment.Data = bytes); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => attachment.IsProtected = true, sentinelsHandled, cancellationToken); + } + + public async Task ResolveRecordsEntitiesForReadAsync(int departmentId, IReadOnlyList<(T Entity, string RowKey)> rows, + IReadOnlyDictionary Get, Action Set)> accessors, + string grantToken, string userId, CancellationToken cancellationToken = default) where T : class + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var row in rows ?? Array.Empty<(T, string)>()) + { + if (row.Entity != null) + CollectTextSlots(result, slots, accessors, row.Entity, row.RowKey); + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveRecordsCompanionsForReadAsync(int departmentId, IReadOnlyList<(T Entity, string RowKey)> rows, + IReadOnlyDictionary Get, Action Set, Func GetEnvelope, Action SetEnvelope)> companions, + string grantToken, string userId, CancellationToken cancellationToken = default) where T : class + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var row in rows ?? Array.Empty<(T, string)>()) + { + if (row.Entity == null) + continue; + foreach (var companion in companions) + { + var envelope = companion.Value.GetEnvelope(row.Entity); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(envelope)) + continue; + + var entity = row.Entity; + var set = companion.Value.Set; + slots.Add(new Slot + { + FieldId = companion.Key, + RowKey = row.RowKey, + WireValue = envelope, + Owner = result, + Reveal = plaintext => set(entity, decimal.TryParse(plaintext, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed) ? parsed : null), + Redact = () => set(entity, null) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task PrepareRecordsBinaryWriteAsync(int departmentId, string fieldId, string rowKey, byte[] data, Action apply, Action markProtected, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + var slots = new List(); + AddBinaryWriteSlot(slots, fieldId, rowKey, data, apply); + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, markProtected, cancellationToken); + } + + public async Task ResolveRecordsBinaryForReadAsync(int departmentId, string fieldId, string rowKey, byte[] data, Action apply, + string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + CollectBinarySlot(result, slots, fieldId, rowKey, data, true, apply); + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveRecordsAttachmentsForReadAsync(int departmentId, IReadOnlyList attachments, + string grantToken, string userId, bool includeData, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var attachment in (attachments ?? Array.Empty()).Where(a => a != null)) + { + var current = attachment; + CollectTextSlots(result, slots, RmsProtectedFields.Attachments, current, current.RmsRecordAttachmentId); + CollectBinarySlot(result, slots, RmsProtectedFields.AttachmentDataFieldId, current.RmsRecordAttachmentId, current.Data, includeData, bytes => current.Data = bytes); + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveRecordsEntitiesForWorkloadAsync(int departmentId, string purpose, IReadOnlyList<(T Entity, string RowKey)> rows, + IReadOnlyDictionary Get, Action Set)> accessors, CancellationToken cancellationToken = default) where T : class + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var row in rows ?? Array.Empty<(T, string)>()) + { + if (row.Entity != null) + CollectTextSlots(result, slots, accessors, row.Entity, row.RowKey); + } + + if (slots.Count == 0) + return result; + + // An envelope is present, so the content is protected whatever the department's current state says; + // the only question is whether this purpose may read it. Nothing is ever redacted in place here: a + // refused workload read leaves the ciphertext exactly as stored and names the reason. + result.IsProtected = true; + + int catalogVersion; + try + { + catalogVersion = (await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId))?.CatalogVersion ?? 0; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Protection-state lookup failed for department {departmentId}; refusing the workload read."); + foreach (var slot in slots) RecordRedaction(slot, "broker_unavailable"); + return result; + } + + var items = slots.Select(s => new ProtectedFieldOperationItem + { + FieldId = s.FieldId, + RowKey = s.RowKey, + Value = s.WireValue, + IsBinary = s.IsBinary, + CatalogVersion = catalogVersion + }).ToList(); + + ProtectedDataBrokerResult brokerResult; + try + { + brokerResult = await _brokerClient.DecryptForWorkloadAsync(departmentId, purpose, Guid.NewGuid().ToString("N"), items, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Protected workload read broker call failed for department {departmentId} ({purpose})."); + brokerResult = null; + } + + if (brokerResult == null || !brokerResult.Success) + { + var reason = brokerResult?.ErrorCode == "workload_purpose_denied" ? "workload_purpose_denied" : "broker_unavailable"; + foreach (var slot in slots) RecordRedaction(slot, reason); + return result; + } + + var decrypted = brokerResult.Items + .Where(i => i != null && i.FieldId != null && i.RowKey != null) + .GroupBy(i => (i.RowKey, i.FieldId)) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var slot in slots) + { + if (decrypted.TryGetValue((slot.RowKey, slot.FieldId), out var item) && item.ErrorCode == null && item.Value != null) + { + try { slot.Reveal(item.Value); continue; } + catch (FormatException) { } + } + RecordRedaction(slot, "broker_unavailable"); + } + + return result; + } + + #endregion } } diff --git a/Core/Resgrid.Services/Records/IncidentAnalysisService.cs b/Core/Resgrid.Services/Records/IncidentAnalysisService.cs index 9e40506a..f7e157bd 100644 --- a/Core/Resgrid.Services/Records/IncidentAnalysisService.cs +++ b/Core/Resgrid.Services/Records/IncidentAnalysisService.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; @@ -41,13 +42,17 @@ public class IncidentAnalysisService : IIncidentAnalysisService private readonly INerisMappingService _mapping; private readonly INerisValidationService _validation; private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsProtectionService _protection; + private readonly IDomainEventOutboxService _outbox; public IncidentAnalysisService(IRmsIncidentAnalysesRepository analyses, IRmsIncidentReportsRepository reports, IRmsIncidentModulesRepository modules, IRmsIncidentPropertiesRepository properties, IRmsIncidentVehiclesRepository vehicles, IRmsValidationIssuesRepository issues, IRmsSubmissionsRepository submissions, IRmsRevisionsRepository revisions, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork, INerisProfileService neris, INerisMappingService mapping, - INerisValidationService validation, IRecordsAuthorizationService authorization) + INerisValidationService validation, IRecordsAuthorizationService authorization, IRecordsProtectionService protection, IDomainEventOutboxService outbox) { + _protection = protection; + _outbox = outbox; _analyses = analyses; _reports = reports; _modules = modules; @@ -95,11 +100,14 @@ public async Task StartForReportAsync(int departmentI RowVersion = 1 }; + var outboxIds = new List(); await InTransactionAsync(async () => { await _analyses.InsertAsync(analysis, cancellationToken, true); + outboxIds.Add(await EnqueueLifecycleEventAsync(analysis, report, null, WorkflowTriggerEventType.RecordCreated, RmsIncidentAnalysisState.Draft, RmsIncidentAnalysisState.Draft, null, null, cancellationToken)); await AuditAsync(departmentId, userId, analysis.RmsIncidentAnalysisId, null, RmsAccessAuditAction.Change, "Start incident analysis", origin, cancellationToken, new { incidentReportId }); }); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return await HydrateAsync(analysis, report, null, false); } @@ -196,6 +204,8 @@ await InTransactionAsync(async () => throw new RecordConcurrencyException(analysisId, expectedRowVersion, analysis.RowVersion); var aggregate = await HydrateAsync(analysis, report, null, false); + aggregate.Protection.RequireRevealed("finalize analysis"); + var priorState = (RmsIncidentAnalysisState)analysis.State; var revision = await WriteRevisionAsync(analysis, aggregate, userId, now, cancellationToken); analysis.State = (int)RmsIncidentAnalysisState.Finalized; @@ -209,14 +219,15 @@ await InTransactionAsync(async () => await _analyses.UpdateAsync(analysis, cancellationToken, true); // Queue immediately when the incident is already filed; otherwise worker 41 picks it up when it is. + outboxIds.Add(await EnqueueLifecycleEventAsync(analysis, report, revision, WorkflowTriggerEventType.RecordFinalized, priorState, RmsIncidentAnalysisState.Finalized, null, null, cancellationToken)); if (report != null && !string.IsNullOrWhiteSpace(report.NerisIncidentId) && await _neris.IsSubmissionEnabledAsync(departmentId)) - await QueueCoreAsync(analysis, report, revision, userId, now, cancellationToken); + outboxIds.Add(await QueueCoreAsync(analysis, report, revision, userId, now, cancellationToken)); await _analyses.UpdateAsync(analysis, cancellationToken, true); await AuditAsync(departmentId, userId, analysisId, revision.RmsRevisionId, RmsAccessAuditAction.Change, "Finalize incident analysis", RmsOriginClient.Web, cancellationToken, new { revision.Checksum }); }); - await Task.CompletedTask; + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return await GetAsync(departmentId, analysisId, true); } @@ -234,18 +245,20 @@ public async Task QueueSubmissionAsync(int department throw new InvalidOperationException("NERIS submission is not enabled for this department."); var now = DateTime.UtcNow; + var outboxIds = new List(); await InTransactionAsync(async () => { var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, analysis.CurrentRevisionId) ?? throw new InvalidOperationException("The finalized revision this analysis points at is missing; it cannot be filed."); if (!await _analyses.TryBumpRowVersionAsync(departmentId, analysisId, analysis.RowVersion, cancellationToken)) throw new RecordConcurrencyException(analysisId, analysis.RowVersion, analysis.RowVersion + 1); - await QueueCoreAsync(analysis, report, revision, userId, now, cancellationToken); + outboxIds.Add(await QueueCoreAsync(analysis, report, revision, userId, now, cancellationToken)); analysis.ModifiedOn = now; analysis.ModifiedByUserId = userId; analysis.RowVersion += 1; await _analyses.UpdateAsync(analysis, cancellationToken, true); }); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return await GetAsync(departmentId, analysisId, true); } @@ -295,6 +308,9 @@ public async Task VoidAsync(int departmentId, string throw new InvalidOperationException("The analysis is in flight to the destination and cannot be voided until it settles."); var now = DateTime.UtcNow; + var outboxIds = new List(); + var priorState = (RmsIncidentAnalysisState)analysis.State; + var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); await InTransactionAsync(async () => { analysis.State = (int)RmsIncidentAnalysisState.Voided; @@ -309,8 +325,10 @@ await InTransactionAsync(async () => analysis.RowVersion += 1; await _analyses.UpdateAsync(analysis, cancellationToken, true); await _submissions.SupersedeOpenForRecordAsync(departmentId, analysisId, null, now, cancellationToken); + outboxIds.Add(await EnqueueLifecycleEventAsync(analysis, report, null, WorkflowTriggerEventType.RecordVoided, priorState, RmsIncidentAnalysisState.Voided, reasonCode, null, cancellationToken)); await AuditAsync(departmentId, userId, analysisId, null, RmsAccessAuditAction.Change, "Void incident analysis", RmsOriginClient.Web, cancellationToken, new { reasonCode }); }); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return await GetAsync(departmentId, analysisId, true); } @@ -327,6 +345,7 @@ public async Task BuildSnapshotAsync(int departme { var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); if (revision == null || revision.RecordId != analysisId || revision.RecordKind != (int)RmsRecordKind.IncidentAnalysis) return null; + (await _protection.RevealRevisionsAsync(departmentId, new[] { revision })).RequireRevealed("analysis revision"); if (RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("The analysis revision checksum does not match."); var frozen = JsonConvert.DeserializeObject(revision.SnapshotJson); if (frozen?.Analysis == null || frozen.Report == null) throw new InvalidOperationException("This legacy analysis revision did not capture its headers. Finalize a corrected revision before submitting it."); @@ -385,6 +404,7 @@ private async Task HydrateAsync(RmsIncidentAnalysis a aggregate.Revisions = (await _revisions.GetForRecordAsync(dept, id))?.ToList() ?? new List(); } + aggregate.Protection = await _protection.RevealAsync(dept, aggregate); return aggregate; } @@ -393,14 +413,16 @@ private async Task> ReplacePropertiesAsync(RmsIncident if (inputs == null) return (await _properties.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, null))?.ToList() ?? new List(); + var existingRows = (await _properties.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, null))?.OrderBy(p => p.Ordinal).ToList() ?? new List(); await _properties.DeleteDraftForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, cancellationToken); var result = new List(); var ordinal = 0; foreach (var input in inputs) { + var existing = existingRows.ElementAtOrDefault(ordinal); var row = new RmsIncidentProperty { - RmsIncidentPropertyId = Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RmsIncidentPropertyId = existing?.RmsIncidentPropertyId ?? Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), RecordId = analysis.RmsIncidentAnalysisId, LocationUse = Trim(input.LocationUse)?.ToUpperInvariant(), ConstructionType = Trim(input.ConstructionType)?.ToUpperInvariant(), Foundation = Trim(input.Foundation)?.ToUpperInvariant(), ExteriorFinish = Trim(input.ExteriorFinish)?.ToUpperInvariant(), @@ -410,6 +432,7 @@ private async Task> ReplacePropertiesAsync(RmsIncident ContentsValue = input.ContentsValue, ContentsLoss = input.ContentsLoss, CurrencyCode = analysis.CurrencyCode, DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + await _protection.ProtectPropertyAsync(analysis.DepartmentId, row, existing, null, cancellationToken); await _properties.InsertAsync(row, cancellationToken, true); result.Add(row); } @@ -452,6 +475,7 @@ private async Task> ReplaceVehiclesAsync(RmsIncidentAna row.LicensePlate = canWriteRestricted ? Trim(input.LicensePlate)?.ToUpperInvariant() : prior?.LicensePlate; row.LicenseState = canWriteRestricted ? Trim(input.LicenseState)?.ToUpperInvariant() : prior?.LicenseState; + await _protection.ProtectVehicleAsync(analysis.DepartmentId, row, prior, null, cancellationToken); await _vehicles.InsertAsync(row, cancellationToken, true); result.Add(row); } @@ -463,6 +487,7 @@ private async Task> ReplaceModulesAsync(RmsIncidentAnaly if (inputs == null) return (await _modules.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, null))?.ToList() ?? new List(); + var existingRows = (await _modules.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, null))?.OrderBy(m => m.Ordinal).ToList() ?? new List(); await _modules.DeleteDraftForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, cancellationToken); var result = new List(); var ordinal = 0; @@ -473,15 +498,17 @@ private async Task> ReplaceModulesAsync(RmsIncidentAnaly if (descriptor == null || !descriptor.BelongsToAnalysis) continue; + var existing = existingRows.ElementAtOrDefault(ordinal); var row = new RmsIncidentModule { - RmsIncidentModuleId = Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RmsIncidentModuleId = existing?.RmsIncidentModuleId ?? Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), RecordId = analysis.RmsIncidentAnalysisId, RecordKind = (int)RmsRecordKind.IncidentAnalysis, ModuleKind = (int)input.Kind, SchemaName = descriptor.SchemaName, ProfileVersion = analysis.ProfileVersion, PrimaryCode = Trim(input.PrimaryCode)?.ToUpperInvariant(), SecondaryCode = Trim(input.SecondaryCode)?.ToUpperInvariant(), Quantity = input.Quantity, QuantityUnit = Trim(input.QuantityUnit)?.ToUpperInvariant(), OccurredOn = input.OccurredOn, DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + await _protection.ProtectModuleAsync(analysis.DepartmentId, row, existing, null, cancellationToken); await _modules.InsertAsync(row, cancellationToken, true); result.Add(row); } @@ -524,17 +551,19 @@ private async Task WriteRevisionAsync(RmsIncidentAnalysis analysis, OriginClient = (int)RmsOriginClient.Web, CreatedOn = now }; + await _protection.ProtectRevisionAsync(analysis.DepartmentId, revision, userId, cancellationToken); await _revisions.InsertAsync(revision, cancellationToken, true); var id = revision.RmsRevisionId; - foreach (var m in draft.Modules) await _modules.InsertAsync(CopyTo(m, x => x.RmsIncidentModuleId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); - foreach (var p in draft.Properties) await _properties.InsertAsync(CopyTo(p, x => x.RmsIncidentPropertyId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); - foreach (var v in draft.Vehicles) await _vehicles.InsertAsync(CopyTo(v, x => x.RmsIncidentVehicleId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + var dept = analysis.DepartmentId; + foreach (var m in draft.Modules) { var row = CopyTo(m, x => x.RmsIncidentModuleId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectModuleAsync(dept, row, null, userId, cancellationToken); await _modules.InsertAsync(row, cancellationToken, true); } + foreach (var p in draft.Properties) { var row = CopyTo(p, x => x.RmsIncidentPropertyId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectPropertyAsync(dept, row, null, userId, cancellationToken); await _properties.InsertAsync(row, cancellationToken, true); } + foreach (var v in draft.Vehicles) { var row = CopyTo(v, x => x.RmsIncidentVehicleId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectVehicleAsync(dept, row, null, userId, cancellationToken); await _vehicles.InsertAsync(row, cancellationToken, true); } return revision; } - private async Task QueueCoreAsync(RmsIncidentAnalysis analysis, RmsIncidentReport report, RmsRevision revision, string userId, DateTime now, CancellationToken cancellationToken) + private async Task QueueCoreAsync(RmsIncidentAnalysis analysis, RmsIncidentReport report, RmsRevision revision, string userId, DateTime now, CancellationToken cancellationToken) { var priorSubmissions = (await _submissions.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId))?.ToList() ?? new List(); RecordsSubmissionService.RequireResolvedCreates(priorSubmissions); @@ -579,6 +608,7 @@ private async Task QueueCoreAsync(RmsIncidentAnalysis analysis, RmsIncidentRepor ModifiedOn = now, RowVersion = 1 }; + await _protection.ProtectSubmissionAsync(analysis.DepartmentId, submission, userId, cancellationToken); await _submissions.SupersedeOpenForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, submission.RmsSubmissionId, now, cancellationToken); await _submissions.InsertAsync(submission, cancellationToken, true); } @@ -597,13 +627,82 @@ private async Task QueueCoreAsync(RmsIncidentAnalysis analysis, RmsIncidentRepor await _submissions.UpdateAsync(submission, cancellationToken, true); } + var priorState = (RmsIncidentAnalysisState)analysis.State; analysis.State = (int)RmsIncidentAnalysisState.Submitted; analysis.LastSubmissionId = submission.RmsSubmissionId; analysis.LastSubmissionState = submission.State; analysis.LastSubmittedOn = now; + var outboxId = await EnqueueLifecycleEventAsync(analysis, report, revision, WorkflowTriggerEventType.RecordSubmissionQueued, priorState, RmsIncidentAnalysisState.Submitted, null, IncidentReportsService.SubmissionBlock(submission), cancellationToken); await AuditAsync(analysis.DepartmentId, userId, analysis.RmsIncidentAnalysisId, revision.RmsRevisionId, RmsAccessAuditAction.Submit, "Queue analysis submission", RmsOriginClient.System, cancellationToken, new { submission.RmsSubmissionId, submission.IdempotencyKey, submission.PayloadChecksum }); + return outboxId; + } + + /// + /// The analysis rides the same Records triggers as its incident (100/104/106/108-111) with + /// record.kind = "IncidentAnalysis"; a subscriber filters on kind. The block mirrors the incident's, + /// so one template serves both. + /// + private async Task EnqueueLifecycleEventAsync(RmsIncidentAnalysis analysis, RmsIncidentReport report, RmsRevision revision, WorkflowTriggerEventType trigger, + RmsIncidentAnalysisState from, RmsIncidentAnalysisState to, string reasonCode, object submission, CancellationToken cancellationToken) + { + var payload = new Dictionary + { + ["record"] = RecordBlock(analysis, report, revision, to), + ["record_change"] = new { previous_state = from.ToString(), current_state = to.ToString(), prior_revision_id = revision?.PriorRevisionId, current_revision_id = revision?.RmsRevisionId ?? analysis.CurrentRevisionId, reason_code = reasonCode }, + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(analysis.DepartmentId)) + }; + if (submission != null) + payload["submission"] = submission; + + var entry = await _outbox.EnqueueAsync(analysis.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = trigger.ToString(), + SchemaVersion = 1, + AggregateType = AnalysisAggregate, + AggregateId = analysis.RmsIncidentAnalysisId, + AggregateVersion = revision?.RevisionNumber ?? analysis.RevisionCount, + Trigger = trigger, + Payload = payload, + CorrelationId = analysis.IncidentReportId, + OriginClient = RmsOriginClient.Web + }, cancellationToken); + return entry.DomainEventOutboxId; + } + + public static object RecordBlock(RmsIncidentAnalysis analysis, RmsIncidentReport report, RmsRevision revision, RmsIncidentAnalysisState state) + { + return new + { + id = analysis.RmsIncidentAnalysisId, + kind = "IncidentAnalysis", + record_number = report?.RecordNumber, + draft_reference = report?.DraftReference, + definition_key = RmsDefinitionKeys.NerisIncidentReport, + definition_version = 1, + type_key = "NerisIncidentAnalysis", + state = state.ToString(), + lifecycle_preset = (string)null, + department_id = analysis.DepartmentId, + station_group_id = report?.StationGroupId, + call_id = report?.CallId, + external_id = analysis.NerisAnalysisId, + author_user_id = analysis.AuthorUserId, + owner_user_id = analysis.OwnerUserId, + started_on = report?.CallCreatedOn, + ended_on = report?.IncidentClearedOn, + created_on = analysis.CreatedOn, + finalized_on = analysis.FinalizedOn, + revision_id = revision?.RmsRevisionId ?? analysis.CurrentRevisionId, + revision_number = revision?.RevisionNumber ?? analysis.RevisionCount, + checksum = revision?.Checksum, + summary = report?.DisplaySummary, + incident_number = report?.IncidentNumber, + neris_incident_id = report?.NerisIncidentId, + incident_report_id = analysis.IncidentReportId, + neris_analysis_id = analysis.NerisAnalysisId + }; } private static T CopyTo(T source, Action assignId, string revisionId, DateTime now) where T : class diff --git a/Core/Resgrid.Services/Records/IncidentAttachmentsService.cs b/Core/Resgrid.Services/Records/IncidentAttachmentsService.cs index a4c93f03..2a0f1744 100644 --- a/Core/Resgrid.Services/Records/IncidentAttachmentsService.cs +++ b/Core/Resgrid.Services/Records/IncidentAttachmentsService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; using Resgrid.Model; +using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; @@ -21,9 +22,12 @@ public sealed class IncidentAttachmentsService : IIncidentAttachmentsService private readonly IRecordsAuthorizationService _authorization; private readonly IRecordAttachmentScanner _scanner; private readonly IUnitOfWork _unitOfWork; + private readonly IRecordsProtectionService _protection; + private readonly IDomainEventOutboxService _outbox; public IncidentAttachmentsService(IRmsIncidentReportsRepository reports, IRmsRecordAttachmentsRepository attachments, IRmsRevisionsRepository revisions, - IRmsAccessAuditsRepository audits, IRecordsAuthorizationService authorization, IRecordAttachmentScanner scanner, IUnitOfWork unitOfWork) - { _reports = reports; _attachments = attachments; _revisions = revisions; _audits = audits; _authorization = authorization; _scanner = scanner; _unitOfWork = unitOfWork; } + IRmsAccessAuditsRepository audits, IRecordsAuthorizationService authorization, IRecordAttachmentScanner scanner, IUnitOfWork unitOfWork, + IRecordsProtectionService protection, IDomainEventOutboxService outbox) + { _reports = reports; _attachments = attachments; _revisions = revisions; _audits = audits; _authorization = authorization; _scanner = scanner; _unitOfWork = unitOfWork; _protection = protection; _outbox = outbox; } private async Task Authorize(int departmentId, string userId, string reportId, bool write) { @@ -57,6 +61,10 @@ public async Task AddAsync(int departmentId, string userId, Checksum = RecordSnapshotSerializer.Checksum(clean.Data), Description = description, UploadedByUserId = userId, UploadedOn = now, ScanState = (int)scan.State, MetadataStripped = clean.MetadataStripped, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; attachment.Classification = classification; + // Never clear the object handed to a repository: in-memory stores can retain that instance. The caller gets + // plaintext metadata; the stored row may carry envelopes (ADP catalog v10). + var metadata = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(attachment)); metadata.Data = null; + long outboxId; _unitOfWork.CreateOrGetConnection(); try { @@ -64,14 +72,36 @@ public async Task AddAsync(int departmentId, string userId, if (classification != 0 && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); if (report.RowVersion != expectedVersion || !await _reports.TryBumpRowVersionAsync(departmentId, reportId, expectedVersion, cancellationToken)) throw new RecordConcurrencyException(reportId, expectedVersion, report.RowVersion); + await _protection.ProtectAttachmentAsync(departmentId, attachment, null, userId, cancellationToken); await _attachments.InsertAsync(attachment, cancellationToken, true); + var count = (await _attachments.GetMetadataForRecordAsync(departmentId, reportId))?.Count() ?? 1; + var state = (RmsRecordState)report.State; + outboxId = (await _outbox.EnqueueAsync(departmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = WorkflowTriggerEventType.RecordAttachmentAdded.ToString(), + SchemaVersion = 1, + AggregateType = IncidentReportsService.IncidentAggregate, + AggregateId = reportId, + AggregateVersion = report.RevisionCount, + Trigger = WorkflowTriggerEventType.RecordAttachmentAdded, + Payload = new Dictionary + { + ["record"] = IncidentReportsService.RecordBlock(report, null, state), + ["record_change"] = new { previous_state = state.ToString(), current_state = state.ToString(), prior_revision_id = (string)null, current_revision_id = report.CurrentRevisionId, reason_code = (string)null }, + ["attachment"] = RecordsService.AttachmentBlock(attachment, count), + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(departmentId)) + }, + CorrelationId = reportId, + OriginClient = RmsOriginClient.Web + }, cancellationToken)).DomainEventOutboxId; await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = reportId, ActorUserId = userId, Action = (int)RmsAccessAuditAction.Change, Purpose = "Incident attachment uploaded", Successful = true, OccurredOn = now, DetailJson = JsonConvert.SerializeObject(new { attachment.RmsRecordAttachmentId, attachment.Checksum, attachment.ByteSize }) }, cancellationToken, true); _unitOfWork.CommitChanges(); } catch { _unitOfWork.DiscardChanges(); throw; } - // Never clear the object handed to a repository: in-memory stores can retain that instance. - var metadata = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(attachment)); metadata.Data = null; return metadata; + await _outbox.DispatchAfterCommitAsync(new[] { outboxId }, cancellationToken); + metadata.IsProtected = attachment.IsProtected; metadata.ProtectedCatalogVersion = attachment.ProtectedCatalogVersion; + return metadata; } public async Task GetAsync(int departmentId, string userId, string reportId, string attachmentId, string revisionId = null) @@ -81,11 +111,14 @@ public async Task GetAsync(int departmentId, string userId, if (attachment == null || attachment.RecordId != reportId || revisionId == null && attachment.DeletedOn.HasValue) return null; if (attachment.ScanState != (int)RmsAttachmentScanState.Clean) throw new InvalidOperationException("The attachment has not passed scanning."); if (attachment.RequiresRestrictedAccess && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + // ADP: bytes and file name resolve for the caller's grant; without one the download fails closed. + (await _protection.RevealAttachmentsAsync(departmentId, new[] { attachment }, true)).RequireRevealed("attachment download"); if (attachment.Data == null || RecordSnapshotSerializer.Checksum(attachment.Data) != attachment.Checksum) throw new InvalidOperationException("Attachment content is unavailable or its checksum does not match."); if (revisionId != null) { var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); if (revision == null || revision.RecordId != reportId || revision.RecordKind != (int)RmsRecordKind.IncidentReport) return null; + (await _protection.RevealRevisionsAsync(departmentId, new[] { revision })).RequireRevealed("revision"); if (RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("The revision checksum does not match."); var snapshot = JsonConvert.DeserializeObject(revision.SnapshotJson); if (snapshot?.Attachments?.Any(a => a.RmsRecordAttachmentId == attachmentId && a.Checksum == attachment.Checksum) != true) return null; diff --git a/Core/Resgrid.Services/Records/IncidentReportsService.cs b/Core/Resgrid.Services/Records/IncidentReportsService.cs index 47d6c742..486dd302 100644 --- a/Core/Resgrid.Services/Records/IncidentReportsService.cs +++ b/Core/Resgrid.Services/Records/IncidentReportsService.cs @@ -68,6 +68,8 @@ public class IncidentReportsService : IIncidentReportsService private readonly IRmsRecordAttachmentsRepository _attachments; private readonly IRmsEvidenceArtifactsRepository _evidence; private readonly IRecordsEvidenceService _evidenceService; + private readonly IIncidentSourceFeedService _feeds; + private readonly IRecordsProtectionService _protection; public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceFactsRepository facts, IRmsUnitResponsesRepository units, IRmsIncidentTypesRepository types, IRmsActionTacticsRepository tactics, IRmsAidsRepository aids, IRmsLocationsRepository locations, @@ -77,8 +79,11 @@ public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceF IRmsRecordSearchProjectionsRepository projections, IDomainEventOutboxService outbox, IDepartmentSettingsService settings, IDepartmentGroupsService groups, IUserProfileService profiles, IPersonnelRolesService roles, IUnitsService unitsService, ICallsService calls, IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, INerisProfileService neris, INerisMappingService mapping, - INerisValidationService validation, IRecordsAuthorizationService authorization, IRmsRecordAttachmentsRepository attachments, IRmsEvidenceArtifactsRepository evidence, IRecordsUdfService udf, IRecordsEvidenceService evidenceService) + INerisValidationService validation, IRecordsAuthorizationService authorization, IRmsRecordAttachmentsRepository attachments, IRmsEvidenceArtifactsRepository evidence, IRecordsUdfService udf, IRecordsEvidenceService evidenceService, + IIncidentSourceFeedService feeds, IRecordsProtectionService protection) { + _protection = protection; + _feeds = feeds; _reports = reports; _facts = facts; _units = units; @@ -144,7 +149,7 @@ public async Task StartFromCallAsync(int departmentId, throw new ArgumentException($"Call {callId} does not belong to this department."); if (!await _authorization.CanReadSourceCallAsync(userId, departmentId, call)) throw new UnauthorizedAccessException("Source Call access is not authorized."); - call = await _calls.PopulateCallData(call, true, false, true, false, true, false, false, false, false) ?? call; + call = await _calls.PopulateCallData(call, true, false, true, false, true, false, false, false, true) ?? call; var now = DateTime.UtcNow; var reportId = Guid.NewGuid().ToString(); @@ -196,6 +201,12 @@ public async Task StartFromCallAsync(int departmentId, var units = await BuildUnitsFromCallAsync(report, call, facts, now); + // RMS-3 feeds: command key times and the contact/place snapshot arrive as Derived facts with their source + // named, so the officer sees a tactical proxy for what it is. The only typed prefill they make is the clear + // time, and only when dispatch never recorded one (plan section 4.2). + await AddCommandKeyTimeFactsAsync(report, call, facts, now); + await AddPreplanFactsAsync(report, call, facts, now); + var types = new List(); var mappedType = await _neris.ResolveCrosswalkAsync(departmentId, "incident_type", NerisCrosswalkSources.CallType, call.Type); if (!string.IsNullOrWhiteSpace(mappedType)) @@ -229,14 +240,21 @@ await InTransactionAsync(async () => report.UdfDefinitionId = await _udf.SaveInTransactionAsync(departmentId, userId, reportId, report.DefinitionKey, report.DefinitionVersion, null, null, cancellationToken); await _reports.UpdateAsync(report, cancellationToken, true); if (location != null) + { + await _protection.ProtectLocationAsync(departmentId, location, null, userId, cancellationToken); await _locations.InsertAsync(location, cancellationToken, true); + } foreach (var unit in units) await _units.InsertAsync(unit, cancellationToken, true); foreach (var type in types) await _types.InsertAsync(type, cancellationToken, true); + await _protection.ProtectNarrativeAsync(departmentId, narrative, null, userId, cancellationToken); await _narratives.InsertAsync(narrative, cancellationToken, true); foreach (var fact in facts) + { + await _protection.ProtectSourceFactAsync(departmentId, fact, null, userId, cancellationToken); await _facts.InsertAsync(fact, cancellationToken, true); + } var aggregate = new IncidentReportAggregate { Report = report, Location = location, Units = units, Types = types, Narrative = narrative, Facts = facts }; await RecomputeGroupScopeAsync(aggregate, authorGroup?.DepartmentGroupId, cancellationToken); @@ -297,6 +315,7 @@ public async Task BuildSnapshotAsync(int departmentId, st { var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); if (revision == null || revision.RecordId != reportId || revision.RecordKind != (int)RmsRecordKind.IncidentReport) return null; + (await _protection.RevealRevisionsAsync(departmentId, new[] { revision })).RequireRevealed("revision"); if (RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("The incident revision checksum does not match."); var frozen = JsonConvert.DeserializeObject(revision.SnapshotJson); if (frozen?.Report == null) throw new InvalidOperationException("The incident revision is incomplete."); @@ -355,6 +374,9 @@ await InTransactionAsync(async () => { await GuardVersionAsync(report, expectedRowVersion, cancellationToken); var facts = (await _facts.GetForRecordAsync(departmentId, reportId, null))?.ToList() ?? new List(); + // Corrections compare the officer's value with the stored one, so the stored one must be readable: + // editing a protected report needs the caller's grant (ADP plan 3.3 RequireStepUpForProtectedWrites). + (await _protection.RevealAsync(departmentId, new IncidentReportAggregate { Report = report, Facts = facts }, cancellationToken)).RequireRevealed("save draft"); ApplyHeader(report, input, facts, userId, now); report.UdfDefinitionId = await _udf.SaveInTransactionAsync(departmentId, userId, reportId, report.DefinitionKey, report.DefinitionVersion, report.UdfDefinitionId, input.CustomFields, cancellationToken); @@ -369,7 +391,10 @@ await InTransactionAsync(async () => var casualties = await ReplaceCasualtiesAsync(report, input.Casualties, canWriteRestricted, now, cancellationToken); var exposures = await ReplaceExposuresAsync(report, input.Exposures, now, cancellationToken); foreach (var fact in facts.Where(f => f.CorrectedOn == now)) + { + await _protection.ProtectSourceFactAsync(departmentId, fact, null, userId, cancellationToken); await _facts.UpdateAsync(fact, cancellationToken, true); + } report.DisplaySummary = BuildSummary(report, types, null); report.ModifiedOn = now; @@ -393,6 +418,7 @@ public async Task> ValidateAsync(int departmentId, stri { var report = await LoadAsync(departmentId, reportId); var aggregate = await HydrateAsync(report, null, false); + aggregate.Protection.RequireRevealed("validate"); var profile = await _neris.GetProfileAsync(departmentId); var snapshot = ToSnapshot(aggregate); @@ -519,6 +545,7 @@ await InTransactionAsync(async () => { await GuardVersionAsync(report, expectedRowVersion, cancellationToken); var draft = await HydrateAsync(report, null, false); + draft.Protection.RequireRevealed(correction ? "correct and resubmit" : isAmendment ? "finalize amendment" : "finalize"); _udf.ValidateForFinalization(draft.CustomFields); await _evidenceService.RequireInventoryCoverageAsync(departmentId, reportId, draft.Evidence); @@ -591,6 +618,7 @@ await InTransactionAsync(async () => await GuardVersionAsync(report, report.RowVersion, cancellationToken); var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, report.CurrentRevisionId); var aggregate = await HydrateAsync(report, revision.RmsRevisionId, false); + aggregate.Protection.RequireRevealed("queue submission"); var queued = await QueueSubmissionCoreAsync(report, aggregate, revision, profile, userId, now, cancellationToken); outboxIds.Add(queued.outboxId); report.ModifiedOn = now; @@ -643,6 +671,9 @@ await InTransactionAsync(async () => ModifiedOn = now, RowVersion = 1 }; + // The queued payload is a generated copy of protected content: sealed at rest, opened only by the + // worker's acknowledged egress lane (RMS plan section 5.9.4). + await _protection.ProtectSubmissionAsync(report.DepartmentId, submission, userId, cancellationToken); await _submissions.SupersedeOpenForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, submission.RmsSubmissionId, now, cancellationToken); await _submissions.InsertAsync(submission, cancellationToken, true); } @@ -872,6 +903,82 @@ private static RmsLocation BuildLocationFromCall(RmsIncidentReport report, Call } /// One unit response per dispatched unit; times come from the unit state log (App) and the dispatch row (Dispatch), each with a provenance fact. + private async Task AddCommandKeyTimeFactsAsync(RmsIncidentReport report, Call call, List facts, DateTime now) + { + IncidentCommandKeyTimes times; + try + { + times = await _feeds.GetCommandKeyTimesAsync(report.DepartmentId, call.CallId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"The command key-time feed failed for call {call.CallId}; the report starts without it."); + return; + } + if (times == null) + return; + + var id = times.IncidentCommandId ?? call.CallId.ToString(CultureInfo.InvariantCulture); + void Time(string key, DateTime? value) + { + if (value.HasValue) + facts.Add(Fact(report, key, RmsSourceKind.Derived, "IncidentCommand", "IncidentCommand", id, Iso(value), value, now)); + } + + Time(NerisFactKeys.CommandEstablished, times.EstablishedOn); + Time(NerisFactKeys.CommandFirstAssignment, times.FirstResourceAssignedOn); + Time(NerisFactKeys.CommandFirstBenchmark, times.FirstBenchmarkCompletedOn); + Time(NerisFactKeys.CommandLastBenchmark, times.LastBenchmarkCompletedOn); + Time(NerisFactKeys.CommandClosed, times.ClosedOn); + if (times.MutualAidResourceCount > 0) + facts.Add(Fact(report, NerisFactKeys.CommandMutualAid, RmsSourceKind.Derived, "IncidentCommand", "IncidentCommand", id, times.MutualAidResourceCount.ToString(CultureInfo.InvariantCulture), times.ClosedOn ?? times.EstablishedOn, now)); + var ordinal = 0; + foreach (var benchmark in times.Benchmarks ?? new List()) + { + if (benchmark?.CompletedOn == null || string.IsNullOrWhiteSpace(benchmark.Name)) + continue; + facts.Add(Fact(report, NerisFactKeys.CommandBenchmark(ordinal++), RmsSourceKind.Derived, "IncidentCommand", "TacticalObjective", id, benchmark.Name + " @ " + Iso(benchmark.CompletedOn), benchmark.CompletedOn, now)); + } + + // A dispatcher-entered clear time always wins; command close is the proxy only when dispatch never recorded one. + if (!call.ClosedOn.HasValue && !report.IncidentClearedOn.HasValue && times.ClosedOn.HasValue) + { + report.IncidentClearedOn = times.ClosedOn; + facts.Add(Fact(report, NerisFactKeys.IncidentClear, RmsSourceKind.Derived, "IncidentCommand", "IncidentCommand", id, Iso(times.ClosedOn), times.ClosedOn, now)); + } + } + + private async Task AddPreplanFactsAsync(RmsIncidentReport report, Call call, List facts, DateTime now) + { + IncidentPreplanSnapshot snapshot; + try + { + snapshot = await _feeds.GetPreplanSnapshotAsync(report.DepartmentId, call); + } + catch (Exception ex) + { + Logging.LogException(ex, $"The contact/preplan feed failed for call {call.CallId}; the report starts without it."); + return; + } + if (snapshot == null || snapshot.IsEmpty) + return; + + foreach (var contact in snapshot.Contacts) + { + var value = string.Join(" · ", new[] { contact.DisplayName, contact.ContactType, contact.CategoryName, contact.Role }.Where(v => !string.IsNullOrWhiteSpace(v))); + facts.Add(Fact(report, NerisFactKeys.PreplanContact(contact.ContactId), RmsSourceKind.Derived, "Contacts", "Contact", contact.ContactId, value, null, now)); + } + + if (snapshot.Place != null) + { + var place = snapshot.Place; + var value = string.Join(" · ", new[] { place.Name, place.TypeName, place.Address, + place.Latitude.HasValue && place.Longitude.HasValue ? place.Latitude.Value.ToString(CultureInfo.InvariantCulture) + "," + place.Longitude.Value.ToString(CultureInfo.InvariantCulture) : null } + .Where(v => !string.IsNullOrWhiteSpace(v))); + facts.Add(Fact(report, NerisFactKeys.PreplanPlace, RmsSourceKind.Derived, "Mapping", "Poi", place.PoiId.ToString(CultureInfo.InvariantCulture), value, null, now)); + } + } + private async Task> BuildUnitsFromCallAsync(RmsIncidentReport report, Call call, List facts, DateTime now) { var result = new List(); @@ -1002,13 +1109,16 @@ private static void ApplyHeader(RmsIncidentReport report, IncidentReportDraftInp private async Task ReplaceLocationAsync(RmsIncidentReport report, IncidentLocationInput input, List facts, string userId, DateTime now, CancellationToken cancellationToken) { + // The draft row keeps its identity across saves: an ADP envelope is bound to the row key, so a REDACTED + // placeholder the editor never had revealed can only be restored onto the same row. + var existing = (await _locations.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.FirstOrDefault(); await _locations.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); if (input == null) return null; var location = new RmsLocation { - RmsLocationId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId, + RmsLocationId = existing?.RmsLocationId ?? Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId, AddressText = Trim(input.AddressText), Number = Trim(input.Number), NumberPrefix = Trim(input.NumberPrefix), NumberSuffix = Trim(input.NumberSuffix), Street = Trim(input.Street), UnitValue = Trim(input.UnitValue), Municipality = Trim(input.Municipality), County = Trim(input.County), State = Trim(input.State)?.ToUpperInvariant(), PostalCode = Trim(input.PostalCode), Country = Trim(input.Country)?.ToUpperInvariant(), PlaceType = Trim(input.PlaceType), LocationUse = Trim(input.LocationUse), CrossStreet1 = Trim(input.CrossStreet1), CrossStreet2 = Trim(input.CrossStreet2), @@ -1017,6 +1127,7 @@ private async Task ReplaceLocationAsync(RmsIncidentReport report, I }; Correct(facts, NerisFactKeys.Location, location.AddressText, userId, now); Correct(facts, NerisFactKeys.Point, location.Latitude.HasValue ? $"{location.Latitude},{location.Longitude}" : null, userId, now); + await _protection.ProtectLocationAsync(report.DepartmentId, location, existing, userId, cancellationToken); await _locations.InsertAsync(location, cancellationToken, true); return location; } @@ -1125,6 +1236,7 @@ private async Task> ReplaceModulesAsync(RmsIncidentRepor if (inputs == null) return (await _modules.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); + var existingRows = (await _modules.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.OrderBy(m => m.Ordinal).ToList() ?? new List(); await _modules.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); var result = new List(); var ordinal = 0; @@ -1136,15 +1248,17 @@ private async Task> ReplaceModulesAsync(RmsIncidentRepor if (descriptor == null || descriptor.BelongsToAnalysis) continue; + var existing = existingRows.ElementAtOrDefault(ordinal); var row = new RmsIncidentModule { - RmsIncidentModuleId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RmsIncidentModuleId = existing?.RmsIncidentModuleId ?? Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId, RecordKind = (int)RmsRecordKind.IncidentReport, ModuleKind = (int)input.Kind, SchemaName = descriptor.SchemaName, ProfileVersion = profileVersion, PrimaryCode = Trim(input.PrimaryCode)?.ToUpperInvariant(), SecondaryCode = Trim(input.SecondaryCode)?.ToUpperInvariant(), Quantity = input.Quantity, QuantityUnit = Trim(input.QuantityUnit)?.ToUpperInvariant(), OccurredOn = input.OccurredOn, DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + await _protection.ProtectModuleAsync(report.DepartmentId, row, existing, null, cancellationToken); await _modules.InsertAsync(row, cancellationToken, true); result.Add(row); } @@ -1156,17 +1270,20 @@ private async Task> ReplaceResourcesAsync(RmsIncidentR if (inputs == null) return (await _resources.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); + var existingRows = (await _resources.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.OrderBy(r => r.Ordinal).ToList() ?? new List(); await _resources.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); var result = new List(); var ordinal = 0; foreach (var input in inputs.Where(i => !string.IsNullOrWhiteSpace(i.ResourceCode))) { + var existing = existingRows.ElementAtOrDefault(ordinal); var row = new RmsIncidentResource { - RmsIncidentResourceId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RmsIncidentResourceId = existing?.RmsIncidentResourceId ?? Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId, ResourceCode = input.ResourceCode.Trim().ToUpperInvariant(), Quantity = input.Quantity, Detail = Trim(input.Detail), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + await _protection.ProtectResourceAsync(report.DepartmentId, row, existing, null, cancellationToken); await _resources.InsertAsync(row, cancellationToken, true); result.Add(row); } @@ -1242,6 +1359,7 @@ private async Task> ReplaceCasualtiesAsync(RmsIncidentRe row.InjuryDetailJson = prior?.InjuryDetailJson; } + await _protection.ProtectCasualtyAsync(report.DepartmentId, row, prior, null, cancellationToken); await _casualties.InsertAsync(row, cancellationToken, true); result.Add(row); } @@ -1253,14 +1371,16 @@ private async Task> ReplaceExposuresAsync(RmsIncidentReport re if (inputs == null) return (await _exposures.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.ToList() ?? new List(); + var existingRows = (await _exposures.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.OrderBy(e => e.Ordinal).ToList() ?? new List(); await _exposures.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); var result = new List(); var ordinal = 0; foreach (var input in inputs) { + var existing = existingRows.ElementAtOrDefault(ordinal); var row = new RmsExposure { - RmsExposureId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RmsExposureId = existing?.RmsExposureId ?? Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId, LocationKind = Trim(input.LocationKind)?.ToUpperInvariant(), ItemType = Trim(input.ItemType)?.ToUpperInvariant(), DamageType = Trim(input.DamageType)?.ToUpperInvariant(), LocationUse = Trim(input.LocationUse)?.ToUpperInvariant(), @@ -1272,6 +1392,7 @@ private async Task> ReplaceExposuresAsync(RmsIncidentReport re EstimatedValue = input.EstimatedValue, EstimatedLoss = input.EstimatedLoss, CurrencyCode = Trim(input.CurrencyCode)?.ToUpperInvariant(), DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + await _protection.ProtectExposureAsync(report.DepartmentId, row, existing, null, cancellationToken); await _exposures.InsertAsync(row, cancellationToken, true); result.Add(row); } @@ -1309,13 +1430,15 @@ private async Task> ReplaceTacticsAsync(RmsIncidentReport private async Task ReplaceNarrativeAsync(RmsIncidentReport report, IncidentReportDraftInput input, DateTime now, CancellationToken cancellationToken) { + var existing = (await _narratives.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, null))?.FirstOrDefault(); await _narratives.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); var row = new RmsNarrative { - RmsNarrativeId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId, + RmsNarrativeId = existing?.RmsNarrativeId ?? Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = existing?.ProtectionId ?? Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId, Narrative = input.Narrative, ImpedimentNarrative = input.ImpedimentNarrative, OutcomeNarrative = input.OutcomeNarrative, SupplementalJson = input.SupplementalJson, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + await _protection.ProtectNarrativeAsync(report.DepartmentId, row, existing, null, cancellationToken); await _narratives.InsertAsync(row, cancellationToken, true); return row; } @@ -1334,17 +1457,18 @@ private async Task ReplaceDraftRowsFromAsync(RmsIncidentReport report, IncidentR await _narratives.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); await _facts.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); - if (source.Location != null) await _locations.InsertAsync(Copy(source.Location, l => l.RmsLocationId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); + var dept = report.DepartmentId; + if (source.Location != null) { var row = Copy(source.Location, l => l.RmsLocationId = Guid.NewGuid().ToString(), null, now); await _protection.ProtectLocationAsync(dept, row, null, null, cancellationToken); await _locations.InsertAsync(row, cancellationToken, true); } foreach (var t in source.Types) await _types.InsertAsync(Copy(t, x => x.RmsIncidentTypeId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); foreach (var u in source.Units) await _units.InsertAsync(Copy(u, x => x.RmsUnitResponseId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); foreach (var a in source.Aids) await _aids.InsertAsync(Copy(a, x => x.RmsAidId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); foreach (var t in source.Tactics) await _tactics.InsertAsync(Copy(t, x => x.RmsActionTacticId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); - if (source.Narrative != null) await _narratives.InsertAsync(Copy(source.Narrative, n => n.RmsNarrativeId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); - foreach (var f in source.Facts) await _facts.InsertAsync(Copy(f, x => x.RmsSourceFactId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); - foreach (var m in source.Modules) await _modules.InsertAsync(Copy(m, x => x.RmsIncidentModuleId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); - foreach (var r in source.Resources) await _resources.InsertAsync(Copy(r, x => x.RmsIncidentResourceId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); - foreach (var c in source.Casualties) await _casualties.InsertAsync(Copy(c, x => x.RmsCasualtyRescueId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); - foreach (var e in source.Exposures) await _exposures.InsertAsync(Copy(e, x => x.RmsExposureId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); + if (source.Narrative != null) { var row = Copy(source.Narrative, n => n.RmsNarrativeId = Guid.NewGuid().ToString(), null, now); await _protection.ProtectNarrativeAsync(dept, row, null, null, cancellationToken); await _narratives.InsertAsync(row, cancellationToken, true); } + foreach (var f in source.Facts) { var row = Copy(f, x => x.RmsSourceFactId = Guid.NewGuid().ToString(), null, now); await _protection.ProtectSourceFactAsync(dept, row, null, null, cancellationToken); await _facts.InsertAsync(row, cancellationToken, true); } + foreach (var m in source.Modules) { var row = Copy(m, x => x.RmsIncidentModuleId = Guid.NewGuid().ToString(), null, now); await _protection.ProtectModuleAsync(dept, row, null, null, cancellationToken); await _modules.InsertAsync(row, cancellationToken, true); } + foreach (var r in source.Resources) { var row = Copy(r, x => x.RmsIncidentResourceId = Guid.NewGuid().ToString(), null, now); await _protection.ProtectResourceAsync(dept, row, null, null, cancellationToken); await _resources.InsertAsync(row, cancellationToken, true); } + foreach (var c in source.Casualties) { var row = Copy(c, x => x.RmsCasualtyRescueId = Guid.NewGuid().ToString(), null, now); await _protection.ProtectCasualtyAsync(dept, row, null, null, cancellationToken); await _casualties.InsertAsync(row, cancellationToken, true); } + foreach (var e in source.Exposures) { var row = Copy(e, x => x.RmsExposureId = Guid.NewGuid().ToString(), null, now); await _protection.ProtectExposureAsync(dept, row, null, null, cancellationToken); await _exposures.InsertAsync(row, cancellationToken, true); } } #endregion @@ -1376,22 +1500,26 @@ private async Task WriteRevisionAsync(RmsIncidentReport report, Inc OriginClient = report.OriginClient, CreatedOn = now }; + // The checksum attests the plaintext snapshot; under ADP the stored column carries its envelope (plan 5.9). + await _protection.ProtectRevisionAsync(report.DepartmentId, revision, userId, cancellationToken); await _revisions.InsertAsync(revision, cancellationToken, true); await _evidence.BindDraftToRevisionAsync(report.DepartmentId, report.RmsIncidentReportId, revision.RmsRevisionId, now, cancellationToken); - // Revision-bound copies keep finalized data queryable without touching the draft rows. + // Revision-bound copies keep finalized data queryable without touching the draft rows. Each copy is a + // new row, so its cataloged columns are sealed again under the copy's own key. var id = revision.RmsRevisionId; - if (draft.Location != null) await _locations.InsertAsync(Copy(draft.Location, l => l.RmsLocationId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + var dept = report.DepartmentId; + if (draft.Location != null) { var row = Copy(draft.Location, l => l.RmsLocationId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectLocationAsync(dept, row, null, userId, cancellationToken); await _locations.InsertAsync(row, cancellationToken, true); } foreach (var t in draft.Types) await _types.InsertAsync(Copy(t, x => x.RmsIncidentTypeId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); foreach (var u in draft.Units) await _units.InsertAsync(Copy(u, x => x.RmsUnitResponseId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); foreach (var a in draft.Aids) await _aids.InsertAsync(Copy(a, x => x.RmsAidId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); foreach (var t in draft.Tactics) await _tactics.InsertAsync(Copy(t, x => x.RmsActionTacticId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); - if (draft.Narrative != null) await _narratives.InsertAsync(Copy(draft.Narrative, n => n.RmsNarrativeId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); - foreach (var f in draft.Facts) await _facts.InsertAsync(Copy(f, x => x.RmsSourceFactId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); - foreach (var m in draft.Modules) await _modules.InsertAsync(Copy(m, x => x.RmsIncidentModuleId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); - foreach (var r in draft.Resources) await _resources.InsertAsync(Copy(r, x => x.RmsIncidentResourceId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); - foreach (var c in draft.Casualties) await _casualties.InsertAsync(Copy(c, x => x.RmsCasualtyRescueId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); - foreach (var e in draft.Exposures) await _exposures.InsertAsync(Copy(e, x => x.RmsExposureId = Guid.NewGuid().ToString(), id, now), cancellationToken, true); + if (draft.Narrative != null) { var row = Copy(draft.Narrative, n => n.RmsNarrativeId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectNarrativeAsync(dept, row, null, userId, cancellationToken); await _narratives.InsertAsync(row, cancellationToken, true); } + foreach (var f in draft.Facts) { var row = Copy(f, x => x.RmsSourceFactId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectSourceFactAsync(dept, row, null, userId, cancellationToken); await _facts.InsertAsync(row, cancellationToken, true); } + foreach (var m in draft.Modules) { var row = Copy(m, x => x.RmsIncidentModuleId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectModuleAsync(dept, row, null, userId, cancellationToken); await _modules.InsertAsync(row, cancellationToken, true); } + foreach (var r in draft.Resources) { var row = Copy(r, x => x.RmsIncidentResourceId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectResourceAsync(dept, row, null, userId, cancellationToken); await _resources.InsertAsync(row, cancellationToken, true); } + foreach (var c in draft.Casualties) { var row = Copy(c, x => x.RmsCasualtyRescueId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectCasualtyAsync(dept, row, null, userId, cancellationToken); await _casualties.InsertAsync(row, cancellationToken, true); } + foreach (var e in draft.Exposures) { var row = Copy(e, x => x.RmsExposureId = Guid.NewGuid().ToString(), id, now); await _protection.ProtectExposureAsync(dept, row, null, userId, cancellationToken); await _exposures.InsertAsync(row, cancellationToken, true); } return revision; } @@ -1400,7 +1528,7 @@ private async Task WriteSignatureAsync(RmsIncidentReport report, RmsRevision rev { var profile = await _profiles.GetProfileByUserIdAsync(userId, false); var roles = await _roles.GetRolesForUserAsync(userId, report.DepartmentId); - await _signatures.InsertAsync(new RmsSignature + var signature = new RmsSignature { RmsSignatureId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, @@ -1420,7 +1548,9 @@ await _signatures.InsertAsync(new RmsSignature ArtifactChecksum = revision.Checksum, CreatedOn = now, RowVersion = 1 - }, cancellationToken, true); + }; + await _protection.ProtectSignatureAsync(report.DepartmentId, signature, userId, cancellationToken); + await _signatures.InsertAsync(signature, cancellationToken, true); } public static string AttestationStatement(string version) @@ -1480,7 +1610,10 @@ private async Task HydrateAsync(RmsIncidentReport repor if (revisionId == null && report.CurrentRevisionId != null) { var revision = await _revisions.GetByIdForDepartmentAsync(dept, report.CurrentRevisionId); - if (revision != null && revision.RecordId == id && revision.Checksum == RecordSnapshotSerializer.Checksum(revision.SnapshotJson)) + // A concealed revision (no grant) simply contributes no prior evidence to the draft view; the + // transitions that need it require a revealed aggregate before they run. + var revealed = revision == null || (await _protection.RevealRevisionsAsync(dept, new[] { revision })).RedactedFields.Count == 0; + if (revealed && revision != null && revision.RecordId == id && revision.Checksum == RecordSnapshotSerializer.Checksum(revision.SnapshotJson)) { var previous = JsonConvert.DeserializeObject(revision.SnapshotJson)?.Evidence ?? new List(); aggregate.Evidence = previous.Where(p => !aggregate.Evidence.Any(e => e.Kind == p.Kind && e.SourceEntityId == p.SourceEntityId)) @@ -1493,6 +1626,9 @@ private async Task HydrateAsync(RmsIncidentReport repor aggregate.Signatures = (await _signatures.GetForRecordAsync(dept, id))?.ToList() ?? new List(); aggregate.Revisions = (await _revisions.GetForRecordAsync(dept, id))?.ToList() ?? new List(); } + // ADP (RMS plan section 5.9): every cataloged column leaves here as plaintext for a grant-holding caller + // and as the REDACTED sentinel otherwise; the result on the aggregate drives the page banner. + aggregate.Protection = await _protection.RevealAsync(dept, aggregate); return aggregate; } @@ -1574,11 +1710,12 @@ void add(int? groupId, RmsGroupScopeAnchorType anchor) aggregate.GroupScope = scopes; } - private async Task EnqueueLifecycleEventAsync(RmsIncidentReport report, RmsRevision revision, WorkflowTriggerEventType trigger, RmsRecordState from, RmsRecordState to, string reasonCode, object submission, CancellationToken cancellationToken, object extra = null) + private async Task EnqueueLifecycleEventAsync(RmsIncidentReport report, RmsRevision revision, WorkflowTriggerEventType trigger, RmsRecordState from, RmsRecordState to, string reasonCode, object submission, CancellationToken cancellationToken, object extra = null, IDictionary blocks = null) { var payload = new Dictionary { ["record"] = RecordBlock(report, revision, to), + ["protection"] = ProtectionBlock(await SafeCatalogVersionAsync(report.DepartmentId)), ["record_change"] = new { previous_state = from.ToString(), @@ -1592,6 +1729,9 @@ private async Task EnqueueLifecycleEventAsync(RmsInciden payload["submission"] = submission; if (extra != null) payload["extra"] = extra; + if (blocks != null) + foreach (var block in blocks) + payload[block.Key] = block.Value; return await _outbox.EnqueueAsync(report.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope { @@ -1607,6 +1747,12 @@ private async Task EnqueueLifecycleEventAsync(RmsInciden }, cancellationToken); } + /// The protection.* block (plan section 5.9.3): the department protects record content at this catalog version; payloads carry header facts only, so nothing is ever redacted. + public static object ProtectionBlock(int catalogVersion) + { + return new { is_protected = catalogVersion > 0, is_redacted = false, redacted_fields = Array.Empty(), protected_catalog_version = catalogVersion }; + } + /// The record.* block for incident reports; kind and the NERIS ID distinguish it from operational records. public static object RecordBlock(RmsIncidentReport report, RmsRevision revision, RmsRecordState state) { diff --git a/Core/Resgrid.Services/Records/IncidentSourceFeedService.cs b/Core/Resgrid.Services/Records/IncidentSourceFeedService.cs new file mode 100644 index 00000000..2a70ae92 --- /dev/null +++ b/Core/Resgrid.Services/Records/IncidentSourceFeedService.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// RMS-3 source feeds for incident report prefill: the Incident Command key-time snapshot and the Call's + /// contact/place snapshot. Both are read-once captures with provenance (RMS plan sections 4.2 and 4.3); + /// neither holds a live reference into its source module, and a source that cannot be read yields + /// nothing rather than an exception, because a feed outage must never block an officer from starting a + /// report. + /// + public class IncidentSourceFeedService : IIncidentSourceFeedService + { + private readonly IIncidentReportingService _reporting; + private readonly IIncidentCommandService _commands; + private readonly IContactsService _contacts; + private readonly IMappingService _mapping; + + public IncidentSourceFeedService(IIncidentReportingService reporting, IIncidentCommandService commands, IContactsService contacts, IMappingService mapping) + { + _reporting = reporting; + _commands = commands; + _contacts = contacts; + _mapping = mapping; + } + + public async Task GetCommandKeyTimesAsync(int departmentId, int callId) + { + if (callId <= 0) + return null; + + try + { + var command = await _commands.GetCommandForCallAsync(departmentId, callId); + if (command == null || command.DepartmentId != departmentId) + return null; + + var times = await _reporting.GetIncidentTimesReportAsync(departmentId, callId); + if (times == null) + return null; + + return new IncidentCommandKeyTimes + { + CallId = callId, + IncidentCommandId = command.IncidentCommandId, + EstablishedOn = times.CommandEstablishedOn, + FirstResourceAssignedOn = times.FirstResourceAssignedOn, + FirstBenchmarkCompletedOn = times.FirstBenchmarkCompletedOn, + LastBenchmarkCompletedOn = times.LastBenchmarkCompletedOn, + ClosedOn = times.CommandClosedOn, + MutualAidResourceCount = times.MutualAidResourceCount, + Benchmarks = (times.Benchmarks ?? new List()) + .Where(b => b != null && b.CompletedOn.HasValue) + .OrderBy(b => b.CompletedOn) + .Select(b => new IncidentCommandBenchmark { Name = b.Name, CompletedOn = b.CompletedOn }) + .ToList(), + CapturedOn = DateTime.UtcNow + }; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Command key times could not be read for call {callId}; the report starts without them."); + return null; + } + } + + public async Task GetPreplanSnapshotAsync(int departmentId, Call call) + { + var snapshot = new IncidentPreplanSnapshot { CallId = call?.CallId ?? 0, CapturedOn = DateTime.UtcNow }; + if (call == null || call.DepartmentId != departmentId) + return snapshot; + + foreach (var link in (call.Contacts ?? new List()).Where(c => c != null && !string.IsNullOrWhiteSpace(c.ContactId)).OrderBy(c => c.CallContactType)) + { + try + { + if (link.DepartmentId != 0 && link.DepartmentId != departmentId) + continue; + var contact = await _contacts.GetContactByIdAsync(link.ContactId); + if (contact == null || contact.DepartmentId != departmentId) + continue; + + var name = contact.ContactType == 1 + ? contact.CompanyName + : string.Join(" ", new[] { contact.FirstName, contact.LastName }.Where(s => !string.IsNullOrWhiteSpace(s))); + if (string.IsNullOrWhiteSpace(name)) + name = contact.OtherName; + + string category = contact.Category?.Name; + if (category == null && !string.IsNullOrWhiteSpace(contact.ContactCategoryId)) + category = (await _contacts.GetContactCategoryByIdAsync(contact.ContactCategoryId))?.Name; + + snapshot.Contacts.Add(new IncidentPreplanContact + { + ContactId = contact.ContactId, + DisplayName = string.IsNullOrWhiteSpace(name) ? contact.ContactId : name.Trim(), + ContactType = contact.ContactType == 1 ? "Company" : "Person", + CategoryName = category, + Role = link.GetContactTypeName() + }); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Contact {link.ContactId} could not be read for the call {call.CallId} preplan snapshot."); + } + } + + if (call.DestinationPoiId.HasValue && call.DestinationPoiId.Value > 0) + { + try + { + var poi = await _mapping.GetDestinationPOIByIdAsync(departmentId, call.DestinationPoiId.Value); + if (poi != null) + { + var typeName = poi.Type?.Name; + if (typeName == null && poi.PoiTypeId > 0) + { + var type = await _mapping.GetTypeByIdAsync(poi.PoiTypeId); + typeName = type != null && type.DepartmentId == departmentId ? type.Name : null; + } + + snapshot.Place = new IncidentPreplanPlace + { + PoiId = poi.PoiId, + Name = poi.Name, + TypeName = typeName, + Address = poi.Address, + Latitude = poi.Latitude, + Longitude = poi.Longitude + }; + } + } + catch (Exception ex) + { + Logging.LogException(ex, $"Destination POI {call.DestinationPoiId} could not be read for the call {call.CallId} preplan snapshot."); + } + } + + return snapshot; + } + } +} diff --git a/Core/Resgrid.Services/Records/PlaintextSnapshot.cs b/Core/Resgrid.Services/Records/PlaintextSnapshot.cs new file mode 100644 index 00000000..acce321e --- /dev/null +++ b/Core/Resgrid.Services/Records/PlaintextSnapshot.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Services.Records +{ + /// + /// The plaintext of an entity's cataloged columns, taken before the ADP seam seals it in place and put back + /// after the sealed row is stored. The caller keeps working with the same instance it passed in (tests and + /// in-memory stores rely on that identity) while the database row carries the envelopes. + /// + public sealed class PlaintextSnapshot where T : class + { + private readonly T _entity; + private readonly List<(Action Set, string Value)> _values = new List<(Action, string)>(); + + private PlaintextSnapshot(T entity) + { + _entity = entity; + } + + public static PlaintextSnapshot Take(T entity, IReadOnlyDictionary Get, Action Set)> accessors) + { + var snapshot = new PlaintextSnapshot(entity); + if (entity != null) + foreach (var accessor in accessors) + snapshot._values.Add((accessor.Value.Set, accessor.Value.Get(entity))); + return snapshot; + } + + /// Puts the plaintext back on the same instance. Marker columns are left as the seam set them. + public void Restore() + { + if (_entity == null) + return; + foreach (var (set, value) in _values) + set(_entity, value); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordOperationalSummaryService.cs b/Core/Resgrid.Services/Records/RecordOperationalSummaryService.cs new file mode 100644 index 00000000..cda2d889 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordOperationalSummaryService.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// RecordOperationalSummaryV1 builder (RMS plan sections 5.1 and 4.7). Every summary is read from an + /// immutable revision snapshot, never from the working draft, so two consumers pinning the same revision + /// always receive the same facts; the checksum they pin is the revision's own. Narrative, restricted + /// sections and every protected-candidate column are simply not part of the contract, so there is no + /// projection step that could accidentally leak them. + /// + public class RecordOperationalSummaryService : IRecordOperationalSummaryService + { + private const string CursorPrefix = "ros1:"; + + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsIncidentReportsRepository _reports; + private readonly IRmsRevisionsRepository _revisions; + private readonly IRmsRecordSearchProjectionsRepository _projections; + private readonly IRecordsAuthorizationService _authorization; + + private readonly IRecordsProtectionService _protection; + + public RecordOperationalSummaryService(IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository reports, IRmsRevisionsRepository revisions, + IRmsRecordSearchProjectionsRepository projections, IRecordsAuthorizationService authorization, IRecordsProtectionService protection) + { + _protection = protection; + _records = records; + _reports = reports; + _revisions = revisions; + _projections = projections; + _authorization = authorization; + } + + public async Task GetAsync(int departmentId, string viewerUserId, string recordId, RmsRecordKind kind, string revisionId = null) + { + if (string.IsNullOrWhiteSpace(viewerUserId) || string.IsNullOrWhiteSpace(recordId)) + return null; + if (!await _authorization.IsActiveMemberAsync(viewerUserId, departmentId) || !await _authorization.CanUserViewRecordAsync(viewerUserId, recordId, departmentId)) + return null; + + return await BuildAsync(departmentId, recordId, kind, revisionId); + } + + public async Task BuildAsync(int departmentId, string recordId, RmsRecordKind kind, string revisionId = null) + { + if (string.IsNullOrWhiteSpace(recordId)) + return null; + + switch (kind) + { + case RmsRecordKind.Operational: + return await BuildOperationalAsync(departmentId, recordId, revisionId); + case RmsRecordKind.IncidentReport: + return await BuildIncidentAsync(departmentId, recordId, revisionId); + default: + return null; + } + } + + public async Task QueryAsync(int departmentId, RecordOperationalSummaryQuery query) + { + query ??= new RecordOperationalSummaryQuery(); + var take = Math.Max(1, Math.Min(RecordOperationalSummaryQuery.MaxTake, query.Take)); + var page = new RecordOperationalSummaryPage { GeneratedOn = DateTime.UtcNow }; + + var since = query.ChangedSince; + string sinceId = null; + if (!string.IsNullOrWhiteSpace(query.Cursor)) + { + if (!TryReadCursor(query.Cursor, out var cursorTime, out sinceId)) + throw new ArgumentException("The cursor is not valid for this feed.", nameof(query)); + since = cursorTime; + } + + // The projection catch-up feed is the delta source (plan 5.3): rows in (ModifiedOn, id) order, tombstones + // included, so a record voided after a consumer pinned it still arrives here as a Voided summary. + var rows = (await _projections.GetModifiedSinceAsync(departmentId, since, take + 1, sinceId))?.ToList() ?? new List(); + var hasMore = rows.Count > take; + var window = rows.Take(take).ToList(); + + foreach (var row in window) + { + if (row == null || row.IsLegacy || row.SourceType != (int)RmsSearchSourceType.Record) + continue; + var rowKind = (RmsRecordKind)row.RecordKind; + if (query.RecordKind.HasValue && rowKind != query.RecordKind.Value) + continue; + + var summary = await BuildAsync(departmentId, row.SourceId ?? row.RmsRecordSearchProjectionId, rowKind); + if (summary != null) + page.Items.Add(summary); + } + + page.HasMore = hasMore; + if (hasMore && window.Count > 0) + { + var last = window[window.Count - 1]; + page.NextCursor = Cursor(last.ModifiedOn, last.RmsRecordSearchProjectionId); + } + + return page; + } + + #region Builders + + private async Task BuildOperationalAsync(int departmentId, string recordId, string revisionId) + { + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); + if (record == null || record.DeletedOn.HasValue || record.PurgedOn.HasValue) + return null; + + var revision = await ResolveRevisionAsync(departmentId, recordId, RmsRecordKind.Operational, revisionId ?? record.CurrentRevisionId); + if (revision == null) + return null; + + var snapshot = RecordSnapshotSerializer.Deserialize(revision.SnapshotJson); + if (snapshot == null) + return null; + + var summary = new RecordOperationalSummaryV1 + { + DepartmentId = departmentId, + RecordId = recordId, + RecordKind = RmsRecordKind.Operational, + DefinitionKey = revision.DefinitionKey ?? record.DefinitionKey, + DefinitionVersion = revision.DefinitionVersion > 0 ? revision.DefinitionVersion : record.DefinitionVersion, + RecordNumber = snapshot.RecordNumber ?? record.RecordNumber, + RevisionId = revision.RmsRevisionId, + RevisionNumber = revision.RevisionNumber, + RevisionChecksum = revision.Checksum, + RevisionCreatedOn = revision.CreatedOn, + State = ((RmsRecordState)record.State).ToString(), + AmendmentOpen = record.AmendsRevisionId != null, + VoidedOn = record.VoidedOn, + CallId = snapshot.CallId, + CallNumber = snapshot.Details?.CallNumber, + StationGroupId = snapshot.StationGroupId, + StartedOn = snapshot.StartedOn ?? snapshot.Details?.ActivityOn, + EndedOn = snapshot.EndedOn, + FinalizedOn = record.FinalizedOn, + GeneratedOn = DateTime.UtcNow + }; + ApplyCorrectionStatus(summary, record.CurrentRevisionId, record.VoidedOn); + + foreach (var unit in snapshot.Units ?? new List()) + { + summary.Units.Add(new RecordOperationalSummaryUnit + { + UnitId = unit.UnitId, UnitName = unit.UnitNameSnapshot, UnitType = unit.UnitTypeSnapshot, StationGroupId = unit.StationGroupIdSnapshot, + Dispatched = unit.Dispatched, Enroute = unit.Enroute, OnScene = unit.OnScene, Released = unit.Released, InQuarters = unit.InQuarters + }); + } + + foreach (var participant in snapshot.Participants ?? new List()) + { + summary.Participants.Add(new RecordOperationalSummaryParticipant + { + UserId = participant.UserId, DisplayName = participant.DisplayNameSnapshot, UnitId = participant.UnitId, Role = participant.Role, + GroupId = participant.GroupIdSnapshot, ParticipationStart = participant.ParticipationStart, ParticipationEnd = participant.ParticipationEnd + }); + } + + return summary; + } + + private async Task BuildIncidentAsync(int departmentId, string reportId, string revisionId) + { + var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId); + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) + return null; + + var revision = await ResolveRevisionAsync(departmentId, reportId, RmsRecordKind.IncidentReport, revisionId ?? report.CurrentRevisionId); + if (revision == null) + return null; + + IncidentReportAggregate frozen; + try + { + frozen = JsonConvert.DeserializeObject(revision.SnapshotJson); + } + catch (JsonException ex) + { + Logging.LogException(ex, $"Incident revision {revision.RmsRevisionId} could not be read for a summary."); + return null; + } + var frozenReport = frozen?.Report ?? report; + + var summary = new RecordOperationalSummaryV1 + { + DepartmentId = departmentId, + RecordId = reportId, + RecordKind = RmsRecordKind.IncidentReport, + DefinitionKey = revision.DefinitionKey ?? report.DefinitionKey, + DefinitionVersion = revision.DefinitionVersion > 0 ? revision.DefinitionVersion : report.DefinitionVersion, + RecordNumber = frozenReport.RecordNumber ?? report.RecordNumber, + RevisionId = revision.RmsRevisionId, + RevisionNumber = revision.RevisionNumber, + RevisionChecksum = revision.Checksum, + RevisionCreatedOn = revision.CreatedOn, + State = ((RmsRecordState)report.State).ToString(), + AmendmentOpen = report.AmendsRevisionId != null, + VoidedOn = report.VoidedOn, + CallId = frozenReport.CallId, + IncidentNumber = frozenReport.IncidentNumber, + ReportingEntityId = frozenReport.ReportingEntityId, + ExternalIncidentId = report.NerisIncidentId, + StationGroupId = frozenReport.StationGroupId, + StartedOn = frozenReport.CallCreatedOn, + EndedOn = frozenReport.IncidentClearedOn, + FinalizedOn = report.FinalizedOn, + GeneratedOn = DateTime.UtcNow + }; + ApplyCorrectionStatus(summary, report.CurrentRevisionId, report.VoidedOn); + + foreach (var unit in frozen?.Units ?? new List()) + { + summary.Units.Add(new RecordOperationalSummaryUnit + { + UnitId = unit.UnitId, UnitName = unit.UnitNameSnapshot, UnitType = unit.UnitTypeSnapshot, StationGroupId = unit.StationGroupIdSnapshot, + Dispatched = unit.DispatchedOn, Enroute = unit.EnrouteOn, OnScene = unit.OnSceneOn, Released = unit.ClearedOn + }); + } + + return summary; + } + + private async Task ResolveRevisionAsync(int departmentId, string recordId, RmsRecordKind kind, string revisionId) + { + if (string.IsNullOrWhiteSpace(revisionId)) + return null; + + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); + if (revision == null || !string.Equals(revision.RecordId, recordId, StringComparison.Ordinal) || revision.RecordKind != (int)kind) + return null; + (await _protection.RevealRevisionsAsync(departmentId, new[] { revision })).RequireRevealed("record summary"); + if (RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) + throw new InvalidOperationException("The revision checksum does not match; the summary cannot be trusted."); + + return revision; + } + + private static void ApplyCorrectionStatus(RecordOperationalSummaryV1 summary, string currentRevisionId, DateTime? voidedOn) + { + if (voidedOn.HasValue) + { + summary.CorrectionStatus = RecordOperationalSummaryCorrectionStatus.Voided; + summary.SupersededByRevisionId = string.Equals(currentRevisionId, summary.RevisionId, StringComparison.Ordinal) ? null : currentRevisionId; + return; + } + + if (string.Equals(currentRevisionId, summary.RevisionId, StringComparison.Ordinal)) + { + summary.CorrectionStatus = RecordOperationalSummaryCorrectionStatus.Current; + return; + } + + summary.CorrectionStatus = RecordOperationalSummaryCorrectionStatus.Superseded; + summary.SupersededByRevisionId = currentRevisionId; + } + + #endregion + + #region Cursor + + public static string Cursor(DateTime modifiedOn, string recordId) + { + return CursorPrefix + modifiedOn.Ticks.ToString(CultureInfo.InvariantCulture) + ":" + Convert.ToBase64String(Encoding.UTF8.GetBytes(recordId ?? string.Empty)); + } + + public static bool TryReadCursor(string cursor, out DateTime modifiedOn, out string recordId) + { + modifiedOn = default; + recordId = null; + if (string.IsNullOrWhiteSpace(cursor) || !cursor.StartsWith(CursorPrefix, StringComparison.Ordinal)) + return false; + + var parts = cursor.Substring(CursorPrefix.Length).Split(':'); + if (parts.Length != 2 || !long.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var ticks)) + return false; + + try + { + recordId = Encoding.UTF8.GetString(Convert.FromBase64String(parts[1])); + } + catch (FormatException) + { + return false; + } + + modifiedOn = new DateTime(ticks, DateTimeKind.Utc); + return !string.IsNullOrEmpty(recordId); + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RecordsCutoverService.cs b/Core/Resgrid.Services/Records/RecordsCutoverService.cs index 625a0460..43fa4e69 100644 --- a/Core/Resgrid.Services/Records/RecordsCutoverService.cs +++ b/Core/Resgrid.Services/Records/RecordsCutoverService.cs @@ -418,7 +418,13 @@ private async Task ResolveProtectedDataPreflightAsync(int departmentId) return "NotApplicable"; var state = (DepartmentDataProtectionState)policy.State; - return state == DepartmentDataProtectionState.Disabled ? "NotApplicable" : state.ToString(); + if (state == DepartmentDataProtectionState.Disabled) + return "NotApplicable"; + // Catalog v10 seals every RMS column (RMS plan section 5.9): a department that is Enabled at that + // catalog or later runs Records under protection; anything mid-transition still blocks activation. + if (state == DepartmentDataProtectionState.Enabled && policy.CatalogVersion >= ProtectedFieldCatalog.RecordsCatalogVersion) + return "NotApplicable"; + return state.ToString(); } catch (Exception ex) { diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.Download.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.Download.cs index f85b0c4c..92eb93b5 100644 --- a/Core/Resgrid.Services/Records/RecordsDisclosureService.Download.cs +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.Download.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Resgrid.Model; +using Resgrid.Model.Services; namespace Resgrid.Services.Records { @@ -19,6 +20,7 @@ public async Task GetReviewAttachmentAsync(int departmentId var expected = review.Records.SingleOrDefault(r => r.RecordId == recordId && r.RevisionId == revisionId)?.Attachments.SingleOrDefault(a => a.AttachmentId == attachmentId); if (expected == null) return null; var file = await _attachments.GetHistoricalByIdForDepartmentAsync(departmentId, attachmentId); + if (file != null) (await _protection.RevealAttachmentsAsync(departmentId, new[] { file }, true)).RequireRevealed("disclosure attachment"); if (file == null || file.RecordId != recordId || file.Checksum != expected.Checksum || file.Data == null || file.ScanState != (int)RmsAttachmentScanState.Clean || RecordSnapshotSerializer.Checksum(file.Data) != file.Checksum) return null; if (file.RequiresRestrictedAccess && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); await RequireDisclosureAsync(departmentId, userId); diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs index 46df078c..dd60c046 100644 --- a/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs @@ -40,6 +40,7 @@ async Task Add(string id, string number, string definition, string revisionId, J if (revisionId != null) { var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); + if (revision != null) (await _protection.RevealRevisionsAsync(departmentId, new[] { revision })).RequireRevealed("disclosure scope"); if (revision == null || revision.RecordId != id || revision.RecordKind != (int)kind || RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("A disclosure source failed its revision integrity check."); var saved = JObject.Parse(revision.SnapshotJson); header = kind != RmsRecordKind.Operational ? (JObject)saved["Report"] ?? header : saved; } @@ -73,7 +74,12 @@ async Task Add(string id, string number, string definition, string revisionId, J var analysis = await _analyses.GetForReportAsync(departmentId, r.RmsIncidentReportId); if (analysis == null || analysis.DeletedOn.HasValue) continue; var parentHeader = JObject.FromObject(r); - if (r.CurrentRevisionId != null) parentHeader = (JObject)JObject.Parse((await _revisions.GetByIdForDepartmentAsync(departmentId, r.CurrentRevisionId)).SnapshotJson)["Report"] ?? parentHeader; + if (r.CurrentRevisionId != null) + { + var parentRevision = await _revisions.GetByIdForDepartmentAsync(departmentId, r.CurrentRevisionId); + (await _protection.RevealRevisionsAsync(departmentId, new[] { parentRevision })).RequireRevealed("disclosure scope"); + parentHeader = (JObject)JObject.Parse(parentRevision.SnapshotJson)["Report"] ?? parentHeader; + } await Add(analysis.RmsIncidentAnalysisId, (r.RecordNumber ?? r.DraftReference) + " · analysis", RmsDefinitionKeys.NerisIncidentReport, analysis.CurrentRevisionId, parentHeader, false, RmsRecordKind.IncidentAnalysis); } if (page.Count < 250 || result.Truncated) break; @@ -156,6 +162,7 @@ public async Task ProduceAsync(int departmentId, string metadata.Replace(new JObject { ["Withheld"] = true }); continue; } var file = await _attachments.GetHistoricalByIdForDepartmentAsync(departmentId, id); + if (file != null) (await _protection.RevealAttachmentsAsync(departmentId, new[] { file }, true, cancellationToken)).RequireRevealed("disclosure production"); if (file == null || file.RecordId != expected.RecordId || file.Checksum != fileDecision.Checksum || file.ScanState != (int)RmsAttachmentScanState.Clean || file.Data == null || RecordSnapshotSerializer.Checksum(file.Data) != file.Checksum) throw new InvalidOperationException("A reviewed attachment is unavailable, changed, or has not passed scanning."); if (file.RequiresRestrictedAccess && !restricted) throw new UnauthorizedAccessException(); byte[] releasedBytes = file.Data; var releasedName = (string)metadata["FileName"]; var releasedType = (string)metadata["ContentType"]; var releasedChecksum = file.Checksum; @@ -195,6 +202,9 @@ public async Task ProduceAsync(int departmentId, string var production = new RmsDisclosureProduction { RmsDisclosureProductionId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), DisclosureRequestId = requestId, RedactionProfile = profile, ProducedSetJson = produced.ToString(Formatting.None), ArtifactJson = json, Checksum = RecordSnapshotSerializer.Checksum(json), ByteSize = Encoding.UTF8.GetByteCount(json), RecordCount = produced.Count, WithheldFieldsJson = JsonConvert.SerializeObject(withheld), WithheldFieldCount = withheld.Count, PreparedByUserId = userId, PreparedOn = now, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + // The row is sealed in place for storage (ADP catalog v10) and the caller gets its plaintext back afterwards. + var plaintext = PlaintextSnapshot.Take(production, RmsProtectedFields.DisclosureProductions); + var outboxIds = new List(); await InTransactionAsync(async () => { await RequireDisclosureAsync(departmentId, userId); @@ -203,11 +213,15 @@ await InTransactionAsync(async () => if (finalReview.ScopeChecksum != current.ScopeChecksum || finalReview.Records.Any(r => !current.Records.Any(c => c.RecordId == r.RecordId && c.RevisionId == r.RevisionId && c.ContentChecksum == r.ContentChecksum))) throw new InvalidOperationException("The scope or access changed during production. Reload the review."); await GuardRequestAsync(request, request.RowVersion, cancellationToken); production.ProductionNumber = await _productions.GetMaxProductionNumberAsync(departmentId, requestId) + 1; + await _protection.ProtectDisclosureProductionAsync(departmentId, production, userId, cancellationToken); await _productions.InsertAsync(production, cancellationToken, true); request.State = (int)RmsDisclosureState.Produced; request.ModifiedOn = now; request.ModifiedByUserId = userId; request.RowVersion++; await _requests.UpdateAsync(request, cancellationToken, true); + outboxIds.Add(await EnqueueDisclosureAsync(request, production, WorkflowTriggerEventType.RecordDisclosureProduced, cancellationToken)); foreach (var record in current.Records) await AuditAsync(departmentId, userId, record.RecordId, RmsAccessAuditAction.Export, "Disclosure production reviewed", new { production.RmsDisclosureProductionId, production.Checksum }, cancellationToken); }); + plaintext.Restore(); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return production; } diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs index b0c19506..e939aa83 100644 --- a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json.Linq; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; @@ -38,12 +39,17 @@ public partial class RecordsDisclosureService : IRecordsDisclosureService private readonly IRecordsUdfService _udf; private readonly IDepartmentSettingsService _settings; private readonly IUnitOfWork _unitOfWork; + private readonly IRecordsProtectionService _protection; + private readonly IDomainEventOutboxService _outbox; public RecordsDisclosureService(IRmsDisclosureRequestsRepository requests, IRmsDisclosureProductionsRepository productions, IRmsOperationalRecordsRepository records, IRmsRevisionsRepository revisions, IRmsAccessAuditsRepository audits, IRecordsAuthorizationService authorization, IDepartmentSettingsService settings, IUnitOfWork unitOfWork, - IRmsIncidentReportsRepository reports, IRecordsDocumentService documents, IRmsRecordAttachmentsRepository attachments, Resgrid.Model.Providers.IPdfProvider pdf, IRmsIncidentAnalysesRepository analyses, IRecordAttachmentScanner scanner, IRecordsUdfService udf) + IRmsIncidentReportsRepository reports, IRecordsDocumentService documents, IRmsRecordAttachmentsRepository attachments, Resgrid.Model.Providers.IPdfProvider pdf, IRmsIncidentAnalysesRepository analyses, IRecordAttachmentScanner scanner, IRecordsUdfService udf, + IRecordsProtectionService protection, IDomainEventOutboxService outbox) { + _protection = protection; + _outbox = outbox; _requests = requests; _productions = productions; _records = records; @@ -81,21 +87,83 @@ public async Task CreateRequestAsync(int departmentId, str request.ModifiedByUserId = userId; request.RowVersion = 1; + // The row is sealed in place for storage (ADP catalog v10) and the caller gets its plaintext back afterwards. + var plaintext = PlaintextSnapshot.Take(request, RmsProtectedFields.DisclosureRequests); + var outboxIds = new List(); await InTransactionAsync(async () => { request.RequestNumber = await AllocateNumberAsync(departmentId, receivedOn); + await _protection.ProtectDisclosureRequestAsync(departmentId, request, null, userId, cancellationToken); await _requests.InsertAsync(request, cancellationToken, true); + outboxIds.Add(await EnqueueDisclosureAsync(request, null, WorkflowTriggerEventType.RecordDisclosureRequested, cancellationToken)); await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclosure request logged", new { request.RmsDisclosureRequestId, request.RequestNumber, request.StatutoryDueOn, request.JurisdictionProfile }, cancellationToken); }); - + plaintext.Restore(); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return request; } + /// + /// disclosure.* (triggers 152-155): request identity, clock, profiles, assignment and counts, plus the + /// production's identity and checksum when one is involved. Never the requester, the scope narrative or + /// the packet (plan section 5.6). + /// + private async Task EnqueueDisclosureAsync(RmsDisclosureRequest request, RmsDisclosureProduction production, WorkflowTriggerEventType trigger, CancellationToken cancellationToken) + { + var entry = await _outbox.EnqueueAsync(request.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = trigger.ToString(), + SchemaVersion = 1, + AggregateType = "RmsDisclosureRequest", + AggregateId = request.RmsDisclosureRequestId, + AggregateVersion = (int)request.RowVersion, + Trigger = trigger, + Payload = new Dictionary + { + ["record"] = new { id = (string)null, kind = "Disclosure", department_id = request.DepartmentId, state = ((RmsDisclosureState)request.State).ToString() }, + ["disclosure"] = DisclosureBlock(request, production), + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(request.DepartmentId)) + }, + CorrelationId = request.RmsDisclosureRequestId, + OriginClient = RmsOriginClient.Web + }, cancellationToken); + return entry.DomainEventOutboxId; + } + + public static object DisclosureBlock(RmsDisclosureRequest request, RmsDisclosureProduction production) + { + return new + { + request_id = request.RmsDisclosureRequestId, + request_number = request.RequestNumber, + state = ((RmsDisclosureState)request.State).ToString(), + received_on = request.ReceivedOn, + statutory_due_on = request.StatutoryDueOn, + jurisdiction_profile = request.JurisdictionProfile, + redaction_profile = request.RedactionProfile, + assigned_to_user_id = request.AssignedToUserId, + closed_on = request.ClosedOn, + closed_by_user_id = request.ClosedByUserId, + disposition = request.ClosedOn.HasValue ? ((RmsDisclosureState)request.State).ToString() : null, + production_id = production?.RmsDisclosureProductionId, + production_number = production?.ProductionNumber, + record_count = production?.RecordCount, + withheld_field_count = production?.WithheldFieldCount, + checksum = production?.Checksum, + byte_size = production?.ByteSize, + prepared_on = production?.PreparedOn, + released_on = production?.ReleasedOn, + released_by_user_id = production?.ReleasedByUserId, + delivery_method = production?.DeliveryMethod + }; + } + public async Task GetAsync(int departmentId, string userId, string requestId) { await RequireDisclosureAsync(departmentId, userId); var row = await _requests.GetByIdForDepartmentAsync(departmentId, requestId); + if (row != null) await _protection.RevealDisclosureRequestsAsync(departmentId, new[] { row }); var restricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); await RequireDisclosureAsync(departmentId, userId); return row?.DeletedOn == null ? ProjectRequest(row, restricted) : null; @@ -106,6 +174,7 @@ public async Task> QueryAsync(int departmentId, strin await RequireDisclosureAsync(departmentId, userId); var stateValues = states?.Select(s => (int)s).ToList(); var rows = (await _requests.GetForDepartmentAsync(departmentId, stateValues, skip, take))?.ToList() ?? new List(); + await _protection.RevealDisclosureRequestsAsync(departmentId, rows); var restricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); await RequireDisclosureAsync(departmentId, userId); return rows.Select(r => ProjectRequest(r, restricted)).ToList(); @@ -140,13 +209,18 @@ public async Task SaveScopeAsync(int departmentId, string request.ModifiedByUserId = userId; request.RowVersion += 1; + // The loaded row carries envelopes for the requester columns and plaintext for the scope the custodian + // just typed; sealing in place leaves the envelopes alone and seals the new text. + var plaintext = PlaintextSnapshot.Take(request, RmsProtectedFields.DisclosureRequests); await InTransactionAsync(async () => { await GuardRequestAsync(request, request.RowVersion - 1, cancellationToken); + await _protection.ProtectDisclosureRequestAsync(departmentId, request, null, userId, cancellationToken); await _requests.UpdateAsync(request, cancellationToken, true); await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclosure scope saved", new { requestId, request.RedactionProfile }, cancellationToken); }); - + plaintext.Restore(); + await _protection.RevealDisclosureRequestsAsync(departmentId, new[] { request }); return request; } @@ -164,6 +238,7 @@ public async Task ReleaseAsync(int departmentId, string RequireOpen(request); var unresolved = (bool?)JObject.Parse(production.ArtifactJson)["scope_fully_resolved"] == false; var now = DateTime.UtcNow; + var outboxIds = new List(); await InTransactionAsync(async () => { @@ -185,10 +260,12 @@ await InTransactionAsync(async () => request.ModifiedByUserId = userId; request.RowVersion += 1; await _requests.UpdateAsync(request, cancellationToken, true); + outboxIds.Add(await EnqueueDisclosureAsync(request, production, WorkflowTriggerEventType.RecordDisclosureReleased, cancellationToken)); await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Share, "Disclosure released", new { request.RmsDisclosureRequestId, request.RequestNumber, production.RmsDisclosureProductionId, production.Checksum, deliveryMethod = deliveryMethod.Trim(), deliveryReference = deliveryReference.Trim(), unresolvedScope = unresolved }, cancellationToken); }); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return production; } @@ -199,7 +276,10 @@ public async Task> GetProductionsAsync(int departm var visible = new List(); foreach (var row in (await _productions.GetForRequestAsync(departmentId, requestId)) ?? Enumerable.Empty()) { - var authorized = await GetAuthorizedProductionAsync(departmentId, userId, row.RmsDisclosureProductionId); + RmsDisclosureProduction authorized; + // A sealed packet the caller cannot open is simply absent from the list; the page shows the step-up banner. + try { authorized = await GetAuthorizedProductionAsync(departmentId, userId, row.RmsDisclosureProductionId); } + catch (RecordProtectedContentException) { continue; } if (authorized?.DisclosureRequestId == requestId) visible.Add(authorized); } // Reading a later packet can outlive the permissions used for an earlier one. Re-project the @@ -218,8 +298,10 @@ public async Task GetAuthorizedProductionAsync(int depa { if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordDisclosures)) return null; var production = await _productions.GetByIdForDepartmentAsync(departmentId, productionId); - if (production == null || production.DepartmentId != departmentId || string.IsNullOrEmpty(production.ArtifactJson) - || production.Checksum != RecordSnapshotSerializer.Checksum(production.ArtifactJson)) return null; + if (production == null || production.DepartmentId != departmentId || string.IsNullOrEmpty(production.ArtifactJson)) return null; + // The packet is a generated copy of released content: sealed at rest, opened for the caller's grant (ADP). + (await _protection.RevealDisclosureProductionsAsync(departmentId, new[] { production })).RequireRevealed("disclosure production"); + if (production.Checksum != RecordSnapshotSerializer.Checksum(production.ArtifactJson)) return null; try { var artifact = JObject.Parse(production.ArtifactJson); @@ -267,13 +349,20 @@ public async Task CloseAsync(int departmentId, string user request.ModifiedByUserId = userId; request.RowVersion += 1; + var outboxIds = new List(); + var plainReason = request.DispositionReason; + var plaintext = PlaintextSnapshot.Take(request, RmsProtectedFields.DisclosureRequests); await InTransactionAsync(async () => { await GuardRequestAsync(request, request.RowVersion - 1, cancellationToken); + await _protection.ProtectDisclosureRequestAsync(departmentId, request, null, userId, cancellationToken); await _requests.UpdateAsync(request, cancellationToken, true); - await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclosure closed: " + disposition, new { requestId, reason = request.DispositionReason }, cancellationToken); + outboxIds.Add(await EnqueueDisclosureAsync(request, null, WorkflowTriggerEventType.RecordDisclosureClosed, cancellationToken)); + await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclosure closed: " + disposition, new { requestId, reason = plainReason }, cancellationToken); }); - + plaintext.Restore(); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + await _protection.RevealDisclosureRequestsAsync(departmentId, new[] { request }); return request; } @@ -283,6 +372,7 @@ public async Task VerifyProductionAsync(int departmentId, string productio if (production == null || string.IsNullOrWhiteSpace(production.Checksum)) return false; + (await _protection.RevealDisclosureProductionsAsync(departmentId, new[] { production })).RequireRevealed("disclosure verification"); return string.Equals(production.Checksum, RecordSnapshotSerializer.Checksum(production.ArtifactJson ?? string.Empty), StringComparison.Ordinal); } diff --git a/Core/Resgrid.Services/Records/RecordsDocumentService.cs b/Core/Resgrid.Services/Records/RecordsDocumentService.cs index 66d976e8..5e0d4582 100644 --- a/Core/Resgrid.Services/Records/RecordsDocumentService.cs +++ b/Core/Resgrid.Services/Records/RecordsDocumentService.cs @@ -29,10 +29,13 @@ public sealed class RecordsDocumentService : IRecordsDocumentService private readonly IPdfProvider _pdf; private readonly IRecordsEvidenceService _evidence; private readonly IRecordsUdfService _udf; + private readonly IRecordsProtectionService _protection; public RecordsDocumentService(IRecordsAuthorizationService authorization, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository reports, IRmsIncidentAnalysesRepository analyses, IRmsRevisionsRepository revisions, IIncidentReportsService incidents, - IDepartmentProfileMediaService branding, IRecordsPrintLayoutService layouts, IPdfProvider pdf, IRecordsEvidenceService evidence, IRecordsUdfService udf) - { _authorization = authorization; _records = records; _reports = reports; _analyses = analyses; _revisions = revisions; _incidents = incidents; _branding = branding; _layouts = layouts; _pdf = pdf; _evidence = evidence; _udf = udf; } + IDepartmentProfileMediaService branding, IRecordsPrintLayoutService layouts, IPdfProvider pdf, IRecordsEvidenceService evidence, IRecordsUdfService udf, + IRecordsProtectionService protection) + { + _protection = protection; _authorization = authorization; _records = records; _reports = reports; _analyses = analyses; _revisions = revisions; _incidents = incidents; _branding = branding; _layouts = layouts; _pdf = pdf; _evidence = evidence; _udf = udf; } public async Task GetAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, string revisionId = null, bool exporting = false) { @@ -63,6 +66,7 @@ public async Task GetAsync(int departmentId, string userId, stri if (string.IsNullOrWhiteSpace(revisionId)) return null; var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); if (revision == null || revision.RecordId != recordId || revision.RecordKind != (int)kind) return null; + (await _protection.RevealRevisionsAsync(departmentId, new[] { revision })).RequireRevealed("record document"); if (RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("The revision checksum does not match."); JObject content; if (kind == RmsRecordKind.IncidentReport) diff --git a/Core/Resgrid.Services/Records/RecordsEvidenceService.cs b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs index 1dbb3c0e..4f83f3a2 100644 --- a/Core/Resgrid.Services/Records/RecordsEvidenceService.cs +++ b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Events; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; @@ -33,11 +34,16 @@ public class RecordsEvidenceService : IRecordsEvidenceService private readonly IRecordsAuthorizationService _authorization; private readonly ICallsService _calls; private readonly IRmsExternalReferencesRepository _references; + private readonly IRecordsProtectionService _protection; + private readonly IDomainEventOutboxService _outbox; public RecordsEvidenceService(IRmsEvidenceArtifactsRepository artifacts, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidentReports, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork, - IEnumerable adapters, IRecordsAuthorizationService authorization, ICallsService calls, IRmsExternalReferencesRepository references) + IEnumerable adapters, IRecordsAuthorizationService authorization, ICallsService calls, IRmsExternalReferencesRepository references, + IRecordsProtectionService protection, IDomainEventOutboxService outbox) { + _protection = protection; + _outbox = outbox; _artifacts = artifacts; _records = records; _incidentReports = incidentReports; @@ -162,10 +168,14 @@ public async Task CaptureAsync(RecordEvidenceCaptureRequest RowVersion = 1 }; + // The row is sealed in place for storage (ADP catalog v10) and the caller gets its plaintext back afterwards. + var plaintext = PlaintextSnapshot.Take(artifact, RmsProtectedFields.Evidence); + var outboxIds = new List(); await InTransactionAsync(async () => { await RequireOpenRecordAsync(request, fence: true, cancellationToken); if (capture.Classification != RmsEvidenceClassification.Unrestricted && !await _authorization.HasPermissionAsync(request.CapturedByUserId, request.DepartmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + await _protection.ProtectEvidenceAsync(request.DepartmentId, artifact, request.CapturedByUserId, cancellationToken); // A re-capture of the same source supersedes rather than replaces: the earlier artifact is what an // earlier revision attested to, and deleting it would rewrite history. var current = await _artifacts.GetCurrentDraftOfKindAsync(request.DepartmentId, request.RecordId, request.Kind, artifact.SourceEntityId); @@ -179,23 +189,93 @@ await InTransactionAsync(async () => } await _artifacts.InsertAsync(artifact, cancellationToken, true); + outboxIds.Add(await EnqueueCapturedAsync(request, artifact, cancellationToken)); await AuditAsync(artifact, RmsAccessAuditAction.Change, "Evidence captured: " + request.Kind, cancellationToken); }); - + plaintext.Restore(); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return artifact; } + /// RecordEvidenceCaptured (158): the artifact's identity, source and checksum; never its manifest, title or reason. + private async Task EnqueueCapturedAsync(RecordEvidenceCaptureRequest request, RmsEvidenceArtifact artifact, CancellationToken cancellationToken) + { + object recordBlock; + string aggregateType; + int version; + if (request.RecordKind == RmsRecordKind.IncidentReport) + { + var report = await _incidentReports.GetByIdForDepartmentAsync(request.DepartmentId, request.RecordId); + recordBlock = IncidentReportsService.RecordBlock(report, null, (RmsRecordState)report.State); + aggregateType = IncidentReportsService.IncidentAggregate; + version = report.RevisionCount; + } + else + { + var record = await _records.GetByIdForDepartmentAsync(request.DepartmentId, request.RecordId); + recordBlock = RecordsService.RecordBlock(record, null, (RmsRecordState)record.State); + aggregateType = DomainEventProducers.RecordsAggregate; + version = record.RevisionCount; + } + + var entry = await _outbox.EnqueueAsync(request.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = WorkflowTriggerEventType.RecordEvidenceCaptured.ToString(), + SchemaVersion = 1, + AggregateType = aggregateType, + AggregateId = request.RecordId, + AggregateVersion = version, + Trigger = WorkflowTriggerEventType.RecordEvidenceCaptured, + Payload = new Dictionary + { + ["record"] = recordBlock, + ["evidence"] = EvidenceBlock(artifact), + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(request.DepartmentId)) + }, + CorrelationId = request.RecordId, + OriginClient = (RmsOriginClient)artifact.OriginClient + }, cancellationToken); + return entry.DomainEventOutboxId; + } + + public static object EvidenceBlock(RmsEvidenceArtifact artifact) + { + return new + { + id = artifact.RmsEvidenceArtifactId, + record_id = artifact.RecordId, + record_kind = ((RmsRecordKind)artifact.RecordKind).ToString(), + kind = ((RmsEvidenceKind)artifact.Kind).ToString(), + source_subsystem = artifact.SourceSubsystem, + source_entity_type = artifact.SourceEntityType, + source_entity_id = artifact.SourceEntityId, + classification = ((RmsEvidenceClassification)artifact.Classification).ToString(), + checksum = artifact.Checksum, + byte_size = artifact.ByteSize, + source_item_count = artifact.SourceItemCount, + coverage_start = artifact.CoverageStart, + coverage_end = artifact.CoverageEnd, + captured_by_user_id = artifact.CapturedByUserId, + captured_on = artifact.CapturedOn + }; + } + public async Task> GetHistoryAsync(int departmentId, string recordId, int skip, int take) => (await _artifacts.GetHistoryAsync(departmentId, recordId, Math.Max(0, skip), Math.Clamp(take, 1, 200)))?.ToList() ?? new List(); public async Task> GetForRecordAsync(int departmentId, string recordId, string revisionId = null, bool includeSuperseded = false) { - return (await _artifacts.GetForRecordAsync(departmentId, recordId, revisionId, includeSuperseded))?.ToList() ?? new List(); + var rows = (await _artifacts.GetForRecordAsync(departmentId, recordId, revisionId, includeSuperseded))?.ToList() ?? new List(); + await _protection.RevealEvidenceAsync(departmentId, rows); + return rows; } - public Task GetAsync(int departmentId, string artifactId) + public async Task GetAsync(int departmentId, string artifactId) { - return _artifacts.GetByIdForDepartmentAsync(departmentId, artifactId); + var row = await _artifacts.GetByIdForDepartmentAsync(departmentId, artifactId); + if (row != null) + await _protection.RevealEvidenceAsync(departmentId, new[] { row }); + return row; } public Task BindToRevisionAsync(int departmentId, string recordId, string revisionId, CancellationToken cancellationToken = default) @@ -212,6 +292,8 @@ public async Task VerifyAsync(int departmentId, string artifactId) if (artifact == null || string.IsNullOrWhiteSpace(artifact.Checksum)) return false; + // The checksum attests the plaintext manifest; verification needs it revealed. + (await _protection.RevealEvidenceAsync(departmentId, new[] { artifact })).RequireRevealed("evidence verification"); return string.Equals(artifact.Checksum, RecordSnapshotSerializer.Checksum(artifact.ManifestJson ?? string.Empty), StringComparison.Ordinal); } diff --git a/Core/Resgrid.Services/Records/RecordsExportRenderer.cs b/Core/Resgrid.Services/Records/RecordsExportRenderer.cs new file mode 100644 index 00000000..1be55cd7 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsExportRenderer.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Services.Records +{ + /// + /// Column values and file bodies for department exports. Values are strings by design: an agency import + /// sees exactly what the department reviewed, timestamps are ISO-8601 UTC, and every CSV cell is quoted and + /// guarded against spreadsheet formula injection (a leading =, +, -, @, tab or CR is prefixed with an apostrophe). + /// + public static class RecordsExportRenderer + { + public static async Task ValueAsync(string key, RecordsExportContext context, Department department, NameResolver names) + { + var record = context.Operational?.Record; + var details = context.Operational?.Details; + var report = context.Incident?.Report; + var incident = context.Incident; + + switch (key) + { + case "record.id": return context.RecordId; + case "record.kind": return record != null ? "Operational" : "IncidentReport"; + case "record.number": return record?.RecordNumber ?? report?.RecordNumber ?? record?.DraftReference ?? report?.DraftReference; + case "record.definition_key": return record?.DefinitionKey ?? report?.DefinitionKey; + case "record.type": return record?.RecordType.HasValue == true ? ((RmsOperationalRecordType)record.RecordType.Value).ToString() : report != null ? "NerisIncident" : null; + case "record.state": return record != null ? ((RmsRecordState)record.State).ToString() : report != null ? ((RmsRecordState)report.State).ToString() : null; + case "record.revision_number": return Num(record?.RevisionCount ?? report?.RevisionCount); + case "record.revision_checksum": return context.Operational?.Revisions?.FirstOrDefault(r => r.RmsRevisionId == record?.CurrentRevisionId)?.Checksum ?? incident?.Revisions?.FirstOrDefault(r => r.RmsRevisionId == report?.CurrentRevisionId)?.Checksum; + case "record.station_group_id": return Num(record?.StationGroupId ?? report?.StationGroupId); + case "record.station_group_name": return await names.GroupAsync(record?.StationGroupId ?? report?.StationGroupId); + case "record.author_user_id": return record?.AuthorUserId ?? report?.AuthorUserId; + case "record.author_name": return await names.PersonAsync(record?.AuthorUserId ?? report?.AuthorUserId); + case "record.started_on": return Iso(record?.StartedOn ?? report?.CallCreatedOn); + case "record.ended_on": return Iso(record?.EndedOn ?? report?.IncidentClearedOn); + case "record.duration_minutes": + { + var start = record?.StartedOn ?? report?.CallCreatedOn; + var end = record?.EndedOn ?? report?.IncidentClearedOn; + return start.HasValue && end.HasValue && end >= start ? Math.Round((end.Value - start.Value).TotalMinutes, 1).ToString(CultureInfo.InvariantCulture) : null; + } + case "record.created_on": return Iso(record?.CreatedOn ?? report?.CreatedOn); + case "record.finalized_on": return Iso(record?.FinalizedOn ?? report?.FinalizedOn); + case "record.external_id": return record?.ExternalId ?? report?.NerisIncidentId; + + case "call.id": return Num(record?.CallId ?? report?.CallId); + case "call.number": return details?.CallNumber ?? report?.IncidentNumber; + case "call.type": return details?.CallType ?? report?.DispatchIncidentCode; + case "call.priority": return Num(details?.CallPriority); + case "call.logged_on": return Iso(details?.CallLoggedOn ?? report?.CallCreatedOn); + case "call.name": return details?.CallName ?? report?.DisplaySummary; + case "call.address": return details?.CallAddress ?? incident?.Location?.AddressText; + case "call.nature": return details?.CallNature; + + case "participants.count": return Num(context.Operational?.Participants?.Count ?? 0); + case "participants.user_ids": return Join(context.Operational?.Participants?.Select(p => p.UserId)); + case "participants.names": + { + if (context.Operational?.Participants == null) return string.Empty; + var list = new List(); + foreach (var p in context.Operational.Participants) list.Add(string.IsNullOrWhiteSpace(p.DisplayNameSnapshot) ? await names.PersonAsync(p.UserId) : p.DisplayNameSnapshot); + return Join(list); + } + case "units.count": return Num(context.Operational?.Units?.Count ?? incident?.Units?.Count ?? 0); + case "units.names": return Join(context.Operational?.Units?.Select(u => u.UnitNameSnapshot ?? u.UnitId.ToString(CultureInfo.InvariantCulture)) ?? incident?.Units?.Select(u => u.UnitNameSnapshot ?? Convert.ToString(u.UnitId, CultureInfo.InvariantCulture))); + case "units.first_dispatched": return Iso(context.Operational?.Units?.Where(u => u.Dispatched.HasValue).Min(u => u.Dispatched) ?? incident?.Units?.Where(u => u.DispatchedOn.HasValue).Min(u => u.DispatchedOn)); + case "units.first_on_scene": return Iso(context.Operational?.Units?.Where(u => u.OnScene.HasValue).Min(u => u.OnScene) ?? incident?.Units?.Where(u => u.OnSceneOn.HasValue).Min(u => u.OnSceneOn)); + case "units.last_cleared": return Iso(context.Operational?.Units?.Where(u => u.Released.HasValue).Max(u => u.Released) ?? incident?.Units?.Where(u => u.ClearedOn.HasValue).Max(u => u.ClearedOn)); + case "attachments.count": return Num(context.Operational?.Attachments?.Count ?? incident?.Attachments?.Count ?? 0); + + case "details.type": return details?.Type; + case "details.course": return details?.Course; + case "details.course_code": return details?.CourseCode; + case "details.instructors": return details?.Instructors; + case "details.facilitator": return details?.Facilitator; + case "details.other_agencies": return details?.OtherAgencies; + case "details.other_units": return details?.OtherUnits; + case "details.unit_id": return Num(details?.UnitId); + case "details.activity_on": return Iso(details?.ActivityOn); + case "details.narrative": return details?.Narrative; + case "details.initial_report": return details?.InitialReport; + case "details.cause": return details?.Cause; + case "details.location": return details?.Location; + case "details.contact_name": return details?.ContactName; + case "details.contact_number": return details?.ContactNumber; + case "details.investigated_by_user_id": return details?.InvestigatedByUserId; + case "details.other_personnel": return details?.OtherPersonnel; + case "details.body_location": return details?.BodyLocation; + case "details.pronounced_deceased_by": return details?.PronouncedDeceasedBy; + case "details.case_number": return details?.CaseNumber; + case "details.destination": return details?.Destination; + + case "incident.number": return report?.IncidentNumber; + case "incident.reporting_entity_id": return report?.ReportingEntityId; + case "incident.neris_incident_id": return report?.NerisIncidentId; + case "incident.dispatch_code": return report?.DispatchIncidentCode; + case "incident.primary_type": return incident?.Types?.FirstOrDefault(t => t.IsPrimary)?.TypeCode ?? incident?.Types?.FirstOrDefault()?.TypeCode; + case "incident.type_codes": return Join(incident?.Types?.OrderBy(t => t.Ordinal).Select(t => t.TypeCode)); + case "incident.call_created_on": return Iso(report?.CallCreatedOn); + case "incident.cleared_on": return Iso(report?.IncidentClearedOn); + case "incident.disposition": return report?.Disposition; + case "incident.last_submission_state": return report?.LastSubmissionState.HasValue == true ? ((RmsSubmissionState)report.LastSubmissionState.Value).ToString() : null; + case "incident.aid_count": return Num(incident?.Aids?.Count ?? 0); + case "incident.tactic_codes": return Join(incident?.Tactics?.OrderBy(t => t.Ordinal).Select(t => t.TacticCode)); + case "incident.location_use": return incident?.Location?.LocationUse; + case "incident.address": return incident?.Location?.AddressText; + case "incident.narrative": return incident?.Narrative?.Narrative; + case "incident.casualty_count": return Num(incident?.Casualties?.Count ?? 0); + case "incident.exposure_count": return Num(incident?.Exposures?.Count ?? 0); + default: return null; + } + } + + public static byte[] RenderCsv(RmsExportTemplate template, IReadOnlyList columns, IReadOnlyList> rows) + { + var delimiter = string.IsNullOrEmpty(template.Delimiter) ? "," : template.Delimiter; + var builder = new StringBuilder(); + if (template.IncludeHeader) + builder.AppendLine(string.Join(delimiter, columns.Select(c => Cell(c.Key, delimiter)))); + foreach (var row in rows) + builder.AppendLine(string.Join(delimiter, columns.Select(c => Cell(row.TryGetValue(c.Key, out var v) ? v : string.Empty, delimiter)))); + // UTF-8 with BOM: the one encoding every spreadsheet an agency clerk opens reads correctly. + return Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(builder.ToString())).ToArray(); + } + + public static byte[] RenderJson(RmsExportTemplate template, IReadOnlyList columns, IReadOnlyList> rows, RecordsExportRequest request, DateTime now) + { + var payload = new + { + format = "resgrid.records.export.v1", + template = template.TemplateKey, + name = template.Name, + generated_on = now, + window_start = request.WindowStart, + window_end = request.WindowEnd, + record_id = request.RecordId, + columns = columns.Select(c => new { key = c.Key, label = c.Label }), + rows = rows.Select(r => columns.ToDictionary(c => c.Key, c => r.TryGetValue(c.Key, out var v) ? v : string.Empty)) + }; + return Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(payload, Formatting.Indented)); + } + + public static byte[] RenderPdf(IPdfProvider pdf, RmsExportTemplate template, IReadOnlyList columns, IReadOnlyList> rows, RecordsExportRequest request, Department department, DateTime now) + { + var html = new StringBuilder(); + html.Append(""); + html.Append("

").Append(WebUtility.HtmlEncode(template.Name ?? template.TemplateKey)).Append("

"); + html.Append("
").Append(WebUtility.HtmlEncode(department?.Name ?? string.Empty)); + if (request.WindowStart.HasValue || request.WindowEnd.HasValue) + html.Append(" · ").Append(WebUtility.HtmlEncode(Iso(request.WindowStart))).Append(" to ").Append(WebUtility.HtmlEncode(Iso(request.WindowEnd))); + html.Append(" · generated ").Append(WebUtility.HtmlEncode(Iso(now))).Append(" · ").Append(rows.Count).Append(" record(s)
"); + html.Append(""); + foreach (var column in columns) + html.Append(""); + html.Append(""); + foreach (var row in rows) + { + html.Append(""); + foreach (var column in columns) + html.Append(""); + html.Append(""); + } + html.Append("
").Append(WebUtility.HtmlEncode(column.Label)).Append("
").Append(WebUtility.HtmlEncode(row.TryGetValue(column.Key, out var v) ? v : string.Empty)).Append("
"); + html.Append("
Resgrid Records export · template ").Append(WebUtility.HtmlEncode(template.TemplateKey)).Append("
"); + html.Append(""); + var bytes = pdf.ConvertHtmlToPdf(html.ToString(), "Letter"); + if (bytes == null || bytes.Length < 4) + throw new InvalidOperationException("The PDF provider did not produce a document."); + return bytes; + } + + /// RFC 4180 quoting plus the spreadsheet formula guard. + public static string Cell(string value, string delimiter) + { + value ??= string.Empty; + if (value.Length > 0 && (value[0] == '=' || value[0] == '+' || value[0] == '-' || value[0] == '@' || value[0] == '\t' || value[0] == '\r')) + value = "'" + value; + var mustQuote = value.Contains(delimiter) || value.Contains('"') || value.Contains('\n') || value.Contains('\r') || value.StartsWith(" ") || value.EndsWith(" "); + return mustQuote ? "\"" + value.Replace("\"", "\"\"") + "\"" : value; + } + + private static string Iso(DateTime? value) => value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture) : null; + private static string Num(long? value) => value.HasValue ? value.Value.ToString(CultureInfo.InvariantCulture) : null; + private static string Join(IEnumerable values) => values == null ? string.Empty : string.Join("; ", values.Where(v => !string.IsNullOrWhiteSpace(v))); + } +} diff --git a/Core/Resgrid.Services/Records/RecordsExportService.cs b/Core/Resgrid.Services/Records/RecordsExportService.cs new file mode 100644 index 00000000..4f5ba107 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsExportService.cs @@ -0,0 +1,802 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Helpers; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Department-authored report exports (RMS plan sections 4.7, 4.10, 5.6 and 5.9.2). A template is a finite + /// column list from , a format, a scope and an optional schedule; a + /// render walks the authorized records, resolves every cataloged column for the caller (attended grant, or + /// the acknowledged export egress lane for the worker), and stores the bytes as an RmsExportRun. Workflow + /// steps carry the run as an attachment; agencies without an API get the file by email, FTP/SFTP or a + /// cloud drop through the executors the department already configured. + /// + public class RecordsExportService : IRecordsExportService + { + public const int RunRetentionDays = 30; + public const int MaxWindowRecords = 5000; + private static readonly Regex KeyPattern = new Regex("^[a-z0-9][a-z0-9-]{1,62}$", RegexOptions.Compiled); + private static readonly Regex FileNamePattern = new Regex(@"^[A-Za-z0-9 _.\-{}]{1,120}$", RegexOptions.Compiled); + private static readonly int[] FinalizedStates = { (int)RmsRecordState.Finalized, (int)RmsRecordState.Amended, (int)RmsRecordState.Submitted, (int)RmsRecordState.Accepted, (int)RmsRecordState.Rejected, (int)RmsRecordState.Corrected }; + + private readonly IRmsExportTemplatesRepository _templates; + private readonly IRmsExportRunsRepository _runs; + private readonly IRecordsService _records; + private readonly IIncidentReportsService _incidents; + private readonly IRmsOperationalRecordsRepository _recordsRepository; + private readonly IRmsIncidentReportsRepository _incidentsRepository; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsProtectionService _protection; + private readonly IDomainEventOutboxService _outbox; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IDepartmentsService _departments; + private readonly IDepartmentGroupsService _groups; + private readonly IUserProfileService _profiles; + private readonly IPdfProvider _pdf; + private readonly IUnitOfWork _unitOfWork; + + public RecordsExportService(IRmsExportTemplatesRepository templates, IRmsExportRunsRepository runs, IRecordsService records, IIncidentReportsService incidents, + IRmsOperationalRecordsRepository recordsRepository, IRmsIncidentReportsRepository incidentsRepository, IRecordsAuthorizationService authorization, + IRecordsProtectionService protection, IDomainEventOutboxService outbox, IRmsAccessAuditsRepository audits, IDepartmentsService departments, + IDepartmentGroupsService groups, IUserProfileService profiles, IPdfProvider pdf, IUnitOfWork unitOfWork) + { + _templates = templates; + _runs = runs; + _records = records; + _incidents = incidents; + _recordsRepository = recordsRepository; + _incidentsRepository = incidentsRepository; + _authorization = authorization; + _protection = protection; + _outbox = outbox; + _audits = audits; + _departments = departments; + _groups = groups; + _profiles = profiles; + _pdf = pdf; + _unitOfWork = unitOfWork; + } + + #region Templates + + public async Task> GetTemplatesAsync(int departmentId) + => (await _templates.GetForDepartmentAsync(departmentId))?.ToList() ?? new List(); + + public Task GetTemplateAsync(int departmentId, string templateId) + => string.IsNullOrWhiteSpace(templateId) ? Task.FromResult(null) : _templates.GetByIdForDepartmentAsync(departmentId, templateId); + + public Task GetTemplateByKeyAsync(int departmentId, string templateKey) + => string.IsNullOrWhiteSpace(templateKey) ? Task.FromResult(null) : _templates.GetByKeyAsync(departmentId, templateKey.Trim().ToLowerInvariant()); + + public async Task ValidateAsync(int departmentId, string userId, RmsExportTemplate template) + { + var result = new RecordsExportTemplateValidation(); + if (template == null) + { + result.Errors.Add("A template is required."); + return result; + } + + if (string.IsNullOrWhiteSpace(template.Name) || template.Name.Trim().Length > 200) + result.Errors.Add("Give the export a name of up to 200 characters."); + var key = (template.TemplateKey ?? string.Empty).Trim().ToLowerInvariant(); + if (!KeyPattern.IsMatch(key)) + result.Errors.Add("The key must be 2-63 lower-case letters, digits or hyphens; it is the stable name a Workflow step and an agency import rely on."); + else + { + var existing = await _templates.GetByKeyAsync(departmentId, key); + if (existing != null && existing.RmsExportTemplateId != template.RmsExportTemplateId) + result.Errors.Add("Another export already uses that key."); + } + if (!Enum.IsDefined(typeof(RmsExportFormat), template.Format)) + result.Errors.Add("Choose CSV, JSON or PDF."); + if (!Enum.IsDefined(typeof(RmsExportScope), template.Scope)) + result.Errors.Add("Choose whether the export covers the triggering record or a window of records."); + if (!Enum.IsDefined(typeof(RmsExportScheduleKind), template.ScheduleKind)) + result.Errors.Add("Choose a schedule."); + if (template.ScheduleHourLocal < 0 || template.ScheduleHourLocal > 23) + result.Errors.Add("The schedule hour must be 0-23."); + var kind = Enum.IsDefined(typeof(RmsExportScheduleKind), template.ScheduleKind) ? (RmsExportScheduleKind)template.ScheduleKind : RmsExportScheduleKind.None; + if (kind == RmsExportScheduleKind.Weekly && (template.ScheduleDayOfWeek < 0 || template.ScheduleDayOfWeek > 6)) + result.Errors.Add("The schedule weekday must be 0 (Sunday) to 6 (Saturday)."); + if (kind == RmsExportScheduleKind.Monthly && (template.ScheduleDayOfMonth < 1 || template.ScheduleDayOfMonth > 28)) + result.Errors.Add("The schedule day of month must be 1-28 so it exists in every month."); + if (template.WindowDays < 0 || template.WindowDays > 366) + result.Errors.Add("The window must be 0-366 days (0 uses the schedule period)."); + if ((RmsExportScheduleKind)template.ScheduleKind != RmsExportScheduleKind.None && (RmsExportScope)template.Scope != RmsExportScope.Window) + result.Errors.Add("A scheduled export covers a window of records; a triggering-record export runs from a Workflow instead."); + if (!string.IsNullOrWhiteSpace(template.FileNameTemplate) && !FileNamePattern.IsMatch(template.FileNameTemplate.Trim())) + result.Errors.Add("The file name may use letters, digits, spaces, dot, dash, underscore and the {template}, {date} and {record} tokens."); + if (!string.IsNullOrEmpty(template.Delimiter) && template.Delimiter != "," && template.Delimiter != ";" && template.Delimiter != "|" && template.Delimiter != "\t") + result.Errors.Add("The delimiter must be a comma, semicolon, pipe or tab."); + + var definitions = SplitCsv(template.DefinitionKeysCsv); + foreach (var definition in definitions) + { + if (definition != RmsDefinitionKeys.NerisIncidentReport && !RmsDefinitionKeys.LockedTypes.ContainsKey(definition)) + result.Errors.Add($"'{definition}' is not an available record definition."); + } + var operational = definitions.Count == 0 || definitions.Any(d => d != RmsDefinitionKeys.NerisIncidentReport); + var incident = definitions.Count == 0 || definitions.Contains(RmsDefinitionKeys.NerisIncidentReport); + + var columns = ParseColumns(template.ColumnsJson); + if (columns.Count == 0) + result.Errors.Add("Choose at least one column."); + if (columns.Count > 80) + result.Errors.Add("An export carries at most 80 columns."); + var needsNarrative = false; + var needsRestricted = false; + foreach (var column in columns) + { + var field = RecordsExportFieldCatalog.Get(column); + if (field == null) + { + result.Errors.Add($"'{column}' is not an export column."); + continue; + } + if (!field.Operational && !incident) + result.Warnings.Add($"'{column}' applies to NERIS incident reports only and will be empty for the chosen definitions."); + if (!field.Incident && !operational) + result.Warnings.Add($"'{column}' applies to operational records only and will be empty for the chosen definitions."); + if (field.Tier == RmsExportFieldTier.Narrative) needsNarrative = true; + if (field.Tier == RmsExportFieldTier.Restricted) needsRestricted = true; + } + + // Tier 2 and Tier 1 columns leave the department's boundary: they need an explicit opt-in, an egress + // acknowledgement and, for restricted, the author's own restricted grant (plan sections 4.7 and 5.9.2). + if (needsNarrative && !template.IncludeNarrative) + result.Errors.Add("Narrative-class columns need 'Include narrative and personal detail' switched on."); + if (needsRestricted && !template.IncludeRestricted) + result.Errors.Add("Restricted columns need 'Include restricted sections' switched on."); + if ((needsNarrative || needsRestricted) && !template.EgressAcknowledgedOn.HasValue) + result.Errors.Add("Acknowledge that this export sends narrative or restricted content outside Resgrid before saving it."); + if (needsRestricted && !string.IsNullOrWhiteSpace(userId) && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) + result.Errors.Add("Only a member with the restricted-records grant can author an export that carries restricted columns."); + + if ((needsNarrative || needsRestricted) && await _protection.IsEnforcedAsync(departmentId)) + result.Warnings.Add("Advanced Data Protection is enforced: protected columns are withheld (REDACTED) in every run until the export's egress acknowledgement is recorded."); + + return result; + } + + public async Task SaveAsync(int departmentId, string userId, RmsExportTemplate template, bool acknowledgeEgress, CancellationToken cancellationToken = default) + { + if (template == null) throw new ArgumentNullException(nameof(template)); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordReports)) + throw new UnauthorizedAccessException("Managing report exports requires the ManageRecordReports permission."); + + var now = DateTime.UtcNow; + var existing = string.IsNullOrWhiteSpace(template.RmsExportTemplateId) ? null : await _templates.GetByIdForDepartmentAsync(departmentId, template.RmsExportTemplateId); + var target = existing ?? new RmsExportTemplate { RmsExportTemplateId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), CreatedOn = now, CreatedByUserId = userId, RowVersion = 0 }; + + target.TemplateKey = (template.TemplateKey ?? string.Empty).Trim().ToLowerInvariant(); + target.Name = template.Name?.Trim(); + target.Description = string.IsNullOrWhiteSpace(template.Description) ? null : template.Description.Trim(); + target.Format = template.Format; + target.Scope = template.Scope; + target.DefinitionKeysCsv = string.Join(",", SplitCsv(template.DefinitionKeysCsv)); + target.ColumnsJson = JsonConvert.SerializeObject(ParseColumns(template.ColumnsJson)); + target.IncludeNarrative = template.IncludeNarrative; + target.IncludeRestricted = template.IncludeRestricted; + target.FileNameTemplate = string.IsNullOrWhiteSpace(template.FileNameTemplate) ? null : template.FileNameTemplate.Trim(); + target.IncludeHeader = template.IncludeHeader; + target.Delimiter = string.IsNullOrEmpty(template.Delimiter) ? "," : template.Delimiter; + target.ScheduleKind = template.ScheduleKind; + target.ScheduleHourLocal = template.ScheduleHourLocal; + target.ScheduleDayOfWeek = template.ScheduleDayOfWeek; + target.ScheduleDayOfMonth = template.ScheduleDayOfMonth; + target.WindowDays = template.WindowDays; + target.IsEnabled = template.IsEnabled; + target.ModifiedOn = now; + target.ModifiedByUserId = userId; + + // The acknowledgement is a recorded decision by a named member; it is never carried over silently + // when the content the export carries widens. + var carriesSensitive = ParseColumns(target.ColumnsJson).Select(RecordsExportFieldCatalog.Get).Any(f => f != null && f.Tier != RmsExportFieldTier.Safe); + if (acknowledgeEgress && carriesSensitive) + { + target.EgressAcknowledgedOn = now; + target.EgressAcknowledgedByUserId = userId; + } + else if (!carriesSensitive || !target.IncludeNarrative && !target.IncludeRestricted) + { + target.EgressAcknowledgedOn = null; + target.EgressAcknowledgedByUserId = null; + } + + var validation = await ValidateAsync(departmentId, userId, target); + if (!validation.IsValid) + throw new ArgumentException(string.Join(" ", validation.Errors)); + + target.NextRunOn = (RmsExportScheduleKind)target.ScheduleKind == RmsExportScheduleKind.None || !target.IsEnabled + ? null + : await ComputeNextRunAsync(departmentId, target, now); + target.RowVersion += 1; + + _unitOfWork.CreateOrGetConnection(); + try + { + if (existing == null) await _templates.InsertAsync(target, cancellationToken, true); + else await _templates.UpdateAsync(target, cancellationToken, true); + await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, existing == null ? "Export template created" : "Export template updated", + new { target.RmsExportTemplateId, target.TemplateKey, target.Format, target.Scope, target.IncludeNarrative, target.IncludeRestricted, target.EgressAcknowledgedOn }, cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + + return target; + } + + public async Task DeleteAsync(int departmentId, string userId, string templateId, CancellationToken cancellationToken = default) + { + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordReports)) + throw new UnauthorizedAccessException("Managing report exports requires the ManageRecordReports permission."); + var template = await _templates.GetByIdForDepartmentAsync(departmentId, templateId); + if (template == null) + return false; + + var now = DateTime.UtcNow; + template.DeletedOn = now; + template.IsEnabled = false; + template.NextRunOn = null; + template.ModifiedOn = now; + template.ModifiedByUserId = userId; + template.RowVersion += 1; + + _unitOfWork.CreateOrGetConnection(); + try + { + await _templates.UpdateAsync(template, cancellationToken, true); + await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Export template deleted", new { template.RmsExportTemplateId, template.TemplateKey }, cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + return true; + } + + #endregion + + #region Render + + public async Task RenderAsync(int departmentId, RmsExportTemplate template, RecordsExportRequest request, CancellationToken cancellationToken = default) + { + return await RenderCoreAsync(departmentId, template, request ?? new RecordsExportRequest(), true, cancellationToken); + } + + public async Task ResolveForWorkflowAsync(int departmentId, string templateId, string recordId, RmsRecordKind? recordKind, string scheduledRunId, string workflowRunId, CancellationToken cancellationToken = default) + { + var template = await _templates.GetByIdForDepartmentAsync(departmentId, templateId); + if (template == null || template.DeletedOn.HasValue) + throw new InvalidOperationException("The export template named by this step no longer exists."); + + var request = new RecordsExportRequest { Trigger = RmsExportTrigger.Record, WorkflowRunId = workflowRunId, Purpose = "Workflow export " + template.Name }; + if ((RmsExportScope)template.Scope == RmsExportScope.Window) + { + // A scheduled run is re-rendered for the step from its recorded window rather than read back from + // storage: the stored bytes are sealed under ADP and a workload never opens them (plan 5.9.2). + var stored = string.IsNullOrWhiteSpace(scheduledRunId) ? null : await _runs.GetByIdForDepartmentAsync(departmentId, scheduledRunId); + if (stored != null) + { + request.Trigger = RmsExportTrigger.Scheduled; + request.WindowStart = stored.WindowStart; + request.WindowEnd = stored.WindowEnd; + } + else + { + var window = ScheduleWindow(template, DateTime.UtcNow); + request.WindowStart = window.start; + request.WindowEnd = window.end; + } + var rendered = await RenderCoreAsync(departmentId, template, request, false, cancellationToken); + if (stored != null) + rendered.RmsExportRunId = stored.RmsExportRunId; + return rendered; + } + + if (string.IsNullOrWhiteSpace(recordId)) + throw new InvalidOperationException("This export covers the triggering record, but the event named no record."); + request.RecordId = recordId; + request.RecordKind = recordKind; + return await RenderCoreAsync(departmentId, template, request, true, cancellationToken); + } + + private async Task RenderCoreAsync(int departmentId, RmsExportTemplate template, RecordsExportRequest request, bool store, CancellationToken cancellationToken) + { + if (template == null) throw new ArgumentNullException(nameof(template)); + if (template.DepartmentId != departmentId) throw new UnauthorizedAccessException("The template belongs to another department."); + if (!string.IsNullOrWhiteSpace(request.ActingUserId) && !await _authorization.HasPermissionAsync(request.ActingUserId, departmentId, PermissionTypes.ExportRecords)) + throw new UnauthorizedAccessException("Exporting records requires the ExportRecords permission."); + + var columns = ParseColumns(template.ColumnsJson).Select(RecordsExportFieldCatalog.Get).Where(f => f != null).ToList(); + var definitions = SplitCsv(template.DefinitionKeysCsv); + var restrictedAllowed = template.IncludeRestricted && (string.IsNullOrWhiteSpace(request.ActingUserId) || await _authorization.HasPermissionAsync(request.ActingUserId, departmentId, PermissionTypes.ViewRestrictedRecords)); + var narrativeAllowed = template.IncludeNarrative; + var department = await _departments.GetDepartmentByIdAsync(departmentId, false); + var now = DateTime.UtcNow; + + var sources = await ResolveSourcesAsync(departmentId, template, request, definitions, cancellationToken); + var rows = new List>(); + var redactedFields = new SortedSet(StringComparer.Ordinal); + var withheldTiers = new SortedSet(StringComparer.Ordinal); + var names = new NameResolver(_profiles, _groups); + var exported = new List<(string RecordId, string RevisionId)>(); + + foreach (var source in sources) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!string.IsNullOrWhiteSpace(request.ActingUserId) && !await _authorization.CanUserViewRecordAsync(request.ActingUserId, source.RecordId, departmentId)) + continue; + + var context = await LoadAsync(departmentId, source, template, cancellationToken); + if (context == null) + continue; + + // ADP: the ambient reveal already ran inside the aggregate hydrate. What it withheld is re-tried on the + // export egress lane only when the template's acknowledgement is recorded; otherwise it stays REDACTED. + if (context.Protection != null && context.Protection.RedactedFields.Count > 0 && template.EgressAcknowledgedOn.HasValue) + await RevealForExportAsync(departmentId, context, cancellationToken); + if (context.Protection != null) + foreach (var field in context.Protection.RedactedFields) + redactedFields.Add(field); + + var row = new Dictionary(StringComparer.Ordinal); + foreach (var column in columns) + { + string value; + if (column.Tier == RmsExportFieldTier.Restricted && !restrictedAllowed || column.Tier == RmsExportFieldTier.Narrative && !narrativeAllowed) + { + value = ProtectedDataEnvelope.RedactionValue; + withheldTiers.Add(column.Key); + } + else + value = await RecordsExportRenderer.ValueAsync(column.Key, context, department, names); + row[column.Key] = value ?? string.Empty; + } + rows.Add(row); + exported.Add((context.RecordId, context.RevisionId)); + } + + var format = (RmsExportFormat)template.Format; + var bytes = format switch + { + RmsExportFormat.Json => RecordsExportRenderer.RenderJson(template, columns, rows, request, now), + RmsExportFormat.Pdf => RecordsExportRenderer.RenderPdf(_pdf, template, columns, rows, request, department, now), + _ => RecordsExportRenderer.RenderCsv(template, columns, rows) + }; + + var run = new RmsExportRun + { + RmsExportRunId = Guid.NewGuid().ToString(), + DepartmentId = departmentId, + ProtectionId = Guid.NewGuid().ToString(), + TemplateId = template.RmsExportTemplateId, + TemplateKey = template.TemplateKey, + Trigger = (int)request.Trigger, + RecordId = (RmsExportScope)template.Scope == RmsExportScope.TriggeringRecord ? request.RecordId : null, + WindowStart = request.WindowStart, + WindowEnd = request.WindowEnd, + RecordCount = rows.Count, + FileName = FileName(template, request, now), + ContentType = format == RmsExportFormat.Json ? "application/json" : format == RmsExportFormat.Pdf ? "application/pdf" : "text/csv", + ByteSize = bytes.LongLength, + Checksum = RecordSnapshotSerializer.Checksum(bytes), + Data = bytes, + Redacted = redactedFields.Count > 0 || withheldTiers.Count > 0, + RedactedFieldsJson = redactedFields.Count == 0 && withheldTiers.Count == 0 ? null : JsonConvert.SerializeObject(new { protected_fields = redactedFields, withheld_columns = withheldTiers }), + GeneratedOn = now, + GeneratedByUserId = request.ActingUserId, + WorkflowRunId = request.WorkflowRunId, + ExpiresOn = now.AddDays(RunRetentionDays) + }; + + if (!store) + return run; + + // The stored copy is sealed under ADP (RmsExportRuns.Data, catalog v10); the caller keeps the plaintext bytes. + var stored = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(run)); + _unitOfWork.CreateOrGetConnection(); + try + { + await _protection.ProtectExportRunAsync(departmentId, stored, request.ActingUserId, cancellationToken); + await _runs.InsertAsync(stored, cancellationToken, true); + foreach (var (recordId, revisionId) in exported) + await AuditAsync(departmentId, request.ActingUserId, recordId, RmsAccessAuditAction.Export, request.Purpose ?? ("Export " + template.Name), + new { run.RmsExportRunId, template.TemplateKey, run.Checksum, revisionId, run.Redacted, workflowRunId = request.WorkflowRunId }, cancellationToken); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + + run.IsProtected = stored.IsProtected; + run.ProtectedCatalogVersion = stored.ProtectedCatalogVersion; + return run; + } + + private sealed class Source + { + public string RecordId; + public RmsRecordKind Kind; + } + + private async Task> ResolveSourcesAsync(int departmentId, RmsExportTemplate template, RecordsExportRequest request, List definitions, CancellationToken cancellationToken) + { + var result = new List(); + if ((RmsExportScope)template.Scope == RmsExportScope.TriggeringRecord) + { + if (string.IsNullOrWhiteSpace(request.RecordId)) + return result; + var kind = request.RecordKind; + if (kind == null) + { + var record = await _recordsRepository.GetByIdForDepartmentAsync(departmentId, request.RecordId); + kind = record != null ? RmsRecordKind.Operational : RmsRecordKind.IncidentReport; + } + result.Add(new Source { RecordId = request.RecordId, Kind = kind.Value }); + return result; + } + + var end = request.WindowEnd ?? DateTime.UtcNow; + var start = request.WindowStart ?? end.AddDays(-Math.Max(1, EffectiveWindowDays(template))); + request.WindowStart = start; + request.WindowEnd = end; + + var wantOperational = definitions.Count == 0 || definitions.Any(d => d != RmsDefinitionKeys.NerisIncidentReport); + var wantIncident = definitions.Count == 0 || definitions.Contains(RmsDefinitionKeys.NerisIncidentReport); + + if (wantOperational) + { + foreach (var record in (await _recordsRepository.GetFinalizedSinceAsync(departmentId, start)) ?? Enumerable.Empty()) + { + if (record.FinalizedOn == null || record.FinalizedOn >= end || record.DeletedOn.HasValue || record.PurgedOn.HasValue) continue; + if (definitions.Count > 0 && !definitions.Contains(record.DefinitionKey)) continue; + if (RmsLifecycle.IsTerminal((RmsRecordState)record.State)) continue; + result.Add(new Source { RecordId = record.RmsOperationalRecordId, Kind = RmsRecordKind.Operational }); + if (result.Count >= MaxWindowRecords) return result; + } + } + + if (wantIncident) + { + var query = new RmsIncidentReportQuery { States = FinalizedStates.ToList(), Skip = 0, Take = 250 }; + for (var skip = 0; skip < MaxWindowRecords; skip += 250) + { + query.Skip = skip; + var page = (await _incidentsRepository.QueryAsync(departmentId, query))?.ToList() ?? new List(); + foreach (var report in page) + { + if (report.FinalizedOn == null || report.FinalizedOn < start || report.FinalizedOn >= end || report.DeletedOn.HasValue || report.PurgedOn.HasValue) continue; + result.Add(new Source { RecordId = report.RmsIncidentReportId, Kind = RmsRecordKind.IncidentReport }); + if (result.Count >= MaxWindowRecords) return result; + } + if (page.Count < 250) break; + } + } + + return result.OrderBy(s => s.RecordId, StringComparer.Ordinal).ToList(); + } + + private async Task LoadAsync(int departmentId, Source source, RmsExportTemplate template, CancellationToken cancellationToken) + { + var definitions = SplitCsv(template.DefinitionKeysCsv); + if (source.Kind == RmsRecordKind.IncidentReport) + { + var aggregate = await _incidents.GetAsync(departmentId, source.RecordId, false); + if (aggregate?.Report == null || aggregate.Report.DeletedOn.HasValue || aggregate.Report.PurgedOn.HasValue) return null; + if (definitions.Count > 0 && !definitions.Contains(RmsDefinitionKeys.NerisIncidentReport)) return null; + return new RecordsExportContext { Incident = aggregate, Protection = aggregate.Protection }; + } + + var record = await _records.GetAsync(departmentId, source.RecordId, false); + if (record?.Record == null || record.Record.DeletedOn.HasValue || record.Record.PurgedOn.HasValue) return null; + if (definitions.Count > 0 && !definitions.Contains(record.Record.DefinitionKey)) return null; + return new RecordsExportContext { Operational = record, Protection = record.Protection }; + } + + private async Task RevealForExportAsync(int departmentId, RecordsExportContext context, CancellationToken cancellationToken) + { + try + { + context.Protection = context.Incident != null + ? await _protection.RevealForWorkloadAsync(departmentId, context.Incident, RecordsProtectionService.RecordsExportPurpose, cancellationToken) + : await _protection.RevealForWorkloadAsync(departmentId, context.Operational, RecordsProtectionService.RecordsExportPurpose, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + Logging.LogException(ex, $"Export egress reveal failed for {context.RecordId}; the protected columns stay withheld."); + } + } + + #endregion + + #region Runs and schedule + + public async Task GetRunAsync(int departmentId, string runId, bool includeData) + { + var run = includeData ? await _runs.GetWithDataAsync(departmentId, runId) : await _runs.GetByIdForDepartmentAsync(departmentId, runId); + if (run == null || run.DeletedOn.HasValue || run.ExpiresOn <= DateTime.UtcNow) + return null; + (await _protection.RevealExportRunsAsync(departmentId, new[] { run }, includeData)).RequireRevealed("export download"); + return run; + } + + public async Task> GetRunsAsync(int departmentId, string templateId, int take) + { + var rows = (await _runs.GetForTemplateAsync(departmentId, templateId, take))?.Where(r => !r.DeletedOn.HasValue).ToList() ?? new List(); + await _protection.RevealExportRunsAsync(departmentId, rows, false); + return rows; + } + + public async Task RunDueSchedulesAsync(CancellationToken cancellationToken = default) + { + var result = new RecordsExportScheduleSweepResult(); + var now = DateTime.UtcNow; + var due = (await _templates.GetDueAsync(now, 100))?.ToList() ?? new List(); + foreach (var template in due) + { + cancellationToken.ThrowIfCancellationRequested(); + result.TemplatesEvaluated++; + try + { + var window = ScheduleWindow(template, template.NextRunOn ?? now); + var run = await RenderCoreAsync(template.DepartmentId, template, + new RecordsExportRequest { Trigger = RmsExportTrigger.Scheduled, WindowStart = window.start, WindowEnd = window.end, Purpose = "Scheduled export " + template.Name }, + true, cancellationToken); + result.RunsRendered++; + result.RunIds.Add(run.RmsExportRunId); + + template.LastRunOn = now; + template.NextRunOn = await ComputeNextRunAsync(template.DepartmentId, template, now); + template.ModifiedOn = now; + template.RowVersion += 1; + await _templates.UpdateAsync(template, cancellationToken, true); + await _runs.DeleteExpiredAsync(template.DepartmentId, now, cancellationToken); + + var entry = await _outbox.EnqueueAsync(template.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = WorkflowTriggerEventType.RecordExportScheduled.ToString(), + SchemaVersion = 1, + AggregateType = "RmsExportTemplate", + AggregateId = template.RmsExportTemplateId, + AggregateVersion = (int)template.RowVersion, + Trigger = WorkflowTriggerEventType.RecordExportScheduled, + Payload = new Dictionary + { + ["record"] = new { id = (string)null, kind = "Export", department_id = template.DepartmentId, state = "Rendered" }, + ["export"] = ExportBlock(template, run), + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(template.DepartmentId)) + }, + CorrelationId = run.RmsExportRunId, + OriginClient = RmsOriginClient.System + }, cancellationToken); + await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId }, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) + { + result.Errors++; + Logging.LogException(ex, $"Scheduled export {template.TemplateKey} for department {template.DepartmentId} failed."); + // Push the schedule forward so one broken template cannot wedge the sweep. + try + { + template.NextRunOn = now.AddHours(1); + template.ModifiedOn = now; + template.RowVersion += 1; + await _templates.UpdateAsync(template, cancellationToken, true); + } + catch (Exception inner) { Logging.LogException(inner); } + } + } + return result; + } + + public Task PurgeExpiredRunsAsync(int departmentId, CancellationToken cancellationToken = default) + => _runs.DeleteExpiredAsync(departmentId, DateTime.UtcNow, cancellationToken); + + /// export.* (trigger 160): the run's identity, window, size and checksum; never the rendered content. + public static object ExportBlock(RmsExportTemplate template, RmsExportRun run) + { + return new + { + run_id = run.RmsExportRunId, + template_id = template.RmsExportTemplateId, + template_key = template.TemplateKey, + template_name = template.Name, + format = ((RmsExportFormat)template.Format).ToString(), + scope = ((RmsExportScope)template.Scope).ToString(), + window_start = run.WindowStart, + window_end = run.WindowEnd, + record_count = run.RecordCount, + file_name = run.FileName, + content_type = run.ContentType, + byte_size = run.ByteSize, + checksum = run.Checksum, + redacted = run.Redacted, + generated_on = run.GeneratedOn, + expires_on = run.ExpiresOn + }; + } + + #endregion + + #region Schedule helpers + + public static int EffectiveWindowDays(RmsExportTemplate template) + { + if (template.WindowDays > 0) return template.WindowDays; + return (RmsExportScheduleKind)template.ScheduleKind switch + { + RmsExportScheduleKind.Weekly => 7, + RmsExportScheduleKind.Monthly => 31, + _ => 1 + }; + } + + /// The finalized-on window a run at covers: the period ending at the run. + public static (DateTime start, DateTime end) ScheduleWindow(RmsExportTemplate template, DateTime runAt) + { + var end = runAt; + var start = (RmsExportScheduleKind)template.ScheduleKind == RmsExportScheduleKind.Monthly && template.WindowDays == 0 ? end.AddMonths(-1) : end.AddDays(-EffectiveWindowDays(template)); + return (start, end); + } + + private async Task ComputeNextRunAsync(int departmentId, RmsExportTemplate template, DateTime utcNow) + { + string timeZone = null; + try { timeZone = (await _departments.GetDepartmentByIdAsync(departmentId, false))?.TimeZone; } + catch (Exception ex) { Logging.LogException(ex); } + return ComputeNextRun(template, utcNow, timeZone); + } + + /// Next occurrence strictly after at the department's local hour. + public static DateTime? ComputeNextRun(RmsExportTemplate template, DateTime utcNow, string timeZone) + { + var kind = (RmsExportScheduleKind)template.ScheduleKind; + if (kind == RmsExportScheduleKind.None) + return null; + + DateTime ToUtc(DateTime local) + { + if (string.IsNullOrWhiteSpace(timeZone)) return DateTime.SpecifyKind(local, DateTimeKind.Utc); + try { return DateTimeHelpers.ConvertToUtc(local, timeZone, true); } + catch { return DateTime.SpecifyKind(local, DateTimeKind.Utc); } + } + DateTime ToLocal(DateTime utc) + { + if (string.IsNullOrWhiteSpace(timeZone)) return utc; + try { return utc + (ToUtc(utc) - utc) * -1; } + catch { return utc; } + } + + var localNow = ToLocal(utcNow); + var candidate = new DateTime(localNow.Year, localNow.Month, localNow.Day, template.ScheduleHourLocal, 0, 0); + for (var i = 0; i < 400; i++) + { + var fits = kind switch + { + RmsExportScheduleKind.Daily => true, + RmsExportScheduleKind.Weekly => (int)candidate.DayOfWeek == template.ScheduleDayOfWeek, + RmsExportScheduleKind.Monthly => candidate.Day == template.ScheduleDayOfMonth, + _ => false + }; + var utc = ToUtc(candidate); + if (fits && utc > utcNow) + return utc; + candidate = candidate.AddDays(1); + } + return null; + } + + #endregion + + #region Small helpers + + public static List ParseColumns(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return new List(); + try + { + var parsed = JsonConvert.DeserializeObject>(json) ?? new List(); + return parsed.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()).Distinct(StringComparer.Ordinal).ToList(); + } + catch (JsonException) + { + return json.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(c => c.Trim()).Distinct(StringComparer.Ordinal).ToList(); + } + } + + public static List SplitCsv(string csv) + => string.IsNullOrWhiteSpace(csv) ? new List() : csv.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(v => v.Trim()).Where(v => v.Length > 0).Distinct(StringComparer.Ordinal).ToList(); + + public static string FileName(RmsExportTemplate template, RecordsExportRequest request, DateTime now) + { + var pattern = string.IsNullOrWhiteSpace(template.FileNameTemplate) ? "{template}-{date}" : template.FileNameTemplate.Trim(); + var name = pattern + .Replace("{template}", template.TemplateKey ?? "export") + .Replace("{date}", now.ToString("yyyyMMdd-HHmm", CultureInfo.InvariantCulture)) + .Replace("{record}", string.IsNullOrWhiteSpace(request.RecordId) ? "window" : request.RecordId); + var safe = new string(name.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.' || c == ' ').ToArray()).Trim(); + if (safe.Length == 0) safe = "export"; + var extension = (RmsExportFormat)template.Format switch { RmsExportFormat.Json => ".json", RmsExportFormat.Pdf => ".pdf", _ => ".csv" }; + return safe.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ? safe : safe + extension; + } + + private Task AuditAsync(int departmentId, string userId, string recordId, RmsAccessAuditAction action, string purpose, object detail, CancellationToken cancellationToken) + { + return _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, + RecordId = recordId, + Action = (int)action, + ActorUserId = userId, + Purpose = purpose, + OriginClient = (int)(string.IsNullOrWhiteSpace(userId) ? RmsOriginClient.System : RmsOriginClient.Web), + Successful = true, + OccurredOn = DateTime.UtcNow, + DetailJson = detail == null ? null : JsonConvert.SerializeObject(detail) + }, cancellationToken, true); + } + + #endregion + } + + /// One record as the renderer sees it: either aggregate, plus the ADP outcome for its columns. + public sealed class RecordsExportContext + { + public RecordAggregate Operational { get; set; } + public IncidentReportAggregate Incident { get; set; } + public ProtectedReadResult Protection { get; set; } + public string RecordId => Operational?.Record?.RmsOperationalRecordId ?? Incident?.Report?.RmsIncidentReportId; + public string RevisionId => Operational?.Record?.CurrentRevisionId ?? Incident?.Report?.CurrentRevisionId; + } + + /// Cached person and group name lookups for one render. + public sealed class NameResolver + { + private readonly IUserProfileService _profiles; + private readonly IDepartmentGroupsService _groups; + private readonly Dictionary _people = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _groupNames = new Dictionary(); + + public NameResolver(IUserProfileService profiles, IDepartmentGroupsService groups) + { + _profiles = profiles; + _groups = groups; + } + + public async Task PersonAsync(string userId) + { + if (string.IsNullOrWhiteSpace(userId)) return string.Empty; + if (_people.TryGetValue(userId, out var cached)) return cached; + string name; + try { var profile = await _profiles.GetProfileByUserIdAsync(userId, false); name = profile == null ? userId : $"{profile.FirstName} {profile.LastName}".Trim(); } + catch (Exception) { name = userId; } + return _people[userId] = string.IsNullOrWhiteSpace(name) ? userId : name; + } + + public async Task GroupAsync(int? groupId) + { + if (!groupId.HasValue) return string.Empty; + if (_groupNames.TryGetValue(groupId.Value, out var cached)) return cached; + string name; + try { name = (await _groups.GetGroupByIdAsync(groupId.Value, false))?.Name ?? groupId.Value.ToString(CultureInfo.InvariantCulture); } + catch (Exception) { name = groupId.Value.ToString(CultureInfo.InvariantCulture); } + return _groupNames[groupId.Value] = name; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs b/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs index 86bf0a24..b7a9a137 100644 --- a/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs +++ b/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; using Resgrid.Model; +using Resgrid.Model.Events; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; @@ -19,9 +20,11 @@ public sealed class RecordsLegalHoldService : IRecordsLegalHoldService private readonly IRecordsAuthorizationService _authorization; private readonly IRmsAccessAuditsRepository _audits; private readonly IUnitOfWork _unitOfWork; + private readonly IRecordsProtectionService _protection; + private readonly IDomainEventOutboxService _outbox; public RecordsLegalHoldService(IRmsRecordLegalHoldsRepository holds, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository reports, - IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork) - { _holds = holds; _records = records; _reports = reports; _authorization = authorization; _audits = audits; _unitOfWork = unitOfWork; } + IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork, IRecordsProtectionService protection, IDomainEventOutboxService outbox) + { _holds = holds; _records = records; _reports = reports; _authorization = authorization; _audits = audits; _unitOfWork = unitOfWork; _protection = protection; _outbox = outbox; } private async Task RequireAsync(int departmentId, string userId, string recordId = null) { if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordLegalHold) @@ -34,6 +37,7 @@ public async Task> GetAsync(int departmentId, string us if (hold.RecordId == null || await _authorization.CanUserViewRecordAsync(userId, hold.RecordId, departmentId)) result.Add(hold); await RequireAsync(departmentId, userId); foreach (var hold in result) await RequireAsync(departmentId, userId, hold.RecordId); + await _protection.RevealLegalHoldsAsync(departmentId, result); return result; } public async Task PlaceAsync(int departmentId, string userId, RmsRecordLegalHold input, CancellationToken cancellationToken = default) @@ -55,15 +59,24 @@ public async Task PlaceAsync(int departmentId, string userId var now = DateTime.UtcNow; var hold = new RmsRecordLegalHold { RmsRecordLegalHoldId = Guid.NewGuid().ToString(), DepartmentId = departmentId, RecordId = recordId, DefinitionKey = definition, PeriodStart = input.PeriodStart, PeriodEnd = input.PeriodEnd, Reason = input.Reason.Trim(), ReferenceNumber = input.ReferenceNumber.Trim(), Notes = input.Notes.Trim(), PlacedByUserId = userId, PlacedOn = now, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + // The row is sealed in place for storage (ADP catalog v10) and the caller gets its plaintext back afterwards. + var plaintext = PlaintextSnapshot.Take(hold, RmsProtectedFields.LegalHolds); + var notes = hold.Notes; + long outboxId; _unitOfWork.CreateOrGetConnection(); try { await RequireAsync(departmentId, userId, recordId); + await _protection.ProtectLegalHoldAsync(departmentId, hold, null, userId, cancellationToken); // Repository shares the retention department/parent lock; placement cannot race a content purge. await _holds.InsertAsync(hold, cancellationToken, true); - await AuditAsync(hold, userId, "Legal hold placed", hold.Notes, cancellationToken); _unitOfWork.CommitChanges(); return hold; + outboxId = await EnqueueAsync(hold, WorkflowTriggerEventType.RecordLegalHoldPlaced, cancellationToken); + await AuditAsync(hold, userId, "Legal hold placed", notes, cancellationToken); _unitOfWork.CommitChanges(); } catch { _unitOfWork.DiscardChanges(); throw; } + plaintext.Restore(); + await _outbox.DispatchAfterCommitAsync(new[] { outboxId }, cancellationToken); + return hold; } public async Task ReleaseAsync(int departmentId, string userId, string holdId, long expectedVersion, string reason, CancellationToken cancellationToken = default) { @@ -71,14 +84,55 @@ public async Task ReleaseAsync(int departmentId, string userId, string holdId, l if (string.IsNullOrWhiteSpace(reason) || reason.Length > 4000) throw new ArgumentException("Record the authority and reason for releasing preservation (up to 4,000 characters)."); var hold = await _holds.GetByIdForDepartmentAsync(departmentId, holdId) ?? throw new ArgumentException("The hold does not exist."); await RequireAsync(departmentId, userId, hold.RecordId); + // ReleaseNotes is a cataloged column written by a targeted UPDATE, so it is sealed on a throwaway row first. + var sealedNotes = new RmsRecordLegalHold { RmsRecordLegalHoldId = holdId, DepartmentId = departmentId, ReleaseNotes = reason.Trim() }; + await _protection.ProtectLegalHoldAsync(departmentId, sealedNotes, null, userId, cancellationToken); + var releasedOn = DateTime.UtcNow; + long outboxId; _unitOfWork.CreateOrGetConnection(); try { await RequireAsync(departmentId, userId, hold.RecordId); - if (!await _holds.TryReleaseAsync(departmentId, holdId, expectedVersion, userId, reason.Trim(), DateTime.UtcNow, cancellationToken)) throw new InvalidOperationException("The hold changed or was already released. Reload it before continuing."); + if (!await _holds.TryReleaseAsync(departmentId, holdId, expectedVersion, userId, sealedNotes.ReleaseNotes, releasedOn, cancellationToken)) throw new InvalidOperationException("The hold changed or was already released. Reload it before continuing."); + hold.ReleasedByUserId = userId; hold.ReleasedOn = releasedOn; + outboxId = await EnqueueAsync(hold, WorkflowTriggerEventType.RecordLegalHoldReleased, cancellationToken); await AuditAsync(hold, userId, "Legal hold released", reason.Trim(), cancellationToken); _unitOfWork.CommitChanges(); } catch { _unitOfWork.DiscardChanges(); throw; } + await _outbox.DispatchAfterCommitAsync(new[] { outboxId }, cancellationToken); + } + + /// legal_hold.* (triggers 156/157): scope, period, reason and actors; never the reference number or the preservation notes. + private async Task EnqueueAsync(RmsRecordLegalHold hold, WorkflowTriggerEventType trigger, CancellationToken cancellationToken) + { + object recordBlock = new { id = hold.RecordId, kind = (string)null, department_id = hold.DepartmentId }; + var aggregateType = DomainEventProducers.RecordsAggregate; + if (hold.RecordId != null) + { + var record = await _records.GetByIdForDepartmentAsync(hold.DepartmentId, hold.RecordId); + if (record != null) recordBlock = RecordsService.RecordBlock(record, null, (RmsRecordState)record.State); + else + { + var report = await _reports.GetByIdForDepartmentAsync(hold.DepartmentId, hold.RecordId); + if (report != null) { recordBlock = IncidentReportsService.RecordBlock(report, null, (RmsRecordState)report.State); aggregateType = IncidentReportsService.IncidentAggregate; } + } + } + var entry = await _outbox.EnqueueAsync(hold.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = trigger.ToString(), SchemaVersion = 1, AggregateType = aggregateType, AggregateId = hold.RecordId ?? hold.RmsRecordLegalHoldId, AggregateVersion = (int)hold.RowVersion, Trigger = trigger, + Payload = new Dictionary + { + ["record"] = recordBlock, + ["legal_hold"] = new + { + id = hold.RmsRecordLegalHoldId, record_id = hold.RecordId, definition_key = hold.DefinitionKey, period_start = hold.PeriodStart, period_end = hold.PeriodEnd, reason = hold.Reason, + placed_by_user_id = hold.PlacedByUserId, placed_on = hold.PlacedOn, released_by_user_id = hold.ReleasedByUserId, released_on = hold.ReleasedOn, is_released = hold.ReleasedOn.HasValue + }, + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(hold.DepartmentId)) + }, + CorrelationId = hold.RmsRecordLegalHoldId, OriginClient = RmsOriginClient.Web + }, cancellationToken); + return entry.DomainEventOutboxId; } private Task AuditAsync(RmsRecordLegalHold hold, string userId, string purpose, string reason, CancellationToken ct) => _audits.InsertAsync(new RmsAccessAudit { DepartmentId = hold.DepartmentId, RecordId = hold.RecordId, ActorUserId = userId, Action = (int)RmsAccessAuditAction.Admin, Successful = true, OccurredOn = DateTime.UtcNow, Purpose = purpose, DetailJson = JsonConvert.SerializeObject(new { hold.RmsRecordLegalHoldId, hold.ReferenceNumber, reason }) }, ct, true); diff --git a/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs b/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs new file mode 100644 index 00000000..4a119bb6 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsNfirsLegacyService.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// Read-only NFIRS Basic Module rendering and per-incident crosswalk (RMS plan sections 4.3 and 6, RMS-3). + /// The field set is the same representative set the existing CSV export declares; the values come from + /// where the department already holds them. Each field names the NERIS fact or section that carries it + /// going forward and, when the department's NERIS report for the Call exists, whether that report already + /// has the value — so the page is the crosswalk report, not just a rendering. Nothing is imported, + /// authored or submitted: NFIRS retired on 2026-01-31. + /// + public class RecordsNfirsLegacyService : IRecordsNfirsLegacyService + { + private const string Calls = "Calls"; + private const string UnitStates = "UnitStates"; + private const string IncidentCommand = "IncidentCommand"; + private const string NerisProfile = "NerisProfile"; + private const string Crosswalk = "Crosswalk"; + + private readonly ICallsService _calls; + private readonly IUnitsService _units; + private readonly IIncidentReportingService _reporting; + private readonly IIncidentReportsService _incidents; + private readonly IRecordsAuthorizationService _authorization; + private readonly INerisProfileService _neris; + + public RecordsNfirsLegacyService(ICallsService calls, IUnitsService units, IIncidentReportingService reporting, IIncidentReportsService incidents, + IRecordsAuthorizationService authorization, INerisProfileService neris) + { + _calls = calls; + _units = units; + _reporting = reporting; + _incidents = incidents; + _authorization = authorization; + _neris = neris; + } + + public async Task RenderAsync(int departmentId, string viewerUserId, int callId) + { + if (callId <= 0) + return null; + + var call = await _calls.GetCallByIdAsync(callId); + // CallId is guessable; another department's Call is "not found", never "forbidden". + if (call == null || call.DepartmentId != departmentId) + return null; + if (string.IsNullOrWhiteSpace(viewerUserId) || !await _authorization.CanReadSourceCallAsync(viewerUserId, departmentId, call)) + throw new UnauthorizedAccessException("Source Call access is not authorized."); + + var rendering = new NfirsLegacyRendering + { + DepartmentId = departmentId, + CallId = callId, + CallNumber = call.Number, + CallName = call.Name, + GeneratedOn = DateTime.UtcNow + }; + + var report = await TryGetReportAsync(departmentId, callId, rendering); + var times = await TryGetTimesAsync(departmentId, callId, rendering); + var firstOnScene = await TryGetFirstOnSceneAsync(departmentId, callId, rendering); + var profile = await TryGetProfileAsync(departmentId, rendering); + var mappedType = await TryResolveTypeAsync(departmentId, call.Type, rendering); + + var f = rendering.Fields; + Add(f, "A", "FDID", true, profile?.NerisEntityId, NerisProfile, null, "base.reporting_entity", report != null ? !string.IsNullOrWhiteSpace(report.Report.ReportingEntityId) : (bool?)null); + Add(f, "B", "IncidentDate", true, Utc(call.LoggedOn), Calls, NerisFactKeys.CallCreate, "dispatch", FactPopulated(report, NerisFactKeys.CallCreate)); + Add(f, "B", "Station", false, null, null, null, "unit_responses", report != null ? report.Units.Any(u => u.StationGroupIdSnapshot.HasValue) : (bool?)null, NfirsLegacyFieldStatus.NotCaptured); + Add(f, "B", "IncidentNumber", true, string.IsNullOrWhiteSpace(call.IncidentNumber) ? call.Number : call.IncidentNumber, Calls, NerisFactKeys.IncidentNumber, "dispatch", FactPopulated(report, NerisFactKeys.IncidentNumber)); + Add(f, "B", "ExposureNumber", false, "000", Calls, null, "exposures", report != null ? report.Exposures.Count > 0 : (bool?)null); + Add(f, "C", "IncidentTypeCode", true, mappedType == null ? call.Type : call.Type + " → " + mappedType, mappedType == null ? Calls : Crosswalk, NerisFactKeys.IncidentType, "incident_types", + report != null ? report.Types.Count > 0 : (bool?)null, mappedType == null ? NfirsLegacyFieldStatus.Missing : NfirsLegacyFieldStatus.Populated); + Add(f, "E1", "AlarmDateTime", true, Utc(call.LoggedOn), Calls, NerisFactKeys.CallCreate, "dispatch", FactPopulated(report, NerisFactKeys.CallCreate)); + Add(f, "E1", "ArrivalDateTime", false, Utc(firstOnScene?.Timestamp), UnitStates, firstOnScene == null ? null : NerisFactKeys.UnitTime(firstOnScene.UnitId, "on_scene"), "unit_responses", + report != null ? report.Units.Any(u => u.OnSceneOn.HasValue) : (bool?)null); + Add(f, "E1", "ControlledDateTime", false, Utc(times?.LastBenchmarkCompletedOn), IncidentCommand, NerisFactKeys.CommandLastBenchmark, "dispatch", FactPopulated(report, NerisFactKeys.CommandLastBenchmark)); + Add(f, "E1", "LastUnitClearedDateTime", false, Utc(call.ClosedOn ?? times?.CommandClosedOn), call.ClosedOn.HasValue ? Calls : IncidentCommand, NerisFactKeys.IncidentClear, "dispatch", FactPopulated(report, NerisFactKeys.IncidentClear)); + Add(f, "B", "LocationAddress", true, call.Address, Calls, NerisFactKeys.Location, "base.location", FactPopulated(report, NerisFactKeys.Location)); + Add(f, "B", "IncidentName", false, call.Name, Calls, null, null, null); + Add(f, "B", "NatureOfCall", false, call.NatureOfCall, Calls, null, "narrative", report != null ? !string.IsNullOrWhiteSpace(report.Narrative?.Narrative) : (bool?)null); + Add(f, "G", "AidGivenOrReceived", false, times == null ? null : times.MutualAidResourceCount > 0 ? "Received (" + times.MutualAidResourceCount.ToString(CultureInfo.InvariantCulture) + ")" : "None", + IncidentCommand, NerisFactKeys.CommandMutualAid, "aids", report != null ? report.Aids.Count > 0 : (bool?)null); + Add(f, "H", "ActionsTaken", false, null, null, null, "action_tactics", report != null ? report.Tactics.Count > 0 : (bool?)null, NfirsLegacyFieldStatus.NotCaptured); + Add(f, "K", "PropertyUse", false, null, null, null, "base.location.use", report != null ? !string.IsNullOrWhiteSpace(report.Location?.LocationUse) : (bool?)null, NfirsLegacyFieldStatus.NotCaptured); + + Summarize(rendering); + return rendering; + } + + #region Sources + + private async Task TryGetReportAsync(int departmentId, int callId, NfirsLegacyRendering rendering) + { + try + { + var report = await _incidents.GetForCallAsync(departmentId, callId); + if (report?.Report == null) + return null; + rendering.IncidentReportId = report.Report.RmsIncidentReportId; + rendering.IncidentReportNumber = report.Report.RecordNumber ?? report.Report.DraftReference; + rendering.IncidentReportState = ((RmsRecordState)report.Report.State).ToString(); + return report; + } + catch (Exception ex) + { + Logging.LogException(ex, $"The NERIS report for call {callId} could not be read for the NFIRS crosswalk."); + rendering.Notes.Add("The department's NERIS report for this call could not be read; crosswalk coverage is unknown."); + return null; + } + } + + private async Task TryGetTimesAsync(int departmentId, int callId, NfirsLegacyRendering rendering) + { + try + { + return await _reporting.GetIncidentTimesReportAsync(departmentId, callId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Incident Command key times could not be read for call {callId}."); + rendering.Notes.Add("Incident Command key times could not be read."); + return null; + } + } + + private async Task TryGetFirstOnSceneAsync(int departmentId, int callId, NfirsLegacyRendering rendering) + { + try + { + var states = await _units.GetUnitStatesForCallAsync(departmentId, callId) ?? new List(); + return states.Where(s => s != null && s.State == (int)UnitStateTypes.OnScene).OrderBy(s => s.Timestamp).FirstOrDefault(); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Unit state history could not be read for call {callId}."); + rendering.Notes.Add("Unit state history could not be read; the arrival time is unknown."); + return null; + } + } + + private async Task TryGetProfileAsync(int departmentId, NfirsLegacyRendering rendering) + { + try + { + return await _neris.GetProfileAsync(departmentId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"The NERIS profile for department {departmentId} could not be read."); + rendering.Notes.Add("The department's NERIS profile could not be read."); + return null; + } + } + + private async Task TryResolveTypeAsync(int departmentId, string callType, NfirsLegacyRendering rendering) + { + if (string.IsNullOrWhiteSpace(callType)) + return null; + try + { + var mapped = await _neris.ResolveCrosswalkAsync(departmentId, "incident_type", NerisCrosswalkSources.CallType, callType); + return string.IsNullOrWhiteSpace(mapped) ? null : mapped; + } + catch (Exception ex) + { + Logging.LogException(ex, $"The incident type crosswalk could not be read for department {departmentId}."); + rendering.Notes.Add("The incident type crosswalk could not be read."); + return null; + } + } + + #endregion + + #region Helpers + + private static bool? FactPopulated(IncidentReportAggregate report, string factKey) + { + if (report == null) + return null; + var fact = report.Facts?.FirstOrDefault(x => string.Equals(x.FactKey, factKey, StringComparison.Ordinal)); + if (fact == null) + return false; + // A correction is authoritative once one was made; until then the imported value stands. + return !string.IsNullOrWhiteSpace(fact.CurrentValue) || !fact.CorrectedOn.HasValue && !string.IsNullOrWhiteSpace(fact.SourceValue); + } + + private static void Add(List fields, string section, string name, bool required, string value, string sourceSystem, string nerisFactKey, string nerisSection, + bool? nerisPopulated, NfirsLegacyFieldStatus? status = null) + { + var populated = !string.IsNullOrWhiteSpace(value); + fields.Add(new NfirsLegacyField + { + Section = section, + Code = section + "-" + name, + Name = name, + Required = required, + Value = populated ? value : null, + Status = status ?? (populated ? NfirsLegacyFieldStatus.Populated : NfirsLegacyFieldStatus.Missing), + SourceSystem = populated ? sourceSystem : (status == NfirsLegacyFieldStatus.NotCaptured ? null : sourceSystem), + NerisFactKey = nerisFactKey, + NerisSection = nerisSection, + NerisPopulated = nerisPopulated + }); + } + + private static void Summarize(NfirsLegacyRendering rendering) + { + var s = rendering.Summary; + s.TotalFields = rendering.Fields.Count; + s.Populated = rendering.Fields.Count(x => x.Status == NfirsLegacyFieldStatus.Populated); + s.Missing = rendering.Fields.Count(x => x.Status == NfirsLegacyFieldStatus.Missing); + s.NotCaptured = rendering.Fields.Count(x => x.Status == NfirsLegacyFieldStatus.NotCaptured); + s.RequiredMissing = rendering.Fields.Count(x => x.Required && x.Status != NfirsLegacyFieldStatus.Populated); + s.CrosswalkedToNeris = rendering.Fields.Count(x => x.NerisFactKey != null || x.NerisSection != null); + s.CrosswalkedAndPopulated = rendering.Fields.Count(x => (x.NerisFactKey != null || x.NerisSection != null) && x.NerisPopulated == true); + } + + private static string Utc(DateTime? value) + { + return value.HasValue ? value.Value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture) : null; + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RecordsProtectionService.cs b/Core/Resgrid.Services/Records/RecordsProtectionService.cs new file mode 100644 index 00000000..56d56fee --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsProtectionService.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// + /// The RMS protection seam (RMS plan section 5.9; ADP catalog v10). Every Records service writes cataloged + /// columns through Protect* and reads them through Reveal*; this class is the only place that + /// knows which RmsProtectedFields map belongs to which entity, which grant the caller holds, and which + /// workload purposes a department has acknowledged. It owns no state and touches no repository. + /// + public sealed class RecordsProtectionService : IRecordsProtectionService, IRecordsProtectedReadService + { + public const string NerisSubmissionPurpose = "neris-submission"; + public const string RecordsExportPurpose = "records-export"; + + private readonly IProtectedReadService _reads; + private readonly IProtectedWriteService _writes; + private readonly IProtectedGrantContext _grant; + private readonly IDepartmentDataProtectionService _dataProtection; + private readonly INerisProfileService _neris; + + public RecordsProtectionService(IProtectedReadService reads, IProtectedWriteService writes, IProtectedGrantContext grant, + IDepartmentDataProtectionService dataProtection, INerisProfileService neris) + { + _reads = reads; + _writes = writes; + _grant = grant; + _dataProtection = dataProtection; + _neris = neris; + } + + public async Task GetCatalogVersionAsync(int departmentId) + { + try { return await _dataProtection.GetPinnedCatalogVersionAsync(departmentId); } + catch (Exception ex) { Logging.LogException(ex, $"Pinned catalog version lookup failed for department {departmentId}."); return 0; } + } + + public async Task IsEnforcedAsync(int departmentId) + { + try { return await _dataProtection.IsProtectionEnforcedAsync(departmentId); } + catch (Exception ex) { Logging.LogException(ex, $"Protection-state lookup failed for department {departmentId}; treating as enforced."); return true; } + } + + #region Writes + + private string GrantToken => _grant.GrantToken; + private bool Workload => _grant.IsWorkloadCaller; + + private async Task ApplyAsync(int departmentId, T row, T existing, string rowKey, + IReadOnlyDictionary Get, Action Set)> accessors, Action mark, string userId, string operation, + CancellationToken cancellationToken) where T : class + { + if (row == null) + return; + + var marked = false; + var result = await _writes.PrepareRecordsEntityWriteAsync(departmentId, row, existing, rowKey, accessors, () => marked = true, + GrantToken, userId ?? _grant.UserId, Workload, cancellationToken); + if (!result.Success) + throw new RecordProtectedContentException(result.Reason, operation); + if (marked) + mark(row, await GetCatalogVersionAsync(departmentId)); + } + + private async Task ApplyCompanionsAsync(int departmentId, T row, string rowKey, + IReadOnlyDictionary Get, Action Set, Func GetEnvelope, Action SetEnvelope)> companions, + Action mark, string userId, string operation, CancellationToken cancellationToken) where T : class + { + if (row == null) + return; + + var marked = false; + var result = await _writes.PrepareRecordsCompanionWriteAsync(departmentId, row, rowKey, companions, () => marked = true, + GrantToken, userId ?? _grant.UserId, Workload, cancellationToken); + if (!result.Success) + throw new RecordProtectedContentException(result.Reason, operation); + if (marked) + mark(row, await GetCatalogVersionAsync(departmentId)); + } + + public Task ProtectDetailsAsync(int departmentId, RmsOperationalRecordDetail row, RmsOperationalRecordDetail existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsOperationalRecordDetailId, RmsProtectedFields.Details, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "record details", cancellationToken); + + public async Task ProtectAttachmentAsync(int departmentId, RmsRecordAttachment row, RmsRecordAttachment existing, string userId = null, CancellationToken cancellationToken = default) + { + if (row == null) + return; + var result = await _writes.PrepareRecordsAttachmentWriteAsync(departmentId, row, existing, GrantToken, userId ?? _grant.UserId, Workload, cancellationToken); + if (!result.Success) + throw new RecordProtectedContentException(result.Reason, "attachment"); + if (row.IsProtected && row.ProtectedCatalogVersion == 0) + row.ProtectedCatalogVersion = await GetCatalogVersionAsync(departmentId); + } + + public Task ProtectRevisionAsync(int departmentId, RmsRevision row, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, null, row?.RmsRevisionId, RmsProtectedFields.Revisions, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "revision", cancellationToken); + + public async Task ProtectLocationAsync(int departmentId, RmsLocation row, RmsLocation existing, string userId = null, CancellationToken cancellationToken = default) + { + await ApplyAsync(departmentId, row, existing, row?.RmsLocationId, RmsProtectedFields.Locations, (r, v) => { }, userId, "location", cancellationToken); + await ApplyCompanionsAsync(departmentId, row, row?.RmsLocationId, RmsProtectedFields.LocationCompanions, (r, v) => { }, userId, "location", cancellationToken); + } + + public Task ProtectNarrativeAsync(int departmentId, RmsNarrative row, RmsNarrative existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsNarrativeId, RmsProtectedFields.Narratives, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "narrative", cancellationToken); + + public Task ProtectSourceFactAsync(int departmentId, RmsSourceFact row, RmsSourceFact existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsSourceFactId, RmsProtectedFields.SourceFacts, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "source fact", cancellationToken); + + public Task ProtectCasualtyAsync(int departmentId, RmsCasualtyRescue row, RmsCasualtyRescue existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsCasualtyRescueId, RmsProtectedFields.Casualties, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "casualty", cancellationToken); + + public async Task ProtectExposureAsync(int departmentId, RmsExposure row, RmsExposure existing, string userId = null, CancellationToken cancellationToken = default) + { + await ApplyAsync(departmentId, row, existing, row?.RmsExposureId, RmsProtectedFields.Exposures, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "exposure", cancellationToken); + await ApplyCompanionsAsync(departmentId, row, row?.RmsExposureId, RmsProtectedFields.ExposureCompanions, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "exposure", cancellationToken); + } + + public Task ProtectModuleAsync(int departmentId, RmsIncidentModule row, RmsIncidentModule existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsIncidentModuleId, RmsProtectedFields.Modules, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "incident module", cancellationToken); + + public Task ProtectPropertyAsync(int departmentId, RmsIncidentProperty row, RmsIncidentProperty existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsIncidentPropertyId, RmsProtectedFields.Properties, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "incident property", cancellationToken); + + public Task ProtectVehicleAsync(int departmentId, RmsIncidentVehicle row, RmsIncidentVehicle existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsIncidentVehicleId, RmsProtectedFields.Vehicles, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "incident vehicle", cancellationToken); + + public Task ProtectResourceAsync(int departmentId, RmsIncidentResource row, RmsIncidentResource existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsIncidentResourceId, RmsProtectedFields.Resources, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "incident resource", cancellationToken); + + public Task ProtectSubmissionAsync(int departmentId, RmsSubmission row, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, null, row?.RmsSubmissionId, RmsProtectedFields.Submissions, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "submission", cancellationToken); + + public Task ProtectSignatureAsync(int departmentId, RmsSignature row, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, null, row?.RmsSignatureId, RmsProtectedFields.Signatures, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "signature", cancellationToken); + + public Task ProtectEvidenceAsync(int departmentId, RmsEvidenceArtifact row, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, null, row?.RmsEvidenceArtifactId, RmsProtectedFields.Evidence, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "evidence", cancellationToken); + + public Task ProtectDisclosureRequestAsync(int departmentId, RmsDisclosureRequest row, RmsDisclosureRequest existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsDisclosureRequestId, RmsProtectedFields.DisclosureRequests, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "disclosure request", cancellationToken); + + public Task ProtectDisclosureProductionAsync(int departmentId, RmsDisclosureProduction row, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, null, row?.RmsDisclosureProductionId, RmsProtectedFields.DisclosureProductions, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "disclosure production", cancellationToken); + + public Task ProtectLegalHoldAsync(int departmentId, RmsRecordLegalHold row, RmsRecordLegalHold existing, string userId = null, CancellationToken cancellationToken = default) + => ApplyAsync(departmentId, row, existing, row?.RmsRecordLegalHoldId, RmsProtectedFields.LegalHolds, (r, v) => { r.IsProtected = true; r.ProtectedCatalogVersion = v; }, userId, "legal hold", cancellationToken); + + public async Task ProtectExportRunAsync(int departmentId, RmsExportRun row, string userId = null, CancellationToken cancellationToken = default) + { + if (row == null) + return; + var marked = false; + var result = await _writes.PrepareRecordsBinaryWriteAsync(departmentId, RmsProtectedFields.ExportRunDataFieldId, row.RmsExportRunId, row.Data, bytes => row.Data = bytes, + () => marked = true, GrantToken, userId ?? _grant.UserId, Workload, cancellationToken); + if (!result.Success) + throw new RecordProtectedContentException(result.Reason, "export run"); + if (marked) + { + row.IsProtected = true; + row.ProtectedCatalogVersion = await GetCatalogVersionAsync(departmentId); + } + } + + #endregion + + #region Ambient reveals + + private string User => _grant.UserId; + + private static IReadOnlyList<(T Entity, string RowKey)> Rows(IEnumerable rows, Func key) where T : class + => (rows ?? Enumerable.Empty()).Where(r => r != null).Select(r => (r, key(r))).ToList(); + + public Task RevealAsync(int departmentId, RecordAggregate aggregate, CancellationToken cancellationToken = default) + => ResolveAggregateAsync(departmentId, aggregate, GrantToken, User, cancellationToken); + + public Task RevealAsync(int departmentId, IncidentReportAggregate aggregate, CancellationToken cancellationToken = default) + => ResolveIncidentAsync(departmentId, aggregate, GrantToken, User, cancellationToken); + + public Task RevealAsync(int departmentId, IncidentAnalysisAggregate aggregate, CancellationToken cancellationToken = default) + => ResolveAnalysisAsync(departmentId, aggregate, GrantToken, User, cancellationToken); + + public Task RevealDetailsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(rows, r => r.RmsOperationalRecordDetailId), RmsProtectedFields.Details, GrantToken, User, cancellationToken); + + public Task RevealRevisionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => ResolveRevisionsAsync(departmentId, rows, GrantToken, User, cancellationToken); + + public Task RevealAttachmentsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default) + => ResolveAttachmentsAsync(departmentId, rows, GrantToken, User, includeData, cancellationToken); + + public Task RevealEvidenceAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => ResolveEvidenceAsync(departmentId, rows, GrantToken, User, cancellationToken); + + public Task RevealDisclosureRequestsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => ResolveDisclosureRequestsAsync(departmentId, rows, GrantToken, User, cancellationToken); + + public Task RevealDisclosureProductionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => ResolveDisclosureProductionsAsync(departmentId, rows, GrantToken, User, cancellationToken); + + public Task RevealLegalHoldsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => ResolveLegalHoldsAsync(departmentId, rows, GrantToken, User, cancellationToken); + + public Task RevealSubmissionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) + => ResolveSubmissionsAsync(departmentId, rows, GrantToken, User, cancellationToken); + + public async Task RevealExportRunsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + foreach (var run in (rows ?? Array.Empty()).Where(r => r != null)) + { + if (!includeData) + { + if (run.Data != null && ProtectedReadService.IsBinaryEnveloped(run.Data)) + { + run.Data = null; + result.IsProtected = true; + result.RedactedFields.Add(RmsProtectedFields.ExportRunDataFieldId); + } + continue; + } + + var current = run; + result.Merge(await _reads.ResolveRecordsBinaryForReadAsync(departmentId, RmsProtectedFields.ExportRunDataFieldId, current.RmsExportRunId, current.Data, + bytes => current.Data = bytes, GrantToken, User, cancellationToken)); + } + return result; + } + + #endregion + + #region Explicit-grant reads (IRecordsProtectedReadService) + + public async Task ResolveAggregateAsync(int departmentId, RecordAggregate aggregate, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + if (aggregate == null) + return result; + + if (aggregate.Details != null) + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(new[] { aggregate.Details }, r => r.RmsOperationalRecordDetailId), RmsProtectedFields.Details, grantToken, userId, cancellationToken)); + result.Merge(await ResolveAttachmentsAsync(departmentId, aggregate.Attachments, grantToken, userId, false, cancellationToken)); + result.Merge(await ResolveRevisionsAsync(departmentId, aggregate.Revisions, grantToken, userId, cancellationToken)); + aggregate.Protection = result; + return result; + } + + public async Task ResolveIncidentAsync(int departmentId, IncidentReportAggregate aggregate, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + if (aggregate == null) + return result; + + if (aggregate.Location != null) + { + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(new[] { aggregate.Location }, r => r.RmsLocationId), RmsProtectedFields.Locations, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsCompanionsForReadAsync(departmentId, Rows(new[] { aggregate.Location }, r => r.RmsLocationId), RmsProtectedFields.LocationCompanions, grantToken, userId, cancellationToken)); + } + if (aggregate.Narrative != null) + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(new[] { aggregate.Narrative }, r => r.RmsNarrativeId), RmsProtectedFields.Narratives, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(aggregate.Facts, r => r.RmsSourceFactId), RmsProtectedFields.SourceFacts, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(aggregate.Casualties, r => r.RmsCasualtyRescueId), RmsProtectedFields.Casualties, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(aggregate.Exposures, r => r.RmsExposureId), RmsProtectedFields.Exposures, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsCompanionsForReadAsync(departmentId, Rows(aggregate.Exposures, r => r.RmsExposureId), RmsProtectedFields.ExposureCompanions, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(aggregate.Modules, r => r.RmsIncidentModuleId), RmsProtectedFields.Modules, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(aggregate.Resources, r => r.RmsIncidentResourceId), RmsProtectedFields.Resources, grantToken, userId, cancellationToken)); + result.Merge(await ResolveAttachmentsAsync(departmentId, aggregate.Attachments, grantToken, userId, false, cancellationToken)); + result.Merge(await ResolveEvidenceAsync(departmentId, aggregate.Evidence, grantToken, userId, cancellationToken)); + result.Merge(await ResolveSubmissionsAsync(departmentId, aggregate.Submissions, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(aggregate.Signatures, r => r.RmsSignatureId), RmsProtectedFields.Signatures, grantToken, userId, cancellationToken)); + result.Merge(await ResolveRevisionsAsync(departmentId, aggregate.Revisions, grantToken, userId, cancellationToken)); + aggregate.Protection = result; + return result; + } + + public async Task ResolveAnalysisAsync(int departmentId, IncidentAnalysisAggregate aggregate, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + if (aggregate == null) + return result; + + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(aggregate.Modules, r => r.RmsIncidentModuleId), RmsProtectedFields.Modules, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(aggregate.Properties, r => r.RmsIncidentPropertyId), RmsProtectedFields.Properties, grantToken, userId, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(aggregate.Vehicles, r => r.RmsIncidentVehicleId), RmsProtectedFields.Vehicles, grantToken, userId, cancellationToken)); + result.Merge(await ResolveSubmissionsAsync(departmentId, aggregate.Submissions, grantToken, userId, cancellationToken)); + result.Merge(await ResolveRevisionsAsync(departmentId, aggregate.Revisions, grantToken, userId, cancellationToken)); + aggregate.Protection = result; + return result; + } + + public Task ResolveRevisionsAsync(int departmentId, IReadOnlyList revisions, string grantToken, string userId, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(revisions, r => r.RmsRevisionId), RmsProtectedFields.Revisions, grantToken, userId, cancellationToken); + + public Task ResolveAttachmentsAsync(int departmentId, IReadOnlyList attachments, string grantToken, string userId, bool includeData, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsAttachmentsForReadAsync(departmentId, attachments ?? Array.Empty(), grantToken, userId, includeData, cancellationToken); + + public Task ResolveDisclosureRequestsAsync(int departmentId, IReadOnlyList requests, string grantToken, string userId, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(requests, r => r.RmsDisclosureRequestId), RmsProtectedFields.DisclosureRequests, grantToken, userId, cancellationToken); + + public Task ResolveDisclosureProductionsAsync(int departmentId, IReadOnlyList productions, string grantToken, string userId, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(productions, r => r.RmsDisclosureProductionId), RmsProtectedFields.DisclosureProductions, grantToken, userId, cancellationToken); + + public Task ResolveEvidenceAsync(int departmentId, IReadOnlyList artifacts, string grantToken, string userId, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(artifacts, r => r.RmsEvidenceArtifactId), RmsProtectedFields.Evidence, grantToken, userId, cancellationToken); + + public Task ResolveLegalHoldsAsync(int departmentId, IReadOnlyList holds, string grantToken, string userId, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(holds, r => r.RmsRecordLegalHoldId), RmsProtectedFields.LegalHolds, grantToken, userId, cancellationToken); + + public Task ResolveSubmissionsAsync(int departmentId, IReadOnlyList submissions, string grantToken, string userId, CancellationToken cancellationToken = default) + => _reads.ResolveRecordsEntitiesForReadAsync(departmentId, Rows(submissions, r => r.RmsSubmissionId), RmsProtectedFields.Submissions, grantToken, userId, cancellationToken); + + #endregion + + #region Workload lane + + public async Task ResolveSubmissionForWorkloadAsync(int departmentId, RmsSubmission submission, string purpose, CancellationToken cancellationToken = default) + { + if (submission == null || !ProtectedDataEnvelope.HasEnvelopePrefix(submission.PayloadJson)) + return true; + + // The egress acknowledgement is the department's decision, recorded on its NERIS profile (RMS plan + // section 5.9.4); without it the payload stays sealed and the submission fails closed. + RmsNerisProfile profile; + try { profile = await _neris.GetProfileAsync(departmentId); } + catch (Exception ex) { Logging.LogException(ex, $"NERIS profile lookup failed for department {departmentId}; refusing protected egress."); return false; } + if (profile == null || !profile.AllowProtectedContentEgress) + return false; + + var result = await _reads.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose ?? NerisSubmissionPurpose, + Rows(new[] { submission }, r => r.RmsSubmissionId), RmsProtectedFields.Submissions, cancellationToken); + return result.RedactedFields.Count == 0 && !ProtectedDataEnvelope.HasEnvelopePrefix(submission.PayloadJson); + } + + public async Task RevealForWorkloadAsync(int departmentId, RecordAggregate aggregate, string purpose, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + if (aggregate == null) + return result; + if (aggregate.Details != null) + result.Merge(await _reads.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose, Rows(new[] { aggregate.Details }, r => r.RmsOperationalRecordDetailId), RmsProtectedFields.Details, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose, Rows(aggregate.Attachments, r => r.RmsRecordAttachmentId), RmsProtectedFields.Attachments, cancellationToken)); + aggregate.Protection = result; + return result; + } + + public async Task RevealForWorkloadAsync(int departmentId, IncidentReportAggregate aggregate, string purpose, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + if (aggregate == null) + return result; + if (aggregate.Location != null) + result.Merge(await _reads.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose, Rows(new[] { aggregate.Location }, r => r.RmsLocationId), RmsProtectedFields.Locations, cancellationToken)); + if (aggregate.Narrative != null) + result.Merge(await _reads.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose, Rows(new[] { aggregate.Narrative }, r => r.RmsNarrativeId), RmsProtectedFields.Narratives, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose, Rows(aggregate.Casualties, r => r.RmsCasualtyRescueId), RmsProtectedFields.Casualties, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose, Rows(aggregate.Exposures, r => r.RmsExposureId), RmsProtectedFields.Exposures, cancellationToken)); + result.Merge(await _reads.ResolveRecordsEntitiesForWorkloadAsync(departmentId, purpose, Rows(aggregate.Attachments, r => r.RmsRecordAttachmentId), RmsProtectedFields.Attachments, cancellationToken)); + aggregate.Protection = result; + return result; + } + + #endregion + } +} diff --git a/Core/Resgrid.Services/Records/RecordsReportingService.cs b/Core/Resgrid.Services/Records/RecordsReportingService.cs index c7d81c66..61473441 100644 --- a/Core/Resgrid.Services/Records/RecordsReportingService.cs +++ b/Core/Resgrid.Services/Records/RecordsReportingService.cs @@ -26,10 +26,13 @@ public class RecordsReportingService : IRecordsReportingService private readonly IRmsRecordGroupScopesRepository _scopes; private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsProtectionService _protection; + public RecordsReportingService(IWorkLogsService legacyLogs, IRecordsCutoverService cutover, IRmsOperationalRecordsRepository records, IRmsRevisionsRepository revisions, - IRmsRecordGroupScopesRepository scopes, IRecordsAuthorizationService authorization) + IRmsRecordGroupScopesRepository scopes, IRecordsAuthorizationService authorization, IRecordsProtectionService protection) { + _protection = protection; _legacyLogs = legacyLogs; _cutover = cutover; _records = records; @@ -80,6 +83,8 @@ private async Task> MapVisibleAsync(int departmentId, ? null : (await _scopes.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()).ToLookup(s => s.RecordId, StringComparer.Ordinal); + // Reporting totals participants from the attested snapshots, so a protected department needs the caller's grant (ADP). + (await _protection.RevealRevisionsAsync(departmentId, revisions.Values.ToList())).RequireRevealed("reporting"); foreach (var record in records) { if (record.PurgedOn.HasValue || record.CurrentRevisionId == null || !revisions.TryGetValue(record.CurrentRevisionId, out var revision) diff --git a/Core/Resgrid.Services/Records/RecordsRetentionService.cs b/Core/Resgrid.Services/Records/RecordsRetentionService.cs index 2a4b68b5..224b466c 100644 --- a/Core/Resgrid.Services/Records/RecordsRetentionService.cs +++ b/Core/Resgrid.Services/Records/RecordsRetentionService.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -46,12 +47,14 @@ public class RecordsRetentionService : IRecordsRetentionService private readonly IRecordAttachmentScanner _scanner; private readonly IDepartmentSettingsService _settings; private readonly IRmsRetentionRepository _purge; + private readonly IDomainEventOutboxService _outbox; public RecordsRetentionService(IRmsDepartmentCutoversRepository cutovers, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidentReports, IRmsOperationalRecordDetailsRepository details, IRmsRecordAttachmentsRepository attachments, IRmsRecordLegalHoldsRepository legalHolds, IRmsAccessAuditsRepository audits, IRmsRecordSearchProjectionsRepository projections, - IRecordAttachmentScanner scanner, IDepartmentSettingsService settings, IRmsRetentionRepository purge) + IRecordAttachmentScanner scanner, IDepartmentSettingsService settings, IRmsRetentionRepository purge, IDomainEventOutboxService outbox) { + _outbox = outbox; _cutovers = cutovers; _records = records; _incidentReports = incidentReports; @@ -154,7 +157,11 @@ private async Task ConsiderAsync(int departmentId, string recordId, RmsRecordKin try { var outcome = await _purge.PurgeAsync(departmentId, recordId, kind, version, now, cancellationToken); - if (outcome.Purged) { result.RecordsPurged++; result.AttachmentsPurged += outcome.AttachmentsPurged; } + if (outcome.Purged) + { + result.RecordsPurged++; result.AttachmentsPurged += outcome.AttachmentsPurged; + await EnqueuePurgedAsync(departmentId, recordId, kind, version, outcome, now, cancellationToken); + } if (outcome.SearchErasurePending) result.SearchErasuresPending++; if (outcome.Held) { @@ -165,6 +172,34 @@ private async Task ConsiderAsync(int departmentId, string recordId, RmsRecordKin catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { Logging.LogException(ex, "RMS retention purge failed."); result.Errors++; } } + /// RecordPurged (159): the identity that was purged and what went with it; the content is gone, so nothing else can be carried. + private async Task EnqueuePurgedAsync(int departmentId, string recordId, RmsRecordKind kind, long version, RmsPurgeResult outcome, DateTime now, CancellationToken cancellationToken) + { + try + { + var entry = await _outbox.EnqueueAsync(departmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = WorkflowTriggerEventType.RecordPurged.ToString(), + SchemaVersion = 1, + AggregateType = kind == RmsRecordKind.IncidentReport ? IncidentReportsService.IncidentAggregate : DomainEventProducers.RecordsAggregate, + AggregateId = recordId, + AggregateVersion = (int)version, + Trigger = WorkflowTriggerEventType.RecordPurged, + Payload = new Dictionary + { + ["record"] = new { id = recordId, kind = kind.ToString(), department_id = departmentId, state = "Purged" }, + ["purge"] = new { purged_on = now, attachments_purged = outcome.AttachmentsPurged, search_erasure_pending = outcome.SearchErasurePending, reason = outcome.Reason }, + ["protection"] = new { is_protected = false, is_redacted = false, redacted_fields = Array.Empty(), protected_catalog_version = 0 } + }, + CorrelationId = recordId, + OriginClient = RmsOriginClient.System + }, cancellationToken); + await _outbox.DispatchAfterCommitAsync(new[] { entry.DomainEventOutboxId }, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) { Logging.LogException(ex, $"RecordPurged event for {recordId} could not be queued."); } + } + /// /// Re-submits Pending attachments to the scanner. A clean result promotes the row; a rejection deletes the /// bytes and marks it Rejected, because a Pending attachment that turns out to be malware has been diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs index f8aa9f12..c5fc885f 100644 --- a/Core/Resgrid.Services/Records/RecordsService.cs +++ b/Core/Resgrid.Services/Records/RecordsService.cs @@ -53,6 +53,7 @@ public class RecordsService : IRecordsService private readonly IRecordAttachmentScanner _attachmentScanner; private readonly IRecordsAuthorizationService _authorization; private readonly IRecordsUdfService _udf; + private readonly IRecordsProtectionService _protection; public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueService details, IRmsRecordParticipantsRepository participants, IRmsRecordUnitResponsesRepository units, IRmsRecordAttachmentsRepository attachments, @@ -60,8 +61,10 @@ public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueS IRmsRecordSearchProjectionsRepository projections, IRmsAccessAuditsRepository audits, IDomainEventOutboxService outbox, IRecordsCutoverService cutover, IDepartmentSettingsService settings, IDepartmentGroupsService groups, IUserProfileService profiles, IUnitsService unitsService, ICallsService calls, IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, - IOutboundQueueProvider outboundQueue, IRecordAttachmentScanner attachmentScanner, IRecordsAuthorizationService authorization, IRecordsUdfService udf) + IOutboundQueueProvider outboundQueue, IRecordAttachmentScanner attachmentScanner, IRecordsAuthorizationService authorization, IRecordsUdfService udf, + IRecordsProtectionService protection) { + _protection = protection; _records = records; _details = details; _participants = participants; @@ -384,6 +387,7 @@ public async Task ApproveAsync(int departmentId, string userId, throw new RecordTransitionException(recordId, from, RmsRecordState.Approved, "the approver may not be the author"); var now = DateTime.UtcNow; + var outboxIds = new List(); await InTransactionAsync(async () => { await GuardVersionAsync(record, record.RowVersion, cancellationToken); @@ -394,10 +398,12 @@ await InTransactionAsync(async () => record.ModifiedByUserId = userId; await _records.UpdateAsync(record, cancellationToken, true); await RefreshProjectionAsync(record, cancellationToken); - // RecordApproved (103) is an RMS-1B trigger; no event until it is appended. + outboxIds.Add((await EnqueueLifecycleEventAsync(record, null, WorkflowTriggerEventType.RecordApproved, from, RmsRecordState.Approved, null, cancellationToken, null, + new Dictionary { ["review"] = new { reviewer_user_id = record.ReviewerUserId, approver_user_id = userId, approved_on = now, submitted_for_review_on = record.SubmittedForReviewOn, review_due_on = record.ReviewDueOn, return_count = record.ReturnCount } })).DomainEventOutboxId); await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Sign, "Approve", RmsOriginClient.Web, cancellationToken); }); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return await GetAsync(departmentId, recordId, false); } @@ -422,6 +428,7 @@ await InTransactionAsync(async () => await GuardVersionAsync(record, expectedRowVersion, cancellationToken); var draft = await HydrateDraftAsync(record); + draft.Protection.RequireRevealed(isAmendment ? "finalize amendment" : "finalize"); ValidateDefinitionRequirements(recordType, draft.Details); ValidateForFinalization(recordType, draft); _udf.ValidateForFinalization(draft.CustomFields); @@ -495,6 +502,8 @@ public async Task AbandonAmendmentAsync(int departmentId, strin return await GetAsync(departmentId, recordId, true); var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, record.CurrentRevisionId); + if (revision != null) + (await _protection.RevealRevisionsAsync(departmentId, new[] { revision }, cancellationToken)).RequireRevealed("abandon amendment"); var snapshot = RecordSnapshotSerializer.Deserialize(revision?.SnapshotJson); if (snapshot == null) throw new InvalidOperationException($"Revision {record.CurrentRevisionId} has no snapshot to restore."); @@ -532,6 +541,7 @@ await InTransactionAsync(async () => { await GuardVersionAsync(record, record.RowVersion, cancellationToken); var draft = await HydrateDraftAsync(record); + draft.Protection.RequireRevealed("void"); var revision = await WriteRevisionAsync(record, draft, RmsRevisionTransition.Voided, userId, reasonCode, reasonText, AttestationStatementVersion, now, cancellationToken); record.State = (int)RmsRecordState.Voided; @@ -671,6 +681,7 @@ public async Task GetRevisionSnapshotAsync(int departmentId, str if (revision == null) return null; var record = await _records.GetByIdForDepartmentAsync(departmentId, revision.RecordId); if (record == null || record.PurgedOn.HasValue || record.DeletedOn.HasValue) return null; + (await _protection.RevealRevisionsAsync(departmentId, new[] { revision })).RequireRevealed("revision"); return RecordSnapshotSerializer.Deserialize(revision.SnapshotJson); } @@ -753,19 +764,46 @@ public async Task AddAttachmentAsync(int departmentId, stri RowVersion = 1 }; + // The caller gets plaintext metadata back; the stored row may carry envelopes (ADP catalog v10). + var metadata = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(attachment)); metadata.Data = null; + var outboxIds = new List(); await InTransactionAsync(async () => { record = await LoadRecordAsync(departmentId, recordId); await RequireAttachmentWriteAsync(record, userId, classification != 0); await GuardVersionAsync(record, expectedVersion, cancellationToken); + await _protection.ProtectAttachmentAsync(departmentId, attachment, null, userId, cancellationToken); await _attachments.InsertAsync(attachment, cancellationToken, true); record.ModifiedOn = now; record.ModifiedByUserId = userId; await _records.UpdateAsync(record, cancellationToken, true); + 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 _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + + metadata.IsProtected = attachment.IsProtected; + metadata.ProtectedCatalogVersion = attachment.ProtectedCatalogVersion; + return metadata; + } - var metadata = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(attachment)); metadata.Data = null; return metadata; + /// The attachment.* block (plan section 5.6, trigger 115): identity, type, size, checksum and scan state; never the file name, description or bytes. + public static object AttachmentBlock(RmsRecordAttachment attachment, int count) + { + return new + { + id = attachment.RmsRecordAttachmentId, + content_type = attachment.ContentType, + byte_size = attachment.ByteSize, + checksum = attachment.Checksum, + classification = ((RmsEvidenceClassification)attachment.Classification).ToString(), + scan_state = ((RmsAttachmentScanState)attachment.ScanState).ToString(), + uploaded_by_user_id = attachment.UploadedByUserId, + uploaded_on = attachment.UploadedOn, + count + }; } public async Task GetAttachmentAsync(int departmentId, string userId, string attachmentId) @@ -773,6 +811,8 @@ public async Task GetAttachmentAsync(int departmentId, stri var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId); if (attachment == null || attachment.DeletedOn.HasValue || !await _authorization.CanUserViewRecordAsync(userId, attachment.RecordId, departmentId)) return null; if (attachment.RequiresRestrictedAccess && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) return null; + // ADP: the bytes and the file name are resolved for the caller's grant; without one the download fails closed. + (await _protection.RevealAttachmentsAsync(departmentId, new[] { attachment }, true)).RequireRevealed("attachment download"); if (attachment.ScanState != (int)RmsAttachmentScanState.Clean || attachment.Data == null || RecordSnapshotSerializer.Checksum(attachment.Data) != attachment.Checksum) return null; return attachment; } @@ -869,13 +909,21 @@ private async Task HydrateAsync(RmsOperationalRecord record, bo var aggregate = await HydrateDraftAsync(record); aggregate.GroupScope = (await _scopes.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId))?.ToList() ?? new List(); if (includeRevisions) + { aggregate.Revisions = (await _revisions.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId))?.ToList() ?? new List(); + aggregate.Protection = aggregate.Protection.Merge(await _protection.RevealRevisionsAsync(record.DepartmentId, aggregate.Revisions)); + } return aggregate; } + /// + /// The working draft, resolved for the ambient caller (ADP, RMS plan section 5.9): the details row and the + /// attachment metadata leave here as plaintext when the caller holds a grant and as the REDACTED sentinel + /// otherwise. Transitions that copy content into a revision require a fully revealed draft. + /// private async Task HydrateDraftAsync(RmsOperationalRecord record) { - return new RecordAggregate + var aggregate = new RecordAggregate { CustomFields = await _udf.CaptureAsync(record.DepartmentId, record.RmsOperationalRecordId, record.DefinitionKey, record.DefinitionVersion, record.UdfDefinitionId), Record = record, @@ -884,6 +932,8 @@ private async Task HydrateDraftAsync(RmsOperationalRecord recor Units = (await _units.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, null))?.ToList() ?? new List(), Attachments = (await _attachments.GetMetadataForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId))?.ToList() ?? new List() }; + aggregate.Protection = await _protection.RevealAsync(record.DepartmentId, aggregate); + return aggregate; } private async Task WriteRevisionAsync(RmsOperationalRecord record, RecordAggregate draft, RmsRevisionTransition transition, string userId, @@ -895,7 +945,9 @@ private async Task WriteRevisionAsync(RmsOperationalRecord record, if (record.CurrentRevisionId != null) { var prior = await _revisions.GetByIdForDepartmentAsync(record.DepartmentId, record.CurrentRevisionId); - if (prior == null || prior.RecordId != record.RmsOperationalRecordId || RecordSnapshotSerializer.Checksum(prior.SnapshotJson) != prior.Checksum) throw new InvalidOperationException("The prior revision failed its integrity check."); + if (prior == null || prior.RecordId != record.RmsOperationalRecordId) throw new InvalidOperationException("The prior revision failed its integrity check."); + (await _protection.RevealRevisionsAsync(record.DepartmentId, new[] { prior }, cancellationToken)).RequireRevealed("finalize"); + if (RecordSnapshotSerializer.Checksum(prior.SnapshotJson) != prior.Checksum) throw new InvalidOperationException("The prior revision failed its integrity check."); var priorSnapshot = RecordSnapshotSerializer.Deserialize(prior.SnapshotJson); var priorEvidence = priorSnapshot.SnapshotVersion >= 2 ? priorSnapshot.Evidence : await _evidence.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, prior.RmsRevisionId, true); snapshot.Evidence.AddRange((priorEvidence ?? new List()).Where(e => !snapshot.Evidence.Any(n => n.Kind == e.Kind && n.SourceEntityId == e.SourceEntityId))); @@ -927,6 +979,8 @@ private async Task WriteRevisionAsync(RmsOperationalRecord record, OriginClient = record.OriginClient, CreatedOn = now }; + // The checksum attests the plaintext snapshot; under ADP the stored column carries its envelope (plan 5.9). + await _protection.ProtectRevisionAsync(record.DepartmentId, revision, userId, cancellationToken); await _revisions.InsertAsync(revision, cancellationToken, true); // Evidence captured against the draft becomes evidence of this revision (RMS-3c). Binding rather than @@ -1111,6 +1165,17 @@ private async Task UpsertProjectionAsync(RecordAggregate aggregate, Cancellation await _projections.UpdateAsync(projection, cancellationToken, true); } + /// + /// The protection.* block (plan section 5.9.3) every Records event carries: a subscriber learns that the + /// department protects record content and which catalog it is pinned to, never a value. Payloads are + /// built from header facts only, so nothing in them is ever redacted. + /// + private async Task ProtectionBlockAsync(int departmentId) + { + var version = await SafeCatalogVersionAsync(departmentId); + return new { is_protected = version > 0, is_redacted = false, redacted_fields = Array.Empty(), protected_catalog_version = version }; + } + private async Task SafeCatalogVersionAsync(int departmentId) { try { return await _dataProtection.GetPinnedCatalogVersionAsync(departmentId); } @@ -1123,11 +1188,12 @@ private async Task SafePolicyEpochAsync(int departmentId) catch (Exception ex) { Logging.LogException(ex); return 0; } } - private async Task EnqueueLifecycleEventAsync(RmsOperationalRecord record, RmsRevision revision, WorkflowTriggerEventType trigger, RmsRecordState from, RmsRecordState to, string reasonCode, CancellationToken cancellationToken, object extra = null) + private async Task EnqueueLifecycleEventAsync(RmsOperationalRecord record, RmsRevision revision, WorkflowTriggerEventType trigger, RmsRecordState from, RmsRecordState to, string reasonCode, CancellationToken cancellationToken, object extra = null, IDictionary blocks = null) { var payload = new Dictionary { ["record"] = RecordBlock(record, revision, to), + ["protection"] = await ProtectionBlockAsync(record.DepartmentId), ["record_change"] = new { previous_state = from.ToString(), @@ -1139,6 +1205,9 @@ private async Task EnqueueLifecycleEventAsync(RmsOperati }; if (extra != null) payload["extra"] = extra; + if (blocks != null) + foreach (var block in blocks) + payload[block.Key] = block.Value; // review.* (plan section 5.6): who reviewed, when it was due, how often it came back. Only the two // review-path triggers carry it; the fields are review bookkeeping, never record content. diff --git a/Core/Resgrid.Services/Records/RecordsSubmissionService.cs b/Core/Resgrid.Services/Records/RecordsSubmissionService.cs index 6d4b6f9b..2d64ece5 100644 --- a/Core/Resgrid.Services/Records/RecordsSubmissionService.cs +++ b/Core/Resgrid.Services/Records/RecordsSubmissionService.cs @@ -43,12 +43,14 @@ public class RecordsSubmissionService : IRecordsSubmissionService private readonly IDomainEventOutboxService _outbox; private readonly IOutboundQueueProvider _outboundQueue; private readonly IUnitOfWork _unitOfWork; + private readonly IRecordsProtectionService _protection; public RecordsSubmissionService(IRmsSubmissionsRepository submissions, IRmsIncidentReportsRepository reports, IRmsIncidentAnalysesRepository analyses, IRmsRecordSearchProjectionsRepository projections, IRmsAccessAuditsRepository audits, INerisProfileService profiles, INerisSubmissionService delivery, IDomainEventOutboxService outbox, IOutboundQueueProvider outboundQueue, IUnitOfWork unitOfWork, IRmsDepartmentCutoversRepository cutovers, IIncidentAnalysisService analysisService, - IRmsSubmissionExchangesRepository exchanges, IRecordsAuthorizationService authorization) + IRmsSubmissionExchangesRepository exchanges, IRecordsAuthorizationService authorization, IRecordsProtectionService protection) { + _protection = protection; _submissions = submissions; _exchanges = exchanges; _authorization = authorization; @@ -329,11 +331,17 @@ public async Task ProcessAsync(RmsSubmission submission, Cancella try { externalId = ResolveDestinationId(await _submissions.GetForRecordAsync(submission.DepartmentId, submission.RecordId), submission.DestinationIdentity, submission.ExternalId ?? report.NerisIncidentId); } catch (InvalidOperationException ex) { return await PersistAsync(submission, report, Fatal(ex.Message), now, false, cancellationToken); } var operation = !wasDelivery ? "Poll" : string.IsNullOrEmpty(externalId) ? "Create" : "Update"; + // ADP (RMS plan section 5.9.4): the queued payload is sealed at rest. It is opened onto a wire copy through + // the broker's purpose-bound workload lane only when the department acknowledged protected egress to + // this destination; the persisted row never sees plaintext, and a refusal fails the delivery closed. + var wire = Copy(submission); + if (wasDelivery && !await _protection.ResolveSubmissionForWorkloadAsync(wire.DepartmentId, wire, RecordsProtectionService.NerisSubmissionPurpose, cancellationToken)) + return await PersistAsync(submission, report, Fatal(ProtectedEgressUnavailable), now, false, cancellationToken); try { - var exchange = await ExchangeAsync(submission, profile, operation, () => !wasDelivery + var exchange = await ExchangeAsync(submission, wire.PayloadJson, profile, operation, () => !wasDelivery ? _delivery.CheckStatusAsync(profile, externalId, cancellationToken) - : _delivery.DeliverAsync(profile, submission, externalId, cancellationToken), cancellationToken); + : _delivery.DeliverAsync(profile, wire, externalId, cancellationToken), cancellationToken); return await PersistAsync(submission, report, exchange.outcome, DateTime.UtcNow, wasDelivery, cancellationToken, exchange.entry); } catch (SubmissionLeaseLostException) { return await _submissions.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RmsSubmissionId) ?? submission; } @@ -375,11 +383,14 @@ private async Task ProcessAnalysisAsync(RmsSubmission submission, } catch (InvalidOperationException ex) { return await PersistAnalysisAsync(submission, analysis, Fatal(ex.Message), now, false, cancellationToken); } var operation = !wasDelivery ? "Poll" : string.IsNullOrEmpty(externalId) ? "Create" : "Update"; + var wire = Copy(submission); + if (wasDelivery && !await _protection.ResolveSubmissionForWorkloadAsync(wire.DepartmentId, wire, RecordsProtectionService.NerisSubmissionPurpose, cancellationToken)) + return await PersistAnalysisAsync(submission, analysis, Fatal(ProtectedEgressUnavailable), now, false, cancellationToken); try { - var exchange = await ExchangeAsync(submission, profile, operation, () => !wasDelivery + var exchange = await ExchangeAsync(submission, wire.PayloadJson, profile, operation, () => !wasDelivery ? _delivery.CheckAnalysisStatusAsync(profile, externalId, cancellationToken) - : _delivery.DeliverAnalysisAsync(profile, submission, parentExternalId, externalId, cancellationToken), cancellationToken); + : _delivery.DeliverAnalysisAsync(profile, wire, parentExternalId, externalId, cancellationToken), cancellationToken); return await PersistAnalysisAsync(submission, analysis, exchange.outcome, DateTime.UtcNow, wasDelivery, cancellationToken, exchange.entry); } catch (SubmissionLeaseLostException) { return await _submissions.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RmsSubmissionId) ?? submission; } @@ -387,6 +398,7 @@ private async Task ProcessAnalysisAsync(RmsSubmission submission, private async Task PersistAnalysisAsync(RmsSubmission submission, RmsIncidentAnalysis analysis, NerisSubmissionOutcome outcome, DateTime now, bool wasDelivery, CancellationToken cancellationToken, RmsSubmissionExchange exchange = null) { + var outboxIds = new List(); await InTransactionAsync(async () => { await FenceAsync(submission, cancellationToken); @@ -405,6 +417,7 @@ await InTransactionAsync(async () => submission.ExternalStatus = outcome.ExternalStatus; RmsIncidentAnalysisState? analysisState = null; + WorkflowTriggerEventType? trigger = null; string auditPurpose; switch (outcome.Kind) @@ -425,6 +438,7 @@ await InTransactionAsync(async () => submission.NextAttemptOn = null; submission.ErrorSummary = null; analysisState = RmsIncidentAnalysisState.Accepted; + trigger = WorkflowTriggerEventType.RecordSubmissionAccepted; auditPurpose = "Analysis accepted"; break; @@ -434,6 +448,7 @@ await InTransactionAsync(async () => submission.NextAttemptOn = null; submission.ErrorSummary = Summarize(outcome); analysisState = RmsIncidentAnalysisState.Rejected; + trigger = WorkflowTriggerEventType.RecordSubmissionRejected; auditPurpose = "Analysis rejected"; break; @@ -447,6 +462,7 @@ await InTransactionAsync(async () => submission.CompletedOn = now; submission.NextAttemptOn = null; submission.ErrorSummary = "Delivery exhausted its retries: " + (outcome.Message ?? "destination unavailable"); + trigger = WorkflowTriggerEventType.RecordSubmissionFailed; auditPurpose = "Analysis submission failed (retries exhausted)"; } else @@ -464,6 +480,7 @@ await InTransactionAsync(async () => submission.CompletedOn = now; submission.NextAttemptOn = null; submission.ErrorSummary = outcome.Message ?? "Delivery needs operator attention."; + trigger = WorkflowTriggerEventType.RecordSubmissionFailed; auditPurpose = "Analysis submission failed"; break; } @@ -472,6 +489,7 @@ await InTransactionAsync(async () => submission.LeaseExpiresOn = null; submission.ModifiedOn = now; submission.RowVersion += 1; + await _protection.ProtectSubmissionAsync(submission.DepartmentId, submission, null, cancellationToken); await _submissions.UpdateAsync(submission, cancellationToken, true); if (exchange?.Stage == "Response") await AppendExchangeAsync(exchange, "Applied", null, cancellationToken); @@ -491,6 +509,32 @@ await InTransactionAsync(async () => analysis.RowVersion += 1; await _analyses.UpdateAsync(analysis, cancellationToken, true); + if (trigger.HasValue) + { + // Same triggers as the incident (109-111) with record.kind = "IncidentAnalysis" and + // submission.destination = NerisIncidentAnalysis, so a subscriber can tell the two filings apart. + var parent = await _reports.GetByIdForDepartmentAsync(analysis.DepartmentId, analysis.IncidentReportId); + var entry = await _outbox.EnqueueAsync(analysis.DepartmentId, DomainEventProducers.Records, new DomainEventEnvelope + { + EventName = trigger.Value.ToString(), + SchemaVersion = 1, + AggregateType = IncidentAnalysisService.AnalysisAggregate, + AggregateId = analysis.RmsIncidentAnalysisId, + AggregateVersion = analysis.RevisionCount, + Trigger = trigger.Value, + Payload = new Dictionary + { + ["record"] = IncidentAnalysisService.RecordBlock(analysis, parent, null, (RmsIncidentAnalysisState)analysis.State), + ["record_change"] = new { previous_state = "Submitted", current_state = ((RmsIncidentAnalysisState)analysis.State).ToString(), prior_revision_id = (string)null, current_revision_id = analysis.CurrentRevisionId, reason_code = (string)null }, + ["submission"] = IncidentReportsService.SubmissionBlock(submission), + ["protection"] = IncidentReportsService.ProtectionBlock(await _protection.GetCatalogVersionAsync(analysis.DepartmentId)) + }, + CorrelationId = analysis.IncidentReportId, + OriginClient = RmsOriginClient.System + }, cancellationToken); + outboxIds.Add(entry.DomainEventOutboxId); + } + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = analysis.DepartmentId, @@ -505,6 +549,7 @@ await _audits.InsertAsync(new RmsAccessAudit }, cancellationToken, true); } }); + await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return submission; } @@ -604,6 +649,7 @@ await InTransactionAsync(async () => submission.LeaseExpiresOn = null; submission.ModifiedOn = now; submission.RowVersion += 1; + await _protection.ProtectSubmissionAsync(submission.DepartmentId, submission, null, cancellationToken); await _submissions.UpdateAsync(submission, cancellationToken, true); if (exchange?.Stage == "Response") await AppendExchangeAsync(exchange, "Applied", null, cancellationToken); @@ -764,13 +810,16 @@ private async Task CurrentAnalysisAsync(RmsSubmission submi return analysis; } - private async Task<(NerisSubmissionOutcome outcome, RmsSubmissionExchange entry)> ExchangeAsync(RmsSubmission submission, RmsNerisProfile profile, string operation, + public const string ProtectedEgressUnavailable = "protected_egress_unavailable: the department has not acknowledged protected content egress to this destination, or the broker refused the workload purpose."; + + private async Task<(NerisSubmissionOutcome outcome, RmsSubmissionExchange entry)> ExchangeAsync(RmsSubmission submission, string payloadJson, RmsNerisProfile profile, string operation, Func> send, CancellationToken cancellationToken) { if (profile == null || string.IsNullOrWhiteSpace(submission.DestinationIdentity) || submission.DestinationIdentity != _profiles.GetDestinationIdentity(profile)) return (Fatal("The queued destination does not match the current profile. Restore the pinned profile before retrying."), null); - if (string.IsNullOrWhiteSpace(submission.PayloadJson) || submission.PayloadChecksum != RecordSnapshotSerializer.Checksum(submission.PayloadJson)) + // The checksum attests the plaintext payload; a poll never opens the envelope, so only a delivery verifies it. + if (operation != "Poll" && (string.IsNullOrWhiteSpace(payloadJson) || submission.PayloadChecksum != RecordSnapshotSerializer.Checksum(payloadJson))) return (Fatal("The queued payload failed its integrity check."), null); var history = (await _exchanges.GetForSubmissionAsync(submission.DepartmentId, submission.RmsSubmissionId))?.ToList() ?? new List(); diff --git a/Core/Resgrid.Services/Records/RmsRecordValueService.cs b/Core/Resgrid.Services/Records/RmsRecordValueService.cs index afa38d3d..69c5d64f 100644 --- a/Core/Resgrid.Services/Records/RmsRecordValueService.cs +++ b/Core/Resgrid.Services/Records/RmsRecordValueService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Resgrid.Model; @@ -9,72 +10,123 @@ namespace Resgrid.Services.Records { /// - /// RMS-1 value seam (plan section 5.9.1): the only caller of the details repository. Every write passes - /// , which is where Protected Data enrollment will clone the row into its - /// protected persistence shape; today it only enforces the inert contract so nothing can pre-empt it. + /// RMS-1 value seam (plan section 5.9.1): the only caller of the details repository. Every write passes the + /// Protected Data seam first (ADP catalog v10: the Logs-parity narrative/contact/location/coroner columns are + /// encrypted in place for an enrolled department) and then , which enforces + /// the storage contract so nothing can write ciphertext unmarked or a protected row without its envelopes. + /// Every read resolves envelopes for the ambient caller, so a row leaves here as plaintext (grant held) or + /// as the REDACTED sentinel, never as ciphertext. /// public class RmsRecordValueService : IRmsRecordValueService { private readonly IRmsOperationalRecordDetailsRepository _details; + private readonly IRecordsProtectionService _protection; - public RmsRecordValueService(IRmsOperationalRecordDetailsRepository details) + public RmsRecordValueService(IRmsOperationalRecordDetailsRepository details, IRecordsProtectionService protection) { _details = details; + _protection = protection; } - public Task GetDraftAsync(int departmentId, string recordId) + /// Pre-ADP construction (unit tests and tools that never enroll a department). + public RmsRecordValueService(IRmsOperationalRecordDetailsRepository details) : this(details, null) { } + + public async Task GetDraftAsync(int departmentId, string recordId) { - return _details.GetDraftAsync(departmentId, recordId); + var row = await _details.GetDraftAsync(departmentId, recordId); + await RevealAsync(departmentId, row); + return row; } - public Task GetByRevisionAsync(int departmentId, string recordId, string revisionId) + public async Task GetByRevisionAsync(int departmentId, string recordId, string revisionId) { - return _details.GetByRevisionAsync(departmentId, recordId, revisionId); + var row = await _details.GetByRevisionAsync(departmentId, recordId, revisionId); + await RevealAsync(departmentId, row); + return row; } - public Task> GetDraftsForRecordsAsync(int departmentId, IEnumerable recordIds) + public async Task> GetDraftsForRecordsAsync(int departmentId, IEnumerable recordIds) { - return _details.GetDraftsForRecordsAsync(departmentId, recordIds); + var rows = (await _details.GetDraftsForRecordsAsync(departmentId, recordIds))?.ToList() ?? new List(); + if (_protection != null && rows.Count > 0) + await _protection.RevealDetailsAsync(departmentId, rows); + return rows; } - public Task InsertAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default) + public async Task InsertAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default) { + await ProtectAsync(details, cancellationToken); PrepareForStorage(details); - return _details.InsertAsync(details, cancellationToken, true); + return await _details.InsertAsync(details, cancellationToken, true); } - public Task UpdateAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default) + public async Task UpdateAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default) { + await ProtectAsync(details, cancellationToken); PrepareForStorage(details); - return _details.UpdateAsync(details, cancellationToken, true); + return await _details.UpdateAsync(details, cancellationToken, true); } - public Task SaveOrUpdateAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default) + public async Task SaveOrUpdateAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken = default) { + await ProtectAsync(details, cancellationToken); PrepareForStorage(details); - return _details.SaveOrUpdateAsync(details, cancellationToken, true); + return await _details.SaveOrUpdateAsync(details, cancellationToken, true); + } + + private async Task RevealAsync(int departmentId, RmsOperationalRecordDetail row) + { + if (_protection != null && row != null) + await _protection.RevealDetailsAsync(departmentId, new[] { row }); + } + + private async Task ProtectAsync(RmsOperationalRecordDetail details, CancellationToken cancellationToken) + { + if (_protection == null || details == null) + return; + + // A REDACTED placeholder can only be restored from the stored copy of the same row (the envelope's + // AAD is bound to the row key). A working-draft update has one; a revision copy never does, and the + // callers that write copies require a revealed draft before they get here. + RmsOperationalRecordDetail existing = null; + if (details.RevisionId == null && RmsProtectedFields.Details.Values.Any(a => a.Get(details) == ProtectedDataEnvelope.RedactionValue)) + existing = await _details.GetDraftAsync(details.DepartmentId, details.RecordId); + + await _protection.ProtectDetailsAsync(details.DepartmentId, details, existing, null, cancellationToken); + + // Nothing to seal (every cataloged column empty) leaves no envelope behind; the marker then says the + // row is unprotected, which is what the enrollment sweep will find true when it looks. + if (details.IsProtected && string.IsNullOrEmpty(details.ProtectedEnvelope) && !RmsProtectedFields.Details.Values.Any(a => ProtectedDataEnvelope.HasEnvelopePrefix(a.Get(details)))) + { + details.IsProtected = false; + details.ProtectedCatalogVersion = 0; + } } /// - /// The inert protection contract. An unprotected row carries no envelope and catalog version 0; a row - /// marked protected must carry its envelope (the typed columns are what enrollment nulls out). Anything - /// else is a caller writing around the enrollment path and is refused. + /// The storage contract. An unprotected row carries no envelope (neither the legacy row envelope nor an + /// in-column one) and catalog version 0; a row marked protected carries at least one. Anything else is a + /// caller writing around the enrollment path and is refused. /// public static void PrepareForStorage(RmsOperationalRecordDetail details) { if (details == null) throw new ArgumentNullException(nameof(details)); + var hasColumnEnvelope = RmsProtectedFields.Details.Values.Any(a => ProtectedDataEnvelope.HasEnvelopePrefix(a.Get(details))); + if (RmsProtectedFields.Details.Values.Any(a => a.Get(details) == ProtectedDataEnvelope.RedactionValue)) + throw new InvalidOperationException("A REDACTED placeholder cannot be stored as record content; reveal the row before saving it."); + if (!details.IsProtected) { - if (!string.IsNullOrEmpty(details.ProtectedEnvelope)) + if (!string.IsNullOrEmpty(details.ProtectedEnvelope) || hasColumnEnvelope) throw new InvalidOperationException("A protected envelope was supplied for a record detail row that is not marked protected; envelopes are written only through Protected Data enrollment."); details.ProtectedCatalogVersion = 0; return; } - if (string.IsNullOrEmpty(details.ProtectedEnvelope)) + if (string.IsNullOrEmpty(details.ProtectedEnvelope) && !hasColumnEnvelope) throw new InvalidOperationException("A record detail row marked protected must carry its envelope."); } } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 55276722..d733aad4 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -181,6 +181,7 @@ protected override void Load(ContainerBuilder builder) // ProtectedDataBrokerClientModule (client only — no key material). builder.RegisterType() .As().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // The real engine is registered everywhere but only functions where a real key wrapping // provider resolves (LocalDev for synthetic testing; the broker host in production). On @@ -238,6 +239,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); // Value seam (plan 5.9.1): the only caller of the details repository; enrollment hooks in here. + builder.RegisterType().As().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -274,6 +276,13 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS-3: command key-time and contact/preplan feeds, the read-only NFIRS crosswalk rendering, and the + // RecordOperationalSummaryV1 contract for downstream consumers (plan sections 4.2, 4.3, 5.1, 4.7). + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // Department report exports via the Workflow system (RMS plan section 5.6, registry M0177, worker 45) + builder.RegisterType().As().InstancePerLifetimeScope(); // 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/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 7a6e38eb..b2b8f4f5 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -521,6 +521,17 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve case WorkflowTriggerEventType.RecordSubmissionRejected: case WorkflowTriggerEventType.RecordSubmissionFailed: case WorkflowTriggerEventType.RecordOverdue: + case WorkflowTriggerEventType.RecordApproved: + case WorkflowTriggerEventType.RecordAttachmentAdded: + case WorkflowTriggerEventType.RecordDisclosureRequested: + case WorkflowTriggerEventType.RecordDisclosureProduced: + case WorkflowTriggerEventType.RecordDisclosureReleased: + case WorkflowTriggerEventType.RecordDisclosureClosed: + case WorkflowTriggerEventType.RecordLegalHoldPlaced: + case WorkflowTriggerEventType.RecordLegalHoldReleased: + case WorkflowTriggerEventType.RecordEvidenceCaptured: + case WorkflowTriggerEventType.RecordPurged: + case WorkflowTriggerEventType.RecordExportScheduled: AddRecordsSamples(obj, eventType); break; } @@ -581,6 +592,25 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType case WorkflowTriggerEventType.RecordCancelled: currentState = "Cancelled"; break; + case WorkflowTriggerEventType.RecordApproved: + previousState = "ReadyForReview"; + currentState = "Approved"; + break; + case WorkflowTriggerEventType.RecordAttachmentAdded: + case WorkflowTriggerEventType.RecordEvidenceCaptured: + case WorkflowTriggerEventType.RecordLegalHoldPlaced: + case WorkflowTriggerEventType.RecordLegalHoldReleased: + recordNumber = "TRN-2026-0042"; + revisionNumber = 1; + revisionId = "0f3c1d2e-5b6a-4c7d-8e9f-0a1b2c3d4e5f"; + currentState = "Finalized"; + previousState = "Finalized"; + finalizedOn = DateTime.Now.AddDays(-1); + break; + case WorkflowTriggerEventType.RecordPurged: + previousState = "Finalized"; + currentState = "Purged"; + break; } var e = new ScriptObject(); @@ -597,6 +627,7 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType var r = new ScriptObject(); r["id"] = "9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a"; + r["kind"] = "Operational"; r["record_number"] = recordNumber; r["draft_reference"] = "D-7Q2MX"; r["definition_key"] = "system.training"; @@ -664,6 +695,142 @@ private static void AddRecordsSamples(ScriptObject obj, WorkflowTriggerEventType obj["obligation"] = o; } + var protection = new ScriptObject(); + protection["is_protected"] = false; + protection["is_redacted"] = false; + protection["redacted_fields"] = new ScriptArray(); + protection["protected_catalog_version"] = 0; + obj["protection"] = protection; + + if (eventType == WorkflowTriggerEventType.RecordApproved) + { + var approval = new ScriptObject(); + approval["reviewer_user_id"] = "00000000-0000-0000-0000-000000000002"; + approval["approver_user_id"] = "00000000-0000-0000-0000-000000000002"; + approval["approved_on"] = DateTime.Now; + approval["submitted_for_review_on"] = DateTime.Now.AddHours(-5); + approval["review_due_on"] = DateTime.Now.AddHours(43); + approval["return_count"] = 0; + obj["review"] = approval; + } + + if (eventType == WorkflowTriggerEventType.RecordAttachmentAdded) + { + var a = new ScriptObject(); + a["id"] = "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8"; + a["content_type"] = "application/pdf"; + a["byte_size"] = 184320; + a["checksum"] = "9b74c9897bac770ffc029102a200c5de7cbb3d8bd2e6f9a5c14f1f2f8a1d0c11"; + a["classification"] = "Unrestricted"; + a["scan_state"] = "Clean"; + a["uploaded_by_user_id"] = "00000000-0000-0000-0000-000000000001"; + a["uploaded_on"] = DateTime.Now; + a["count"] = 2; + obj["attachment"] = a; + } + + if (eventType >= WorkflowTriggerEventType.RecordDisclosureRequested && eventType <= WorkflowTriggerEventType.RecordDisclosureClosed) + { + r["kind"] = "Disclosure"; + var produced = eventType != WorkflowTriggerEventType.RecordDisclosureRequested; + var d = new ScriptObject(); + d["request_id"] = "6d7e8f9a-0b1c-4d2e-8f3a-4b5c6d7e8f9a"; + d["request_number"] = "PRR-2026-0007"; + d["state"] = eventType == WorkflowTriggerEventType.RecordDisclosureRequested ? "Received" : eventType == WorkflowTriggerEventType.RecordDisclosureProduced ? "Produced" : eventType == WorkflowTriggerEventType.RecordDisclosureReleased ? "Released" : "Closed"; + d["received_on"] = DateTime.Now.AddDays(-6); + d["statutory_due_on"] = DateTime.Now.AddDays(4); + d["jurisdiction_profile"] = "State public records act"; + d["redaction_profile"] = "Standard"; + d["assigned_to_user_id"] = "00000000-0000-0000-0000-000000000003"; + d["closed_on"] = eventType == WorkflowTriggerEventType.RecordDisclosureClosed || eventType == WorkflowTriggerEventType.RecordDisclosureReleased ? DateTime.Now : (DateTime?)null; + d["disposition"] = eventType == WorkflowTriggerEventType.RecordDisclosureClosed ? "Withdrawn" : eventType == WorkflowTriggerEventType.RecordDisclosureReleased ? "Released" : ""; + d["production_id"] = produced ? "7e8f9a0b-1c2d-4e3f-9a4b-5c6d7e8f9a0b" : ""; + d["production_number"] = produced ? 1 : (int?)null; + d["record_count"] = produced ? 3 : (int?)null; + d["withheld_field_count"] = produced ? 4 : (int?)null; + d["checksum"] = produced ? "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" : ""; + d["byte_size"] = produced ? 512000 : (int?)null; + d["released_on"] = eventType == WorkflowTriggerEventType.RecordDisclosureReleased ? DateTime.Now : (DateTime?)null; + d["released_by_user_id"] = eventType == WorkflowTriggerEventType.RecordDisclosureReleased ? "00000000-0000-0000-0000-000000000003" : ""; + d["delivery_method"] = eventType == WorkflowTriggerEventType.RecordDisclosureReleased ? "Secure email" : ""; + obj["disclosure"] = d; + } + + if (eventType == WorkflowTriggerEventType.RecordLegalHoldPlaced || eventType == WorkflowTriggerEventType.RecordLegalHoldReleased) + { + r["kind"] = "Operational"; + var released = eventType == WorkflowTriggerEventType.RecordLegalHoldReleased; + var h = new ScriptObject(); + h["id"] = "8f9a0b1c-2d3e-4f4a-8b5c-6d7e8f9a0b1c"; + h["record_id"] = "9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a"; + h["definition_key"] = ""; + h["period_start"] = (DateTime?)null; + h["period_end"] = (DateTime?)null; + h["reason"] = "Litigation"; + h["placed_by_user_id"] = "00000000-0000-0000-0000-000000000003"; + h["placed_on"] = DateTime.Now.AddDays(released ? -30 : 0); + h["released_by_user_id"] = released ? "00000000-0000-0000-0000-000000000003" : ""; + h["released_on"] = released ? DateTime.Now : (DateTime?)null; + h["is_released"] = released; + obj["legal_hold"] = h; + } + + if (eventType == WorkflowTriggerEventType.RecordEvidenceCaptured) + { + r["kind"] = "Operational"; + var ev = new ScriptObject(); + ev["id"] = "0b1c2d3e-4f5a-4b6c-8d7e-8f9a0b1c2d3e"; + ev["record_id"] = "9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a"; + ev["record_kind"] = "Operational"; + ev["kind"] = "RunCardActivation"; + ev["source_subsystem"] = "Dispatch"; + ev["source_entity_type"] = "RunCardActivation"; + ev["source_entity_id"] = "4471"; + ev["classification"] = "Unrestricted"; + ev["checksum"] = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + ev["byte_size"] = 2048; + ev["source_item_count"] = 3; + ev["coverage_start"] = DateTime.Now.AddHours(-3); + ev["coverage_end"] = DateTime.Now.AddHours(-1); + ev["captured_by_user_id"] = "00000000-0000-0000-0000-000000000001"; + ev["captured_on"] = DateTime.Now; + obj["evidence"] = ev; + } + + if (eventType == WorkflowTriggerEventType.RecordPurged) + { + r["kind"] = "Operational"; + var p = new ScriptObject(); + p["purged_on"] = DateTime.Now; + p["attachments_purged"] = 2; + p["search_erasure_pending"] = true; + p["reason"] = "Retention period elapsed (7 years)"; + obj["purge"] = p; + } + + if (eventType == WorkflowTriggerEventType.RecordExportScheduled) + { + r["kind"] = "Export"; + var x = new ScriptObject(); + x["run_id"] = "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f"; + x["template_id"] = "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a"; + x["template_key"] = "state-monthly-runs"; + x["template_name"] = "State monthly run report"; + x["format"] = "Csv"; + x["scope"] = "Window"; + x["window_start"] = DateTime.Now.AddMonths(-1); + x["window_end"] = DateTime.Now; + x["record_count"] = 127; + x["file_name"] = "state-monthly-runs-20260901-0600.csv"; + x["content_type"] = "text/csv"; + x["byte_size"] = 48213; + x["checksum"] = "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a"; + x["redacted"] = false; + x["generated_on"] = DateTime.Now; + x["expires_on"] = DateTime.Now.AddDays(30); + obj["export"] = x; + } + if (eventType == WorkflowTriggerEventType.RecordSubmittedForReview || eventType == WorkflowTriggerEventType.RecordReturnedForCorrection) { var returned = eventType == WorkflowTriggerEventType.RecordReturnedForCorrection; diff --git a/Core/Resgrid.Services/WorkflowService.cs b/Core/Resgrid.Services/WorkflowService.cs index 76424538..1c6eefce 100644 --- a/Core/Resgrid.Services/WorkflowService.cs +++ b/Core/Resgrid.Services/WorkflowService.cs @@ -5,9 +5,11 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -27,6 +29,7 @@ public class WorkflowService : IWorkflowService private readonly IWorkflowActionExecutorFactory _executorFactory; private readonly IWorkflowTemplateContextBuilder _contextBuilder; private readonly ISubscriptionsService _subscriptionsService; + private readonly IRecordsExportService _recordsExportService; public WorkflowService( IWorkflowRepository workflowRepository, @@ -38,8 +41,10 @@ public WorkflowService( IEncryptionService encryptionService, IWorkflowActionExecutorFactory executorFactory, IWorkflowTemplateContextBuilder contextBuilder, - ISubscriptionsService subscriptionsService) + ISubscriptionsService subscriptionsService, + IRecordsExportService recordsExportService) { + _recordsExportService = recordsExportService; _workflowRepository = workflowRepository; _stepRepository = stepRepository; _credentialRepository = credentialRepository; @@ -235,6 +240,50 @@ public async Task DeleteCredentialAsync(string credentialId, CancellationT // ── Execution ───────────────────────────────────────────────────────────────── + /// The export template a step's ActionConfig names (designer key recordsExportTemplateId), or null. + public static string ReadExportTemplateId(string actionConfigJson) + { + if (string.IsNullOrWhiteSpace(actionConfigJson)) + return null; + try + { + var config = JObject.Parse(actionConfigJson); + var token = config.GetValue("recordsExportTemplateId", StringComparison.OrdinalIgnoreCase); + var value = token?.Type == JTokenType.String ? (string)token : null; + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + catch (JsonException) + { + return null; + } + } + + /// record.id / record.kind / export.run_id from the dispatched Records payload, for the export render. + public static (string recordId, RmsRecordKind? recordKind, string scheduledRunId) ReadExportSubject(string eventPayloadJson) + { + try + { + var evt = string.IsNullOrWhiteSpace(eventPayloadJson) ? null : JsonConvert.DeserializeObject(eventPayloadJson); + var payload = evt?.Payload; + if (payload == null) + return (null, null, null); + + var record = payload["record"] as JObject; + var recordId = record?["id"]?.Type == JTokenType.String ? (string)record["id"] : null; + RmsRecordKind? kind = null; + var kindName = record?["kind"]?.Type == JTokenType.String ? (string)record["kind"] : null; + if (Enum.TryParse(kindName, true, out var parsed)) + kind = parsed; + var export = payload["export"] as JObject; + var runId = export?["run_id"]?.Type == JTokenType.String ? (string)export["run_id"] : null; + return (recordId, kind, runId); + } + catch (JsonException) + { + return (null, null, null); + } + } + public async Task ExecuteWorkflowAsync( string workflowId, string eventPayloadJson, @@ -497,6 +546,54 @@ public async Task ExecuteWorkflowAsync( cred.EncryptedData, departmentId, departmentCode); } + // ── Records report export attachment (RMS plan section 5.6) ───── + // A step that names an export template carries the rendered file: email actions attach it, + // file actions upload it. The render happens here, inside the run, so the run log records what + // was sent and an ADP-redacted export is visible as such. + WorkflowAttachment attachment = null; + var exportTemplateId = ReadExportTemplateId(renderedActionConfig); + if (!string.IsNullOrWhiteSpace(exportTemplateId)) + { + if (!WorkflowTriggerEventTypes.IsRecordsTrigger(triggerEventType)) + { + sw.Stop(); + logEntry.Status = (int)WorkflowRunStatus.Failed; + logEntry.ErrorMessage = "A report export can only be attached to a Records trigger."; + logEntry.DurationMs = sw.ElapsedMilliseconds; + logEntry.CompletedOn = DateTime.UtcNow; + await _runLogRepository.InsertAsync(logEntry, cancellationToken); + anyFailure = true; + continue; + } + + try + { + var (recordId, recordKind, scheduledRunId) = ReadExportSubject(eventPayloadJson); + var exportRun = await _recordsExportService.ResolveForWorkflowAsync(departmentId, exportTemplateId, recordId, recordKind, scheduledRunId, run.WorkflowRunId, cancellationToken); + attachment = new WorkflowAttachment + { + FileName = exportRun.FileName, + ContentType = exportRun.ContentType, + Data = exportRun.Data, + Redacted = exportRun.Redacted, + ExportRunId = exportRun.RmsExportRunId + }; + } + catch (Exception exportEx) when (!(exportEx is OperationCanceledException && cancellationToken.IsCancellationRequested)) + { + sw.Stop(); + logEntry.Status = (int)WorkflowRunStatus.Failed; + logEntry.ErrorMessage = $"Report export failed: {exportEx.Message}"; + logEntry.DurationMs = sw.ElapsedMilliseconds; + logEntry.CompletedOn = DateTime.UtcNow; + await _runLogRepository.InsertAsync(logEntry, cancellationToken); + anyFailure = true; + Logging.LogException(exportEx); + continue; + } + } + // ── End export attachment ──────────────────────────────────────── + var context = new WorkflowActionContext { RenderedContent = renderedContent, @@ -507,7 +604,8 @@ public async Task ExecuteWorkflowAsync( WorkflowRunId = run.WorkflowRunId, DepartmentId = departmentId, ActionType = step.ActionType, - IsFreePlanDepartment = isFreePlan + IsFreePlanDepartment = isFreePlan, + Attachment = attachment }; var executor = _executorFactory.GetExecutor((WorkflowActionType)step.ActionType); @@ -520,9 +618,12 @@ public async Task ExecuteWorkflowAsync( if (result.Success) { logEntry.Status = (int)WorkflowRunStatus.Completed; - logEntry.ActionResult = result.ResultMessage?.Length > 4000 - ? result.ResultMessage.Substring(0, 4000) - : result.ResultMessage; + var resultMessage = attachment == null + ? result.ResultMessage + : $"{result.ResultMessage} [export {attachment.ExportRunId}: {attachment.FileName}, {attachment.Data?.Length ?? 0} bytes{(attachment.Redacted ? ", protected fields withheld" : string.Empty)}]"; + logEntry.ActionResult = resultMessage?.Length > 4000 + ? resultMessage.Substring(0, 4000) + : resultMessage; // Record daily usage for outbound messaging actions if (actionType == WorkflowActionType.SendEmail || actionType == WorkflowActionType.SendSms) diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index d52ce4e5..d2bcb7a2 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -412,6 +412,17 @@ public async Task BuildContextAsync( case WorkflowTriggerEventType.RecordSubmissionRejected: case WorkflowTriggerEventType.RecordSubmissionFailed: case WorkflowTriggerEventType.RecordOverdue: + case WorkflowTriggerEventType.RecordApproved: + case WorkflowTriggerEventType.RecordAttachmentAdded: + case WorkflowTriggerEventType.RecordDisclosureRequested: + case WorkflowTriggerEventType.RecordDisclosureProduced: + case WorkflowTriggerEventType.RecordDisclosureReleased: + case WorkflowTriggerEventType.RecordDisclosureClosed: + case WorkflowTriggerEventType.RecordLegalHoldPlaced: + case WorkflowTriggerEventType.RecordLegalHoldReleased: + case WorkflowTriggerEventType.RecordEvidenceCaptured: + case WorkflowTriggerEventType.RecordPurged: + case WorkflowTriggerEventType.RecordExportScheduled: { // Records (RMS): the payload is the outbox snapshot carried by RecordsWorkflowEvent; it is never // rehydrated from current record state, so a retry sees exactly what the original run saw. @@ -1207,6 +1218,20 @@ private static string MapRecordsEventVariables(ScriptObject obj, RecordsWorkflow if (payload["obligation"] is JObject obligation) obj["obligation"] = ToScriptObject(obligation); + // RMS-3e blocks (plan section 5.6): each is present only on the triggers that carry it. + foreach (var name in new[] { "attachment", "disclosure", "legal_hold", "evidence", "purge", "export" }) + { + if (payload[name] is JObject block) + obj[name] = ToScriptObject(block); + } + + // protection.* is on every Records event since ADP catalog v10; older outbox rows carry none, so the + // template sees the unprotected shape rather than a missing namespace. + var protection = payload["protection"] as JObject; + obj["protection"] = protection != null + ? ToScriptObject(protection) + : new ScriptObject { ["is_protected"] = false, ["is_redacted"] = false, ["redacted_fields"] = new ScriptArray(), ["protected_catalog_version"] = 0 }; + return recordToken?["author_user_id"]?.Type == JTokenType.String ? (string)recordToken["author_user_id"] : null; } diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0176_RmsProtectedDataCatalogV10.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0176_RmsProtectedDataCatalogV10.cs new file mode 100644 index 00000000..baf1d832 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0176_RmsProtectedDataCatalogV10.cs @@ -0,0 +1,65 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// RMS Advanced Data Protection integration (RMS plan section 5.9, ADP catalog v10): protection markers on + /// every RMS table that carries a cataloged column, companion envelopes for the numeric coordinates, and the + /// NERIS profile's protected-egress acknowledgement. Every column is guarded so a table that already shipped + /// its markers inert (RMS-1/2/3) is left alone. + /// + [Migration(176)] + public class M0176_RmsProtectedDataCatalogV10 : Migration + { + private static readonly string[] MarkerTables = + { + "RmsOperationalRecordDetails", "RmsNarratives", "RmsLocations", "RmsSourceFacts", "RmsCasualtyRescues", "RmsExposures", + "RmsIncidentModules", "RmsIncidentProperties", "RmsIncidentVehicles", "RmsIncidentResources", "RmsRevisions", + "RmsSubmissions", "RmsSignatures", "RmsEvidenceArtifacts", "RmsDisclosureRequests", "RmsDisclosureProductions", + "RmsRecordLegalHolds", "RmsRecordAttachments" + }; + + public override void Up() + { + foreach (var table in MarkerTables) + { + if (!Schema.Table(table).Exists()) + continue; + if (!Schema.Table(table).Column("IsProtected").Exists()) + Alter.Table(table).AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table(table).Column("ProtectedCatalogVersion").Exists()) + Alter.Table(table).AddColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0); + } + + foreach (var table in new[] { "RmsLocations", "RmsExposures" }) + { + if (!Schema.Table(table).Column("ProtectedLatitudeEnvelope").Exists()) + Alter.Table(table).AddColumn("ProtectedLatitudeEnvelope").AsString(int.MaxValue).Nullable(); + if (!Schema.Table(table).Column("ProtectedLongitudeEnvelope").Exists()) + Alter.Table(table).AddColumn("ProtectedLongitudeEnvelope").AsString(int.MaxValue).Nullable(); + } + + if (!Schema.Table("RmsNerisProfiles").Column("AllowProtectedContentEgress").Exists()) + Alter.Table("RmsNerisProfiles").AddColumn("AllowProtectedContentEgress").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table("RmsNerisProfiles").Column("ProtectedEgressAcknowledgedOn").Exists()) + Alter.Table("RmsNerisProfiles").AddColumn("ProtectedEgressAcknowledgedOn").AsDateTime2().Nullable(); + if (!Schema.Table("RmsNerisProfiles").Column("ProtectedEgressAcknowledgedByUserId").Exists()) + Alter.Table("RmsNerisProfiles").AddColumn("ProtectedEgressAcknowledgedByUserId").AsString(128).Nullable(); + } + + public override void Down() + { + foreach (var table in new[] { "RmsLocations", "RmsExposures" }) + { + if (Schema.Table(table).Column("ProtectedLatitudeEnvelope").Exists()) + Delete.Column("ProtectedLatitudeEnvelope").FromTable(table); + if (Schema.Table(table).Column("ProtectedLongitudeEnvelope").Exists()) + Delete.Column("ProtectedLongitudeEnvelope").FromTable(table); + } + foreach (var column in new[] { "AllowProtectedContentEgress", "ProtectedEgressAcknowledgedOn", "ProtectedEgressAcknowledgedByUserId" }) + if (Schema.Table("RmsNerisProfiles").Column(column).Exists()) + Delete.Column(column).FromTable("RmsNerisProfiles"); + // Marker columns that predate this migration stay; the ones it added are harmless to keep. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0177_AddRmsExportTemplates.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0177_AddRmsExportTemplates.cs new file mode 100644 index 00000000..5547f371 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0177_AddRmsExportTemplates.cs @@ -0,0 +1,96 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Department-authored report exports (RMS plan section 5.6): the export template a department designs and + /// the stored runs a Workflow step or the schedule sweep (worker 45) renders from it. + /// + [Migration(177)] + public class M0177_AddRmsExportTemplates : Migration + { + public override void Up() + { + if (!Schema.Table("RmsExportTemplates").Exists()) + { + Create.Table("RmsExportTemplates") + .WithColumn("RmsExportTemplateId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("TemplateKey").AsString(64).NotNullable() + .WithColumn("Name").AsString(200).NotNullable() + .WithColumn("Description").AsString(1000).Nullable() + .WithColumn("Format").AsInt32().NotNullable() + .WithColumn("Scope").AsInt32().NotNullable() + .WithColumn("DefinitionKeysCsv").AsString(1000).Nullable() + .WithColumn("ColumnsJson").AsString(int.MaxValue).NotNullable() + .WithColumn("IncludeNarrative").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IncludeRestricted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("EgressAcknowledgedOn").AsDateTime2().Nullable() + .WithColumn("EgressAcknowledgedByUserId").AsString(128).Nullable() + .WithColumn("FileNameTemplate").AsString(200).Nullable() + .WithColumn("IncludeHeader").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("Delimiter").AsString(4).Nullable() + .WithColumn("ScheduleKind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ScheduleHourLocal").AsInt32().NotNullable().WithDefaultValue(6) + .WithColumn("ScheduleDayOfWeek").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("ScheduleDayOfMonth").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("WindowDays").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("NextRunOn").AsDateTime2().Nullable() + .WithColumn("LastRunOn").AsDateTime2().Nullable() + .WithColumn("IsEnabled").AsBoolean().NotNullable().WithDefaultValue(true) + .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("UX_RmsExportTemplates_Key").OnTable("RmsExportTemplates") + .OnColumn("DepartmentId").Ascending().OnColumn("TemplateKey").Ascending().WithOptions().Unique(); + Create.Index("IX_RmsExportTemplates_Due").OnTable("RmsExportTemplates") + .OnColumn("IsEnabled").Ascending().OnColumn("NextRunOn").Ascending(); + } + + if (!Schema.Table("RmsExportRuns").Exists()) + { + Create.Table("RmsExportRuns") + .WithColumn("RmsExportRunId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("ProtectionId").AsString(36).Nullable() + .WithColumn("TemplateId").AsString(36).NotNullable() + .WithColumn("TemplateKey").AsString(64).NotNullable() + .WithColumn("Trigger").AsInt32().NotNullable() + .WithColumn("RecordId").AsString(36).Nullable() + .WithColumn("WindowStart").AsDateTime2().Nullable() + .WithColumn("WindowEnd").AsDateTime2().Nullable() + .WithColumn("RecordCount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("FileName").AsString(260).NotNullable() + .WithColumn("ContentType").AsString(100).NotNullable() + .WithColumn("ByteSize").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("Checksum").AsString(80).NotNullable() + .WithColumn("Data").AsBinary(int.MaxValue).Nullable() + .WithColumn("Redacted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RedactedFieldsJson").AsString(int.MaxValue).Nullable() + .WithColumn("GeneratedOn").AsDateTime2().NotNullable() + .WithColumn("GeneratedByUserId").AsString(128).Nullable() + .WithColumn("WorkflowRunId").AsString(36).Nullable() + .WithColumn("ExpiresOn").AsDateTime2().NotNullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("DeletedOn").AsDateTime2().Nullable(); + Create.Index("IX_RmsExportRuns_Template").OnTable("RmsExportRuns") + .OnColumn("DepartmentId").Ascending().OnColumn("TemplateId").Ascending().OnColumn("GeneratedOn").Descending(); + Create.Index("IX_RmsExportRuns_Expires").OnTable("RmsExportRuns") + .OnColumn("DepartmentId").Ascending().OnColumn("ExpiresOn").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("RmsExportRuns").Exists()) + Delete.Table("RmsExportRuns"); + if (Schema.Table("RmsExportTemplates").Exists()) + Delete.Table("RmsExportTemplates"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0176_RmsProtectedDataCatalogV10Pg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0176_RmsProtectedDataCatalogV10Pg.cs new file mode 100644 index 00000000..c05bb3d9 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0176_RmsProtectedDataCatalogV10Pg.cs @@ -0,0 +1,64 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// RMS Advanced Data Protection integration (RMS plan section 5.9, ADP catalog v10): protection markers on + /// every RMS table that carries a cataloged column, companion envelopes for the numeric coordinates, and the + /// NERIS profile's protected-egress acknowledgement. Every column is guarded so a table that already shipped + /// its markers inert (RMS-1/2/3) is left alone. + /// + [Migration(176)] + public class M0176_RmsProtectedDataCatalogV10Pg : Migration + { + private static readonly string[] MarkerTables = + { + "rmsoperationalrecorddetails", "rmsnarratives", "rmslocations", "rmssourcefacts", "rmscasualtyrescues", "rmsexposures", + "rmsincidentmodules", "rmsincidentproperties", "rmsincidentvehicles", "rmsincidentresources", "rmsrevisions", + "rmssubmissions", "rmssignatures", "rmsevidenceartifacts", "rmsdisclosurerequests", "rmsdisclosureproductions", + "rmsrecordlegalholds", "rmsrecordattachments" + }; + + public override void Up() + { + foreach (var table in MarkerTables) + { + if (!Schema.Table(table).Exists()) + continue; + if (!Schema.Table(table).Column("isprotected").Exists()) + Alter.Table(table).AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table(table).Column("protectedcatalogversion").Exists()) + Alter.Table(table).AddColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0); + } + + foreach (var table in new[] { "rmslocations", "rmsexposures" }) + { + if (!Schema.Table(table).Column("protectedlatitudeenvelope").Exists()) + Alter.Table(table).AddColumn("protectedlatitudeenvelope").AsCustom("text").Nullable(); + if (!Schema.Table(table).Column("protectedlongitudeenvelope").Exists()) + Alter.Table(table).AddColumn("protectedlongitudeenvelope").AsCustom("text").Nullable(); + } + + if (!Schema.Table("rmsnerisprofiles").Column("allowprotectedcontentegress").Exists()) + Alter.Table("rmsnerisprofiles").AddColumn("allowprotectedcontentegress").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table("rmsnerisprofiles").Column("protectedegressacknowledgedon").Exists()) + Alter.Table("rmsnerisprofiles").AddColumn("protectedegressacknowledgedon").AsDateTime2().Nullable(); + if (!Schema.Table("rmsnerisprofiles").Column("protectedegressacknowledgedbyuserid").Exists()) + Alter.Table("rmsnerisprofiles").AddColumn("protectedegressacknowledgedbyuserid").AsString(128).Nullable(); + } + + public override void Down() + { + foreach (var table in new[] { "rmslocations", "rmsexposures" }) + { + if (Schema.Table(table).Column("protectedlatitudeenvelope").Exists()) + Delete.Column("protectedlatitudeenvelope").FromTable(table); + if (Schema.Table(table).Column("protectedlongitudeenvelope").Exists()) + Delete.Column("protectedlongitudeenvelope").FromTable(table); + } + foreach (var column in new[] { "allowprotectedcontentegress", "protectedegressacknowledgedon", "protectedegressacknowledgedbyuserid" }) + if (Schema.Table("rmsnerisprofiles").Column(column).Exists()) + Delete.Column(column).FromTable("rmsnerisprofiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0177_AddRmsExportTemplatesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0177_AddRmsExportTemplatesPg.cs new file mode 100644 index 00000000..840717e5 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0177_AddRmsExportTemplatesPg.cs @@ -0,0 +1,96 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Department-authored report exports (RMS plan section 5.6): the export template a department designs and + /// the stored runs a Workflow step or the schedule sweep (worker 45) renders from it. + /// + [Migration(177)] + public class M0177_AddRmsExportTemplatesPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmsexporttemplates").Exists()) + { + Create.Table("rmsexporttemplates") + .WithColumn("rmsexporttemplateid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("templatekey").AsString(64).NotNullable() + .WithColumn("name").AsString(200).NotNullable() + .WithColumn("description").AsString(1000).Nullable() + .WithColumn("format").AsInt32().NotNullable() + .WithColumn("scope").AsInt32().NotNullable() + .WithColumn("definitionkeyscsv").AsString(1000).Nullable() + .WithColumn("columnsjson").AsCustom("text").NotNullable() + .WithColumn("includenarrative").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("includerestricted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("egressacknowledgedon").AsDateTime2().Nullable() + .WithColumn("egressacknowledgedbyuserid").AsString(128).Nullable() + .WithColumn("filenametemplate").AsString(200).Nullable() + .WithColumn("includeheader").AsBoolean().NotNullable().WithDefaultValue(true) + .WithColumn("delimiter").AsString(4).Nullable() + .WithColumn("schedulekind").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("schedulehourlocal").AsInt32().NotNullable().WithDefaultValue(6) + .WithColumn("scheduledayofweek").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("scheduledayofmonth").AsInt32().NotNullable().WithDefaultValue(1) + .WithColumn("windowdays").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("nextrunon").AsDateTime2().Nullable() + .WithColumn("lastrunon").AsDateTime2().Nullable() + .WithColumn("isenabled").AsBoolean().NotNullable().WithDefaultValue(true) + .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("ux_rmsexporttemplates_key").OnTable("rmsexporttemplates") + .OnColumn("departmentid").Ascending().OnColumn("templatekey").Ascending().WithOptions().Unique(); + Create.Index("ix_rmsexporttemplates_due").OnTable("rmsexporttemplates") + .OnColumn("isenabled").Ascending().OnColumn("nextrunon").Ascending(); + } + + if (!Schema.Table("rmsexportruns").Exists()) + { + Create.Table("rmsexportruns") + .WithColumn("rmsexportrunid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("protectionid").AsString(36).Nullable() + .WithColumn("templateid").AsString(36).NotNullable() + .WithColumn("templatekey").AsString(64).NotNullable() + .WithColumn("trigger").AsInt32().NotNullable() + .WithColumn("recordid").AsString(36).Nullable() + .WithColumn("windowstart").AsDateTime2().Nullable() + .WithColumn("windowend").AsDateTime2().Nullable() + .WithColumn("recordcount").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("filename").AsString(260).NotNullable() + .WithColumn("contenttype").AsString(100).NotNullable() + .WithColumn("bytesize").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("checksum").AsString(80).NotNullable() + .WithColumn("data").AsCustom("bytea").Nullable() + .WithColumn("redacted").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("redactedfieldsjson").AsCustom("text").Nullable() + .WithColumn("generatedon").AsDateTime2().NotNullable() + .WithColumn("generatedbyuserid").AsString(128).Nullable() + .WithColumn("workflowrunid").AsString(36).Nullable() + .WithColumn("expireson").AsDateTime2().NotNullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("deletedon").AsDateTime2().Nullable(); + Create.Index("ix_rmsexportruns_template").OnTable("rmsexportruns") + .OnColumn("departmentid").Ascending().OnColumn("templateid").Ascending().OnColumn("generatedon").Descending(); + Create.Index("ix_rmsexportruns_expires").OnTable("rmsexportruns") + .OnColumn("departmentid").Ascending().OnColumn("expireson").Ascending(); + } + } + + public override void Down() + { + if (Schema.Table("rmsexportruns").Exists()) + Delete.Table("rmsexportruns"); + if (Schema.Table("rmsexporttemplates").Exists()) + Delete.Table("rmsexporttemplates"); + } + } +} diff --git a/Providers/Resgrid.Providers.Neris/NerisContractCatalog.cs b/Providers/Resgrid.Providers.Neris/NerisContractCatalog.cs index 86592049..eabfd6fb 100644 --- a/Providers/Resgrid.Providers.Neris/NerisContractCatalog.cs +++ b/Providers/Resgrid.Providers.Neris/NerisContractCatalog.cs @@ -119,7 +119,8 @@ private static NerisContractCatalog Load() var contract = JObject.Parse(reader.ReadToEnd()); if ((string)contract["info"]?["version"] != NerisValueSetCatalog.Instance.ContractVersion) throw new InvalidOperationException("The NERIS schema and value-set versions do not match."); - return new NerisContractCatalog((JObject)contract["components"]["schemas"]); + return new NerisContractCatalog(contract["components"]?["schemas"] as JObject + ?? throw new InvalidOperationException("The pinned NERIS contract does not declare component schemas.")); } } } diff --git a/Providers/Resgrid.Providers.Neris/NerisProfileService.cs b/Providers/Resgrid.Providers.Neris/NerisProfileService.cs index e027c7d4..3c83defb 100644 --- a/Providers/Resgrid.Providers.Neris/NerisProfileService.cs +++ b/Providers/Resgrid.Providers.Neris/NerisProfileService.cs @@ -84,6 +84,20 @@ public async Task SaveProfileAsync(RmsNerisProfile profile, Ner target.ContractVersion = ContractVersion; target.AutoSubmitOnFinalize = profile.AutoSubmitOnFinalize; target.IsEnabled = profile.IsEnabled; + // The protected-egress acknowledgement (RMS plan 5.9.4) is stamped by whoever turns it on and cleared + // when it is turned off, so the audit trail names the decision maker rather than the last editor. + if (profile.AllowProtectedContentEgress && !target.AllowProtectedContentEgress) + { + target.AllowProtectedContentEgress = true; + target.ProtectedEgressAcknowledgedOn = now; + target.ProtectedEgressAcknowledgedByUserId = userId; + } + else if (!profile.AllowProtectedContentEgress) + { + target.AllowProtectedContentEgress = false; + target.ProtectedEgressAcknowledgedOn = null; + target.ProtectedEgressAcknowledgedByUserId = null; + } target.UpdatedByUserId = userId; target.ModifiedOn = now; target.RowVersion += 1; diff --git a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs index 2746218a..098890ad 100644 --- a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs +++ b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs @@ -102,6 +102,19 @@ public Task EncryptAsync(int departmentId, string gra IReadOnlyList items, CancellationToken cancellationToken = default) => SendAsync("api/v1/broker/encrypt", departmentId, grantToken, requestId, items, cancellationToken); + /// + /// The purpose-bound workload decrypt lane (RMS plan section 5.9.2): no grant, the workload key plus a + /// named purpose the broker must have been configured to allow for this department. A broker without + /// the lane answers 404 and the caller fails closed with workload_purpose_denied. + /// + public Task DecryptForWorkloadAsync(int departmentId, string purpose, string requestId, + IReadOnlyList items, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(purpose)) + return Task.FromResult(Failed("workload_purpose_denied")); + return SendAsync("api/v1/broker/workload/decrypt?purpose=" + Uri.EscapeDataString(purpose.Trim()), departmentId, null, requestId, items, cancellationToken); + } + private async Task SendAsync(string path, int departmentId, string grantToken, string requestId, IReadOnlyList items, CancellationToken cancellationToken) { diff --git a/Providers/Resgrid.Providers.Workflow/Executors/AzureBlobExecutor.cs b/Providers/Resgrid.Providers.Workflow/Executors/AzureBlobExecutor.cs index a5487b8b..eb68e4b3 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/AzureBlobExecutor.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/AzureBlobExecutor.cs @@ -33,7 +33,7 @@ public async Task ExecuteAsync(WorkflowActionContext conte return WorkflowActionResult.Failed("Azure Blob upload failed.", "Azure Blob credential is missing a 'ContainerName'. Please update the credential with the target container name."); var blobName = string.IsNullOrWhiteSpace(config.BlobName) - ? $"workflow/{DateTime.UtcNow:yyyy/MM/dd}/{DateTime.UtcNow:HHmmss}.txt" + ? $"workflow/{DateTime.UtcNow:yyyy/MM/dd}/{(context.Attachment?.FileName ?? $"{DateTime.UtcNow:HHmmss}.txt")}" : config.BlobName; var serviceClient = new BlobServiceClient(cred.ConnectionString); @@ -41,12 +41,12 @@ public async Task ExecuteAsync(WorkflowActionContext conte await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken); var blobClient = containerClient.GetBlobClient(blobName); - var bytes = Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); + var bytes = context.Attachment?.Data ?? Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); using var stream = new MemoryStream(bytes); var headers = new BlobHttpHeaders { - ContentType = string.IsNullOrWhiteSpace(config.ContentType) ? "text/plain" : config.ContentType + ContentType = context.Attachment?.ContentType ?? (string.IsNullOrWhiteSpace(config.ContentType) ? "text/plain" : config.ContentType) }; await blobClient.UploadAsync(stream, new BlobUploadOptions { HttpHeaders = headers }, cancellationToken); diff --git a/Providers/Resgrid.Providers.Workflow/Executors/BoxFileExecutor.cs b/Providers/Resgrid.Providers.Workflow/Executors/BoxFileExecutor.cs index d767b56a..f3468260 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/BoxFileExecutor.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/BoxFileExecutor.cs @@ -43,7 +43,7 @@ public async Task ExecuteAsync(WorkflowActionContext conte return WorkflowActionResult.Failed("Box upload failed.", "Box credential is missing 'PublicKeyId'. Please update the credential."); var filename = string.IsNullOrWhiteSpace(config.Filename) - ? $"workflow_{DateTime.UtcNow:yyyyMMddHHmmss}.txt" + ? context.Attachment?.FileName ?? $"workflow_{DateTime.UtcNow:yyyyMMddHHmmss}.txt" : config.Filename; var folderId = string.IsNullOrWhiteSpace(config.FolderId) ? "0" : config.FolderId; @@ -53,7 +53,7 @@ public async Task ExecuteAsync(WorkflowActionContext conte var adminToken = await session.AdminTokenAsync(); var client = session.AdminClient(adminToken); - var bytes = Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); + var bytes = context.Attachment?.Data ?? Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); using var stream = new MemoryStream(bytes); var file = await client.FilesManager.UploadAsync( new Box.V2.Models.BoxFileRequest { Name = filename, Parent = new Box.V2.Models.BoxRequestEntity { Id = folderId } }, diff --git a/Providers/Resgrid.Providers.Workflow/Executors/DropboxFileExecutor.cs b/Providers/Resgrid.Providers.Workflow/Executors/DropboxFileExecutor.cs index 518d7360..1dff552f 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/DropboxFileExecutor.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/DropboxFileExecutor.cs @@ -36,7 +36,7 @@ public async Task ExecuteAsync(WorkflowActionContext conte return WorkflowActionResult.Failed("Dropbox upload failed.", "Dropbox credential is missing 'AppSecret'. Please update the credential."); var filename = string.IsNullOrWhiteSpace(config.Filename) - ? $"workflow_{DateTime.UtcNow:yyyyMMddHHmmss}.txt" + ? context.Attachment?.FileName ?? $"workflow_{DateTime.UtcNow:yyyyMMddHHmmss}.txt" : config.Filename; var targetPath = $"{config.TargetPath?.TrimEnd('/')}/{filename}"; @@ -48,7 +48,7 @@ public async Task ExecuteAsync(WorkflowActionContext conte appKey: cred.AppKey, appSecret: cred.AppSecret, new DropboxClientConfig("ResgridWorkflow/1.0")); - var bytes = Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); + var bytes = context.Attachment?.Data ?? Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); using var stream = new MemoryStream(bytes); var metadata = await client.Files.UploadAsync(targetPath, writeMode, body: stream); diff --git a/Providers/Resgrid.Providers.Workflow/Executors/FtpFileExecutor.cs b/Providers/Resgrid.Providers.Workflow/Executors/FtpFileExecutor.cs index 6b105614..6e51882c 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/FtpFileExecutor.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/FtpFileExecutor.cs @@ -36,12 +36,12 @@ public async Task ExecuteAsync(WorkflowActionContext conte return WorkflowActionResult.Failed("FTP upload blocked.", ftpReason); // ── End SSRF protection ────────────────────────────────────────────── - var remotePath = $"{config.RemotePath?.TrimEnd('/')}/{config.Filename ?? $"workflow_{DateTime.UtcNow:yyyyMMddHHmmss}.txt"}"; + var remotePath = $"{config.RemotePath?.TrimEnd('/')}/{config.Filename ?? context.Attachment?.FileName ?? $"workflow_{DateTime.UtcNow:yyyyMMddHHmmss}.txt"}"; using var client = new AsyncFtpClient(cred.Host, cred.Username, cred.Password, cred.Port > 0 ? cred.Port : 21); await client.Connect(cancellationToken); - var bytes = Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); + var bytes = context.Attachment?.Data ?? Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); using var stream = new MemoryStream(bytes); await client.UploadStream(stream, remotePath, FtpRemoteExists.Overwrite, true,null, cancellationToken); await client.Disconnect(cancellationToken); diff --git a/Providers/Resgrid.Providers.Workflow/Executors/S3FileExecutor.cs b/Providers/Resgrid.Providers.Workflow/Executors/S3FileExecutor.cs index ac76572e..f13c0e25 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/S3FileExecutor.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/S3FileExecutor.cs @@ -38,14 +38,14 @@ public async Task ExecuteAsync(WorkflowActionContext conte return WorkflowActionResult.Failed("S3 upload failed.", "S3 credential is missing 'BucketName'. Please update the credential with the target S3 bucket name."); var key = string.IsNullOrWhiteSpace(config.S3Key) - ? $"workflow/{DateTime.UtcNow:yyyy/MM/dd}/{DateTime.UtcNow:HHmmss}.txt" + ? $"workflow/{DateTime.UtcNow:yyyy/MM/dd}/{(context.Attachment?.FileName ?? $"{DateTime.UtcNow:HHmmss}.txt")}" : config.S3Key; var awsCreds = new BasicAWSCredentials(cred.AccessKey, cred.SecretKey); var region = RegionEndpoint.GetBySystemName(string.IsNullOrWhiteSpace(cred.Region) ? "us-east-1" : cred.Region); using var s3Client = new AmazonS3Client(awsCreds, region); - var bytes = Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); + var bytes = context.Attachment?.Data ?? Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); using var stream = new MemoryStream(bytes); var uploadRequest = new TransferUtilityUploadRequest @@ -53,7 +53,7 @@ public async Task ExecuteAsync(WorkflowActionContext conte BucketName = cred.BucketName, Key = key, InputStream = stream, - ContentType = string.IsNullOrWhiteSpace(config.ContentType) ? "text/plain" : config.ContentType + ContentType = context.Attachment?.ContentType ?? (string.IsNullOrWhiteSpace(config.ContentType) ? "text/plain" : config.ContentType) }; var transferUtility = new TransferUtility(s3Client); diff --git a/Providers/Resgrid.Providers.Workflow/Executors/SftpFileExecutor.cs b/Providers/Resgrid.Providers.Workflow/Executors/SftpFileExecutor.cs index 3a0253fb..07e1d047 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/SftpFileExecutor.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/SftpFileExecutor.cs @@ -37,7 +37,7 @@ public async Task ExecuteAsync(WorkflowActionContext conte return WorkflowActionResult.Failed("SFTP upload blocked.", sftpReason); // ── End SSRF protection ────────────────────────────────────────────── - var filename = config.Filename ?? $"workflow_{DateTime.UtcNow:yyyyMMddHHmmss}.txt"; + var filename = config.Filename ?? context.Attachment?.FileName ?? $"workflow_{DateTime.UtcNow:yyyyMMddHHmmss}.txt"; var remotePath = $"{config.RemotePath?.TrimEnd('/')}/{filename}"; ConnectionInfo connectionInfo; @@ -62,7 +62,7 @@ await Task.Run(() => { using var client = new SftpClient(connectionInfo); client.Connect(); - var bytes = Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); + var bytes = context.Attachment?.Data ?? Encoding.UTF8.GetBytes(context.RenderedContent ?? string.Empty); using var stream = new MemoryStream(bytes); client.UploadFile(stream, remotePath, true); client.Disconnect(); diff --git a/Providers/Resgrid.Providers.Workflow/Executors/SmtpEmailExecutor.cs b/Providers/Resgrid.Providers.Workflow/Executors/SmtpEmailExecutor.cs index 441a14c2..f74ec0e9 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/SmtpEmailExecutor.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/SmtpEmailExecutor.cs @@ -118,6 +118,14 @@ public async Task ExecuteAsync(WorkflowActionContext conte // one by stripping HTML (which can lose
→ newline conversions). TextBody = rawContent }; + // A Records report export rides as a real attachment (RMS plan section 5.6). + if (context.Attachment?.Data != null) + { + var attachmentType = MimeKit.ContentType.TryParse(context.Attachment.ContentType ?? string.Empty, out var parsedType) + ? parsedType + : new MimeKit.ContentType("application", "octet-stream"); + bodyBuilder.Attachments.Add(context.Attachment.FileName ?? "export.dat", context.Attachment.Data, attachmentType); + } message.Body = bodyBuilder.ToMessageBody(); using var smtp = new SmtpClient(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index f21438e7..248d1818 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -351,6 +351,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS department report exports (registry M0177, worker 45) + 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 c3f6cef0..56638028 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -115,6 +115,13 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Kept in step with DataModule: the RMS-3 incident source feed resolves IncidentCommandService, which needs these. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -304,6 +311,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // RMS department report exports (registry M0177, worker 45) + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsExportRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsExportRepositories.cs new file mode 100644 index 00000000..5d9c4d32 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsExportRepositories.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +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 +{ + /// Department export templates (registry M0177). Every query begins at DepartmentId; the schedule sweep is the one cross-department read. + public class RmsExportTemplatesRepository : RmsRepositoryBase, IRmsExportTemplatesRepository + { + public RmsExportTemplatesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string templateId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsExportTemplates")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExportTemplateId")} = {P}Id AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, Id = templateId }); + } + + public Task GetByKeyAsync(int departmentId, string templateKey) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsExportTemplates")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("TemplateKey")} = {P}Key AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, Key = templateKey }); + } + + public Task> GetForDepartmentAsync(int departmentId) + { + return QueryAsync( + $"SELECT * FROM {Tbl("RmsExportTemplates")} 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 limit = Math.Clamp(take, 1, 500); + var sql = IsPostgres + ? $"SELECT * FROM {Tbl("RmsExportTemplates")} WHERE {Col("IsEnabled")} = TRUE AND {Col("ScheduleKind")} <> 0 AND {Col("NextRunOn")} IS NOT NULL AND {Col("NextRunOn")} <= {P}Now AND {Col("DeletedOn")} IS NULL ORDER BY {Col("NextRunOn")} LIMIT {limit}" + : $"SELECT TOP {limit} * FROM {Tbl("RmsExportTemplates")} WHERE {Col("IsEnabled")} = 1 AND {Col("ScheduleKind")} <> 0 AND {Col("NextRunOn")} IS NOT NULL AND {Col("NextRunOn")} <= {P}Now AND {Col("DeletedOn")} IS NULL ORDER BY {Col("NextRunOn")}"; + return QueryAsync(sql, new { Now = utcNow }); + } + + public async Task TryBumpRowVersionAsync(int departmentId, string templateId, long expectedVersion, CancellationToken cancellationToken = default) + { + return await ExecuteAsync( + $"UPDATE {Tbl("RmsExportTemplates")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExportTemplateId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, Id = templateId, Version = expectedVersion }, cancellationToken) == 1; + } + } + + /// Rendered export artifacts (registry M0177). The Data column is read only by the endpoints that serve the file. + public class RmsExportRunsRepository : RmsRepositoryBase, IRmsExportRunsRepository + { + private static readonly string MetadataColumns = Cols( + "RmsExportRunId", "DepartmentId", "ProtectionId", "TemplateId", "TemplateKey", "Trigger", "RecordId", "WindowStart", "WindowEnd", "RecordCount", + "FileName", "ContentType", "ByteSize", "Checksum", "Redacted", "RedactedFieldsJson", "GeneratedOn", "GeneratedByUserId", "WorkflowRunId", "ExpiresOn", + "IsProtected", "ProtectedCatalogVersion", "DeletedOn"); + + public RmsExportRunsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetByIdForDepartmentAsync(int departmentId, string runId) + { + return QueryFirstOrDefaultAsync( + $"SELECT {MetadataColumns} FROM {Tbl("RmsExportRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExportRunId")} = {P}Id AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, Id = runId }); + } + + public Task GetWithDataAsync(int departmentId, string runId) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsExportRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsExportRunId")} = {P}Id AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, Id = runId }); + } + + public Task> GetForTemplateAsync(int departmentId, string templateId, int take) + { + var limit = Math.Clamp(take, 1, 500); + var sql = IsPostgres + ? $"SELECT {MetadataColumns} FROM {Tbl("RmsExportRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("TemplateId")} = {P}TemplateId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("GeneratedOn")} DESC LIMIT {limit}" + : $"SELECT TOP {limit} {MetadataColumns} FROM {Tbl("RmsExportRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("TemplateId")} = {P}TemplateId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("GeneratedOn")} DESC"; + return QueryAsync(sql, new { DepartmentId = departmentId, TemplateId = templateId }); + } + + public Task DeleteExpiredAsync(int departmentId, DateTime utcNow, CancellationToken cancellationToken = default) + { + // The bytes go first and the row stays as a tombstone, so a run log that names the run still resolves. + return ExecuteAsync( + $"UPDATE {Tbl("RmsExportRuns")} SET {Col("Data")} = NULL, {Col("DeletedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ExpiresOn")} <= {P}Now AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, Now = utcNow }, cancellationToken); + } + } +} diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index c2360095..42ccd126 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -62,5 +62,16 @@ "RecordSubmissionAccepted": 109, "RecordSubmissionRejected": 110, "RecordSubmissionFailed": 111, - "RecordOverdue": 112 + "RecordOverdue": 112, + "RecordApproved": 103, + "RecordAttachmentAdded": 115, + "RecordDisclosureRequested": 152, + "RecordDisclosureProduced": 153, + "RecordDisclosureReleased": 154, + "RecordDisclosureClosed": 155, + "RecordLegalHoldPlaced": 156, + "RecordLegalHoldReleased": 157, + "RecordEvidenceCaptured": 158, + "RecordPurged": 159, + "RecordExportScheduled": 160 } diff --git a/Tests/Resgrid.Tests/Bootstrapper.cs b/Tests/Resgrid.Tests/Bootstrapper.cs index ff3bcbf2..696cec93 100644 --- a/Tests/Resgrid.Tests/Bootstrapper.cs +++ b/Tests/Resgrid.Tests/Bootstrapper.cs @@ -46,6 +46,9 @@ public static void Initialize() builder.RegisterModule(new MarketingModule()); builder.RegisterModule(new MessagingProviderModule()); builder.RegisterModule(new Resgrid.Providers.Workflow.WorkflowProviderModule()); + // IncidentCommandService takes the incident weather provider; the RMS-3 incident source feed + // resolves it, so the composition test needs the real module here. + builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); // Override real repository registrations with in-memory mocks so that // tests do not require a live database connection. @@ -71,11 +74,22 @@ public static void Initialize() builder.RegisterInstance(new Moq.Mock().Object) .As(); + // The RMS-3 incident source feed composes IIncidentReportingService, whose real graph reaches + // IncidentCommandService and the VoIP/voice transmission repositories that this container has + // never carried. A loose mock keeps the Records composition test about Records registrations. + builder.RegisterInstance(new Moq.Mock().Object) + .As(); + // ADP repositories are not part of the testing data module; loose mocks keep the // protection/projection/lock services resolvable. Un-setup members return null, which // reads as "no policy row" = Disabled — every safe view is then the original value. builder.RegisterInstance(new Moq.Mock().Object) .As(); + // The RMS protection seam (catalog v10) composes ProtectedReadService, whose broker client lives in + // the ProtectedData provider module the hosts register; a loose mock keeps the Records composition + // test about Records registrations. + builder.RegisterInstance(new Moq.Mock().Object) + .As(); builder.RegisterInstance(new Moq.Mock().Object) .As(); builder.RegisterInstance(new Moq.Mock().Object) diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs index 459dfb83..3affc65d 100644 --- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs +++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs @@ -77,6 +77,14 @@ private static Dictionary Load(string path) "DataProtection|de|AddonStatusLabel", // "Status" is the German word too. "DataProtection|pl|AddonStatusLabel", // Polish uses "Status" as well. "DataProtection|sv|AddonStatusLabel", // So does Swedish. + // Records report exports (2026-09-05): "Format", "Bytes" and "Description" are the target-language words. + "Records|de|ExportFormat", + "Records|de|ExportRunSize", + "Records|es|ExportRunSize", + "Records|fr|ExportFormat", + "Records|fr|ExportTemplateDescription", + "Records|pl|ExportFormat", + "Records|sv|ExportFormat", // Brand and protocol names carry across every language. "CommunicationTest|de|Push", "CommunicationTest|de|SMS", diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index a3d40b33..b5a0b796 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -2,6 +2,8 @@ net9.0 Debug;Release;Docker + + $(DefaultItemExcludes);Web\node_modules\** @@ -14,6 +16,9 @@ Always + + Always + Always diff --git a/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs index c91a0376..b3d8d7d4 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs @@ -69,7 +69,7 @@ public void SetUp() _store.ModulesRepo.Object, _store.PropertiesRepo.Object, _store.VehiclesRepo.Object, _store.IssuesRepo.Object, _store.SubmissionsRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _store.UnitOfWork.Object, _neris.Object, - new NerisMappingService(), new NerisValidationService(Mock.Of(), _neris.Object), Authorized()); + new NerisMappingService(), new NerisValidationService(Mock.Of(), _neris.Object), Authorized(), new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, Mock.Of())); } private IRecordsAuthorizationService Authorized() diff --git a/Tests/Resgrid.Tests/Rms/IncidentAttachmentTests.cs b/Tests/Resgrid.Tests/Rms/IncidentAttachmentTests.cs index 81d0c256..d22a7847 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentAttachmentTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentAttachmentTests.cs @@ -28,7 +28,8 @@ public void Setup() _auth.Setup(a => a.CanUserViewRecordAsync("officer", "report", 1)).ReturnsAsync(true); _auth.Setup(a => a.HasPermissionAsync("officer", 1, It.IsAny())).ReturnsAsync(true); _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }); _store.Reports.Add(new RmsIncidentReport { DepartmentId = 1, RmsIncidentReportId = "report", AuthorUserId = "officer", State = (int)RmsRecordState.Draft, RowVersion = 1 }); - _service = new IncidentAttachmentsService(_store.ReportsRepo.Object, _store.Shared.AttachmentsRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _auth.Object, _scanner.Object, _store.UnitOfWork.Object); + _service = new IncidentAttachmentsService(_store.ReportsRepo.Object, _store.Shared.AttachmentsRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _auth.Object, _scanner.Object, _store.UnitOfWork.Object, + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, Mock.Of())); } private Task Add() => _service.AddAsync(1, "officer", "report", 1, "scene.txt", "text/plain", Encoding.UTF8.GetBytes("scene evidence"), "Scene notes"); diff --git a/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs index f2097d9c..efad2aaf 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs @@ -40,7 +40,7 @@ public async Task Officer_completes_submits_corrects_and_discloses_an_incident_w ResultJson = "{\"decision\":\"Engine 5 selected\",\"caller\":\"Private caller identity\"}" } }); var evidence = new RecordsEvidenceService(_store.Shared.EvidenceRepo.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.Shared.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { new RunCardActivationEvidenceAdapter(activations.Object) }, - _authorization.Object, _calls.Object, Mock.Of()); + _authorization.Object, _calls.Object, Mock.Of(), new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object)); _service = BuildService(udf, evidence); var started = await _service.StartFromCallAsync(Dept, "author", CallId); var id = started.Report.RmsIncidentReportId; var sample = Resgrid.Tests.Providers.NerisMappingTests.Snapshot(); @@ -57,7 +57,7 @@ public async Task Officer_completes_submits_corrects_and_discloses_an_incident_w var scanner = new Mock(); scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }); var files = new IncidentAttachmentsService(_store.ReportsRepo.Object, _store.Shared.AttachmentsRepo.Object, _store.Shared.RevisionsRepo.Object, - _store.Shared.AuditsRepo.Object, _authorization.Object, scanner.Object, _store.UnitOfWork.Object); + _store.Shared.AuditsRepo.Object, _authorization.Object, scanner.Object, _store.UnitOfWork.Object, new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object)); var file = await files.AddAsync(Dept, "author", id, (await _service.GetAsync(Dept, id)).Report.RowVersion, "scene.txt", "text/plain", Encoding.UTF8.GetBytes("Officer's reviewed scene notes"), "Scene observations", classification: 0); var captured = await evidence.CaptureAsync(new RecordEvidenceCaptureRequest { DepartmentId = Dept, CapturedByUserId = "author", RecordId = id, @@ -77,7 +77,7 @@ public async Task Officer_completes_submits_corrects_and_discloses_an_incident_w .ReturnsAsync(new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Rejected, StatusCode = 422, ResponseJson = "{\"detail\":\"Review outcome narrative\"}" }); var worker = new RecordsSubmissionService(_store.SubmissionsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, _store.Shared.ProjectionsRepo.Object, _store.Shared.AuditsRepo.Object, _neris.Object, delivery.Object, new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object), - Mock.Of(), _store.UnitOfWork.Object, _store.Shared.CutoversRepo.Object, Mock.Of(), _store.ExchangesRepo.Object, _authorization.Object); + Mock.Of(), _store.UnitOfWork.Object, _store.Shared.CutoversRepo.Object, Mock.Of(), _store.ExchangesRepo.Object, _authorization.Object, new PassthroughRecordsProtection()); void Lease(RmsSubmission submission) { submission.LeaseOwner = "journey-worker"; submission.LeaseExpiresOn = DateTime.UtcNow.AddMinutes(5); submission.RowVersion++; } Lease(firstSubmission); (await worker.ProcessAsync(firstSubmission)).State.Should().Be((int)RmsSubmissionState.Rejected); var rejected = await _service.GetAsync(Dept, id); rejected.State.Should().Be(RmsRecordState.Rejected); @@ -99,7 +99,7 @@ public async Task Officer_completes_submits_corrects_and_discloses_an_incident_w pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter")).Returns((string html, string paper) => { rendered.Add(html); return Encoding.ASCII.GetBytes("%PDF-journey-fixture"); }); var branding = new Mock(); branding.Setup(b => b.GetBrandingAsync(Dept)).ReturnsAsync(new DepartmentBranding { DisplayName = "Journey Fire Department" }); var documents = new RecordsDocumentService(_authorization.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, - _store.Shared.RevisionsRepo.Object, _service, branding.Object, Mock.Of(), pdf.Object, evidence, udf); + _store.Shared.RevisionsRepo.Object, _service, branding.Object, Mock.Of(), pdf.Object, evidence, udf, new PassthroughRecordsProtection()); var original = await documents.GetAsync(Dept, "author", id, RmsRecordKind.IncidentReport, firstRevision.RmsRevisionId, true); var corrected = await documents.GetAsync(Dept, "author", id, RmsRecordKind.IncidentReport, secondRevision.RmsRevisionId, true); JObject.Parse(original.ContentJson)["CustomFields"]["Fields"][0]["Value"].Value().Should().Be("23"); @@ -112,7 +112,7 @@ public async Task Officer_completes_submits_corrects_and_discloses_an_incident_w var disclosures = new RecordsDisclosureService(_store.Shared.DisclosureRequestsRepo.Object, _store.Shared.DisclosureProductionsRepo.Object, _store.Shared.RecordsRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _authorization.Object, _settings.Object, - _store.UnitOfWork.Object, _store.ReportsRepo.Object, documents, _store.Shared.AttachmentsRepo.Object, pdf.Object, _store.AnalysesRepo.Object, scanner.Object, udf); + _store.UnitOfWork.Object, _store.ReportsRepo.Object, documents, _store.Shared.AttachmentsRepo.Object, pdf.Object, _store.AnalysesRepo.Object, scanner.Object, udf, new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object)); var request = await disclosures.CreateRequestAsync(Dept, "custodian", new RmsDisclosureRequest { RequesterName = "Training requester", JurisdictionProfile = "Fixture jurisdiction", ReceivedOn = DateTime.UtcNow }); await disclosures.SaveScopeAsync(Dept, "custodian", request.RmsDisclosureRequestId, "Incident and supporting file", new RmsRecordQuery { CallId = CallId, DefinitionKey = RmsDefinitionKeys.NerisIncidentReport }, RmsRedactionProfiles.Standard); var review = await disclosures.GetReviewAsync(Dept, "custodian", request.RmsDisclosureRequestId); diff --git a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.Feeds.cs b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.Feeds.cs new file mode 100644 index 00000000..42140054 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.Feeds.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// RMS-3 feeds into the incident report start (plan sections 4.2 and 4.3): command key times arrive as Derived + /// facts naming Incident Command, the clear-time proxy only fills a hole dispatch left, the contact/preplan + /// snapshot arrives as identity-and-place facts with no contact detail, and a feed failure never blocks the + /// officer from starting the report. + /// + public partial class IncidentReportsServiceTests + { + private static readonly DateTime CommandOn = LoggedOn.AddMinutes(4); + + private IncidentCommandKeyTimes KeyTimes(DateTime? closed) + { + return new IncidentCommandKeyTimes + { + CallId = CallId, IncidentCommandId = "cmd-1", EstablishedOn = CommandOn, FirstResourceAssignedOn = CommandOn.AddMinutes(1), + FirstBenchmarkCompletedOn = CommandOn.AddMinutes(12), LastBenchmarkCompletedOn = CommandOn.AddMinutes(30), ClosedOn = closed, MutualAidResourceCount = 2, + Benchmarks = new List { new IncidentCommandBenchmark { Name = "Primary search complete", CompletedOn = CommandOn.AddMinutes(12) }, new IncidentCommandBenchmark { Name = "Fire under control", CompletedOn = CommandOn.AddMinutes(30) } }, + CapturedOn = DateTime.UtcNow + }; + } + + [Test] + public async Task Command_key_times_arrive_as_derived_facts_naming_incident_command() + { + _feeds.Setup(f => f.GetCommandKeyTimesAsync(Dept, CallId)).ReturnsAsync(KeyTimes(CommandOn.AddMinutes(45))); + + var aggregate = await _service.StartFromCallAsync(Dept, "author", CallId); + + var command = aggregate.Facts.Where(f => f.FactKey.StartsWith(NerisFactKeys.CommandPrefix, StringComparison.Ordinal)).ToList(); + command.Select(f => f.FactKey).Should().Contain(new[] { NerisFactKeys.CommandEstablished, NerisFactKeys.CommandFirstAssignment, NerisFactKeys.CommandFirstBenchmark, NerisFactKeys.CommandLastBenchmark, NerisFactKeys.CommandClosed, NerisFactKeys.CommandMutualAid, NerisFactKeys.CommandBenchmark(0), NerisFactKeys.CommandBenchmark(1) }); + command.Should().OnlyContain(f => f.SourceKind == (int)RmsSourceKind.Derived && f.SourceSystem == "IncidentCommand" && f.SourceEntityId == "cmd-1"); + command.Single(f => f.FactKey == NerisFactKeys.CommandEstablished).SourceValue.Should().Be(IncidentReportsService.Iso(CommandOn)); + command.Single(f => f.FactKey == NerisFactKeys.CommandMutualAid).SourceValue.Should().Be("2"); + command.Single(f => f.FactKey == NerisFactKeys.CommandBenchmark(1)).SourceValue.Should().StartWith("Fire under control @ "); + _store.Facts.Should().Contain(f => f.FactKey == NerisFactKeys.CommandEstablished, "the feed facts are persisted with the report"); + } + + [Test] + public async Task Command_close_is_the_clear_time_proxy_only_when_dispatch_recorded_none() + { + var closed = CommandOn.AddMinutes(45); + _feeds.Setup(f => f.GetCommandKeyTimesAsync(Dept, CallId)).ReturnsAsync(KeyTimes(closed)); + + var aggregate = await _service.StartFromCallAsync(Dept, "author", CallId); + aggregate.Report.IncidentClearedOn.Should().Be(closed, "the Call has no ClosedOn, so the command close is the derived proxy"); + var clear = aggregate.Facts.Single(f => f.FactKey == NerisFactKeys.IncidentClear); + clear.SourceKind.Should().Be((int)RmsSourceKind.Derived); + clear.SourceSystem.Should().Be("IncidentCommand"); + } + + [Test] + public async Task Dispatch_clear_time_always_wins_over_the_command_proxy() + { + var dispatchClosed = LoggedOn.AddMinutes(70); + _call.ClosedOn = dispatchClosed; + _feeds.Setup(f => f.GetCommandKeyTimesAsync(Dept, CallId)).ReturnsAsync(KeyTimes(CommandOn.AddMinutes(45))); + + var aggregate = await _service.StartFromCallAsync(Dept, "author", CallId); + aggregate.Report.IncidentClearedOn.Should().Be(dispatchClosed); + var clear = aggregate.Facts.Single(f => f.FactKey == NerisFactKeys.IncidentClear); + clear.SourceKind.Should().Be((int)RmsSourceKind.Dispatch); + aggregate.Facts.Should().ContainSingle(f => f.FactKey == NerisFactKeys.CommandClosed, "the command close is still recorded as its own fact"); + } + + [Test] + public async Task Preplan_snapshot_arrives_as_identity_and_place_facts_without_contact_detail() + { + _feeds.Setup(f => f.GetPreplanSnapshotAsync(Dept, It.IsAny())).ReturnsAsync(new IncidentPreplanSnapshot + { + CallId = CallId, + Contacts = new List { new IncidentPreplanContact { ContactId = "c-1", DisplayName = "Acme Storage", ContactType = "Company", CategoryName = "Warehouse", Role = "Primary" } }, + Place = new IncidentPreplanPlace { PoiId = 9, Name = "Acme Warehouse", TypeName = "Commercial", Address = "1 Main St", Latitude = 39.5, Longitude = -104.9 } + }); + + var aggregate = await _service.StartFromCallAsync(Dept, "author", CallId); + + var contact = aggregate.Facts.Single(f => f.FactKey == NerisFactKeys.PreplanContact("c-1")); + contact.SourceKind.Should().Be((int)RmsSourceKind.Derived); + contact.SourceSystem.Should().Be("Contacts"); + contact.SourceValue.Should().Be("Acme Storage · Company · Warehouse · Primary"); + var place = aggregate.Facts.Single(f => f.FactKey == NerisFactKeys.PreplanPlace); + place.SourceSystem.Should().Be("Mapping"); + place.SourceEntityId.Should().Be("9"); + place.SourceValue.Should().Be("Acme Warehouse · Commercial · 1 Main St · 39.5,-104.9"); + _calls.Verify(c => c.PopulateCallData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), true, It.IsAny()), Times.Once, + "the Call's contact links are loaded so the feed can see them"); + } + + [Test] + public async Task A_failing_feed_never_blocks_the_report_from_starting() + { + _feeds.Setup(f => f.GetCommandKeyTimesAsync(Dept, CallId)).ThrowsAsync(new InvalidOperationException("command store offline")); + _feeds.Setup(f => f.GetPreplanSnapshotAsync(Dept, It.IsAny())).ThrowsAsync(new InvalidOperationException("contacts offline")); + + var aggregate = await _service.StartFromCallAsync(Dept, "author", CallId); + + aggregate.State.Should().Be(RmsRecordState.Draft); + aggregate.Facts.Should().NotContain(f => f.FactKey.StartsWith(NerisFactKeys.CommandPrefix, StringComparison.Ordinal) || f.FactKey.StartsWith(NerisFactKeys.PreplanPrefix, StringComparison.Ordinal)); + aggregate.Facts.Should().Contain(f => f.FactKey == NerisFactKeys.CallCreate, "dispatch facts are untouched by a feed outage"); + } + + [Test] + public async Task No_command_and_no_contacts_add_nothing() + { + _feeds.Setup(f => f.GetCommandKeyTimesAsync(Dept, CallId)).ReturnsAsync((IncidentCommandKeyTimes)null); + + var aggregate = await _service.StartFromCallAsync(Dept, "author", CallId); + + aggregate.Facts.Should().NotContain(f => f.FactKey.StartsWith(NerisFactKeys.CommandPrefix, StringComparison.Ordinal) || f.FactKey.StartsWith(NerisFactKeys.PreplanPrefix, StringComparison.Ordinal)); + aggregate.Report.IncidentClearedOn.Should().BeNull(); + } + } + + /// The feed service itself: snapshots with provenance, department isolation, no contact detail, outages swallowed. + [TestFixture] + public class IncidentSourceFeedServiceTests + { + private const int Dept = 42; + private Mock _reporting; + private Mock _commands; + private Mock _contacts; + private Mock _mapping; + private IncidentSourceFeedService _service; + + [SetUp] + public void SetUp() + { + _reporting = new Mock(); + _commands = new Mock(); + _contacts = new Mock(); + _mapping = new Mock(); + _service = new IncidentSourceFeedService(_reporting.Object, _commands.Object, _contacts.Object, _mapping.Object); + } + + [Test] + public async Task Key_times_are_a_snapshot_of_the_command_times_report() + { + var established = new DateTime(2026, 9, 1, 8, 5, 0, DateTimeKind.Utc); + _commands.Setup(c => c.GetCommandForCallAsync(Dept, 77)).ReturnsAsync(new IncidentCommand { IncidentCommandId = "cmd-1", DepartmentId = Dept, CallId = 77, EstablishedOn = established }); + _reporting.Setup(r => r.GetIncidentTimesReportAsync(Dept, 77)).ReturnsAsync(new IncidentTimesReport + { + CallId = 77, CommandEstablishedOn = established, FirstResourceAssignedOn = established.AddMinutes(1), LastBenchmarkCompletedOn = established.AddMinutes(30), CommandClosedOn = established.AddMinutes(60), MutualAidResourceCount = 1, + Benchmarks = new List { new BenchmarkTime { Name = "Later", CompletedOn = established.AddMinutes(30) }, new BenchmarkTime { Name = "Earlier", CompletedOn = established.AddMinutes(10) }, new BenchmarkTime { Name = "Never", CompletedOn = null } } + }); + + var times = await _service.GetCommandKeyTimesAsync(Dept, 77); + + times.IncidentCommandId.Should().Be("cmd-1"); + times.EstablishedOn.Should().Be(established); + times.ClosedOn.Should().Be(established.AddMinutes(60)); + times.MutualAidResourceCount.Should().Be(1); + times.Benchmarks.Select(b => b.Name).Should().Equal("Earlier", "Later"); + } + + [Test] + public async Task Another_departments_command_and_a_missing_command_yield_nothing() + { + _commands.Setup(c => c.GetCommandForCallAsync(Dept, 77)).ReturnsAsync(new IncidentCommand { IncidentCommandId = "cmd-1", DepartmentId = 99, CallId = 77 }); + (await _service.GetCommandKeyTimesAsync(Dept, 77)).Should().BeNull(); + _reporting.Verify(r => r.GetIncidentTimesReportAsync(It.IsAny(), It.IsAny()), Times.Never); + + _commands.Setup(c => c.GetCommandForCallAsync(Dept, 78)).ReturnsAsync((IncidentCommand)null); + (await _service.GetCommandKeyTimesAsync(Dept, 78)).Should().BeNull(); + } + + [Test] + public async Task A_command_store_failure_returns_null_rather_than_throwing() + { + _commands.Setup(c => c.GetCommandForCallAsync(Dept, 77)).ThrowsAsync(new TimeoutException()); + (await _service.GetCommandKeyTimesAsync(Dept, 77)).Should().BeNull(); + } + + [Test] + public async Task Preplan_snapshot_carries_identity_role_and_place_and_never_contact_detail() + { + var call = new Call + { + CallId = 77, DepartmentId = Dept, DestinationPoiId = 9, + Contacts = new List + { + new CallContact { CallContactId = "l2", DepartmentId = Dept, CallId = 77, ContactId = "c-2", CallContactType = 1 }, + new CallContact { CallContactId = "l1", DepartmentId = Dept, CallId = 77, ContactId = "c-1", CallContactType = 0 }, + new CallContact { CallContactId = "l3", DepartmentId = Dept, CallId = 77, ContactId = "c-foreign", CallContactType = 1 } + } + }; + _contacts.Setup(c => c.GetContactByIdAsync("c-1")).ReturnsAsync(new Contact { ContactId = "c-1", DepartmentId = Dept, ContactType = 1, CompanyName = "Acme Storage", ContactCategoryId = "cat", Email = "private@example.invalid" }); + _contacts.Setup(c => c.GetContactByIdAsync("c-2")).ReturnsAsync(new Contact { ContactId = "c-2", DepartmentId = Dept, ContactType = 0, FirstName = "Pat", LastName = "Owner" }); + _contacts.Setup(c => c.GetContactByIdAsync("c-foreign")).ReturnsAsync(new Contact { ContactId = "c-foreign", DepartmentId = 99, ContactType = 0, FirstName = "Other", LastName = "Dept" }); + _contacts.Setup(c => c.GetContactCategoryByIdAsync("cat")).ReturnsAsync(new ContactCategory { ContactCategoryId = "cat", DepartmentId = Dept, Name = "Warehouse" }); + _mapping.Setup(m => m.GetDestinationPOIByIdAsync(Dept, 9)).ReturnsAsync(new Poi { PoiId = 9, PoiTypeId = 3, Name = "Acme Warehouse", Address = "1 Main St", Latitude = 39.5, Longitude = -104.9 }); + _mapping.Setup(m => m.GetTypeByIdAsync(3)).ReturnsAsync(new PoiType { PoiTypeId = 3, DepartmentId = Dept, Name = "Commercial" }); + + var snapshot = await _service.GetPreplanSnapshotAsync(Dept, call); + + snapshot.Contacts.Select(c => c.ContactId).Should().Equal("c-1", "c-2"); + var primary = snapshot.Contacts[0]; + primary.DisplayName.Should().Be("Acme Storage"); + primary.ContactType.Should().Be("Company"); + primary.CategoryName.Should().Be("Warehouse"); + primary.Role.Should().Be("Primary"); + snapshot.Contacts[1].DisplayName.Should().Be("Pat Owner"); + snapshot.Contacts[1].Role.Should().Be("Additional"); + typeof(IncidentPreplanContact).GetProperties().Select(p => p.Name).Should().NotContain(new[] { "Email", "Phone", "PhoneNumber", "Address" }, "contact detail is a protected candidate and never enters the snapshot"); + snapshot.Place.Name.Should().Be("Acme Warehouse"); + snapshot.Place.TypeName.Should().Be("Commercial"); + snapshot.Place.Latitude.Should().Be(39.5); + } + + [Test] + public async Task Foreign_calls_and_missing_links_produce_an_empty_snapshot() + { + (await _service.GetPreplanSnapshotAsync(Dept, new Call { CallId = 1, DepartmentId = 99 })).IsEmpty.Should().BeTrue(); + (await _service.GetPreplanSnapshotAsync(Dept, new Call { CallId = 2, DepartmentId = Dept })).IsEmpty.Should().BeTrue(); + (await _service.GetPreplanSnapshotAsync(Dept, null)).IsEmpty.Should().BeTrue(); + _contacts.Verify(c => c.GetContactByIdAsync(It.IsAny()), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs index bbddecde..fac86a83 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs @@ -44,6 +44,7 @@ public partial class IncidentReportsServiceTests private Call _call; private IncidentReportsService _service; private Mock _authorization; + private Mock _feeds; [Test] public async Task Starting_an_existing_report_rechecks_current_record_visibility() @@ -115,6 +116,9 @@ public void SetUp() _neris.Setup(n => n.IsSubmissionEnabledAsync(Dept)).ReturnsAsync(() => _submissionEnabled); _neris.Setup(n => n.ResolveCrosswalkAsync(Dept, "incident_type", NerisCrosswalkSources.CallType, "Fire")).ReturnsAsync("FIRE||STRUCTURE_FIRE||RESIDENTIAL"); + _feeds = new Mock(); + _feeds.Setup(f => f.GetPreplanSnapshotAsync(Dept, It.IsAny())).ReturnsAsync((int d, Call c) => new IncidentPreplanSnapshot { CallId = c?.CallId ?? 0 }); + _localIssues = new List(); _validation = new Mock(); _validation.Setup(v => v.ValidateLocal(It.IsAny(), It.IsAny())).Returns(() => _localIssues.ToList()); @@ -128,6 +132,8 @@ public void SetUp() _service = BuildService(); } + protected PassthroughRecordsProtection Protection { get; } = new PassthroughRecordsProtection(); + private IncidentReportsService BuildService(IRecordsUdfService udf = null, IRecordsEvidenceService evidence = null) { var outbox = new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object); @@ -138,7 +144,8 @@ private IncidentReportsService BuildService(IRecordsUdfService udf = null, IReco _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _store.Shared.ScopesRepo.Object, _store.Shared.SharesRepo.Object, _store.Shared.ProjectionsRepo.Object, outbox, _settings.Object, _groups.Object, _profiles.Object, _roles.Object, _units.Object, _calls.Object, _adp.Object, _store.UnitOfWork.Object, - _neris.Object, new NerisMappingService(), _validation.Object, _authorization.Object, _store.Shared.AttachmentsRepo.Object, _store.Shared.EvidenceRepo.Object, udf ?? Mock.Of(), evidence ?? Mock.Of()); + _neris.Object, new NerisMappingService(), _validation.Object, _authorization.Object, _store.Shared.AttachmentsRepo.Object, _store.Shared.EvidenceRepo.Object, udf ?? Mock.Of(), evidence ?? Mock.Of(), + _feeds.Object, Protection); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs b/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs new file mode 100644 index 00000000..26b54684 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/LogsDeepLinkTests.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Logs; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Rms +{ + /// + /// Deep-link tests (RMS plan section 7): after a department activates Records, every old Logs and Unit Logs + /// URL a member may have bookmarked or been emailed still resolves — list, detail, export, attachment, the + /// list JSON and the training chart — while every old new/edit/delete link redirects without touching a + /// service write. Both halves are asserted against the real controllers with the cutover engaged, so a + /// hidden button is never the evidence. + /// + [TestFixture, NonParallelizable] + public class LogsDeepLinkTests + { + private const int Dept = 3; + private const string Me = "member"; + + private Mock _workLogs; + private Mock _units; + private Mock _cutover; + private Mock _departments; + private Mock _authorization; + private Mock _protectedRead; + private LogsController _logs; + private UnitsController _unitsController; + private DefaultHttpContext _http; + + [SetUp] + public void SetUp() + { + _workLogs = new Mock(); + _units = new Mock(); + _cutover = new Mock(); + _cutover.Setup(c => c.AreLegacyWritesBlockedAsync(Dept)).ReturnsAsync(true); + _departments = new Mock(); + _departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, Name = "Test FD", TimeZone = "UTC" }); + _departments.Setup(d => d.GetAllPersonnelNamesForDepartmentAsync(Dept)).ReturnsAsync(new List()); + _authorization = new Mock(); + _authorization.Setup(a => a.CanUserDeleteWorkLogAsync(Me, It.IsAny())).ReturnsAsync(true); + _authorization.Setup(a => a.CanUserViewUnitAsync(Me, It.IsAny())).ReturnsAsync(true); + _protectedRead = new Mock(); + _protectedRead.Setup(p => p.ResolveCallLogsForReadAsync(Dept, It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new ProtectedReadResult()); + _protectedRead.Setup(p => p.ResolveLogsForReadAsync(Dept, It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new ProtectedReadResult()); + _protectedRead.Setup(p => p.ResolveUnitLogsForReadAsync(Dept, It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new ProtectedReadResult()); + + var log = new Log { LogId = 41, DepartmentId = Dept, LogType = (int)LogTypes.Training, Narrative = "Legacy drill", Course = "CPR", LoggedOn = new DateTime(2025, 3, 1), Users = new List(), Units = new List() }; + _workLogs.Setup(w => w.GetWorkLogByIdAsync(41)).ReturnsAsync(log); + _workLogs.Setup(w => w.GetWorkLogByIdAsync(99)).ReturnsAsync(new Log { LogId = 99, DepartmentId = Dept + 1, Users = new List(), Units = new List() }); + _workLogs.Setup(w => w.GetAttachmentsForLogAsync(41)).ReturnsAsync(new List()); + _workLogs.Setup(w => w.GetAttachmentByIdAsync(7)).ReturnsAsync(new LogAttachment { LogAttachmentId = 7, LogId = 41, FileName = "roster.pdf", Type = "application/pdf", Data = new byte[] { 1, 2, 3 } }); + _workLogs.Setup(w => w.GetAllCallLogsForUserAsync(Me)).ReturnsAsync(new List()); + _workLogs.Setup(w => w.GetAllLogsForUserAsync(Me)).ReturnsAsync(new List { log }); + _workLogs.Setup(w => w.GetLogYearsByDeptartmentAsync(Dept)).ReturnsAsync(new List { "2025" }); + _workLogs.Setup(w => w.GetAllLogsForDepartmentAsync(Dept)).ReturnsAsync(new List { log }); + _workLogs.Setup(w => w.GetAllLogsForDepartmentAndYearAsync(Dept, "2025")).ReturnsAsync(new List { log }); + _workLogs.Setup(w => w.PopulateLogData(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((Log l, bool a, bool b) => l); + _workLogs.Setup(w => w.GetAllLogsByDepartmentDateRangeAsync(Dept, LogTypes.Training, It.IsAny(), It.IsAny())).ReturnsAsync(new List { log }); + + _units.Setup(u => u.GetUnitsForDepartmentAsync(Dept)).ReturnsAsync(new List()); + _units.Setup(u => u.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 5" }); + _units.Setup(u => u.GetLogsForUnitAsync(5)).ReturnsAsync(new List { new UnitLog { UnitLogId = 1, UnitId = 5, Narrative = "Legacy unit log", Timestamp = new DateTime(2025, 3, 1) } }); + + _http = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, Me), + new Claim(ClaimTypes.PrimaryGroupSid, Dept.ToString()), + new Claim(ResgridClaimTypes.Resources.Log, ResgridClaimTypes.Actions.View), + new Claim(ResgridClaimTypes.Resources.Log, ResgridClaimTypes.Actions.Delete), + new Claim(ResgridClaimTypes.Resources.Department, ResgridClaimTypes.Actions.Update) + }, "test")) + }; + _http.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; + + var groups = new Mock(); + groups.Setup(g => g.GetAllGroupsForDepartmentAsync(Dept)).ReturnsAsync(new List()); + _logs = new LogsController(_departments.Object, Mock.Of(), Mock.Of(), groups.Object, Mock.Of(), Mock.Of(), + _authorization.Object, _workLogs.Object, Mock.Of(), _units.Object, _protectedRead.Object, _cutover.Object) + { ControllerContext = new ControllerContext { HttpContext = _http } }; + + _unitsController = new UnitsController(_departments.Object, Mock.Of(), _units.Object, _authorization.Object, Mock.Of(), groups.Object, Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>(), Mock.Of(), + _protectedRead.Object, _cutover.Object) + { ControllerContext = new ControllerContext { HttpContext = _http } }; + } + + [TearDown] + public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = null; + + #region Reads still resolve + + [Test] + public async Task The_logs_list_still_resolves_and_announces_read_only() + { + var view = (await _logs.Index()).Should().BeOfType().Which; + var model = view.Model.Should().BeOfType().Which; + model.LegacyReadOnly.Should().BeTrue(); + model.WorkLogs.Should().ContainSingle(l => l.LogId == 41); + model.Years.Select(y => y.Value).Should().Equal("2025"); + } + + [Test] + public async Task A_bookmarked_log_detail_still_renders_and_offers_no_delete() + { + var view = (await _logs.View(41)).Should().BeOfType().Which; + view.ViewName.Should().Be("ViewLog"); + var model = view.Model.Should().BeOfType().Which; + model.WorkLog.LogId.Should().Be(41); + model.WorkLog.Narrative.Should().Be("Legacy drill"); + // The member holds Log:Delete and is a department admin, which is exactly the case the plan calls out: + // "even by a department administrator or previous creator". + model.CanDelete.Should().BeFalse("legacy rows cannot be deleted after activation, whoever the viewer is"); + } + + [Test] + public async Task A_bookmarked_export_and_attachment_still_resolve() + { + var export = (await _logs.LogExport(41)).Should().BeOfType().Which; + export.Model.Should().BeOfType().Which.WorkLog.LogId.Should().Be(41); + + var file = (await _logs.GetAttachment(41, 7)).Should().BeOfType().Which; + file.FileDownloadName.Should().Be("roster.pdf"); + file.FileContents.Should().Equal(1, 2, 3); + } + + [Test] + public async Task The_list_json_and_training_chart_still_resolve() + { + (await _logs.GetLogsList("2025")).Should().BeOfType(); + (await _logs.GetLogsList(null)).Should().BeOfType(); + (await _logs.TrainingPerMonth()).Should().BeOfType(); + } + + [Test] + public async Task Another_departments_log_never_renders_through_an_old_link() + { + (await _logs.View(99)).Should().BeOfType().Which.ActionName.Should().Be("Index"); + (await _logs.LogExport(99)).Should().BeOfType().Which.ActionName.Should().Be("Index"); + } + + [Test] + public async Task Unit_logs_still_render_read_only() + { + var view = (await _unitsController.ViewLogs(5)).Should().BeOfType().Which; + var model = view.Model.Should().BeOfType().Which; + model.LegacyReadOnly.Should().BeTrue(); + model.Logs.Should().ContainSingle(l => l.Narrative == "Legacy unit log"); + } + + #endregion + + #region Writes cannot mutate anything + + [Test] + public async Task Old_new_log_links_redirect_before_any_write() + { + (await _logs.NewLog()).Should().BeOfType().Which.ActionName.Should().Be("Index"); + var posted = await _logs.NewLog(new NewLogView { Log = new Log { Narrative = "Late entry" }, LogType = LogTypes.Work }, null, null, CancellationToken.None); + posted.Should().BeOfType().Which.ActionName.Should().Be("Index"); + + _workLogs.Verify(w => w.SaveLogAsync(It.IsAny(), It.IsAny()), Times.Never); + _workLogs.Verify(w => w.SaveLogAttachmentAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Old_delete_links_redirect_before_any_write() + { + (await _logs.DeleteWorkLog(41, CancellationToken.None)).Should().BeOfType().Which.ActionName.Should().Be("Index"); + _workLogs.Verify(w => w.DeleteLogAsync(It.IsAny(), It.IsAny()), Times.Never); + _authorization.Verify(a => a.CanUserDeleteWorkLogAsync(It.IsAny(), It.IsAny()), Times.Never, "the guard runs before the per-log authorization question"); + } + + [Test] + public async Task Old_unit_log_create_links_redirect_before_any_write() + { + (await _unitsController.AddLog(5)).Should().BeOfType().Which.ActionName.Should().Be("ViewLogs"); + var posted = await _unitsController.AddLog(new Resgrid.Web.Areas.User.Models.Units.AddLogView { Log = new UnitLog { UnitId = 5, Narrative = "Late unit entry" } }, CancellationToken.None); + posted.Should().BeOfType().Which.ActionName.Should().Be("ViewLogs"); + _units.Verify(u => u.SaveUnitLogAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Before_activation_the_same_links_still_write() + { + _cutover.Setup(c => c.AreLegacyWritesBlockedAsync(Dept)).ReturnsAsync(false); + _workLogs.Setup(w => w.DeleteLogAsync(41, It.IsAny())).ReturnsAsync(true); + await _logs.DeleteWorkLog(41, CancellationToken.None); + _workLogs.Verify(w => w.DeleteLogAsync(41, It.IsAny()), Times.Once, "the guard is the cutover, not the controller"); + ((ViewLogsView)((ViewResult)await _logs.View(41)).Model).CanDelete.Should().BeTrue("before activation an administrator may still delete"); + } + + #endregion + + #region The route surface itself is pinned + + /// + /// Every legacy action a deep link can point at, with the policy it must keep. Renaming or dropping one + /// silently breaks bookmarks and emailed links inside the read-only module; this list makes that a test + /// failure instead of a support ticket. + /// + [TestCase(typeof(LogsController), "Index", ResgridResources.Log_View)] + [TestCase(typeof(LogsController), "View", ResgridResources.Log_View)] + [TestCase(typeof(LogsController), "LogExport", ResgridResources.Log_View)] + [TestCase(typeof(LogsController), "GetAttachment", ResgridResources.Log_View)] + [TestCase(typeof(LogsController), "GetLogsList", ResgridResources.Log_View)] + [TestCase(typeof(LogsController), "TrainingPerMonth", ResgridResources.Log_View)] + [TestCase(typeof(LogsController), "NewLog", ResgridResources.Log_Create)] + [TestCase(typeof(LogsController), "DeleteWorkLog", ResgridResources.Log_Delete)] + [TestCase(typeof(UnitsController), "ViewLogs", ResgridResources.UnitLog_View)] + [TestCase(typeof(UnitsController), "AddLog", ResgridResources.UnitLog_Create)] + public void Legacy_deep_link_actions_keep_their_names_and_policies(Type controller, string action, string policy) + { + var methods = controller.GetMethods(BindingFlags.Public | BindingFlags.Instance).Where(m => m.Name == action && typeof(Task).IsAssignableFrom(m.ReturnType)).ToList(); + methods.Should().NotBeEmpty($"{controller.Name}.{action} is a legacy deep-link target"); + foreach (var method in methods) + method.GetCustomAttributes().Select(a => a.Policy).Should().Contain(policy); + } + + [Test] + public void Every_legacy_mutation_action_is_guarded_by_the_cutover_before_its_body() + { + // The write guard is a service-boundary rule too (LegacyWriteGuardTests); this pins the controller half so + // a future edit cannot move the redirect below a write. + var source = ReadControllerSource("LogsController.cs"); + foreach (var action in new[] { "NewLog()", "NewLog(NewLogView model", "DeleteWorkLog(int logId" }) + { + var start = source.IndexOf("Task " + action, StringComparison.Ordinal); + start.Should().BeGreaterThan(0, action); + var body = source.Substring(start, Math.Min(600, source.Length - start)); + var guard = body.IndexOf("await _recordsCutoverService.AreLegacyWritesBlockedAsync", StringComparison.Ordinal); + guard.Should().BeGreaterThan(0, $"{action} must check the cutover"); + body.IndexOf("await ", StringComparison.Ordinal).Should().Be(guard, $"{action} must check the cutover before any other await"); + } + } + + private static string ReadControllerSource(string file) + { + var directory = new System.IO.DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null) + { + var candidate = System.IO.Path.Combine(directory.FullName, "Web", "Resgrid.Web", "Areas", "User", "Controllers", file); + if (System.IO.File.Exists(candidate)) + return System.IO.File.ReadAllText(candidate); + directory = directory.Parent; + } + Assert.Inconclusive($"{file} not found relative to the test assembly."); + return null; + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/LegacyFieldMap.cs b/Tests/Resgrid.Tests/Rms/Parity/LegacyFieldMap.cs new file mode 100644 index 00000000..dd666488 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/LegacyFieldMap.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Tests.Rms.Parity +{ + /// + /// The executable field inventory (RMS plan section 6, RMS-0): every persisted column of the legacy Log + /// family and where Records carries it. + /// reflects over the entities, so a column added to without a decision here + /// fails CI rather than silently losing a field at activation. "Not carried" entries are deliberate + /// decisions with their reason, never omissions. + /// + public static class LegacyFieldMap + { + public const string NotCarried = "not carried: "; + + public static readonly IReadOnlyDictionary Log = new Dictionary(StringComparer.Ordinal) + { + { "LogId", NotCarried + "legacy identity stays in the read-only Logs module; Records allocates RmsOperationalRecordId and nothing is migrated" }, + { "DepartmentId", "RmsOperationalRecord.DepartmentId (tenant scope)" }, + { "Narrative", "RmsOperationalRecordDetail.Narrative" }, + { "LogType", "RmsOperationalRecord.DefinitionKey + RecordType (RmsDefinitionKeys.ForType)" }, + { "ExternalId", "RmsOperationalRecord.ExternalId" }, + { "InitialReport", "RmsOperationalRecordDetail.InitialReport" }, + { "Type", "RmsOperationalRecordDetail.Type" }, + { "StationGroupId", "RmsOperationalRecord.StationGroupId" }, + { "Course", "RmsOperationalRecordDetail.Course" }, + { "CourseCode", "RmsOperationalRecordDetail.CourseCode" }, + { "Instructors", "RmsOperationalRecordDetail.Instructors" }, + { "Cause", "RmsOperationalRecordDetail.Cause" }, + { "InvestigatedByUserId", "RmsOperationalRecordDetail.InvestigatedByUserId" }, + { "ContactName", "RmsOperationalRecordDetail.ContactName" }, + { "ContactNumber", "RmsOperationalRecordDetail.ContactNumber" }, + { "StartedOn", "RmsOperationalRecord.StartedOn" }, + { "EndedOn", "RmsOperationalRecord.EndedOn" }, + { "LoggedOn", "RmsOperationalRecord.CreatedOn (server clock; a Record is never back-dated by the client)" }, + { "LoggedByUserId", "RmsOperationalRecord.AuthorUserId (the acting user)" }, + { "OfficerUserId", "RmsRecordParticipant with Role = Officer" }, + { "CallId", "RmsOperationalRecord.CallId + RmsOperationalRecordDetail.Call* snapshot" }, + { "OtherPersonnel", "RmsOperationalRecordDetail.OtherPersonnel (restricted section)" }, + { "Location", "RmsOperationalRecordDetail.Location" }, + { "OtherAgencies", "RmsOperationalRecordDetail.OtherAgencies" }, + { "OtherUnits", "RmsOperationalRecordDetail.OtherUnits" }, + { "BodyLocation", "RmsOperationalRecordDetail.BodyLocation (restricted section)" }, + { "PronouncedDeceasedBy", "RmsOperationalRecordDetail.PronouncedDeceasedBy (restricted section)" }, + { "Units", "RmsRecordUnitResponse rows" }, + { "Users", "RmsRecordParticipant rows" } + }; + + public static readonly IReadOnlyDictionary LogUser = new Dictionary(StringComparer.Ordinal) + { + { "LogUserId", NotCarried + "legacy row identity" }, + { "LogId", "RmsRecordParticipant.RecordId (parent)" }, + { "UnitId", "RmsRecordParticipant.UnitId" }, + { "UserId", "RmsRecordParticipant.UserId + DisplayNameSnapshot/GroupIdSnapshot" } + }; + + public static readonly IReadOnlyDictionary LogUnit = new Dictionary(StringComparer.Ordinal) + { + { "LogUnitId", NotCarried + "legacy row identity" }, + { "LogId", "RmsRecordUnitResponse.RecordId (parent)" }, + { "UnitId", "RmsRecordUnitResponse.UnitId + UnitNameSnapshot/UnitTypeSnapshot" }, + { "Dispatched", "RmsRecordUnitResponse.Dispatched" }, + { "Enroute", "RmsRecordUnitResponse.Enroute" }, + { "OnScene", "RmsRecordUnitResponse.OnScene" }, + { "Released", "RmsRecordUnitResponse.Released" }, + { "InQuarters", "RmsRecordUnitResponse.InQuarters" } + }; + + public static readonly IReadOnlyDictionary LogAttachment = new Dictionary(StringComparer.Ordinal) + { + { "LogAttachmentId", NotCarried + "legacy row identity" }, + { "LogId", "RmsRecordAttachment.RecordId (parent)" }, + { "FileName", "RmsRecordAttachment.FileName" }, + { "Type", "RmsRecordAttachment.ContentType" }, + { "Data", "RmsRecordAttachment.Data + Checksum (SHA-256)" }, + { "UserId", "RmsRecordAttachment.UploadedByUserId (the acting user)" }, + { "Timestamp", "RmsRecordAttachment.UploadedOn (server clock)" }, + { "Size", "RmsRecordAttachment.ByteSize" } + }; + + public static readonly IReadOnlyDictionary UnitLog = new Dictionary(StringComparer.Ordinal) + { + { "UnitLogId", NotCarried + "legacy row identity" }, + { "UnitId", "RmsOperationalRecordDetail.UnitId + one RmsRecordUnitResponse (Unit Activity subject)" }, + { "Timestamp", "RmsOperationalRecordDetail.ActivityOn" }, + { "Narrative", "RmsOperationalRecordDetail.Narrative" }, + { "IsProtected", "RmsOperationalRecordDetail.IsProtected / ProtectedEnvelope (inert until enrollment)" } + }; + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/RecordsParityFixture.cs b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityFixture.cs new file mode 100644 index 00000000..0eb42f9c --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityFixture.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Tests.Rms.Parity +{ + /// + /// One golden Logs-parity fixture (RMS plan section 6, RMS-0 deliverable): the legacy row exactly as the + /// Logs module would hold it — every column, association and attachment — and the list, detail, print and + /// export projections Records must produce for it. Product signs the content; the harness replays it. + /// Dates are ISO-8601 UTC strings and are compared as strings so a fixture never drifts with the clock. + /// + public sealed class RecordsParityFixture + { + [JsonIgnore] + public string FileName { get; set; } + + public string RecordType { get; set; } + + public string DefinitionKey { get; set; } + + public ParityLegacy Legacy { get; set; } + + public ParityExpected Expected { get; set; } + + public override string ToString() => RecordType; + } + + public sealed class ParityLegacy + { + /// "Logs" for the Log family, "UnitLogs" for the Units-area writer. + public string Source { get; set; } + + /// Every column of , value or null. + public JObject Log { get; set; } + + /// Every column of when Source is UnitLogs. + public JObject UnitLog { get; set; } + + public List Users { get; set; } = new List(); + + public List Units { get; set; } = new List(); + + public List Attachments { get; set; } = new List(); + } + + public sealed class ParityAttachment + { + public int LogAttachmentId { get; set; } + public int LogId { get; set; } + public string FileName { get; set; } + public string Type { get; set; } + public int Size { get; set; } + public string DataBase64 { get; set; } + public string UserId { get; set; } + public string Timestamp { get; set; } + public string Description { get; set; } + } + + public sealed class ParityExpected + { + public string NumberPrefix { get; set; } + public ParityListExpectation List { get; set; } = new ParityListExpectation(); + public ParityDetailExpectation Detail { get; set; } = new ParityDetailExpectation(); + public ParityExportExpectation Export { get; set; } = new ParityExportExpectation(); + public ParityPrintExpectation Print { get; set; } = new ParityPrintExpectation(); + } + + public sealed class ParityListExpectation + { + public string DisplaySummary { get; set; } + public List SearchTextContains { get; set; } = new List(); + /// Fields that must never reach the safe list projection (narrative, restricted, contact detail). + public List SearchTextExcludes { get; set; } = new List(); + public string OccurredOn { get; set; } + public string CallNumber { get; set; } + public List ParticipantUserIds { get; set; } = new List(); + public List UnitIds { get; set; } = new List(); + } + + public sealed class ParityDetailExpectation + { + /// Expected RmsOperationalRecordDetail values keyed by property name. + public JObject Details { get; set; } = new JObject(); + public List Participants { get; set; } = new List(); + public List Units { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + public string ExternalId { get; set; } + public int? StationGroupId { get; set; } + public int? CallId { get; set; } + public string StartedOn { get; set; } + public string EndedOn { get; set; } + } + + public sealed class ParityExportExpectation + { + public List MustContain { get; set; } = new List(); + /// Detail fields withheld from a viewer without RecordRestricted_View. + public List RestrictedFields { get; set; } = new List(); + } + + public sealed class ParityPrintExpectation + { + public List Contains { get; set; } = new List(); + /// Text that must be absent from the print produced for a viewer without RecordRestricted_View. + public List WithheldWithoutRestrictedAccess { get; set; } = new List(); + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs new file mode 100644 index 00000000..8b99e87d --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms.Parity +{ + /// + /// Replays a golden fixture against the real Records aggregate (RMS plan section 6, RMS-0: "the assertion + /// harness that replays them against Records"). The legacy row is mapped through + /// into a draft, attachments are added, the Quick Entry finalize runs, and the four projections the plan + /// names — list, detail, print and export — are captured for the test to assert against. + /// + public sealed class RecordsParityHarness + { + public const int Dept = 9; + public const string Author = "author"; + public const string Clerk = "clerk"; + + private static readonly JsonSerializerSettings FixtureSettings = new JsonSerializerSettings { DateParseHandling = DateParseHandling.None }; + private static readonly JsonSerializerSettings ActualSettings = new JsonSerializerSettings + { + DateFormatHandling = DateFormatHandling.IsoDateFormat, DateTimeZoneHandling = DateTimeZoneHandling.Utc, NullValueHandling = NullValueHandling.Include, + Culture = CultureInfo.InvariantCulture, ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }; + + public FakeIncidentStore IncidentStore { get; } + public FakeRmsStore Store { get; } + public RecordsService Records { get; } + public RecordsDocumentService Documents { get; } + public Mock Authorization { get; } + + public RecordsParityHarness() + { + Resgrid.Config.SystemBehaviorConfig.CacheEnabled = false; + IncidentStore = new FakeIncidentStore(); + Store = IncidentStore.Shared; + + Authorization = new Mock(); + Authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync(true); + Authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); + Authorization.Setup(a => a.HasPermissionAsync(Clerk, Dept, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + Authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + Authorization.Setup(a => a.CanReadSourceCallAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); + Authorization.Setup(a => a.IsDepartmentAdminAsync(It.IsAny(), Dept)).ReturnsAsync(false); + + var cutover = new Mock(); + cutover.Setup(c => c.GetModuleStateAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsModuleState { DepartmentId = Dept, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active, LegacyWritesBlocked = true }); + var settings = new Mock(); + settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsNumberingConfig()); + settings.Setup(s => s.GetRecordsReviewDueHoursAsync(Dept, It.IsAny())).ReturnsAsync(72); + var groups = new Mock(); + groups.Setup(g => g.GetGroupForUserAsync(It.IsAny(), Dept)).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 11, Name = "Station 1" }); + groups.Setup(g => g.GetGroupForUserAsync("p3", Dept)).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 12, Name = "Station 2" }); + var profiles = new Mock(); + profiles.Setup(p => p.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool b) => new UserProfile { UserId = id, FirstName = "Member", LastName = id }); + var units = new Mock(); + units.Setup(u => u.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 5", Type = "Engine", StationGroupId = 11 }); + units.Setup(u => u.GetUnitByIdAsync(6)).ReturnsAsync(new Unit { UnitId = 6, DepartmentId = Dept, Name = "Ladder 6", Type = "Ladder", StationGroupId = 12 }); + var calls = new Mock(); + calls.Setup(c => c.GetCallByIdAsync(77, It.IsAny())).ReturnsAsync(new Call { CallId = 77, DepartmentId = Dept, Number = "C2026-0009", Name = "Structure fire", Type = "Fire", Priority = 3, LoggedOn = new DateTime(2026, 3, 4, 17, 42, 0, DateTimeKind.Utc), Address = "1 Main St", NatureOfCall = "Smoke showing" }); + var adp = new Mock(); + adp.Setup(a => a.GetPinnedCatalogVersionAsync(Dept)).ReturnsAsync(0); + var outbox = new DomainEventOutboxService(Store.OutboxRepo.Object, Mock.Of()); + var queue = new Mock(); + queue.Setup(q => q.EnqueueNotification(It.IsAny())).ReturnsAsync(true); + var evidence = new Mock(); + evidence.Setup(e => e.BindToRevisionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(0); + evidence.Setup(e => e.GetForRecordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + var udf = new RecordsUdfService(Mock.Of(), Mock.Of(), Mock.Of(), Authorization.Object, groups.Object, Mock.Of(), adp.Object); + + Records = new RecordsService(Store.RecordsRepo.Object, new RmsRecordValueService(Store.DetailsRepo.Object), Store.ParticipantsRepo.Object, Store.UnitsRepo.Object, + Store.AttachmentsRepo.Object, Store.RevisionsRepo.Object, evidence.Object, Store.ScopesRepo.Object, Store.SharesRepo.Object, Store.ProjectionsRepo.Object, + Store.AuditsRepo.Object, outbox, cutover.Object, settings.Object, groups.Object, profiles.Object, units.Object, calls.Object, adp.Object, + Store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), Authorization.Object, udf, new PassthroughRecordsProtection()); + + var branding = new Mock(); + branding.Setup(b => b.GetBrandingAsync(Dept)).ReturnsAsync(new DepartmentBranding { DisplayName = "Parity Fire Department", ShortName = "PFD", AddressText = "100 Station Road" }); + var layouts = new Mock(); + layouts.Setup(l => l.GetDepartmentDefaultAsync(Dept)).ReturnsAsync(new RmsRecordPrintLayout { Version = 1, Scope = 1, Config = RecordsPrintLayoutConfig.Default() }); + Documents = new RecordsDocumentService(Authorization.Object, Store.RecordsRepo.Object, IncidentStore.ReportsRepo.Object, IncidentStore.AnalysesRepo.Object, Store.RevisionsRepo.Object, + Mock.Of(), branding.Object, layouts.Object, Mock.Of(), evidence.Object, udf, new PassthroughRecordsProtection()); + } + + #region Fixtures + + public static string FixtureDirectory() + { + var local = Path.Combine(TestContext.CurrentContext.TestDirectory, "Rms", "Parity"); + if (Directory.Exists(local) && Directory.EnumerateFiles(local, "*.json").Any()) + return local; + + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, "Tests", "Resgrid.Tests", "Rms", "Parity"); + if (Directory.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new DirectoryNotFoundException("The Records parity fixtures were not found beside the test assembly or in the repository."); + } + + public static IEnumerable LoadFixtures() + { + foreach (var file in Directory.EnumerateFiles(FixtureDirectory(), "*.json").OrderBy(f => f, StringComparer.Ordinal)) + { + var fixture = JsonConvert.DeserializeObject(System.IO.File.ReadAllText(file), FixtureSettings); + fixture.FileName = Path.GetFileName(file); + yield return fixture; + } + } + + #endregion + + #region Legacy mapping + + /// The legacy row, mapped field by field per , as the draft Records would author. + public static RecordDraftInput MapLegacy(RecordsParityFixture fixture) + { + var legacy = fixture.Legacy ?? throw new ArgumentException("The fixture has no legacy row."); + var input = new RecordDraftInput { DefinitionKey = fixture.DefinitionKey, Details = new RmsOperationalRecordDetail() }; + + if (string.Equals(legacy.Source, "UnitLogs", StringComparison.Ordinal)) + { + var unitLog = legacy.UnitLog ?? throw new ArgumentException("A UnitLogs fixture needs a unitLog object."); + var unitId = Int(unitLog, "UnitId") ?? throw new ArgumentException("UnitLog.UnitId is required."); + input.Details.UnitId = unitId; + input.Details.ActivityOn = Date(unitLog, "Timestamp"); + input.Details.Narrative = Text(unitLog, "Narrative"); + input.Units.Add(new RecordUnitResponseInput { UnitId = unitId }); + return input; + } + + var log = legacy.Log ?? throw new ArgumentException("A Logs fixture needs a log object."); + input.ExternalId = Text(log, "ExternalId"); + input.StationGroupId = Int(log, "StationGroupId"); + input.CallId = Int(log, "CallId"); + input.StartedOn = Date(log, "StartedOn"); + input.EndedOn = Date(log, "EndedOn"); + var d = input.Details; + d.Narrative = Text(log, "Narrative"); + d.InitialReport = Text(log, "InitialReport"); + d.Type = Text(log, "Type"); + d.Course = Text(log, "Course"); + d.CourseCode = Text(log, "CourseCode"); + d.Instructors = Text(log, "Instructors"); + d.Cause = Text(log, "Cause"); + d.InvestigatedByUserId = Text(log, "InvestigatedByUserId"); + d.ContactName = Text(log, "ContactName"); + d.ContactNumber = Text(log, "ContactNumber"); + d.OtherPersonnel = Text(log, "OtherPersonnel"); + d.Location = Text(log, "Location"); + d.OtherAgencies = Text(log, "OtherAgencies"); + d.OtherUnits = Text(log, "OtherUnits"); + d.BodyLocation = Text(log, "BodyLocation"); + d.PronouncedDeceasedBy = Text(log, "PronouncedDeceasedBy"); + + var officer = Text(log, "OfficerUserId"); + if (officer != null) + input.Participants.Add(new RecordParticipantInput { UserId = officer, Role = "Officer" }); + foreach (var user in legacy.Users) + { + var userId = Text(user, "UserId"); + if (userId == null || input.Participants.Any(p => p.UserId == userId)) + continue; + input.Participants.Add(new RecordParticipantInput { UserId = userId, UnitId = Int(user, "UnitId"), Role = Text(user, "Role") }); + } + foreach (var unit in legacy.Units) + { + input.Units.Add(new RecordUnitResponseInput + { + UnitId = Int(unit, "UnitId") ?? throw new ArgumentException("LogUnit.UnitId is required."), + Dispatched = Date(unit, "Dispatched"), Enroute = Date(unit, "Enroute"), OnScene = Date(unit, "OnScene"), Released = Date(unit, "Released"), InQuarters = Date(unit, "InQuarters") + }); + } + return input; + } + + public static string LegacyAuthor(RecordsParityFixture fixture) + { + return Text(fixture.Legacy?.Log, "LoggedByUserId") ?? Author; + } + + private static string Text(JObject o, string key) + { + var token = o?[key]; + return token == null || token.Type == JTokenType.Null ? null : token.Value(); + } + + private static int? Int(JObject o, string key) + { + var token = o?[key]; + return token == null || token.Type == JTokenType.Null ? (int?)null : token.Value(); + } + + public static DateTime? Date(JObject o, string key) => Date(Text(o, key)); + + public static DateTime? Date(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + return DateTime.TryParseExact(value, new[] { "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'", "yyyy-MM-dd'T'HH:mm:sszzz" }, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed : (DateTime?)null; + } + + #endregion + + #region Replay + + public async Task ReplayAsync(RecordsParityFixture fixture) + { + var author = LegacyAuthor(fixture); + var input = MapLegacy(fixture); + var draft = await Records.CreateDraftAsync(Dept, author, input); + var id = draft.Record.RmsOperationalRecordId; + + var bytes = new Dictionary(StringComparer.Ordinal); + foreach (var attachment in fixture.Legacy.Attachments) + { + var data = Convert.FromBase64String(attachment.DataBase64 ?? string.Empty); + bytes[attachment.FileName] = data; + await Records.AddAttachmentAsync(Dept, attachment.UserId ?? author, id, attachment.FileName, attachment.Type, data, attachment.Description); + } + + var current = await Records.GetAsync(Dept, id); + var finalized = await Records.FinalizeAsync(Dept, author, id, current.Record.RowVersion, "1", null, null); + var aggregate = await Records.GetAsync(Dept, id, includeRevisions: true); + var revision = Store.Revisions.Single(r => r.RecordId == id); + var snapshot = RecordSnapshotSerializer.Deserialize(revision.SnapshotJson); + + var officerDocument = await Documents.GetAsync(Dept, Author, id, RmsRecordKind.Operational); + var clerkDocument = await Documents.GetAsync(Dept, Clerk, id, RmsRecordKind.Operational); + + return new RecordsParityReplay + { + Fixture = fixture, + RecordId = id, + Aggregate = aggregate, + Finalized = finalized, + Projection = Store.Projections.Single(p => p.RmsRecordSearchProjectionId == id), + Revision = revision, + Snapshot = snapshot, + ExportJson = RecordSnapshotSerializer.Serialize(snapshot), + PrintHtml = await Documents.RenderHtmlAsync(Dept, Author, officerDocument), + PrintHtmlWithoutRestrictedAccess = await Documents.RenderHtmlAsync(Dept, Clerk, clerkDocument), + ClerkDocument = clerkDocument, + AttachmentBytes = bytes + }; + } + + #endregion + + #region Comparison + + /// Serialize the actual object the way the fixture is written: ISO UTC dates, nulls kept, no reference loops. + public static JObject Actual(object value) + { + return JObject.Parse(JsonConvert.SerializeObject(value, ActualSettings), new JsonLoadSettings()); + } + + /// Every property named in must equal the actual value, compared as JSON tokens with dates as ISO strings. + public static List Mismatches(JObject expected, object actualObject) + { + var actualJson = JsonConvert.SerializeObject(actualObject, ActualSettings); + var actual = JsonConvert.DeserializeObject(actualJson, FixtureSettings); + var mismatches = new List(); + foreach (var property in expected.Properties()) + { + var actualToken = actual[property.Name]; + if (actualToken == null) + { + mismatches.Add($"{property.Name}: not present on the Records side"); + continue; + } + if (!JToken.DeepEquals(Normalize(property.Value), Normalize(actualToken))) + mismatches.Add($"{property.Name}: expected {property.Value.ToString(Formatting.None)} but Records holds {actualToken.ToString(Formatting.None)}"); + } + return mismatches; + } + + private static JToken Normalize(JToken token) + { + if (token.Type == JTokenType.Integer) + return new JValue(token.Value()); + if (token.Type == JTokenType.Float) + return new JValue(token.Value()); + if (token.Type == JTokenType.String) + { + var text = token.Value(); + var date = Date(text); + if (date.HasValue && text.Contains("T")) + return new JValue(date.Value.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture)); + return new JValue(text); + } + return token; + } + + #endregion + } + + public sealed class RecordsParityReplay + { + public RecordsParityFixture Fixture { get; set; } + public string RecordId { get; set; } + public RecordAggregate Aggregate { get; set; } + public RecordAggregate Finalized { get; set; } + public RmsRecordSearchProjection Projection { get; set; } + public RmsRevision Revision { get; set; } + public RecordSnapshot Snapshot { get; set; } + public string ExportJson { get; set; } + public string PrintHtml { get; set; } + public string PrintHtmlWithoutRestrictedAccess { get; set; } + public RecordDocument ClerkDocument { get; set; } + public Dictionary AttachmentBytes { get; set; } + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/RecordsParityTests.cs b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityTests.cs new file mode 100644 index 00000000..695dc02c --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityTests.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms.Parity +{ + /// + /// The Logs parity gate (RMS plan sections 4.1, 6 and 7): "Records must be able to author, view, search, + /// report, print, and export the equivalent of a Run, Training, Work, Meeting, Coroner, Callback, and Unit + /// Activity record before it replaces the Logs writer", and "the parity gate is defined as 'the automated + /// matrix passes'". One golden fixture per record type is replayed through the real aggregate and every + /// projection is asserted. Nothing here reads a legacy row — parity is behavior, not migration. + /// + [TestFixture] + public class RecordsParityTests + { + private static readonly string[] RequiredRecordTypes = { "Run", "Training", "Work", "Meeting", "Coroner", "Callback", "UnitActivity" }; + + public static IEnumerable Fixtures() + { + foreach (var fixture in RecordsParityHarness.LoadFixtures()) + yield return new TestCaseData(fixture).SetName("{m}(" + fixture.RecordType + ")"); + } + + #region The matrix itself + + [Test] + public void Every_replacement_record_type_has_a_golden_fixture() + { + var present = RecordsParityHarness.LoadFixtures().Select(f => f.RecordType).ToList(); + present.Should().Contain(RequiredRecordTypes, "the replacement inventory (plan section 4.1) names exactly these types"); + present.Should().OnlyHaveUniqueItems(); + foreach (var fixture in RecordsParityHarness.LoadFixtures()) + { + RmsDefinitionKeys.LockedTypes.Should().ContainKey(fixture.DefinitionKey); + RmsDefinitionKeys.LockedTypes[fixture.DefinitionKey].ToString().Should().Be(fixture.RecordType); + fixture.Expected.NumberPrefix.Should().Be(RmsDefinitionKeys.DefaultNumberPrefix(fixture.DefinitionKey)); + } + } + + [TestCaseSource(nameof(Fixtures))] + public void A_logs_fixture_captures_every_legacy_column(RecordsParityFixture fixture) + { + // "one file per record type capturing every field, association, attachment" — a fixture that omits a + // column cannot prove the column survives. + if (fixture.Legacy.Source == "UnitLogs") + { + fixture.Legacy.UnitLog.Properties().Select(p => p.Name).Should().BeEquivalentTo(LegacyFieldMap.UnitLog.Keys); + return; + } + fixture.Legacy.Log.Properties().Select(p => p.Name).Should().BeEquivalentTo(LegacyFieldMap.Log.Keys.Where(k => k != "Units" && k != "Users")); + foreach (var user in fixture.Legacy.Users) + user.Properties().Select(p => p.Name).Should().BeEquivalentTo(LegacyFieldMap.LogUser.Keys.Concat(new[] { "Role" }).Where(k => user[k] != null || k != "Role")); + foreach (var unit in fixture.Legacy.Units) + unit.Properties().Select(p => p.Name).Should().BeEquivalentTo(LegacyFieldMap.LogUnit.Keys); + foreach (var attachment in fixture.Legacy.Attachments) + attachment.FileName.Should().NotBeNullOrWhiteSpace(); + } + + [TestCaseSource(nameof(Fixtures))] + public async Task Records_reproduces_the_detail_projection(RecordsParityFixture fixture) + { + var replay = await new RecordsParityHarness().ReplayAsync(fixture); + var expected = fixture.Expected.Detail; + var record = replay.Aggregate.Record; + + record.State.Should().Be((int)RmsRecordState.Finalized, "a legacy Log is created already-final; Quick Entry preserves that"); + record.RecordNumber.Should().StartWith(fixture.Expected.NumberPrefix); + record.AuthorUserId.Should().Be(RecordsParityHarness.LegacyAuthor(fixture)); + record.DefinitionKey.Should().Be(fixture.DefinitionKey); + record.RecordType.Should().Be((int)RmsDefinitionKeys.LockedTypes[fixture.DefinitionKey]); + if (expected.ExternalId != null) record.ExternalId.Should().Be(expected.ExternalId); + if (expected.StationGroupId.HasValue) record.StationGroupId.Should().Be(expected.StationGroupId); + if (expected.CallId.HasValue) record.CallId.Should().Be(expected.CallId); + if (expected.StartedOn != null) record.StartedOn.Should().Be(RecordsParityHarness.Date(expected.StartedOn)); + if (expected.EndedOn != null) record.EndedOn.Should().Be(RecordsParityHarness.Date(expected.EndedOn)); + + RecordsParityHarness.Mismatches(expected.Details, replay.Aggregate.Details).Should().BeEmpty("every typed field must survive the replay"); + + replay.Aggregate.Participants.Should().HaveCount(expected.Participants.Count); + foreach (var participant in expected.Participants) + { + var actual = replay.Aggregate.Participants.SingleOrDefault(p => p.UserId == participant.Value("UserId")); + actual.Should().NotBeNull($"participant {participant["UserId"]} must be carried"); + RecordsParityHarness.Mismatches(participant, actual).Should().BeEmpty(); + actual.DisplayNameSnapshot.Should().NotBeNullOrWhiteSpace("person snapshots keep the record stable when a profile changes later"); + } + + replay.Aggregate.Units.Should().HaveCount(expected.Units.Count); + foreach (var unit in expected.Units) + { + var actual = replay.Aggregate.Units.SingleOrDefault(u => u.UnitId == unit.Value("UnitId")); + actual.Should().NotBeNull($"unit {unit["UnitId"]} must be carried"); + RecordsParityHarness.Mismatches(unit, actual).Should().BeEmpty(); + actual.UnitNameSnapshot.Should().NotBeNullOrWhiteSpace("unit snapshots keep the record stable when apparatus changes later"); + } + + replay.Aggregate.Attachments.Should().HaveCount(expected.Attachments.Count); + foreach (var attachment in expected.Attachments) + { + var actual = replay.Aggregate.Attachments.SingleOrDefault(a => a.FileName == attachment.Value("FileName")); + actual.Should().NotBeNull($"attachment {attachment["FileName"]} must be carried"); + RecordsParityHarness.Mismatches(attachment, actual).Should().BeEmpty(); + actual.Checksum.Should().Be(RecordSnapshotSerializer.Checksum(replay.AttachmentBytes[actual.FileName]), "the stored bytes are what was uploaded"); + actual.UploadedByUserId.Should().NotBeNullOrWhiteSpace(); + } + } + + [TestCaseSource(nameof(Fixtures))] + public async Task Records_reproduces_the_list_and_search_projection(RecordsParityFixture fixture) + { + var replay = await new RecordsParityHarness().ReplayAsync(fixture); + var expected = fixture.Expected.List; + var projection = replay.Projection; + + projection.State.Should().Be((int)RmsRecordState.Finalized); + projection.DefinitionKey.Should().Be(fixture.DefinitionKey); + projection.RecordNumber.Should().StartWith(fixture.Expected.NumberPrefix); + projection.IsLegacy.Should().BeFalse(); + projection.DisplaySummary.Should().Be(expected.DisplaySummary); + if (expected.OccurredOn != null) projection.OccurredOn.Should().Be(RecordsParityHarness.Date(expected.OccurredOn)); + projection.CallNumber.Should().Be(expected.CallNumber); + foreach (var text in expected.SearchTextContains) + projection.SearchText.Should().Contain(text); + foreach (var text in expected.SearchTextExcludes) + (projection.SearchText ?? string.Empty).Should().NotContain(text, "the list projection is the safe generic-search projection (plan section 5.10)"); + SplitIds(projection.ParticipantUserIds).Should().BeEquivalentTo(expected.ParticipantUserIds); + SplitIds(projection.UnitIds).Select(int.Parse).Should().BeEquivalentTo(expected.UnitIds); + projection.AuthorUserId.Should().Be(RecordsParityHarness.LegacyAuthor(fixture)); + } + + [TestCaseSource(nameof(Fixtures))] + public async Task Records_reproduces_the_export_projection(RecordsParityFixture fixture) + { + var replay = await new RecordsParityHarness().ReplayAsync(fixture); + var expected = fixture.Expected.Export; + + replay.Revision.Checksum.Should().Be(RecordSnapshotSerializer.Checksum(replay.Revision.SnapshotJson), "the revision is checksummed"); + RecordSnapshotSerializer.Serialize(RecordSnapshotSerializer.Deserialize(replay.Revision.SnapshotJson)).Should().Be(replay.Revision.SnapshotJson, "serialization is canonical so a re-export reproduces the checksum"); + var export = JObject.Parse(replay.ExportJson); + foreach (var field in RecordSnapshotSerializer.DetailFieldOrder) + ((JObject)export["Details"]).ContainsKey(field).Should().BeTrue($"the export carries every typed field by its stable key, including {field}"); + foreach (var text in expected.MustContain) + replay.ExportJson.Should().Contain(text); + foreach (var attachment in export["Attachments"]) + attachment["Data"].Type.Should().Be(JTokenType.Null, "attachment bytes are referenced by checksum, never embedded in an export"); + foreach (var field in expected.RestrictedFields) + { + RecordSnapshotSerializer.RestrictedDetailFields.Should().Contain(field); + replay.ClerkDocument.WithheldFields.Should().Contain(w => w.EndsWith(field, StringComparison.Ordinal), $"{field} is withheld from a viewer without RecordRestricted_View"); + } + } + + [TestCaseSource(nameof(Fixtures))] + public async Task Records_reproduces_the_print_projection(RecordsParityFixture fixture) + { + var replay = await new RecordsParityHarness().ReplayAsync(fixture); + var expected = fixture.Expected.Print; + + replay.PrintHtml.Should().Contain("Parity Fire Department").And.Contain(replay.Aggregate.Record.RecordNumber).And.Contain(replay.Revision.Checksum); + foreach (var text in expected.Contains) + replay.PrintHtml.Should().Contain(text); + foreach (var text in expected.WithheldWithoutRestrictedAccess) + { + replay.PrintHtml.Should().Contain(text, "an authorized officer sees the restricted section"); + replay.PrintHtmlWithoutRestrictedAccess.Should().NotContain(text, "a viewer without RecordRestricted_View never sees it on paper either"); + } + if (expected.WithheldWithoutRestrictedAccess.Count > 0) + replay.PrintHtmlWithoutRestrictedAccess.Should().Contain("withheld"); + } + + [TestCaseSource(nameof(Fixtures))] + public async Task A_replayed_record_emits_exactly_one_created_and_one_finalized_event(RecordsParityFixture fixture) + { + var harness = new RecordsParityHarness(); + var replay = await harness.ReplayAsync(fixture); + var events = harness.Store.Outbox.Where(o => o.AggregateId == replay.RecordId).Select(o => o.TriggerEventType).ToList(); + events.Should().Contain((int)WorkflowTriggerEventType.RecordCreated).And.Contain((int)WorkflowTriggerEventType.RecordFinalized); + events.Count(t => t == (int)WorkflowTriggerEventType.RecordFinalized).Should().Be(1, "one-step create/finalize preserves sequence (plan section 7)"); + } + + #endregion + + #region The inventory is executable + + [TestCase(typeof(Log), nameof(LegacyFieldMap.Log))] + [TestCase(typeof(LogUser), nameof(LegacyFieldMap.LogUser))] + [TestCase(typeof(LogUnit), nameof(LegacyFieldMap.LogUnit))] + [TestCase(typeof(LogAttachment), nameof(LegacyFieldMap.LogAttachment))] + [TestCase(typeof(UnitLog), nameof(LegacyFieldMap.UnitLog))] + public void Every_legacy_column_is_in_the_inventory(Type entity, string mapName) + { + var map = (IReadOnlyDictionary)typeof(LegacyFieldMap).GetField(mapName, BindingFlags.Public | BindingFlags.Static).GetValue(null); + var columns = PersistedColumns(entity).ToList(); + + columns.Should().NotBeEmpty(); + columns.Should().BeSubsetOf(map.Keys, $"every persisted column of {entity.Name} needs a Records destination or a stated reason it is not carried; a new column must be decided here before it ships"); + map.Keys.Should().BeSubsetOf(columns.Concat(new[] { "Units", "Users" }), $"the {entity.Name} inventory names a column that no longer exists"); + map.Values.Should().OnlyContain(v => !string.IsNullOrWhiteSpace(v)); + } + + [Test] + public void Every_not_carried_decision_states_its_reason() + { + var all = new[] { LegacyFieldMap.Log, LegacyFieldMap.LogUser, LegacyFieldMap.LogUnit, LegacyFieldMap.LogAttachment, LegacyFieldMap.UnitLog }.SelectMany(m => m); + foreach (var entry in all.Where(e => e.Value.StartsWith(LegacyFieldMap.NotCarried, StringComparison.Ordinal))) + entry.Value.Length.Should().BeGreaterThan(LegacyFieldMap.NotCarried.Length + 10, $"{entry.Key} is not carried and must say why"); + all.Count(e => e.Value.StartsWith(LegacyFieldMap.NotCarried, StringComparison.Ordinal)).Should().Be(5, "only the five legacy identity columns are not carried"); + } + + private static IEnumerable PersistedColumns(Type entity) + { + var plumbing = new HashSet(StringComparer.Ordinal) { "IdValue", "IdType", "TableName", "IdName", "IgnoredProperties" }; + foreach (var property in entity.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (plumbing.Contains(property.Name)) + continue; + var getter = property.GetGetMethod(); + var isNavigation = getter != null && getter.IsVirtual && !getter.IsFinal && !typeof(IEnumerable).IsAssignableFrom(property.PropertyType); + if (isNavigation) + continue; + if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType) && property.PropertyType != typeof(string) && property.PropertyType != typeof(byte[])) + { + // Collections are associations: Units/Users on Log are mapped explicitly; anything else must be decided. + yield return property.Name; + continue; + } + yield return property.Name; + } + } + + private static IEnumerable SplitIds(string csv) + { + return string.IsNullOrWhiteSpace(csv) ? Enumerable.Empty() : csv.Split(',', StringSplitOptions.RemoveEmptyEntries); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/callback.json b/Tests/Resgrid.Tests/Rms/Parity/callback.json new file mode 100644 index 00000000..2914c35f --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/callback.json @@ -0,0 +1,83 @@ +{ + "recordType": "Callback", + "definitionKey": "system.callback", + "legacy": { + "source": "Logs", + "log": { + "LogId": 1006, + "DepartmentId": 9, + "Narrative": "Off-duty crew called back to cover the station while first-due units were committed to the structure fire.", + "LogType": 7, + "ExternalId": "CB-0042", + "InitialReport": null, + "Type": "Station coverage", + "StationGroupId": 11, + "Course": null, + "CourseCode": null, + "Instructors": null, + "Cause": null, + "InvestigatedByUserId": null, + "ContactName": null, + "ContactNumber": null, + "StartedOn": "2026-03-04T17:50:00Z", + "EndedOn": "2026-03-04T19:30:00Z", + "LoggedOn": "2026-03-04T19:45:00Z", + "LoggedByUserId": "author", + "OfficerUserId": null, + "CallId": 77, + "OtherPersonnel": null, + "Location": null, + "OtherAgencies": null, + "OtherUnits": null, + "BodyLocation": null, + "PronouncedDeceasedBy": null + }, + "users": [ + { "LogUserId": 9, "LogId": 1006, "UnitId": 6, "UserId": "p3" } + ], + "units": [ + { "LogUnitId": 6, "LogId": 1006, "UnitId": 6, "Dispatched": null, "Enroute": null, "OnScene": "2026-03-04T17:50:00Z", "Released": "2026-03-04T19:30:00Z", "InQuarters": null } + ], + "attachments": [] + }, + "expected": { + "numberPrefix": "CBK", + "list": { + "displaySummary": "C2026-0009 Structure fire", + "searchTextContains": [ "C2026-0009", "CB-0042", "Station coverage" ], + "searchTextExcludes": [ "Off-duty crew" ], + "occurredOn": "2026-03-04T17:50:00Z", + "callNumber": "C2026-0009", + "participantUserIds": [ "p3" ], + "unitIds": [ 6 ] + }, + "detail": { + "externalId": "CB-0042", + "stationGroupId": 11, + "callId": 77, + "startedOn": "2026-03-04T17:50:00Z", + "endedOn": "2026-03-04T19:30:00Z", + "details": { + "Narrative": "Off-duty crew called back to cover the station while first-due units were committed to the structure fire.", + "Type": "Station coverage", + "CallNumber": "C2026-0009", + "CallName": "Structure fire" + }, + "participants": [ + { "UserId": "p3", "UnitId": 6 } + ], + "units": [ + { "UnitId": 6, "UnitNameSnapshot": "Ladder 6", "OnScene": "2026-03-04T17:50:00Z", "Released": "2026-03-04T19:30:00Z" } + ], + "attachments": [] + }, + "export": { + "mustContain": [ "C2026-0009", "CB-0042", "Station coverage" ], + "restrictedFields": [] + }, + "print": { + "contains": [ "C2026-0009", "Station coverage", "Ladder 6" ], + "withheldWithoutRestrictedAccess": [] + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/coroner.json b/Tests/Resgrid.Tests/Rms/Parity/coroner.json new file mode 100644 index 00000000..2c67b46d --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/coroner.json @@ -0,0 +1,89 @@ +{ + "recordType": "Coroner", + "definitionKey": "system.coroner", + "legacy": { + "source": "Logs", + "log": { + "LogId": 1005, + "DepartmentId": 9, + "Narrative": "Crew assisted the county coroner at a residence; scene secured pending transport.", + "LogType": 6, + "ExternalId": null, + "InitialReport": null, + "Type": null, + "StationGroupId": 11, + "Course": null, + "CourseCode": null, + "Instructors": null, + "Cause": null, + "InvestigatedByUserId": "inv1", + "ContactName": "Deputy Coroner Lee", + "ContactNumber": "555-0177", + "StartedOn": "2026-03-12T03:10:00Z", + "EndedOn": "2026-03-12T05:00:00Z", + "LoggedOn": "2026-03-12T05:30:00Z", + "LoggedByUserId": "author", + "OfficerUserId": "officer1", + "CallId": 77, + "OtherPersonnel": "Neighbor who found the deceased", + "Location": "1 Main St", + "OtherAgencies": "County Coroner", + "OtherUnits": null, + "BodyLocation": "Rear bedroom, ground floor", + "PronouncedDeceasedBy": "Dr. Okafor" + }, + "users": [ + { "LogUserId": 8, "LogId": 1005, "UnitId": 5, "UserId": "p2" } + ], + "units": [ + { "LogUnitId": 5, "LogId": 1005, "UnitId": 5, "Dispatched": "2026-03-12T03:05:00Z", "Enroute": "2026-03-12T03:07:00Z", "OnScene": "2026-03-12T03:15:00Z", "Released": "2026-03-12T05:00:00Z", "InQuarters": "2026-03-12T05:12:00Z" } + ], + "attachments": [] + }, + "expected": { + "numberPrefix": "COR", + "list": { + "displaySummary": "Coroner", + "searchTextContains": [ "C2026-0009" ], + "searchTextExcludes": [ "Rear bedroom", "Dr. Okafor", "Neighbor", "555-0177", "Deputy Coroner Lee" ], + "occurredOn": "2026-03-12T03:10:00Z", + "callNumber": "C2026-0009", + "participantUserIds": [ "officer1", "p2" ], + "unitIds": [ 5 ] + }, + "detail": { + "stationGroupId": 11, + "callId": 77, + "startedOn": "2026-03-12T03:10:00Z", + "endedOn": "2026-03-12T05:00:00Z", + "details": { + "Narrative": "Crew assisted the county coroner at a residence; scene secured pending transport.", + "InvestigatedByUserId": "inv1", + "ContactName": "Deputy Coroner Lee", + "ContactNumber": "555-0177", + "OtherPersonnel": "Neighbor who found the deceased", + "Location": "1 Main St", + "OtherAgencies": "County Coroner", + "BodyLocation": "Rear bedroom, ground floor", + "PronouncedDeceasedBy": "Dr. Okafor", + "CallNumber": "C2026-0009" + }, + "participants": [ + { "UserId": "officer1", "Role": "Officer" }, + { "UserId": "p2", "UnitId": 5 } + ], + "units": [ + { "UnitId": 5, "UnitNameSnapshot": "Engine 5", "OnScene": "2026-03-12T03:15:00Z", "InQuarters": "2026-03-12T05:12:00Z" } + ], + "attachments": [] + }, + "export": { + "mustContain": [ "Rear bedroom, ground floor", "Dr. Okafor", "County Coroner" ], + "restrictedFields": [ "BodyLocation", "PronouncedDeceasedBy", "OtherPersonnel" ] + }, + "print": { + "contains": [ "County Coroner", "Engine 5", "Crew assisted the county coroner" ], + "withheldWithoutRestrictedAccess": [ "Rear bedroom, ground floor", "Dr. Okafor", "Neighbor who found the deceased" ] + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/meeting.json b/Tests/Resgrid.Tests/Rms/Parity/meeting.json new file mode 100644 index 00000000..30ccb35d --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/meeting.json @@ -0,0 +1,84 @@ +{ + "recordType": "Meeting", + "definitionKey": "system.meeting", + "legacy": { + "source": "Logs", + "log": { + "LogId": 1004, + "DepartmentId": 9, + "Narrative": "Monthly business meeting. Approved the apparatus replacement study; next meeting set for April.", + "LogType": 5, + "ExternalId": "MTG-2026-03", + "InitialReport": null, + "Type": "Business meeting", + "StationGroupId": null, + "Course": null, + "CourseCode": null, + "Instructors": "Chief Alvarez", + "Cause": null, + "InvestigatedByUserId": null, + "ContactName": null, + "ContactNumber": null, + "StartedOn": "2026-03-10T00:00:00Z", + "EndedOn": "2026-03-10T01:30:00Z", + "LoggedOn": "2026-03-10T01:40:00Z", + "LoggedByUserId": "author", + "OfficerUserId": null, + "CallId": null, + "OtherPersonnel": "Two guests from the auxiliary", + "Location": "Station 1 training room", + "OtherAgencies": null, + "OtherUnits": null, + "BodyLocation": null, + "PronouncedDeceasedBy": null + }, + "users": [ + { "LogUserId": 6, "LogId": 1004, "UnitId": null, "UserId": "p2" }, + { "LogUserId": 7, "LogId": 1004, "UnitId": null, "UserId": "p3" } + ], + "units": [], + "attachments": [ + { "LogAttachmentId": 3, "LogId": 1004, "FileName": "minutes.docx", "Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "Size": 2, "DataBase64": "UEs=", "UserId": "author", "Timestamp": "2026-03-10T01:41:00Z", "Description": "Meeting minutes" } + ] + }, + "expected": { + "numberPrefix": "MTG", + "list": { + "displaySummary": "Business meeting", + "searchTextContains": [ "Business meeting", "MTG-2026-03" ], + "searchTextExcludes": [ "apparatus replacement", "auxiliary" ], + "occurredOn": "2026-03-10T00:00:00Z", + "callNumber": null, + "participantUserIds": [ "p2", "p3" ], + "unitIds": [] + }, + "detail": { + "externalId": "MTG-2026-03", + "startedOn": "2026-03-10T00:00:00Z", + "endedOn": "2026-03-10T01:30:00Z", + "details": { + "Narrative": "Monthly business meeting. Approved the apparatus replacement study; next meeting set for April.", + "Type": "Business meeting", + "Instructors": "Chief Alvarez", + "OtherPersonnel": "Two guests from the auxiliary", + "Location": "Station 1 training room" + }, + "participants": [ + { "UserId": "p2" }, + { "UserId": "p3" } + ], + "units": [], + "attachments": [ + { "FileName": "minutes.docx", "ContentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "ByteSize": 2, "Description": "Meeting minutes", "UploadedByUserId": "author" } + ] + }, + "export": { + "mustContain": [ "Business meeting", "Chief Alvarez", "minutes.docx", "Station 1 training room" ], + "restrictedFields": [ "OtherPersonnel" ] + }, + "print": { + "contains": [ "Business meeting", "Chief Alvarez", "Station 1 training room", "minutes.docx" ], + "withheldWithoutRestrictedAccess": [ "Two guests from the auxiliary" ] + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/run.json b/Tests/Resgrid.Tests/Rms/Parity/run.json new file mode 100644 index 00000000..d36365dd --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/run.json @@ -0,0 +1,103 @@ +{ + "recordType": "Run", + "definitionKey": "system.run", + "legacy": { + "source": "Logs", + "log": { + "LogId": 1002, + "DepartmentId": 9, + "Narrative": "Engine 5 arrived to find light smoke from the kitchen. Fire confined to the stove; extinguished with a dry chemical extinguisher.", + "LogType": 1, + "ExternalId": "EXT-RUN-1002", + "InitialReport": "Caller reported smoke from a residence.", + "Type": "Structure fire", + "StationGroupId": 11, + "Course": null, + "CourseCode": null, + "Instructors": null, + "Cause": "Unattended cooking", + "InvestigatedByUserId": "inv1", + "ContactName": "Resident Occupant", + "ContactNumber": "555-0142", + "StartedOn": "2026-03-04T17:45:00Z", + "EndedOn": "2026-03-04T18:40:00Z", + "LoggedOn": "2026-03-04T19:10:00Z", + "LoggedByUserId": "author", + "OfficerUserId": "officer1", + "CallId": 77, + "OtherPersonnel": null, + "Location": "1 Main St", + "OtherAgencies": "County Sheriff", + "OtherUnits": "Mutual aid Ladder 12", + "BodyLocation": null, + "PronouncedDeceasedBy": null + }, + "users": [ + { "LogUserId": 3, "LogId": 1002, "UnitId": 5, "UserId": "p2" } + ], + "units": [ + { "LogUnitId": 2, "LogId": 1002, "UnitId": 5, "Dispatched": "2026-03-04T17:43:00Z", "Enroute": "2026-03-04T17:45:00Z", "OnScene": "2026-03-04T17:52:00Z", "Released": "2026-03-04T18:35:00Z", "InQuarters": "2026-03-04T18:48:00Z" }, + { "LogUnitId": 3, "LogId": 1002, "UnitId": 6, "Dispatched": "2026-03-04T17:43:00Z", "Enroute": "2026-03-04T17:46:00Z", "OnScene": "2026-03-04T17:55:00Z", "Released": "2026-03-04T18:20:00Z", "InQuarters": null } + ], + "attachments": [ + { "LogAttachmentId": 2, "LogId": 1002, "FileName": "scene-notes.txt", "Type": "text/plain", "Size": 22, "DataBase64": "UG9pbnQgb2Ygb3JpZ2luOiBzdG92ZQ==", "UserId": "author", "Timestamp": "2026-03-04T19:11:00Z", "Description": "Point of origin" } + ] + }, + "expected": { + "numberPrefix": "RUN", + "list": { + "displaySummary": "C2026-0009 Structure fire", + "searchTextContains": [ "C2026-0009", "Structure fire", "EXT-RUN-1002" ], + "searchTextExcludes": [ "light smoke", "555-0142", "Resident Occupant", "Unattended cooking" ], + "occurredOn": "2026-03-04T17:45:00Z", + "callNumber": "C2026-0009", + "participantUserIds": [ "officer1", "p2" ], + "unitIds": [ 5, 6 ] + }, + "detail": { + "externalId": "EXT-RUN-1002", + "stationGroupId": 11, + "callId": 77, + "startedOn": "2026-03-04T17:45:00Z", + "endedOn": "2026-03-04T18:40:00Z", + "details": { + "Narrative": "Engine 5 arrived to find light smoke from the kitchen. Fire confined to the stove; extinguished with a dry chemical extinguisher.", + "InitialReport": "Caller reported smoke from a residence.", + "Type": "Structure fire", + "Cause": "Unattended cooking", + "InvestigatedByUserId": "inv1", + "ContactName": "Resident Occupant", + "ContactNumber": "555-0142", + "Location": "1 Main St", + "OtherAgencies": "County Sheriff", + "OtherUnits": "Mutual aid Ladder 12", + "CallNumber": "C2026-0009", + "CallName": "Structure fire", + "CallType": "Fire", + "CallPriority": 3, + "CallLoggedOn": "2026-03-04T17:42:00Z", + "CallAddress": "1 Main St", + "CallNature": "Smoke showing" + }, + "participants": [ + { "UserId": "officer1", "Role": "Officer" }, + { "UserId": "p2", "UnitId": 5 } + ], + "units": [ + { "UnitId": 5, "UnitNameSnapshot": "Engine 5", "Dispatched": "2026-03-04T17:43:00Z", "Enroute": "2026-03-04T17:45:00Z", "OnScene": "2026-03-04T17:52:00Z", "Released": "2026-03-04T18:35:00Z", "InQuarters": "2026-03-04T18:48:00Z" }, + { "UnitId": 6, "UnitNameSnapshot": "Ladder 6", "Dispatched": "2026-03-04T17:43:00Z", "Enroute": "2026-03-04T17:46:00Z", "OnScene": "2026-03-04T17:55:00Z", "Released": "2026-03-04T18:20:00Z", "InQuarters": null } + ], + "attachments": [ + { "FileName": "scene-notes.txt", "ContentType": "text/plain", "ByteSize": 22, "Description": "Point of origin" } + ] + }, + "export": { + "mustContain": [ "C2026-0009", "Unattended cooking", "County Sheriff", "scene-notes.txt", "Mutual aid Ladder 12" ], + "restrictedFields": [] + }, + "print": { + "contains": [ "C2026-0009", "Structure fire", "Unattended cooking", "Engine 5", "Ladder 6", "County Sheriff" ], + "withheldWithoutRestrictedAccess": [] + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/training.json b/Tests/Resgrid.Tests/Rms/Parity/training.json new file mode 100644 index 00000000..87738bea --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/training.json @@ -0,0 +1,91 @@ +{ + "recordType": "Training", + "definitionKey": "system.training", + "legacy": { + "source": "Logs", + "log": { + "LogId": 1001, + "DepartmentId": 9, + "Narrative": "Hose evolutions and CPR refresher for A shift. All attendees completed the skills station.", + "LogType": 2, + "ExternalId": "EXT-TRN-1001", + "InitialReport": null, + "Type": null, + "StationGroupId": 11, + "Course": "CPR Refresher", + "CourseCode": "CPR-101", + "Instructors": "Capt. Reyes", + "Cause": null, + "InvestigatedByUserId": null, + "ContactName": null, + "ContactNumber": null, + "StartedOn": "2026-03-04T18:00:00Z", + "EndedOn": "2026-03-04T20:30:00Z", + "LoggedOn": "2026-03-04T21:05:00Z", + "LoggedByUserId": "author", + "OfficerUserId": null, + "CallId": null, + "OtherPersonnel": null, + "Location": null, + "OtherAgencies": null, + "OtherUnits": null, + "BodyLocation": null, + "PronouncedDeceasedBy": null + }, + "users": [ + { "LogUserId": 1, "LogId": 1001, "UnitId": 5, "UserId": "p2" }, + { "LogUserId": 2, "LogId": 1001, "UnitId": null, "UserId": "p3" } + ], + "units": [ + { "LogUnitId": 1, "LogId": 1001, "UnitId": 5, "Dispatched": null, "Enroute": null, "OnScene": "2026-03-04T18:00:00Z", "Released": "2026-03-04T20:30:00Z", "InQuarters": null } + ], + "attachments": [ + { "LogAttachmentId": 1, "LogId": 1001, "FileName": "roster.pdf", "Type": "application/pdf", "Size": 3, "DataBase64": "AQID", "UserId": "author", "Timestamp": "2026-03-04T21:06:00Z", "Description": "Sign-in roster" } + ] + }, + "expected": { + "numberPrefix": "TRN", + "list": { + "displaySummary": "CPR Refresher (CPR-101)", + "searchTextContains": [ "CPR Refresher", "CPR-101", "EXT-TRN-1001" ], + "searchTextExcludes": [ "Hose evolutions", "Capt. Reyes" ], + "occurredOn": "2026-03-04T18:00:00Z", + "callNumber": null, + "participantUserIds": [ "p2", "p3" ], + "unitIds": [ 5 ] + }, + "detail": { + "externalId": "EXT-TRN-1001", + "stationGroupId": 11, + "startedOn": "2026-03-04T18:00:00Z", + "endedOn": "2026-03-04T20:30:00Z", + "details": { + "Narrative": "Hose evolutions and CPR refresher for A shift. All attendees completed the skills station.", + "Course": "CPR Refresher", + "CourseCode": "CPR-101", + "Instructors": "Capt. Reyes", + "InitialReport": null, + "Cause": null, + "ContactName": null + }, + "participants": [ + { "UserId": "p2", "UnitId": 5 }, + { "UserId": "p3", "UnitId": null } + ], + "units": [ + { "UnitId": 5, "UnitNameSnapshot": "Engine 5", "OnScene": "2026-03-04T18:00:00Z", "Released": "2026-03-04T20:30:00Z", "Dispatched": null } + ], + "attachments": [ + { "FileName": "roster.pdf", "ContentType": "application/pdf", "ByteSize": 3, "Description": "Sign-in roster" } + ] + }, + "export": { + "mustContain": [ "CPR Refresher", "CPR-101", "Capt. Reyes", "roster.pdf", "EXT-TRN-1001" ], + "restrictedFields": [] + }, + "print": { + "contains": [ "CPR Refresher", "CPR-101", "Capt. Reyes", "Engine 5", "roster.pdf" ], + "withheldWithoutRestrictedAccess": [] + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/unit-activity.json b/Tests/Resgrid.Tests/Rms/Parity/unit-activity.json new file mode 100644 index 00000000..d6624118 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/unit-activity.json @@ -0,0 +1,52 @@ +{ + "recordType": "UnitActivity", + "definitionKey": "system.unit-activity", + "legacy": { + "source": "UnitLogs", + "unitLog": { + "UnitLogId": 501, + "UnitId": 5, + "Timestamp": "2026-03-06T14:20:00Z", + "Narrative": "Engine 5 pump test completed; 1,250 gpm at 150 psi for 20 minutes, no leaks.", + "IsProtected": false + }, + "log": null, + "users": [], + "units": [], + "attachments": [] + }, + "expected": { + "numberPrefix": "UNT", + "list": { + "displaySummary": "Engine 5", + "searchTextContains": [ "Engine 5" ], + "searchTextExcludes": [ "pump test" ], + "occurredOn": "2026-03-06T14:20:00Z", + "callNumber": null, + "participantUserIds": [], + "unitIds": [ 5 ] + }, + "detail": { + "details": { + "Narrative": "Engine 5 pump test completed; 1,250 gpm at 150 psi for 20 minutes, no leaks.", + "UnitId": 5, + "ActivityOn": "2026-03-06T14:20:00Z", + "IsProtected": false, + "ProtectedEnvelope": null + }, + "participants": [], + "units": [ + { "UnitId": 5, "UnitNameSnapshot": "Engine 5", "UnitTypeSnapshot": "Engine" } + ], + "attachments": [] + }, + "export": { + "mustContain": [ "pump test", "\"UnitId\":5", "2026-03-06T14:20:00Z" ], + "restrictedFields": [] + }, + "print": { + "contains": [ "Engine 5", "pump test" ], + "withheldWithoutRestrictedAccess": [] + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/Parity/work.json b/Tests/Resgrid.Tests/Rms/Parity/work.json new file mode 100644 index 00000000..e0eda9b4 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/Parity/work.json @@ -0,0 +1,83 @@ +{ + "recordType": "Work", + "definitionKey": "system.work", + "legacy": { + "source": "Logs", + "log": { + "LogId": 1003, + "DepartmentId": 9, + "Narrative": "Station 1 apparatus bay: replaced the compressor belt and flushed the hydrant on Elm St.", + "LogType": 4, + "ExternalId": null, + "InitialReport": null, + "Type": "Station maintenance", + "StationGroupId": 11, + "Course": null, + "CourseCode": null, + "Instructors": null, + "Cause": null, + "InvestigatedByUserId": null, + "ContactName": null, + "ContactNumber": null, + "StartedOn": "2026-03-05T13:00:00Z", + "EndedOn": "2026-03-05T16:15:00Z", + "LoggedOn": "2026-03-05T16:20:00Z", + "LoggedByUserId": "author", + "OfficerUserId": null, + "CallId": null, + "OtherPersonnel": null, + "Location": "Station 1", + "OtherAgencies": null, + "OtherUnits": null, + "BodyLocation": null, + "PronouncedDeceasedBy": null + }, + "users": [ + { "LogUserId": 4, "LogId": 1003, "UnitId": null, "UserId": "p2" }, + { "LogUserId": 5, "LogId": 1003, "UnitId": 6, "UserId": "p3" } + ], + "units": [ + { "LogUnitId": 4, "LogId": 1003, "UnitId": 6, "Dispatched": null, "Enroute": null, "OnScene": null, "Released": null, "InQuarters": null } + ], + "attachments": [] + }, + "expected": { + "numberPrefix": "WRK", + "list": { + "displaySummary": "Station maintenance", + "searchTextContains": [ "Station maintenance" ], + "searchTextExcludes": [ "compressor belt" ], + "occurredOn": "2026-03-05T13:00:00Z", + "callNumber": null, + "participantUserIds": [ "p2", "p3" ], + "unitIds": [ 6 ] + }, + "detail": { + "stationGroupId": 11, + "startedOn": "2026-03-05T13:00:00Z", + "endedOn": "2026-03-05T16:15:00Z", + "details": { + "Narrative": "Station 1 apparatus bay: replaced the compressor belt and flushed the hydrant on Elm St.", + "Type": "Station maintenance", + "Location": "Station 1", + "Course": null + }, + "participants": [ + { "UserId": "p2", "UnitId": null }, + { "UserId": "p3", "UnitId": 6 } + ], + "units": [ + { "UnitId": 6, "UnitNameSnapshot": "Ladder 6", "Dispatched": null, "OnScene": null } + ], + "attachments": [] + }, + "export": { + "mustContain": [ "Station maintenance", "compressor belt", "Station 1" ], + "restrictedFields": [] + }, + "print": { + "contains": [ "Station maintenance", "compressor belt", "Ladder 6" ], + "withheldWithoutRestrictedAccess": [] + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs b/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs new file mode 100644 index 00000000..3ca3b395 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/PassthroughRecordsProtection.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Tests.Rms +{ + /// + /// The RMS protection seam for fixtures that test everything except Advanced Data Protection: writes pass + /// through untouched, reads reveal nothing because nothing is sealed, and the workload lanes always open. + /// The ADP behaviour itself is covered by RecordsProtectionServiceTests against the real service. + /// + public sealed class PassthroughRecordsProtection : IRecordsProtectionService + { + public bool Enforced { get; set; } + public int CatalogVersion { get; set; } + public bool AllowWorkload { get; set; } = true; + public List Writes { get; } = new List(); + + private Task Write(string what) { Writes.Add(what); return Task.CompletedTask; } + private static Task Empty() => Task.FromResult(new ProtectedReadResult()); + + public Task GetCatalogVersionAsync(int departmentId) => Task.FromResult(CatalogVersion); + public Task IsEnforcedAsync(int departmentId) => Task.FromResult(Enforced); + public Task ProtectDetailsAsync(int departmentId, RmsOperationalRecordDetail row, RmsOperationalRecordDetail existing, string userId = null, CancellationToken cancellationToken = default) => Write("details"); + public Task ProtectAttachmentAsync(int departmentId, RmsRecordAttachment row, RmsRecordAttachment existing, string userId = null, CancellationToken cancellationToken = default) => Write("attachment"); + public Task ProtectRevisionAsync(int departmentId, RmsRevision row, string userId = null, CancellationToken cancellationToken = default) => Write("revision"); + public Task ProtectLocationAsync(int departmentId, RmsLocation row, RmsLocation existing, string userId = null, CancellationToken cancellationToken = default) => Write("location"); + public Task ProtectNarrativeAsync(int departmentId, RmsNarrative row, RmsNarrative existing, string userId = null, CancellationToken cancellationToken = default) => Write("narrative"); + public Task ProtectSourceFactAsync(int departmentId, RmsSourceFact row, RmsSourceFact existing, string userId = null, CancellationToken cancellationToken = default) => Write("fact"); + public Task ProtectCasualtyAsync(int departmentId, RmsCasualtyRescue row, RmsCasualtyRescue existing, string userId = null, CancellationToken cancellationToken = default) => Write("casualty"); + public Task ProtectExposureAsync(int departmentId, RmsExposure row, RmsExposure existing, string userId = null, CancellationToken cancellationToken = default) => Write("exposure"); + public Task ProtectModuleAsync(int departmentId, RmsIncidentModule row, RmsIncidentModule existing, string userId = null, CancellationToken cancellationToken = default) => Write("module"); + public Task ProtectPropertyAsync(int departmentId, RmsIncidentProperty row, RmsIncidentProperty existing, string userId = null, CancellationToken cancellationToken = default) => Write("property"); + public Task ProtectVehicleAsync(int departmentId, RmsIncidentVehicle row, RmsIncidentVehicle existing, string userId = null, CancellationToken cancellationToken = default) => Write("vehicle"); + public Task ProtectResourceAsync(int departmentId, RmsIncidentResource row, RmsIncidentResource existing, string userId = null, CancellationToken cancellationToken = default) => Write("resource"); + public Task ProtectSubmissionAsync(int departmentId, RmsSubmission row, string userId = null, CancellationToken cancellationToken = default) => Write("submission"); + public Task ProtectSignatureAsync(int departmentId, RmsSignature row, string userId = null, CancellationToken cancellationToken = default) => Write("signature"); + public Task ProtectEvidenceAsync(int departmentId, RmsEvidenceArtifact row, string userId = null, CancellationToken cancellationToken = default) => Write("evidence"); + public Task ProtectDisclosureRequestAsync(int departmentId, RmsDisclosureRequest row, RmsDisclosureRequest existing, string userId = null, CancellationToken cancellationToken = default) => Write("disclosure-request"); + public Task ProtectDisclosureProductionAsync(int departmentId, RmsDisclosureProduction row, string userId = null, CancellationToken cancellationToken = default) => Write("disclosure-production"); + public Task ProtectLegalHoldAsync(int departmentId, RmsRecordLegalHold row, RmsRecordLegalHold existing, string userId = null, CancellationToken cancellationToken = default) => Write("legal-hold"); + public Task ProtectExportRunAsync(int departmentId, RmsExportRun row, string userId = null, CancellationToken cancellationToken = default) => Write("export-run"); + + public Task RevealAsync(int departmentId, RecordAggregate aggregate, CancellationToken cancellationToken = default) { var r = new ProtectedReadResult(); if (aggregate != null) aggregate.Protection = r; return Task.FromResult(r); } + public Task RevealAsync(int departmentId, IncidentReportAggregate aggregate, CancellationToken cancellationToken = default) { var r = new ProtectedReadResult(); if (aggregate != null) aggregate.Protection = r; return Task.FromResult(r); } + public Task RevealAsync(int departmentId, IncidentAnalysisAggregate aggregate, CancellationToken cancellationToken = default) { var r = new ProtectedReadResult(); if (aggregate != null) aggregate.Protection = r; return Task.FromResult(r); } + public Task RevealDetailsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task RevealRevisionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task RevealAttachmentsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default) => Empty(); + public Task RevealEvidenceAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task RevealDisclosureRequestsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task RevealDisclosureProductionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task RevealLegalHoldsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task RevealSubmissionsAsync(int departmentId, IReadOnlyList rows, CancellationToken cancellationToken = default) => Empty(); + public Task RevealExportRunsAsync(int departmentId, IReadOnlyList rows, bool includeData, CancellationToken cancellationToken = default) => Empty(); + public Task ResolveSubmissionForWorkloadAsync(int departmentId, RmsSubmission submission, string purpose, CancellationToken cancellationToken = default) => Task.FromResult(AllowWorkload); + public Task RevealForWorkloadAsync(int departmentId, RecordAggregate aggregate, string purpose, CancellationToken cancellationToken = default) => Empty(); + public Task RevealForWorkloadAsync(int departmentId, IncidentReportAggregate aggregate, string purpose, CancellationToken cancellationToken = default) => Empty(); + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs new file mode 100644 index 00000000..baa78ad6 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// RecordOperationalSummaryV1 (RMS plan sections 5.1 and 4.7): built only from official revisions, carries + /// identity/correlation/dates/participation and nothing narrative or restricted, pins the revision checksum, + /// reports correction status across an amendment and a void, pages the change feed with a stable cursor, + /// and refuses a revision whose checksum no longer matches its content. + /// + [TestFixture] + public class RecordOperationalSummaryServiceTests + { + private const int Dept = 9; + private FakeRmsStore _store; + private FakeIncidentStore _incidentStore; + private Mock _authorization; + private RecordsService _records; + private RecordOperationalSummaryService _service; + + [SetUp] + public void SetUp() + { + Resgrid.Config.SystemBehaviorConfig.CacheEnabled = false; + _incidentStore = new FakeIncidentStore(); + _store = _incidentStore.Shared; + _authorization = new Mock(); + _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync(true); + _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); + + var cutover = new Mock(); + cutover.Setup(c => c.GetModuleStateAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsModuleState { DepartmentId = Dept, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active, LegacyWritesBlocked = true }); + var settings = new Mock(); + settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsNumberingConfig()); + settings.Setup(s => s.GetRecordsReviewDueHoursAsync(Dept, It.IsAny())).ReturnsAsync(72); + var groups = new Mock(); + groups.Setup(g => g.GetGroupForUserAsync(It.IsAny(), Dept)).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 11, Name = "Station 1" }); + var profiles = new Mock(); + profiles.Setup(p => p.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool b) => new UserProfile { UserId = id, FirstName = "First", LastName = id }); + var units = new Mock(); + units.Setup(u => u.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 5", Type = "Engine", StationGroupId = 13 }); + var calls = new Mock(); + calls.Setup(c => c.GetCallByIdAsync(77, It.IsAny())).ReturnsAsync(new Call { CallId = 77, DepartmentId = Dept, Number = "C2026-0009", Name = "Structure fire", Type = "Fire", LoggedOn = new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc), Address = "1 Main St" }); + var adp = new Mock(); + adp.Setup(a => a.GetPinnedCatalogVersionAsync(Dept)).ReturnsAsync(0); + var outbox = new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of()); + var queue = new Mock(); + queue.Setup(q => q.EnqueueNotification(It.IsAny())).ReturnsAsync(true); + var evidence = new Mock(); + evidence.Setup(e => e.BindToRevisionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(0); + + _records = new RecordsService(_store.RecordsRepo.Object, new RmsRecordValueService(_store.DetailsRepo.Object), _store.ParticipantsRepo.Object, _store.UnitsRepo.Object, + _store.AttachmentsRepo.Object, _store.RevisionsRepo.Object, evidence.Object, _store.ScopesRepo.Object, _store.SharesRepo.Object, _store.ProjectionsRepo.Object, + _store.AuditsRepo.Object, outbox, cutover.Object, settings.Object, groups.Object, profiles.Object, units.Object, calls.Object, adp.Object, + _store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), new PassthroughRecordsProtection()); + + _store.ProjectionsRepo.Setup(r => r.GetModifiedSinceAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, DateTime? since, int take, string sinceId) => _store.Projections + .Where(p => p.DepartmentId == d && (!since.HasValue || p.ModifiedOn > since.Value || p.ModifiedOn == since.Value && string.CompareOrdinal(p.RmsRecordSearchProjectionId, sinceId ?? string.Empty) > 0)) + .OrderBy(p => p.ModifiedOn).ThenBy(p => p.RmsRecordSearchProjectionId, StringComparer.Ordinal).Take(take).ToList()); + + _service = new RecordOperationalSummaryService(_store.RecordsRepo.Object, _incidentStore.ReportsRepo.Object, _store.RevisionsRepo.Object, _store.ProjectionsRepo.Object, _authorization.Object, new PassthroughRecordsProtection()); + } + + private static RecordDraftInput RunInput(string narrative = "Engine 5 responded to a reported structure fire.") + { + return new RecordDraftInput + { + DefinitionKey = RmsDefinitionKeys.Run, + CallId = 77, + StartedOn = new DateTime(2026, 9, 1, 8, 5, 0, DateTimeKind.Utc), + EndedOn = new DateTime(2026, 9, 1, 9, 0, 0, DateTimeKind.Utc), + Details = new RmsOperationalRecordDetail { Narrative = narrative, Cause = "Cooking", ContactName = "Private Person", ContactNumber = "555-0100" }, + Participants = new List { new RecordParticipantInput { UserId = "p2", UnitId = 5, Role = "Firefighter" } }, + Units = new List { new RecordUnitResponseInput { UnitId = 5, Dispatched = new DateTime(2026, 9, 1, 8, 1, 0, DateTimeKind.Utc), OnScene = new DateTime(2026, 9, 1, 8, 9, 0, DateTimeKind.Utc) } } + }; + } + + private async Task FinalizedRunAsync() + { + var draft = await _records.CreateDraftAsync(Dept, "author", RunInput()); + return await _records.FinalizeAsync(Dept, "author", draft.Record.RmsOperationalRecordId, draft.Record.RowVersion, "1", null, null); + } + + [Test] + public async Task A_draft_has_no_summary_and_a_finalized_record_pins_its_revision() + { + var draft = await _records.CreateDraftAsync(Dept, "author", RunInput()); + (await _service.GetAsync(Dept, "viewer", draft.Record.RmsOperationalRecordId, RmsRecordKind.Operational)).Should().BeNull("a working draft is never a downstream fact"); + + var finalized = await _records.FinalizeAsync(Dept, "author", draft.Record.RmsOperationalRecordId, draft.Record.RowVersion, "1", null, null); + var summary = await _service.GetAsync(Dept, "viewer", finalized.Record.RmsOperationalRecordId, RmsRecordKind.Operational); + + summary.ContractVersion.Should().Be(1); + summary.RecordKind.Should().Be(RmsRecordKind.Operational); + summary.RecordNumber.Should().StartWith("RUN"); + summary.RevisionId.Should().Be(finalized.Record.CurrentRevisionId); + summary.RevisionNumber.Should().Be(1); + summary.RevisionChecksum.Should().Be(_store.Revisions.Single().Checksum); + summary.CorrectionStatus.Should().Be(RecordOperationalSummaryCorrectionStatus.Current); + summary.AmendmentOpen.Should().BeFalse(); + summary.State.Should().Be("Finalized"); + summary.CallId.Should().Be(77); + summary.CallNumber.Should().Be("C2026-0009"); + summary.StartedOn.Should().Be(new DateTime(2026, 9, 1, 8, 5, 0, DateTimeKind.Utc)); + summary.FinalizedOn.Should().Be(finalized.Record.FinalizedOn); + summary.Units.Should().ContainSingle(u => u.UnitId == 5 && u.UnitName == "Engine 5" && u.OnScene == new DateTime(2026, 9, 1, 8, 9, 0, DateTimeKind.Utc)); + summary.Participants.Should().ContainSingle(p => p.UserId == "p2" && p.UnitId == 5 && p.Role == "Firefighter" && p.DisplayName == "First p2"); + } + + [Test] + public async Task The_contract_carries_no_narrative_restricted_or_contact_detail() + { + var finalized = await FinalizedRunAsync(); + var summary = await _service.BuildAsync(Dept, finalized.Record.RmsOperationalRecordId, RmsRecordKind.Operational); + + var json = JsonConvert.SerializeObject(summary); + json.Should().NotContain("reported structure fire").And.NotContain("Cooking").And.NotContain("Private Person").And.NotContain("555-0100"); + typeof(RecordOperationalSummaryV1).GetProperties().Select(p => p.Name).Should().NotContain(new[] { "Narrative", "Details", "Cause", "ContactName", "ContactNumber", "CaseNumber", "BodyLocation" }); + } + + [Test] + public async Task An_amendment_supersedes_the_pinned_revision_and_a_void_withdraws_it() + { + var finalized = await FinalizedRunAsync(); + var id = finalized.Record.RmsOperationalRecordId; + var first = finalized.Record.CurrentRevisionId; + + var amendment = await _records.OpenAmendmentAsync(Dept, "author", id); + (await _service.BuildAsync(Dept, id, RmsRecordKind.Operational)).AmendmentOpen.Should().BeTrue(); + (await _service.BuildAsync(Dept, id, RmsRecordKind.Operational)).RevisionId.Should().Be(first, "an open amendment draft is not yet a fact"); + + var input = RunInput("Corrected narrative"); + var saved = await _records.SaveDraftAsync(Dept, "author", id, amendment.Record.RowVersion, input); + var amended = await _records.FinalizeAsync(Dept, "author", id, saved.Record.RowVersion, "1", "correction", "Wrong unit time"); + + var pinned = await _service.BuildAsync(Dept, id, RmsRecordKind.Operational, first); + pinned.CorrectionStatus.Should().Be(RecordOperationalSummaryCorrectionStatus.Superseded); + pinned.SupersededByRevisionId.Should().Be(amended.Record.CurrentRevisionId); + pinned.RevisionNumber.Should().Be(1); + var current = await _service.BuildAsync(Dept, id, RmsRecordKind.Operational); + current.CorrectionStatus.Should().Be(RecordOperationalSummaryCorrectionStatus.Current); + current.RevisionNumber.Should().Be(2); + current.State.Should().Be("Amended"); + + await _records.VoidAsync(Dept, "author", id, "duplicate", "Entered twice"); + var voided = await _service.BuildAsync(Dept, id, RmsRecordKind.Operational, first); + voided.CorrectionStatus.Should().Be(RecordOperationalSummaryCorrectionStatus.Voided); + voided.VoidedOn.Should().NotBeNull(); + } + + [Test] + public async Task A_tampered_revision_is_refused_rather_than_summarized() + { + var finalized = await FinalizedRunAsync(); + var revision = _store.Revisions.Single(); + revision.SnapshotJson = revision.SnapshotJson.Replace("Engine 5", "Engine 9"); + + Func build = () => _service.BuildAsync(Dept, finalized.Record.RmsOperationalRecordId, RmsRecordKind.Operational); + await build.Should().ThrowAsync().WithMessage("*checksum*"); + } + + [Test] + public async Task Member_path_requires_membership_and_visibility() + { + var finalized = await FinalizedRunAsync(); + var id = finalized.Record.RmsOperationalRecordId; + _authorization.Setup(a => a.CanUserViewRecordAsync("outsider", id, Dept)).ReturnsAsync(false); + (await _service.GetAsync(Dept, "outsider", id, RmsRecordKind.Operational)).Should().BeNull(); + _authorization.Setup(a => a.IsActiveMemberAsync("former", Dept)).ReturnsAsync(false); + (await _service.GetAsync(Dept, "former", id, RmsRecordKind.Operational)).Should().BeNull(); + (await _service.GetAsync(Dept, "viewer", id, RmsRecordKind.Operational)).Should().NotBeNull(); + } + + [Test] + public async Task Foreign_revisions_and_the_wrong_kind_never_resolve() + { + var finalized = await FinalizedRunAsync(); + var id = finalized.Record.RmsOperationalRecordId; + (await _service.BuildAsync(Dept, id, RmsRecordKind.IncidentReport)).Should().BeNull("the id is an operational record, not an incident report"); + (await _service.BuildAsync(Dept, id, RmsRecordKind.Operational, "not-a-revision")).Should().BeNull(); + (await _service.BuildAsync(Dept + 1, id, RmsRecordKind.Operational)).Should().BeNull(); + } + + [Test] + public async Task The_change_feed_pages_with_a_stable_cursor_and_skips_drafts() + { + var first = await FinalizedRunAsync(); + var second = await FinalizedRunAsync(); + await _records.CreateDraftAsync(Dept, "author", RunInput("Still a draft")); + + var page1 = await _service.QueryAsync(Dept, new RecordOperationalSummaryQuery { Take = 1 }); + page1.Items.Should().ContainSingle().Which.RecordId.Should().Be(first.Record.RmsOperationalRecordId); + page1.HasMore.Should().BeTrue(); + page1.NextCursor.Should().StartWith("ros1:"); + + var page2 = await _service.QueryAsync(Dept, new RecordOperationalSummaryQuery { Take = 1, Cursor = page1.NextCursor }); + page2.Items.Should().ContainSingle().Which.RecordId.Should().Be(second.Record.RmsOperationalRecordId); + + var page3 = await _service.QueryAsync(Dept, new RecordOperationalSummaryQuery { Take = 1, Cursor = page2.NextCursor }); + page3.Items.Should().BeEmpty("the third projection row is a draft with no official revision"); + page3.HasMore.Should().BeFalse(); + page3.NextCursor.Should().BeNull(); + + var all = await _service.QueryAsync(Dept, new RecordOperationalSummaryQuery { Take = 50 }); + all.Items.Select(i => i.RecordId).Should().Equal(first.Record.RmsOperationalRecordId, second.Record.RmsOperationalRecordId); + + Func bad = () => _service.QueryAsync(Dept, new RecordOperationalSummaryQuery { Cursor = "garbage" }); + await bad.Should().ThrowAsync(); + } + + [Test] + public void Cursors_round_trip_and_reject_foreign_shapes() + { + var when = new DateTime(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc); + var cursor = RecordOperationalSummaryService.Cursor(when, "rec-1"); + RecordOperationalSummaryService.TryReadCursor(cursor, out var back, out var id).Should().BeTrue(); + back.Should().Be(when); + id.Should().Be("rec-1"); + RecordOperationalSummaryService.TryReadCursor("rms1:1:AA==", out _, out _).Should().BeFalse("the Records changes cursor is a different feed"); + RecordOperationalSummaryService.TryReadCursor("ros1:notanumber:AA==", out _, out _).Should().BeFalse(); + } + + [Test] + public async Task An_incident_report_revision_summarizes_from_its_frozen_aggregate() + { + var report = new RmsIncidentReport + { + RmsIncidentReportId = "rep-1", DepartmentId = Dept, CallId = 77, IncidentNumber = "2026-000123", ReportingEntityId = "FD24027000", RecordNumber = "INC-2026-0001", + DefinitionKey = RmsDefinitionKeys.NerisIncidentReport, DefinitionVersion = 1, State = (int)RmsRecordState.Finalized, CurrentRevisionId = "rev-1", NerisIncidentId = "neris-123", + StationGroupId = 11, CallCreatedOn = new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc), IncidentClearedOn = new DateTime(2026, 9, 1, 9, 0, 0, DateTimeKind.Utc), FinalizedOn = DateTime.UtcNow + }; + _incidentStore.Reports.Add(report); + var frozen = new IncidentReportAggregate + { + Report = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(report)), + Units = new List { new RmsUnitResponse { UnitId = 5, UnitNameSnapshot = "Engine 5", DispatchedOn = new DateTime(2026, 9, 1, 8, 1, 0, DateTimeKind.Utc), ClearedOn = new DateTime(2026, 9, 1, 9, 0, 0, DateTimeKind.Utc) } }, + Narrative = new RmsNarrative { Narrative = "Officer narrative that must not leave" } + }; + var json = JsonConvert.SerializeObject(frozen); + _store.Revisions.Add(new RmsRevision { RmsRevisionId = "rev-1", DepartmentId = Dept, RecordId = "rep-1", RecordKind = (int)RmsRecordKind.IncidentReport, RevisionNumber = 1, DefinitionKey = RmsDefinitionKeys.NerisIncidentReport, DefinitionVersion = 1, SnapshotJson = json, Checksum = RecordSnapshotSerializer.Checksum(json), CreatedOn = DateTime.UtcNow }); + + var summary = await _service.BuildAsync(Dept, "rep-1", RmsRecordKind.IncidentReport); + + summary.RecordKind.Should().Be(RmsRecordKind.IncidentReport); + summary.IncidentNumber.Should().Be("2026-000123"); + summary.ReportingEntityId.Should().Be("FD24027000"); + summary.ExternalIncidentId.Should().Be("neris-123"); + summary.StartedOn.Should().Be(report.CallCreatedOn); + summary.EndedOn.Should().Be(report.IncidentClearedOn); + summary.Units.Should().ContainSingle(u => u.UnitName == "Engine 5" && u.Released == report.IncidentClearedOn); + JsonConvert.SerializeObject(summary).Should().NotContain("must not leave"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs index 959bc8df..0f6cea1a 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs @@ -65,10 +65,11 @@ public void SetUp() incidents.Setup(s => s.BuildSnapshotAsync(Dept, It.IsAny(), It.IsAny())).ReturnsAsync((int d, string id, string revision) => JsonConvert.DeserializeObject(_store.Revisions.Single(r => r.RmsRevisionId == revision).SnapshotJson)); var udf = new RecordsUdfService(Mock.Of(), Mock.Of(), Mock.Of(), _authorization.Object, Mock.Of(), _store.UnitOfWork.Object, Mock.Of()); var documents = new RecordsDocumentService(_authorization.Object, _store.RecordsRepo.Object, _incidentStore.ReportsRepo.Object, _incidentStore.AnalysesRepo.Object, _store.RevisionsRepo.Object, - incidents.Object, Mock.Of(), Mock.Of(), _pdf.Object, Mock.Of(), udf); + incidents.Object, Mock.Of(), Mock.Of(), _pdf.Object, Mock.Of(), udf, new PassthroughRecordsProtection()); _service = new RecordsDisclosureService(_store.DisclosureRequestsRepo.Object, _store.DisclosureProductionsRepo.Object, _store.RecordsRepo.Object, _store.RevisionsRepo.Object, _store.AuditsRepo.Object, - _authorization.Object, _settings.Object, _store.UnitOfWork.Object, _incidentStore.ReportsRepo.Object, documents, _store.AttachmentsRepo.Object, _pdf.Object, _incidentStore.AnalysesRepo.Object, _scanner.Object, udf); + _authorization.Object, _settings.Object, _store.UnitOfWork.Object, _incidentStore.ReportsRepo.Object, documents, _store.AttachmentsRepo.Object, _pdf.Object, _incidentStore.AnalysesRepo.Object, _scanner.Object, udf, + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of())); } private async Task ReviewedProduceAsync(int departmentId, string userId, string requestId) @@ -436,6 +437,22 @@ private async Task OpenRequestAsync(string profile = RmsRe new RmsRecordQuery { States = new List { (int)RmsRecordState.Finalized }, DefinitionKey = RmsDefinitionKeys.Run }, profile); } + [Test] + public async Task Logging_a_request_emits_record_disclosure_requested_without_the_requester() + { + var request = await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "A. Reporter", RequesterOrganization = "Local Paper", JurisdictionProfile = "US-IL", ReceivedOn = DateTime.UtcNow }); + + var entry = _store.Outbox.Single(o => o.EventName == "RecordDisclosureRequested"); + entry.TriggerEventType.Should().Be((int)WorkflowTriggerEventType.RecordDisclosureRequested); + var payload = JObject.Parse(entry.PayloadJson); + ((string)payload["disclosure"]["request_number"]).Should().Be(request.RequestNumber); + ((string)payload["disclosure"]["state"]).Should().Be("Received"); + ((string)payload["disclosure"]["jurisdiction_profile"]).Should().Be("US-IL"); + ((string)payload["record"]["kind"]).Should().Be("Disclosure"); + entry.PayloadJson.Should().NotContain("A. Reporter").And.NotContain("Local Paper"); + request.RequesterName.Should().Be("A. Reporter", "the caller keeps the plaintext request"); + } + [Test] public async Task A_new_request_gets_a_number_and_a_statutory_clock() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs index c3b64e36..fef7c29a 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs @@ -52,7 +52,7 @@ public void Setup() var brand = new Mock(); brand.Setup(b => b.GetBrandingAsync(1)).ReturnsAsync(new DepartmentBranding { DisplayName = "Example Fire Department", AddressText = "100 Example Street", PhoneNumber = "555-0100", Website = "example.invalid" }); var layouts = new Mock(); layouts.Setup(l => l.GetDepartmentDefaultAsync(1)).ReturnsAsync(new RmsRecordPrintLayout { Version = 3, Scope = 1, Config = new RecordsPrintLayoutConfig { LetterheadLine1 = "Fire Prevention and Emergency Response", FooterText = "Departmental record copy", WatermarkLabel = "TRAINING FIXTURE" } }); var udf = new RecordsUdfService(Mock.Of(), Mock.Of(), Mock.Of(), _auth.Object, _groups.Object, Mock.Of(), Mock.Of()); - _service = new RecordsDocumentService(_auth.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, _store.Shared.RevisionsRepo.Object, _incidents.Object, brand.Object, layouts.Object, _pdf.Object, Mock.Of(), udf); + _service = new RecordsDocumentService(_auth.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, _store.Shared.RevisionsRepo.Object, _incidents.Object, brand.Object, layouts.Object, _pdf.Object, Mock.Of(), udf, new PassthroughRecordsProtection()); } private void CaptureCustomFields() diff --git a/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs index 55b7cc63..2ab2409d 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsEvidenceServiceTests.cs @@ -95,7 +95,8 @@ public void SetUp() _authorization.Setup(a => a.CanUserViewRecordAsync("author", It.IsAny(), Dept)).ReturnsAsync(true); _authorization.Setup(a => a.CanReadSourceCallAsync("author", Dept, It.IsAny())).ReturnsAsync(true); _service = new RecordsEvidenceService(_store.EvidenceRepo.Object, _store.RecordsRepo.Object, - _incidents.ReportsRepo.Object, _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { (IRecordEvidenceAdapter)_adapter }, _authorization.Object, Mock.Of(), _references.Object); + _incidents.ReportsRepo.Object, _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { (IRecordEvidenceAdapter)_adapter }, _authorization.Object, Mock.Of(), _references.Object, + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of())); } private RecordEvidenceCaptureRequest Request(RmsEvidenceKind kind = RmsEvidenceKind.RunCardActivation) @@ -111,6 +112,22 @@ private RecordEvidenceCaptureRequest Request(RmsEvidenceKind kind = RmsEvidenceK }; } + [Test] + public async Task Capturing_evidence_emits_record_evidence_captured_with_identity_and_checksum_only() + { + var artifact = await _service.CaptureAsync(Request()); + + var entry = _store.Outbox.Single(o => o.EventName == "RecordEvidenceCaptured"); + entry.TriggerEventType.Should().Be((int)WorkflowTriggerEventType.RecordEvidenceCaptured); + var payload = Newtonsoft.Json.Linq.JObject.Parse(entry.PayloadJson); + ((string)payload["evidence"]["id"]).Should().Be(artifact.RmsEvidenceArtifactId); + ((string)payload["evidence"]["kind"]).Should().Be("RunCardActivation"); + ((string)payload["evidence"]["checksum"]).Should().Be(artifact.Checksum); + ((string)payload["record"]["id"]).Should().Be(_record.RmsOperationalRecordId); + entry.PayloadJson.Should().NotContain("activations", "the manifest is record content").And.NotContain("Run card activation for call", "so is the title"); + artifact.ManifestJson.Should().Contain("activation_id", "the caller keeps the plaintext artifact"); + } + [Test] public async Task Separate_chat_selections_survive_signing_and_only_an_exact_selection_supersedes_its_draft_predecessor() { @@ -124,7 +141,8 @@ public async Task Separate_chat_selections_survive_signing_and_only_an_exact_sel DepartmentId = Dept, ChatChannelId = channel.ChatChannelId, ChatMessageId = id, Body = "Message " + id, SentOn = DateTime.UtcNow }); var adapter = new ChatPromotionEvidenceAdapter(messages.Object, channels.Object, new Lazy(() => permission.Object)); var service = new RecordsEvidenceService(_store.EvidenceRepo.Object, _store.RecordsRepo.Object, _incidents.ReportsRepo.Object, - _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { adapter }, _authorization.Object, Mock.Of(), _references.Object); + _store.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { adapter }, _authorization.Object, Mock.Of(), _references.Object, + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of())); var request = Request(RmsEvidenceKind.ChatPromotion); request.SourceIds = new() { "one", "two" }; var first = await service.CaptureAsync(request); var original = first.ManifestJson; request.SourceIds = new() { "three" }; var second = await service.CaptureAsync(request); diff --git a/Tests/Resgrid.Tests/Rms/RecordsExportServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsExportServiceTests.cs new file mode 100644 index 00000000..99c3b5e9 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsExportServiceTests.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// Department report exports (RMS plan section 5.6): the template contract a department authors, the file + /// an agency receives, and the way a Workflow step and the schedule sweep (worker 45) get at it. + /// + [TestFixture] + public class RecordsExportServiceTests + { + private const int Dept = 41; + private FakeRmsStore _store; + private List _templates; + private List _runs; + private Mock _templatesRepo; + private Mock _runsRepo; + private Mock _records; + private Mock _incidents; + private Mock _authorization; + private PassthroughRecordsProtection _protection; + private Mock _pdf; + private List _finalized; + private RecordsExportService _service; + private bool _restricted; + + [SetUp] + public void SetUp() + { + _store = new FakeRmsStore(); + _templates = new List(); + _runs = new List(); + _finalized = new List(); + _restricted = true; + + _templatesRepo = new Mock(); + _templatesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((RmsExportTemplate t, CancellationToken c, bool f) => { _templates.Add(t); return t; }); + _templatesRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((RmsExportTemplate t, CancellationToken c, bool f) => { _templates.RemoveAll(x => x.RmsExportTemplateId == t.RmsExportTemplateId); _templates.Add(t); return t; }); + _templatesRepo.Setup(r => r.GetByIdForDepartmentAsync(Dept, It.IsAny())).ReturnsAsync((int d, string id) => _templates.FirstOrDefault(t => t.RmsExportTemplateId == id && t.DeletedOn == null)); + _templatesRepo.Setup(r => r.GetByKeyAsync(Dept, It.IsAny())).ReturnsAsync((int d, string key) => _templates.FirstOrDefault(t => t.TemplateKey == key && t.DeletedOn == null)); + _templatesRepo.Setup(r => r.GetForDepartmentAsync(Dept)).ReturnsAsync(() => _templates.Where(t => t.DeletedOn == null).ToList()); + _templatesRepo.Setup(r => r.GetDueAsync(It.IsAny(), It.IsAny())).ReturnsAsync((DateTime now, int take) => _templates.Where(t => t.IsEnabled && t.ScheduleKind != 0 && t.NextRunOn <= now && t.DeletedOn == null).ToList()); + + _runsRepo = new Mock(); + _runsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((RmsExportRun run, CancellationToken c, bool f) => { _runs.Add(run); return run; }); + _runsRepo.Setup(r => r.GetByIdForDepartmentAsync(Dept, It.IsAny())).ReturnsAsync((int d, string id) => _runs.FirstOrDefault(x => x.RmsExportRunId == id)); + _runsRepo.Setup(r => r.GetWithDataAsync(Dept, It.IsAny())).ReturnsAsync((int d, string id) => _runs.FirstOrDefault(x => x.RmsExportRunId == id)); + _runsRepo.Setup(r => r.GetForTemplateAsync(Dept, It.IsAny(), It.IsAny())).ReturnsAsync((int d, string id, int take) => _runs.Where(x => x.TemplateId == id).ToList()); + _runsRepo.Setup(r => r.DeleteExpiredAsync(Dept, It.IsAny(), It.IsAny())).ReturnsAsync(0); + + _records = new Mock(); + _records.Setup(r => r.GetAsync(Dept, It.IsAny(), It.IsAny())).ReturnsAsync((int d, string id, bool rev) => Aggregate(id)); + _incidents = new Mock(); + _store.RecordsRepo.Setup(r => r.GetFinalizedSinceAsync(Dept, It.IsAny())).ReturnsAsync((int d, DateTime since) => _finalized.Where(r => r.FinalizedOn >= since).ToList()); + _store.RecordsRepo.Setup(r => r.GetByIdForDepartmentAsync(Dept, It.IsAny())).ReturnsAsync((int d, string id) => _finalized.FirstOrDefault(r => r.RmsOperationalRecordId == id)); + var incidentsRepo = new Mock(); + incidentsRepo.Setup(r => r.QueryAsync(Dept, It.IsAny())).ReturnsAsync(new List()); + + _authorization = new Mock(); + _authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, PermissionTypes.ManageRecordReports)).ReturnsAsync(true); + _authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, PermissionTypes.ExportRecords)).ReturnsAsync(true); + _authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(() => _restricted); + _authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + + _protection = new PassthroughRecordsProtection(); + _pdf = new Mock(); + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter")).Returns(Encoding.ASCII.GetBytes("%PDF-export")); + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, Name = "Test FD", TimeZone = "Eastern Standard Time" }); + var groups = new Mock(); + groups.Setup(g => g.GetGroupByIdAsync(12, It.IsAny())).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 12, Name = "Station 1" }); + var profiles = new Mock(); + profiles.Setup(p => p.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool b) => new UserProfile { UserId = id, FirstName = "Pat", LastName = id }); + + _service = new RecordsExportService(_templatesRepo.Object, _runsRepo.Object, _records.Object, _incidents.Object, _store.RecordsRepo.Object, incidentsRepo.Object, + _authorization.Object, _protection, new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of()), _store.AuditsRepo.Object, + departments.Object, groups.Object, profiles.Object, _pdf.Object, _store.UnitOfWork.Object); + } + + private RmsOperationalRecord Seed(string id, DateTime finalizedOn, string narrative = "=Engine 5 responded, \"quoted\"") + { + var record = new RmsOperationalRecord + { + RmsOperationalRecordId = id, DepartmentId = Dept, DefinitionKey = RmsDefinitionKeys.Training, RecordType = (int)RmsOperationalRecordType.Training, RecordNumber = "TRN-" + id, + State = (int)RmsRecordState.Finalized, FinalizedOn = finalizedOn, StartedOn = finalizedOn.AddHours(-3), EndedOn = finalizedOn.AddHours(-1), StationGroupId = 12, AuthorUserId = "author", CreatedOn = finalizedOn.AddHours(-4), CurrentRevisionId = "rev-" + id + }; + _finalized.Add(record); + _store.Details.Add(new RmsOperationalRecordDetail { RmsOperationalRecordDetailId = "det-" + id, DepartmentId = Dept, RecordId = id, Narrative = narrative, Course = "Ropes", CaseNumber = "case-" + id, CallNumber = "C-1" }); + return record; + } + + private RecordAggregate Aggregate(string id) + { + var record = _finalized.FirstOrDefault(r => r.RmsOperationalRecordId == id); + if (record == null) return null; + return new RecordAggregate + { + Record = record, + Details = _store.Details.FirstOrDefault(d => d.RecordId == id), + Participants = new List { new RmsRecordParticipant { UserId = "p1", DisplayNameSnapshot = "Pat One" } }, + Units = new List { new RmsRecordUnitResponse { UnitId = 5, UnitNameSnapshot = "Engine 5", Dispatched = record.StartedOn } }, + Protection = new ProtectedReadResult() + }; + } + + private static RmsExportTemplate Template(params string[] columns) + { + return new RmsExportTemplate + { + TemplateKey = "state-runs", Name = "State runs", Format = (int)RmsExportFormat.Csv, Scope = (int)RmsExportScope.Window, + ColumnsJson = JsonConvert.SerializeObject(columns.Length == 0 ? RecordsExportFieldCatalog.DefaultColumns : columns), IncludeHeader = true, Delimiter = ",", + ScheduleKind = (int)RmsExportScheduleKind.Daily, ScheduleHourLocal = 6, IsEnabled = true + }; + } + + [Test] + public async Task Validation_rejects_unknown_columns_bad_keys_and_unacknowledged_sensitive_columns() + { + var bad = Template("record.number", "details.narrative", "nope.column"); + bad.TemplateKey = "Not A Key"; + var result = await _service.ValidateAsync(Dept, "admin", bad); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.Contains("nope.column")); + result.Errors.Should().Contain(e => e.Contains("key must be")); + result.Errors.Should().Contain(e => e.Contains("Include narrative")); + result.Errors.Should().Contain(e => e.Contains("Acknowledge")); + + var restrictedOnly = Template("record.number", "details.case_number"); + restrictedOnly.IncludeRestricted = true; + restrictedOnly.EgressAcknowledgedOn = DateTime.UtcNow; + _restricted = false; + (await _service.ValidateAsync(Dept, "clerk", restrictedOnly)).Errors.Should().Contain(e => e.Contains("restricted-records grant")); + } + + [Test] + public async Task Save_normalizes_the_template_records_the_acknowledgement_and_schedules_the_next_run() + { + var template = Template("record.number", "details.narrative", "record.finalized_on"); + template.TemplateKey = " State-Runs "; + template.IncludeNarrative = true; + + var saved = await _service.SaveAsync(Dept, "admin", template, acknowledgeEgress: true); + + saved.TemplateKey.Should().Be("state-runs"); + saved.EgressAcknowledgedOn.Should().NotBeNull(); + saved.EgressAcknowledgedByUserId.Should().Be("admin"); + saved.NextRunOn.Should().NotBeNull().And.BeAfter(DateTime.UtcNow); + saved.RowVersion.Should().Be(1); + _templates.Should().ContainSingle(); + _store.Audits.Should().ContainSingle(a => a.Purpose == "Export template created"); + + // Widening a template past what was acknowledged drops the acknowledgement unless it is given again. + saved.IncludeNarrative = false; + saved.ColumnsJson = JsonConvert.SerializeObject(new[] { "record.number" }); + var narrowed = await _service.SaveAsync(Dept, "admin", saved, acknowledgeEgress: false); + narrowed.EgressAcknowledgedOn.Should().BeNull(); + } + + [Test] + public void Next_run_lands_on_the_department_local_hour_strictly_after_now() + { + var weekly = new RmsExportTemplate { ScheduleKind = (int)RmsExportScheduleKind.Weekly, ScheduleHourLocal = 6, ScheduleDayOfWeek = 1 }; + var now = new DateTime(2026, 9, 7, 12, 0, 0, DateTimeKind.Utc); // a Monday, after 06:00 UTC + var next = RecordsExportService.ComputeNextRun(weekly, now, null); + next.Should().Be(new DateTime(2026, 9, 14, 6, 0, 0, DateTimeKind.Utc), "the same weekday later today has passed, so it is next week"); + + var monthly = new RmsExportTemplate { ScheduleKind = (int)RmsExportScheduleKind.Monthly, ScheduleHourLocal = 2, ScheduleDayOfMonth = 1 }; + RecordsExportService.ComputeNextRun(monthly, now, null).Should().Be(new DateTime(2026, 10, 1, 2, 0, 0, DateTimeKind.Utc)); + + RecordsExportService.ComputeNextRun(new RmsExportTemplate { ScheduleKind = (int)RmsExportScheduleKind.None }, now, null).Should().BeNull(); + RecordsExportService.ScheduleWindow(weekly, now).Should().Be((now.AddDays(-7), now)); + } + + [Test] + public async Task Window_render_produces_a_guarded_csv_and_audits_every_record() + { + var now = DateTime.UtcNow; + Seed("a", now.AddHours(-2)); + Seed("b", now.AddHours(-30), "Nothing to report"); + Seed("old", now.AddDays(-9)); + var template = Template("record.number", "record.type", "record.station_group_name", "record.author_name", "participants.names", "units.names", "details.narrative", "details.course"); + template.IncludeNarrative = true; template.EgressAcknowledgedOn = now; template.RmsExportTemplateId = "t1"; template.DepartmentId = Dept; template.WindowDays = 2; + _templates.Add(template); + + var run = await _service.RenderAsync(Dept, template, new RecordsExportRequest { Trigger = RmsExportTrigger.Manual, ActingUserId = "admin", Purpose = "Manual export" }); + + run.RecordCount.Should().Be(2, "the record finalized nine days ago is outside the two-day window"); + run.ContentType.Should().Be("text/csv"); + run.FileName.Should().StartWith("state-runs-").And.EndWith(".csv"); + run.Checksum.Should().Be(RecordSnapshotSerializer.Checksum(run.Data)); + var csv = Encoding.UTF8.GetString(run.Data).TrimStart(''); + var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToList(); + lines[0].Should().Be("record.number,record.type,record.station_group_name,record.author_name,participants.names,units.names,details.narrative,details.course"); + lines.Should().Contain(l => l.StartsWith("TRN-a,Training,Station 1,Pat author,Pat One,Engine 5,\"'=Engine 5 responded, \"\"quoted\"\"\",Ropes"), "a leading = is neutralized and quotes are doubled"); + _runs.Should().ContainSingle(r => r.RmsExportRunId == run.RmsExportRunId); + _store.Audits.Where(a => a.Action == (int)RmsAccessAuditAction.Export).Select(a => a.RecordId).Should().BeEquivalentTo(new[] { "a", "b" }); + _protection.Writes.Should().Contain("export-run", "the stored bytes pass the ADP seam"); + } + + [Test] + public async Task Restricted_columns_are_withheld_from_a_caller_without_the_grant_and_the_run_says_so() + { + var now = DateTime.UtcNow; + Seed("a", now.AddHours(-2)); + var template = Template("record.number", "details.case_number"); + template.IncludeRestricted = true; template.EgressAcknowledgedOn = now; template.RmsExportTemplateId = "t2"; template.DepartmentId = Dept; template.Format = (int)RmsExportFormat.Json; + _restricted = false; + + var run = await _service.RenderAsync(Dept, template, new RecordsExportRequest { ActingUserId = "clerk" }); + + run.Redacted.Should().BeTrue(); + var json = JObject.Parse(Encoding.UTF8.GetString(run.Data)); + json["rows"][0]["details.case_number"].Value().Should().Be(ProtectedDataEnvelope.RedactionValue); + json["rows"][0]["record.number"].Value().Should().Be("TRN-a"); + JObject.Parse(run.RedactedFieldsJson)["withheld_columns"].Values().Should().Contain("details.case_number"); + } + + [Test] + public async Task Workflow_resolution_renders_the_triggering_record_or_re_renders_a_scheduled_window() + { + var now = DateTime.UtcNow; + Seed("a", now.AddHours(-2)); + var perRecord = Template("record.number", "record.kind"); + perRecord.Scope = (int)RmsExportScope.TriggeringRecord; perRecord.ScheduleKind = 0; perRecord.RmsExportTemplateId = "t3"; perRecord.DepartmentId = Dept; perRecord.Format = (int)RmsExportFormat.Pdf; + _templates.Add(perRecord); + + var single = await _service.ResolveForWorkflowAsync(Dept, "t3", "a", RmsRecordKind.Operational, null, "wf-run-1"); + single.RecordCount.Should().Be(1); + single.ContentType.Should().Be("application/pdf"); + single.WorkflowRunId.Should().Be("wf-run-1"); + _runs.Should().Contain(r => r.RmsExportRunId == single.RmsExportRunId, "a per-record render is stored for the run history"); + + var scheduled = Template("record.number"); scheduled.RmsExportTemplateId = "t4"; scheduled.DepartmentId = Dept; scheduled.NextRunOn = now.AddMinutes(-5); + _templates.Add(scheduled); + var sweep = await _service.RunDueSchedulesAsync(); + sweep.RunsRendered.Should().Be(1); + var stored = _runs.Single(r => r.TemplateId == "t4"); + _store.Outbox.Should().ContainSingle(o => o.EventName == "RecordExportScheduled"); + var payload = JObject.Parse(_store.Outbox.Single(o => o.EventName == "RecordExportScheduled").PayloadJson); + payload["export"]["run_id"].Value().Should().Be(stored.RmsExportRunId); + payload["export"]["record_count"].Value().Should().Be(1); + payload["protection"]["is_redacted"].Value().Should().BeFalse(); + scheduled.NextRunOn.Should().BeAfter(now); + scheduled.LastRunOn.Should().NotBeNull(); + + var carried = await _service.ResolveForWorkflowAsync(Dept, "t4", null, null, stored.RmsExportRunId, "wf-run-2"); + carried.RmsExportRunId.Should().Be(stored.RmsExportRunId, "the step carries the scheduled run it was told about"); + carried.WindowStart.Should().Be(stored.WindowStart); + carried.Data.Should().Equal(stored.Data, "re-rendering the same window gives the same file"); + _runs.Count(r => r.TemplateId == "t4").Should().Be(1, "a workflow re-render is not stored again"); + + Func missing = () => _service.ResolveForWorkflowAsync(Dept, "t3", null, null, null, "wf-run-3"); + await missing.Should().ThrowAsync(); + } + + [Test] + public void Csv_cells_are_rfc4180_quoted_and_formula_guarded() + { + RecordsExportRenderer.Cell("plain", ",").Should().Be("plain"); + RecordsExportRenderer.Cell("a,b", ",").Should().Be("\"a,b\""); + RecordsExportRenderer.Cell("=SUM(A1)", ",").Should().Be("'=SUM(A1)"); + RecordsExportRenderer.Cell("+1", ";").Should().Be("'+1"); + RecordsExportRenderer.Cell("line\nbreak", ",").Should().Be("\"line\nbreak\""); + RecordsExportRenderer.Cell(null, ",").Should().Be(string.Empty); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsLegalHoldServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsLegalHoldServiceTests.cs index cc48cf15..0c48b3ff 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsLegalHoldServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsLegalHoldServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -22,7 +23,8 @@ public void Setup() _auth.Setup(a => a.HasPermissionAsync("officer", 11, PermissionTypes.ManageRecordLegalHold)).ReturnsAsync(true); _auth.Setup(a => a.CanUserViewRecordAsync("officer", "record", 11)).ReturnsAsync(true); _store.Shared.Records.Add(new RmsOperationalRecord { DepartmentId = 11, RmsOperationalRecordId = "record" }); - _service = new RecordsLegalHoldService(_store.Shared.LegalHoldsRepo.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _auth.Object, _store.Shared.AuditsRepo.Object, _store.Shared.UnitOfWork.Object); + _service = new RecordsLegalHoldService(_store.Shared.LegalHoldsRepo.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _auth.Object, _store.Shared.AuditsRepo.Object, _store.Shared.UnitOfWork.Object, + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, Mock.Of())); } private RmsRecordLegalHold Input() => new RmsRecordLegalHold { RecordId = "record", Reason = "Litigation", ReferenceNumber = "Case-7", Notes = "Preserve source record, analyses and all evidence" }; [Test] @@ -35,6 +37,27 @@ public async Task Hold_placement_ignores_forged_actor_state_and_release_is_once_ Func repeated = () => _service.ReleaseAsync(11, "officer", hold.RmsRecordLegalHoldId, 1, "overwrite"); await repeated.Should().ThrowAsync(); hold.ReleaseNotes.Should().Be("Court order dated 2026-09-04"); _store.Shared.Audits.Should().HaveCount(2); } + [Test] + public async Task Placing_and_releasing_a_hold_emit_the_legal_hold_events_without_the_reference_or_notes() + { + var hold = await _service.PlaceAsync(11, "officer", Input()); + await _service.ReleaseAsync(11, "officer", hold.RmsRecordLegalHoldId, 1, "Court order dated 2026-09-04"); + + var outbox = _store.Shared.Outbox; + outbox.Select(o => o.EventName).Should().Equal("RecordLegalHoldPlaced", "RecordLegalHoldReleased"); + outbox[0].TriggerEventType.Should().Be((int)WorkflowTriggerEventType.RecordLegalHoldPlaced); + outbox[1].TriggerEventType.Should().Be((int)WorkflowTriggerEventType.RecordLegalHoldReleased); + var placed = Newtonsoft.Json.Linq.JObject.Parse(outbox[0].PayloadJson); + ((string)placed["legal_hold"]["reason"]).Should().Be("Litigation"); + ((string)placed["legal_hold"]["record_id"]).Should().Be("record"); + ((string)placed["record"]["id"]).Should().Be("record"); + outbox[0].PayloadJson.Should().NotContain("Case-7").And.NotContain("Preserve source record"); + var released = Newtonsoft.Json.Linq.JObject.Parse(outbox[1].PayloadJson); + ((bool)released["legal_hold"]["is_released"]).Should().BeTrue(); + released["legal_hold"]["released_on"].Type.Should().Be(Newtonsoft.Json.Linq.JTokenType.Date); + outbox[1].PayloadJson.Should().NotContain("Court order"); + } + [Test] public async Task Revoked_hold_authority_and_foreign_or_purged_records_cannot_be_held() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsNfirsLegacyServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsNfirsLegacyServiceTests.cs new file mode 100644 index 00000000..afac9a7f --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsNfirsLegacyServiceTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// Read-only NFIRS rendering and crosswalk (RMS-3, plan section 4.3): every value comes from something the + /// department already holds, each field names its NERIS equivalent, a NERIS report's coverage is reported + /// per field, the source-Call rule is enforced, another department's Call is not found, and no source + /// outage turns into an exception. + /// + [TestFixture] + public class RecordsNfirsLegacyServiceTests + { + private const int Dept = 42; + private static readonly DateTime LoggedOn = new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc); + + private Mock _calls; + private Mock _units; + private Mock _reporting; + private Mock _incidents; + private Mock _authorization; + private Mock _neris; + private Call _call; + private RecordsNfirsLegacyService _service; + + [SetUp] + public void SetUp() + { + _call = new Call { CallId = 77, DepartmentId = Dept, Number = "2026-000123", IncidentNumber = "INC-9", Name = "Structure fire", Type = "Fire", Address = "1 Main St", NatureOfCall = "Smoke showing", LoggedOn = LoggedOn }; + _calls = new Mock(); + _calls.Setup(c => c.GetCallByIdAsync(77, It.IsAny())).ReturnsAsync(() => _call); + _calls.Setup(c => c.GetCallByIdAsync(78, It.IsAny())).ReturnsAsync(new Call { CallId = 78, DepartmentId = 99 }); + _units = new Mock(); + _units.Setup(u => u.GetUnitStatesForCallAsync(Dept, 77)).ReturnsAsync(new List + { + new UnitState { UnitId = 5, State = (int)UnitStateTypes.Responding, Timestamp = LoggedOn.AddMinutes(2) }, + new UnitState { UnitId = 6, State = (int)UnitStateTypes.OnScene, Timestamp = LoggedOn.AddMinutes(11) }, + new UnitState { UnitId = 5, State = (int)UnitStateTypes.OnScene, Timestamp = LoggedOn.AddMinutes(9) } + }); + _reporting = new Mock(); + _reporting.Setup(r => r.GetIncidentTimesReportAsync(Dept, 77)).ReturnsAsync(new IncidentTimesReport { CallId = 77, LastBenchmarkCompletedOn = LoggedOn.AddMinutes(40), CommandClosedOn = LoggedOn.AddMinutes(90), MutualAidResourceCount = 2 }); + _incidents = new Mock(); + _authorization = new Mock(); + _authorization.Setup(a => a.CanReadSourceCallAsync("officer", Dept, It.IsAny())).ReturnsAsync(true); + _neris = new Mock(); + _neris.Setup(n => n.GetProfileAsync(Dept)).ReturnsAsync(new RmsNerisProfile { DepartmentId = Dept, NerisEntityId = "FD24027000" }); + _neris.Setup(n => n.ResolveCrosswalkAsync(Dept, "incident_type", NerisCrosswalkSources.CallType, "Fire")).ReturnsAsync("FIRE||STRUCTURE_FIRE||RESIDENTIAL"); + _service = new RecordsNfirsLegacyService(_calls.Object, _units.Object, _reporting.Object, _incidents.Object, _authorization.Object, _neris.Object); + } + + private NfirsLegacyField Field(NfirsLegacyRendering r, string name) => r.Fields.Single(f => f.Name == name); + + [Test] + public async Task Every_value_is_read_from_an_existing_source_and_names_its_neris_equivalent() + { + var r = await _service.RenderAsync(Dept, "officer", 77); + + r.ReadOnly.Should().BeTrue(); + r.CallNumber.Should().Be("2026-000123"); + Field(r, "FDID").Value.Should().Be("FD24027000"); + Field(r, "FDID").SourceSystem.Should().Be("NerisProfile"); + Field(r, "IncidentNumber").Value.Should().Be("INC-9", "the incident number is preferred over the call number, as the CSV export does"); + Field(r, "IncidentNumber").NerisFactKey.Should().Be(NerisFactKeys.IncidentNumber); + Field(r, "AlarmDateTime").Value.Should().Be("2026-09-01T08:00:00Z"); + Field(r, "IncidentTypeCode").Value.Should().Be("Fire → FIRE||STRUCTURE_FIRE||RESIDENTIAL"); + Field(r, "IncidentTypeCode").SourceSystem.Should().Be("Crosswalk"); + Field(r, "ArrivalDateTime").Value.Should().Be("2026-09-01T08:09:00Z", "the earliest on-scene state is the arrival"); + Field(r, "ArrivalDateTime").NerisFactKey.Should().Be(NerisFactKeys.UnitTime(5, "on_scene")); + Field(r, "ControlledDateTime").Value.Should().Be("2026-09-01T08:40:00Z"); + Field(r, "ControlledDateTime").SourceSystem.Should().Be("IncidentCommand"); + Field(r, "LastUnitClearedDateTime").Value.Should().Be("2026-09-01T09:30:00Z", "command close is the proxy when dispatch never closed the call"); + Field(r, "LastUnitClearedDateTime").SourceSystem.Should().Be("IncidentCommand"); + Field(r, "AidGivenOrReceived").Value.Should().Be("Received (2)"); + Field(r, "LocationAddress").NerisFactKey.Should().Be(NerisFactKeys.Location); + Field(r, "IncidentName").NerisFactKey.Should().BeNull("NERIS has no incident name; it is department-only"); + Field(r, "ActionsTaken").Status.Should().Be(NfirsLegacyFieldStatus.NotCaptured); + Field(r, "PropertyUse").Status.Should().Be(NfirsLegacyFieldStatus.NotCaptured); + r.Fields.Should().OnlyContain(f => f.NerisPopulated == null, "no NERIS report exists for the call"); + r.IncidentReportId.Should().BeNull(); + + r.Summary.TotalFields.Should().Be(r.Fields.Count); + r.Summary.NotCaptured.Should().Be(3); + r.Summary.RequiredMissing.Should().Be(0); + r.Summary.CrosswalkedToNeris.Should().Be(r.Fields.Count(f => f.NerisFactKey != null || f.NerisSection != null)); + } + + [Test] + public async Task Dispatch_close_wins_over_command_close_and_an_unmapped_type_is_missing() + { + _call.ClosedOn = LoggedOn.AddMinutes(75); + _call.Type = "Odd"; + + var r = await _service.RenderAsync(Dept, "officer", 77); + + Field(r, "LastUnitClearedDateTime").Value.Should().Be("2026-09-01T09:15:00Z"); + Field(r, "LastUnitClearedDateTime").SourceSystem.Should().Be("Calls"); + var type = Field(r, "IncidentTypeCode"); + type.Status.Should().Be(NfirsLegacyFieldStatus.Missing, "an unmapped local type is a crosswalk gap the department must close"); + type.Value.Should().Be("Odd"); + r.Summary.RequiredMissing.Should().Be(1); + } + + [Test] + public async Task An_existing_neris_report_reports_per_field_coverage() + { + _incidents.Setup(i => i.GetForCallAsync(Dept, 77)).ReturnsAsync(new IncidentReportAggregate + { + Report = new RmsIncidentReport { RmsIncidentReportId = "rep-1", DepartmentId = Dept, CallId = 77, RecordNumber = "INC-2026-0001", State = (int)RmsRecordState.Finalized, ReportingEntityId = "FD24027000" }, + Facts = new List + { + new RmsSourceFact { FactKey = NerisFactKeys.CallCreate, SourceValue = "2026-09-01T08:00:00Z" }, + new RmsSourceFact { FactKey = NerisFactKeys.IncidentNumber, SourceValue = "INC-9", CurrentValue = "INC-9" }, + new RmsSourceFact { FactKey = NerisFactKeys.Location, SourceValue = "1 Main St", CurrentValue = "" } + }, + Types = new List { new RmsIncidentType { TypeCode = "FIRE||STRUCTURE_FIRE||RESIDENTIAL" } }, + Units = new List { new RmsUnitResponse { UnitId = 5, OnSceneOn = LoggedOn.AddMinutes(9), StationGroupIdSnapshot = 3 } }, + Narrative = new RmsNarrative { Narrative = "Officer narrative" } + }); + + var r = await _service.RenderAsync(Dept, "officer", 77); + + r.IncidentReportId.Should().Be("rep-1"); + r.IncidentReportNumber.Should().Be("INC-2026-0001"); + r.IncidentReportState.Should().Be("Finalized"); + Field(r, "FDID").NerisPopulated.Should().BeTrue(); + Field(r, "AlarmDateTime").NerisPopulated.Should().BeTrue(); + Field(r, "IncidentNumber").NerisPopulated.Should().BeTrue(); + Field(r, "LocationAddress").NerisPopulated.Should().BeTrue("an empty correction falls back to the source value"); + Field(r, "IncidentTypeCode").NerisPopulated.Should().BeTrue(); + Field(r, "ArrivalDateTime").NerisPopulated.Should().BeTrue(); + Field(r, "Station").NerisPopulated.Should().BeTrue(); + Field(r, "NatureOfCall").NerisPopulated.Should().BeTrue(); + Field(r, "LastUnitClearedDateTime").NerisPopulated.Should().BeFalse(); + Field(r, "AidGivenOrReceived").NerisPopulated.Should().BeFalse(); + Field(r, "ActionsTaken").NerisPopulated.Should().BeFalse(); + Field(r, "IncidentName").NerisPopulated.Should().BeNull("there is nothing in NERIS to be populated"); + r.Summary.CrosswalkedAndPopulated.Should().Be(r.Fields.Count(f => f.NerisPopulated == true)); + } + + [Test] + public async Task Another_departments_call_is_not_found_and_an_unauthorized_viewer_is_refused() + { + (await _service.RenderAsync(Dept, "officer", 78)).Should().BeNull(); + (await _service.RenderAsync(Dept, "officer", 0)).Should().BeNull(); + _authorization.Verify(a => a.CanReadSourceCallAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, "a foreign call is not found before any authorization question is asked"); + + _authorization.Setup(a => a.CanReadSourceCallAsync("stranger", Dept, It.IsAny())).ReturnsAsync(false); + Func render = () => _service.RenderAsync(Dept, "stranger", 77); + await render.Should().ThrowAsync(); + _units.Verify(u => u.GetUnitStatesForCallAsync(It.IsAny(), It.IsAny()), Times.Never, "nothing is read for an unauthorized viewer"); + } + + [Test] + public async Task Source_outages_degrade_into_notes_rather_than_failures() + { + _units.Setup(u => u.GetUnitStatesForCallAsync(Dept, 77)).ThrowsAsync(new TimeoutException()); + _reporting.Setup(r => r.GetIncidentTimesReportAsync(Dept, 77)).ThrowsAsync(new TimeoutException()); + _incidents.Setup(i => i.GetForCallAsync(Dept, 77)).ThrowsAsync(new TimeoutException()); + _neris.Setup(n => n.GetProfileAsync(Dept)).ThrowsAsync(new TimeoutException()); + _neris.Setup(n => n.ResolveCrosswalkAsync(Dept, "incident_type", NerisCrosswalkSources.CallType, "Fire")).ThrowsAsync(new TimeoutException()); + + var r = await _service.RenderAsync(Dept, "officer", 77); + + r.Notes.Should().HaveCount(5); + Field(r, "FDID").Status.Should().Be(NfirsLegacyFieldStatus.Missing); + Field(r, "ArrivalDateTime").Status.Should().Be(NfirsLegacyFieldStatus.Missing); + Field(r, "AlarmDateTime").Status.Should().Be(NfirsLegacyFieldStatus.Populated, "the Call itself was readable"); + } + + [Test] + public void The_service_exposes_no_write_path() + { + typeof(IRecordsNfirsLegacyService).GetMethods().Select(m => m.Name).Should().Equal("RenderAsync"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsProtectionServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsProtectionServiceTests.cs new file mode 100644 index 00000000..0cf07fb4 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsProtectionServiceTests.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + /// + /// The RMS protection seam (RMS plan section 5.9) against the real ProtectedReadService with a fake broker: + /// an enrolled department's cataloged columns leave the seam as envelopes and come back as plaintext for a + /// grant holder or as the REDACTED sentinel without one; a refused write throws with the machine-readable + /// reason; the NERIS workload lane opens only behind the profile acknowledgement. + /// + [TestFixture] + public class RecordsProtectionServiceTests + { + private const int Dept = 31; + private Mock _adp; + private FakeGrantService _grants; + private Mock _broker; + private Mock _neris; + private FixedProtectedGrantContext _context; + private bool _enrolled; + private bool _ackEgress; + private int _workloadCalls; + + [SetUp] + public void SetUp() + { + _enrolled = true; + _ackEgress = false; + _workloadCalls = 0; + _adp = new Mock(); + _adp.Setup(a => a.ShouldEncryptNewWritesAsync(Dept)).ReturnsAsync(() => _enrolled); + _adp.Setup(a => a.IsProtectionEnforcedAsync(Dept)).ReturnsAsync(() => _enrolled); + _adp.Setup(a => a.GetPinnedCatalogVersionAsync(Dept)).ReturnsAsync(() => _enrolled ? ProtectedFieldCatalog.RecordsCatalogVersion : 0); + _adp.Setup(a => a.GetPolicyByDepartmentIdAsync(Dept, It.IsAny())).ReturnsAsync(() => new DepartmentDataProtectionPolicy { DepartmentId = Dept, CatalogVersion = ProtectedFieldCatalog.RecordsCatalogVersion, PolicyEpoch = 3, State = (int)DepartmentDataProtectionState.Enabled }); + + _grants = new FakeGrantService(); + + _broker = new Mock(); + _broker.Setup(b => b.EncryptAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken c) => new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = i.IsBinary ? Convert.ToBase64String(Seal(Convert.FromBase64String(i.Value))) : "rgdp:" + Reverse(i.Value) }).ToList() + }); + _broker.Setup(b => b.DecryptAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken c) => new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = i.IsBinary ? Convert.ToBase64String(Open(Convert.FromBase64String(i.Value))) : Reverse(i.Value.Substring(5)) }).ToList() + }); + _broker.Setup(b => b.DecryptForWorkloadAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string purpose, string r, IReadOnlyList items, CancellationToken c) => + { + _workloadCalls++; + return new ProtectedDataBrokerResult { Success = true, Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = Reverse(i.Value.Substring(5)) }).ToList() }; + }); + + _neris = new Mock(); + _neris.Setup(n => n.GetProfileAsync(Dept)).ReturnsAsync(() => new RmsNerisProfile { DepartmentId = Dept, AllowProtectedContentEgress = _ackEgress }); + _context = new FixedProtectedGrantContext("good-grant", false, "officer"); + } + + private static string Reverse(string value) => new string(value.Reverse().ToArray()); + private static byte[] Seal(byte[] value) => System.Text.Encoding.ASCII.GetBytes("rgdpb:").Concat(value.Reverse()).ToArray(); + private static byte[] Open(byte[] value) => value.Skip(6).Reverse().ToArray(); + + private RecordsProtectionService Build(IProtectedGrantContext context = null) + { + var reads = new ProtectedReadService(_adp.Object, _grants, _broker.Object, new ProtectedFieldCatalog()); + return new RecordsProtectionService(reads, reads, context ?? _context, _adp.Object, _neris.Object); + } + + [Test] + public async Task Narrative_is_sealed_on_write_and_revealed_for_a_grant_holder() + { + var service = Build(); + var narrative = new RmsNarrative { RmsNarrativeId = "n1", DepartmentId = Dept, Narrative = "Engine 5 found smoke showing.", OutcomeNarrative = "Fire out." }; + + await service.ProtectNarrativeAsync(Dept, narrative, null, "officer"); + + narrative.IsProtected.Should().BeTrue(); + narrative.ProtectedCatalogVersion.Should().Be(ProtectedFieldCatalog.RecordsCatalogVersion); + narrative.Narrative.Should().StartWith("rgdp:"); + narrative.OutcomeNarrative.Should().StartWith("rgdp:"); + narrative.ImpedimentNarrative.Should().BeNull("an empty column is never sealed"); + + var aggregate = new IncidentReportAggregate { Narrative = narrative }; + var result = await service.RevealAsync(Dept, aggregate); + result.IsProtected.Should().BeTrue(); + result.RedactedFields.Should().BeEmpty(); + narrative.Narrative.Should().Be("Engine 5 found smoke showing."); + aggregate.Protection.Should().BeSameAs(result); + } + + [Test] + public async Task Without_a_grant_the_reveal_leaves_the_sentinel_and_names_the_reason() + { + var writer = Build(); + var details = new RmsOperationalRecordDetail { RmsOperationalRecordDetailId = "d1", DepartmentId = Dept, RecordId = "r1", Narrative = "Private narrative", Type = "Drill" }; + await writer.ProtectDetailsAsync(Dept, details, null, "officer"); + details.Type.Should().Be("Drill", "Type is not a cataloged column"); + + var reader = Build(FixedProtectedGrantContext.Workload); + var aggregate = new RecordAggregate { Details = details }; + var result = await reader.RevealAsync(Dept, aggregate); + + result.IsProtected.Should().BeTrue(); + result.ProtectedReason.Should().Be("step_up_required"); + result.RedactedFields.Should().Contain("rmsoperationalrecorddetails.narrative"); + details.Narrative.Should().Be(ProtectedDataEnvelope.RedactionValue); + FluentActions.Invoking(() => result.RequireRevealed("finalize")).Should().Throw().Which.Reason.Should().Be("step_up_required"); + } + + [Test] + public async Task A_stale_grant_refuses_the_write_with_the_reason() + { + var service = Build(new FixedProtectedGrantContext("stale-grant", false, "officer")); + var hold = new RmsRecordLegalHold { RmsRecordLegalHoldId = "h1", DepartmentId = Dept, Notes = "Preserve everything" }; + + Func write = () => service.ProtectLegalHoldAsync(Dept, hold, null, "officer"); + + (await write.Should().ThrowAsync()).Which.Reason.Should().Be("grant_expired"); + hold.Notes.Should().Be("Preserve everything", "a refused write never half-applies"); + } + + [Test] + public async Task Unenrolled_departments_pass_through_untouched() + { + _enrolled = false; + var service = Build(FixedProtectedGrantContext.Workload); + var location = new RmsLocation { RmsLocationId = "l1", DepartmentId = Dept, AddressText = "1 Main St", Latitude = 39.5m, Longitude = -104.9m }; + + await service.ProtectLocationAsync(Dept, location, null, null); + var result = await service.RevealAsync(Dept, new IncidentReportAggregate { Location = location }); + + location.AddressText.Should().Be("1 Main St"); + location.Latitude.Should().Be(39.5m); + location.ProtectedLatitudeEnvelope.Should().BeNull(); + result.IsProtected.Should().BeFalse(); + } + + [Test] + public async Task Coordinates_ride_companion_envelopes_and_come_back_as_numbers() + { + var service = Build(); + var exposure = new RmsExposure { RmsExposureId = "e1", DepartmentId = Dept, AddressText = "2 Side St", Latitude = 39.25m, Longitude = -104.75m }; + + await service.ProtectExposureAsync(Dept, exposure, null, "officer"); + exposure.Latitude.Should().BeNull(); + exposure.ProtectedLatitudeEnvelope.Should().StartWith("rgdp:"); + exposure.IsProtected.Should().BeTrue(); + + await service.RevealAsync(Dept, new IncidentReportAggregate { Exposures = new List { exposure } }); + exposure.Latitude.Should().Be(39.25m); + exposure.Longitude.Should().Be(-104.75m); + exposure.AddressText.Should().Be("2 Side St"); + } + + [Test] + public async Task Attachment_bytes_are_sealed_and_only_opened_when_the_download_asks_for_them() + { + var service = Build(); + var bytes = System.Text.Encoding.UTF8.GetBytes("scene photo"); + var attachment = new RmsRecordAttachment { RmsRecordAttachmentId = "a1", DepartmentId = Dept, FileName = "scene.jpg", Data = bytes }; + + await service.ProtectAttachmentAsync(Dept, attachment, null, "officer"); + attachment.FileName.Should().StartWith("rgdp:"); + ProtectedReadService.IsBinaryEnveloped(attachment.Data).Should().BeTrue(); + + var metadata = await service.RevealAttachmentsAsync(Dept, new[] { attachment }, false); + attachment.FileName.Should().Be("scene.jpg"); + attachment.Data.Should().BeNull("a metadata read strips the ciphertext rather than carrying it out"); + metadata.RedactedFields.Should().Contain("rmsrecordattachments.data"); + + attachment.Data = Seal(bytes); + var full = await service.RevealAttachmentsAsync(Dept, new[] { attachment }, true); + full.RedactedFields.Should().BeEmpty(); + attachment.Data.Should().Equal(bytes); + } + + [Test] + public async Task Neris_workload_lane_opens_only_behind_the_profile_acknowledgement() + { + var writer = Build(); + var submission = new RmsSubmission { RmsSubmissionId = "s1", DepartmentId = Dept, PayloadJson = "{\"incident\":1}" }; + await writer.ProtectSubmissionAsync(Dept, submission, "officer"); + submission.PayloadJson.Should().StartWith("rgdp:"); + + var worker = Build(FixedProtectedGrantContext.Workload); + (await worker.ResolveSubmissionForWorkloadAsync(Dept, submission, RecordsProtectionService.NerisSubmissionPurpose)).Should().BeFalse("no acknowledgement, no egress"); + _workloadCalls.Should().Be(0, "the broker is never asked without the department's decision"); + submission.PayloadJson.Should().StartWith("rgdp:", "a refused workload read leaves the ciphertext in place"); + + _ackEgress = true; + (await worker.ResolveSubmissionForWorkloadAsync(Dept, submission, RecordsProtectionService.NerisSubmissionPurpose)).Should().BeTrue(); + _workloadCalls.Should().Be(1); + submission.PayloadJson.Should().Be("{\"incident\":1}"); + } + + [Test] + public async Task Sentinel_on_an_update_restores_the_stored_envelope_instead_of_overwriting_it() + { + var service = Build(); + var stored = new RmsDisclosureRequest { RmsDisclosureRequestId = "q1", DepartmentId = Dept, RequesterName = "Jane Requester", ScopeNarrative = "All runs in July" }; + await service.ProtectDisclosureRequestAsync(Dept, stored, null, "officer"); + var sealedName = stored.RequesterName; + + // An editor who never had the requester revealed posts the placeholder back with a new scope. + var edited = new RmsDisclosureRequest { RmsDisclosureRequestId = "q1", DepartmentId = Dept, RequesterName = ProtectedDataEnvelope.RedactionValue, ScopeNarrative = "All runs in July and August" }; + await service.ProtectDisclosureRequestAsync(Dept, edited, stored, "officer"); + + edited.RequesterName.Should().Be(sealedName, "the placeholder restores the stored envelope for the same row"); + edited.ScopeNarrative.Should().StartWith("rgdp:").And.NotBe(stored.ScopeNarrative); + } + } + /// Grant validation the way the broker sees it: "good-grant" is the officer's live grant, "stale-grant" has expired. + internal sealed class FakeGrantService : IProtectedDataGrantService + { + public bool CanIssueGrants => false; + public bool CanValidateGrants => true; + public ProtectedDataGrantIssueResult IssueGrant(ProtectedDataGrantIssueRequest request) => throw new NotSupportedException(); + public ProtectedDataGrantValidationOutcome ValidateGrant(string token, int expectedDepartmentId, long currentPolicyEpoch, string requiredScope, out ProtectedDataGrant grant, DateTime? utcNow = null) + { + grant = null; + if (token == "good-grant") { grant = new ProtectedDataGrant { UserId = "officer", DepartmentId = expectedDepartmentId }; return ProtectedDataGrantValidationOutcome.Valid; } + if (token == "stale-grant") return ProtectedDataGrantValidationOutcome.Expired; + return ProtectedDataGrantValidationOutcome.Invalid; + } + } + +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsReportingServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsReportingServiceTests.cs index 0bb31d60..4eb54108 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsReportingServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsReportingServiceTests.cs @@ -95,7 +95,7 @@ public void SetUp() _records.Invocations.Clear(); _revisions = new Mock(); _revisions.Setup(r => r.GetByIdsForDepartmentAsync(Dept, It.IsAny>())).ReturnsAsync((int d, IEnumerable ids) => _finalized.Values.Where(r => ids.Contains(r.RmsRevisionId)).ToList()); - _service = new RecordsReportingService(_legacy.Object, _cutover.Object, _records.Object, _revisions.Object, _scopes.Object, _authorization.Object); + _service = new RecordsReportingService(_legacy.Object, _cutover.Object, _records.Object, _revisions.Object, _scopes.Object, _authorization.Object, new PassthroughRecordsProtection()); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RecordsRetentionServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsRetentionServiceTests.cs index a38eeae5..3763b660 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsRetentionServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsRetentionServiceTests.cs @@ -68,7 +68,7 @@ public void SetUp() _service = new RecordsRetentionService(_store.CutoversRepo.Object, _store.RecordsRepo.Object, _incidents.ReportsRepo.Object, _store.DetailsRepo.Object, _store.AttachmentsRepo.Object, _store.LegalHoldsRepo.Object, _store.AuditsRepo.Object, _store.ProjectionsRepo.Object, - _scanner.Object, _settings.Object, _purge.Object); + _scanner.Object, _settings.Object, _purge.Object, new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of())); } private RmsOperationalRecord SeedFinalized(DateTime finalizedOn, string definitionKey = null) @@ -222,6 +222,14 @@ public async Task A_record_past_retention_becomes_a_content_free_tombstone() _store.Projections.Single().SearchText.Should().BeNull(); _store.Audits.Should().Contain(a => a.Purpose == RecordsRetentionService.PurgeAuditPurpose); + + // RecordPurged (159): the identity that went away and what went with it; never content. + var entry = _store.Outbox.Single(o => o.EventName == "RecordPurged"); + entry.TriggerEventType.Should().Be((int)WorkflowTriggerEventType.RecordPurged); + var payload = Newtonsoft.Json.Linq.JObject.Parse(entry.PayloadJson); + ((string)payload["record"]["id"]).Should().Be(record.RmsOperationalRecordId); + ((string)payload["record"]["kind"]).Should().Be("Operational"); + ((int)payload["purge"]["attachments_purged"]).Should().Be(1); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RecordsRms3eWorkflowTriggerTests.cs b/Tests/Resgrid.Tests/Rms/RecordsRms3eWorkflowTriggerTests.cs new file mode 100644 index 00000000..53ea6369 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsRms3eWorkflowTriggerTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Services; +using Scriban.Runtime; + +namespace Resgrid.Tests.Rms +{ + /// + /// Workflow integration for the RMS-3e triggers (2026-09-05 pass): the block-1 gaps (103 approved, 115 + /// attachment added) and block 2 (152-160: disclosure, legal hold, evidence, purge, scheduled export). Every + /// trigger has a variable catalog, every catalog variable renders from the sample data, and the context + /// builder maps each block from the dispatched outbox payload, never from current record state. + /// + [TestFixture] + public class RecordsRms3eWorkflowTriggerTests + { + private static readonly WorkflowTriggerEventType[] Triggers = + { + WorkflowTriggerEventType.RecordApproved, + WorkflowTriggerEventType.RecordAttachmentAdded, + WorkflowTriggerEventType.RecordDisclosureRequested, + WorkflowTriggerEventType.RecordDisclosureProduced, + WorkflowTriggerEventType.RecordDisclosureReleased, + WorkflowTriggerEventType.RecordDisclosureClosed, + WorkflowTriggerEventType.RecordLegalHoldPlaced, + WorkflowTriggerEventType.RecordLegalHoldReleased, + WorkflowTriggerEventType.RecordEvidenceCaptured, + WorkflowTriggerEventType.RecordPurged, + WorkflowTriggerEventType.RecordExportScheduled + }; + + [Test] + public void Every_rms3e_trigger_is_a_records_trigger_with_a_catalog_and_a_protection_block() + { + foreach (var trigger in Triggers) + { + WorkflowTriggerEventTypes.IsRecordsTrigger(trigger).Should().BeTrue($"{trigger} lives in an RMS block"); + var catalog = WorkflowTemplateVariableCatalog.GetVariableCatalog(trigger); + catalog.Should().Contain(v => v.Name == "event.name", $"{trigger} carries the event namespace"); + catalog.Should().Contain(v => v.Name == "protection.is_protected", $"{trigger} carries the ADP posture"); + catalog.Should().NotContain(v => v.Name.Contains("narrative") || v.Name.Contains("requester") || v.Name.Contains("notes") || v.Name.Contains("file_name") && trigger == WorkflowTriggerEventType.RecordAttachmentAdded, + $"{trigger} must never expose record content"); + } + } + + [Test] + public void Every_catalog_variable_has_a_sample_value() + { + foreach (var trigger in Triggers) + { + var data = (ScriptObject)WorkflowSampleDataGenerator.GenerateSampleData(trigger); + foreach (var descriptor in WorkflowTemplateVariableCatalog.GetVariableCatalog(trigger)) + Has(data, descriptor.Name).Should().BeTrue($"{trigger} sample data must carry {descriptor.Name}"); + } + } + + [Test] + public void Block_specific_namespaces_ride_only_their_own_triggers() + { + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordAttachmentAdded).Should().Contain(v => v.Name == "attachment.checksum"); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordFinalized).Should().NotContain(v => v.Name.StartsWith("attachment.")); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordDisclosureReleased).Should().Contain(v => v.Name == "disclosure.delivery_method"); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordLegalHoldPlaced).Should().Contain(v => v.Name == "legal_hold.reason").And.NotContain(v => v.Name == "legal_hold.reference_number"); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordEvidenceCaptured).Should().Contain(v => v.Name == "evidence.checksum").And.NotContain(v => v.Name.Contains("manifest")); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordPurged).Should().Contain(v => v.Name == "purge.attachments_purged"); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordExportScheduled).Should().Contain(v => v.Name == "export.run_id"); + WorkflowTemplateVariableCatalog.GetVariableCatalog(WorkflowTriggerEventType.RecordApproved).Should().Contain(v => v.Name == "review.approver_user_id"); + } + + [Test] + public void Workflow_step_export_hooks_read_the_designer_config_and_the_dispatched_subject() + { + WorkflowService.ReadExportTemplateId("{\"to\":\"a@b\",\"recordsExportTemplateId\":\"tpl-1\"}").Should().Be("tpl-1"); + WorkflowService.ReadExportTemplateId("{\"RecordsExportTemplateId\":\" tpl-2 \"}").Should().Be("tpl-2"); + WorkflowService.ReadExportTemplateId("{\"to\":\"a@b\"}").Should().BeNull(); + WorkflowService.ReadExportTemplateId("not json").Should().BeNull(); + WorkflowService.ReadExportTemplateId(null).Should().BeNull(); + + var dispatched = new DomainEventDispatchedEvent + { + DepartmentId = 1, EventId = "evt", EventName = "RecordExportScheduled", TriggerEventType = (int)WorkflowTriggerEventType.RecordExportScheduled, + PayloadJson = JsonConvert.SerializeObject(new { record = new { id = (string)null, kind = "Export" }, export = new { run_id = "run-9" } }) + }; + var subject = WorkflowService.ReadExportSubject(JsonConvert.SerializeObject(RecordsWorkflowEvent.From(dispatched))); + subject.recordId.Should().BeNull(); + subject.recordKind.Should().BeNull(); + subject.scheduledRunId.Should().Be("run-9"); + + var finalized = new DomainEventDispatchedEvent + { + DepartmentId = 1, EventId = "evt2", EventName = "RecordFinalized", TriggerEventType = (int)WorkflowTriggerEventType.RecordFinalized, + PayloadJson = JsonConvert.SerializeObject(new { record = new { id = "rec-1", kind = "IncidentReport" } }) + }; + var single = WorkflowService.ReadExportSubject(JsonConvert.SerializeObject(RecordsWorkflowEvent.From(finalized))); + single.recordId.Should().Be("rec-1"); + single.recordKind.Should().Be(RmsRecordKind.IncidentReport); + WorkflowService.ReadExportSubject("garbage").Should().Be(((string)null, (RmsRecordKind?)null, (string)null)); + } + + private static bool Has(ScriptObject root, string path) + { + object current = root; + foreach (var segment in path.Split('.')) + { + if (!(current is ScriptObject obj) || !obj.ContainsKey(segment)) + return false; + current = obj[segment]; + } + return true; + } + } + + /// The template context for a dispatched RMS-3e event, built with the shared context-builder fixture. + [TestFixture] + public class RecordsRms3eContextBuilderTests : Resgrid.Tests.Services.WorkflowTemplateContextBuilderTests.with_the_context_builder + { + [Test] + public async Task Maps_the_block_namespaces_and_defaults_protection_for_older_payloads() + { + var dispatched = new DomainEventDispatchedEvent + { + DepartmentId = 1, EventId = "evt-3", EventName = "RecordLegalHoldPlaced", SchemaVersion = 1, AggregateType = DomainEventProducers.RecordsAggregate, AggregateId = "rec-1", + TriggerEventType = (int)WorkflowTriggerEventType.RecordLegalHoldPlaced, CorrelationId = "hold-1", OriginClient = (int)RmsOriginClient.Web, OccurredOn = DateTime.UtcNow, + PayloadJson = JsonConvert.SerializeObject(new + { + record = new { id = "rec-1", kind = "Operational", record_number = "RUN-2026-0007", author_user_id = "author-1" }, + legal_hold = new { id = "hold-1", record_id = "rec-1", reason = "Litigation", placed_by_user_id = "counsel", is_released = false }, + protection = new { is_protected = true, is_redacted = false, protected_catalog_version = 10 } + }) + }; + + var ctx = await BuildContext(WorkflowTriggerEventType.RecordLegalHoldPlaced, RecordsWorkflowEvent.From(dispatched)); + + var hold = (ScriptObject)ctx["legal_hold"]; + hold["reason"].Should().Be("Litigation"); + hold["is_released"].Should().Be(false); + ((ScriptObject)ctx["record"])["record_number"].Should().Be("RUN-2026-0007"); + var protection = (ScriptObject)ctx["protection"]; + protection["is_protected"].Should().Be(true); + protection["protected_catalog_version"].Should().Be(10L); + + var legacy = new DomainEventDispatchedEvent + { + DepartmentId = 1, EventId = "evt-4", EventName = "RecordFinalized", TriggerEventType = (int)WorkflowTriggerEventType.RecordFinalized, + PayloadJson = JsonConvert.SerializeObject(new { record = new { id = "rec-2", author_user_id = "author-1" }, record_change = new { previous_state = "Draft", current_state = "Finalized" } }) + }; + var older = await BuildContext(WorkflowTriggerEventType.RecordFinalized, RecordsWorkflowEvent.From(legacy)); + var defaulted = (ScriptObject)older["protection"]; + defaulted["is_protected"].Should().Be(false, "an outbox row written before catalog v10 still renders the unprotected shape"); + older.ContainsKey("legal_hold").Should().BeFalse("block namespaces appear only when the payload carries them"); + } + + [Test] + public async Task Export_scheduled_maps_the_export_block_for_the_carrying_step() + { + var dispatched = new DomainEventDispatchedEvent + { + DepartmentId = 1, EventId = "evt-5", EventName = "RecordExportScheduled", TriggerEventType = (int)WorkflowTriggerEventType.RecordExportScheduled, AggregateType = "RmsExportTemplate", AggregateId = "tpl-1", + PayloadJson = JsonConvert.SerializeObject(new + { + record = new { id = (string)null, kind = "Export", department_id = 1 }, + export = new { run_id = "run-1", template_key = "state-runs", format = "Csv", record_count = 12, file_name = "state-runs.csv", redacted = false } + }) + }; + + var ctx = await BuildContext(WorkflowTriggerEventType.RecordExportScheduled, RecordsWorkflowEvent.From(dispatched)); + + var export = (ScriptObject)ctx["export"]; + export["run_id"].Should().Be("run-1"); + export["record_count"].Should().Be(12L); + ((ScriptObject)ctx["record"])["kind"].Should().Be("Export"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs index 85d4b6d9..ef3ef9f9 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs @@ -31,6 +31,7 @@ public class RecordsServiceTests private Mock _units; private Mock _calls; private Mock _adp; + private PassthroughRecordsProtection _protection; private Mock _aggregator; private List _published; private List _notifications; @@ -69,6 +70,7 @@ public void SetUp() _adp = new Mock(); _adp.Setup(a => a.GetPinnedCatalogVersionAsync(Dept)).ReturnsAsync(0); + _protection = new PassthroughRecordsProtection(); _published = new List(); _aggregator = new Mock(); @@ -92,7 +94,7 @@ public void SetUp() _service = new RecordsService(_store.RecordsRepo.Object, new Resgrid.Services.Records.RmsRecordValueService(_store.DetailsRepo.Object), _store.ParticipantsRepo.Object, _store.UnitsRepo.Object, _store.AttachmentsRepo.Object, _store.RevisionsRepo.Object, _evidence.Object, _store.ScopesRepo.Object, _store.SharesRepo.Object, _store.ProjectionsRepo.Object, _store.AuditsRepo.Object, outbox, _cutover.Object, _settings.Object, _groups.Object, _profiles.Object, _units.Object, _calls.Object, _adp.Object, - _store.UnitOfWork.Object, _outboundQueue.Object, new Resgrid.Services.Records.NullRecordAttachmentScanner(), _authorization.Object, Mock.Of()); + _store.UnitOfWork.Object, _outboundQueue.Object, new Resgrid.Services.Records.NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), _protection); } [Test] @@ -621,6 +623,41 @@ public async Task Unit_activity_requires_a_unit_and_an_activity_time() _store.Outbox.Should().NotContain(o => o.TriggerEventType == (int)WorkflowTriggerEventType.LogAdded, "Unit Activity never emitted LogAdded and must not start now"); } + [Test] + public async Task Approval_emits_record_approved_with_the_review_and_protection_blocks() + { + var created = await _service.CreateDraftAsync(Dept, "author", TrainingInput()); + _store.Records.Single(r => r.RmsOperationalRecordId == created.Record.RmsOperationalRecordId).LifecyclePreset = (int)RmsLifecyclePreset.ApprovalAcknowledgement; + await _service.SubmitForReviewAsync(Dept, "author", created.Record.RmsOperationalRecordId, created.Record.RowVersion); + + await _service.ApproveAsync(Dept, "chief", created.Record.RmsOperationalRecordId); + + _published.Last().EventName.Should().Be("RecordApproved"); + var payload = Newtonsoft.Json.Linq.JObject.Parse(_store.Outbox.Single(o => o.EventName == "RecordApproved").PayloadJson); + ((string)payload["record"]["state"]).Should().Be("Approved"); + ((string)payload["review"]["approver_user_id"]).Should().Be("chief"); + ((bool)payload["protection"]["is_redacted"]).Should().BeFalse(); + ((int)payload["protection"]["protected_catalog_version"]).Should().Be(0); + _store.Outbox.Single(o => o.EventName == "RecordApproved").TriggerEventType.Should().Be((int)WorkflowTriggerEventType.RecordApproved); + } + + [Test] + public async Task Adding_an_attachment_emits_record_attachment_added_without_the_file_name() + { + var created = await _service.CreateDraftAsync(Dept, "author", TrainingInput()); + await _service.AddAttachmentAsync(Dept, "author", created.Record.RmsOperationalRecordId, "private-roster.pdf", "application/pdf", new byte[] { 9, 8, 7 }, "Roster with names"); + + var entry = _store.Outbox.Single(o => o.EventName == "RecordAttachmentAdded"); + entry.TriggerEventType.Should().Be((int)WorkflowTriggerEventType.RecordAttachmentAdded); + var payload = Newtonsoft.Json.Linq.JObject.Parse(entry.PayloadJson); + ((string)payload["attachment"]["content_type"]).Should().Be("application/pdf"); + ((int)payload["attachment"]["byte_size"]).Should().Be(3); + ((int)payload["attachment"]["count"]).Should().Be(1); + ((string)payload["attachment"]["scan_state"]).Should().NotBeNullOrEmpty(); + entry.PayloadJson.Should().NotContain("private-roster").And.NotContain("Roster with names", "file names and descriptions are record content"); + _protection.Writes.Should().Contain("attachment", "the attachment passes the ADP seam before it is stored"); + } + [Test] public async Task Attachments_are_checksummed_and_never_added_to_a_terminal_record() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsSubmissionServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsSubmissionServiceTests.cs index bb1bd131..f9b25439 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsSubmissionServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsSubmissionServiceTests.cs @@ -39,6 +39,7 @@ public class RecordsSubmissionServiceTests private bool _enabled; private bool _previousEnabled; private RecordsSubmissionService _service; + protected PassthroughRecordsProtection Protection { get; } = new PassthroughRecordsProtection(); [SetUp] public void SetUp() @@ -73,7 +74,7 @@ public void SetUp() var outbox = new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, new Mock().Object); _service = new RecordsSubmissionService(_store.SubmissionsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, _store.Shared.ProjectionsRepo.Object, - _store.Shared.AuditsRepo.Object, _profiles.Object, _delivery.Object, outbox, _outboundQueue.Object, _store.UnitOfWork.Object, _store.Shared.CutoversRepo.Object, Mock.Of(), _store.ExchangesRepo.Object, _authorization.Object); + _store.Shared.AuditsRepo.Object, _profiles.Object, _delivery.Object, outbox, _outboundQueue.Object, _store.UnitOfWork.Object, _store.Shared.CutoversRepo.Object, Mock.Of(), _store.ExchangesRepo.Object, _authorization.Object, Protection); } [TearDown] diff --git a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs index 6f691b6c..38062461 100644 --- a/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsContainerCompositionTests.cs @@ -52,6 +52,17 @@ public void Records_services_resolve_from_the_container() Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); Resolve().Should().NotBeNull(); + // RMS-3 feeds, the read-only NFIRS crosswalk and the RecordOperationalSummaryV1 contract + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + // RMS-3e (2026-09-05): ADP seam, ambient grant context and department report exports + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); + Resolve().Should().NotBeNull(); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index ba2f0c44..0da23b62 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -98,6 +98,31 @@ public void Rms_worker_command_ids_are_the_registry_values() program.Should().Contain("new Commands.RmsDueStateEvaluationCommand(42)"); program.Should().Contain("new Commands.RmsRetentionAndPurgeCommand(43)"); 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)"); + } + + [Test] + public void Workflow_triggers_in_the_rms_3e_block_are_the_registry_values() + { + // Block 1 gaps filled (103 approved, 115 attachment added) and block 2 (152-163) opened for the + // disclosure, legal hold, evidence, purge and export events (registry section 1, 2026-09-05). + ((int)WorkflowTriggerEventType.RecordApproved).Should().Be(103); + ((int)WorkflowTriggerEventType.RecordAttachmentAdded).Should().Be(115); + ((int)WorkflowTriggerEventType.RecordDisclosureRequested).Should().Be(152); + ((int)WorkflowTriggerEventType.RecordDisclosureProduced).Should().Be(153); + ((int)WorkflowTriggerEventType.RecordDisclosureReleased).Should().Be(154); + ((int)WorkflowTriggerEventType.RecordDisclosureClosed).Should().Be(155); + ((int)WorkflowTriggerEventType.RecordLegalHoldPlaced).Should().Be(156); + ((int)WorkflowTriggerEventType.RecordLegalHoldReleased).Should().Be(157); + ((int)WorkflowTriggerEventType.RecordEvidenceCaptured).Should().Be(158); + ((int)WorkflowTriggerEventType.RecordPurged).Should().Be(159); + ((int)WorkflowTriggerEventType.RecordExportScheduled).Should().Be(160); + + foreach (var value in Enumerable.Range(161, 3)) + Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved in the RMS block 2"); + WorkflowTriggerEventTypes.IsRecordsTrigger(WorkflowTriggerEventType.RecordExportScheduled).Should().BeTrue(); + WorkflowTriggerEventTypes.IsRecordsTrigger((WorkflowTriggerEventType)151).Should().BeFalse("151 belongs to AI Dispatch"); } private static string ProgramSource() diff --git a/Tests/Resgrid.Tests/Rms/RmsProtectedFieldsCatalogTests.cs b/Tests/Resgrid.Tests/Rms/RmsProtectedFieldsCatalogTests.cs new file mode 100644 index 00000000..21a21619 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RmsProtectedFieldsCatalogTests.cs @@ -0,0 +1,56 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Rms +{ + /// + /// Pins the three-way agreement the RMS ADP integration rests on (RMS plan section 5.9.2, ADP catalog v10): + /// every field the write seam can encrypt is in the catalog, every cataloged RMS field has a migration + /// binding, and the catalog version the cutover preflight keys on is the one those entries were added in. + /// + [TestFixture] + public class RmsProtectedFieldsCatalogTests + { + [Test] + public void Every_rms_seam_field_is_cataloged_at_version_10() + { + var catalog = new ProtectedFieldCatalog(); + var entries = catalog.GetAll().Where(e => e.Family == RmsProtectedFields.Family).ToDictionary(e => e.FieldId); + + ProtectedFieldCatalog.RecordsCatalogVersion.Should().Be(10); + catalog.Version.Should().BeGreaterThanOrEqualTo(ProtectedFieldCatalog.RecordsCatalogVersion); + + foreach (var fieldId in RmsProtectedFields.AllFieldIds()) + { + entries.Should().ContainKey(fieldId, $"the write seam can encrypt {fieldId}, so the catalog must own it"); + entries[fieldId].AddedInCatalogVersion.Should().Be(ProtectedFieldCatalog.RecordsCatalogVersion, $"{fieldId} ships with the RMS catalog bump"); + } + } + + [Test] + public void Every_cataloged_rms_field_has_a_seam_and_a_migration_binding() + { + var catalog = new ProtectedFieldCatalog(); + var seam = RmsProtectedFields.AllFieldIds().ToHashSet(); + var bindings = AdpTableBindings.V1.Select(b => b.TableName.ToLowerInvariant()).ToHashSet(); + + foreach (var entry in catalog.GetAll().Where(e => e.Family == RmsProtectedFields.Family)) + { + seam.Should().Contain(entry.FieldId, $"a cataloged column without a write seam would ship plaintext: {entry.FieldId}"); + bindings.Should().Contain(entry.TableName.ToLowerInvariant(), $"the enrollment sweep needs a binding for {entry.TableName}"); + } + } + + [Test] + public void Field_ids_follow_the_table_dot_column_convention_and_never_collide() + { + var ids = RmsProtectedFields.AllFieldIds().ToList(); + ids.Should().OnlyHaveUniqueItems(); + ids.Should().OnlyContain(id => id == id.ToLowerInvariant() && id.Contains('.')); + ids.Should().Contain("rmsoperationalrecorddetails.narrative").And.Contain("rmsrecordattachments.data").And.Contain("rmsexportruns.data"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs b/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs index d832871a..1c812f89 100644 --- a/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs @@ -203,7 +203,7 @@ await db.ExecuteAsync(@"INSERT RmsRevisions (RmsRevisionId,DepartmentId,Protecti var scopes = new RmsRecordGroupScopesRepository(connections, new SqlServerConfiguration(), unit, WriteQueries()); var auth = new Mock(); auth.Setup(a => a.GetVisibleGroupIdsAsync("officer", 11)).ReturnsAsync((List)null); var cutover = new Mock(); cutover.Setup(c => c.GetModuleStateAsync(11, It.IsAny())).ReturnsAsync(new RecordsModuleState { FlagEnabled = true }); - var reporting = new Resgrid.Services.Records.RecordsReportingService(Mock.Of(), cutover.Object, records, revisions, scopes, auth.Object); + var reporting = new Resgrid.Services.Records.RecordsReportingService(Mock.Of(), cutover.Object, records, revisions, scopes, auth.Object, new PassthroughRecordsProtection()); var activity = (await reporting.GetActivityAsync(11, "officer", RmsOperationalRecordType.Training, start.Date, start.Date.AddDays(1))).Single(e => e.SourceId == id); activity.StartedOn.Should().Be(start); activity.EndedOn.Should().Be(start.AddHours(2)); activity.CallId.Should().Be(91021); activity.Course.Should().Be("Ropes"); (await reporting.GetActivityAsync(11, "officer", RmsOperationalRecordType.Training, start.AddYears(3).Date, start.AddYears(3).Date.AddDays(1))).Should().NotContain(e => e.SourceId == id); diff --git a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs index f1f6e0fb..f4b868b4 100644 --- a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs @@ -55,9 +55,9 @@ public async Task Scan_counts_every_binding_and_derives_the_range_and_nights() "every catalog binding is counted"); result.TotalRows.Should().Be(10000L * AdpTableBindings.V1.Count); - // 260,000 rows / 100 rps = 2600s + 26×30s overhead = 3380s; ×1.25 = 4225s → 71 min P50. - result.EstimatedP50Minutes.Should().Be(71); - result.EstimatedP90Minutes.Should().Be(141); + // 450,000 rows / 100 rps = 4500s + 45×30s overhead = 5850s; ×1.25 = 7312.5s → 122 min P50 (catalog v10 binds 45 tables). + result.EstimatedP50Minutes.Should().Be(122); + result.EstimatedP90Minutes.Should().Be(244); result.ProjectedNights.Should().Be(1, "the P90 estimate still fits one 480-minute window"); result.BenchmarkRowsPerSecond.Should().Be(100); } diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index ed5f09e5..a996ad9d 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -282,7 +282,12 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude "Messages", "MessageRecipients", "ModerationRequests", "ModerationReports", "ModerationActions", "ChatMessageFlags", "ChatModerationActions", "ChatExports", - "UnitLogs", "UserStates", "CalendarItems", "Documents", "DistributionLists" + "UnitLogs", "UserStates", "CalendarItems", "Documents", "DistributionLists", + // Records (RMS) family, catalog v10: read through the generic Records resolvers behind IRecordsProtectionService. + "RmsOperationalRecordDetails", "RmsNarratives", "RmsLocations", "RmsSourceFacts", "RmsCasualtyRescues", "RmsExposures", + "RmsIncidentModules", "RmsIncidentProperties", "RmsIncidentVehicles", "RmsIncidentResources", "RmsRevisions", + "RmsSubmissions", "RmsSignatures", "RmsEvidenceArtifacts", "RmsDisclosureRequests", "RmsDisclosureProductions", + "RmsRecordLegalHolds", "RmsRecordAttachments", "RmsExportRuns" }; AdpTableBindings.V1.Select(b => b.TableName) diff --git a/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs b/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs index 15987dc2..eb3f8456 100644 --- a/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs +++ b/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs @@ -20,9 +20,12 @@ public class RemainingCandidateProtectionTests public void SetUp() => _catalog = new ProtectedFieldCatalog(); [Test] - public void The_catalog_is_at_version_nine_and_the_last_candidates_are_what_moved_it() + public void The_catalog_is_at_version_ten_and_the_last_candidates_are_what_moved_it() { - _catalog.Version.Should().Be(9); + // v9 closed the remaining Protected Data candidates; v10 (2026-09-05) is the Records (RMS) family, + // pinned field-by-field in RmsProtectedFieldsCatalogTests. + _catalog.Version.Should().Be(10); + _catalog.GetAddedBetween(9, 10).Select(e => e.FieldId).Should().BeEquivalentTo(Resgrid.Model.RmsProtectedFields.AllFieldIds()); _catalog.GetAddedBetween(8, 9).Select(e => e.FieldId) .Should().BeEquivalentTo(new[] diff --git a/Tests/Resgrid.Tests/Web/BrowserScriptTests.cs b/Tests/Resgrid.Tests/Web/BrowserScriptTests.cs new file mode 100644 index 00000000..112ad4b8 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/BrowserScriptTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using NUnit.Framework; + +namespace Resgrid.Tests.Web +{ + /// + /// Runs the headless browser scripts in Tests/Resgrid.Tests/Web (record authoring, the NERIS guided form, + /// the ADP reveal module) as NUnit cases, one per *.test.cjs file, so they run with the C# suite locally + /// and in CI. Each script drives the real page script in Playwright; see browser-launch.cjs for how the + /// Playwright install and the browser channel are chosen. + /// + /// Without node or a resolvable Playwright the cases are skipped, unless RESGRID_BROWSER_TESTS=required + /// (CI sets it), in which case a missing browser is a failure rather than a silent gap. + /// + [TestFixture] + [Category("Browser")] + [NonParallelizable] + public class BrowserScriptTests + { + private static readonly TimeSpan ScriptTimeout = TimeSpan.FromMinutes(5); + private string _skipReason; + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + return directory?.FullName; + } + + private static string ScriptsDirectory() + { + var root = RepositoryRoot(); + return root == null ? null : Path.Combine(root, "Tests", "Resgrid.Tests", "Web"); + } + + public static IEnumerable Scripts + { + get + { + var directory = ScriptsDirectory(); + if (directory == null || !Directory.Exists(directory)) + yield break; + foreach (var file in Directory.GetFiles(directory, "*.test.cjs").Select(Path.GetFileName).OrderBy(n => n, StringComparer.Ordinal)) + yield return new TestCaseData(file).SetName("{m}(" + file + ")"); + } + } + + [OneTimeSetUp] + public void ProbeEnvironment() + { + var directory = ScriptsDirectory(); + if (directory == null) + { + _skipReason = "Repository root (Resgrid.sln) not found above the test directory."; + return; + } + + var node = Run(directory, "--version", TimeSpan.FromSeconds(30)); + if (node.ExitCode != 0) + { + _skipReason = "node is not available on PATH: " + node.Output.Trim(); + return; + } + + var playwright = Run(directory, "-e \"require('./browser-launch.cjs').playwright()\"", TimeSpan.FromSeconds(60)); + if (playwright.ExitCode != 0) + _skipReason = "Playwright could not be resolved (set RESGRID_PLAYWRIGHT_PATH or run `npm ci` in Tests/Resgrid.Tests/Web): " + LastLine(playwright.Output); + } + + [TestCaseSource(nameof(Scripts))] + public void script_passes(string file) + { + if (_skipReason != null) + { + if (string.Equals(Environment.GetEnvironmentVariable("RESGRID_BROWSER_TESTS"), "required", StringComparison.OrdinalIgnoreCase)) + Assert.Fail("Browser tests are required in this environment but cannot run: " + _skipReason); + Assert.Ignore(_skipReason); + } + + var result = Run(ScriptsDirectory(), Quote(file), ScriptTimeout); + TestContext.Out.WriteLine(result.Output); + Assert.That(result.TimedOut, Is.False, file + " did not finish within " + ScriptTimeout); + Assert.That(result.ExitCode, Is.EqualTo(0), file + " failed:\n" + result.Output); + } + + private static string Quote(string value) => "\"" + value + "\""; + + /// The line that says what went wrong (node prints its version last, which says nothing). + private static string LastLine(string output) + { + var lines = (output ?? string.Empty).Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList(); + var telling = lines.FirstOrDefault(l => l.Contains("Cannot find module", StringComparison.Ordinal) || l.StartsWith("Error", StringComparison.Ordinal)); + return telling ?? (lines.Count == 0 ? string.Empty : lines[lines.Count - 1]); + } + + private static (int ExitCode, string Output, bool TimedOut) Run(string workingDirectory, string arguments, TimeSpan timeout) + { + var info = new ProcessStartInfo("node", arguments) + { + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + var output = new StringBuilder(); + try + { + using var process = new Process { StartInfo = info }; + process.OutputDataReceived += (_, e) => { if (e.Data != null) lock (output) output.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (output) output.AppendLine(e.Data); }; + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + if (!process.WaitForExit((int)timeout.TotalMilliseconds)) + { + try { process.Kill(true); } catch (Exception) { } + return (-1, output.ToString(), true); + } + process.WaitForExit(); + return (process.ExitCode, output.ToString(), false); + } + catch (Exception ex) + { + return (-1, output + ex.Message, false); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Web/HttpProtectedGrantContextTests.cs b/Tests/Resgrid.Tests/Web/HttpProtectedGrantContextTests.cs new file mode 100644 index 00000000..1e31d35f --- /dev/null +++ b/Tests/Resgrid.Tests/Web/HttpProtectedGrantContextTests.cs @@ -0,0 +1,150 @@ +using System.Collections.Generic; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Web.Areas.User.Models.Records; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Web +{ + /// + /// The request-bound grant context behind the Records edit pages (RMS plan section 5.9.3): a grant arrives as the + /// X-Resgrid-Protected-Grant header on AJAX calls and as the __ResgridProtectedGrant field on full-page form posts. + /// + [TestFixture] + public class HttpProtectedGrantContextTests + { + private static HttpProtectedGrantContext Build(DefaultHttpContext http) => new HttpProtectedGrantContext(new HttpContextAccessor { HttpContext = http }); + + private static DefaultHttpContext Authenticated(string userId = "user-1") + { + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.PrimarySid, userId) }, "test"); + return new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; + } + + private static void PostForm(DefaultHttpContext http, string field, string value) + { + http.Request.Method = "POST"; + http.Request.ContentType = "application/x-www-form-urlencoded"; + http.Request.Form = new FormCollection(new Dictionary { [field] = value }); + } + + [Test] + public void header_carries_the_grant() + { + var http = Authenticated(); + http.Request.Headers[HttpProtectedGrantContext.HeaderName] = " grant-from-header "; + + var context = Build(http); + + context.GrantToken.Should().Be("grant-from-header"); + context.IsWorkloadCaller.Should().BeFalse(); + context.UserId.Should().Be("user-1"); + } + + [Test] + public void form_field_carries_the_grant_on_a_form_post() + { + var http = Authenticated(); + PostForm(http, HttpProtectedGrantContext.FormFieldName, "grant-from-form"); + + Build(http).GrantToken.Should().Be("grant-from-form"); + } + + [Test] + public void header_wins_over_the_form_field() + { + var http = Authenticated(); + http.Request.Headers[HttpProtectedGrantContext.HeaderName] = "grant-from-header"; + PostForm(http, HttpProtectedGrantContext.FormFieldName, "grant-from-form"); + + Build(http).GrantToken.Should().Be("grant-from-header"); + } + + [Test] + public void form_field_is_ignored_without_a_form_content_type() + { + var http = Authenticated(); + http.Request.Method = "POST"; + http.Request.ContentType = "application/json"; + + Build(http).GrantToken.Should().BeNull(); + } + + [Test] + public void blank_values_read_as_no_grant() + { + var http = Authenticated(); + PostForm(http, HttpProtectedGrantContext.FormFieldName, " "); + + Build(http).GrantToken.Should().BeNull(); + } + + [Test] + public void an_unauthenticated_request_is_a_workload_caller() + { + var context = Build(new DefaultHttpContext()); + + context.IsWorkloadCaller.Should().BeTrue(); + context.UserId.Should().BeNull(); + context.GrantToken.Should().BeNull(); + } + + [Test] + public void user_id_falls_back_to_name_identifier() + { + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "api-user") }, "test"); + var http = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; + + Build(http).UserId.Should().Be("api-user"); + } + + [Test] + public void expiry_field_is_read_only_from_a_form_post_and_parses_as_utc() + { + var http = Authenticated(); + PostForm(http, HttpProtectedGrantContext.ExpiresOnFormFieldName, "2026-09-05T18:30:00.0000000Z"); + + var expiry = HttpProtectedGrantContext.ReadExpiry(http.Request); + expiry.Should().Be(new System.DateTime(2026, 9, 5, 18, 30, 0, System.DateTimeKind.Utc)); + expiry.Value.Kind.Should().Be(System.DateTimeKind.Utc); + + var json = Authenticated(); + json.Request.Method = "POST"; + json.Request.ContentType = "application/json"; + HttpProtectedGrantContext.ReadExpiry(json.Request).Should().BeNull(); + HttpProtectedGrantContext.ReadExpiry(null).Should().BeNull(); + } + + [Test] + public void expiry_values_that_do_not_parse_are_ignored() + { + HttpProtectedGrantContext.ParseExpiry(null).Should().BeNull(); + HttpProtectedGrantContext.ParseExpiry(" ").Should().BeNull(); + HttpProtectedGrantContext.ParseExpiry("not-a-date").Should().BeNull(); + HttpProtectedGrantContext.ParseExpiry("2026-09-05T18:30:00+02:00").Should().Be(new System.DateTime(2026, 9, 5, 16, 30, 0, System.DateTimeKind.Utc)); + } + + [Test] + public void edit_views_mirror_the_read_result() + { + var view = new RecordEditView(); + + view.ApplyProtection(new ProtectedReadResult { IsProtected = true, RedactedFields = new List { "rmsoperationalrecorddetails.narrative" }, ProtectedReason = "step_up_required" }); + view.ProtectionEnforced.Should().BeTrue(); + view.ProtectionRedacted.Should().BeTrue(); + view.ProtectionReason.Should().Be("step_up_required"); + + view.ApplyProtection(new ProtectedReadResult { IsProtected = true }); + view.ProtectionRedacted.Should().BeFalse(); + view.ProtectionReason.Should().BeNull(); + + view.ApplyProtection(null); + view.ProtectionEnforced.Should().BeFalse(); + view.ProtectedGrant.Should().BeNull("the grant is never derived from a read result"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/RecordSummariesApiControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/RecordSummariesApiControllerTests.cs new file mode 100644 index 00000000..7718daeb --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/RecordSummariesApiControllerTests.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + /// + /// v4 RecordSummaries (RMS plan sections 5.1 and 4.7): flag gate, member visibility per row, rows the caller + /// cannot see are omitted from the feed, a bad cursor is a 400, a checksum failure is a 409, and the same + /// endpoints serve a system principal only through its configured grant. + /// + [TestFixture] + public class RecordSummariesApiControllerTests + { + private const int Dept = 42; + private const string Me = "author"; + + private Mock _summaries; + private Mock _cutover; + private Mock _authorization; + private RecordsModuleState _moduleState; + private RecordSummariesController _controller; + private DefaultHttpContext _http; + private Activity _activity; + + [SetUp] + public void SetUp() + { + _summaries = new Mock(); + _cutover = new Mock(); + _moduleState = new RecordsModuleState { DepartmentId = Dept, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active, LegacyWritesBlocked = true }; + _cutover.Setup(c => c.GetModuleStateAsync(Dept, It.IsAny())).ReturnsAsync(() => _moduleState); + _authorization = new Mock(); + _authorization.Setup(a => a.IsActiveMemberAsync(Me, Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.CanUserViewRecordAsync(Me, It.IsAny(), Dept)).ReturnsAsync(true); + + _http = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, Me), + new Claim(ClaimTypes.PrimaryGroupSid, Dept.ToString()), + new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View) + }, "test")) + }; + _http.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; + _activity = new Activity("RecordSummariesApiControllerTests").Start(); + _controller = new RecordSummariesController(_summaries.Object, _cutover.Object, _authorization.Object) { ControllerContext = new ControllerContext { HttpContext = _http } }; + } + + [TearDown] + public void TearDown() + { + _activity?.Stop(); + } + + private static RecordOperationalSummaryV1 Summary(string id) => new RecordOperationalSummaryV1 { DepartmentId = Dept, RecordId = id, RecordKind = RmsRecordKind.Operational, RevisionId = id + "-r1", RevisionChecksum = "abc", CorrectionStatus = RecordOperationalSummaryCorrectionStatus.Current }; + + [Test] + public async Task Flag_off_hides_both_endpoints() + { + _moduleState.FlagEnabled = false; + (await _controller.Get("rec")).Result.Should().BeOfType(); + (await _controller.List()).Result.Should().BeOfType(); + _summaries.VerifyNoOtherCalls(); + } + + [Test] + public async Task Get_returns_the_summary_only_for_a_visible_record() + { + _summaries.Setup(s => s.BuildAsync(Dept, "rec", RmsRecordKind.Operational, null)).ReturnsAsync(Summary("rec")); + var ok = (await _controller.Get("rec")).Result.Should().BeOfType().Which; + ((RecordOperationalSummaryResult)ok.Value).Data.RecordId.Should().Be("rec"); + + _authorization.Setup(a => a.CanUserViewRecordAsync(Me, "hidden", Dept)).ReturnsAsync(false); + (await _controller.Get("hidden")).Result.Should().BeOfType("a record the member cannot see is indistinguishable from a missing one"); + _summaries.Verify(s => s.BuildAsync(Dept, "hidden", It.IsAny(), It.IsAny()), Times.Never); + + (await _controller.Get(" ")).Result.Should().BeOfType(); + } + + [Test] + public async Task Get_reports_a_checksum_failure_as_a_conflict() + { + _summaries.Setup(s => s.BuildAsync(Dept, "rec", RmsRecordKind.Operational, "r9")).ThrowsAsync(new InvalidOperationException("The revision checksum does not match; the summary cannot be trusted.")); + var problem = (await _controller.Get("rec", RmsRecordKind.Operational, "r9")).Result.Should().BeOfType().Which; + problem.StatusCode.Should().Be(409); + } + + [Test] + public async Task List_omits_rows_the_member_cannot_see_and_passes_paging_through() + { + _summaries.Setup(s => s.QueryAsync(Dept, It.Is(q => q.Take == 2 && q.Cursor == "ros1:x" && q.RecordKind == RmsRecordKind.IncidentReport && q.ChangedSince.HasValue))) + .ReturnsAsync(new RecordOperationalSummaryPage { Items = new List { Summary("a"), Summary("b") }, HasMore = true, NextCursor = "ros1:next" }); + _authorization.Setup(a => a.CanUserViewRecordAsync(Me, "b", Dept)).ReturnsAsync(false); + + var ok = (await _controller.List(1000, 2, "ros1:x", RmsRecordKind.IncidentReport)).Result.Should().BeOfType().Which; + var result = (RecordOperationalSummariesResult)ok.Value; + result.Data.Select(d => d.RecordId).Should().Equal("a"); + result.HasMore.Should().BeTrue(); + result.NextCursor.Should().Be("ros1:next"); + result.ContractVersion.Should().Be(1); + result.PageSize.Should().Be(1); + } + + [Test] + public async Task List_rejects_a_bad_cursor_and_a_bad_since() + { + _summaries.Setup(s => s.QueryAsync(Dept, It.IsAny())).ThrowsAsync(new ArgumentException("bad cursor")); + (await _controller.List(0, 10, "garbage")).Result.Should().BeOfType(); + (await _controller.List(-1)).Result.Should().BeOfType(); + } + + [Test] + public async Task List_before_activation_is_empty_rather_than_an_error() + { + _moduleState.Activated = false; + var ok = (await _controller.List()).Result.Should().BeOfType().Which; + ((RecordOperationalSummariesResult)ok.Value).Data.Should().BeEmpty(); + _summaries.VerifyNoOtherCalls(); + } + + [Test] + public async Task A_former_member_sees_nothing() + { + _authorization.Setup(a => a.IsActiveMemberAsync(Me, Dept)).ReturnsAsync(false); + _summaries.Setup(s => s.BuildAsync(Dept, "rec", RmsRecordKind.Operational, null)).ReturnsAsync(Summary("rec")); + (await _controller.Get("rec")).Result.Should().BeOfType(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/browser-launch.cjs b/Tests/Resgrid.Tests/Web/browser-launch.cjs new file mode 100644 index 00000000..89a4efe0 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/browser-launch.cjs @@ -0,0 +1,23 @@ +// Shared browser launch for the headless script tests in this folder. +// +// Playwright resolves from RESGRID_PLAYWRIGHT_PATH, then this folder's own node_modules +// (`npm ci` here installs the pinned version), then a global install. RESGRID_PLAYWRIGHT_CHANNEL +// picks the browser: msedge (default, uses the Edge already installed on a developer machine) or +// chromium (Playwright's bundled build, which is what CI installs with `playwright install chromium`). +const path = require('node:path'); + +function playwright() { + const candidates = [process.env.RESGRID_PLAYWRIGHT_PATH, path.join(__dirname, 'node_modules', 'playwright'), 'playwright'].filter(Boolean); + let lastError; + for (const candidate of candidates) { + try { return require(candidate); } catch (error) { lastError = error; } + } + throw lastError; +} + +function launchOptions() { + const channel = process.env.RESGRID_PLAYWRIGHT_CHANNEL || 'msedge'; + return channel === 'chromium' ? { headless: true } : { channel: channel, headless: true }; +} + +module.exports = { playwright, launchOptions }; diff --git a/Tests/Resgrid.Tests/Web/browser-tests.cjs b/Tests/Resgrid.Tests/Web/browser-tests.cjs new file mode 100644 index 00000000..c633eabc --- /dev/null +++ b/Tests/Resgrid.Tests/Web/browser-tests.cjs @@ -0,0 +1,35 @@ +// Runs every *.test.cjs in this folder in its own node process and reports a summary. +// +// npm test (from Tests/Resgrid.Tests/Web, after `npm ci`) +// node browser-tests.cjs (same; optional arguments filter by file name substring) +// +// `dotnet test` runs the same scripts through BrowserScriptTests.cs, one NUnit case per script, so +// they sit alongside the C# suite locally and in CI. See browser-launch.cjs for the environment +// variables that pick the Playwright install and the browser channel. +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const filters = process.argv.slice(2); +const scripts = fs.readdirSync(__dirname) + .filter((name) => name.endsWith('.test.cjs')) + .filter((name) => filters.length === 0 || filters.some((filter) => name.includes(filter))) + .sort(); + +if (scripts.length === 0) { + console.error('No browser test scripts matched.'); + process.exit(1); +} + +let failed = 0; +for (const script of scripts) { + console.log(`\n=== ${script}`); + const result = spawnSync(process.execPath, [path.join(__dirname, script)], { stdio: 'inherit', cwd: __dirname }); + if (result.status !== 0) { + failed++; + console.error(`--- ${script} FAILED (exit ${result.status === null ? result.signal : result.status})`); + } +} + +console.log(`\n${scripts.length - failed} of ${scripts.length} browser test scripts passed.`); +process.exit(failed === 0 ? 0 : 1); diff --git a/Tests/Resgrid.Tests/Web/neris-guided-form.test.cjs b/Tests/Resgrid.Tests/Web/neris-guided-form.test.cjs index 4ddc616d..37a3b35f 100644 --- a/Tests/Resgrid.Tests/Web/neris-guided-form.test.cjs +++ b/Tests/Resgrid.Tests/Web/neris-guided-form.test.cjs @@ -1,12 +1,12 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { chromium } = require(process.env.RESGRID_PLAYWRIGHT_PATH || 'playwright'); +const { chromium } = require('./browser-launch.cjs').playwright(); const root = path.resolve(__dirname, '../../..'); const script = fs.readFileSync(path.join(root, 'Web/Resgrid.Web/wwwroot/js/neris-guided-form.js'), 'utf8'); const schemas = JSON.parse(fs.readFileSync(path.join(root, 'Providers/Resgrid.Providers.Neris/Contract/neris-openapi-v1.4.78-2026-09-03.json'), 'utf8')).components.schemas; (async () => { - const browser = await chromium.launch({ channel: 'msedge', headless: true }); + const browser = await chromium.launch(require('./browser-launch.cjs').launchOptions()); try { const page = await browser.newPage(); await page.setContent('
'); diff --git a/Tests/Resgrid.Tests/Web/package-lock.json b/Tests/Resgrid.Tests/Web/package-lock.json new file mode 100644 index 00000000..382c588e --- /dev/null +++ b/Tests/Resgrid.Tests/Web/package-lock.json @@ -0,0 +1,44 @@ +{ + "name": "resgrid-web-browser-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "resgrid-web-browser-tests", + "version": "1.0.0", + "devDependencies": { + "playwright": "1.63.0" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/Tests/Resgrid.Tests/Web/package.json b/Tests/Resgrid.Tests/Web/package.json new file mode 100644 index 00000000..8275deb0 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/package.json @@ -0,0 +1,12 @@ +{ + "name": "resgrid-web-browser-tests", + "version": "1.0.0", + "private": true, + "description": "Headless browser tests for Resgrid.Web scripts (record authoring, NERIS guided form, ADP reveal). Run through BrowserScriptTests in Resgrid.Tests or directly with npm test.", + "scripts": { + "test": "node browser-tests.cjs" + }, + "devDependencies": { + "playwright": "1.63.0" + } +} diff --git a/Tests/Resgrid.Tests/Web/record-authoring.test.cjs b/Tests/Resgrid.Tests/Web/record-authoring.test.cjs index 425d047d..48e48180 100644 --- a/Tests/Resgrid.Tests/Web/record-authoring.test.cjs +++ b/Tests/Resgrid.Tests/Web/record-authoring.test.cjs @@ -1,9 +1,9 @@ const assert = require('node:assert/strict'); const path = require('node:path'); -const { chromium } = require(process.env.RESGRID_PLAYWRIGHT_PATH || 'playwright'); +const { chromium } = require('./browser-launch.cjs').playwright(); const root = path.resolve(__dirname, '../../..'); (async () => { - const browser = await chromium.launch({ channel: 'msedge', headless: true }); + const browser = await chromium.launch(require('./browser-launch.cjs').launchOptions()); try { const page = await browser.newPage(); await page.clock.install(); diff --git a/Tests/Resgrid.Tests/Web/resgrid-adp-reveal.test.cjs b/Tests/Resgrid.Tests/Web/resgrid-adp-reveal.test.cjs new file mode 100644 index 00000000..283470e7 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/resgrid-adp-reveal.test.cjs @@ -0,0 +1,155 @@ +// ADP reveal module (plan 7.2 / RMS plan 5.9.3): bound forms, the expiry warning and in-place +// re-verification. Runs the real module in headless Edge with a fake clock; jQuery is real, +// Bootstrap's modal and the server endpoints are stubbed. +// node Tests/Resgrid.Tests/Web/resgrid-adp-reveal.test.cjs (or through BrowserScriptTests / npm test) +// See browser-launch.cjs for RESGRID_PLAYWRIGHT_PATH and RESGRID_PLAYWRIGHT_CHANNEL. +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { chromium } = require('./browser-launch.cjs').playwright(); +const root = path.resolve(__dirname, '../../..'); +const jquery = path.join(root, 'Web/Resgrid.Web/wwwroot/lib/jquery/dist/jquery.min.js'); +const module_ = path.join(root, 'Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js'); +const MINUTE = 60 * 1000; + +const chrome = ` +
+
`; + +async function harness(page, html, options) { + await page.clock.install({ time: new Date('2026-09-05T12:00:00Z') }); + await page.setContent(chrome + html); + await page.addScriptTag({ path: jquery }); + await page.evaluate(() => { + window.posts = []; window.ajaxCalls = []; window.modalCalls = []; window.submits = []; window.natural = []; + window.renewed = 0; window.cancelled = 0; + $.fn.modal = function (action) { window.modalCalls.push(action); if (action === 'hide') this.trigger('hidden.bs.modal'); return this; }; + $.post = function (url, data) { var d = $.Deferred(); window.posts.push({ url: url, data: data, d: d }); return d.promise(); }; + $.ajax = function (options) { var d = $.Deferred(); window.ajaxCalls.push({ options: options, d: d }); return d.promise(); }; + // The module re-dispatches a held submit through requestSubmit; the stub dispatches a real + // SubmitEvent so every listener (the module's pass-through included) sees it, and records the outcome. + HTMLFormElement.prototype.requestSubmit = function (submitter) { + // The harness's own guard listener cancels every submit (a real one would navigate), so the + // module's decision is read from window.natural, recorded before that guard runs. + this.dispatchEvent(new SubmitEvent('submit', { submitter: submitter || null, bubbles: true, cancelable: true })); + window.submits.push({ id: this.id, submitter: submitter ? submitter.name : null }); + }; + }); + await page.addScriptTag({ path: module_ }); + await page.evaluate((options) => { + resgridAdpReveal.init(Object.assign({ + verifyUrl: '/verify', requestGrantUrl: '/request', revealUrl: '/reveal', revealData: { callId: '1' }, + antiForgeryToken: 'af', messages: {}, warnBeforeSeconds: 120 + }, options)); + document.querySelectorAll('form').forEach(function (form) { + // Registered after the module so it sees the module's decision; a natural submit would navigate the page. + form.addEventListener('submit', function (e) { window.natural.push(!e.defaultPrevented); e.preventDefault(); }); + form.addEventListener('adp:grant-renewed', function () { window.renewed++; }); + form.addEventListener('adp:submit-cancelled', function () { window.cancelled++; }); + }); + }, options); +} + +const resolvePost = (page, index, response) => page.evaluate(([i, r]) => posts[i].d.resolve(r), [index, response]); +const field = (page, name) => page.evaluate((n) => { var i = document.querySelector('input[name="' + n + '"]'); return i ? i.value : null; }, name); +const warningText = (page) => page.evaluate(() => { var el = document.getElementById('adpExpiryWarning'); return el && el.style.display !== 'none' ? el.textContent : null; }); +const expiresIn = (page, minutes) => page.evaluate((m) => new Date(Date.now() + m * 60000).toISOString(), minutes); + +(async () => { + const browser = await chromium.launch(require('./browser-launch.cjs').launchOptions()); + try { + // ---- Bound form: held submit, step-up in place, same submit re-dispatched with the grant ---- + let page = await browser.newPage(); + await harness(page, `
`, { bindForms: ['#f'] }); + await page.click('#go'); + assert.deepEqual(await page.evaluate(() => natural), [false], 'a submit without a grant is held'); + assert.equal(await page.evaluate(() => posts[0].url), '/request', 'the exempt-app grant is asked for first'); + await resolvePost(page, 0, { success: false, error: 'step_up_required' }); + assert.deepEqual(await page.evaluate(() => modalCalls), ['show'], 'the prompt opens when no exemption applies'); + await page.fill('#adpStepUpCode', '123456'); + await page.click('#adpStepUpSubmit'); + assert.equal(await page.evaluate(() => posts[1].url), '/verify'); + await resolvePost(page, 1, { success: true, grantToken: 'T1', expiresOnUtc: await expiresIn(page, 15) }); + assert.equal(await field(page, '__ResgridProtectedGrant'), 'T1', 'the grant is written into the form'); + assert.match(await field(page, '__ResgridProtectedGrantExpiresOn'), /^2026-09-05T12:15:00/); + assert.deepEqual(await page.evaluate(() => submits), [{ id: 'f', submitter: 'Go' }], 'the same submit, same button, is re-dispatched once'); + assert.deepEqual(await page.evaluate(() => natural), [false, true], 'the re-dispatched submit passes through with the grant'); + assert.equal(await field(page, 'Note'), 'typed work'); + assert.equal(await page.evaluate(() => renewed), 1); + assert.equal(await page.evaluate(() => modalCalls.slice(-1)[0]), 'hide'); + assert.equal(await page.evaluate(() => cancelled), 0, 'a completed prompt is not a cancellation'); + assert.equal(await page.evaluate(() => resgridAdpReveal.hasLiveGrant()), true); + + // ---- Expiry warning with countdown; renewal in place keeps the grant and the form ---- + assert.equal(await warningText(page), null, 'no warning long before expiry'); + await page.clock.fastForward(13 * MINUTE); + assert.match(await warningText(page) || '', /expires in 2:00/); + await page.clock.fastForward(30 * 1000); + assert.match(await warningText(page) || '', /expires in 1:30/); + await page.click('#adpRenewButton'); + assert.equal(await page.evaluate(() => posts[2].url), '/request'); + await resolvePost(page, 2, { success: true, grantToken: 'T2', expiresOnUtc: await expiresIn(page, 15) }); + assert.equal(await warningText(page), null, 'renewal hides the warning'); + assert.equal(await field(page, '__ResgridProtectedGrant'), 'T2', 'bound forms carry the renewed grant'); + assert.equal(await page.evaluate(() => renewed), 2); + assert.equal(await page.evaluate(() => modalCalls.filter(c => c === 'show').length), 1, 'an exempt renewal needs no prompt'); + await page.click('#go'); + assert.deepEqual(await page.evaluate(() => natural.slice(-1)), [true], 'a live grant lets the submit through directly'); + + // ---- Expiry without renewal: field cleared, typed work kept, expired warning offered ---- + await page.clock.fastForward(16 * MINUTE); + assert.match(await warningText(page) || '', /has expired/); + assert.equal(await field(page, '__ResgridProtectedGrant'), '', 'an expired token is not left in the form'); + assert.equal(await field(page, 'Note'), 'typed work', 'expiry never touches typed work'); + assert.equal(await page.evaluate(() => resgridAdpReveal.hasLiveGrant()), false); + + // ---- Cancelling the prompt leaves the form alone and tells the host ---- + await page.click('#go'); + assert.deepEqual(await page.evaluate(() => natural.slice(-1)), [false]); + await resolvePost(page, 3, { success: false, error: 'step_up_required' }); + await page.evaluate(() => $('#adpStepUpModal').modal('hide')); + assert.equal(await page.evaluate(() => cancelled), 1); + assert.equal(await page.evaluate(() => submits.length), 1, 'nothing was re-dispatched'); + + // ---- The host reports a refused save (autosave): expired warning without waiting for the clock ---- + await page.evaluate(() => document.getElementById('f').dispatchEvent(new CustomEvent('adp:grant-required'))); + assert.match(await warningText(page) || '', /has expired/); + await page.close(); + + // ---- Server-held grant (a *Revealed page): live submits pass, warning runs, expiry clears ---- + page = await browser.newPage(); + await harness(page, `
`, + { revealUrl: null, bindForms: ['#f'], grantExpiresOnUtc: new Date(Date.parse('2026-09-05T12:00:00Z') + 5 * MINUTE).toISOString() }); + assert.equal(await page.evaluate(() => resgridAdpReveal.hasLiveGrant()), true, 'the page trusts the expiry it was rendered with'); + await page.click('#go'); + assert.deepEqual(await page.evaluate(() => natural.slice(-1)), [true]); + assert.equal(await field(page, '__ResgridProtectedGrant'), 'S1', 'the server-issued token is kept as rendered'); + await page.clock.fastForward(3 * MINUTE + 1000); + assert.match(await warningText(page) || '', /expires in 1:59/); + await page.clock.fastForward(2 * MINUTE); + assert.match(await warningText(page) || '', /has expired/); + assert.equal(await field(page, '__ResgridProtectedGrant'), ''); + await page.click('#go'); + assert.deepEqual(await page.evaluate(() => natural.slice(-1)), [false], 'after expiry the submit is held again'); + await page.close(); + + // ---- Reveal-only page: values conceal at expiry, warning offered before, hidden after ---- + page = await browser.newPage(); + await harness(page, ``, {}); + await page.click('#adpRevealButton'); + await resolvePost(page, 0, { success: true, grantToken: 'R1', expiresOnUtc: await expiresIn(page, 10) }); + assert.equal(await page.evaluate(() => ajaxCalls[0].options.url), '/reveal'); + assert.equal(await page.evaluate(() => ajaxCalls[0].options.headers['X-Resgrid-Protected-Grant']), 'R1'); + await page.evaluate(() => ajaxCalls[0].d.resolve({ success: true, fields: { 'calls.notes:1': 'secret' } })); + assert.equal(await page.inputValue('#notes'), 'secret'); + await page.clock.fastForward(9 * MINUTE); + assert.match(await warningText(page) || '', /expires in 1:00/); + await page.clock.fastForward(2 * MINUTE); + assert.equal(await page.inputValue('#notes'), 'REDACTED', 'revealed values leave the DOM at expiry'); + assert.equal(await warningText(page), null, 'a page without a form has nothing to hold; the banner takes over'); + await page.close(); + + console.log('resgrid-adp-reveal.test.cjs passed'); + } finally { + await browser.close(); + } +})().catch((error) => { console.error(error); process.exit(1); }); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs index 8ceba730..e67b536b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs @@ -40,13 +40,16 @@ public class IncidentReportsController : V4AuthenticatedApiControllerbase, IActi private readonly IRecordsApiIdempotencyService _idempotency; private readonly IIncidentAnalysisService _analysis; private readonly IRecordsSubmissionService _submissionWorker; + private readonly IRecordsNfirsLegacyService _nfirs; private SystemPrincipalRecordGrant _systemGrant; private bool _systemGrantResolved; public IncidentReportsController(IIncidentReportsService incidentReports, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, - INerisProfileService neris, IFeatureToggleService featureToggleService, IRecordsApiIdempotencyService idempotency, IIncidentAnalysisService analysis, IRecordsSubmissionService submissionWorker) + INerisProfileService neris, IFeatureToggleService featureToggleService, IRecordsApiIdempotencyService idempotency, IIncidentAnalysisService analysis, IRecordsSubmissionService submissionWorker, + IRecordsNfirsLegacyService nfirs) { + _nfirs = nfirs; _incidentReports = incidentReports; _cutoverService = cutoverService; _recordsAuthorizationService = recordsAuthorizationService; @@ -233,6 +236,40 @@ public async Task> GetForCall(int callId) return Ok(await WrapAsync(aggregate)); } + /// + /// Read-only NFIRS Basic Module rendering and crosswalk for a Call (RMS-3). Values are read from what the department + /// already holds; nothing is imported or authored. A system principal is refused: this is a member-facing report, and + /// the source-Call rule inside the service is a member rule. + /// + [HttpGet("GetNfirsLegacy")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task> GetNfirsLegacy(int callId) + { + if (!await FlagOnAsync()) + return NotFound(); + if (RecordsSystemPrincipal.IsSystemPrincipal(User)) + return Forbid(); + + NfirsLegacyRendering rendering; + try + { + rendering = await _nfirs.RenderAsync(DepartmentId, UserId, callId); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + if (rendering == null) + return NotFound(); + + var result = new NfirsLegacyResult { Data = rendering, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + #endregion #region Authoring diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs index 509a2862..7a67c07b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs @@ -306,6 +306,7 @@ private async Task WrapAsync(RmsEvidenceArtifact artifact, } #endregion - private Task CanViewRestrictedAsync() => _recordsAuthorizationService.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ViewRestrictedRecords); + private async Task CanViewRestrictedAsync() => ClaimsAuthorizationHelper.CanViewRestrictedRecords() + && await _recordsAuthorizationService.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ViewRestrictedRecords); } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordLegalHoldsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordLegalHoldsController.cs index 1ff77306..04923ed0 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordLegalHoldsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordLegalHoldsController.cs @@ -26,6 +26,7 @@ public async Task GetHolds() public async Task Place([FromBody] RmsRecordLegalHold input, CancellationToken cancellationToken) { if (!(await _cutover.GetModuleStateAsync(DepartmentId)).FlagEnabled) return NotFound(); + if (input == null) return BadRequest(); try { return StatusCode(201, await _holds.PlaceAsync(DepartmentId, UserId, input, cancellationToken)); } catch (UnauthorizedAccessException) { return Forbid(); } catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { return Problem(ex.Message, statusCode: 409); } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordSummariesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordSummariesController.cs new file mode 100644 index 00000000..de7e8181 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordSummariesController.cs @@ -0,0 +1,172 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// RecordOperationalSummaryV1 over the v4 contract (RMS plan sections 5.1 and 4.7): the authorization-scoped, + /// paged, department-scoped read API that Billing, deployment and customer BI tools use instead of a database + /// credential. Same gates as Records: flag first, then per-record visibility for a member or the configured + /// grant for a system principal; a row the caller cannot see is omitted, never tombstoned, because the feed + /// carries facts rather than sync state. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class RecordSummariesController : V4AuthenticatedApiControllerbase, IActionFilter + { + private readonly IRecordOperationalSummaryService _summaries; + private readonly IRecordsCutoverService _cutoverService; + private readonly IRecordsAuthorizationService _recordsAuthorizationService; + + private SystemPrincipalRecordGrant _systemGrant; + private bool _systemGrantResolved; + + public RecordSummariesController(IRecordOperationalSummaryService summaries, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService) + { + _summaries = summaries; + _cutoverService = cutoverService; + _recordsAuthorizationService = recordsAuthorizationService; + } + + /// A system principal with no configured Record grant for the resolved department is refused before any action runs. + public void OnActionExecuting(ActionExecutingContext context) + { + if (IsSystemPrincipal && SystemGrant == null) + context.Result = Problem(statusCode: StatusCodes.Status403Forbidden, + title: "This system principal has no configured Record grant for this department.", type: "record_grant_missing"); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + /// The summary for one record's official revision, or a specific revision a consumer pinned earlier. + [HttpGet("Get")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = ResgridResources.Record_View)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task> Get(string recordId, RmsRecordKind kind = RmsRecordKind.Operational, string revisionId = null) + { + if (string.IsNullOrWhiteSpace(recordId)) + return BadRequest(); + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + if (!await CanViewRecordAsync(recordId)) + return NotFound(); + + RecordOperationalSummaryV1 summary; + try + { + summary = await _summaries.BuildAsync(DepartmentId, recordId, kind, revisionId); + } + catch (InvalidOperationException ex) + { + return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message); + } + if (summary == null) + return NotFound(); + + var result = new RecordOperationalSummaryResult { Data = summary, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + /// + /// Summaries for records whose projection changed after (Unix ms), oldest first. Follow + /// until HasMore is false. Voided and superseded rows are + /// included so a consumer that pinned a revision learns it must re-read. + /// + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Record_View)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task> List(long since = 0, int take = 50, string cursor = null, RmsRecordKind? kind = null) + { + if (since < 0 || since > DateTimeOffset.MaxValue.ToUnixTimeMilliseconds()) + return BadRequest(); + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + + var result = new RecordOperationalSummariesResult(); + if (moduleState.RecordsUsable) + { + RecordOperationalSummaryPage page; + try + { + page = await _summaries.QueryAsync(DepartmentId, new RecordOperationalSummaryQuery + { + ChangedSince = RecordsApiHelper.FromUnixMs(since), + Cursor = cursor, + Take = Math.Max(1, Math.Min(RecordOperationalSummaryQuery.MaxTake, take)), + RecordKind = kind + }); + } + catch (ArgumentException) + { + return BadRequest(); + } + catch (InvalidOperationException ex) + { + return Problem(statusCode: StatusCodes.Status409Conflict, title: ex.Message); + } + + foreach (var summary in page.Items) + { + if (await CanViewRecordAsync(summary.RecordId)) + result.Data.Add(summary); + } + result.HasMore = page.HasMore; + result.NextCursor = page.NextCursor; + } + + result.PageSize = result.Data.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + + #region Principal helpers + + private SystemPrincipalRecordGrant SystemGrant + { + get + { + if (!_systemGrantResolved) + { + _systemGrant = RecordsSystemPrincipal.ResolveGrant(User, DepartmentId); + _systemGrantResolved = true; + } + + return _systemGrant; + } + } + + private bool IsSystemPrincipal => RecordsSystemPrincipal.IsSystemPrincipal(User); + + private async Task CanViewRecordAsync(string recordId) + { + var grant = SystemGrant; + if (grant != null) + return await _recordsAuthorizationService.CanSystemPrincipalViewRecordAsync(grant, recordId); + + return await _recordsAuthorizationService.IsActiveMemberAsync(UserId, DepartmentId) + && await _recordsAuthorizationService.CanUserViewRecordAsync(UserId, recordId, DepartmentId); + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/HttpProtectedGrantContext.cs b/Web/Resgrid.Web.Services/Helpers/HttpProtectedGrantContext.cs new file mode 100644 index 00000000..807d7c25 --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/HttpProtectedGrantContext.cs @@ -0,0 +1,64 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// Request-bound (ADP plan 3.3 / 7.2): the caller's Protected Data + /// Grant is the X-Resgrid-Protected-Grant header of the current request, and a request without an + /// authenticated user is a workload (relay or system principal). Registered after ServicesModule so it + /// replaces the workload default the module ships for hosts without requests. + /// + public sealed class HttpProtectedGrantContext : IProtectedGrantContext + { + public const string HeaderName = "X-Resgrid-Protected-Grant"; + public const string FormFieldName = "__ResgridProtectedGrant"; + + private readonly IHttpContextAccessor _accessor; + + public HttpProtectedGrantContext(IHttpContextAccessor accessor) + { + _accessor = accessor; + } + + public string GrantToken + { + get + { + var context = _accessor?.HttpContext; + if (context == null) + return null; + string value = context.Request.Headers[HeaderName]; + if (string.IsNullOrWhiteSpace(value) && context.Request.HasFormContentType) + { + // A full-page form post cannot carry a header; the reveal module writes the grant into this + // hidden field on the edit pages that need it (RMS plan section 5.9.3). + value = context.Request.Form[FormFieldName]; + } + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + } + + public string UserId + { + get + { + var user = _accessor?.HttpContext?.User; + if (user?.Identity == null || !user.Identity.IsAuthenticated) + return null; + // Resgrid identities carry the user id as PrimarySid (ClaimsAuthorizationHelper.GetUserId); NameIdentifier is the API fallback. + return user.FindFirst(ClaimTypes.PrimarySid)?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + } + + public bool IsWorkloadCaller + { + get + { + var context = _accessor?.HttpContext; + return context?.User?.Identity == null || !context.User.Identity.IsAuthenticated; + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs index 09f2c67e..7691319d 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Records/IncidentReportsApiModels.cs @@ -38,6 +38,12 @@ public class IncidentReportResult : StandardApiResponseV4Base public IncidentReportData Data { get; set; } } + /// Read-only NFIRS rendering and crosswalk for one Call (RMS-3); the model is the contract. + public class NfirsLegacyResult : StandardApiResponseV4Base + { + public NfirsLegacyRendering Data { get; set; } + } + /// A hydrated NERIS incident report: header, dispatch facts, sections, provenance, validation issues, sanitized submission history. public class IncidentReportData { diff --git a/Web/Resgrid.Web.Services/Models/v4/Records/RecordSummariesApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Records/RecordSummariesApiModels.cs new file mode 100644 index 00000000..9e627ca5 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Records/RecordSummariesApiModels.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Services.Models.v4.Records +{ + /// One RecordOperationalSummaryV1 (RMS plan section 5.1); the model class is the versioned contract. + public class RecordOperationalSummaryResult : StandardApiResponseV4Base + { + public RecordOperationalSummaryV1 Data { get; set; } + } + + /// Paged summary feed for downstream consumers and customer analytics egress (RMS plan section 4.7). + public class RecordOperationalSummariesResult : StandardApiResponseV4Base + { + public List Data { get; set; } = new List(); + + public bool HasMore { get; set; } + + /// Opaque cursor for the next page; absent at the end of the feed. + public string NextCursor { get; set; } + + /// Contract version every row in conforms to. + public int ContractVersion { get; set; } = RecordOperationalSummaryV1.CurrentContractVersion; + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index eb1206ed..b0944d41 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -2039,6 +2039,13 @@ The authoritative report for a Call, if one exists. + + + Read-only NFIRS Basic Module rendering and crosswalk for a Call (RMS-3). Values are read from what the department + already holds; nothing is imported or authored. A system principal is refused: this is a member-facing report, and + the source-Call rule inside the service is a member rule. + + Starts the report for a Call with dispatch prefill (SingleAuthoritative): a second start returns the existing report with 200. @@ -2751,6 +2758,28 @@ Loads a record only when the caller passes the per-record visibility rule; a refusal is audited as Denied. + + + RecordOperationalSummaryV1 over the v4 contract (RMS plan sections 5.1 and 4.7): the authorization-scoped, + paged, department-scoped read API that Billing, deployment and customer BI tools use instead of a database + credential. Same gates as Records: flag first, then per-record visibility for a member or the configured + grant for a system principal; a row the caller cannot see is omitted, never tombstoned, because the feed + carries facts rather than sync state. + + + + A system principal with no configured Record grant for the resolved department is refused before any action runs. + + + The summary for one record's official revision, or a specific revision a consumer pinned earlier. + + + + Summaries for records whose projection changed after (Unix ms), oldest first. Follow + until HasMore is false. Voided and superseded rows are + included so a consumer that pinned a revision learns it must re-read. + + Reporting and analytics for the caller's department: a composite dashboard, realtime personnel/ @@ -5968,6 +5997,14 @@ part of the shallow /health liveness endpoint. + + + Request-bound (ADP plan 3.3 / 7.2): the caller's Protected Data + Grant is the X-Resgrid-Protected-Grant header of the current request, and a request without an + authenticated user is a workload (relay or system principal). Registered after ServicesModule so it + replaces the workload default the module ships for hosts without requests. + + TEMPORARY (RG-T132): serialises a UTC instant WITHOUT the trailing "Z". @@ -12243,6 +12280,9 @@ Draft save; every list replaces the draft rows, except a null list which leaves that set alone. + + Read-only NFIRS rendering and crosswalk for one Call (RMS-3); the model is the contract. + A hydrated NERIS incident report: header, dispatch facts, sections, provenance, validation issues, sanitized submission history. @@ -12457,6 +12497,18 @@ Crosswalk gap report: an unmapped or stale local code is a filing that will need manual work. + + One RecordOperationalSummaryV1 (RMS plan section 5.1); the model class is the versioned contract. + + + Paged summary feed for downstream consumers and customer analytics egress (RMS plan section 4.7). + + + Opaque cursor for the next page; absent at the end of the feed. + + + Contract version every row in conforms to. + Composite dashboard report (scalar totals, dense series, breakdowns). diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index a88c723d..506b20c4 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -680,6 +680,7 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterModule(new DataModule()); builder.RegisterModule(new NoSqlDataModule()); builder.RegisterModule(new ServicesModule()); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterModule(new Resgrid.Search.SearchModule()); builder.RegisterModule(new Resgrid.Providers.Scanning.ScanningProviderModule()); builder.RegisterModule(new Resgrid.Providers.Neris.NerisProviderModule()); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index fcf7dd21..4b35b696 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -77,6 +77,7 @@ public class DispatchController : SecureBaseController private readonly IFeatureToggleService _featureToggleService; private readonly IProtectedReadService _protectedReadService; private readonly IRecordsCutoverService _recordsCutoverService; + private readonly IRecordsProtectionService _recordsProtection; public DispatchController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, @@ -90,7 +91,7 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService ICallDispatchStatusService callDispatchStatusService, IModerationService moderationService, IStringLocalizer dispatchLocalizer, IStringLocalizer commonLocalizer, IDispatchRecommendationService dispatchRecommendationService, IFeatureToggleService featureToggleService, - IProtectedReadService protectedReadService, IRecordsCutoverService recordsCutoverService) + IProtectedReadService protectedReadService, IRecordsCutoverService recordsCutoverService, IRecordsProtectionService recordsProtection) { _departmentsService = departmentsService; _usersService = usersService; @@ -126,6 +127,7 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService _featureToggleService = featureToggleService; _protectedReadService = protectedReadService; _recordsCutoverService = recordsCutoverService; + _recordsProtection = recordsProtection; } #endregion Private Members and Constructors @@ -3354,6 +3356,7 @@ private async Task FillViewCallView(ViewCallView model) // The Incident Report control posts to IncidentReports/Start, which 404s when Records is not usable for // the department; the button has to know that as well as whether the member may create a record. model.RecordsUsable = (await _recordsCutoverService.GetModuleStateAsync(model.Department.DepartmentId)).RecordsUsable; + model.ProtectionEnforced = model.RecordsUsable && await _recordsProtection.IsEnforcedAsync(model.Department.DepartmentId); model.UnGroupedUsers = new List(); model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(model.Call.DepartmentId, model.Call.CallId)).OrderBy(y => y.Timestamp).ToList(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs index 799f0be8..3949ce03 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs @@ -47,13 +47,21 @@ public class IncidentReportsController : SecureBaseController private readonly IIncidentAnalysisService _analysis; private readonly IRecordsEvidenceService _evidence; private readonly IIncidentAttachmentsService _attachments; + private readonly IRecordsNfirsLegacyService _nfirs; private readonly IStringLocalizer _localizer; + private readonly IRecordsProtectionService _protection; + private readonly IProtectedGrantContext _grantContext; + public IncidentReportsController(IIncidentReportsService incidentReports, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, ICallsService callsService, INerisProfileService neris, IRmsSubmissionsRepository submissions, IIncidentAnalysisService analysis, IRecordsEvidenceService evidence, - IStringLocalizer localizer, IRecordsSubmissionService submissionWorker, IIncidentAttachmentsService attachments, IRecordsUdfService udf) + IStringLocalizer localizer, IRecordsSubmissionService submissionWorker, IIncidentAttachmentsService attachments, IRecordsUdfService udf, + IRecordsNfirsLegacyService nfirs, IRecordsProtectionService protection, IProtectedGrantContext grantContext) { + _protection = protection; + _grantContext = grantContext; + _nfirs = nfirs; _incidentReports = incidentReports; _cutoverService = cutoverService; _recordsAuthorizationService = recordsAuthorizationService; @@ -248,6 +256,38 @@ public async Task ForCall(int callId) return RedirectToAction("Index"); } + /// + /// Read-only NFIRS Basic Module rendering and crosswalk for a Call (RMS-3, plan section 4.3). Rendered from data the + /// department already holds; there is no NFIRS import, authoring or submission path. Source-Call authorization is + /// applied inside the service, and the Call's department is checked before anything is read. + /// + [HttpGet] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task NfirsLegacy(int callId) + { + var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) + return NotFound(); + + NfirsLegacyRendering rendering; + try + { + rendering = await _nfirs.RenderAsync(DepartmentId, UserId, callId); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + if (rendering == null) + return NotFound(); + + return View(new NfirsLegacyView + { + Rendering = rendering, + Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false) + }); + } + #endregion #region Reads @@ -266,6 +306,46 @@ public async Task Details(string id) return View(model); } + /// ADP client-side reveal (plan 7.2; RMS plan 5.9.3): the resolved values keyed the way the Details page marks its cells. + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task RevealIncident([FromForm] string id) + { + var aggregate = await LoadAuthorizedAsync(id); + if (aggregate == null) + return NotFound(); + + var protection = aggregate.Protection ?? new ProtectedReadResult(); + if (protection.IsProtected && protection.ProtectedReason != null) + return Json(new { success = false, error = protection.ProtectedReason }); + + var fields = new Dictionary(); + void Add(IEnumerable rows, Func key, IReadOnlyDictionary Get, Action Set)> accessors) + { + foreach (var row in rows ?? Enumerable.Empty()) + foreach (var accessor in accessors) + fields[$"{accessor.Key}:{key(row)}"] = accessor.Value.Get(row); + } + if (aggregate.Narrative != null) Add(new[] { aggregate.Narrative }, n => n.RmsNarrativeId, RmsProtectedFields.Narratives); + if (aggregate.Location != null) + { + Add(new[] { aggregate.Location }, l => l.RmsLocationId, RmsProtectedFields.Locations); + fields[$"rmslocations.coordinates:{aggregate.Location.RmsLocationId}"] = aggregate.Location.Latitude.HasValue ? aggregate.Location.Latitude + ", " + aggregate.Location.Longitude : "-"; + } + Add(aggregate.Facts, f => f.RmsSourceFactId, RmsProtectedFields.SourceFacts); + Add(aggregate.Exposures, e => e.RmsExposureId, RmsProtectedFields.Exposures); + Add(aggregate.Resources, r => r.RmsIncidentResourceId, RmsProtectedFields.Resources); + Add(aggregate.Modules, m => m.RmsIncidentModuleId, RmsProtectedFields.Modules); + if (await CanViewRestrictedAsync()) + Add(aggregate.Casualties, c => c.RmsCasualtyRescueId, RmsProtectedFields.Casualties); + foreach (var attachment in aggregate.Attachments ?? new List()) + fields[$"rmsrecordattachments.filename:{attachment.RmsRecordAttachmentId}"] = attachment.FileName; + + await _incidentReports.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, "Protected reveal", IpAddressHelper.GetRequestIP(Request, true)); + return Json(new { success = true, fields }); + } + /// The immutable submission artifact (the exact payload sent), for administrators auditing a delivery. [HttpGet] [Authorize(Policy = ResgridResources.Record_Submit)] @@ -327,9 +407,34 @@ public async Task Edit(string id) var model = await BuildEditAsync(aggregate); if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) + model.ErrorMessage = error; return View(model); } + /// + /// Reveal-and-edit (RMS plan section 5.9.3): the reveal module posts the grant here after step-up. The report is + /// hydrated with that grant, so the form (guided sections included) renders plaintext, and the grant is carried + /// in the form's hidden field so the save presents it again. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task EditRevealed(string id) + { + var result = await Edit(id); + if (result is ViewResult view && view.Model is IncidentReportEditView model) + CarryGrant(model); + return result; + } + + /// The grant the current request presented (header or form field) travels with the re-rendered form. + private void CarryGrant(RecordsBaseView model) + { + model.ProtectedGrant = _grantContext.GrantToken; + model.ProtectedGrantExpiresOnUtc = model.ProtectedGrant == null ? null : HttpProtectedGrantContext.ReadExpiry(Request); + } + [HttpPost] [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Record_Create)] @@ -562,6 +667,9 @@ public async Task Settings(NerisSettingsView model, CancellationT profile.GrantType = string.IsNullOrWhiteSpace(model.GrantType) ? NerisGrantTypes.Password : model.GrantType; profile.AutoSubmitOnFinalize = model.AutoSubmitOnFinalize; profile.IsEnabled = model.IsEnabled; + // Protected-egress acknowledgement (RMS plan 5.9.4): a named administrator's decision that sealed + // incident content may leave for this destination through the broker's workload lane. + profile.AllowProtectedContentEgress = model.AllowProtectedContentEgress; // The credential is write-only: any filled field replaces the stored one; all blank keeps it. NerisCredential credential = null; @@ -621,6 +729,10 @@ private async Task BuildSettingsAsync(RecordsModuleState modu GrantType = profile?.GrantType ?? NerisGrantTypes.Password, AutoSubmitOnFinalize = profile?.AutoSubmitOnFinalize ?? false, IsEnabled = profile?.IsEnabled ?? false, + AllowProtectedContentEgress = profile?.AllowProtectedContentEgress ?? false, + ProtectedEgressAcknowledgedOn = profile?.ProtectedEgressAcknowledgedOn, + ProtectedEgressAcknowledgedByUserId = profile?.ProtectedEgressAcknowledgedByUserId, + ProtectionEnforced = await _protection.IsEnforcedAsync(DepartmentId), HasCredential = !string.IsNullOrWhiteSpace(profile?.EncryptedCredentialJson), LastTokenIssuedOn = profile?.LastTokenIssuedOn, LastSuccessfulCallOn = profile?.LastSuccessfulCallOn, @@ -703,6 +815,7 @@ private async Task EditWithErrorAsync(IncidentReportAggregate agg foreach (var field in model.CustomFieldForm.Fields.Where(f => !f.Field.IsReadOnly)) if (submittedFields.Values?.TryGetValue(field.Field.UdfFieldId, out var value) == true) field.Value = value; model.ErrorMessage = error; + CarryGrant(model); return View("Edit", model); } @@ -841,6 +954,7 @@ private async Task BuildEditAsync(IncidentReportAggregat model.ExposureDamageTypes = Codes("exposure_damage"); model.DisplacementCauseCodes = Codes("displace_cause"); model.Personnel = (await PersonnelNamesAsync()).OrderBy(kvp => kvp.Value).Select(kvp => new SelectListItem { Value = kvp.Key, Text = kvp.Value }).ToList(); + model.ApplyProtection(aggregate.Protection); return model; } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs index dea2e286..bbdbcdbd 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs @@ -564,7 +564,9 @@ public async Task View(int logId) if (!String.IsNullOrWhiteSpace(model.WorkLog.InvestigatedByUserId) && !model.PersonnelNames.ContainsKey(model.WorkLog.InvestigatedByUserId)) model.PersonnelNames[model.WorkLog.InvestigatedByUserId] = await UserHelper.GetFullNameForUser(model.WorkLog.InvestigatedByUserId); - if (ClaimsAuthorizationHelper.CanDeleteLog()) + // After Records activation the delete path is denied at the controller and the service (RMS plan section + // 4.1); the page must not offer it either, whoever the viewer is. + if (ClaimsAuthorizationHelper.CanDeleteLog() && !await _recordsCutoverService.AreLegacyWritesBlockedAsync(DepartmentId)) { if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || model.WorkLog.LoggedByUserId == UserId || (model.WorkLog.StationGroupId.HasValue && ClaimsAuthorizationHelper.IsUserGroupAdmin(model.WorkLog.StationGroupId.Value))) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index 9e65b211..7a60e546 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -55,15 +55,20 @@ public class RecordsController : SecureBaseController private readonly IRecordsPrintLayoutService _printLayouts; private readonly IRecordsAccountabilityService _accountability; private readonly IRecordsDashboardService _dashboard; + private readonly IRecordsProtectionService _protection; + private readonly IProtectedGrantContext _grantContext; public RecordsController(IRecordsService recordsService, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, ICallsService callsService, IDepartmentSettingsService departmentSettingsService, IEventAggregator eventAggregator, IStringLocalizer localizer, ICompositeViewEngine viewEngine, IPdfProvider pdfProvider, IRecordsSearchService recordsSearch, IDepartmentDataProtectionService dataProtection, - IDepartmentProfileMediaService branding, IRecordsPrintLayoutService printLayouts, IRecordsAccountabilityService accountability, IRecordsDashboardService dashboard, IRecordsUdfService udf) + IDepartmentProfileMediaService branding, IRecordsPrintLayoutService printLayouts, IRecordsAccountabilityService accountability, IRecordsDashboardService dashboard, IRecordsUdfService udf, + IRecordsProtectionService protection, IProtectedGrantContext grantContext) { _accountability = accountability; + _protection = protection; + _grantContext = grantContext; _recordsService = recordsService; _cutoverService = cutoverService; _recordsAuthorizationService = recordsAuthorizationService; @@ -328,10 +333,28 @@ public async Task New(string definitionKey, int? callId) model.StartedOn = DateTime.UtcNow.TimeConverter(model.Department); if (callId.HasValue) model.DuplicateCandidates = await _recordsService.GetDuplicateCandidatesAsync(DepartmentId, definitionKey, callId.Value); + // A new record has nothing to reveal, but its first save seals the cataloged columns and needs the grant. + model.ProtectionEnforced = await _protection.IsEnforcedAsync(DepartmentId); + ApplyTempDataError(model); return View("Edit", model); } + /// + /// Reveal-and-edit for a new record under Protected Data enforcement (RMS plan section 5.9.3): the reveal module + /// posts the grant here after step-up, and the authoring form renders carrying it so the first save can seal. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task NewRevealed(string definitionKey, int? callId) + { + var result = await New(definitionKey, callId); + if (result is ViewResult view && view.Model is RecordEditView model) + CarryGrant(model); + return result; + } + [HttpPost] [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Record_Create)] @@ -346,6 +369,8 @@ public async Task Create(RecordEditView model, ICollection Edit(string id) if (!CanEditRecord(record)) return Unauthorized(); + var model = await BuildEditAsync(aggregate); + ApplyTempDataError(model); + return View(model); + } + + /// + /// Reveal-and-edit (RMS plan section 5.9.3): the reveal module posts the grant here after step-up. The draft is + /// hydrated through the value seam with that grant, so the form renders plaintext, and the grant is carried in + /// the form's hidden field so the save and every autosave present it again. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_Create)] + public async Task EditRevealed(string id) + { + var result = await Edit(id); + if (result is ViewResult view && view.Model is RecordEditView model) + CarryGrant(model); + return result; + } + + private async Task BuildEditAsync(RecordAggregate aggregate) + { + var record = aggregate.Record; var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var model = new RecordEditView { @@ -428,13 +477,27 @@ public async Task Edit(string id) { if (RmsDefinitionKeys.RestrictedClass.Contains(model.DefinitionKey ?? string.Empty)) foreach (var name in RecordSnapshotSerializer.RestrictedDetailFields) - typeof(RmsOperationalRecordDetail).GetProperty(name).SetValue(model.Details, null); + typeof(RmsOperationalRecordDetail).GetProperty(name)?.SetValue(model.Details, null); } if (model.Details.ActivityOn.HasValue) model.Details.ActivityOn = model.Details.ActivityOn.Value.TimeConverter(department); await PopulateListsAsync(model); - return View(model); + model.ApplyProtection(aggregate.Protection); + return model; + } + + /// The grant the current request presented (header or form field) travels with the re-rendered form. + private void CarryGrant(RecordsBaseView model) + { + model.ProtectedGrant = _grantContext.GrantToken; + model.ProtectedGrantExpiresOnUtc = model.ProtectedGrant == null ? null : HttpProtectedGrantContext.ReadExpiry(Request); + } + + private void ApplyTempDataError(RecordsBaseView model) + { + if (TempData["RecordsError"] is string error && string.IsNullOrEmpty(model.ErrorMessage)) + model.ErrorMessage = error; } [HttpPost] @@ -454,6 +517,8 @@ public async Task Edit(RecordEditView model, ICollection Details(string id) return View(model); } + /// + /// ADP client-side reveal (plan 7.2; RMS plan 5.9.3). The grant rides the request header, so the ordinary + /// authorized load already resolves the aggregate for it; this returns the resolved values keyed the way the + /// Details page marks its cells. A grant proves the caller stepped up, never that they may read this record, + /// so the record is authorized exactly as the page is. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Record_View)] + public async Task RevealRecord([FromForm] string id) + { + var aggregate = await LoadAuthorizedAsync(id); + if (aggregate == null) + return NotFound(); + + var protection = aggregate.Protection ?? new ProtectedReadResult(); + if (protection.IsProtected && protection.ProtectedReason != null) + return Json(new { success = false, error = protection.ProtectedReason }); + + var fields = new Dictionary(); + var details = aggregate.Details; + if (details != null) + { + var restricted = await CanViewRestrictedAsync(); + foreach (var accessor in RmsProtectedFields.Details) + { + // The reveal hides exactly what the page hides: restricted detail columns stay withheld without the grant. + var column = accessor.Key.Substring(accessor.Key.IndexOf('.') + 1); + if (!restricted && RecordSnapshotSerializer.RestrictedDetailFields.Any(f => string.Equals(f, column, StringComparison.OrdinalIgnoreCase))) + continue; + fields[$"{accessor.Key}:{details.RmsOperationalRecordDetailId}"] = accessor.Value.Get(details); + } + } + foreach (var attachment in aggregate.Attachments ?? new List()) + fields[$"rmsrecordattachments.filename:{attachment.RmsRecordAttachmentId}"] = attachment.FileName; + + await _recordsService.RecordAccessAsync(DepartmentId, UserId, id, null, RmsAccessAuditAction.Read, "Protected reveal", IpAddressHelper.GetRequestIP(Request, true)); + return Json(new { success = true, fields }); + } + [HttpGet] [Authorize(Policy = ResgridResources.Record_View)] public IActionResult Revision(string id, string revisionId) => RedirectToAction("Revision", "RecordDocuments", new { id, kind=RmsRecordKind.Operational, revisionId }); @@ -998,6 +1103,17 @@ public async Task Settings(RecordsSettingsView model, Cancellatio searchConfig.IndexNarrative = model.IndexNarrative; await _departmentSettingsService.SetRecordsSearchConfigAsync(DepartmentId, searchConfig, cancellationToken); + // Setting 77 (plan section 4.9): the statutory clock is bounded, the profile must be one the disclosure + // workflow knows, and the release approver must be a current member so a departed user is never the gate. + var disclosure = await _departmentSettingsService.GetRecordsDisclosureConfigAsync(DepartmentId, true) ?? new RecordsDisclosureConfig(); + disclosure.StatutoryClockDays = Math.Max(1, Math.Min(365, model.DisclosureStatutoryClockDays)); + disclosure.DefaultRedactionProfile = RmsRedactionProfiles.IsKnown(model.DisclosureDefaultRedactionProfile) ? model.DisclosureDefaultRedactionProfile : RmsRedactionProfiles.Standard; + var approver = string.IsNullOrWhiteSpace(model.DisclosureReleaseApproverUserId) ? null : model.DisclosureReleaseApproverUserId.Trim(); + if (approver != null && !await _recordsAuthorizationService.IsActiveMemberAsync(approver, DepartmentId)) + approver = null; + disclosure.ReleaseApproverUserId = approver; + await _departmentSettingsService.SetRecordsDisclosureConfigAsync(DepartmentId, disclosure, cancellationToken); + var after = await BuildSettingsAsync(moduleState); SendAudit(AuditLogTypes.DepartmentSettingsChanged, before.CloneJsonToString(), after.CloneJsonToString()); @@ -1056,6 +1172,15 @@ private async Task BuildSettingsAsync(RecordsModuleState mo model.SearchHealth = await _recordsSearch.GetHealthAsync(); model.NarrativeSearchAvailable = model.SearchHealth.Online && await NarrativeSearchAvailableAsync(); + var disclosure = await _departmentSettingsService.GetRecordsDisclosureConfigAsync(DepartmentId, true) ?? new RecordsDisclosureConfig(); + model.DisclosureStatutoryClockDays = disclosure.StatutoryClockDays; + model.DisclosureDefaultRedactionProfile = RmsRedactionProfiles.IsKnown(disclosure.DefaultRedactionProfile) ? disclosure.DefaultRedactionProfile : RmsRedactionProfiles.Standard; + model.DisclosureReleaseApproverUserId = disclosure.ReleaseApproverUserId; + model.RedactionProfiles = RmsRedactionProfiles.All.Select(p => new SelectListItem { Value = p, Text = _localizer["RedactionProfile" + p] }).ToList(); + model.ReleaseApprovers.Add(new SelectListItem { Value = string.Empty, Text = _localizer["DisclosureApproverAnyAdmin"] }); + foreach (var person in (await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(p => p.Name)) + model.ReleaseApprovers.Add(new SelectListItem { Value = person.UserId, Text = person.Name }); + var layout = await _printLayouts.GetDepartmentDefaultAsync(DepartmentId); model.PrintLayout = layout.Config ?? RecordsPrintLayoutConfig.Default(); model.PrintLayoutVersion = layout.LayoutVersion; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsExportTemplatesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsExportTemplatesController.cs new file mode 100644 index 00000000..087324fa --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsExportTemplatesController.cs @@ -0,0 +1,203 @@ +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.Extensions.Localization; +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 +{ + /// + /// Department report exports (RMS plan section 5.6): a department designs the file an agency without an API + /// receives, and a Workflow step or the schedule sweep (worker 45) carries it. Authoring needs + /// ManageRecordReports; every render is an Export audit against each record it contains. + /// + [Area("User")] + [Authorize(Policy = ResgridResources.Record_Export)] + public class RecordsExportTemplatesController : SecureBaseController + { + private readonly IRecordsExportService _exports; + private readonly IRecordsCutoverService _cutover; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsProtectionService _protection; + private readonly IDepartmentsService _departments; + private readonly IStringLocalizer _localizer; + + public RecordsExportTemplatesController(IRecordsExportService exports, IRecordsCutoverService cutover, IRecordsAuthorizationService authorization, + IRecordsProtectionService protection, IDepartmentsService departments, IStringLocalizer localizer) + { + _exports = exports; + _cutover = cutover; + _authorization = authorization; + _protection = protection; + _departments = departments; + _localizer = localizer; + } + + [HttpGet] + public async Task Index() + { + if (!await RequireAsync()) return Forbid(); + var moduleState = await _cutover.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) return NotFound(); + + var model = new RecordsExportTemplatesIndexView + { + ModuleState = moduleState, + Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), + Templates = await _exports.GetTemplatesAsync(DepartmentId), + ProtectionEnforced = await _protection.IsEnforcedAsync(DepartmentId) + }; + if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) model.ErrorMessage = error; + return View(model); + } + + [HttpGet] + public async Task Edit(string id) + { + if (!await RequireAsync()) return Forbid(); + var moduleState = await _cutover.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) return NotFound(); + + RecordsExportTemplateEditView model; + if (string.IsNullOrWhiteSpace(id)) + { + model = new RecordsExportTemplateEditView { Columns = RecordsExportFieldCatalog.DefaultColumns.ToList() }; + } + else + { + var template = await _exports.GetTemplateAsync(DepartmentId, id); + if (template == null) return NotFound(); + model = RecordsExportTemplateEditView.From(template); + } + await DecorateAsync(model, moduleState); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(RecordsExportTemplateEditView model, CancellationToken cancellationToken) + { + if (!await RequireAsync()) return Forbid(); + var moduleState = await _cutover.GetModuleStateAsync(DepartmentId); + if (!moduleState.FlagEnabled) return NotFound(); + + try + { + var template = model.ToTemplate(); + var saved = await _exports.SaveAsync(DepartmentId, UserId, template, model.AcknowledgeEgress, cancellationToken); + TempData["RecordsMessage"] = _localizer["ExportTemplateSaved"].Value; + return RedirectToAction("Edit", new { id = saved.RmsExportTemplateId }); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + catch (ArgumentException ex) + { + model.ErrorMessage = ex.Message; + await DecorateAsync(model, moduleState); + return View(model); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(string id, CancellationToken cancellationToken) + { + if (!await RequireAsync()) return Forbid(); + try + { + await _exports.DeleteAsync(DepartmentId, UserId, id, cancellationToken); + TempData["RecordsMessage"] = _localizer["ExportTemplateDeleted"].Value; + } + catch (UnauthorizedAccessException) { return Forbid(); } + return RedirectToAction("Index"); + } + + /// Renders the template now for the last window (or a named record) and returns the file, so the author can check the layout before an agency does. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RunNow(string id, string recordId, CancellationToken cancellationToken) + { + if (!await RequireAsync()) return Forbid(); + var template = await _exports.GetTemplateAsync(DepartmentId, id); + if (template == null) return NotFound(); + + try + { + var request = new RecordsExportRequest { Trigger = RmsExportTrigger.Manual, ActingUserId = UserId, Purpose = "Manual export " + template.Name }; + if ((RmsExportScope)template.Scope == RmsExportScope.TriggeringRecord) + { + if (string.IsNullOrWhiteSpace(recordId)) + { + TempData["RecordsError"] = _localizer["ExportRunNowNeedsRecord"].Value; + return RedirectToAction("Runs", new { id }); + } + request.RecordId = recordId.Trim(); + } + var run = await _exports.RenderAsync(DepartmentId, template, request, cancellationToken); + return File(run.Data, run.ContentType, run.FileName); + } + catch (UnauthorizedAccessException) { return Forbid(); } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + TempData["RecordsError"] = ex.Message; + return RedirectToAction("Runs", new { id }); + } + } + + [HttpGet] + public async Task Runs(string id) + { + if (!await RequireAsync()) return Forbid(); + var template = await _exports.GetTemplateAsync(DepartmentId, id); + if (template == null) return NotFound(); + + var model = new RecordsExportRunsView + { + Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false), + Template = template, + Runs = await _exports.GetRunsAsync(DepartmentId, id, 100) + }; + if (TempData["RecordsMessage"] is string message) model.Message = message; + if (TempData["RecordsError"] is string error) model.ErrorMessage = error; + return View(model); + } + + [HttpGet] + public async Task Download(string id) + { + if (!await RequireAsync()) return Forbid(); + var run = await _exports.GetRunAsync(DepartmentId, id, true); + if (run?.Data == null) return NotFound(); + return File(run.Data, run.ContentType ?? "application/octet-stream", run.FileName ?? "export"); + } + + private async Task RequireAsync() + => await _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ManageRecordReports); + + private async Task DecorateAsync(RecordsExportTemplateEditView model, RecordsModuleState moduleState) + { + model.ModuleState = moduleState; + model.Department = await _departments.GetDepartmentByIdAsync(DepartmentId, false); + model.CanIncludeRestricted = await _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ViewRestrictedRecords); + model.ProtectionEnforced = await _protection.IsEnforcedAsync(DepartmentId); + model.Definitions = RmsDefinitionKeys.LockedTypes.Select(t => new KeyValuePair(t.Key, t.Value.ToString())) + .Append(new KeyValuePair(RmsDefinitionKeys.NerisIncidentReport, "NERIS incident report")).ToList(); + if (!string.IsNullOrWhiteSpace(model.RmsExportTemplateId)) + { + var validation = await _exports.ValidateAsync(DepartmentId, UserId, model.ToTemplate()); + model.Warnings = validation.Warnings; + } + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkflowsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkflowsController.cs index 9d69d2e0..d14a8823 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkflowsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkflowsController.cs @@ -29,13 +29,15 @@ public class WorkflowsController : SecureBaseController private readonly ISubscriptionsService _subscriptionsService; private readonly IWorkflowTemplateContextBuilder _contextBuilder; private readonly IRecordsCutoverService _recordsCutoverService; + private readonly IRecordsExportService _recordsExportService; public WorkflowsController(IWorkflowService workflowService, IDepartmentsService departmentsService, IPermissionsService permissionsService, IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, IAuditService auditService, IEventAggregator eventAggregator, ISubscriptionsService subscriptionsService, - IWorkflowTemplateContextBuilder contextBuilder, IRecordsCutoverService recordsCutoverService) + IWorkflowTemplateContextBuilder contextBuilder, IRecordsCutoverService recordsCutoverService, IRecordsExportService recordsExportService) { + _recordsExportService = recordsExportService; _workflowService = workflowService; _departmentsService = departmentsService; _permissionsService = permissionsService; @@ -169,6 +171,7 @@ public async Task Edit(string workflowId, CancellationToken ct) ViewBag.TriggerEventTypeName = ((WorkflowTriggerEventType)workflow.TriggerEventType).ToString(); ViewBag.RecordsTriggersAvailable = await RecordsTriggersAvailableAsync(); + await AddExportTemplatesAsync((WorkflowTriggerEventType)workflow.TriggerEventType); return View(workflow); } @@ -185,6 +188,7 @@ public async Task Edit(Workflow model, CancellationToken ct) { ViewBag.TriggerEventTypeName = ((WorkflowTriggerEventType)model.TriggerEventType).ToString(); ViewBag.RecordsTriggersAvailable = await RecordsTriggersAvailableAsync(); + await AddExportTemplatesAsync((WorkflowTriggerEventType)model.TriggerEventType); return View(model); } @@ -627,6 +631,23 @@ public async Task DeleteCredential(string credentialId, Cancellat /// Records (RMS) triggers 100-115 are only offered once Records is usable for the department (plan section /// 5.6). A workflow already bound to one keeps working; the outbox simply never fires before activation. /// + /// + /// Department report exports a step on a Records trigger may attach (RMS plan section 5.6). Only the + /// designer for a Records workflow gets the list; other triggers never carry a record to export. + /// + private async Task AddExportTemplatesAsync(WorkflowTriggerEventType trigger) + { + ViewBag.RecordsExportAvailable = false; + ViewBag.RecordsExportTemplatesJson = "[]"; + if (!WorkflowTriggerEventTypes.IsRecordsTrigger(trigger) || !await RecordsTriggersAvailableAsync()) + return; + + var templates = await _recordsExportService.GetTemplatesAsync(DepartmentId); + ViewBag.RecordsExportAvailable = true; + ViewBag.RecordsExportTemplatesJson = JsonSerializer.Serialize(templates.Where(t => t.IsEnabled) + .Select(t => new { id = t.RmsExportTemplateId, name = t.Name, key = t.TemplateKey, format = ((RmsExportFormat)t.Format).ToString(), scope = ((RmsExportScope)t.Scope).ToString() })); + } + private async Task RecordsTriggersAvailableAsync() { var state = await _recordsCutoverService.GetModuleStateAsync(DepartmentId); diff --git a/Web/Resgrid.Web/Areas/User/Models/AdpRevealView.cs b/Web/Resgrid.Web/Areas/User/Models/AdpRevealView.cs index 0a3ae5ca..86b747f1 100644 --- a/Web/Resgrid.Web/Areas/User/Models/AdpRevealView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/AdpRevealView.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace Resgrid.Web.Areas.User.Models @@ -29,5 +30,19 @@ public class AdpRevealView /// they may read this particular record. /// public Dictionary RevealData { get; set; } = new Dictionary(); + + /// + /// UTC expiry of a grant the page already holds in a bound form's hidden field (the Records *Revealed + /// actions render this way). The module only needs the expiry: it drives the warning that offers an + /// in-place re-verification before the window closes, and the submit hold once it has. + /// + public DateTime? GrantExpiresOnUtc { get; set; } + + /// + /// CSS selectors of forms whose submit must carry a live grant (RMS plan section 5.9.3). The module + /// writes the grant into the form's hidden fields on submit and, when none is live, runs the step-up in + /// place and re-dispatches the same submit afterwards so nothing typed is lost. + /// + public List BindForms { get; set; } = new List(); } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs index f5f946e7..215e1bbf 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs @@ -38,6 +38,12 @@ public class ViewCallView: BaseUserModel /// ADP: true when this call carries protected fields rendered as REDACTED (plan 7.2). public bool IsProtectedCall { get; set; } public string ProtectedReason { get; set; } + + /// + /// True when the department enforces Protected Data over Records: starting an incident report from this + /// call seals the captured location and narrative, so the start form needs a grant (RMS plan section 5.9.3). + /// + public bool ProtectionEnforced { get; set; } public List RedactedFields { get; set; } = new List(); public string IsMapTabActive() diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs index 1db57b5e..112722b3 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/IncidentReportsViewModels.cs @@ -30,6 +30,13 @@ public class IncidentReportsIndexView : RecordsBaseView } /// Incident report detail: header, NERIS status, validation issues, sections, provenance and history. + /// Read-only NFIRS rendering and per-incident crosswalk for one Call (RMS-3). + public class NfirsLegacyView : RecordsBaseView + { + public NfirsLegacyRendering Rendering { get; set; } + public Department Department { get; set; } + } + public class IncidentReportDetailView : RecordsBaseView { public IncidentReportAggregate Aggregate { get; set; } @@ -254,6 +261,10 @@ public class NerisSettingsView : RecordsBaseView public DateTime? LastSuccessfulCallOn { get; set; } public string LastError { get; set; } public bool SubmissionEnabled { get; set; } + public bool AllowProtectedContentEgress { get; set; } + public DateTime? ProtectedEgressAcknowledgedOn { get; set; } + public string ProtectedEgressAcknowledgedByUserId { get; set; } + public bool ProtectionEnforced { get; set; } public List Environments { get; set; } = new List(); public List GrantTypes { get; set; } = new List(); public List Crosswalk { get; set; } = new List(); diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsExportViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsExportViewModels.cs new file mode 100644 index 00000000..20c74620 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsExportViewModels.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Records +{ + public class RecordsExportTemplatesIndexView : RecordsBaseView + { + public RecordsModuleState ModuleState { get; set; } + public Department Department { get; set; } + public List Templates { get; set; } = new List(); + public bool ProtectionEnforced { get; set; } + } + + public class RecordsExportTemplateEditView : RecordsBaseView + { + public RecordsModuleState ModuleState { get; set; } + public Department Department { get; set; } + public string RmsExportTemplateId { get; set; } + public string TemplateKey { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public int Format { get; set; } = (int)RmsExportFormat.Csv; + public int Scope { get; set; } = (int)RmsExportScope.TriggeringRecord; + public List DefinitionKeys { get; set; } = new List(); + public List Columns { get; set; } = new List(); + public bool IncludeNarrative { get; set; } + public bool IncludeRestricted { get; set; } + public bool AcknowledgeEgress { get; set; } + public DateTime? EgressAcknowledgedOn { get; set; } + public string EgressAcknowledgedByUserId { get; set; } + public string FileNameTemplate { get; set; } + public bool IncludeHeader { get; set; } = true; + public string Delimiter { get; set; } = ","; + public int ScheduleKind { get; set; } = (int)RmsExportScheduleKind.None; + public int ScheduleHourLocal { get; set; } = 6; + public int ScheduleDayOfWeek { get; set; } = 1; + public int ScheduleDayOfMonth { get; set; } = 1; + public int WindowDays { get; set; } + public bool IsEnabled { get; set; } = true; + public DateTime? NextRunOn { get; set; } + public DateTime? LastRunOn { get; set; } + public long RowVersion { get; set; } + public bool CanIncludeRestricted { get; set; } + public bool ProtectionEnforced { get; set; } + public List Warnings { get; set; } = new List(); + public IReadOnlyList Catalog { get; set; } = RecordsExportFieldCatalog.Fields; + public List> Definitions { get; set; } = new List>(); + public bool IsNew => string.IsNullOrWhiteSpace(RmsExportTemplateId); + + public RmsExportTemplate ToTemplate() + { + return new RmsExportTemplate + { + RmsExportTemplateId = RmsExportTemplateId, + TemplateKey = TemplateKey, + Name = Name, + Description = Description, + Format = Format, + Scope = Scope, + DefinitionKeysCsv = string.Join(",", DefinitionKeys ?? new List()), + ColumnsJson = Newtonsoft.Json.JsonConvert.SerializeObject(Columns ?? new List()), + IncludeNarrative = IncludeNarrative, + IncludeRestricted = IncludeRestricted, + EgressAcknowledgedOn = EgressAcknowledgedOn, + EgressAcknowledgedByUserId = EgressAcknowledgedByUserId, + FileNameTemplate = FileNameTemplate, + IncludeHeader = IncludeHeader, + Delimiter = Delimiter == "tab" ? "\t" : Delimiter, + ScheduleKind = ScheduleKind, + ScheduleHourLocal = ScheduleHourLocal, + ScheduleDayOfWeek = ScheduleDayOfWeek, + ScheduleDayOfMonth = ScheduleDayOfMonth, + WindowDays = WindowDays, + IsEnabled = IsEnabled, + RowVersion = RowVersion + }; + } + + public static RecordsExportTemplateEditView From(RmsExportTemplate template) + { + var view = new RecordsExportTemplateEditView + { + RmsExportTemplateId = template.RmsExportTemplateId, + TemplateKey = template.TemplateKey, + Name = template.Name, + Description = template.Description, + Format = template.Format, + Scope = template.Scope, + DefinitionKeys = string.IsNullOrWhiteSpace(template.DefinitionKeysCsv) ? new List() : new List(template.DefinitionKeysCsv.Split(',', StringSplitOptions.RemoveEmptyEntries)), + IncludeNarrative = template.IncludeNarrative, + IncludeRestricted = template.IncludeRestricted, + EgressAcknowledgedOn = template.EgressAcknowledgedOn, + EgressAcknowledgedByUserId = template.EgressAcknowledgedByUserId, + FileNameTemplate = template.FileNameTemplate, + IncludeHeader = template.IncludeHeader, + Delimiter = template.Delimiter == "\t" ? "tab" : (template.Delimiter ?? ","), + ScheduleKind = template.ScheduleKind, + ScheduleHourLocal = template.ScheduleHourLocal, + ScheduleDayOfWeek = template.ScheduleDayOfWeek, + ScheduleDayOfMonth = template.ScheduleDayOfMonth, + WindowDays = template.WindowDays, + IsEnabled = template.IsEnabled, + NextRunOn = template.NextRunOn, + LastRunOn = template.LastRunOn, + RowVersion = template.RowVersion + }; + try { view.Columns = Newtonsoft.Json.JsonConvert.DeserializeObject>(template.ColumnsJson ?? "[]") ?? new List(); } + catch (Newtonsoft.Json.JsonException) { view.Columns = new List(); } + return view; + } + } + + public class RecordsExportRunsView : RecordsBaseView + { + public Department Department { get; set; } + public RmsExportTemplate Template { get; set; } + public List Runs { get; set; } = new List(); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs index 774c8bbf..79b8d688 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs @@ -10,6 +10,37 @@ public abstract class RecordsBaseView : BaseUserModel { public string Message { get; set; } public string ErrorMessage { get; set; } + + /// True when the department enforces Protected Data over Records (the shield indicator). + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public bool ProtectionEnforced { get; set; } + + /// True when this render carries REDACTED placeholders because no valid grant was presented. + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public bool ProtectionRedacted { get; set; } + + /// Reason code behind (step_up_required, grant_expired, ...). + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public string ProtectionReason { get; set; } + + /// + /// The Protected Data Grant an edit page carries through its own form posts and autosaves after the reveal + /// module handed it over (RMS plan section 5.9.3). Set only by the *Revealed actions and the error re-renders + /// of a post that carried it; never bound from the request. + /// + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public string ProtectedGrant { get; set; } + + /// Expiry of as the reveal module reported it; times the in-page expiry warning. + [Microsoft.AspNetCore.Mvc.ModelBinding.BindNever] + public DateTime? ProtectedGrantExpiresOnUtc { get; set; } + + public void ApplyProtection(ProtectedReadResult protection) + { + ProtectionEnforced = protection?.IsProtected == true; + ProtectionRedacted = protection?.RedactedFields?.Count > 0; + ProtectionReason = protection?.ProtectedReason; + } } /// @@ -245,5 +276,12 @@ public class RecordsSettingsView : RecordsBaseView public RecordsPrintLayoutConfig PrintLayout { get; set; } = new RecordsPrintLayoutConfig(); public string PrintLayoutVersion { get; set; } public bool HasLogo { get; set; } + + // Setting 77 (RecordsDisclosureConfig, plan section 4.9): the public-records clock and production defaults. + public int DisclosureStatutoryClockDays { get; set; } + public string DisclosureDefaultRedactionProfile { get; set; } + public string DisclosureReleaseApproverUserId { get; set; } + public List RedactionProfiles { get; set; } = new List(); + public List ReleaseApprovers { get; set; } = new List(); } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Disclosures/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/Disclosures/Details.cshtml index 2afb89f8..ac904f31 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Disclosures/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Disclosures/Details.cshtml @@ -214,7 +214,7 @@
Record delivery - +

Records the delivery you completed. Requests with outstanding scope items remain open.

diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml index 0037aa57..1952c6b0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml @@ -53,7 +53,7 @@ } @if (Model.RecordsUsable && ClaimsAuthorizationHelper.CanCreateRecord()) { -
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
} @@ -69,7 +69,12 @@ BannerTitle = commonLocalizer["AdpProtectedCall"], RevealController = "Dispatch", RevealAction = "RevealCall", - RevealData = new Dictionary { { "callId", Model.Call.CallId.ToString() } } + RevealData = new Dictionary { { "callId", Model.Call.CallId.ToString() } }, + // Starting an incident report seals the captured call facts under Records protection (RMS plan + // section 5.9.3): the start form is bound so the submit runs the step-up in place when no grant is held. + BindForms = Model.ProtectionEnforced && Model.RecordsUsable && ClaimsAuthorizationHelper.CanCreateRecord() + ? new List { "#start-incident-report-form" } + : new List() }; } @@ -982,7 +987,7 @@ @section Scripts { - @if (Model.IsProtectedCall) + @if (Model.IsProtectedCall || Model.ProtectionEnforced) { } diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml index 36af4b78..79d0537c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml @@ -14,6 +14,14 @@ string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; string Source(int kind) => ((RmsSourceKind)kind).ToString(); var errors = a.Issues.Count(i => i.Severity == (int)RmsValidationSeverity.Error); + var isProtected = a.Protection?.IsProtected == true; + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = localizer["ProtectedIncidentBanner"], + RevealController = "IncidentReports", + RevealAction = "RevealIncident", + RevealData = new Dictionary { { "id", r.RmsIncidentReportId } } + }; }
@@ -30,7 +38,7 @@
@if (Model.CanEdit && Model.IsEditable) { - @commonLocalizer["Edit"] + @commonLocalizer["Edit"] Inventory usage
@Html.AntiForgeryToken()
} @@ -52,14 +60,20 @@ JSON CSV } + @localizer["NfirsLegacyButton"]
+@if (isProtected) +{ +
+} +
@localizer["Attachments"]
- @foreach (var attachment in a.Attachments.Where(x => !x.IsProtected || Model.CanViewRestricted)) + @foreach (var attachment in a.Attachments.Where(x => !x.RequiresRestrictedAccess || Model.CanViewRestricted)) {

@attachment.FileName — @attachment.Description @((RmsAttachmentScanState)attachment.ScanState) · @attachment.ByteSize bytes

@if (Model.CanEdit && Model.IsEditable) @@ -186,13 +200,13 @@ else {
-
@localizer["AddressText"]
@(a.Location.AddressText ?? "-")
+
@localizer["AddressText"]
@(a.Location.AddressText ?? "-")
@localizer["Street"]
@string.Join(" ", new[] { a.Location.NumberPrefix, a.Location.Number, a.Location.NumberSuffix, a.Location.Street, a.Location.UnitValue }.Where(s => !string.IsNullOrWhiteSpace(s)))
@localizer["Municipality"]
@string.Join(", ", new[] { a.Location.Municipality, a.Location.County, a.Location.State, a.Location.PostalCode, a.Location.Country }.Where(s => !string.IsNullOrWhiteSpace(s)))
@localizer["PlaceType"]
@(a.Location.PlaceType ?? "-")
@localizer["LocationUse"]
@(a.Location.LocationUse ?? "-")
@localizer["CrossStreet"]
@string.Join(" / ", new[] { a.Location.CrossStreet1, a.Location.CrossStreet2 }.Where(s => !string.IsNullOrWhiteSpace(s)))
-
@localizer["Coordinates"]
@(a.Location.Latitude.HasValue ? a.Location.Latitude + ", " + a.Location.Longitude : "-")
+
@localizer["Coordinates"]
@(a.Location.Latitude.HasValue ? a.Location.Latitude + ", " + a.Location.Longitude : "-")
@localizer["Source"]
@Source(a.Location.SourceKind)
} @@ -350,7 +364,7 @@ @exposure.DamageType @exposure.ItemType @exposure.LocationUse - @(exposure.AddressText ?? exposure.Street) + @(exposure.AddressText ?? exposure.Street) @(exposure.EstimatedLoss?.ToString("0.##") ?? "-") } @@ -466,14 +480,14 @@
@localizer["Narrative"]
-

@(a.Narrative?.Narrative ?? "-")

+

@(a.Narrative?.Narrative ?? "-")

@if (!string.IsNullOrWhiteSpace(a.Narrative?.ImpedimentNarrative)) { -

@localizer["ImpedimentNarrative"]

@a.Narrative.ImpedimentNarrative

+

@localizer["ImpedimentNarrative"]

@a.Narrative.ImpedimentNarrative

} @if (!string.IsNullOrWhiteSpace(a.Narrative?.OutcomeNarrative)) { -

@localizer["OutcomeNarrative"]

@a.Narrative.OutcomeNarrative

+

@localizer["OutcomeNarrative"]

@a.Narrative.OutcomeNarrative

}
@@ -701,3 +715,18 @@
+ +@section Scripts { + @if (isProtected) + { + + @* A grant held here travels into the edit page (RMS plan section 5.9.3) so the form opens revealed instead of asking again. *@ +
@Html.AntiForgeryToken()
+ + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml index 6f01fcc0..256d538b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml @@ -6,6 +6,17 @@ ViewBag.Title = "Resgrid | " + localizer["EditIncidentReport"]; var reference = string.IsNullOrWhiteSpace(Model.RecordNumber) ? Model.DraftReference : Model.RecordNumber; string Dt(DateTime? v) => v.HasValue ? v.Value.ToString("yyyy-MM-ddTHH:mm") : string.Empty; + // ADP (RMS plan section 5.9.3): under enforcement the form needs the grant both to show sealed columns (the guided + // sections read their JSON from hidden fields at load) and to save them. The reveal module posts the grant to + // EditRevealed, which renders this same view with the grant in the form's hidden field. + var holdsGrant = !string.IsNullOrEmpty(Model.ProtectedGrant); + var needsGrant = Model.ProtectionEnforced && !holdsGrant; + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = localizer["ProtectedIncidentBanner"], + GrantExpiresOnUtc = Model.ProtectedGrantExpiresOnUtc, + BindForms = new List { "#incident-edit-form" } + }; // Never render fewer rows than the report already holds: SaveDraft replaces each collection with what the // form posts, so a persisted row the form omitted would be deleted on the next save. var typeRowCount = Math.Max(Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.TypeRows, Model.Types.Count + 1); @@ -33,8 +44,12 @@
-
+ @Html.AntiForgeryToken() + @if (holdsGrant) + { + + } @Html.HiddenFor(m => m.ReportId) @Html.HiddenFor(m => m.RowVersion) @Html.HiddenFor(m => m.CallId) @@ -47,6 +62,15 @@ {
@Model.ErrorMessage
} + @if (needsGrant) + { + +

@localizer["ProtectedEditHint"]

+ } + else if (holdsGrant) + { +
@localizer["ProtectedEditRevealed"]
+ } @if (Model.IsAmendment) {
@localizer["AmendmentOpen"]
@@ -584,6 +608,29 @@ +@if (needsGrant) +{ +
@Html.AntiForgeryToken()
+} +@section Scripts { + @if (Model.ProtectionEnforced) + { + @if (needsGrant) + { + + } + @* Always under enforcement: the edit form is bound, so a save without a live grant runs the step-up in + place and continues with the same submit, and the expiry warning offers a renewal before the window + closes. Nothing typed is lost either way. *@ + + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/NfirsLegacy.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/NfirsLegacy.cshtml new file mode 100644 index 00000000..1d9b9914 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/NfirsLegacy.cshtml @@ -0,0 +1,151 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@model Resgrid.Web.Areas.User.Models.Records.NfirsLegacyView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["NfirsLegacyHeader"]; + var r = Model.Rendering; + string StatusLabel(NfirsLegacyFieldStatus status) + { + switch (status) + { + case NfirsLegacyFieldStatus.Populated: return localizer["NfirsStatusPopulated"]; + case NfirsLegacyFieldStatus.Missing: return localizer["NfirsStatusMissing"]; + default: return localizer["NfirsStatusNotCaptured"]; + } + } + string StatusCss(NfirsLegacyFieldStatus status) + { + switch (status) + { + case NfirsLegacyFieldStatus.Populated: return "label-primary"; + case NfirsLegacyFieldStatus.Missing: return "label-warning"; + default: return "label-default"; + } + } +} + +
+
+

@localizer["NfirsLegacyHeader"]

+ +
+
+
+ @localizer["Call"] + @if (!string.IsNullOrWhiteSpace(r.IncidentReportId)) + { + @localizer["IncidentReport"] + } +
+
+
+ +
+
+
+
+
@r.Profile
+
+

@localizer["NfirsLegacyIntro"]

+
@localizer["NfirsLegacyReadOnlyNotice"]
+
+ + + + + + + + + + + + @foreach (var field in r.Fields) + { + + + + + + + + } + +
@localizer["NfirsField"]@localizer["Value"]@localizer["Source"]@localizer["NfirsStatus"]@localizer["NerisEquivalent"]
+ @field.Code + @if (field.Required) + { + * + } + @(field.Value ?? "-")@(field.SourceSystem ?? "-")@StatusLabel(field.Status) + @if (field.NerisFactKey == null && field.NerisSection == null) + { + @localizer["NerisNoEquivalent"] + } + else + { + @(field.NerisFactKey ?? field.NerisSection) + @if (field.NerisPopulated == true) + { + @localizer["NerisCarried"] + } + else if (field.NerisPopulated == false) + { + @localizer["NerisNotYetCarried"] + } + } +
+
+

@localizer["NfirsRequiredLegend"]

+
+
+
+
+
+
@localizer["NfirsCrosswalkSummary"]
+
+
+
@localizer["Call"]
@(r.CallNumber ?? "-") @(string.IsNullOrWhiteSpace(r.CallName) ? string.Empty : "· " + r.CallName)
+
@localizer["IncidentReport"]
+
+ @if (string.IsNullOrWhiteSpace(r.IncidentReportId)) + { + @localizer["NfirsNoNerisReport"] + } + else + { + @r.IncidentReportNumber (@r.IncidentReportState) + } +
+
@localizer["NfirsStatusPopulated"]
@r.Summary.Populated / @r.Summary.TotalFields
+
@localizer["NfirsStatusMissing"]
@r.Summary.Missing
+
@localizer["NfirsStatusNotCaptured"]
@r.Summary.NotCaptured
+
@localizer["NfirsRequiredMissing"]
@r.Summary.RequiredMissing
+
@localizer["NfirsCrosswalked"]
@r.Summary.CrosswalkedToNeris / @r.Summary.TotalFields
+ @if (!string.IsNullOrWhiteSpace(r.IncidentReportId)) + { +
@localizer["NerisCarried"]
@r.Summary.CrosswalkedAndPopulated / @r.Summary.CrosswalkedToNeris
+ } +
@localizer["GeneratedOn"]
@r.GeneratedOn.TimeConverterToString(Model.Department)
+
+ @if (r.Notes.Count > 0) + { +
    + @foreach (var note in r.Notes) + { +
  • @note
  • + } +
+ } +

@localizer["NfirsSunsetNote"]

+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Settings.cshtml index 45f33a03..61273339 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Settings.cshtml @@ -61,6 +61,16 @@
+
+ @localizer["NerisProtectedEgressHelp"] + @if (Model.ProtectedEgressAcknowledgedOn.HasValue) + { + @localizer["NerisProtectedEgressAcknowledged"] @Model.ProtectedEgressAcknowledgedOn.Value.TimeConverterToString(Model.Department) + } + @if (Model.ProtectionEnforced && !Model.AllowProtectedContentEgress) + { + @localizer["NerisProtectedEgressRequired"] + }
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml index 543a3637..40758a86 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml @@ -12,6 +12,16 @@ var isEditable = RmsLifecycle.IsEditable(Model.State) || record.AmendsRevisionId != null; string Name(string userId) => string.IsNullOrEmpty(userId) ? "-" : (Model.PersonnelNames.TryGetValue(userId, out var n) ? n : userId); string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; + var isProtected = Model.Aggregate.Protection?.IsProtected == true; + var detailId = details.RmsOperationalRecordDetailId ?? string.Empty; + string Field(string column) => "rmsoperationalrecorddetails." + column + ":" + detailId; + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = localizer["ProtectedRecordBanner"], + RevealController = "Records", + RevealAction = "RevealRecord", + RevealData = new Dictionary { { "id", record.RmsOperationalRecordId } } + }; }
@@ -28,7 +38,7 @@ Supporting evidence @if (Model.CanEdit && isEditable) { - @commonLocalizer["Edit"] + @commonLocalizer["Edit"] Inventory usage } @if (Model.CanAmend && RmsLifecycle.CanTransition((RmsLifecyclePreset)record.LifecyclePreset, Model.State, RmsRecordState.Amended) && record.AmendsRevisionId == null) @@ -52,6 +62,10 @@
+ @if (isProtected) + { + + } @if (!string.IsNullOrEmpty(Model.Message)) {
@Model.Message
@@ -100,10 +114,10 @@
@localizer["CallSnapshot"]
-
@localizer["Call"]
@details.CallNumber @details.CallName
+
@localizer["Call"]
@details.CallNumber @details.CallName
@commonLocalizer["Type"]
@details.CallType
-
@localizer["Location"]
@details.CallAddress
-
@commonLocalizer["Nature"]
@details.CallNature
+
@localizer["Location"]
@details.CallAddress
+
@commonLocalizer["Nature"]
@details.CallNature
@@ -125,8 +139,8 @@ } @if (t == RmsOperationalRecordType.Run || t == RmsOperationalRecordType.Callback) { -
@localizer["InitialReport"]
@details.InitialReport
-
@localizer["Cause"]
@details.Cause
+
@localizer["InitialReport"]
@details.InitialReport
+
@localizer["Cause"]
@details.Cause
@localizer["Investigator"]
@Name(details.InvestigatedByUserId)
@localizer["OtherAgencies"]
@details.OtherAgencies
@localizer["OtherUnits"]
@details.OtherUnits
@@ -138,11 +152,11 @@ } @if (!isCoroner) { -
@localizer["Location"]
@details.Location
-
@localizer["OtherPersonnel"]
@details.OtherPersonnel
+
@localizer["Location"]
@details.Location
+
@localizer["OtherPersonnel"]
@details.OtherPersonnel
} -
@Html.Raw(Resgrid.Framework.RecordNarrativeFormatter.Render(details.Narrative))
+
@Html.Raw(Resgrid.Framework.RecordNarrativeFormatter.Render(details.Narrative))
@@ -154,13 +168,13 @@ @if (Model.CanViewRestricted) {
-
@localizer["CaseNumber"]
@details.CaseNumber
-
@localizer["PronouncedDeceasedBy"]
@details.PronouncedDeceasedBy
-
@localizer["BodyLocation"]
@details.BodyLocation
-
@localizer["Destination"]
@details.Destination
-
@localizer["OtherPersonnel"]
@details.OtherPersonnel
-
@localizer["ContactName"]
@details.ContactName
-
@localizer["ContactNumber"]
@details.ContactNumber
+
@localizer["CaseNumber"]
@details.CaseNumber
+
@localizer["PronouncedDeceasedBy"]
@details.PronouncedDeceasedBy
+
@localizer["BodyLocation"]
@details.BodyLocation
+
@localizer["Destination"]
@details.Destination
+
@localizer["OtherPersonnel"]
@details.OtherPersonnel
+
@localizer["ContactName"]
@details.ContactName
+
@localizer["ContactNumber"]
@details.ContactNumber
} else @@ -339,3 +353,18 @@
+ +@section Scripts { + @if (isProtected) + { + + @* A grant held here travels into the edit page (RMS plan section 5.9.3) so the form opens revealed instead of asking again. *@ +
@Html.AntiForgeryToken()
+ + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Edit.cshtml index f0131d0c..a2e46168 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Edit.cshtml @@ -10,6 +10,17 @@ var isTraining = t == RmsOperationalRecordType.Training; var isMeeting = t == RmsOperationalRecordType.Meeting; var isCoroner = t == RmsOperationalRecordType.Coroner; + // ADP (RMS plan section 5.9.3): under enforcement the form needs the grant both to show sealed columns and to + // save them. Step-up alone is the whole job here: the reveal module posts the grant to EditRevealed/NewRevealed, + // which renders this same view with the grant in the form's hidden field. + var holdsGrant = !string.IsNullOrEmpty(Model.ProtectedGrant); + var needsGrant = Model.ProtectionEnforced && !holdsGrant; + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = localizer["ProtectedRecordBanner"], + GrantExpiresOnUtc = Model.ProtectedGrantExpiresOnUtc, + BindForms = new List { "#record-edit-form" } + }; var isUnitActivity = t == RmsOperationalRecordType.UnitActivity; } @@ -27,6 +38,10 @@
@Html.AntiForgeryToken() + @if (holdsGrant) + { + + } @Html.HiddenFor(m => m.RecordId) @Html.HiddenFor(m => m.RowVersion) @Html.HiddenFor(m => m.DefinitionKey) @@ -45,6 +60,15 @@ {
@Model.ErrorMessage
} + @if (needsGrant) + { + +

@localizer["ProtectedEditHint"]

+ } + else if (holdsGrant) + { +
@localizer["ProtectedEditRevealed"]
+ } @if (Model.IsAmendment) {
@localizer["AmendmentOpen"]
@@ -302,7 +326,28 @@ +@if (needsGrant) +{ +
@Html.AntiForgeryToken()
+} @section Scripts { + @if (Model.ProtectionEnforced) + { + @if (needsGrant) + { + + } + @* Always under enforcement: the edit form is bound, so a save without a live grant runs the step-up in + place and continues with the same submit, and the expiry warning offers a renewal before the window + closes. Nothing typed is lost either way. *@ + + } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml index d20092e8..78e97b31 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Index.cshtml @@ -45,6 +45,10 @@ { @localizer["Disclosures"] } + @if (Model.ModuleState.RecordsUsable && ClaimsAuthorizationHelper.CanManageRecordReports()) + { + @localizer["ExportTemplates"] + } @if (Model.ModuleState.RecordsUsable && ClaimsAuthorizationHelper.CanExportRecords()) { @localizer["ExportCsv"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Settings.cshtml index 45d1132a..cb979c0e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Settings.cshtml @@ -192,6 +192,28 @@ +
+
@localizer["DisclosureSettingsHeader"]
+
+

@localizer["DisclosureSettingsIntro"]

+
+ +
+
@localizer["StatutoryClockDaysHelp"]
+
+
+ +
+
@localizer["DefaultRedactionProfileHelp"]
+
+
+ +
+
@localizer["DisclosureReleaseApproverHelp"]
+
+
+
+
@localizer["SearchIndexStatus"]
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Edit.cshtml new file mode 100644 index 00000000..8ecb416a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Edit.cshtml @@ -0,0 +1,193 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@model Resgrid.Web.Areas.User.Models.Records.RecordsExportTemplateEditView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + (Model.IsNew ? localizer["NewExportTemplate"] : localizer["EditExportTemplate"]); + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; + var tiers = new[] { RmsExportFieldTier.Safe, RmsExportFieldTier.Narrative, RmsExportFieldTier.Restricted }; + string TierLabel(RmsExportFieldTier tier) => tier == RmsExportFieldTier.Safe ? localizer["ExportTierSafe"].Value : tier == RmsExportFieldTier.Narrative ? localizer["ExportTierNarrative"].Value : localizer["ExportTierRestricted"].Value; +} + +
+
+

@(Model.IsNew ? localizer["NewExportTemplate"] : localizer["EditExportTemplate"])

+ +
+
+ @if (!Model.IsNew) + { + + } +
+
+ +
+ @Html.AntiForgeryToken() + + + + +
+ @if (!string.IsNullOrEmpty(Model.Message)) {
@Model.Message
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} + @foreach (var warning in Model.Warnings) {
@warning
} +
+
+
+
@localizer["ExportTemplates"]
+
+
+ +
+
+
+ +
@localizer["ExportTemplateKeyHelp"]
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ @foreach (var definition in Model.Definitions) + { + + } + @localizer["ExportDefinitionsHelp"] +
+
+
+ +
@localizer["ExportFileNameHelp"]
+ +
+ +
+
+
+
+ +   +
+
+
+
+ +
+
@localizer["ExportColumns"]
+
+

@localizer["ExportColumnsHelp"]

+ @foreach (var tier in tiers) + { +

@TierLabel(tier)

+
+ @foreach (var field in Model.Catalog.Where(f => f.Tier == tier)) + { +
+ +
+ } +
+ } +
+
+
+
+ @if (Model.EgressAcknowledgedOn.HasValue) + { +

@localizer["ExportEgressAcknowledgedOn"] @When(Model.EgressAcknowledgedOn)

+ } + @if (Model.ProtectionEnforced) + { +
@localizer["ExportAdpWarning"]
+ } +
+
+
+ +
+
+
@localizer["ExportSchedule"]
+
+
+ +
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
@localizer["ExportWindowDaysHelp"]
+
+ @if (!Model.IsNew) + { +
+
@localizer["ExportNextRun"]
@When(Model.NextRunOn)
+
@localizer["ExportLastRun"]
@When(Model.LastRunOn)
+
+ } +

@localizer["ExportWorkflowHint"]

+
+
+
+
+ + @commonLocalizer["Cancel"] +
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Index.cshtml new file mode 100644 index 00000000..fd950e43 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Index.cshtml @@ -0,0 +1,78 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@model Resgrid.Web.Areas.User.Models.Records.RecordsExportTemplatesIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ExportTemplates"]; + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; +} + +
+
+

@localizer["ExportTemplates"]

+ +
+ +
+ +
+ @if (!string.IsNullOrEmpty(Model.Message)) {
@Model.Message
} + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} +
+
+

@localizer["ExportTemplatesIntro"]

+

@localizer["ExportWorkflowHint"]

+ @if (Model.ProtectionEnforced) + { +
@localizer["ExportAdpWarning"]
+ } + @if (Model.Templates.Count == 0) + { +

@localizer["ExportNoTemplates"]

+ } + else + { + + + + + + + + + + + + + + + @foreach (var t in Model.Templates) + { + + + + + + + + + + + } + +
@localizer["ExportTemplateName"]@localizer["ExportTemplateKey"]@localizer["ExportFormat"]@localizer["ExportScope"]@localizer["ExportSchedule"]@localizer["ExportNextRun"]@localizer["ExportLastRun"]
@t.Name @if (!t.IsEnabled) { @commonLocalizer["Disabled"] } @if (t.IncludeNarrative || t.IncludeRestricted) { }@t.TemplateKey@((RmsExportFormat)t.Format)@((RmsExportScope)t.Scope == RmsExportScope.Window ? localizer["ExportScopeWindow"] : localizer["ExportScopeTriggeringRecord"])@((RmsExportScheduleKind)t.ScheduleKind switch { RmsExportScheduleKind.Daily => localizer["ExportScheduleDaily"].Value, RmsExportScheduleKind.Weekly => localizer["ExportScheduleWeekly"].Value, RmsExportScheduleKind.Monthly => localizer["ExportScheduleMonthly"].Value, _ => localizer["ExportScheduleNone"].Value })@When(t.NextRunOn)@When(t.LastRunOn) + @localizer["ExportRuns"] +
@Html.AntiForgeryToken()
+
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Runs.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Runs.cshtml new file mode 100644 index 00000000..d38a4b1f --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Runs.cshtml @@ -0,0 +1,73 @@ +@using Resgrid.Model +@using Resgrid.Model.Helpers +@model Resgrid.Web.Areas.User.Models.Records.RecordsExportRunsView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ExportRuns"]; + string When(DateTime? value) => value.HasValue ? value.Value.TimeConverterToString(Model.Department) : "-"; + var byRecord = (RmsExportScope)Model.Template.Scope == RmsExportScope.TriggeringRecord; +} + +
+
+

@localizer["ExportRuns"]: @Model.Template.Name

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

@localizer["ExportRunNowHelp"]

+
+ @Html.AntiForgeryToken() + @if (byRecord) + { + + } + +
+
+
+
+
@localizer["ExportRuns"]
+
+ @if (Model.Runs.Count == 0) + { +

-

+ } + else + { + + + + @foreach (var run in Model.Runs) + { + + + + + + + + + } + +
@localizer["ExportRunGenerated"]@localizer["ExportRunTrigger"]@localizer["ExportRunRecords"]@localizer["ExportRunSize"]@localizer["Checksum"]
@When(run.GeneratedOn) @run.FileName@((RmsExportTrigger)run.Trigger) @if (run.Redacted) { }@run.RecordCount@run.ByteSize@run.Checksum?.Substring(0, Math.Min(12, run.Checksum.Length)) @localizer["ExportRunDownload"]
+ } +
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml index 72787a6c..2b32b8ec 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml @@ -1,3 +1,4 @@ +@using System.Collections.Generic @model Resgrid.Web.Areas.User.Models.AdpRevealView @* Step-up modal + reveal module wiring (plan 7.2). Render inside the host view's Scripts @@ -6,7 +7,14 @@ A host page that needs to react to a reveal (re-fetching a client-rendered list with the grant attached, for example) defines window.resgridAdpPageRevealed / ...PageConcealed before - this block runs; the module calls them and nothing else changes. + this block runs; the module calls them and nothing else changes. ...PageRenewed runs after an + in-place re-verification. + + Every page that renders this block gets the expiry warning: a fixed toast shown + DataProtectionConfig.StepUpExpiryWarningSeconds before the grant window closes, with a + re-verify button that renews the grant without leaving the page (revealed fields and typed + work stay). Model.BindForms names the forms whose submit must carry the grant; Model.GrantExpiresOnUtc + is set when the page was rendered by a *Revealed action and already holds the grant in that form. *@