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