diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
index def2f83ed..014bbf03c 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
@@ -64,17 +64,17 @@
نوع الملف
البحث عن عنوان
البحث عن موقع w3w
- تعليم ملاحظة البلاغ
- تعليم صورة البلاغ
+ الإبلاغ عن ملاحظة البلاغ للمراجعة
+ الإبلاغ عن صورة البلاغ للمراجعة
تعليم ملف البلاغ
أضيفت الصورة بواسطة
طابع وقت الصورة
أضيف الملف بواسطة
طابع وقت الملف
- تعليم الصورة
+ الإبلاغ عن الصورة
تعليم الملف
- سبب التعليم
- سبب التعليم
+ سبب الإبلاغ
+ سبب الإبلاغ
GPS
معرف الحادثة
معرف الحادثة الخارجي الرئيسي أو الأصل
@@ -278,4 +278,4 @@
تاريخ الإرسال المجدول
-
\ No newline at end of file
+
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
index 0a7621e14..049c909cf 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
@@ -150,16 +150,16 @@
A description of the nature of the call
- Call Note Added By
+ Einsatznotiz hinzugefügt von
- Call Note Added On
+ Einsatznotiz hinzugefügt am
Call Notes
- Call Note Text
+ Text der Einsatznotiz
Call Number
@@ -234,7 +234,7 @@
This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url.
- File Name
+ Dateiname
File Type
@@ -246,10 +246,10 @@
Find w3w Location
- Flag Call Note
+ Einsatznotiz zur Moderation melden
- Anrufbild markieren
+ Einsatzbild zur Moderation melden
Anrufdatei markieren
@@ -267,16 +267,16 @@
Dateizeitstempel
- Bild markieren
+ Bild melden
Datei markieren
- Flagged Reason
+ Meldegrund
- Reason for flagging
+ Grund für die Meldung
GPS
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
index dae748e0a..7e75d5ea7 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
@@ -295,10 +295,10 @@
Find w3w Location
- Flag Call Note
+ Report Call Note for Moderation
- Flag Call Image
+ Report Call Image for Moderation
Flag Call File
@@ -316,16 +316,16 @@
File Timestamp
- Flag Image
+ Report Image
Flag File
- Flagged Reason
+ Report Reason
- Reason for flagging
+ Reason for reporting
GPS
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
index 4667a209a..fdd543286 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
@@ -295,10 +295,10 @@
Encuentra la ubicación de w3w
- Marcar nota de llamada
+ Reportar nota de llamada para moderación
- Marcar imagen del incidente
+ Reportar imagen de la llamada para moderación
Marcar archivo del incidente
@@ -316,16 +316,16 @@
Marca de tiempo de archivo
- Marcar imagen
+ Reportar imagen
Marcar archivo
- Motivo marcado
+ Motivo del reporte
- Motivo de marcar
+ Motivo para reportar
GPS
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
index 03b749255..7c8b65f2d 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
@@ -150,16 +150,16 @@
A description of the nature of the call
- Call Note Added By
+ Note d’appel ajoutée par
- Call Note Added On
+ Note d’appel ajoutée le
Call Notes
- Call Note Text
+ Texte de la note d’appel
Call Number
@@ -234,7 +234,7 @@
This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url.
- File Name
+ Nom du fichier
File Type
@@ -246,10 +246,10 @@
Find w3w Location
- Flag Call Note
+ Signaler la note d’appel pour modération
- Signaler l'image de l'appel
+ Signaler l’image de l’appel pour modération
Signaler le fichier de l'appel
@@ -267,16 +267,16 @@
Horodatage du fichier
- Signaler l'image
+ Signaler l’image
Signaler le fichier
- Flagged Reason
+ Motif du signalement
- Reason for flagging
+ Motif du signalement
GPS
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
index 6c600e50b..df3f925d2 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
@@ -150,16 +150,16 @@
A description of the nature of the call
- Call Note Added By
+ Nota della chiamata aggiunta da
- Call Note Added On
+ Nota della chiamata aggiunta il
Call Notes
- Call Note Text
+ Testo della nota della chiamata
Call Number
@@ -234,7 +234,7 @@
This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url.
- File Name
+ Nome del file
File Type
@@ -246,10 +246,10 @@
Find w3w Location
- Flag Call Note
+ Segnala la nota della chiamata per la moderazione
- Segnala immagine chiamata
+ Segnala l’immagine della chiamata per la moderazione
Segnala file chiamata
@@ -273,10 +273,10 @@
Segnala file
- Flagged Reason
+ Motivo della segnalazione
- Reason for flagging
+ Motivo della segnalazione
GPS
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
index 50826950a..19aa4c7d3 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
@@ -150,16 +150,16 @@
A description of the nature of the call
- Call Note Added By
+ Notatkę dodał(a)
- Call Note Added On
+ Notatkę dodano
Call Notes
- Call Note Text
+ Tekst notatki ze zgłoszenia
Call Number
@@ -234,7 +234,7 @@
This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url.
- File Name
+ Nazwa pliku
File Type
@@ -246,10 +246,10 @@
Find w3w Location
- Flag Call Note
+ Zgłoś notatkę ze zgłoszenia do moderacji
- Oznacz obraz zgłoszenia
+ Zgłoś obraz ze zgłoszenia do moderacji
Oznacz plik zgłoszenia
@@ -267,16 +267,16 @@
Znacznik czasu pliku
- Oznacz obraz
+ Zgłoś obraz
Oznacz plik
- Flagged Reason
+ Powód zgłoszenia
- Reason for flagging
+ Powód zgłoszenia
GPS
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
index ddb0ee1d0..cd2d8b843 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
@@ -150,16 +150,16 @@
A description of the nature of the call
- Call Note Added By
+ Samtalsanteckning tillagd av
- Call Note Added On
+ Samtalsanteckning tillagd
Call Notes
- Call Note Text
+ Samtalsanteckningens text
Call Number
@@ -234,7 +234,7 @@
This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url.
- File Name
+ Filnamn
File Type
@@ -246,10 +246,10 @@
Find w3w Location
- Flag Call Note
+ Rapportera samtalsanteckning för moderering
- Flagga samtalsbild
+ Rapportera samtalsbild för moderering
Flagga samtalsfil
@@ -267,16 +267,16 @@
Filtidsstämpel
- Flagga bild
+ Rapportera bild
Flagga fil
- Flagged Reason
+ Rapportorsak
- Reason for flagging
+ Orsak till rapporteringen
GPS
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
index 6e6e7b2e5..bae02ba7e 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
@@ -150,16 +150,16 @@
A description of the nature of the call
- Call Note Added By
+ Нотатку виклику додав(ла)
- Call Note Added On
+ Нотатку виклику додано
Call Notes
- Call Note Text
+ Текст нотатки виклику
Call Number
@@ -234,7 +234,7 @@
This is an unauthenticated page for viewing this call only. You are not logged into Resgrid. Do not share this url.
- File Name
+ Назва файлу
File Type
@@ -246,10 +246,10 @@
Find w3w Location
- Flag Call Note
+ Повідомити про нотатку виклику для модерації
- Позначити зображення виклику
+ Повідомити про зображення виклику для модерації
Позначити файл виклику
@@ -267,16 +267,16 @@
Мітка часу файлу
- Позначити зображення
+ Повідомити про зображення
Позначити файл
- Flagged Reason
+ Причина повідомлення
- Reason for flagging
+ Причина повідомлення
GPS
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.ar.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.ar.resx
new file mode 100644
index 000000000..521f23055
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.ar.resx
@@ -0,0 +1,156 @@
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ القسم والمستخدم المبلّغ والعنصر مطلوبة.
+ أُعيد فتح الطلب بعد بلاغ جديد.
+ هذا الطلب خارج نطاق مراجعتك.
+ لا يمكن إكمال إلا طلبات المراجعة المعلّقة.
+ تعذّرت إزالة المحتوى المبلّغ عنه.
+ هذا الدليل خارج نطاق مراجعتك.
+ تم تنزيل الدليل الأصلي.
+ رسالة المحادثة غير متاحة أو لا يمكن الإبلاغ عنها.
+ الرسالة غير متاحة أو لا يمكن الإبلاغ عنها.
+ معرّف ملاحظة البلاغ غير صالح.
+ ملاحظة البلاغ غير متاحة أو لا يمكن الإبلاغ عنها.
+ معرّف صورة البلاغ غير صالح.
+ صورة البلاغ غير متاحة أو لا يمكن الإبلاغ عنها.
+ أُزيل المحتوى بعد المراجعة
+ أُزيلت هذه الرسالة بعد مراجعة المحتوى.
+ أُزيلت ملاحظة البلاغ هذه بعد مراجعة المحتوى.
+ أُزيلت هذه الرسالة بعد مراجعة المحتوى.
+ تمت إزالة المحتوى المبلّغ عنه.
+ تمت مراجعة الطلب ولم تتم إزالة أي محتوى.
+ <br/><br/><strong>ملاحظة المسؤول:</strong> {0}
+ اكتمل طلب مراجعة المحتوى
+ اكتمل بلاغ المراجعة الخاص بك بشأن {0} {1}. {2}{3}
+ النظام
+ رسالة محادثة
+ رسالة
+ ملاحظة بلاغ
+ صورة بلاغ
+ الطلبات
+ التقارير
+ عناصر تحكم المحادثة
+ إعدادات المحادثة
+ تصدير المحادثة
+ ابحث في طلبات المراجعة الجديدة والمكتملة حسب المستخدم الذي أضاف المحتوى أو المستخدم الذي أبلغ عنه.
+ الحالة
+ الكل
+ معلّق
+ مكتمل
+ نوع المحتوى
+ أضافه معرّف المستخدم
+ أبلغ عنه معرّف المستخدم
+ من
+ إلى
+ تحديث
+ جارٍ تحميل طلبات المراجعة…
+ لا توجد طلبات مراجعة تطابق عوامل التصفية هذه.
+ العنصر
+ الدليل الأصلي
+ أضافه
+ البلاغات
+ الإجراء
+ تمت إزالة المحتوى
+ لا إجراء
+ نوع محتوى غير معروف
+ المعرّف {0}
+ البلاغ {0}
+ تنزيل الدليل المحفوظ
+ النظام أو غير معروف
+ المجموعة {0}
+ ملاحظة الإكمال التي ستُرسل إلى المستخدمين المبلّغين (اختياري)
+ إزالة المحتوى
+ إكمال بلا إجراء
+ أكمله
+ لا توجد ملاحظة إكمال.
+ إخفاء سجل التدقيق
+ سجل التدقيق ({0})
+ بواسطة
+ لا توجد ملاحظة.
+ المستخدم المبلّغ
+ مسؤول القسم
+ مسؤول المجموعة
+ استيراد قديم
+ دور غير معروف
+ لا يوجد عنوان IP
+ التتبّع {0}
+ غير متاح
+ —
+ تعذّر تحميل طلبات المراجعة.
+ تعذّر إكمال طلب المراجعة.
+ أخرى
+ محتوى غير لائق
+ مضايقة أو إساءة
+ محتوى غير مرغوب فيه
+ معلومات حساسة
+ مخالفة السياسات
+ تم إرسال البلاغ
+ أُعيد فتح الطلب
+ اكتمل بلا إجراء
+ تمت إزالة المحتوى
+ تم تنزيل الدليل
+ حالة بلاغ المراجعة
+ الإبلاغ عن الرسالة للمراجعة
+ إغلاق
+ إلغاء
+ إبلاغ
+ جارٍ التحقق من حالة البلاغ…
+ الحالة
+ تمت إزالة المحتوى
+ لم تتم إزالة أي محتوى
+ ملاحظة المراجع
+ السبب
+ أضف ملاحظة (اختياري)
+ حالة بلاغ المراجعة الخاص بك هي
+ الإبلاغ للمراجعة
+ اشرح سبب ضرورة مراجعة هذه الرسالة
+ تم إرسال بلاغ المراجعة الخاص بك.
+ حذف الرسالة
+ كتم المستخدم
+ حظر المستخدم
+ إلغاء حظر المستخدم
+ قفل القناة
+ فتح القناة
+ حل البلاغ
+ الإجراء رقم {0}
+ جارٍ التحميل…
+ لا توجد أنشطة مراجعة.
+ الهدف
+ الوقت
+ الوحدة {0}
+ السابق
+ الصفحة {0}
+ التالي
+ السماح بإرفاق الصور
+ السماح بصور GIF
+ السماح بمشاركة الموقع
+ الرسائل العاجلة تتجاوز الكتم
+ المساعد مفعّل
+ الاحتفاظ بالرسائل (بالأيام، 0 = دائمًا)
+ الحد الأقصى لحجم المرفق (ميغابايت)
+ جارٍ الحفظ…
+ حفظ الإعدادات
+ تم الحفظ
+ تعذّر حفظ إعدادات المراجعة.
+ في قائمة الانتظار
+ قيد التشغيل
+ مكتمل
+ فشل
+ تعذّر طلب تصدير المحادثة.
+ التنسيق
+ تاريخ البدء
+ تاريخ الانتهاء
+ جارٍ الطلب…
+ طلب التصدير
+ تاريخ الطلب
+ النطاق
+ تنزيل
+ لا توجد مهام تصدير.
+ الآن
+ دليل-المراجعة-{0}
+ تم حذف هذه الرسالة.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs
new file mode 100644
index 000000000..3a9592b6f
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Resources;
+
+namespace Resgrid.Localization.Areas.User.Moderation
+{
+ /// Marker type used by ASP.NET Core localization for moderation resources.
+ public class Moderation
+ {
+ }
+
+ ///
+ /// Culture-explicit access to moderation resources for background/service messages where the
+ /// recipient's preferred culture can differ from the current request culture.
+ ///
+ public static class ModerationResources
+ {
+ private static readonly ResourceManager ResourceManager = new ResourceManager(
+ typeof(Moderation).FullName!, typeof(Moderation).Assembly);
+
+ public static string Get(string key, string? culture, params object[] arguments)
+ {
+ var cultureInfo = GetSupportedCulture(culture);
+ var value = ResourceManager.GetString(key, cultureInfo)
+ ?? ResourceManager.GetString(key, CultureInfo.GetCultureInfo("en"))
+ ?? key;
+
+ return arguments == null || arguments.Length == 0
+ ? value
+ : string.Format(cultureInfo, value, arguments);
+ }
+
+ public static string GetCurrent(string key, params object[] arguments)
+ {
+ return Get(key, CultureInfo.CurrentUICulture.Name, arguments);
+ }
+
+ public static IReadOnlyDictionary GetAll(string culture)
+ {
+ var resourceSet = ResourceManager.GetResourceSet(GetSupportedCulture(culture), true, false);
+ if (resourceSet == null)
+ return new Dictionary();
+
+ return resourceSet.Cast()
+ .Where(x => x.Key is string && x.Value is string)
+ .ToDictionary(x => (string)x.Key, x => (string)x.Value!);
+ }
+
+ private static CultureInfo GetSupportedCulture(string? culture)
+ {
+ var candidate = string.IsNullOrWhiteSpace(culture) ? "en" : culture.Trim();
+ var separator = candidate.IndexOfAny(new[] { '-', '_' });
+ if (separator > 0)
+ candidate = candidate.Substring(0, separator);
+
+ candidate = candidate.ToLowerInvariant();
+ return SupportedLocales.SupportedLanguagesMap.ContainsKey(candidate)
+ ? CultureInfo.GetCultureInfo(candidate)
+ : CultureInfo.GetCultureInfo("en");
+ }
+ }
+}
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.de.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.de.resx
new file mode 100644
index 000000000..d5d046748
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.de.resx
@@ -0,0 +1,156 @@
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ Abteilung, meldende Person und Element sind erforderlich.
+ Der Vorgang wurde nach einer neuen Meldung erneut geöffnet.
+ Dieser Vorgang liegt außerhalb Ihres Moderationsbereichs.
+ Nur ausstehende Moderationsvorgänge können abgeschlossen werden.
+ Der gemeldete Inhalt konnte nicht entfernt werden.
+ Dieses Beweismaterial liegt außerhalb Ihres Moderationsbereichs.
+ Originalbeweismaterial heruntergeladen.
+ Die Chatnachricht ist nicht verfügbar oder kann nicht gemeldet werden.
+ Die Nachricht ist nicht verfügbar oder kann nicht gemeldet werden.
+ Die Kennung der Einsatznotiz ist ungültig.
+ Die Einsatznotiz ist nicht verfügbar oder kann nicht gemeldet werden.
+ Die Kennung des Einsatzbildes ist ungültig.
+ Das Einsatzbild ist nicht verfügbar oder kann nicht gemeldet werden.
+ Inhalt durch Moderation entfernt
+ Diese Nachricht wurde durch die Moderation entfernt.
+ Diese Einsatznotiz wurde durch die Moderation entfernt.
+ Diese Nachricht wurde durch die Moderation entfernt.
+ Der gemeldete Inhalt wurde entfernt.
+ Der Vorgang wurde geprüft und es wurden keine Inhalte entfernt.
+ <br/><br/><strong>Hinweis der Administration:</strong> {0}
+ Moderationsvorgang abgeschlossen
+ Ihre Moderationsmeldung zu {0} {1} wurde abgeschlossen. {2}{3}
+ System
+ Chatnachricht
+ Nachricht
+ Einsatznotiz
+ Einsatzbild
+ Vorgänge
+ Berichte
+ Chat-Steuerung
+ Chat-Einstellungen
+ Chat-Exporte
+ Suchen Sie neue und abgeschlossene Moderationsvorgänge nach der Person, die den Inhalt hinzugefügt oder gemeldet hat.
+ Status
+ Alle
+ Ausstehend
+ Abgeschlossen
+ Inhaltstyp
+ Hinzugefügt von Benutzer-ID
+ Gemeldet von Benutzer-ID
+ Von
+ Bis
+ Aktualisieren
+ Moderationsvorgänge werden geladen…
+ Keine Moderationsvorgänge entsprechen diesen Filtern.
+ Element
+ Originalbeweismaterial
+ Hinzugefügt von
+ Meldungen
+ Aktion
+ Inhalt entfernt
+ Keine Aktion
+ Unbekannter Inhaltstyp
+ ID {0}
+ Einsatz {0}
+ Gespeichertes Beweismaterial herunterladen
+ System oder unbekannt
+ Gruppe {0}
+ Abschlussnotiz an meldende Benutzer (optional)
+ Inhalt entfernen
+ Ohne Aktion abschließen
+ Abgeschlossen von
+ Keine Abschlussnotiz.
+ Auditprotokoll ausblenden
+ Auditprotokoll ({0})
+ von
+ Keine Notiz.
+ Meldende Person
+ Abteilungsadministration
+ Gruppenadministration
+ Altimport
+ Unbekannte Rolle
+ Keine IP-Adresse
+ Trace {0}
+ nicht verfügbar
+ —
+ Die Moderationsvorgänge konnten nicht geladen werden.
+ Der Moderationsvorgang konnte nicht abgeschlossen werden.
+ Sonstiges
+ Unangemessener Inhalt
+ Belästigung oder Missbrauch
+ Spam
+ Vertrauliche Informationen
+ Richtlinienverstoß
+ Meldung eingereicht
+ Vorgang erneut geöffnet
+ Ohne Aktion abgeschlossen
+ Inhalt entfernt
+ Beweismaterial heruntergeladen
+ Status der Moderationsmeldung
+ Nachricht zur Moderation melden
+ Schließen
+ Abbrechen
+ Melden
+ Meldungsstatus wird geprüft…
+ Status
+ Der Inhalt wurde entfernt
+ Es wurde kein Inhalt entfernt
+ Moderationsnotiz
+ Grund
+ Notiz hinzufügen (optional)
+ Ihre Moderationsmeldung ist
+ Zur Moderation melden
+ Erklären Sie, warum diese Nachricht geprüft werden sollte
+ Ihre Moderationsmeldung wurde eingereicht.
+ Nachricht löschen
+ Benutzer stummschalten
+ Benutzer sperren
+ Benutzersperre aufheben
+ Kanal sperren
+ Kanal entsperren
+ Meldung klären
+ Aktion Nr. {0}
+ Wird geladen…
+ Keine Moderationsaktivität.
+ Ziel
+ Zeitpunkt
+ Einheit {0}
+ Zurück
+ Seite {0}
+ Weiter
+ Bildanhänge erlauben
+ GIFs erlauben
+ Standortfreigabe erlauben
+ Dringende Nachrichten umgehen die Stummschaltung
+ Assistent aktiviert
+ Nachrichtenaufbewahrung (Tage, 0 = unbegrenzt)
+ Maximale Anhangsgröße (MB)
+ Speichern…
+ Einstellungen speichern
+ Gespeichert
+ Die Moderationseinstellungen konnten nicht gespeichert werden.
+ In Warteschlange
+ Wird ausgeführt
+ Abgeschlossen
+ Fehlgeschlagen
+ Der Chat-Export konnte nicht angefordert werden.
+ Format
+ Startdatum
+ Enddatum
+ Wird angefordert…
+ Export anfordern
+ Angefordert
+ Zeitraum
+ Herunterladen
+ Keine Exportaufträge.
+ Jetzt
+ moderationsbeweis-{0}
+ Diese Nachricht wurde gelöscht.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.en.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.en.resx
new file mode 100644
index 000000000..0169a7113
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.en.resx
@@ -0,0 +1,156 @@
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ Department, reporter, and item are required.
+ Request reopened after a new report.
+ This request is outside your moderation scope.
+ Only pending moderation requests can be completed.
+ The reported content could not be removed.
+ This evidence is outside your moderation scope.
+ Original evidence downloaded.
+ The chat message is unavailable or cannot be reported.
+ The message is unavailable or cannot be reported.
+ The call note identifier is invalid.
+ The call note is unavailable or cannot be reported.
+ The call image identifier is invalid.
+ The call image is unavailable or cannot be reported.
+ Content removed by moderation
+ This message was removed by moderation.
+ This call note was removed by moderation.
+ This message was removed by moderation.
+ The reported content was removed.
+ The request was reviewed and no content was removed.
+ <br/><br/><strong>Administrator note:</strong> {0}
+ Moderation Request Completed
+ Your moderation report for {0} {1} has been completed. {2}{3}
+ System
+ Chat message
+ Message
+ Call note
+ Call image
+ Requests
+ Reports
+ Chat controls
+ Chat settings
+ Chat exports
+ Search new and completed moderation requests by the user who added the content or the user who reported it.
+ Status
+ All
+ Pending
+ Completed
+ Content type
+ Added by user ID
+ Reported by user ID
+ From
+ To
+ Refresh
+ Loading moderation requests…
+ No moderation requests match these filters.
+ Item
+ Original evidence
+ Added by
+ Reports
+ Action
+ Content removed
+ No action
+ Unknown content type
+ ID {0}
+ Call {0}
+ Download retained evidence
+ System or unknown
+ group {0}
+ Completion note sent to reporting users (optional)
+ Remove content
+ Complete—no action
+ Completed by
+ No completion note.
+ Hide audit trail
+ Audit trail ({0})
+ by
+ No note.
+ Reporter
+ Department administrator
+ Group administrator
+ Legacy import
+ Unknown role
+ No IP address
+ trace {0}
+ not available
+ —
+ Unable to load moderation requests.
+ Unable to complete the moderation request.
+ Other
+ Inappropriate content
+ Harassment or abuse
+ Spam
+ Sensitive information
+ Policy violation
+ Report submitted
+ Request reopened
+ Completed without action
+ Content removed
+ Evidence downloaded
+ Moderation report status
+ Report message for moderation
+ Close
+ Cancel
+ Report
+ Checking report status…
+ Status
+ Content was removed
+ No content was removed
+ Moderator note
+ Reason
+ Add a note (optional)
+ Your moderation report is
+ Report for moderation
+ Explain why this message should be reviewed
+ Your moderation report was submitted.
+ Delete message
+ Mute user
+ Ban user
+ Unban user
+ Lock channel
+ Unlock channel
+ Resolve report
+ Action #{0}
+ Loading…
+ No moderation activity.
+ Target
+ When
+ Unit {0}
+ Previous
+ Page {0}
+ Next
+ Allow image attachments
+ Allow GIFs
+ Allow location sharing
+ Urgent messages override mute
+ Assistant enabled
+ Message retention (days, 0 = forever)
+ Maximum attachment size (MB)
+ Saving…
+ Save settings
+ Saved
+ Failed to save moderation settings.
+ Queued
+ Running
+ Complete
+ Failed
+ Failed to request a chat export.
+ Format
+ Start date
+ End date
+ Requesting…
+ Request export
+ Requested
+ Range
+ Download
+ No export jobs.
+ Now
+ moderation-evidence-{0}
+ This message was deleted.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.es.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.es.resx
new file mode 100644
index 000000000..6281daed3
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.es.resx
@@ -0,0 +1,156 @@
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ Se requieren el departamento, el usuario que reporta y el elemento.
+ La solicitud se reabrió tras un nuevo reporte.
+ Esta solicitud está fuera de su ámbito de moderación.
+ Solo se pueden completar las solicitudes de moderación pendientes.
+ No se pudo eliminar el contenido reportado.
+ Esta evidencia está fuera de su ámbito de moderación.
+ Se descargó la evidencia original.
+ El mensaje de chat no está disponible o no se puede reportar.
+ El mensaje no está disponible o no se puede reportar.
+ El identificador de la nota de llamada no es válido.
+ La nota de llamada no está disponible o no se puede reportar.
+ El identificador de la imagen de llamada no es válido.
+ La imagen de llamada no está disponible o no se puede reportar.
+ Contenido eliminado por moderación
+ Este mensaje fue eliminado por moderación.
+ Esta nota de llamada fue eliminada por moderación.
+ Este mensaje fue eliminado por moderación.
+ El contenido reportado fue eliminado.
+ La solicitud fue revisada y no se eliminó ningún contenido.
+ <br/><br/><strong>Nota del administrador:</strong> {0}
+ Solicitud de moderación completada
+ Su reporte de moderación sobre {0} {1} se ha completado. {2}{3}
+ Sistema
+ mensaje de chat
+ mensaje
+ nota de llamada
+ imagen de llamada
+ Solicitudes
+ Informes
+ Controles del chat
+ Configuración del chat
+ Exportaciones del chat
+ Busque solicitudes de moderación nuevas y completadas por el usuario que agregó el contenido o por quien lo reportó.
+ Estado
+ Todos
+ Pendiente
+ Completada
+ Tipo de contenido
+ Agregado por ID de usuario
+ Reportado por ID de usuario
+ Desde
+ Hasta
+ Actualizar
+ Cargando solicitudes de moderación…
+ Ninguna solicitud de moderación coincide con estos filtros.
+ Elemento
+ Evidencia original
+ Agregado por
+ Reportes
+ Acción
+ Contenido eliminado
+ Sin acción
+ Tipo de contenido desconocido
+ ID {0}
+ Llamada {0}
+ Descargar evidencia conservada
+ Sistema o desconocido
+ grupo {0}
+ Nota de finalización enviada a los usuarios que reportaron (opcional)
+ Eliminar contenido
+ Completar sin acción
+ Completada por
+ Sin nota de finalización.
+ Ocultar registro de auditoría
+ Registro de auditoría ({0})
+ por
+ Sin nota.
+ Usuario que reportó
+ Administrador del departamento
+ Administrador del grupo
+ Importación heredada
+ Rol desconocido
+ Sin dirección IP
+ seguimiento {0}
+ no disponible
+ —
+ No se pudieron cargar las solicitudes de moderación.
+ No se pudo completar la solicitud de moderación.
+ Otro
+ Contenido inapropiado
+ Acoso o abuso
+ Correo no deseado
+ Información confidencial
+ Infracción de políticas
+ Reporte enviado
+ Solicitud reabierta
+ Completada sin acción
+ Contenido eliminado
+ Evidencia descargada
+ Estado del reporte de moderación
+ Reportar mensaje para moderación
+ Cerrar
+ Cancelar
+ Reportar
+ Comprobando el estado del reporte…
+ Estado
+ El contenido fue eliminado
+ No se eliminó ningún contenido
+ Nota del moderador
+ Motivo
+ Agregue una nota (opcional)
+ Su reporte de moderación está
+ Reportar para moderación
+ Explique por qué se debe revisar este mensaje
+ Su reporte de moderación fue enviado.
+ Eliminar mensaje
+ Silenciar usuario
+ Bloquear usuario
+ Desbloquear usuario
+ Bloquear canal
+ Desbloquear canal
+ Resolver reporte
+ Acción n.º {0}
+ Cargando…
+ No hay actividad de moderación.
+ Objetivo
+ Cuándo
+ Unidad {0}
+ Anterior
+ Página {0}
+ Siguiente
+ Permitir imágenes adjuntas
+ Permitir GIF
+ Permitir compartir la ubicación
+ Los mensajes urgentes ignoran el silencio
+ Asistente activado
+ Conservación de mensajes (días, 0 = para siempre)
+ Tamaño máximo de archivo adjunto (MB)
+ Guardando…
+ Guardar configuración
+ Guardado
+ No se pudo guardar la configuración de moderación.
+ En cola
+ En curso
+ Completada
+ Fallida
+ No se pudo solicitar una exportación del chat.
+ Formato
+ Fecha de inicio
+ Fecha de fin
+ Solicitando…
+ Solicitar exportación
+ Solicitada
+ Intervalo
+ Descargar
+ No hay tareas de exportación.
+ Ahora
+ evidencia-moderacion-{0}
+ Este mensaje fue eliminado.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.fr.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.fr.resx
new file mode 100644
index 000000000..76aaaafc5
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.fr.resx
@@ -0,0 +1,156 @@
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ Le service, l’auteur du signalement et l’élément sont obligatoires.
+ La demande a été rouverte après un nouveau signalement.
+ Cette demande ne relève pas de votre périmètre de modération.
+ Seules les demandes de modération en attente peuvent être terminées.
+ Le contenu signalé n’a pas pu être supprimé.
+ Cette preuve ne relève pas de votre périmètre de modération.
+ Preuve d’origine téléchargée.
+ Le message de discussion est indisponible ou ne peut pas être signalé.
+ Le message est indisponible ou ne peut pas être signalé.
+ L’identifiant de la note d’appel n’est pas valide.
+ La note d’appel est indisponible ou ne peut pas être signalée.
+ L’identifiant de l’image d’appel n’est pas valide.
+ L’image d’appel est indisponible ou ne peut pas être signalée.
+ Contenu supprimé par la modération
+ Ce message a été supprimé par la modération.
+ Cette note d’appel a été supprimée par la modération.
+ Ce message a été supprimé par la modération.
+ Le contenu signalé a été supprimé.
+ La demande a été examinée et aucun contenu n’a été supprimé.
+ <br/><br/><strong>Note de l’administrateur :</strong> {0}
+ Demande de modération terminée
+ Votre signalement de modération concernant {0} {1} est terminé. {2}{3}
+ Système
+ message de discussion
+ message
+ note d’appel
+ image d’appel
+ Demandes
+ Rapports
+ Contrôles de discussion
+ Paramètres de discussion
+ Exports de discussion
+ Recherchez les demandes de modération nouvelles et terminées par l’utilisateur qui a ajouté le contenu ou celui qui l’a signalé.
+ État
+ Tous
+ En attente
+ Terminée
+ Type de contenu
+ Ajouté par l’ID utilisateur
+ Signalé par l’ID utilisateur
+ Du
+ Au
+ Actualiser
+ Chargement des demandes de modération…
+ Aucune demande de modération ne correspond à ces filtres.
+ Élément
+ Preuve d’origine
+ Ajouté par
+ Signalements
+ Action
+ Contenu supprimé
+ Aucune action
+ Type de contenu inconnu
+ ID {0}
+ Appel {0}
+ Télécharger la preuve conservée
+ Système ou inconnu
+ groupe {0}
+ Note de clôture envoyée aux utilisateurs ayant signalé (facultatif)
+ Supprimer le contenu
+ Terminer sans action
+ Terminée par
+ Aucune note de clôture.
+ Masquer le journal d’audit
+ Journal d’audit ({0})
+ par
+ Aucune note.
+ Auteur du signalement
+ Administrateur du service
+ Administrateur du groupe
+ Importation héritée
+ Rôle inconnu
+ Aucune adresse IP
+ trace {0}
+ indisponible
+ —
+ Impossible de charger les demandes de modération.
+ Impossible de terminer la demande de modération.
+ Autre
+ Contenu inapproprié
+ Harcèlement ou abus
+ Courrier indésirable
+ Informations sensibles
+ Violation de la politique
+ Signalement envoyé
+ Demande rouverte
+ Terminée sans action
+ Contenu supprimé
+ Preuve téléchargée
+ État du signalement de modération
+ Signaler le message pour modération
+ Fermer
+ Annuler
+ Signaler
+ Vérification de l’état du signalement…
+ État
+ Le contenu a été supprimé
+ Aucun contenu n’a été supprimé
+ Note du modérateur
+ Motif
+ Ajouter une note (facultatif)
+ Votre signalement de modération est
+ Signaler pour modération
+ Expliquez pourquoi ce message doit être examiné
+ Votre signalement de modération a été envoyé.
+ Supprimer le message
+ Mettre l’utilisateur en sourdine
+ Bannir l’utilisateur
+ Annuler le bannissement
+ Verrouiller le canal
+ Déverrouiller le canal
+ Résoudre le signalement
+ Action n° {0}
+ Chargement…
+ Aucune activité de modération.
+ Cible
+ Date
+ Unité {0}
+ Précédent
+ Page {0}
+ Suivant
+ Autoriser les images jointes
+ Autoriser les GIF
+ Autoriser le partage de position
+ Les messages urgents ignorent la sourdine
+ Assistant activé
+ Conservation des messages (jours, 0 = illimitée)
+ Taille maximale des pièces jointes (Mo)
+ Enregistrement…
+ Enregistrer les paramètres
+ Enregistré
+ Impossible d’enregistrer les paramètres de modération.
+ En attente
+ En cours
+ Terminé
+ Échec
+ Impossible de demander un export de discussion.
+ Format
+ Date de début
+ Date de fin
+ Demande en cours…
+ Demander l’export
+ Demandé
+ Période
+ Télécharger
+ Aucune tâche d’export.
+ Maintenant
+ preuve-moderation-{0}
+ Ce message a été supprimé.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.it.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.it.resx
new file mode 100644
index 000000000..d974d08c4
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.it.resx
@@ -0,0 +1,156 @@
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ Il dipartimento, l’utente segnalante e l’elemento sono obbligatori.
+ La richiesta è stata riaperta dopo una nuova segnalazione.
+ Questa richiesta non rientra nel tuo ambito di moderazione.
+ È possibile completare solo le richieste di moderazione in sospeso.
+ Non è stato possibile rimuovere il contenuto segnalato.
+ Questa prova non rientra nel tuo ambito di moderazione.
+ Prova originale scaricata.
+ Il messaggio della chat non è disponibile o non può essere segnalato.
+ Il messaggio non è disponibile o non può essere segnalato.
+ L’identificatore della nota della chiamata non è valido.
+ La nota della chiamata non è disponibile o non può essere segnalata.
+ L’identificatore dell’immagine della chiamata non è valido.
+ L’immagine della chiamata non è disponibile o non può essere segnalata.
+ Contenuto rimosso dalla moderazione
+ Questo messaggio è stato rimosso dalla moderazione.
+ Questa nota della chiamata è stata rimossa dalla moderazione.
+ Questo messaggio è stato rimosso dalla moderazione.
+ Il contenuto segnalato è stato rimosso.
+ La richiesta è stata esaminata e non è stato rimosso alcun contenuto.
+ <br/><br/><strong>Nota dell’amministratore:</strong> {0}
+ Richiesta di moderazione completata
+ La tua segnalazione di moderazione relativa a {0} {1} è stata completata. {2}{3}
+ Sistema
+ messaggio della chat
+ messaggio
+ nota della chiamata
+ immagine della chiamata
+ Richieste
+ Rapporti
+ Controlli della chat
+ Impostazioni della chat
+ Esportazioni della chat
+ Cerca le richieste di moderazione nuove e completate in base all’utente che ha aggiunto il contenuto o che lo ha segnalato.
+ Stato
+ Tutte
+ In sospeso
+ Completata
+ Tipo di contenuto
+ Aggiunto dall’ID utente
+ Segnalato dall’ID utente
+ Da
+ A
+ Aggiorna
+ Caricamento delle richieste di moderazione…
+ Nessuna richiesta di moderazione corrisponde a questi filtri.
+ Elemento
+ Prova originale
+ Aggiunto da
+ Segnalazioni
+ Azione
+ Contenuto rimosso
+ Nessuna azione
+ Tipo di contenuto sconosciuto
+ ID {0}
+ Chiamata {0}
+ Scarica la prova conservata
+ Sistema o sconosciuto
+ gruppo {0}
+ Nota di completamento inviata agli utenti segnalanti (facoltativa)
+ Rimuovi contenuto
+ Completa senza azione
+ Completata da
+ Nessuna nota di completamento.
+ Nascondi registro di controllo
+ Registro di controllo ({0})
+ da
+ Nessuna nota.
+ Utente segnalante
+ Amministratore del dipartimento
+ Amministratore del gruppo
+ Importazione precedente
+ Ruolo sconosciuto
+ Nessun indirizzo IP
+ traccia {0}
+ non disponibile
+ —
+ Impossibile caricare le richieste di moderazione.
+ Impossibile completare la richiesta di moderazione.
+ Altro
+ Contenuto inappropriato
+ Molestie o abusi
+ Posta indesiderata
+ Informazioni sensibili
+ Violazione delle norme
+ Segnalazione inviata
+ Richiesta riaperta
+ Completata senza azione
+ Contenuto rimosso
+ Prova scaricata
+ Stato della segnalazione di moderazione
+ Segnala il messaggio per la moderazione
+ Chiudi
+ Annulla
+ Segnala
+ Verifica dello stato della segnalazione…
+ Stato
+ Il contenuto è stato rimosso
+ Non è stato rimosso alcun contenuto
+ Nota del moderatore
+ Motivo
+ Aggiungi una nota (facoltativa)
+ La tua segnalazione di moderazione è
+ Segnala per la moderazione
+ Spiega perché questo messaggio dovrebbe essere esaminato
+ La tua segnalazione di moderazione è stata inviata.
+ Elimina messaggio
+ Silenzia utente
+ Blocca utente
+ Sblocca utente
+ Blocca canale
+ Sblocca canale
+ Risolvi segnalazione
+ Azione n. {0}
+ Caricamento…
+ Nessuna attività di moderazione.
+ Destinazione
+ Data
+ Unità {0}
+ Precedente
+ Pagina {0}
+ Successiva
+ Consenti allegati immagine
+ Consenti GIF
+ Consenti condivisione della posizione
+ I messaggi urgenti ignorano il silenziamento
+ Assistente abilitato
+ Conservazione dei messaggi (giorni, 0 = per sempre)
+ Dimensione massima allegato (MB)
+ Salvataggio…
+ Salva impostazioni
+ Salvato
+ Impossibile salvare le impostazioni di moderazione.
+ In coda
+ In corso
+ Completata
+ Non riuscita
+ Impossibile richiedere un’esportazione della chat.
+ Formato
+ Data di inizio
+ Data di fine
+ Richiesta in corso…
+ Richiedi esportazione
+ Richiesta
+ Intervallo
+ Scarica
+ Nessuna attività di esportazione.
+ Ora
+ prova-moderazione-{0}
+ Questo messaggio è stato eliminato.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.pl.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.pl.resx
new file mode 100644
index 000000000..9b2096bbb
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.pl.resx
@@ -0,0 +1,156 @@
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ Wymagane są dział, osoba zgłaszająca i element.
+ Wniosek został ponownie otwarty po nowym zgłoszeniu.
+ Ten wniosek znajduje się poza zakresem Twojej moderacji.
+ Można zakończyć tylko oczekujące wnioski o moderację.
+ Nie udało się usunąć zgłoszonej treści.
+ Ten materiał dowodowy znajduje się poza zakresem Twojej moderacji.
+ Pobrano oryginalny materiał dowodowy.
+ Wiadomość czatu jest niedostępna lub nie można jej zgłosić.
+ Wiadomość jest niedostępna lub nie można jej zgłosić.
+ Identyfikator notatki ze zgłoszenia jest nieprawidłowy.
+ Notatka ze zgłoszenia jest niedostępna lub nie można jej zgłosić.
+ Identyfikator obrazu ze zgłoszenia jest nieprawidłowy.
+ Obraz ze zgłoszenia jest niedostępny lub nie można go zgłosić.
+ Treść usunięta przez moderację
+ Ta wiadomość została usunięta przez moderację.
+ Ta notatka ze zgłoszenia została usunięta przez moderację.
+ Ta wiadomość została usunięta przez moderację.
+ Zgłoszona treść została usunięta.
+ Wniosek został rozpatrzony i nie usunięto żadnej treści.
+ <br/><br/><strong>Notatka administratora:</strong> {0}
+ Wniosek o moderację zakończony
+ Twoje zgłoszenie do moderacji dotyczące elementu {0} {1} zostało zakończone. {2}{3}
+ System
+ wiadomość czatu
+ wiadomość
+ notatka ze zgłoszenia
+ obraz ze zgłoszenia
+ Wnioski
+ Raporty
+ Kontrola czatu
+ Ustawienia czatu
+ Eksporty czatu
+ Wyszukuj nowe i zakończone wnioski o moderację według użytkownika, który dodał treść, lub użytkownika, który ją zgłosił.
+ Stan
+ Wszystkie
+ Oczekujący
+ Zakończony
+ Typ treści
+ Dodał użytkownik o identyfikatorze
+ Zgłosił użytkownik o identyfikatorze
+ Od
+ Do
+ Odśwież
+ Ładowanie wniosków o moderację…
+ Żaden wniosek o moderację nie pasuje do tych filtrów.
+ Element
+ Oryginalny materiał dowodowy
+ Dodane przez
+ Zgłoszenia
+ Działanie
+ Treść usunięta
+ Bez działania
+ Nieznany typ treści
+ ID {0}
+ Zgłoszenie {0}
+ Pobierz zachowany materiał dowodowy
+ System lub nieznany
+ grupa {0}
+ Notatka końcowa wysyłana zgłaszającym użytkownikom (opcjonalnie)
+ Usuń treść
+ Zakończ bez działania
+ Zakończone przez
+ Brak notatki końcowej.
+ Ukryj dziennik audytu
+ Dziennik audytu ({0})
+ przez
+ Brak notatki.
+ Osoba zgłaszająca
+ Administrator działu
+ Administrator grupy
+ Import starszych danych
+ Nieznana rola
+ Brak adresu IP
+ ślad {0}
+ niedostępne
+ —
+ Nie można załadować wniosków o moderację.
+ Nie można zakończyć wniosku o moderację.
+ Inne
+ Nieodpowiednia treść
+ Nękanie lub przemoc
+ Niechciana treść
+ Informacje poufne
+ Naruszenie zasad
+ Zgłoszenie przesłane
+ Wniosek ponownie otwarty
+ Zakończono bez działania
+ Treść usunięta
+ Materiał dowodowy pobrany
+ Stan zgłoszenia do moderacji
+ Zgłoś wiadomość do moderacji
+ Zamknij
+ Anuluj
+ Zgłoś
+ Sprawdzanie stanu zgłoszenia…
+ Stan
+ Treść została usunięta
+ Nie usunięto żadnej treści
+ Notatka moderatora
+ Powód
+ Dodaj notatkę (opcjonalnie)
+ Twoje zgłoszenie do moderacji ma stan
+ Zgłoś do moderacji
+ Wyjaśnij, dlaczego ta wiadomość powinna zostać sprawdzona
+ Twoje zgłoszenie do moderacji zostało przesłane.
+ Usuń wiadomość
+ Wycisz użytkownika
+ Zablokuj użytkownika
+ Odblokuj użytkownika
+ Zablokuj kanał
+ Odblokuj kanał
+ Rozstrzygnij zgłoszenie
+ Działanie nr {0}
+ Ładowanie…
+ Brak aktywności moderacyjnej.
+ Cel
+ Czas
+ Jednostka {0}
+ Poprzednia
+ Strona {0}
+ Następna
+ Zezwalaj na załączniki graficzne
+ Zezwalaj na pliki GIF
+ Zezwalaj na udostępnianie lokalizacji
+ Pilne wiadomości pomijają wyciszenie
+ Asystent włączony
+ Przechowywanie wiadomości (dni, 0 = bezterminowo)
+ Maksymalny rozmiar załącznika (MB)
+ Zapisywanie…
+ Zapisz ustawienia
+ Zapisano
+ Nie udało się zapisać ustawień moderacji.
+ W kolejce
+ W toku
+ Zakończony
+ Niepowodzenie
+ Nie udało się zażądać eksportu czatu.
+ Format
+ Data początkowa
+ Data końcowa
+ Wysyłanie żądania…
+ Zażądaj eksportu
+ Zażądano
+ Zakres
+ Pobierz
+ Brak zadań eksportu.
+ Teraz
+ dowod-moderacji-{0}
+ Ta wiadomość została usunięta.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.sv.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.sv.resx
new file mode 100644
index 000000000..f39fe698e
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.sv.resx
@@ -0,0 +1,156 @@
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ Avdelning, rapportör och objekt krävs.
+ Ärendet öppnades igen efter en ny rapport.
+ Det här ärendet ligger utanför ditt modereringsområde.
+ Endast väntande modereringsärenden kan slutföras.
+ Det rapporterade innehållet kunde inte tas bort.
+ Det här bevismaterialet ligger utanför ditt modereringsområde.
+ Det ursprungliga bevismaterialet hämtades.
+ Chattmeddelandet är inte tillgängligt eller kan inte rapporteras.
+ Meddelandet är inte tillgängligt eller kan inte rapporteras.
+ Identifieraren för samtalsanteckningen är ogiltig.
+ Samtalsanteckningen är inte tillgänglig eller kan inte rapporteras.
+ Identifieraren för samtalsbilden är ogiltig.
+ Samtalsbilden är inte tillgänglig eller kan inte rapporteras.
+ Innehåll borttaget av moderator
+ Det här meddelandet togs bort av en moderator.
+ Den här samtalsanteckningen togs bort av en moderator.
+ Det här meddelandet togs bort av en moderator.
+ Det rapporterade innehållet togs bort.
+ Ärendet granskades och inget innehåll togs bort.
+ <br/><br/><strong>Administratörens anteckning:</strong> {0}
+ Modereringsärendet är slutfört
+ Din modereringsrapport för {0} {1} är slutförd. {2}{3}
+ System
+ chattmeddelande
+ meddelande
+ samtalsanteckning
+ samtalsbild
+ Ärenden
+ Rapporter
+ Chattkontroller
+ Chattinställningar
+ Chattexporter
+ Sök nya och slutförda modereringsärenden efter användaren som lade till innehållet eller användaren som rapporterade det.
+ Status
+ Alla
+ Väntande
+ Slutförd
+ Innehållstyp
+ Tillagt av användar-ID
+ Rapporterat av användar-ID
+ Från
+ Till
+ Uppdatera
+ Läser in modereringsärenden…
+ Inga modereringsärenden matchar filtren.
+ Objekt
+ Ursprungligt bevismaterial
+ Tillagt av
+ Rapporter
+ Åtgärd
+ Innehåll borttaget
+ Ingen åtgärd
+ Okänd innehållstyp
+ ID {0}
+ Samtal {0}
+ Hämta sparat bevismaterial
+ System eller okänd
+ grupp {0}
+ Slutanteckning som skickas till rapporterande användare (valfritt)
+ Ta bort innehåll
+ Slutför utan åtgärd
+ Slutförd av
+ Ingen slutanteckning.
+ Dölj granskningslogg
+ Granskningslogg ({0})
+ av
+ Ingen anteckning.
+ Rapportör
+ Avdelningsadministratör
+ Gruppadministratör
+ Äldre import
+ Okänd roll
+ Ingen IP-adress
+ spårning {0}
+ inte tillgänglig
+ —
+ Det gick inte att läsa in modereringsärendena.
+ Det gick inte att slutföra modereringsärendet.
+ Annat
+ Olämpligt innehåll
+ Trakasserier eller övergrepp
+ Skräppost
+ Känslig information
+ Policyöverträdelse
+ Rapport inskickad
+ Ärendet öppnades igen
+ Slutförd utan åtgärd
+ Innehåll borttaget
+ Bevismaterial hämtat
+ Status för modereringsrapport
+ Rapportera meddelande för moderering
+ Stäng
+ Avbryt
+ Rapportera
+ Kontrollerar rapportstatus…
+ Status
+ Innehållet togs bort
+ Inget innehåll togs bort
+ Moderatorns anteckning
+ Orsak
+ Lägg till en anteckning (valfritt)
+ Din modereringsrapport är
+ Rapportera för moderering
+ Förklara varför det här meddelandet bör granskas
+ Din modereringsrapport skickades.
+ Ta bort meddelande
+ Tysta användare
+ Blockera användare
+ Häv blockering av användare
+ Lås kanal
+ Lås upp kanal
+ Lös rapport
+ Åtgärd nr {0}
+ Läser in…
+ Ingen modereringsaktivitet.
+ Mål
+ Tidpunkt
+ Enhet {0}
+ Föregående
+ Sida {0}
+ Nästa
+ Tillåt bildbilagor
+ Tillåt GIF-bilder
+ Tillåt platsdelning
+ Brådskande meddelanden åsidosätter tyst läge
+ Assistent aktiverad
+ Lagring av meddelanden (dagar, 0 = för alltid)
+ Största bilagestorlek (MB)
+ Sparar…
+ Spara inställningar
+ Sparat
+ Det gick inte att spara modereringsinställningarna.
+ I kö
+ Pågår
+ Slutförd
+ Misslyckades
+ Det gick inte att begära en chattexport.
+ Format
+ Startdatum
+ Slutdatum
+ Begär…
+ Begär export
+ Begärd
+ Intervall
+ Hämta
+ Inga exportjobb.
+ Nu
+ modereringsbevis-{0}
+ Det här meddelandet har tagits bort.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.uk.resx b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.uk.resx
new file mode 100644
index 000000000..c1fbefb3f
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Moderation/Moderation.uk.resx
@@ -0,0 +1,156 @@
+
+
+ text/microsoft-resx
+ 2.0
+ System.Resources.ResXResourceReader, System.Windows.Forms
+ System.Resources.ResXResourceWriter, System.Windows.Forms
+ Необхідно вказати підрозділ, користувача, який повідомляє, і елемент.
+ Запит повторно відкрито після нового повідомлення.
+ Цей запит перебуває поза межами вашої модерації.
+ Завершити можна лише запити на модерацію, що очікують розгляду.
+ Не вдалося видалити вміст, про який повідомлено.
+ Цей доказ перебуває поза межами вашої модерації.
+ Оригінальний доказ завантажено.
+ Повідомлення чату недоступне або про нього неможливо повідомити.
+ Повідомлення недоступне або про нього неможливо повідомити.
+ Ідентифікатор нотатки виклику недійсний.
+ Нотатка виклику недоступна або про неї неможливо повідомити.
+ Ідентифікатор зображення виклику недійсний.
+ Зображення виклику недоступне або про нього неможливо повідомити.
+ Вміст видалено модерацією
+ Це повідомлення видалено модерацією.
+ Цю нотатку виклику видалено модерацією.
+ Це повідомлення видалено модерацією.
+ Вміст, про який повідомлено, видалено.
+ Запит розглянуто, вміст не видалено.
+ <br/><br/><strong>Примітка адміністратора:</strong> {0}
+ Запит на модерацію завершено
+ Ваше повідомлення для модерації щодо {0} {1} завершено. {2}{3}
+ Система
+ повідомлення чату
+ повідомлення
+ нотатка виклику
+ зображення виклику
+ Запити
+ Звіти
+ Керування чатом
+ Налаштування чату
+ Експорт чату
+ Шукайте нові й завершені запити на модерацію за користувачем, який додав вміст, або користувачем, який повідомив про нього.
+ Стан
+ Усі
+ Очікує
+ Завершено
+ Тип вмісту
+ Додано користувачем з ID
+ Повідомлено користувачем з ID
+ Від
+ До
+ Оновити
+ Завантаження запитів на модерацію…
+ Жоден запит на модерацію не відповідає цим фільтрам.
+ Елемент
+ Оригінальний доказ
+ Додано
+ Повідомлення
+ Дія
+ Вміст видалено
+ Без дій
+ Невідомий тип вмісту
+ ID {0}
+ Виклик {0}
+ Завантажити збережений доказ
+ Система або невідомо
+ група {0}
+ Примітка про завершення для користувачів, які повідомили (необов’язково)
+ Видалити вміст
+ Завершити без дій
+ Завершив(ла)
+ Примітки про завершення немає.
+ Приховати журнал аудиту
+ Журнал аудиту ({0})
+ користувачем
+ Примітки немає.
+ Користувач, який повідомив
+ Адміністратор підрозділу
+ Адміністратор групи
+ Імпорт застарілих даних
+ Невідома роль
+ IP-адреси немає
+ трасування {0}
+ недоступно
+ —
+ Не вдалося завантажити запити на модерацію.
+ Не вдалося завершити запит на модерацію.
+ Інше
+ Неприйнятний вміст
+ Домагання або образи
+ Небажаний вміст
+ Конфіденційна інформація
+ Порушення правил
+ Повідомлення надіслано
+ Запит повторно відкрито
+ Завершено без дій
+ Вміст видалено
+ Доказ завантажено
+ Стан повідомлення для модерації
+ Повідомити про повідомлення для модерації
+ Закрити
+ Скасувати
+ Повідомити
+ Перевірка стану повідомлення…
+ Стан
+ Вміст видалено
+ Вміст не видалено
+ Примітка модератора
+ Причина
+ Додайте примітку (необов’язково)
+ Стан вашого повідомлення для модерації:
+ Повідомити для модерації
+ Поясніть, чому це повідомлення слід перевірити
+ Ваше повідомлення для модерації надіслано.
+ Видалити повідомлення
+ Вимкнути звук для користувача
+ Заблокувати користувача
+ Розблокувати користувача
+ Заблокувати канал
+ Розблокувати канал
+ Розглянути повідомлення
+ Дія № {0}
+ Завантаження…
+ Дій модерації немає.
+ Ціль
+ Час
+ Підрозділ {0}
+ Попередня
+ Сторінка {0}
+ Наступна
+ Дозволити вкладення зображень
+ Дозволити GIF
+ Дозволити надсилання місцезнаходження
+ Термінові повідомлення обходять вимкнення звуку
+ Помічник увімкнений
+ Зберігання повідомлень (днів, 0 = безстроково)
+ Максимальний розмір вкладення (МБ)
+ Збереження…
+ Зберегти налаштування
+ Збережено
+ Не вдалося зберегти налаштування модерації.
+ У черзі
+ Виконується
+ Завершено
+ Помилка
+ Не вдалося запросити експорт чату.
+ Формат
+ Дата початку
+ Дата завершення
+ Надсилання запиту…
+ Запросити експорт
+ Запитано
+ Діапазон
+ Завантажити
+ Завдань експорту немає.
+ Зараз
+ доказ-модерації-{0}
+ Це повідомлення видалено.
+
diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx
index 75f8a75a8..dd4dc2892 100644
--- a/Core/Resgrid.Localization/Common.ar.resx
+++ b/Core/Resgrid.Localization/Common.ar.resx
@@ -42,7 +42,7 @@
Assistant
- Chat Moderation
+ مراجعة المحتوى
اتصل بنا
أُنشئ في
diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx
index 56744b830..5a77731e7 100644
--- a/Core/Resgrid.Localization/Common.de.resx
+++ b/Core/Resgrid.Localization/Common.de.resx
@@ -165,7 +165,7 @@
Assistant
- Chat Moderation
+ Moderation
Kontaktieren Sie uns
@@ -633,4 +633,3 @@
POIs
-
diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx
index a3975e2ea..2fd7d7e62 100644
--- a/Core/Resgrid.Localization/Common.en.resx
+++ b/Core/Resgrid.Localization/Common.en.resx
@@ -217,7 +217,7 @@
Assistant
- Chat Moderation
+ Moderation
Contact Us
diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx
index e29cd33e7..cbcd2dd4e 100644
--- a/Core/Resgrid.Localization/Common.es.resx
+++ b/Core/Resgrid.Localization/Common.es.resx
@@ -211,7 +211,7 @@
Assistant
- Chat Moderation
+ Moderación
Contáctenos
diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx
index b00166b53..bdac4533f 100644
--- a/Core/Resgrid.Localization/Common.fr.resx
+++ b/Core/Resgrid.Localization/Common.fr.resx
@@ -165,7 +165,7 @@
Assistant
- Chat Moderation
+ Modération
Contactez-nous
@@ -633,4 +633,3 @@
POI
-
diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx
index 75b8614a5..0e586af2c 100644
--- a/Core/Resgrid.Localization/Common.it.resx
+++ b/Core/Resgrid.Localization/Common.it.resx
@@ -165,7 +165,7 @@
Assistant
- Chat Moderation
+ Moderazione
Contattaci
@@ -633,4 +633,3 @@
POI
-
diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx
index f12241802..8a89b2ecf 100644
--- a/Core/Resgrid.Localization/Common.pl.resx
+++ b/Core/Resgrid.Localization/Common.pl.resx
@@ -165,7 +165,7 @@
Assistant
- Chat Moderation
+ Moderacja
Skontaktuj się z nami
@@ -633,4 +633,3 @@
POI
-
diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx
index cb76184f0..365f1d326 100644
--- a/Core/Resgrid.Localization/Common.sv.resx
+++ b/Core/Resgrid.Localization/Common.sv.resx
@@ -165,7 +165,7 @@
Assistant
- Chat Moderation
+ Moderering
Kontakta oss
@@ -633,4 +633,3 @@
POI
-
diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx
index 99be96bbf..769a1e921 100644
--- a/Core/Resgrid.Localization/Common.uk.resx
+++ b/Core/Resgrid.Localization/Common.uk.resx
@@ -165,7 +165,7 @@
Assistant
- Chat Moderation
+ Модерація
Зв'яжіться з нами
@@ -633,4 +633,3 @@
POI
-
diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs
index c1a8b2104..fd19d4e41 100644
--- a/Core/Resgrid.Model/AuditLogTypes.cs
+++ b/Core/Resgrid.Model/AuditLogTypes.cs
@@ -186,6 +186,11 @@ public enum AuditLogTypes
ChatFlagResolved,
ChatSettingsChanged,
ChatExportRequested,
- ChatExportDownloaded
+ ChatExportDownloaded,
+ // General moderation
+ ModerationReportSubmitted,
+ ModerationRequestReopened,
+ ModerationRequestCompleted,
+ ModerationEvidenceDownloaded
}
}
diff --git a/Core/Resgrid.Model/Chat/ChatMessage.cs b/Core/Resgrid.Model/Chat/ChatMessage.cs
index c78d592e1..64399f2cc 100644
--- a/Core/Resgrid.Model/Chat/ChatMessage.cs
+++ b/Core/Resgrid.Model/Chat/ChatMessage.cs
@@ -69,6 +69,9 @@ public class ChatMessage : IEntity
public string DeletedByUserId { get; set; }
+ /// True when the tombstone was applied by a moderator rather than the sender.
+ public bool IsModerated { get; set; }
+
public DateTime? PinnedOn { get; set; }
public string PinnedByUserId { get; set; }
diff --git a/Core/Resgrid.Model/ChatbotDepartmentConfig.cs b/Core/Resgrid.Model/ChatbotDepartmentConfig.cs
index bedf68221..fcf1f2b53 100644
--- a/Core/Resgrid.Model/ChatbotDepartmentConfig.cs
+++ b/Core/Resgrid.Model/ChatbotDepartmentConfig.cs
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
+using System.ComponentModel;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
+using ProtoBuf;
namespace Resgrid.Model
{
@@ -17,38 +19,57 @@ namespace Resgrid.Model
/// NluProvider column that exists on the table is deliberately left unmapped/unused.
///
[Table("ChatbotDepartmentConfigs")]
+ [ProtoContract]
public class ChatbotDepartmentConfig : IEntity
{
+ [ProtoMember(1)]
public string Id { get; set; }
+ [ProtoMember(2)]
public int DepartmentId { get; set; }
+ [ProtoMember(3)]
public bool IsEnabled { get; set; }
/// Comma-separated platform names this department allows, or "*" for all.
+ [ProtoMember(4)]
public string AllowedPlatforms { get; set; } = "*";
+ [ProtoMember(5)]
public int MaxSessionsPerUser { get; set; } = 3;
+ [ProtoMember(6)]
public int SessionTtlMinutes { get; set; } = 30;
+ [ProtoMember(7)]
public bool AllowDispatchViaChatbot { get; set; }
+ [ProtoMember(8)]
public bool RequireConfirmationForStatusChange { get; set; }
// --- Per-department LLM/AI override (added M0070). Key is encrypted at rest. ---
+ [ProtoMember(9)]
public string LlmApiEndpoint { get; set; }
+ [ProtoMember(10)]
public string LlmApiKey { get; set; }
+ [ProtoMember(11)]
public string LlmModelName { get; set; }
// --- Per-department rate limits (null => fall back to system defaults). ---
+ [ProtoMember(12)]
public int? MessagesPerUserPerMinute { get; set; }
+ [ProtoMember(13)]
public int? MessagesPerDepartmentPerMinute { get; set; }
+ [DefaultValue(true)]
+ [ProtoMember(14)]
public bool RequireLinkingConfirmation { get; set; } = true;
+ [ProtoMember(15)]
public bool ProactiveNotificationsEnabled { get; set; }
+ [ProtoMember(16)]
public DateTime CreatedAt { get; set; }
+ [ProtoMember(17)]
public DateTime? UpdatedAt { get; set; }
[NotMapped]
diff --git a/Core/Resgrid.Model/FormAutomation.cs b/Core/Resgrid.Model/FormAutomation.cs
index a68ad80f7..e00646e58 100644
--- a/Core/Resgrid.Model/FormAutomation.cs
+++ b/Core/Resgrid.Model/FormAutomation.cs
@@ -41,9 +41,9 @@ public object IdValue
public string IdName => "FormAutomationId";
[NotMapped]
- public int IdType => 0;
+ public int IdType => 1;
[NotMapped]
- public IEnumerable IgnoredProperties => new string[] { "IdValue", "TableName", "IdName", "Department", "Message" };
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "Form" };
}
}
diff --git a/Core/Resgrid.Model/Message.cs b/Core/Resgrid.Model/Message.cs
index 942aa27f6..4dba9202e 100644
--- a/Core/Resgrid.Model/Message.cs
+++ b/Core/Resgrid.Model/Message.cs
@@ -13,6 +13,9 @@ namespace Resgrid.Model
[Table("Messages")]
public class Message : IEntity
{
+ public const int MaximumSubjectLength = 150;
+ public const int MaximumBodyLength = 4000;
+
[Key]
[Required]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
@@ -20,7 +23,7 @@ public class Message : IEntity
public int MessageId { get; set; }
[Required]
- [MaxLength(150)]
+ [MaxLength(MaximumSubjectLength)]
[ProtoMember(2)]
public string Subject { get; set; }
@@ -43,7 +46,7 @@ public class Message : IEntity
public bool SystemGenerated { get; set; }
[Required]
- [MaxLength(4000)]
+ [MaxLength(MaximumBodyLength)]
[ProtoMember(7)]
public string Body { get; set; }
diff --git a/Core/Resgrid.Model/Moderation/Moderation.cs b/Core/Resgrid.Model/Moderation/Moderation.cs
new file mode 100644
index 000000000..5937492f9
--- /dev/null
+++ b/Core/Resgrid.Model/Moderation/Moderation.cs
@@ -0,0 +1,203 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+
+namespace Resgrid.Model
+{
+ public enum ModerationItemType
+ {
+ ChatMessage = 0,
+ Message = 1,
+ CallNote = 2,
+ CallImage = 3
+ }
+
+ public enum ModerationReason
+ {
+ Other = 0,
+ Inappropriate = 1,
+ Harassment = 2,
+ Spam = 3,
+ SensitiveInformation = 4,
+ PolicyViolation = 5
+ }
+
+ public enum ModerationRequestStatus
+ {
+ Pending = 0,
+ Completed = 1
+ }
+
+ public enum ModerationDisposition
+ {
+ None = 0,
+ NoAction = 1,
+ ContentRemoved = 2
+ }
+
+ public enum ModerationActionType
+ {
+ ReportSubmitted = 0,
+ RequestReopened = 1,
+ CompletedNoAction = 2,
+ ContentRemoved = 3,
+ EvidenceDownloaded = 4
+ }
+
+ ///
+ /// One permanent moderation case per department/content item. The original content and metadata are
+ /// captured when the first report is submitted and are never refreshed from the live item.
+ ///
+ public class ModerationRequest : IEntity
+ {
+ public string ModerationRequestId { get; set; }
+ public int DepartmentId { get; set; }
+ public int ItemType { get; set; }
+ public string ItemId { get; set; }
+ public int? CallId { get; set; }
+ public string ChatChannelId { get; set; }
+ public string ContentAuthorUserId { get; set; }
+ public int? ContentAuthorUnitId { get; set; }
+ public DateTime? ContentCreatedOn { get; set; }
+ public string OriginalSubject { get; set; }
+ public string OriginalText { get; set; }
+ public string OriginalFileName { get; set; }
+ public string OriginalContentType { get; set; }
+
+ [JsonIgnore]
+ public byte[] OriginalContent { get; set; }
+
+ public string OriginalMetadataJson { get; set; }
+ public int Status { get; set; }
+ public int Disposition { get; set; }
+ public DateTime CreatedOn { get; set; }
+ public DateTime ModifiedOn { get; set; }
+ public string CompletedByUserId { get; set; }
+ public DateTime? CompletedOn { get; set; }
+ public string AdminNote { get; set; }
+
+ [NotMapped]
+ public List Reports { get; set; } = new List();
+
+ [NotMapped]
+ public List Actions { get; set; } = new List();
+
+ [NotMapped]
+ public string TableName => "ModerationRequests";
+
+ [NotMapped]
+ public string IdName => "ModerationRequestId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return ModerationRequestId; }
+ set { ModerationRequestId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[]
+ {
+ "IdValue", "IdType", "TableName", "IdName", "Reports", "Actions"
+ };
+ }
+
+ /// A reporter's flag within a shared moderation request.
+ public class ModerationReport : IEntity
+ {
+ public string ModerationReportId { get; set; }
+ public string ModerationRequestId { get; set; }
+ public int DepartmentId { get; set; }
+ public string ReportedByUserId { get; set; }
+ public int? ReporterGroupId { get; set; }
+ public int Reason { get; set; }
+ public string Note { get; set; }
+ public DateTime ReportedOn { get; set; }
+
+ [NotMapped]
+ public string TableName => "ModerationReports";
+
+ [NotMapped]
+ public string IdName => "ModerationReportId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return ModerationReportId; }
+ set { ModerationReportId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ ///
+ /// Immutable moderation audit entry. The first ReportSubmitted action holds the original evidence as
+ /// well as its request snapshot so the audit trail survives removal of the live content.
+ ///
+ public class ModerationAction : IEntity
+ {
+ public string ModerationActionId { get; set; }
+ public string ModerationRequestId { get; set; }
+ public int DepartmentId { get; set; }
+ public int ActionType { get; set; }
+ public string PerformedByUserId { get; set; }
+ public DateTime PerformedOn { get; set; }
+ public string Note { get; set; }
+ public int? PreviousStatus { get; set; }
+ public int? NewStatus { get; set; }
+ public string ActorRole { get; set; }
+ public string IpAddress { get; set; }
+ public string UserAgent { get; set; }
+ public string TraceId { get; set; }
+ public string ServerName { get; set; }
+ public string DetailsJson { get; set; }
+ public string EvidenceText { get; set; }
+
+ [JsonIgnore]
+ public byte[] EvidenceContent { get; set; }
+
+ public string EvidenceMetadataJson { get; set; }
+
+ [NotMapped]
+ public string TableName => "ModerationActions";
+
+ [NotMapped]
+ public string IdName => "ModerationActionId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return ModerationActionId; }
+ set { ModerationActionId = (string)value; }
+ }
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+
+ public class ModerationSearchCriteria
+ {
+ public ModerationRequestStatus? Status { get; set; }
+ public ModerationItemType? ItemType { get; set; }
+ public string ContentAuthorUserId { get; set; }
+ public string ReportedByUserId { get; set; }
+ public DateTime? From { get; set; }
+ public DateTime? To { get; set; }
+ public int Page { get; set; }
+ public int PageSize { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IChatRepositories.cs b/Core/Resgrid.Model/Repositories/IChatRepositories.cs
index 64a928cc1..2070a184b 100644
--- a/Core/Resgrid.Model/Repositories/IChatRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IChatRepositories.cs
@@ -144,8 +144,8 @@ public interface IChatMessageRepository : IRepository
///
Task UpdateBodyAsync(string chatMessageId, string body, DateTime editedOn, CancellationToken cancellationToken);
- /// Targeted tombstone (body/metadata cleared, DeletedOn/DeletedByUserId stamped) guarded by DeletedOn IS NULL.
- Task TombstoneAsync(string chatMessageId, DateTime deletedOn, string deletedByUserId, CancellationToken cancellationToken);
+ /// Targeted tombstone (body/metadata cleared, deletion and moderation state stamped) guarded by DeletedOn IS NULL.
+ Task TombstoneAsync(string chatMessageId, DateTime deletedOn, string deletedByUserId, bool isModerated, CancellationToken cancellationToken);
/// Targeted pin update guarded by DeletedOn IS NULL.
Task SetPinnedAsync(string chatMessageId, DateTime? pinnedOn, string pinnedByUserId, CancellationToken cancellationToken);
diff --git a/Core/Resgrid.Model/Repositories/IModerationRepositories.cs b/Core/Resgrid.Model/Repositories/IModerationRepositories.cs
new file mode 100644
index 000000000..c7e07d6f2
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IModerationRepositories.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IModerationRequestRepository : IRepository
+ {
+ Task GetByItemAsync(int departmentId, int itemType, string itemId);
+
+ Task> SearchAsync(int departmentId, ModerationSearchCriteria criteria,
+ IEnumerable visibleGroupIds, string reporterUserId);
+ }
+
+ public interface IModerationReportRepository : IRepository
+ {
+ Task GetByRequestAndReporterAsync(string moderationRequestId, string reportedByUserId);
+ Task> GetByRequestAsync(string moderationRequestId);
+ }
+
+ public interface IModerationActionRepository : IRepository
+ {
+ Task> GetByRequestAsync(string moderationRequestId);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IModerationService.cs b/Core/Resgrid.Model/Services/IModerationService.cs
new file mode 100644
index 000000000..0f8a3a17f
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IModerationService.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IModerationService
+ {
+ Task FlagAsync(int departmentId, string reportedByUserId, ModerationItemType itemType,
+ string itemId, ModerationReason reason, string note, ChatModerationContext context = null,
+ CancellationToken cancellationToken = default(CancellationToken));
+
+ Task CanModerateAsync(int departmentId, string userId);
+
+ Task> SearchRequestsAsync(int departmentId, string viewerUserId,
+ ModerationSearchCriteria criteria);
+
+ Task GetRequestAsync(string moderationRequestId, int departmentId, string viewerUserId);
+
+ Task GetReporterRequestAsync(int departmentId, string reporterUserId,
+ ModerationItemType itemType, string itemId);
+
+ Task CompleteRequestAsync(string moderationRequestId, int departmentId,
+ string completedByUserId, ModerationDisposition disposition, string adminNote,
+ ChatModerationContext context = null, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task RecordEvidenceAccessAsync(string moderationRequestId, int departmentId, string viewedByUserId,
+ ChatModerationContext context = null, CancellationToken cancellationToken = default(CancellationToken));
+ }
+}
diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs
index fd5aee482..37de91031 100644
--- a/Core/Resgrid.Services/AuditService.cs
+++ b/Core/Resgrid.Services/AuditService.cs
@@ -222,6 +222,14 @@ public string GetAuditLogTypeString(AuditLogTypes logType)
return "Chat Export Requested";
case AuditLogTypes.ChatExportDownloaded:
return "Chat Export Downloaded";
+ case AuditLogTypes.ModerationReportSubmitted:
+ return "Moderation Report Submitted";
+ case AuditLogTypes.ModerationRequestReopened:
+ return "Moderation Request Reopened";
+ case AuditLogTypes.ModerationRequestCompleted:
+ return "Moderation Request Completed";
+ case AuditLogTypes.ModerationEvidenceDownloaded:
+ return "Moderation Evidence Downloaded";
}
return $"Unknown ({logType})";
diff --git a/Core/Resgrid.Services/ChatMessageService.cs b/Core/Resgrid.Services/ChatMessageService.cs
index 959acded9..6de832918 100644
--- a/Core/Resgrid.Services/ChatMessageService.cs
+++ b/Core/Resgrid.Services/ChatMessageService.cs
@@ -303,13 +303,14 @@ public async Task> GetThreadPageAsync(string threadRootMessage
await SaveEditHistoryAsync(message, asModerator && !isSender ? ChatMessageEditType.ModeratorDelete : ChatMessageEditType.SenderDelete, byUserId, cancellationToken);
var deletedOn = DateTime.UtcNow;
- if (!await _chatMessageRepository.TombstoneAsync(chatMessageId, deletedOn, byUserId, cancellationToken))
+ if (!await _chatMessageRepository.TombstoneAsync(chatMessageId, deletedOn, byUserId, asModerator, cancellationToken))
return false;
message.Body = null;
message.MetadataJson = null;
message.DeletedOn = deletedOn;
message.DeletedByUserId = byUserId;
+ message.IsModerated = asModerator;
var channel = await _chatChannelRepository.GetByIdAsync(message.ChatChannelId);
PublishEvent(channel, ChatEventKinds.MessageDeleted, new
@@ -318,7 +319,8 @@ public async Task> GetThreadPageAsync(string threadRootMessage
message.ChatChannelId,
message.MessageSeq,
message.DeletedOn,
- DeletedByModerator = asModerator && !isSender
+ DeletedByModerator = asModerator,
+ message.IsModerated
});
return true;
@@ -707,7 +709,8 @@ private object BuildMessageDto(ChatMessage message)
message.MetadataJson,
message.ClientMessageId,
message.SentOn,
- message.EditedOn
+ message.EditedOn,
+ message.IsModerated
};
}
diff --git a/Core/Resgrid.Services/MessageService.cs b/Core/Resgrid.Services/MessageService.cs
index c24b08056..f6def6c83 100644
--- a/Core/Resgrid.Services/MessageService.cs
+++ b/Core/Resgrid.Services/MessageService.cs
@@ -3,6 +3,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Resgrid.Framework;
using Resgrid.Model;
using Resgrid.Model.Queue;
using Resgrid.Model.Repositories;
@@ -39,6 +40,8 @@ public async Task GetMessageByIdAsync(int messageId)
public async Task SaveMessageAsync(Message message, CancellationToken cancellationToken = default(CancellationToken))
{
+ message.Subject = message.Subject?.Truncate(Message.MaximumSubjectLength);
+ message.Body = message.Body?.Truncate(Message.MaximumBodyLength);
message.SentOn = message.SentOn.ToUniversalTime();
if (message.ReadOn.HasValue)
diff --git a/Core/Resgrid.Services/ModerationService.cs b/Core/Resgrid.Services/ModerationService.cs
new file mode 100644
index 000000000..19a0615f6
--- /dev/null
+++ b/Core/Resgrid.Services/ModerationService.cs
@@ -0,0 +1,723 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Resgrid.Localization.Areas.User.Moderation;
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Content-agnostic moderation requests for chat messages, Resgrid Messages, call notes and call
+ /// images. One request owns every report for an item and preserves the first-seen evidence forever.
+ ///
+ public class ModerationService : IModerationService
+ {
+ public static string ModeratedChatMessage => ModerationResources.GetCurrent("MessageRemovedByModeration");
+ public static string ModeratedMessageSubject => ModerationResources.GetCurrent("ModeratedMessageSubject");
+ public static string ModeratedMessageBody => ModerationResources.GetCurrent("ModeratedMessageBody");
+ public static string ModeratedCallNote => ModerationResources.GetCurrent("ModeratedCallNote");
+
+ private readonly IModerationRequestRepository _moderationRequestRepository;
+ private readonly IModerationReportRepository _moderationReportRepository;
+ private readonly IModerationActionRepository _moderationActionRepository;
+ private readonly IChatMessageRepository _chatMessageRepository;
+ private readonly IChatAttachmentRepository _chatAttachmentRepository;
+ private readonly IChatChannelService _chatChannelService;
+ private readonly IChatPermissionService _chatPermissionService;
+ private readonly IChatMessageService _chatMessageService;
+ private readonly IMessageService _messageService;
+ private readonly ICallNotesRepository _callNotesRepository;
+ private readonly ICallAttachmentRepository _callAttachmentRepository;
+ private readonly ICallsService _callsService;
+ private readonly IDepartmentGroupsService _departmentGroupsService;
+ private readonly IAuthorizationService _authorizationService;
+ private readonly IAuditService _auditService;
+ private readonly IUserProfileService _userProfileService;
+
+ public ModerationService(IModerationRequestRepository moderationRequestRepository,
+ IModerationReportRepository moderationReportRepository, IModerationActionRepository moderationActionRepository,
+ IChatMessageRepository chatMessageRepository, IChatAttachmentRepository chatAttachmentRepository,
+ IChatChannelService chatChannelService, IChatPermissionService chatPermissionService,
+ IChatMessageService chatMessageService, IMessageService messageService,
+ ICallNotesRepository callNotesRepository, ICallAttachmentRepository callAttachmentRepository,
+ ICallsService callsService, IDepartmentGroupsService departmentGroupsService,
+ IAuthorizationService authorizationService, IAuditService auditService,
+ IUserProfileService userProfileService)
+ {
+ _moderationRequestRepository = moderationRequestRepository;
+ _moderationReportRepository = moderationReportRepository;
+ _moderationActionRepository = moderationActionRepository;
+ _chatMessageRepository = chatMessageRepository;
+ _chatAttachmentRepository = chatAttachmentRepository;
+ _chatChannelService = chatChannelService;
+ _chatPermissionService = chatPermissionService;
+ _chatMessageService = chatMessageService;
+ _messageService = messageService;
+ _callNotesRepository = callNotesRepository;
+ _callAttachmentRepository = callAttachmentRepository;
+ _callsService = callsService;
+ _departmentGroupsService = departmentGroupsService;
+ _authorizationService = authorizationService;
+ _auditService = auditService;
+ _userProfileService = userProfileService;
+ }
+
+ public async Task FlagAsync(int departmentId, string reportedByUserId,
+ ModerationItemType itemType, string itemId, ModerationReason reason, string note,
+ ChatModerationContext context = null, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (departmentId <= 0 || string.IsNullOrWhiteSpace(reportedByUserId) || string.IsNullOrWhiteSpace(itemId))
+ throw new ArgumentException(ModerationResources.GetCurrent("RequiredModerationContext"));
+
+ if (!Enum.IsDefined(typeof(ModerationItemType), itemType) || !Enum.IsDefined(typeof(ModerationReason), reason))
+ throw new ArgumentOutOfRangeException(nameof(itemType));
+
+ var evidence = await LoadEvidenceAsync(departmentId, reportedByUserId, itemType, itemId);
+ var request = await _moderationRequestRepository.GetByItemAsync(departmentId, (int)itemType, itemId);
+ var createdRequest = request == null;
+
+ if (createdRequest)
+ {
+ var now = DateTime.UtcNow;
+ request = new ModerationRequest
+ {
+ ModerationRequestId = Guid.NewGuid().ToString(),
+ DepartmentId = departmentId,
+ ItemType = (int)itemType,
+ ItemId = itemId,
+ CallId = evidence.CallId,
+ ChatChannelId = evidence.ChatChannelId,
+ ContentAuthorUserId = evidence.ContentAuthorUserId,
+ ContentAuthorUnitId = evidence.ContentAuthorUnitId,
+ ContentCreatedOn = evidence.ContentCreatedOn,
+ OriginalSubject = evidence.Subject,
+ OriginalText = evidence.Text,
+ OriginalFileName = evidence.FileName,
+ OriginalContentType = evidence.ContentType,
+ OriginalContent = evidence.Content,
+ OriginalMetadataJson = evidence.MetadataJson,
+ Status = (int)ModerationRequestStatus.Pending,
+ Disposition = (int)ModerationDisposition.None,
+ CreatedOn = now,
+ ModifiedOn = now
+ };
+
+ try
+ {
+ request = await _moderationRequestRepository.InsertAsync(request, cancellationToken);
+ }
+ catch
+ {
+ // The unique department/type/item index is the race backstop. If another reporter won
+ // the insert, join that request; otherwise preserve the original failure.
+ var concurrent = await _moderationRequestRepository.GetByItemAsync(departmentId, (int)itemType, itemId);
+ if (concurrent == null)
+ throw;
+
+ request = concurrent;
+ createdRequest = false;
+ }
+ }
+
+ var existingReport = await _moderationReportRepository.GetByRequestAndReporterAsync(
+ request.ModerationRequestId, reportedByUserId);
+ if (existingReport != null)
+ return existingReport;
+
+ if (request.Status == (int)ModerationRequestStatus.Completed)
+ {
+ var previousStatus = request.Status;
+ request.Status = (int)ModerationRequestStatus.Pending;
+ request.Disposition = (int)ModerationDisposition.None;
+ request.CompletedByUserId = null;
+ request.CompletedOn = null;
+ request.AdminNote = null;
+ request.ModifiedOn = DateTime.UtcNow;
+ await _moderationRequestRepository.UpdateAsync(request, cancellationToken);
+
+ await RecordActionAsync(request, ModerationActionType.RequestReopened, reportedByUserId,
+ ModerationResources.GetCurrent("RequestReopenedAudit"), previousStatus, request.Status,
+ context, null, cancellationToken);
+ await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationRequestReopened,
+ reportedByUserId, context, new { request.ItemType, request.ItemId }, cancellationToken);
+ }
+
+ var groupMember = await _departmentGroupsService.GetGroupMemberForUserAsync(reportedByUserId, departmentId);
+ var report = new ModerationReport
+ {
+ ModerationReportId = Guid.NewGuid().ToString(),
+ ModerationRequestId = request.ModerationRequestId,
+ DepartmentId = departmentId,
+ ReportedByUserId = reportedByUserId,
+ ReporterGroupId = groupMember?.DepartmentGroupId,
+ Reason = (int)reason,
+ Note = note,
+ ReportedOn = DateTime.UtcNow
+ };
+
+ try
+ {
+ report = await _moderationReportRepository.InsertAsync(report, cancellationToken);
+ }
+ catch
+ {
+ // A unique request/reporter index prevents duplicate reports under concurrent submissions.
+ var concurrent = await _moderationReportRepository.GetByRequestAndReporterAsync(
+ request.ModerationRequestId, reportedByUserId);
+ if (concurrent == null)
+ throw;
+
+ return concurrent;
+ }
+ request.ModifiedOn = report.ReportedOn;
+ await _moderationRequestRepository.UpdateAsync(request, cancellationToken);
+
+ await RecordActionAsync(request, ModerationActionType.ReportSubmitted, reportedByUserId, note,
+ null, request.Status, context,
+ new
+ {
+ report.ModerationReportId,
+ report.ReporterGroupId,
+ Reason = reason.ToString()
+ },
+ cancellationToken, createdRequest);
+
+ await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationReportSubmitted,
+ reportedByUserId, context,
+ new { report.ModerationReportId, report.ReporterGroupId, Reason = reason.ToString(), note },
+ cancellationToken);
+
+ return report;
+ }
+
+ public async Task CanModerateAsync(int departmentId, string userId)
+ {
+ if (await _authorizationService.CanUserModifyDepartmentAsync(userId, departmentId))
+ return true;
+
+ var groups = await GetAdminGroupIdsAsync(departmentId, userId);
+ return groups.Count > 0;
+ }
+
+ public async Task> SearchRequestsAsync(int departmentId, string viewerUserId,
+ ModerationSearchCriteria criteria)
+ {
+ var isDepartmentAdmin = await _authorizationService.CanUserModifyDepartmentAsync(viewerUserId, departmentId);
+ var groupIds = isDepartmentAdmin ? null : await GetAdminGroupIdsAsync(departmentId, viewerUserId);
+ var reporterScope = isDepartmentAdmin ? null : viewerUserId;
+ var requests = await _moderationRequestRepository.SearchAsync(departmentId, criteria, groupIds, reporterScope);
+ return await HydrateAsync(requests, isDepartmentAdmin ? null : groupIds, viewerUserId);
+ }
+
+ public async Task GetRequestAsync(string moderationRequestId, int departmentId,
+ string viewerUserId)
+ {
+ var request = await _moderationRequestRepository.GetByIdAsync(moderationRequestId);
+ if (request == null || request.DepartmentId != departmentId)
+ return null;
+
+ var reports = (await _moderationReportRepository.GetByRequestAsync(moderationRequestId))?.ToList()
+ ?? new List();
+ if (!await CanViewRequestAsync(request, reports, viewerUserId, requireAdmin: false))
+ return null;
+
+ var actions = (await _moderationActionRepository.GetByRequestAsync(moderationRequestId))?.ToList()
+ ?? new List();
+ await ApplyViewerScopeAsync(request, reports, actions, viewerUserId);
+ return request;
+ }
+
+ public async Task GetReporterRequestAsync(int departmentId, string reporterUserId,
+ ModerationItemType itemType, string itemId)
+ {
+ var request = await _moderationRequestRepository.GetByItemAsync(departmentId, (int)itemType, itemId);
+ if (request == null)
+ return null;
+
+ var report = await _moderationReportRepository.GetByRequestAndReporterAsync(request.ModerationRequestId,
+ reporterUserId);
+ if (report == null)
+ return null;
+
+ request.Reports = new List { report };
+ request.Actions = (await _moderationActionRepository.GetByRequestAsync(request.ModerationRequestId))?.ToList()
+ ?? new List();
+ return request;
+ }
+
+ public async Task CompleteRequestAsync(string moderationRequestId, int departmentId,
+ string completedByUserId, ModerationDisposition disposition, string adminNote,
+ ChatModerationContext context = null, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ if (disposition != ModerationDisposition.NoAction && disposition != ModerationDisposition.ContentRemoved)
+ throw new ArgumentOutOfRangeException(nameof(disposition));
+
+ var request = await _moderationRequestRepository.GetByIdAsync(moderationRequestId);
+ if (request == null || request.DepartmentId != departmentId)
+ return null;
+
+ var reports = (await _moderationReportRepository.GetByRequestAsync(moderationRequestId))?.ToList()
+ ?? new List();
+ if (!await CanViewRequestAsync(request, reports, completedByUserId, requireAdmin: true))
+ throw new UnauthorizedAccessException(ModerationResources.GetCurrent("RequestOutsideScope"));
+
+ if (request.Status != (int)ModerationRequestStatus.Pending)
+ throw new InvalidOperationException(ModerationResources.GetCurrent("OnlyPendingRequests"));
+
+ if (disposition == ModerationDisposition.ContentRemoved && !await RemoveLiveContentAsync(request, completedByUserId, cancellationToken))
+ throw new InvalidOperationException(ModerationResources.GetCurrent("ContentCouldNotBeRemoved"));
+
+ var previousStatus = request.Status;
+ request.Status = (int)ModerationRequestStatus.Completed;
+ request.Disposition = (int)disposition;
+ request.CompletedByUserId = completedByUserId;
+ request.CompletedOn = DateTime.UtcNow;
+ request.ModifiedOn = request.CompletedOn.Value;
+ request.AdminNote = adminNote;
+ request = await _moderationRequestRepository.UpdateAsync(request, cancellationToken);
+
+ var actionType = disposition == ModerationDisposition.ContentRemoved
+ ? ModerationActionType.ContentRemoved
+ : ModerationActionType.CompletedNoAction;
+ await RecordActionAsync(request, actionType, completedByUserId, adminNote, previousStatus,
+ request.Status, context, new { Disposition = disposition.ToString() }, cancellationToken);
+ await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationRequestCompleted,
+ completedByUserId, context, new { Disposition = disposition.ToString(), adminNote }, cancellationToken);
+
+ await NotifyReportersAsync(request, reports, disposition, adminNote, cancellationToken);
+
+ var actions = (await _moderationActionRepository.GetByRequestAsync(request.ModerationRequestId))?.ToList()
+ ?? new List();
+ await ApplyViewerScopeAsync(request, reports, actions, completedByUserId);
+ return request;
+ }
+
+ public async Task RecordEvidenceAccessAsync(string moderationRequestId, int departmentId,
+ string viewedByUserId, ChatModerationContext context = null,
+ CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var request = await _moderationRequestRepository.GetByIdAsync(moderationRequestId);
+ if (request == null || request.DepartmentId != departmentId)
+ return false;
+
+ var reports = (await _moderationReportRepository.GetByRequestAsync(moderationRequestId))?.ToList()
+ ?? new List();
+ if (!await CanViewRequestAsync(request, reports, viewedByUserId, requireAdmin: true))
+ throw new UnauthorizedAccessException(ModerationResources.GetCurrent("EvidenceOutsideScope"));
+
+ await RecordActionAsync(request, ModerationActionType.EvidenceDownloaded, viewedByUserId,
+ ModerationResources.GetCurrent("EvidenceDownloadedAudit"), request.Status, request.Status, context, null,
+ cancellationToken);
+ await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationEvidenceDownloaded,
+ viewedByUserId, context, new { request.OriginalFileName, request.OriginalContentType },
+ cancellationToken);
+ return true;
+ }
+
+ private async Task LoadEvidenceAsync(int departmentId, string reporterUserId,
+ ModerationItemType itemType, string itemId)
+ {
+ switch (itemType)
+ {
+ case ModerationItemType.ChatMessage:
+ {
+ var message = await _chatMessageRepository.GetByIdAsync(itemId);
+ if (message == null || message.DepartmentId != departmentId || message.DeletedOn.HasValue)
+ throw new InvalidOperationException(ModerationResources.GetCurrent("ChatMessageUnavailable"));
+
+ var channel = await _chatChannelService.GetChannelByIdAsync(message.ChatChannelId);
+ if (channel == null || channel.DepartmentId != departmentId ||
+ !await _chatPermissionService.CanAccessChannelAsync(channel, reporterUserId, null))
+ throw new UnauthorizedAccessException();
+
+ ChatAttachment attachment = null;
+ var attachmentMetadata = await _chatAttachmentRepository.GetMetadataByMessageIdsAsync(new[] { itemId });
+ var firstAttachment = attachmentMetadata?.FirstOrDefault();
+ if (firstAttachment != null)
+ attachment = await _chatAttachmentRepository.GetByIdAsync(firstAttachment.ChatAttachmentId);
+
+ return new ModerationEvidence
+ {
+ ChatChannelId = message.ChatChannelId,
+ ContentAuthorUserId = message.SenderUserId,
+ ContentAuthorUnitId = message.SenderUnitId,
+ ContentCreatedOn = message.SentOn,
+ Text = message.Body,
+ FileName = attachment?.FileName,
+ ContentType = attachment?.ContentType,
+ Content = attachment?.Data,
+ MetadataJson = JsonConvert.SerializeObject(new
+ {
+ message.MessageType,
+ message.Priority,
+ message.SenderDisplayName,
+ message.MetadataJson,
+ Attachment = attachment == null ? null : new
+ {
+ attachment.ChatAttachmentId,
+ attachment.FileName,
+ attachment.ContentType,
+ attachment.Size,
+ attachment.Sha256
+ }
+ })
+ };
+ }
+
+ case ModerationItemType.Message:
+ {
+ if (!int.TryParse(itemId, out var messageId) ||
+ !await _authorizationService.CanUserViewMessageAsync(reporterUserId, messageId))
+ throw new UnauthorizedAccessException();
+
+ var message = await _messageService.GetMessageByIdAsync(messageId);
+ if (message == null)
+ throw new InvalidOperationException(ModerationResources.GetCurrent("MessageUnavailable"));
+
+ return new ModerationEvidence
+ {
+ ContentAuthorUserId = message.SendingUserId,
+ ContentCreatedOn = message.SentOn,
+ Subject = message.Subject,
+ Text = message.Body,
+ MetadataJson = JsonConvert.SerializeObject(new
+ {
+ message.Type,
+ message.IsBroadcast,
+ message.SystemGenerated,
+ message.ReceivingUserId,
+ Recipients = message.GetRecipients()
+ })
+ };
+ }
+
+ case ModerationItemType.CallNote:
+ {
+ if (!int.TryParse(itemId, out var callNoteId))
+ throw new ArgumentException(ModerationResources.GetCurrent("InvalidCallNoteId"));
+
+ var note = await _callNotesRepository.GetByIdAsync(callNoteId);
+ var call = note == null ? null : await _callsService.GetCallByIdAsync(note.CallId, false);
+ if (note == null || call == null || call.DepartmentId != departmentId || note.IsDeleted)
+ throw new InvalidOperationException(ModerationResources.GetCurrent("CallNoteUnavailable"));
+ if (!await _authorizationService.CanUserViewCallAsync(reporterUserId, note.CallId))
+ throw new UnauthorizedAccessException();
+
+ return new ModerationEvidence
+ {
+ CallId = note.CallId,
+ ContentAuthorUserId = note.UserId,
+ ContentCreatedOn = note.Timestamp,
+ Text = note.Note,
+ MetadataJson = JsonConvert.SerializeObject(new
+ {
+ note.Source,
+ note.Latitude,
+ note.Longitude
+ })
+ };
+ }
+
+ case ModerationItemType.CallImage:
+ {
+ if (!int.TryParse(itemId, out var callAttachmentId))
+ throw new ArgumentException(ModerationResources.GetCurrent("InvalidCallImageId"));
+
+ var attachment = await _callAttachmentRepository.GetByIdAsync(callAttachmentId);
+ var call = attachment == null ? null : await _callsService.GetCallByIdAsync(attachment.CallId, false);
+ if (attachment == null || call == null || call.DepartmentId != departmentId || attachment.IsDeleted ||
+ attachment.CallAttachmentType != (int)CallAttachmentTypes.Image)
+ throw new InvalidOperationException(ModerationResources.GetCurrent("CallImageUnavailable"));
+ if (!await _authorizationService.CanUserViewCallAsync(reporterUserId, attachment.CallId))
+ throw new UnauthorizedAccessException();
+
+ return new ModerationEvidence
+ {
+ CallId = attachment.CallId,
+ ContentAuthorUserId = attachment.UserId,
+ ContentCreatedOn = attachment.Timestamp,
+ FileName = attachment.FileName,
+ ContentType = "image/jpeg",
+ Content = attachment.Data,
+ MetadataJson = JsonConvert.SerializeObject(new
+ {
+ attachment.Name,
+ attachment.Size,
+ attachment.Latitude,
+ attachment.Longitude
+ })
+ };
+ }
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(itemType));
+ }
+ }
+
+ private async Task RemoveLiveContentAsync(ModerationRequest request, string byUserId,
+ CancellationToken cancellationToken)
+ {
+ switch ((ModerationItemType)request.ItemType)
+ {
+ case ModerationItemType.ChatMessage:
+ return await _chatMessageService.DeleteMessageAsync(request.ItemId, byUserId, true,
+ ModeratedChatMessage, cancellationToken);
+
+ case ModerationItemType.Message:
+ if (!int.TryParse(request.ItemId, out var messageId))
+ return false;
+ var message = await _messageService.GetMessageByIdAsync(messageId);
+ if (message == null)
+ return false;
+ message.Subject = ModeratedMessageSubject;
+ message.Body = ModeratedMessageBody;
+ return await _messageService.SaveMessageAsync(message, cancellationToken) != null;
+
+ case ModerationItemType.CallNote:
+ if (!int.TryParse(request.ItemId, out var callNoteId))
+ return false;
+ var note = await _callNotesRepository.GetByIdAsync(callNoteId);
+ if (note == null)
+ return false;
+ note.Note = ModeratedCallNote;
+ note.IsDeleted = true;
+ note.DeletedByUserId = byUserId;
+ note.DeletedOn = DateTime.UtcNow;
+ note.IsFlagged = false;
+ note.FlaggedReason = null;
+ note.FlaggedByUserId = null;
+ note.FlaggedOn = null;
+ return await _callNotesRepository.SaveOrUpdateAsync(note, cancellationToken) != null;
+
+ case ModerationItemType.CallImage:
+ if (!int.TryParse(request.ItemId, out var callAttachmentId))
+ return false;
+ var attachment = await _callAttachmentRepository.GetByIdAsync(callAttachmentId);
+ if (attachment == null)
+ return false;
+ attachment.Data = null;
+ attachment.Size = 0;
+ attachment.IsDeleted = true;
+ attachment.DeletedByUserId = byUserId;
+ attachment.DeletedOn = DateTime.UtcNow;
+ attachment.IsFlagged = false;
+ attachment.FlaggedReason = null;
+ attachment.FlaggedByUserId = null;
+ attachment.FlaggedOn = null;
+ return await _callAttachmentRepository.SaveOrUpdateAsync(attachment, cancellationToken) != null;
+
+ default:
+ return false;
+ }
+ }
+
+ private async Task> HydrateAsync(IEnumerable requests,
+ List visibleGroupIds, string viewerUserId)
+ {
+ var result = requests?.ToList() ?? new List();
+ foreach (var request in result)
+ {
+ var reports = (await _moderationReportRepository.GetByRequestAsync(request.ModerationRequestId))?.ToList()
+ ?? new List();
+ var actions = (await _moderationActionRepository.GetByRequestAsync(request.ModerationRequestId))?.ToList()
+ ?? new List();
+
+ if (visibleGroupIds == null)
+ {
+ request.Reports = reports;
+ request.Actions = actions;
+ }
+ else
+ {
+ ApplyGroupScope(request, reports, actions, visibleGroupIds, viewerUserId);
+ }
+ }
+
+ return result;
+ }
+
+ private async Task ApplyViewerScopeAsync(ModerationRequest request, List reports,
+ List actions, string viewerUserId)
+ {
+ if (await _authorizationService.CanUserModifyDepartmentAsync(viewerUserId, request.DepartmentId))
+ {
+ request.Reports = reports;
+ request.Actions = actions;
+ return;
+ }
+
+ ApplyGroupScope(request, reports, actions,
+ await GetAdminGroupIdsAsync(request.DepartmentId, viewerUserId), viewerUserId);
+ }
+
+ private static void ApplyGroupScope(ModerationRequest request, List reports,
+ List actions, IEnumerable visibleGroupIds, string viewerUserId)
+ {
+ var groups = visibleGroupIds?.ToHashSet() ?? new HashSet();
+ var visibleReports = reports.Where(x =>
+ string.Equals(x.ReportedByUserId, viewerUserId, StringComparison.OrdinalIgnoreCase) ||
+ (x.ReporterGroupId.HasValue && groups.Contains(x.ReporterGroupId.Value))).ToList();
+ var visibleReporters = visibleReports.Select(x => x.ReportedByUserId)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ request.Reports = visibleReports;
+ request.Actions = actions.Where(x =>
+ x.ActionType == (int)ModerationActionType.CompletedNoAction ||
+ x.ActionType == (int)ModerationActionType.ContentRemoved ||
+ x.ActionType == (int)ModerationActionType.EvidenceDownloaded ||
+ visibleReporters.Contains(x.PerformedByUserId)).ToList();
+ }
+
+ private async Task CanViewRequestAsync(ModerationRequest request, List reports,
+ string userId, bool requireAdmin)
+ {
+ if (await _authorizationService.CanUserModifyDepartmentAsync(userId, request.DepartmentId))
+ return true;
+
+ var groupIds = await GetAdminGroupIdsAsync(request.DepartmentId, userId);
+ if (groupIds.Count > 0 && reports.Any(x => x.ReporterGroupId.HasValue && groupIds.Contains(x.ReporterGroupId.Value)))
+ return true;
+
+ return !requireAdmin && reports.Any(x => string.Equals(x.ReportedByUserId, userId, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private async Task> GetAdminGroupIdsAsync(int departmentId, string userId)
+ {
+ var admins = await _departmentGroupsService.GetAllGroupAdminsByDepartmentIdAsync(departmentId);
+ return admins?
+ .Where(x => x.IsAdmin == true && string.Equals(x.UserId, userId, StringComparison.OrdinalIgnoreCase))
+ .Select(x => x.DepartmentGroupId)
+ .Distinct()
+ .ToList() ?? new List();
+ }
+
+ private async Task RecordActionAsync(ModerationRequest request, ModerationActionType actionType,
+ string byUserId, string note, int? previousStatus, int? newStatus, ChatModerationContext context,
+ object details, CancellationToken cancellationToken, bool includeEvidence = false)
+ {
+ await _moderationActionRepository.InsertAsync(new ModerationAction
+ {
+ ModerationActionId = Guid.NewGuid().ToString(),
+ ModerationRequestId = request.ModerationRequestId,
+ DepartmentId = request.DepartmentId,
+ ActionType = (int)actionType,
+ PerformedByUserId = byUserId,
+ PerformedOn = DateTime.UtcNow,
+ Note = note,
+ PreviousStatus = previousStatus,
+ NewStatus = newStatus,
+ ActorRole = context?.ActorRole,
+ IpAddress = context?.IpAddress,
+ UserAgent = context?.UserAgent,
+ TraceId = context?.TraceId,
+ ServerName = Environment.MachineName,
+ DetailsJson = details == null ? null : JsonConvert.SerializeObject(details),
+ EvidenceText = includeEvidence ? request.OriginalText : null,
+ EvidenceContent = includeEvidence ? request.OriginalContent : null,
+ EvidenceMetadataJson = includeEvidence ? request.OriginalMetadataJson : null
+ }, cancellationToken);
+ }
+
+ private async Task RecordDepartmentAuditAsync(ModerationRequest request, AuditLogTypes logType,
+ string byUserId, ChatModerationContext context, object details, CancellationToken cancellationToken)
+ {
+ await _auditService.SaveAuditLogAsync(new AuditLog
+ {
+ LogType = (int)logType,
+ DepartmentId = request.DepartmentId,
+ UserId = byUserId,
+ Message = _auditService.GetAuditLogTypeString(logType),
+ Data = JsonConvert.SerializeObject(new
+ {
+ result = "Success",
+ request.ModerationRequestId,
+ request.ItemType,
+ request.ItemId,
+ actorRole = context?.ActorRole,
+ traceId = context?.TraceId,
+ details
+ }),
+ LoggedOn = DateTime.UtcNow,
+ ObjectId = request.ModerationRequestId,
+ ObjectDepartmentId = request.DepartmentId,
+ IpAddress = context?.IpAddress,
+ UserAgent = context?.UserAgent,
+ ServerName = Environment.MachineName
+ }, cancellationToken);
+ }
+
+ private async Task NotifyReportersAsync(ModerationRequest request, IEnumerable reports,
+ ModerationDisposition disposition, string adminNote, CancellationToken cancellationToken)
+ {
+ var recipients = reports
+ .Select(x => x.ReportedByUserId)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Where(x => !string.Equals(x, request.ContentAuthorUserId, StringComparison.OrdinalIgnoreCase))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ foreach (var recipient in recipients)
+ {
+ var profile = await _userProfileService.GetProfileByUserIdAsync(recipient);
+ var culture = string.IsNullOrWhiteSpace(profile?.Language) ? "en" : profile.Language;
+ var action = ModerationResources.Get(
+ disposition == ModerationDisposition.ContentRemoved
+ ? "CompletionContentRemoved"
+ : "CompletionNoContentRemoved", culture);
+ var note = string.IsNullOrWhiteSpace(adminNote)
+ ? string.Empty
+ : ModerationResources.Get("CompletionAdminNoteHtml", culture,
+ System.Net.WebUtility.HtmlEncode(adminNote));
+ var itemType = ModerationResources.Get(GetItemTypeResourceKey((ModerationItemType)request.ItemType), culture);
+ var message = await _messageService.SaveMessageAsync(new Message
+ {
+ Subject = ModerationResources.Get("CompletionSubject", culture),
+ Body = ModerationResources.Get("CompletionBody", culture, itemType, request.ItemId, action, note),
+ ReceivingUserId = recipient,
+ SystemGenerated = true,
+ SentOn = DateTime.UtcNow
+ }, cancellationToken);
+
+ await _messageService.SendMessageAsync(message,
+ ModerationResources.Get("SystemSenderName", culture), request.DepartmentId, false, cancellationToken);
+ }
+ }
+
+ private static string GetItemTypeResourceKey(ModerationItemType itemType)
+ {
+ switch (itemType)
+ {
+ case ModerationItemType.ChatMessage:
+ return "ItemTypeChatMessage";
+ case ModerationItemType.Message:
+ return "ItemTypeMessage";
+ case ModerationItemType.CallNote:
+ return "ItemTypeCallNote";
+ case ModerationItemType.CallImage:
+ return "ItemTypeCallImage";
+ default:
+ return "UnknownContentType";
+ }
+ }
+
+ private class ModerationEvidence
+ {
+ public int? CallId { get; set; }
+ public string ChatChannelId { get; set; }
+ public string ContentAuthorUserId { get; set; }
+ public int? ContentAuthorUnitId { get; set; }
+ public DateTime? ContentCreatedOn { get; set; }
+ public string Subject { get; set; }
+ public string Text { get; set; }
+ public string FileName { get; set; }
+ public string ContentType { get; set; }
+ public byte[] Content { get; set; }
+ public string MetadataJson { get; set; }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/NotificationService.cs b/Core/Resgrid.Services/NotificationService.cs
index 91e44766b..f947a2f98 100644
--- a/Core/Resgrid.Services/NotificationService.cs
+++ b/Core/Resgrid.Services/NotificationService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -461,7 +462,7 @@ public async Task GetGroupForEventAsync(ProcessedNotification n
return await _departmentGroupsService.GetGroupByIdAsync(unitEvent.Unit.StationGroupId.GetValueOrDefault());
}
}
- else if (notification.Type == EventTypes.PersonnelStatusChanged)
+ else if (notification.Type == EventTypes.PersonnelStaffingChanged)
{
var userStaffing = await _userStateService.GetUserStateByIdAsync(dynamicData.StateId);
@@ -498,94 +499,88 @@ public async Task ValidateNotificationForProcessingAsync(ProcessedNotifica
switch (notification.Type)
{
case EventTypes.UnitStatusChanged:
- if (!String.IsNullOrWhiteSpace(setting.BeforeData) && !String.IsNullOrWhiteSpace(setting.CurrentData))
{
- if (setting.BeforeData.Contains("-1") && setting.CurrentData.Contains("-1"))
- return true;
+ // Empty Before/Current data means "Any": the post-Telerik UI posts "" for the
+ // default Any option, so settings saved that way must still match every change.
+ var beforeData = String.IsNullOrWhiteSpace(setting.BeforeData) ? "-1" : setting.BeforeData;
+ var currentData = String.IsNullOrWhiteSpace(setting.CurrentData) ? "-1" : setting.CurrentData;
- bool beforeAny = setting.BeforeData.Contains("-1");
- bool currentAny = setting.CurrentData.Contains("-1");
+ if (!int.TryParse(beforeData, NumberStyles.Integer, CultureInfo.InvariantCulture, out int beforeStateValue) ||
+ !int.TryParse(currentData, NumberStyles.Integer, CultureInfo.InvariantCulture, out int currentStateValue))
+ return false;
- UnitState beforeState = null;
- UnitState currentState = null;
+ bool beforeAny = beforeStateValue == -1;
+ bool currentAny = currentStateValue == -1;
- currentState = await _unitsService.GetUnitStateByIdAsync(dynamicData.StateId);
+ if (beforeAny && currentAny)
+ return true;
+
+ UnitState beforeState = null;
+ UnitState currentState = await _unitsService.GetUnitStateByIdAsync(dynamicData.StateId);
if (currentState != null)
{
if (!beforeAny)
beforeState = await _unitsService.GetLastUnitStateBeforeIdAsync(currentState.UnitId, currentState.UnitStateId);
- if ((currentAny || currentState.State == int.Parse(setting.CurrentData)) &&
- (beforeAny || beforeState.State == int.Parse(setting.BeforeData)))
+ if ((currentAny || currentState.State == currentStateValue) &&
+ (beforeAny || (beforeState != null && beforeState.State == beforeStateValue)))
return true;
}
}
- else
- {
- return false;
- }
break;
case EventTypes.PersonnelStaffingChanged:
- if (!String.IsNullOrWhiteSpace(setting.BeforeData) && !String.IsNullOrWhiteSpace(setting.CurrentData))
{
- if (setting.BeforeData.Contains("-1") && setting.CurrentData.Contains("-1"))
- return true;
+ // Empty Before/Current data means "Any": the post-Telerik UI posts "" for the
+ // default Any option, so settings saved that way must still match every change.
+ var beforeData = String.IsNullOrWhiteSpace(setting.BeforeData) ? "-1" : setting.BeforeData;
+ var currentData = String.IsNullOrWhiteSpace(setting.CurrentData) ? "-1" : setting.CurrentData;
- bool beforeAny = false;
- if (!string.IsNullOrWhiteSpace(setting.BeforeData))
- beforeAny = setting.BeforeData.Contains("-1");
+ if (beforeData.Contains("-1") && currentData.Contains("-1"))
+ return true;
- bool currentAny = false;
- if (!string.IsNullOrWhiteSpace(setting.CurrentData))
- currentAny = setting.CurrentData.Contains("-1");
+ bool beforeAny = beforeData.Contains("-1");
+ bool currentAny = currentData.Contains("-1");
UserState beforeState = null;
- UserState currentState = null;
-
- currentState = await _userStateService.GetUserStateByIdAsync((int)dynamicData.StateId);
+ UserState currentState = await _userStateService.GetUserStateByIdAsync((int)dynamicData.StateId);
if (currentState != null)
{
if (!beforeAny)
beforeState = await _userStateService.GetPreviousUserStateAsync(currentState.UserId, currentState.UserStateId);
- if ((currentAny || currentState.State == int.Parse(setting.CurrentData)) &&
- (beforeAny || beforeState.State == int.Parse(setting.BeforeData)))
+ if ((currentAny || currentState.State == int.Parse(currentData)) &&
+ (beforeAny || (beforeState != null && beforeState.State == int.Parse(beforeData))))
return true;
}
-
- return false;
- }
- else
- {
- return false;
}
break;
case EventTypes.PersonnelStatusChanged:
- if (!String.IsNullOrWhiteSpace(setting.BeforeData) && !String.IsNullOrWhiteSpace(setting.CurrentData))
{
- if (setting.BeforeData.Contains("-1") && setting.CurrentData.Contains("-1"))
+ // Empty Before/Current data means "Any": the post-Telerik UI posts "" for the
+ // default Any option, so settings saved that way must still match every change.
+ var beforeData = String.IsNullOrWhiteSpace(setting.BeforeData) ? "-1" : setting.BeforeData;
+ var currentData = String.IsNullOrWhiteSpace(setting.CurrentData) ? "-1" : setting.CurrentData;
+
+ if (beforeData.Contains("-1") && currentData.Contains("-1"))
return true;
- bool beforeAny = setting.BeforeData.Contains("-1");
- bool currentAny = setting.CurrentData.Contains("-1");
+ bool beforeAny = beforeData.Contains("-1");
+ bool currentAny = currentData.Contains("-1");
ActionLog beforeState = null;
- ActionLog currentState = null;
-
- currentState = await _actionLogsService.GetActionLogByIdAsync((int)dynamicData.StateId);
+ ActionLog currentState = await _actionLogsService.GetActionLogByIdAsync((int)dynamicData.StateId);
- if (!beforeAny)
- beforeState = await _actionLogsService.GetPreviousActionLogAsync(currentState.UserId, currentState.ActionLogId);
+ if (currentState != null)
+ {
+ if (!beforeAny)
+ beforeState = await _actionLogsService.GetPreviousActionLogAsync(currentState.UserId, currentState.ActionLogId);
- if ((currentAny || currentState.ActionTypeId == int.Parse(setting.CurrentData)) &&
- (beforeAny || beforeState.ActionTypeId == int.Parse(setting.BeforeData)))
- return true;
- }
- else
- {
- return false;
+ if ((currentAny || currentState.ActionTypeId == int.Parse(currentData)) &&
+ (beforeAny || (beforeState != null && beforeState.ActionTypeId == int.Parse(beforeData))))
+ return true;
+ }
}
break;
case EventTypes.RolesInGroupAvailabilityAlert:
diff --git a/Core/Resgrid.Services/Resgrid.Services.csproj b/Core/Resgrid.Services/Resgrid.Services.csproj
index b50c7cac4..9f3a6c997 100644
--- a/Core/Resgrid.Services/Resgrid.Services.csproj
+++ b/Core/Resgrid.Services/Resgrid.Services.csproj
@@ -31,6 +31,7 @@
+
diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs
index 381eb06e0..ef0b45471 100644
--- a/Core/Resgrid.Services/ServicesModule.cs
+++ b/Core/Resgrid.Services/ServicesModule.cs
@@ -22,6 +22,7 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().SingleInstance().AutoActivate();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0111_CascadeCommunicationTestDeletes.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0111_CascadeCommunicationTestDeletes.cs
new file mode 100644
index 000000000..33610d4dd
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0111_CascadeCommunicationTestDeletes.cs
@@ -0,0 +1,43 @@
+using System.Data;
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(111)]
+ public class M0111_CascadeCommunicationTestDeletes : Migration
+ {
+ public override void Up()
+ {
+ Delete.ForeignKey("FK_CommunicationTestResults_CommunicationTestRuns")
+ .OnTable("CommunicationTestResults");
+ Delete.ForeignKey("FK_CommunicationTestRuns_CommunicationTests")
+ .OnTable("CommunicationTestRuns");
+
+ Create.ForeignKey("FK_CommunicationTestRuns_CommunicationTests")
+ .FromTable("CommunicationTestRuns").ForeignColumn("CommunicationTestId")
+ .ToTable("CommunicationTests").PrimaryColumn("CommunicationTestId")
+ .OnDelete(Rule.Cascade);
+
+ Create.ForeignKey("FK_CommunicationTestResults_CommunicationTestRuns")
+ .FromTable("CommunicationTestResults").ForeignColumn("CommunicationTestRunId")
+ .ToTable("CommunicationTestRuns").PrimaryColumn("CommunicationTestRunId")
+ .OnDelete(Rule.Cascade);
+ }
+
+ public override void Down()
+ {
+ Delete.ForeignKey("FK_CommunicationTestResults_CommunicationTestRuns")
+ .OnTable("CommunicationTestResults");
+ Delete.ForeignKey("FK_CommunicationTestRuns_CommunicationTests")
+ .OnTable("CommunicationTestRuns");
+
+ Create.ForeignKey("FK_CommunicationTestRuns_CommunicationTests")
+ .FromTable("CommunicationTestRuns").ForeignColumn("CommunicationTestId")
+ .ToTable("CommunicationTests").PrimaryColumn("CommunicationTestId");
+
+ Create.ForeignKey("FK_CommunicationTestResults_CommunicationTestRuns")
+ .FromTable("CommunicationTestResults").ForeignColumn("CommunicationTestRunId")
+ .ToTable("CommunicationTestRuns").PrimaryColumn("CommunicationTestRunId");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs
new file mode 100644
index 000000000..48be3ad52
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0112_AddModeration.cs
@@ -0,0 +1,252 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(112)]
+ public class M0112_AddModeration : Migration
+ {
+ public override void Up()
+ {
+ if (Schema.Table("ChatMessages").Exists() && !Schema.Table("ChatMessages").Column("IsModerated").Exists())
+ {
+ Alter.Table("ChatMessages")
+ .AddColumn("IsModerated").AsBoolean().NotNullable().WithDefaultValue(false);
+ }
+
+ if (!Schema.Table("ModerationRequests").Exists())
+ {
+ Create.Table("ModerationRequests")
+ .WithColumn("ModerationRequestId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("ItemType").AsInt32().NotNullable()
+ .WithColumn("ItemId").AsString(128).NotNullable()
+ .WithColumn("CallId").AsInt32().Nullable()
+ .WithColumn("ChatChannelId").AsString(128).Nullable()
+ .WithColumn("ContentAuthorUserId").AsString(450).Nullable()
+ .WithColumn("ContentAuthorUnitId").AsInt32().Nullable()
+ .WithColumn("ContentCreatedOn").AsDateTime2().Nullable()
+ .WithColumn("OriginalSubject").AsString(int.MaxValue).Nullable()
+ .WithColumn("OriginalText").AsString(int.MaxValue).Nullable()
+ .WithColumn("OriginalFileName").AsString(int.MaxValue).Nullable()
+ .WithColumn("OriginalContentType").AsString(256).Nullable()
+ .WithColumn("OriginalContent").AsBinary(int.MaxValue).Nullable()
+ .WithColumn("OriginalMetadataJson").AsString(int.MaxValue).Nullable()
+ .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Disposition").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("CreatedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("ModifiedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("CompletedByUserId").AsString(450).Nullable()
+ .WithColumn("CompletedOn").AsDateTime2().Nullable()
+ .WithColumn("AdminNote").AsString(int.MaxValue).Nullable();
+
+ Create.Index("UX_ModerationRequests_Department_Item")
+ .OnTable("ModerationRequests")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("ItemType").Ascending()
+ .OnColumn("ItemId").Ascending()
+ .WithOptions().Unique();
+
+ Create.Index("IX_ModerationRequests_Department_Status_ModifiedOn")
+ .OnTable("ModerationRequests")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("Status").Ascending()
+ .OnColumn("ModifiedOn").Descending();
+
+ Create.Index("IX_ModerationRequests_Department_Author")
+ .OnTable("ModerationRequests")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("ContentAuthorUserId").Ascending();
+ }
+
+ if (!Schema.Table("ModerationReports").Exists())
+ {
+ Create.Table("ModerationReports")
+ .WithColumn("ModerationReportId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("ModerationRequestId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("ReportedByUserId").AsString(450).NotNullable()
+ .WithColumn("ReporterGroupId").AsInt32().Nullable()
+ .WithColumn("Reason").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("Note").AsString(int.MaxValue).Nullable()
+ .WithColumn("ReportedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime);
+
+ Create.Index("UX_ModerationReports_Request_Reporter")
+ .OnTable("ModerationReports")
+ .OnColumn("ModerationRequestId").Ascending()
+ .OnColumn("ReportedByUserId").Ascending()
+ .WithOptions().Unique();
+
+ Create.Index("IX_ModerationReports_Department_Group")
+ .OnTable("ModerationReports")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("ReporterGroupId").Ascending();
+
+ Create.Index("IX_ModerationReports_Department_Reporter")
+ .OnTable("ModerationReports")
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("ReportedByUserId").Ascending();
+ }
+
+ if (!Schema.Table("ModerationActions").Exists())
+ {
+ Create.Table("ModerationActions")
+ .WithColumn("ModerationActionId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("ModerationRequestId").AsString(128).NotNullable()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("ActionType").AsInt32().NotNullable()
+ .WithColumn("PerformedByUserId").AsString(450).Nullable()
+ .WithColumn("PerformedOn").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("Note").AsString(int.MaxValue).Nullable()
+ .WithColumn("PreviousStatus").AsInt32().Nullable()
+ .WithColumn("NewStatus").AsInt32().Nullable()
+ .WithColumn("ActorRole").AsString(128).Nullable()
+ .WithColumn("IpAddress").AsString(128).Nullable()
+ .WithColumn("UserAgent").AsString(int.MaxValue).Nullable()
+ .WithColumn("TraceId").AsString(256).Nullable()
+ .WithColumn("ServerName").AsString(256).Nullable()
+ .WithColumn("DetailsJson").AsString(int.MaxValue).Nullable()
+ .WithColumn("EvidenceText").AsString(int.MaxValue).Nullable()
+ .WithColumn("EvidenceContent").AsBinary(int.MaxValue).Nullable()
+ .WithColumn("EvidenceMetadataJson").AsString(int.MaxValue).Nullable();
+
+ Create.Index("IX_ModerationActions_Request_PerformedOn")
+ .OnTable("ModerationActions")
+ .OnColumn("ModerationRequestId").Ascending()
+ .OnColumn("PerformedOn").Ascending();
+ }
+
+ Create.ForeignKey("FK_ModerationReports_ModerationRequests")
+ .FromTable("ModerationReports").ForeignColumn("ModerationRequestId")
+ .ToTable("ModerationRequests").PrimaryColumn("ModerationRequestId");
+
+ Create.ForeignKey("FK_ModerationActions_ModerationRequests")
+ .FromTable("ModerationActions").ForeignColumn("ModerationRequestId")
+ .ToTable("ModerationRequests").PrimaryColumn("ModerationRequestId");
+
+ ImportLegacyFlags();
+ }
+
+ private void ImportLegacyFlags()
+ {
+ Execute.Sql(@"
+INSERT INTO ModerationRequests
+ (ModerationRequestId, DepartmentId, ItemType, ItemId, ChatChannelId, ContentAuthorUserId,
+ ContentAuthorUnitId, ContentCreatedOn, OriginalText, OriginalFileName, OriginalContentType,
+ OriginalContent, OriginalMetadataJson, Status, Disposition, CreatedOn, ModifiedOn,
+ CompletedByUserId, CompletedOn, AdminNote)
+SELECT MIN(f.ChatMessageFlagId), f.DepartmentId, 0, f.ChatMessageId, MIN(f.ChatChannelId),
+ MIN(m.SenderUserId), MIN(m.SenderUnitId), MIN(m.SentOn),
+ COALESCE(MIN(m.Body), (SELECT TOP 1 e.PriorBody FROM ChatMessageEdits e
+ WHERE e.ChatMessageId = f.ChatMessageId ORDER BY e.EditedOn DESC)),
+ (SELECT TOP 1 ca.FileName FROM ChatAttachments ca
+ WHERE ca.ChatMessageId = f.ChatMessageId ORDER BY ca.UploadedOn),
+ (SELECT TOP 1 ca.ContentType FROM ChatAttachments ca
+ WHERE ca.ChatMessageId = f.ChatMessageId ORDER BY ca.UploadedOn),
+ (SELECT TOP 1 ca.Data FROM ChatAttachments ca
+ WHERE ca.ChatMessageId = f.ChatMessageId ORDER BY ca.UploadedOn),
+ MIN(m.MetadataJson),
+ CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN 0 ELSE 1 END,
+ CASE WHEN SUM(CASE WHEN f.Status = 0 THEN 1 ELSE 0 END) > 0 THEN 0
+ WHEN SUM(CASE WHEN f.Status = 3 THEN 1 ELSE 0 END) > 0 THEN 2 ELSE 1 END,
+ MIN(f.FlaggedOn), MAX(COALESCE(f.ReviewedOn, f.FlaggedOn)), MAX(f.ReviewedByUserId),
+ MAX(f.ReviewedOn), MAX(f.ResolutionNote)
+FROM ChatMessageFlags f
+LEFT JOIN ChatMessages m ON m.ChatMessageId = f.ChatMessageId
+GROUP BY f.DepartmentId, f.ChatMessageId;
+
+INSERT INTO ModerationReports
+ (ModerationReportId, ModerationRequestId, DepartmentId, ReportedByUserId, ReporterGroupId,
+ Reason, Note, ReportedOn)
+SELECT MIN(f.ChatMessageFlagId),
+ (SELECT MIN(f2.ChatMessageFlagId) FROM ChatMessageFlags f2
+ WHERE f2.DepartmentId = f.DepartmentId AND f2.ChatMessageId = f.ChatMessageId),
+ f.DepartmentId, f.FlaggedByUserId,
+ (SELECT TOP 1 gm.DepartmentGroupId FROM DepartmentGroupMembers gm
+ WHERE gm.DepartmentId = f.DepartmentId AND gm.UserId = f.FlaggedByUserId
+ ORDER BY gm.DepartmentGroupMemberId),
+ MIN(f.Reason), MIN(f.Note), MIN(f.FlaggedOn)
+FROM ChatMessageFlags f
+WHERE f.FlaggedByUserId IS NOT NULL
+GROUP BY f.DepartmentId, f.ChatMessageId, f.FlaggedByUserId;
+
+INSERT INTO ModerationRequests
+ (ModerationRequestId, DepartmentId, ItemType, ItemId, CallId, ContentAuthorUserId,
+ ContentCreatedOn, OriginalText, OriginalMetadataJson, Status, Disposition, CreatedOn, ModifiedOn)
+SELECT CONCAT('callnote-', n.CallNoteId), c.DepartmentId, 2, CONVERT(varchar(32), n.CallNoteId),
+ n.CallId, n.UserId, n.Timestamp, n.Note,
+ CONCAT('{""source"":', n.Source, ',""latitude"":', COALESCE(CONVERT(varchar(64), n.Latitude), 'null'),
+ ',""longitude"":', COALESCE(CONVERT(varchar(64), n.Longitude), 'null'), '}'),
+ 0, 0, COALESCE(n.FlaggedOn, n.Timestamp), COALESCE(n.FlaggedOn, n.Timestamp)
+FROM CallNotes n
+INNER JOIN Calls c ON c.CallId = n.CallId
+WHERE n.IsFlagged = 1;
+
+INSERT INTO ModerationReports
+ (ModerationReportId, ModerationRequestId, DepartmentId, ReportedByUserId, ReporterGroupId,
+ Reason, Note, ReportedOn)
+SELECT CONCAT('callnote-', n.CallNoteId), CONCAT('callnote-', n.CallNoteId), c.DepartmentId,
+ n.FlaggedByUserId,
+ (SELECT TOP 1 gm.DepartmentGroupId FROM DepartmentGroupMembers gm
+ WHERE gm.DepartmentId = c.DepartmentId AND gm.UserId = n.FlaggedByUserId
+ ORDER BY gm.DepartmentGroupMemberId),
+ 0, n.FlaggedReason, COALESCE(n.FlaggedOn, n.Timestamp)
+FROM CallNotes n
+INNER JOIN Calls c ON c.CallId = n.CallId
+WHERE n.IsFlagged = 1 AND n.FlaggedByUserId IS NOT NULL;
+
+INSERT INTO ModerationRequests
+ (ModerationRequestId, DepartmentId, ItemType, ItemId, CallId, ContentAuthorUserId,
+ ContentCreatedOn, OriginalFileName, OriginalContentType, OriginalContent, OriginalMetadataJson,
+ Status, Disposition, CreatedOn, ModifiedOn)
+SELECT CONCAT('callimage-', a.CallAttachmentId), c.DepartmentId, 3,
+ CONVERT(varchar(32), a.CallAttachmentId), a.CallId, a.UserId, a.Timestamp, a.FileName,
+ 'image/jpeg', a.Data,
+ CONCAT('{""name"":""', COALESCE(REPLACE(a.Name, '""', '\""'), ''),
+ '"",""size"":', COALESCE(CONVERT(varchar(32), a.Size), 'null'), '}'),
+ 0, 0, COALESCE(a.FlaggedOn, a.Timestamp, GETUTCDATE()),
+ COALESCE(a.FlaggedOn, a.Timestamp, GETUTCDATE())
+FROM CallAttachments a
+INNER JOIN Calls c ON c.CallId = a.CallId
+WHERE a.IsFlagged = 1 AND a.CallAttachmentType = 2;
+
+INSERT INTO ModerationReports
+ (ModerationReportId, ModerationRequestId, DepartmentId, ReportedByUserId, ReporterGroupId,
+ Reason, Note, ReportedOn)
+SELECT CONCAT('callimage-', a.CallAttachmentId), CONCAT('callimage-', a.CallAttachmentId),
+ c.DepartmentId, a.FlaggedByUserId,
+ (SELECT TOP 1 gm.DepartmentGroupId FROM DepartmentGroupMembers gm
+ WHERE gm.DepartmentId = c.DepartmentId AND gm.UserId = a.FlaggedByUserId
+ ORDER BY gm.DepartmentGroupMemberId),
+ 0, a.FlaggedReason, COALESCE(a.FlaggedOn, a.Timestamp, GETUTCDATE())
+FROM CallAttachments a
+INNER JOIN Calls c ON c.CallId = a.CallId
+WHERE a.IsFlagged = 1 AND a.CallAttachmentType = 2 AND a.FlaggedByUserId IS NOT NULL;
+
+INSERT INTO ModerationActions
+ (ModerationActionId, ModerationRequestId, DepartmentId, ActionType, PerformedByUserId,
+ PerformedOn, NewStatus, ActorRole, ServerName, DetailsJson, EvidenceText, EvidenceContent,
+ EvidenceMetadataJson)
+SELECT r.ModerationRequestId, r.ModerationRequestId, r.DepartmentId, 0,
+ (SELECT TOP 1 rp.ReportedByUserId FROM ModerationReports rp
+ WHERE rp.ModerationRequestId = r.ModerationRequestId ORDER BY rp.ReportedOn),
+ r.CreatedOn, r.Status, 'LegacyImport', HOST_NAME(), '{""imported"":true}', r.OriginalText,
+ r.OriginalContent, r.OriginalMetadataJson
+FROM ModerationRequests r;");
+ }
+
+ public override void Down()
+ {
+ if (Schema.Table("ModerationActions").Exists())
+ Delete.Table("ModerationActions");
+
+ if (Schema.Table("ModerationReports").Exists())
+ Delete.Table("ModerationReports");
+
+ if (Schema.Table("ModerationRequests").Exists())
+ Delete.Table("ModerationRequests");
+
+ if (Schema.Table("ChatMessages").Exists() && Schema.Table("ChatMessages").Column("IsModerated").Exists())
+ Delete.Column("IsModerated").FromTable("ChatMessages");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs
new file mode 100644
index 000000000..bc4b8d429
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0111_CascadeCommunicationTestDeletesPg.cs
@@ -0,0 +1,43 @@
+using System.Data;
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(111)]
+ public class M0111_CascadeCommunicationTestDeletesPg : Migration
+ {
+ public override void Up()
+ {
+ Delete.ForeignKey("fk_communicationtestresults_communicationtestruns")
+ .OnTable("communicationtestresults");
+ Delete.ForeignKey("fk_communicationtestruns_communicationtests")
+ .OnTable("communicationtestruns");
+
+ Create.ForeignKey("fk_communicationtestruns_communicationtests")
+ .FromTable("communicationtestruns").ForeignColumn("communicationtestid")
+ .ToTable("communicationtests").PrimaryColumn("communicationtestid")
+ .OnDelete(Rule.Cascade);
+
+ Create.ForeignKey("fk_communicationtestresults_communicationtestruns")
+ .FromTable("communicationtestresults").ForeignColumn("communicationtestrunid")
+ .ToTable("communicationtestruns").PrimaryColumn("communicationtestrunid")
+ .OnDelete(Rule.Cascade);
+ }
+
+ public override void Down()
+ {
+ Delete.ForeignKey("fk_communicationtestresults_communicationtestruns")
+ .OnTable("communicationtestresults");
+ Delete.ForeignKey("fk_communicationtestruns_communicationtests")
+ .OnTable("communicationtestruns");
+
+ Create.ForeignKey("fk_communicationtestruns_communicationtests")
+ .FromTable("communicationtestruns").ForeignColumn("communicationtestid")
+ .ToTable("communicationtests").PrimaryColumn("communicationtestid");
+
+ Create.ForeignKey("fk_communicationtestresults_communicationtestruns")
+ .FromTable("communicationtestresults").ForeignColumn("communicationtestrunid")
+ .ToTable("communicationtestruns").PrimaryColumn("communicationtestrunid");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs
new file mode 100644
index 000000000..60725ba3f
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0112_AddModerationPg.cs
@@ -0,0 +1,249 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(112)]
+ public class M0112_AddModerationPg : Migration
+ {
+ public override void Up()
+ {
+ if (Schema.Table("chatmessages").Exists() && !Schema.Table("chatmessages").Column("ismoderated").Exists())
+ {
+ Alter.Table("chatmessages")
+ .AddColumn("ismoderated").AsBoolean().NotNullable().WithDefaultValue(false);
+ }
+
+ if (!Schema.Table("moderationrequests").Exists())
+ {
+ Create.Table("moderationrequests")
+ .WithColumn("moderationrequestid").AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("departmentid").AsInt32().NotNullable()
+ .WithColumn("itemtype").AsInt32().NotNullable()
+ .WithColumn("itemid").AsCustom("citext").NotNullable()
+ .WithColumn("callid").AsInt32().Nullable()
+ .WithColumn("chatchannelid").AsCustom("citext").Nullable()
+ .WithColumn("contentauthoruserid").AsCustom("citext").Nullable()
+ .WithColumn("contentauthorunitid").AsInt32().Nullable()
+ .WithColumn("contentcreatedon").AsDateTime2().Nullable()
+ .WithColumn("originalsubject").AsCustom("text").Nullable()
+ .WithColumn("originaltext").AsCustom("text").Nullable()
+ .WithColumn("originalfilename").AsCustom("text").Nullable()
+ .WithColumn("originalcontenttype").AsCustom("citext").Nullable()
+ .WithColumn("originalcontent").AsCustom("bytea").Nullable()
+ .WithColumn("originalmetadatajson").AsCustom("text").Nullable()
+ .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("disposition").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("createdon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("modifiedon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("completedbyuserid").AsCustom("citext").Nullable()
+ .WithColumn("completedon").AsDateTime2().Nullable()
+ .WithColumn("adminnote").AsCustom("text").Nullable();
+
+ Create.Index("ux_moderationrequests_department_item")
+ .OnTable("moderationrequests")
+ .OnColumn("departmentid").Ascending()
+ .OnColumn("itemtype").Ascending()
+ .OnColumn("itemid").Ascending()
+ .WithOptions().Unique();
+
+ Create.Index("ix_moderationrequests_department_status_modifiedon")
+ .OnTable("moderationrequests")
+ .OnColumn("departmentid").Ascending()
+ .OnColumn("status").Ascending()
+ .OnColumn("modifiedon").Descending();
+
+ Create.Index("ix_moderationrequests_department_author")
+ .OnTable("moderationrequests")
+ .OnColumn("departmentid").Ascending()
+ .OnColumn("contentauthoruserid").Ascending();
+ }
+
+ if (!Schema.Table("moderationreports").Exists())
+ {
+ Create.Table("moderationreports")
+ .WithColumn("moderationreportid").AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("moderationrequestid").AsCustom("citext").NotNullable()
+ .WithColumn("departmentid").AsInt32().NotNullable()
+ .WithColumn("reportedbyuserid").AsCustom("citext").NotNullable()
+ .WithColumn("reportergroupid").AsInt32().Nullable()
+ .WithColumn("reason").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("note").AsCustom("text").Nullable()
+ .WithColumn("reportedon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime);
+
+ Create.Index("ux_moderationreports_request_reporter")
+ .OnTable("moderationreports")
+ .OnColumn("moderationrequestid").Ascending()
+ .OnColumn("reportedbyuserid").Ascending()
+ .WithOptions().Unique();
+
+ Create.Index("ix_moderationreports_department_group")
+ .OnTable("moderationreports")
+ .OnColumn("departmentid").Ascending()
+ .OnColumn("reportergroupid").Ascending();
+
+ Create.Index("ix_moderationreports_department_reporter")
+ .OnTable("moderationreports")
+ .OnColumn("departmentid").Ascending()
+ .OnColumn("reportedbyuserid").Ascending();
+ }
+
+ if (!Schema.Table("moderationactions").Exists())
+ {
+ Create.Table("moderationactions")
+ .WithColumn("moderationactionid").AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("moderationrequestid").AsCustom("citext").NotNullable()
+ .WithColumn("departmentid").AsInt32().NotNullable()
+ .WithColumn("actiontype").AsInt32().NotNullable()
+ .WithColumn("performedbyuserid").AsCustom("citext").Nullable()
+ .WithColumn("performedon").AsDateTime2().NotNullable().WithDefault(SystemMethods.CurrentUTCDateTime)
+ .WithColumn("note").AsCustom("text").Nullable()
+ .WithColumn("previousstatus").AsInt32().Nullable()
+ .WithColumn("newstatus").AsInt32().Nullable()
+ .WithColumn("actorrole").AsCustom("citext").Nullable()
+ .WithColumn("ipaddress").AsCustom("citext").Nullable()
+ .WithColumn("useragent").AsCustom("text").Nullable()
+ .WithColumn("traceid").AsCustom("citext").Nullable()
+ .WithColumn("servername").AsCustom("citext").Nullable()
+ .WithColumn("detailsjson").AsCustom("text").Nullable()
+ .WithColumn("evidencetext").AsCustom("text").Nullable()
+ .WithColumn("evidencecontent").AsCustom("bytea").Nullable()
+ .WithColumn("evidencemetadatajson").AsCustom("text").Nullable();
+
+ Create.Index("ix_moderationactions_request_performedon")
+ .OnTable("moderationactions")
+ .OnColumn("moderationrequestid").Ascending()
+ .OnColumn("performedon").Ascending();
+ }
+
+ Create.ForeignKey("fk_moderationreports_moderationrequests")
+ .FromTable("moderationreports").ForeignColumn("moderationrequestid")
+ .ToTable("moderationrequests").PrimaryColumn("moderationrequestid");
+
+ Create.ForeignKey("fk_moderationactions_moderationrequests")
+ .FromTable("moderationactions").ForeignColumn("moderationrequestid")
+ .ToTable("moderationrequests").PrimaryColumn("moderationrequestid");
+
+ ImportLegacyFlags();
+ }
+
+ private void ImportLegacyFlags()
+ {
+ Execute.Sql(@"
+INSERT INTO moderationrequests
+ (moderationrequestid, departmentid, itemtype, itemid, chatchannelid, contentauthoruserid,
+ contentauthorunitid, contentcreatedon, originaltext, originalfilename, originalcontenttype,
+ originalcontent, originalmetadatajson, status, disposition, createdon, modifiedon,
+ completedbyuserid, completedon, adminnote)
+SELECT MIN(f.chatmessageflagid::text), f.departmentid, 0, f.chatmessageid, MIN(f.chatchannelid::text),
+ MIN(m.senderuserid::text), MIN(m.senderunitid), MIN(m.senton),
+ COALESCE(MIN(m.body), (SELECT e.priorbody FROM chatmessageedits e
+ WHERE e.chatmessageid = f.chatmessageid ORDER BY e.editedon DESC LIMIT 1)),
+ (SELECT ca.filename FROM chatattachments ca
+ WHERE ca.chatmessageid = f.chatmessageid ORDER BY ca.uploadedon LIMIT 1),
+ (SELECT ca.contenttype FROM chatattachments ca
+ WHERE ca.chatmessageid = f.chatmessageid ORDER BY ca.uploadedon LIMIT 1),
+ (SELECT ca.data FROM chatattachments ca
+ WHERE ca.chatmessageid = f.chatmessageid ORDER BY ca.uploadedon LIMIT 1),
+ MIN(m.metadatajson),
+ CASE WHEN SUM(CASE WHEN f.status = 0 THEN 1 ELSE 0 END) > 0 THEN 0 ELSE 1 END,
+ CASE WHEN SUM(CASE WHEN f.status = 0 THEN 1 ELSE 0 END) > 0 THEN 0
+ WHEN SUM(CASE WHEN f.status = 3 THEN 1 ELSE 0 END) > 0 THEN 2 ELSE 1 END,
+ MIN(f.flaggedon), MAX(COALESCE(f.reviewedon, f.flaggedon)), MAX(f.reviewedbyuserid::text),
+ MAX(f.reviewedon), MAX(f.resolutionnote)
+FROM chatmessageflags f
+LEFT JOIN chatmessages m ON m.chatmessageid = f.chatmessageid
+GROUP BY f.departmentid, f.chatmessageid;
+
+INSERT INTO moderationreports
+ (moderationreportid, moderationrequestid, departmentid, reportedbyuserid, reportergroupid,
+ reason, note, reportedon)
+SELECT MIN(f.chatmessageflagid::text),
+ (SELECT MIN(f2.chatmessageflagid::text) FROM chatmessageflags f2
+ WHERE f2.departmentid = f.departmentid AND f2.chatmessageid = f.chatmessageid),
+ f.departmentid, f.flaggedbyuserid,
+ (SELECT gm.departmentgroupid FROM departmentgroupmembers gm
+ WHERE gm.departmentid = f.departmentid AND gm.userid = f.flaggedbyuserid
+ ORDER BY gm.departmentgroupmemberid LIMIT 1),
+ MIN(f.reason), MIN(f.note), MIN(f.flaggedon)
+FROM chatmessageflags f
+WHERE f.flaggedbyuserid IS NOT NULL
+GROUP BY f.departmentid, f.chatmessageid, f.flaggedbyuserid;
+
+INSERT INTO moderationrequests
+ (moderationrequestid, departmentid, itemtype, itemid, callid, contentauthoruserid,
+ contentcreatedon, originaltext, originalmetadatajson, status, disposition, createdon, modifiedon)
+SELECT CONCAT('callnote-', n.callnoteid), c.departmentid, 2, n.callnoteid::text,
+ n.callid, n.userid, n.timestamp, n.note,
+ json_build_object('source', n.source, 'latitude', n.latitude, 'longitude', n.longitude)::text,
+ 0, 0, COALESCE(n.flaggedon, n.timestamp), COALESCE(n.flaggedon, n.timestamp)
+FROM callnotes n
+INNER JOIN calls c ON c.callid = n.callid
+WHERE n.isflagged = true;
+
+INSERT INTO moderationreports
+ (moderationreportid, moderationrequestid, departmentid, reportedbyuserid, reportergroupid,
+ reason, note, reportedon)
+SELECT CONCAT('callnote-', n.callnoteid), CONCAT('callnote-', n.callnoteid), c.departmentid,
+ n.flaggedbyuserid,
+ (SELECT gm.departmentgroupid FROM departmentgroupmembers gm
+ WHERE gm.departmentid = c.departmentid AND gm.userid = n.flaggedbyuserid
+ ORDER BY gm.departmentgroupmemberid LIMIT 1),
+ 0, n.flaggedreason, COALESCE(n.flaggedon, n.timestamp)
+FROM callnotes n
+INNER JOIN calls c ON c.callid = n.callid
+WHERE n.isflagged = true AND n.flaggedbyuserid IS NOT NULL;
+
+INSERT INTO moderationrequests
+ (moderationrequestid, departmentid, itemtype, itemid, callid, contentauthoruserid,
+ contentcreatedon, originalfilename, originalcontenttype, originalcontent, originalmetadatajson,
+ status, disposition, createdon, modifiedon)
+SELECT CONCAT('callimage-', a.callattachmentid), c.departmentid, 3, a.callattachmentid::text,
+ a.callid, a.userid, a.timestamp, a.filename, 'image/jpeg', a.data,
+ json_build_object('name', a.name, 'size', a.size)::text,
+ 0, 0, COALESCE(a.flaggedon, a.timestamp, NOW() AT TIME ZONE 'utc'),
+ COALESCE(a.flaggedon, a.timestamp, NOW() AT TIME ZONE 'utc')
+FROM callattachments a
+INNER JOIN calls c ON c.callid = a.callid
+WHERE a.isflagged = true AND a.callattachmenttype = 2;
+
+INSERT INTO moderationreports
+ (moderationreportid, moderationrequestid, departmentid, reportedbyuserid, reportergroupid,
+ reason, note, reportedon)
+SELECT CONCAT('callimage-', a.callattachmentid), CONCAT('callimage-', a.callattachmentid),
+ c.departmentid, a.flaggedbyuserid,
+ (SELECT gm.departmentgroupid FROM departmentgroupmembers gm
+ WHERE gm.departmentid = c.departmentid AND gm.userid = a.flaggedbyuserid
+ ORDER BY gm.departmentgroupmemberid LIMIT 1),
+ 0, a.flaggedreason, COALESCE(a.flaggedon, a.timestamp, NOW() AT TIME ZONE 'utc')
+FROM callattachments a
+INNER JOIN calls c ON c.callid = a.callid
+WHERE a.isflagged = true AND a.callattachmenttype = 2 AND a.flaggedbyuserid IS NOT NULL;
+
+INSERT INTO moderationactions
+ (moderationactionid, moderationrequestid, departmentid, actiontype, performedbyuserid,
+ performedon, newstatus, actorrole, servername, detailsjson, evidencetext, evidencecontent,
+ evidencemetadatajson)
+SELECT r.moderationrequestid, r.moderationrequestid, r.departmentid, 0,
+ (SELECT rp.reportedbyuserid FROM moderationreports rp
+ WHERE rp.moderationrequestid = r.moderationrequestid ORDER BY rp.reportedon LIMIT 1),
+ r.createdon, r.status, 'LegacyImport', inet_server_addr()::text, '{""imported"":true}',
+ r.originaltext, r.originalcontent, r.originalmetadatajson
+FROM moderationrequests r;");
+ }
+
+ public override void Down()
+ {
+ if (Schema.Table("moderationactions").Exists())
+ Delete.Table("moderationactions");
+
+ if (Schema.Table("moderationreports").Exists())
+ Delete.Table("moderationreports");
+
+ if (Schema.Table("moderationrequests").Exists())
+ Delete.Table("moderationrequests");
+
+ if (Schema.Table("chatmessages").Exists() && Schema.Table("chatmessages").Column("ismoderated").Exists())
+ Delete.Column("ismoderated").FromTable("chatmessages");
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
index 1cf7a962d..1dab7e26a 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
@@ -1491,7 +1491,7 @@ public async Task UpdateBodyAsync(string chatMessageId, string body, DateT
}
}
- public async Task TombstoneAsync(string chatMessageId, DateTime deletedOn, string deletedByUserId, CancellationToken cancellationToken)
+ public async Task TombstoneAsync(string chatMessageId, DateTime deletedOn, string deletedByUserId, bool isModerated, CancellationToken cancellationToken)
{
try
{
@@ -1499,10 +1499,11 @@ public async Task TombstoneAsync(string chatMessageId, DateTime deletedOn,
parameters.Add("Id", chatMessageId);
parameters.Add("DeletedOn", deletedOn, DbType.DateTime2);
parameters.Add("DeletedByUserId", deletedByUserId);
+ parameters.Add("IsModerated", isModerated);
var notation = _sqlConfiguration.ParameterNotation;
var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
- ? $"UPDATE {_sqlConfiguration.SchemaName}.chatmessages SET body = NULL, metadatajson = NULL, deletedon = {notation}DeletedOn, deletedbyuserid = {notation}DeletedByUserId WHERE chatmessageid = {notation}Id AND deletedon IS NULL"
- : $"UPDATE {_sqlConfiguration.SchemaName}.[ChatMessages] SET [Body] = NULL, [MetadataJson] = NULL, [DeletedOn] = {notation}DeletedOn, [DeletedByUserId] = {notation}DeletedByUserId WHERE [ChatMessageId] = {notation}Id AND [DeletedOn] IS NULL";
+ ? $"UPDATE {_sqlConfiguration.SchemaName}.chatmessages SET body = NULL, metadatajson = NULL, deletedon = {notation}DeletedOn, deletedbyuserid = {notation}DeletedByUserId, ismoderated = {notation}IsModerated WHERE chatmessageid = {notation}Id AND deletedon IS NULL"
+ : $"UPDATE {_sqlConfiguration.SchemaName}.[ChatMessages] SET [Body] = NULL, [MetadataJson] = NULL, [DeletedOn] = {notation}DeletedOn, [DeletedByUserId] = {notation}DeletedByUserId, [IsModerated] = {notation}IsModerated WHERE [ChatMessageId] = {notation}Id AND [DeletedOn] IS NULL";
var execute = new Func>(connection =>
connection.ExecuteAsync(sql, parameters, _unitOfWork.Transaction));
diff --git a/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs
new file mode 100644
index 000000000..afbd5182d
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs
@@ -0,0 +1,316 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using System.Threading.Tasks;
+using Dapper;
+using Resgrid.Config;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Connection;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Repositories.DataRepository.Configs;
+
+namespace Resgrid.Repositories.DataRepository
+{
+ public class ModerationRequestRepository : RepositoryBase, IModerationRequestRepository
+ {
+ private readonly IConnectionProvider _connectionProvider;
+ private readonly SqlConfiguration _sqlConfiguration;
+ private readonly IUnitOfWork _unitOfWork;
+
+ public ModerationRequestRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration,
+ IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ _connectionProvider = connectionProvider;
+ _sqlConfiguration = sqlConfiguration;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task GetByItemAsync(int departmentId, int itemType, string itemId)
+ {
+ try
+ {
+ var parameters = new DynamicParametersExtension();
+ parameters.Add("DepartmentId", departmentId);
+ parameters.Add("ItemType", itemType);
+ parameters.Add("ItemId", itemId);
+ var notation = _sqlConfiguration.ParameterNotation;
+ var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
+ ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.moderationrequests WHERE departmentid = {notation}DepartmentId AND itemtype = {notation}ItemType AND itemid = {notation}ItemId"
+ : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ModerationRequests] WHERE [DepartmentId] = {notation}DepartmentId AND [ItemType] = {notation}ItemType AND [ItemId] = {notation}ItemId";
+
+ return (await QueryAsync(sql, parameters)).FirstOrDefault();
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ throw;
+ }
+ }
+
+ public async Task> SearchAsync(int departmentId, ModerationSearchCriteria criteria,
+ IEnumerable visibleGroupIds, string reporterUserId)
+ {
+ criteria ??= new ModerationSearchCriteria();
+ var pageSize = criteria.PageSize <= 0 ? 50 : Math.Min(criteria.PageSize, 200);
+ var page = Math.Max(criteria.Page, 1);
+
+ try
+ {
+ var parameters = new DynamicParametersExtension();
+ parameters.Add("DepartmentId", departmentId);
+ parameters.Add("PageSize", pageSize);
+ parameters.Add("Offset", (page - 1) * pageSize);
+ var notation = _sqlConfiguration.ParameterNotation;
+ var postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres;
+ var requestAlias = postgres ? "r" : "r";
+ var filters = new List();
+
+ if (criteria.Status.HasValue)
+ {
+ parameters.Add("Status", (int)criteria.Status.Value);
+ filters.Add(postgres ? $"r.status = {notation}Status" : $"r.[Status] = {notation}Status");
+ }
+
+ if (criteria.ItemType.HasValue)
+ {
+ parameters.Add("ItemType", (int)criteria.ItemType.Value);
+ filters.Add(postgres ? $"r.itemtype = {notation}ItemType" : $"r.[ItemType] = {notation}ItemType");
+ }
+
+ if (!string.IsNullOrWhiteSpace(criteria.ContentAuthorUserId))
+ {
+ parameters.Add("ContentAuthorUserId", criteria.ContentAuthorUserId);
+ filters.Add(postgres
+ ? $"r.contentauthoruserid = {notation}ContentAuthorUserId"
+ : $"r.[ContentAuthorUserId] = {notation}ContentAuthorUserId");
+ }
+
+ if (!string.IsNullOrWhiteSpace(criteria.ReportedByUserId))
+ {
+ parameters.Add("ReportedByUserId", criteria.ReportedByUserId);
+ if (string.IsNullOrWhiteSpace(reporterUserId))
+ {
+ filters.Add(postgres
+ ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rf WHERE rf.moderationrequestid = r.moderationrequestid AND rf.reportedbyuserid = {notation}ReportedByUserId)"
+ : $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.[ModerationReports] rf WHERE rf.[ModerationRequestId] = r.[ModerationRequestId] AND rf.[ReportedByUserId] = {notation}ReportedByUserId)");
+ }
+ }
+
+ if (criteria.From.HasValue)
+ {
+ parameters.Add("From", criteria.From.Value);
+ filters.Add(postgres ? $"r.createdon >= {notation}From" : $"r.[CreatedOn] >= {notation}From");
+ }
+
+ if (criteria.To.HasValue)
+ {
+ parameters.Add("To", criteria.To.Value);
+ filters.Add(postgres ? $"r.createdon < {notation}To" : $"r.[CreatedOn] < {notation}To");
+ }
+
+ var groupIds = visibleGroupIds?.Distinct().ToList() ?? new List();
+ if (!string.IsNullOrWhiteSpace(reporterUserId))
+ {
+ parameters.Add("ReporterUserId", reporterUserId);
+ if (groupIds.Count > 0)
+ {
+ parameters.Add("VisibleGroupIds", groupIds);
+ var requestedReporter = string.IsNullOrWhiteSpace(criteria.ReportedByUserId)
+ ? string.Empty
+ : postgres
+ ? $" AND rv.reportedbyuserid = {notation}ReportedByUserId"
+ : $" AND rv.[ReportedByUserId] = {notation}ReportedByUserId";
+ filters.Add(postgres
+ ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rv WHERE rv.moderationrequestid = r.moderationrequestid{requestedReporter} AND (rv.reportedbyuserid = {notation}ReporterUserId OR rv.reportergroupid IN {notation}VisibleGroupIds))"
+ : $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.[ModerationReports] rv WHERE rv.[ModerationRequestId] = r.[ModerationRequestId]{requestedReporter} AND (rv.[ReportedByUserId] = {notation}ReporterUserId OR rv.[ReporterGroupId] IN {notation}VisibleGroupIds))");
+ }
+ else
+ {
+ var requestedReporter = string.IsNullOrWhiteSpace(criteria.ReportedByUserId)
+ ? string.Empty
+ : postgres
+ ? $" AND rv.reportedbyuserid = {notation}ReportedByUserId"
+ : $" AND rv.[ReportedByUserId] = {notation}ReportedByUserId";
+ filters.Add(postgres
+ ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rv WHERE rv.moderationrequestid = r.moderationrequestid{requestedReporter} AND rv.reportedbyuserid = {notation}ReporterUserId)"
+ : $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.[ModerationReports] rv WHERE rv.[ModerationRequestId] = r.[ModerationRequestId]{requestedReporter} AND rv.[ReportedByUserId] = {notation}ReporterUserId)");
+ }
+ }
+
+ var extra = filters.Count > 0 ? " AND " + string.Join(" AND ", filters) : string.Empty;
+ string sql;
+ if (postgres)
+ {
+ sql = $@"SELECT r.moderationrequestid, r.departmentid, r.itemtype, r.itemid, r.callid,
+ r.chatchannelid, r.contentauthoruserid, r.contentauthorunitid, r.contentcreatedon,
+ r.originalsubject, r.originaltext, r.originalfilename, r.originalcontenttype,
+ r.originalmetadatajson, r.status, r.disposition, r.createdon, r.modifiedon,
+ r.completedbyuserid, r.completedon, r.adminnote
+FROM {_sqlConfiguration.SchemaName}.moderationrequests {requestAlias}
+WHERE r.departmentid = {notation}DepartmentId{extra}
+ORDER BY r.modifiedon DESC
+LIMIT {notation}PageSize OFFSET {notation}Offset";
+ }
+ else
+ {
+ sql = $@"SELECT r.[ModerationRequestId], r.[DepartmentId], r.[ItemType], r.[ItemId], r.[CallId],
+ r.[ChatChannelId], r.[ContentAuthorUserId], r.[ContentAuthorUnitId], r.[ContentCreatedOn],
+ r.[OriginalSubject], r.[OriginalText], r.[OriginalFileName], r.[OriginalContentType],
+ r.[OriginalMetadataJson], r.[Status], r.[Disposition], r.[CreatedOn], r.[ModifiedOn],
+ r.[CompletedByUserId], r.[CompletedOn], r.[AdminNote]
+FROM {_sqlConfiguration.SchemaName}.[ModerationRequests] r
+WHERE r.[DepartmentId] = {notation}DepartmentId{extra}
+ORDER BY r.[ModifiedOn] DESC
+OFFSET {notation}Offset ROWS FETCH NEXT {notation}PageSize ROWS ONLY";
+ }
+
+ return await QueryAsync(sql, parameters);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ throw;
+ }
+ }
+
+ private async Task> QueryAsync(string sql, object parameters)
+ {
+ var select = new Func>>(connection =>
+ connection.QueryAsync(sql, parameters, _unitOfWork.Transaction));
+
+ if (_unitOfWork?.Connection == null)
+ {
+ using var connection = _connectionProvider.Create();
+ await connection.OpenAsync();
+ return await select(connection);
+ }
+
+ return await select(_unitOfWork.CreateOrGetConnection());
+ }
+ }
+
+ public class ModerationReportRepository : RepositoryBase, IModerationReportRepository
+ {
+ private readonly IConnectionProvider _connectionProvider;
+ private readonly SqlConfiguration _sqlConfiguration;
+ private readonly IUnitOfWork _unitOfWork;
+
+ public ModerationReportRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration,
+ IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ _connectionProvider = connectionProvider;
+ _sqlConfiguration = sqlConfiguration;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task GetByRequestAndReporterAsync(string moderationRequestId, string reportedByUserId)
+ {
+ var rows = await QueryAsync(moderationRequestId, reportedByUserId);
+ return rows.FirstOrDefault();
+ }
+
+ public Task> GetByRequestAsync(string moderationRequestId)
+ {
+ return QueryAsync(moderationRequestId, null);
+ }
+
+ private async Task> QueryAsync(string moderationRequestId, string reportedByUserId)
+ {
+ try
+ {
+ var parameters = new DynamicParametersExtension();
+ parameters.Add("ModerationRequestId", moderationRequestId);
+ var notation = _sqlConfiguration.ParameterNotation;
+ var postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres;
+ var reporterClause = string.Empty;
+ if (!string.IsNullOrWhiteSpace(reportedByUserId))
+ {
+ parameters.Add("ReportedByUserId", reportedByUserId);
+ reporterClause = postgres
+ ? $" AND reportedbyuserid = {notation}ReportedByUserId"
+ : $" AND [ReportedByUserId] = {notation}ReportedByUserId";
+ }
+
+ var sql = postgres
+ ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.moderationreports WHERE moderationrequestid = {notation}ModerationRequestId{reporterClause} ORDER BY reportedon"
+ : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ModerationReports] WHERE [ModerationRequestId] = {notation}ModerationRequestId{reporterClause} ORDER BY [ReportedOn]";
+
+ var select = new Func>>(connection =>
+ connection.QueryAsync(sql, parameters, _unitOfWork.Transaction));
+
+ if (_unitOfWork?.Connection == null)
+ {
+ using var connection = _connectionProvider.Create();
+ await connection.OpenAsync();
+ return await select(connection);
+ }
+
+ return await select(_unitOfWork.CreateOrGetConnection());
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ throw;
+ }
+ }
+ }
+
+ public class ModerationActionRepository : RepositoryBase, IModerationActionRepository
+ {
+ private readonly IConnectionProvider _connectionProvider;
+ private readonly SqlConfiguration _sqlConfiguration;
+ private readonly IUnitOfWork _unitOfWork;
+
+ public ModerationActionRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration,
+ IUnitOfWork unitOfWork, IQueryFactory queryFactory)
+ : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory)
+ {
+ _connectionProvider = connectionProvider;
+ _sqlConfiguration = sqlConfiguration;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task> GetByRequestAsync(string moderationRequestId)
+ {
+ try
+ {
+ var parameters = new DynamicParametersExtension();
+ parameters.Add("ModerationRequestId", moderationRequestId);
+ var notation = _sqlConfiguration.ParameterNotation;
+ var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
+ ? $@"SELECT moderationactionid, moderationrequestid, departmentid, actiontype,
+ performedbyuserid, performedon, note, previousstatus, newstatus, actorrole, ipaddress,
+ useragent, traceid, servername, detailsjson, evidencetext, evidencemetadatajson
+FROM {_sqlConfiguration.SchemaName}.moderationactions
+WHERE moderationrequestid = {notation}ModerationRequestId ORDER BY performedon"
+ : $@"SELECT [ModerationActionId], [ModerationRequestId], [DepartmentId], [ActionType],
+ [PerformedByUserId], [PerformedOn], [Note], [PreviousStatus], [NewStatus], [ActorRole], [IpAddress],
+ [UserAgent], [TraceId], [ServerName], [DetailsJson], [EvidenceText], [EvidenceMetadataJson]
+FROM {_sqlConfiguration.SchemaName}.[ModerationActions]
+WHERE [ModerationRequestId] = {notation}ModerationRequestId ORDER BY [PerformedOn]";
+
+ var select = new Func>>(connection =>
+ connection.QueryAsync(sql, parameters, _unitOfWork.Transaction));
+
+ if (_unitOfWork?.Connection == null)
+ {
+ using var connection = _connectionProvider.Create();
+ await connection.OpenAsync();
+ return await select(connection);
+ }
+
+ return await select(_unitOfWork.CreateOrGetConnection());
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ throw;
+ }
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
index dfd128b73..d20dfd91d 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs
@@ -130,6 +130,9 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
index 768282971..b7f871f0c 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
@@ -142,6 +142,9 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
index fd5259fbf..cc8085732 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs
@@ -69,6 +69,9 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
index eb484d3e8..aeac592d5 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs
@@ -128,6 +128,9 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs
index f649407b1..2dcc66994 100644
--- a/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs
+++ b/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs
@@ -7,6 +7,7 @@
using Resgrid.Config;
using Resgrid.Chatbot.Models;
using Resgrid.Chatbot.Services;
+using Resgrid.Framework;
using Resgrid.Model;
using Resgrid.Model.Providers;
using Resgrid.Model.Repositories;
@@ -32,6 +33,43 @@ private static Mock CacheMock()
// ---- Per-department LLM override ---------------------------------------------------------
+ [Test]
+ public void DepartmentConfig_SerializesForDistributedCache()
+ {
+ var createdAt = DateTime.UtcNow.AddDays(-1);
+ var updatedAt = DateTime.UtcNow;
+ var config = new ChatbotDepartmentConfig
+ {
+ Id = "config-id",
+ DepartmentId = 5,
+ IsEnabled = true,
+ AllowedPlatforms = "Discord,Telegram",
+ MaxSessionsPerUser = 4,
+ SessionTtlMinutes = 45,
+ AllowDispatchViaChatbot = true,
+ RequireConfirmationForStatusChange = true,
+ LlmApiEndpoint = "https://dept.example/v1/chat/completions",
+ LlmApiKey = "ENCRYPTED",
+ LlmModelName = "dept-model",
+ MessagesPerUserPerMinute = 12,
+ MessagesPerDepartmentPerMinute = 120,
+ RequireLinkingConfirmation = false,
+ ProactiveNotificationsEnabled = true,
+ CreatedAt = createdAt,
+ UpdatedAt = updatedAt
+ };
+
+ var serialized = ObjectSerialization.Serialize(config);
+ var deserialized = ObjectSerialization.Deserialize(serialized);
+
+ deserialized.Should().BeEquivalentTo(config, options => options
+ .Excluding(value => value.IdValue)
+ .Excluding(value => value.TableName)
+ .Excluding(value => value.IdName)
+ .Excluding(value => value.IdType)
+ .Excluding(value => value.IgnoredProperties));
+ }
+
[Test]
public async Task GetLlmOverride_WhenEndpointAndKeyConfigured_ReturnsDecryptedOverride()
{
diff --git a/Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs b/Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs
new file mode 100644
index 000000000..e84ca4216
--- /dev/null
+++ b/Tests/Resgrid.Tests/Localization/ModerationLocalizationTests.cs
@@ -0,0 +1,124 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Resources;
+using System.Text.RegularExpressions;
+using FluentAssertions;
+using NUnit.Framework;
+using Resgrid.Localization;
+using Resgrid.Localization.Areas.User.Moderation;
+
+namespace Resgrid.Tests.Localization
+{
+ [TestFixture]
+ public class ModerationLocalizationTests
+ {
+ private static readonly string[] SupportedCultures = SupportedLocales.GetSupportedCultures();
+
+ private static readonly string[] RepresentativeTranslatedKeys =
+ {
+ "ReportsDescription",
+ "CompletionSubject",
+ "ModerationReportStatus",
+ "MessageRetention",
+ "ReportForModeration",
+ "ContentCouldNotBeRemoved"
+ };
+
+ private static readonly string[] CallReportKeys =
+ {
+ "FlagCallNoteHeader",
+ "FlagCallImageHeader",
+ "CallNoteTextLabel",
+ "CallNoteAddedOnLabel",
+ "CallNoteAddedByLabel",
+ "CallImageTimestampLabel",
+ "CallImageAddedByLabel",
+ "FileName",
+ "FlaggedReasonLabel",
+ "FlaggedReasonPlaceholder"
+ };
+
+ [Test]
+ public void EverySupportedCultureContainsEveryModerationKey()
+ {
+ var english = ModerationResources.GetAll("en");
+ english.Should().NotBeEmpty();
+
+ foreach (var culture in SupportedCultures)
+ {
+ var resources = ModerationResources.GetAll(culture);
+ resources.Keys.Should().BeEquivalentTo(english.Keys,
+ $"the {culture} moderation resources must not fall back to English because of missing keys");
+ resources.Values.Should().OnlyContain(value => !string.IsNullOrWhiteSpace(value));
+ AssertFormatPlaceholdersMatch(english, resources, culture);
+ }
+ }
+
+ [Test]
+ public void EveryNonEnglishCultureContainsActualTranslations()
+ {
+ var english = ModerationResources.GetAll("en");
+
+ foreach (var culture in SupportedCultures.Where(x => x != "en"))
+ {
+ var resources = ModerationResources.GetAll(culture);
+ foreach (var key in RepresentativeTranslatedKeys)
+ resources[key].Should().NotBe(english[key], $"{key} must be translated for {culture}");
+ }
+ }
+
+ [Test]
+ public void ExplicitCultureFormatsReporterNotificationWithoutUsingThreadCulture()
+ {
+ ModerationResources.Get("CompletionSubject", "es")
+ .Should().Be("Solicitud de moderación completada");
+ ModerationResources.Get("CompletionBody", "es", "mensaje", "42",
+ "El contenido reportado fue eliminado.", string.Empty)
+ .Should().Contain("mensaje 42").And.Contain("se ha completado");
+ ModerationResources.Get("CompletionSubject", "unsupported")
+ .Should().Be(ModerationResources.Get("CompletionSubject", "en"));
+ ModerationResources.Get("CompletionSubject", null)
+ .Should().Be(ModerationResources.Get("CompletionSubject", "en"));
+ }
+
+ [Test]
+ public void ExistingModerationEntryPointsAreBroadAndTranslated()
+ {
+ var assembly = typeof(Common).Assembly;
+ var commonResources = new ResourceManager(typeof(Common).FullName!, assembly);
+ var callResources = new ResourceManager(
+ typeof(Resgrid.Localization.Areas.User.Dispatch.Call).FullName!, assembly);
+ var englishCallValues = CallReportKeys.ToDictionary(key => key,
+ key => callResources.GetString(key, CultureInfo.GetCultureInfo("en")));
+
+ commonResources.GetString("ChatModerationModule", CultureInfo.GetCultureInfo("en"))
+ .Should().Be("Moderation");
+
+ foreach (var culture in SupportedCultures.Where(x => x != "en"))
+ {
+ var cultureInfo = CultureInfo.GetCultureInfo(culture);
+ commonResources.GetString("ChatModerationModule", cultureInfo)
+ .Should().NotBeNullOrWhiteSpace().And.NotBe("Chat Moderation");
+
+ foreach (var key in CallReportKeys)
+ {
+ var value = callResources.GetString(key, cultureInfo);
+ value.Should().NotBeNullOrWhiteSpace().And.NotBe(englishCallValues[key],
+ $"{key} must be translated for {culture}");
+ }
+ }
+ }
+
+ private static void AssertFormatPlaceholdersMatch(IReadOnlyDictionary english,
+ IReadOnlyDictionary translated, string culture)
+ {
+ foreach (var pair in english)
+ {
+ var expected = Regex.Matches(pair.Value, @"\{\d+\}").Select(x => x.Value).OrderBy(x => x);
+ var actual = Regex.Matches(translated[pair.Key], @"\{\d+\}").Select(x => x.Value).OrderBy(x => x);
+ actual.Should().Equal(expected, $"format placeholders for {pair.Key} must match in {culture}");
+ }
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Models/FormAutomationTests.cs b/Tests/Resgrid.Tests/Models/FormAutomationTests.cs
new file mode 100644
index 000000000..e5db61705
--- /dev/null
+++ b/Tests/Resgrid.Tests/Models/FormAutomationTests.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Linq;
+using FluentAssertions;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Repositories.DataRepository.Extensions;
+using Resgrid.Repositories.DataRepository.Servers.SqlServer;
+
+namespace Resgrid.Tests.Models
+{
+ [TestFixture]
+ public class FormAutomationTests
+ {
+ [Test]
+ public void PersistenceMetadata_UsesStringIdAndExcludesNonColumnProperties()
+ {
+ var automation = new FormAutomation
+ {
+ FormAutomationId = Guid.NewGuid().ToString(),
+ FormId = Guid.NewGuid().ToString(),
+ Form = new Form()
+ };
+
+ var columns = automation
+ .GetColumns(new SqlServerConfiguration(), ignoreProperties: automation.IgnoredProperties)
+ .ToList();
+
+ automation.IdType.Should().Be(1);
+ columns.Should().Contain(x => x.Equals("[FormAutomationId]", StringComparison.OrdinalIgnoreCase));
+ columns.Should().Contain(x => x.Equals("[FormId]", StringComparison.OrdinalIgnoreCase));
+ columns.Should().NotContain(x => x.Contains("IdType", StringComparison.OrdinalIgnoreCase));
+ columns.Should().NotContain(x => x.Equals("[Form]", StringComparison.OrdinalIgnoreCase));
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs b/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs
index 6b3982c69..066053a42 100644
--- a/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs
+++ b/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Moq;
@@ -19,6 +20,33 @@ namespace Resgrid.Tests.Services
[TestFixture]
public class MessageServiceInboxTests
{
+ [Test]
+ public async Task SaveMessageTruncatesValuesToDatabaseColumnLengths()
+ {
+ var repository = new Mock();
+ repository
+ .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((Message savedMessage, CancellationToken _, bool _) => savedMessage);
+ var service = new MessageService(repository.Object, null, null, null, null, null);
+ var message = new Message
+ {
+ Subject = new string('s', Message.MaximumSubjectLength + 1),
+ Body = new string('b', Message.MaximumBodyLength + 1),
+ SentOn = DateTime.UtcNow
+ };
+
+ var savedMessage = await service.SaveMessageAsync(message);
+
+ savedMessage.Subject.Should().HaveLength(Message.MaximumSubjectLength);
+ savedMessage.Body.Should().HaveLength(Message.MaximumBodyLength);
+ repository.Verify(
+ x => x.SaveOrUpdateAsync(
+ It.Is(value => value.Subject.Length == Message.MaximumSubjectLength && value.Body.Length == Message.MaximumBodyLength),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
[Test]
public async Task InboxAndUnreadCountExcludeExpiredMessages()
{
diff --git a/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs b/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs
new file mode 100644
index 000000000..be4c5039a
--- /dev/null
+++ b/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+using Resgrid.Services;
+
+namespace Resgrid.Tests.Services
+{
+ [TestFixture]
+ public class ModerationServiceTests
+ {
+ private Mock _requests;
+ private Mock _reports;
+ private Mock _actions;
+ private Mock _chatMessages;
+ private Mock _chatAttachments;
+ private Mock _chatChannels;
+ private Mock _chatPermissions;
+ private Mock _chatMessageService;
+ private Mock _messages;
+ private Mock _callNotes;
+ private Mock _callAttachments;
+ private Mock _calls;
+ private Mock _groups;
+ private Mock _authorization;
+ private Mock _audit;
+ private Mock _userProfiles;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _requests = new Mock();
+ _reports = new Mock();
+ _actions = new Mock();
+ _chatMessages = new Mock();
+ _chatAttachments = new Mock();
+ _chatChannels = new Mock();
+ _chatPermissions = new Mock();
+ _chatMessageService = new Mock();
+ _messages = new Mock();
+ _callNotes = new Mock();
+ _callAttachments = new Mock();
+ _calls = new Mock();
+ _groups = new Mock();
+ _authorization = new Mock();
+ _audit = new Mock();
+ _userProfiles = new Mock();
+
+ _actions.Setup(x => x.GetByRequestAsync(It.IsAny()))
+ .ReturnsAsync(new List());
+ _actions.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ModerationAction value, CancellationToken _, bool _) => value);
+ _audit.Setup(x => x.SaveAuditLogAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((AuditLog value, CancellationToken _) => value);
+ _userProfiles.Setup(x => x.GetProfileByUserIdAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((string userId, bool _) => new UserProfile { UserId = userId, Language = "en" });
+ }
+
+ [Test]
+ public async Task MultipleReportersShareOneRequestAndOnlyFirstActionStoresEvidence()
+ {
+ ModerationRequest storedRequest = null;
+ var storedReports = new List();
+ var storedActions = new List();
+ var sourceMessage = new Message
+ {
+ MessageId = 42,
+ SendingUserId = "author",
+ Subject = "Original subject",
+ Body = "Original body",
+ SentOn = DateTime.UtcNow
+ };
+
+ _authorization.Setup(x => x.CanUserViewMessageAsync(It.IsAny(), 42)).ReturnsAsync(true);
+ _messages.Setup(x => x.GetMessageByIdAsync(42)).ReturnsAsync(sourceMessage);
+ _requests.Setup(x => x.GetByItemAsync(7, (int)ModerationItemType.Message, "42"))
+ .ReturnsAsync(() => storedRequest);
+ _requests.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ModerationRequest value, CancellationToken _, bool _) => storedRequest = value);
+ _requests.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ModerationRequest value, CancellationToken _, bool _) => value);
+ _reports.Setup(x => x.GetByRequestAndReporterAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((string requestId, string userId) => storedReports.FirstOrDefault(x =>
+ x.ModerationRequestId == requestId && x.ReportedByUserId == userId));
+ _reports.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ModerationReport value, CancellationToken _, bool _) =>
+ {
+ storedReports.Add(value);
+ return value;
+ });
+ _groups.Setup(x => x.GetGroupMemberForUserAsync(It.IsAny(), 7))
+ .ReturnsAsync((string userId, int _) => new DepartmentGroupMember
+ {
+ DepartmentId = 7,
+ DepartmentGroupId = userId == "reporter-a" ? 10 : 20,
+ UserId = userId
+ });
+ _actions.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((ModerationAction value, CancellationToken _, bool _) =>
+ {
+ storedActions.Add(value);
+ return value;
+ });
+
+ var service = CreateService();
+ var first = await service.FlagAsync(7, "reporter-a", ModerationItemType.Message, "42",
+ ModerationReason.Harassment, "First report");
+ var second = await service.FlagAsync(7, "reporter-b", ModerationItemType.Message, "42",
+ ModerationReason.Spam, "Second report");
+
+ first.ModerationRequestId.Should().Be(second.ModerationRequestId);
+ storedReports.Should().HaveCount(2);
+ storedRequest.OriginalSubject.Should().Be("Original subject");
+ storedRequest.OriginalText.Should().Be("Original body");
+ storedActions.Should().HaveCount(2);
+ storedActions.Count(x => x.EvidenceText == "Original body").Should().Be(1);
+ _requests.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task RemovingMessageRetainsEvidenceAndNotifiesReportersExceptContentAuthor()
+ {
+ var request = CreateRequest();
+ var reports = new List
+ {
+ CreateReport(request, "reporter", 10),
+ CreateReport(request, "author", 10)
+ };
+ var liveMessage = new Message { MessageId = 42, Subject = "Live subject", Body = "Live body" };
+ var savedMessages = new List();
+
+ _requests.Setup(x => x.GetByIdAsync(request.ModerationRequestId)).ReturnsAsync(request);
+ _requests.Setup(x => x.UpdateAsync(It.IsAny