diff --git a/Core/Resgrid.Framework/StringHelpers.cs b/Core/Resgrid.Framework/StringHelpers.cs
index ec477286c..c7102e126 100644
--- a/Core/Resgrid.Framework/StringHelpers.cs
+++ b/Core/Resgrid.Framework/StringHelpers.cs
@@ -79,12 +79,167 @@ public static string SanitizeHtmlInString(string source)
if (string.IsNullOrWhiteSpace(source))
return source;
+ var cleanHtml = SanitizeHtml(source);
+ if (cleanHtml == null)
+ {
+ // The text itself is not logged: it is call, message or record content.
+ Logging.LogError($"Rich text of {source.Length} characters held only comments, styles or document tags; stored the placeholder.");
+ return "Invalid HTML in Source String";
+ }
+
+ if (!String.IsNullOrWhiteSpace(cleanHtml))
+ return cleanHtml.Trim();
+
+ //Logging.LogError("Following string could not be sanitized: " + source);
+ return "Invalid HTML in Source String";
+ }
+
+ ///
+ /// Stored rich text (call nature and notes, messages, department notes, trainings, protocols, documents, calendar
+ /// items, logs, contact notes...) made safe to write into a page with Html.Raw: the same allow-list as
+ /// , so scripts, event handlers, styles, frames and script URLs never reach the
+ /// browser, whichever path stored the value (web form, v4 API, inbound e-mail, import, workers) and however old it
+ /// is. Unlike the save-time sanitizer it never substitutes a placeholder: nothing left to show is an empty string.
+ /// Text without a '<' cannot hold markup and is returned unchanged; plain text that only mentions angle brackets
+ /// ("pt <unconscious> bp 80", which the sanitizer would delete with its text) is shown literally.
+ ///
+ public static string SanitizeHtmlForDisplay(string source)
+ {
+ if (string.IsNullOrWhiteSpace(source))
+ return string.Empty;
+ if (source.IndexOf('<') < 0)
+ return source;
+
+ try
+ {
+ if (!LooksLikeHtml(source))
+ return EncodeAngleBrackets(source);
+ return SanitizeHtml(source)?.Trim() ?? string.Empty;
+ }
+ catch (Exception ex)
+ {
+ // Fail closed: the text is shown encoded rather than as markup.
+ Logging.LogException(ex, "Rich text could not be sanitized for display; encoding it instead.");
+ return System.Net.WebUtility.HtmlEncode(source);
+ }
+ }
+
+ ///
+ /// Free text about to be stored where pages, apps or exports may treat it as HTML: markup goes through the same
+ /// allow-list as , so a script, handler, frame or script URL is never stored, while
+ /// plain text (including text that mentions "<" or "<word>") and protected-data envelopes are stored unchanged.
+ /// Unlike it never substitutes a placeholder for the text.
+ ///
+ public static string SanitizeHtmlForStorage(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.IndexOf('<') < 0)
+ return value;
+
+ try
+ {
+ if (!LooksLikeHtml(value))
+ return value;
+ return SanitizeHtml(value)?.Trim() ?? string.Empty;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, "Rich text could not be sanitized for storage; storing it with its angle brackets encoded.");
+ return EncodeAngleBrackets(value);
+ }
+ }
+
+ ///
+ /// True when the text holds markup: a comment, a known HTML element, a namespaced (Word "o:p") or custom ("x-el")
+ /// element, or any tag carrying attributes. "pressure < 90", "x<y" and "<unconscious>" are plain text.
+ ///
+ public static bool LooksLikeHtml(string text)
+ {
+ if (string.IsNullOrEmpty(text) || text.IndexOf('<') < 0)
+ return false;
+
+ foreach (Match match in HtmlTagToken.Matches(text))
+ {
+ if (match.Value.StartsWith("= 0 || name.IndexOf('-') >= 0)
+ return true;
+ if (match.Groups[3].Success && match.Groups[3].Value.IndexOf('=') >= 0)
+ return true;
+ }
+ return false;
+ }
+
+ private static string EncodeAngleBrackets(string text) => text.Replace("<", "<").Replace(">", ">");
+
+ private static readonly Regex HtmlTagToken = new Regex(@"|$)|<(script|style|head|title|xml)\b[^>]*>[\s\S]*?(\2\s*>|$)",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
+ // A tag needs a name and a closing '>' with no '<' in between, so "pressure < 90" and "x]*)?/?>|]*>",
+ RegexOptions.Compiled, TimeSpan.FromSeconds(1));
+ private static readonly Regex PlainWhitespace = new Regex(@"\s+", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
+ private static readonly HashSet InlineHtmlTags = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "a", "abbr", "b", "big", "cite", "code", "em", "font", "i", "ins", "del", "kbd", "mark", "q", "s", "samp", "small",
+ "span", "strike", "strong", "sub", "sup", "tt", "u", "var"
+ };
+
+ ///
+ /// The readable text of stored rich text, for previews, excerpts and search: comments, scripts, styles and document
+ /// heads are dropped with their content, other tags are removed (block tags leave a space so "<div>a</div><div>b</div>"
+ /// reads "a b"), entities are decoded and whitespace is collapsed. The result is text, not HTML: encode it when it is
+ /// written into a page. Null or blank in, empty string out.
+ ///
+ public static string HtmlToPlainText(string html)
+ {
+ if (string.IsNullOrWhiteSpace(html))
+ return string.Empty;
+
+ var text = html;
+ try
+ {
+ if (text.IndexOf('<') >= 0)
+ {
+ text = HtmlNonTextBlocks.Replace(text, " ");
+ text = HtmlTagPattern.Replace(text, m => m.Groups[2].Success && InlineHtmlTags.Contains(m.Groups[2].Value) ? string.Empty : " ");
+ }
+ if (text.IndexOf('&') >= 0)
+ text = System.Net.WebUtility.HtmlDecode(text);
+ return PlainWhitespace.Replace(text.Replace(' ', ' '), " ").Trim();
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ return StripHtmlTagsCharArray(html).Trim();
+ }
+ }
+
+ /// The allow-list sanitizer behind and ;
+ /// null when the clean-up left nothing.
+ private static string SanitizeHtml(string source)
+ {
StringCollection sc = new StringCollection();
- string temp = source;
- // get rid of unnecessary tag spans (comments and title)
+ // get rid of unnecessary tag spans (comments, title, and style blocks whose CSS would otherwise be left behind as text)
sc.Add(@"");
sc.Add(@"[\s\S]*?");
+ sc.Add(@"");
// Get rid of unnecessary tags but keep the text they wrap (document wrappers, Word's and smart tags)
sc.Add(@"<(meta|link|/?o:|/?style|/?st\d|/?head|/?html|body|/?body)[^>]*?>");
@@ -100,10 +255,7 @@ public static string SanitizeHtmlInString(string source)
}
if (String.IsNullOrWhiteSpace(source))
- {
- Logging.LogError("Following string could not be stripped of Word HTML: " + temp);
- return "Invalid HTML in Source String";
- }
+ return null;
var sanitizer = new HtmlSanitizer();
sanitizer.Tag("h1").RemoveEmpty();
@@ -149,15 +301,22 @@ public static string SanitizeHtmlInString(string source)
.SanitizeAttributes("href", new SpaceEncodingUrlAttributeSanitizer())
.RemoveEmpty();
- string cleanHtml = sanitizer.Sanitize(source);
-
- if (!String.IsNullOrWhiteSpace(cleanHtml))
- return cleanHtml.Trim();
-
- //Logging.LogError("Following string could not be sanitized: " + source);
- return "Invalid HTML in Source String";
+ // The sanitizer writes every non-ASCII character and the apostrophe as an entity ("l'école" becomes "l'école").
+ // Those characters can never form markup (attributes are written in double quotes), and exports, voice prompts and
+ // texts that strip tags without decoding entities printed or read the entity, so they are put back as characters.
+ // & < > " and stay encoded.
+ return NonAsciiEntity.Replace(sanitizer.Sanitize(source) ?? string.Empty, m =>
+ {
+ var decoded = System.Net.WebUtility.HtmlDecode(m.Value);
+ if (decoded.Length == 0 || decoded == m.Value)
+ return m.Value;
+ return decoded[0] > ' ' || decoded == "'" ? decoded : m.Value;
+ });
}
+ private static readonly Regex NonAsciiEntity = new Regex(@"&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});",
+ RegexOptions.Compiled, TimeSpan.FromSeconds(1));
+
///
/// Tags unwraps (tag removed, text kept) instead of deleting with their content.
///
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.ar.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.ar.resx
index a563ae042..d3cbfd4b5 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.ar.resx
@@ -54,6 +54,7 @@
انتهت صلاحية العرض
بند العرض
العرض رقم
+ تصفية
تأخذ البنود سعرها من هذا الجدول عند الإضافة.
تم إرسال العرض
مقبول
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.de.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.de.resx
index 4d5af539a..754abdbbe 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.de.resx
@@ -54,6 +54,7 @@
Angebot abgelaufen
Angebotsposition
Angebot Nr.
+ Filtern
Positionen übernehmen ihren Satz beim Hinzufügen aus diesem Tarifplan.
Angebot gesendet
Angenommen
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.el.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.el.resx
index a66af4936..5e3949ff2 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.el.resx
@@ -54,6 +54,7 @@
Έληξε προσφορά
Γραμμή προσφοράς
Προσφορά αρ.
+ Φίλτρο
Οι γραμμές παίρνουν την τιμή τους από αυτόν τον τιμοκατάλογο κατά την προσθήκη.
Απεστάλη προσφορά
Αποδεκτή
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.en.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.en.resx
index 8c1c46bc6..0c5277185 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.en.resx
@@ -54,6 +54,7 @@
Bid expired
Bid line
Bid #
+ Filter
Lines snapshot their rate from this schedule when added.
Bid sent
Accepted
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.es.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.es.resx
index 8deb5af00..2d03b6b96 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.es.resx
@@ -54,6 +54,7 @@
Oferta vencida
Línea de oferta
Oferta n.º
+ Filtrar
Las líneas toman su tarifa de esta tabla al añadirse.
Oferta enviada
Aceptada
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.fr.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.fr.resx
index a13a0a3bc..fd2093abe 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.fr.resx
@@ -54,6 +54,7 @@
Offre expirée
Ligne d'offre
Offre n°
+ Filtrer
Les lignes figent leur taux depuis cette grille à l'ajout.
Offre envoyée
Acceptée
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.it.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.it.resx
index 65e770047..3b5b48338 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.it.resx
@@ -54,6 +54,7 @@
Offerta scaduta
Riga offerta
Offerta n.
+ Filtra
Le righe copiano la tariffa da questo listino quando aggiunte.
Offerta inviata
Accettata
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.pl.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.pl.resx
index 350d128af..c934added 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.pl.resx
@@ -54,6 +54,7 @@
Oferta wygasła
Pozycja oferty
Oferta nr
+ Filtruj
Pozycje pobierają stawkę z tego cennika przy dodaniu.
Oferta wysłana
Przyjęta
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.resx
index 8c1c46bc6..0c5277185 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.resx
@@ -54,6 +54,7 @@
Bid expired
Bid line
Bid #
+ Filter
Lines snapshot their rate from this schedule when added.
Bid sent
Accepted
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.sv.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.sv.resx
index 1be677e53..fef37e06f 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.sv.resx
@@ -54,6 +54,7 @@
Anbud utgånget
Anbudsrad
Anbud nr
+ Filtrera
Rader låser sin taxa från denna prislista när de läggs till.
Anbud skickat
Antaget
diff --git a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.uk.resx b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.uk.resx
index 1e4d58166..9a2de2938 100644
--- a/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/ContractorBilling/ContractorBilling.uk.resx
@@ -54,6 +54,7 @@
Пропозицію прострочено
Рядок пропозиції
Пропозиція №
+ Фільтр
Рядки фіксують ставку з цих тарифів під час додавання.
Пропозицію надіслано
Прийнято
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
index c41a70da3..a4747ed89 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
@@ -513,18 +513,9 @@
إرسال
-
- أقرب الأفراد
-
-
- الشخص
-
الجاهزية
-
- المجموعة / الأدوار
-
الأقرب رقم {0}
@@ -543,7 +534,19 @@
حدود المحطات
-
- يتم عرض أقرب {0} من أصل {1}. تتضمن قوائم الإرسال أدناه الجميع، الأقرب أولًا.
+
+ وحدات غير مجمعة
+
+
+ تعذر تحميل الوحدات. أعد تحميل الصفحة للمحاولة مرة أخرى.
+
+
+ عرض هذا الموقع وموقع البلاغ على الخريطة
+
+
+ {0}: الموقع
+
+
+ موقع البلاغ
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
index 24947861e..355bf339e 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
@@ -1071,18 +1071,9 @@
Alarmieren
-
- Nächstes Personal
-
-
- Person
-
Verfügbarkeit
-
- Gruppe / Rollen
-
Nächste #{0}
@@ -1101,7 +1092,19 @@
Wachbereiche
-
- Die {0} nächsten von {1} werden angezeigt. Die Alarmierungslisten unten enthalten alle, die nächsten zuerst.
+
+ Nicht gruppierte Einheiten
+
+
+ Die Einheiten konnten nicht geladen werden. Laden Sie die Seite neu, um es erneut zu versuchen.
+
+
+ Diese Position und den Einsatzort auf einer Karte anzeigen
+
+
+ {0}: Position
+
+
+ Einsatzort
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
index 082d2ad7f..a0a8c5042 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
@@ -983,18 +983,9 @@
Αποστολή
-
- Πλησιέστερο προσωπικό
-
-
- Άτομο
-
Στελέχωση
-
- Ομάδα / ρόλοι
-
Πλησιέστερος #{0}
@@ -1013,7 +1004,19 @@
Όρια σταθμών
-
- Εμφανίζονται οι {0} πλησιέστεροι από {1}. Οι λίστες αποστολής παρακάτω περιλαμβάνουν όλους, με τους πλησιέστερους πρώτους.
+
+ Μη ομαδοποιημένες μονάδες
+
+
+ Δεν ήταν δυνατή η φόρτωση των μονάδων. Ανανεώστε τη σελίδα για να δοκιμάσετε ξανά.
+
+
+ Εμφάνιση αυτής της θέσης και του συμβάντος σε χάρτη
+
+
+ {0}: θέση
+
+
+ Τοποθεσία συμβάντος
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
index 864bb8b99..b8dc29fbd 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
@@ -983,18 +983,9 @@
Dispatch
-
- Closest personnel
-
-
- Person
-
Staffing
-
- Group / roles
-
Nearest #{0}
@@ -1013,7 +1004,19 @@
Station boundaries
-
- Showing the {0} closest of {1}. The dispatch lists below include everyone, closest first.
+
+ Ungrouped Units
+
+
+ The units could not be loaded. Reload the page to try again.
+
+
+ Show this position and the call on a map
+
+
+ {0}: position
+
+
+ Call location
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
index 2bf998d45..b77ccabf5 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
@@ -1129,18 +1129,9 @@
Despachar
-
- Personal más cercano
-
-
- Persona
-
Disponibilidad
-
- Grupo / funciones
-
Más cercano #{0}
@@ -1159,7 +1150,19 @@
Límites de estaciones
-
- Se muestran los {0} más cercanos de {1}. Las listas de despacho de abajo incluyen a todos, primero los más cercanos.
+
+ Unidades sin agrupar
+
+
+ No se pudieron cargar las unidades. Recargue la página para intentarlo de nuevo.
+
+
+ Mostrar esta posición y la llamada en un mapa
+
+
+ {0}: posición
+
+
+ Ubicación de la llamada
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
index 6ea1c4806..c5d229969 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
@@ -1071,18 +1071,9 @@
Engager
-
- Personnel le plus proche
-
-
- Personne
-
Disponibilité
-
- Groupe / rôles
-
Plus proche n°{0}
@@ -1101,7 +1092,19 @@
Secteurs des casernes
-
- Affichage des {0} plus proches sur {1}. Les listes d'engagement ci-dessous incluent tout le monde, les plus proches en premier.
+
+ Unités non groupées
+
+
+ Impossible de charger les unités. Rechargez la page pour réessayer.
+
+
+ Afficher cette position et l'appel sur une carte
+
+
+ {0} : position
+
+
+ Lieu de l'appel
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
index 9a106f422..8cfebb311 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
@@ -1071,18 +1071,9 @@
Invia
-
- Personale più vicino
-
-
- Persona
-
Disponibilità
-
- Gruppo / ruoli
-
Più vicino n. {0}
@@ -1101,7 +1092,19 @@
Confini delle sedi
-
- Vengono mostrati i {0} più vicini su {1}. Gli elenchi di invio qui sotto includono tutti, prima i più vicini.
+
+ Unità non raggruppate
+
+
+ Impossibile caricare le unità. Ricarica la pagina per riprovare.
+
+
+ Mostra questa posizione e la chiamata su una mappa
+
+
+ {0}: posizione
+
+
+ Luogo della chiamata
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
index 65d8bc0fb..ec7acb53d 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
@@ -1071,18 +1071,9 @@
Zadysponuj
-
- Najbliższy personel
-
-
- Osoba
-
Dostępność
-
- Grupa / role
-
Najbliższy nr {0}
@@ -1101,7 +1092,19 @@
Granice rejonów
-
- Pokazano {0} najbliższych z {1}. Listy dysponowania poniżej zawierają wszystkich, najpierw najbliższych.
+
+ Jednostki bez grupy
+
+
+ Nie udało się wczytać jednostek. Odśwież stronę, aby spróbować ponownie.
+
+
+ Pokaż tę pozycję i zgłoszenie na mapie
+
+
+ {0}: pozycja
+
+
+ Miejsce zgłoszenia
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
index 63947ee06..8b1b8c1d4 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
@@ -1071,18 +1071,9 @@
Larma
-
- Närmaste personal
-
-
- Person
-
Bemanning
-
- Grupp / roller
-
Närmast #{0}
@@ -1101,7 +1092,19 @@
Stationsområden
-
- Visar de {0} närmaste av {1}. Larmlistorna nedan innehåller alla, närmast först.
+
+ Ogrupperade enheter
+
+
+ Enheterna kunde inte läsas in. Ladda om sidan för att försöka igen.
+
+
+ Visa den här positionen och händelsen på en karta
+
+
+ {0}: position
+
+
+ Händelsens plats
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
index f2058f7d1..d98a093ba 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
@@ -1071,18 +1071,9 @@
Направити
-
- Найближчий персонал
-
-
- Особа
-
Укомплектування
-
- Група / ролі
-
Найближчий №{0}
@@ -1101,7 +1092,19 @@
Межі станцій
-
- Показано {0} найближчих із {1}. Списки відправлення нижче містять усіх, найближчі першими.
+
+ Незгруповані підрозділи
+
+
+ Не вдалося завантажити підрозділи. Перезавантажте сторінку, щоб спробувати ще раз.
+
+
+ Показати цю позицію та виклик на карті
+
+
+ {0}: позиція
+
+
+ Місце виклику
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx
index 274446494..9358a3c57 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx
@@ -465,6 +465,14 @@
سجل صرف المخزون
تقييم المخزون
سجل المواد الخاضعة للرقابة
+ المخزون الحالي والأصول ذات الأرقام التسلسلية في كل موقع يمكنك عرضه، مع الدفعة والرقم التسلسلي والحالة وتاريخ انتهاء الصلاحية.
+ المخزون المستهلك أو المشطوب خلال نطاق زمني، مع البلاغ أو السجل الذي استخدمه.
+ المخزون والأصول التي تنتهي صلاحيتها خلال نطاق زمني، بما في ذلك العناصر المنتهية بالفعل والأيام المتبقية حتى انتهاء الصلاحية.
+ العناصر النشطة التي بلغت نقطة إعادة الطلب أو الحد الأدنى أو انخفضت عنهما، مع مقدار النقص وكمية إعادة الطلب.
+ عمليات نقل المخزون بين المواقع خلال نطاق زمني، مع منفّذ كل عملية نقل وأي عمليات عكس.
+ المعدات المصروفة للأفراد أو الوحدات خلال نطاق زمني، مع الكميات المُرجعة والمتبقية.
+ القيمة الحالية للمخزون حسب العنصر والموقع استنادًا إلى تكاليف الوحدة المسجلة، مع الإجمالي لكل عملة.
+ كل حركة للمواد الخاضعة للرقابة خلال نطاق زمني، مع الكميات قبل الحركة وبعدها والشاهد والإقرار.
الأيام المتبقية حتى انتهاء الصلاحية
الكمية الناقصة عن نقطة إعادة الطلب
كمية إعادة الطلب المقترحة
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx
index 14a5a726c..7631228f1 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx
@@ -589,6 +589,14 @@
Ausgabeverlauf des Inventars
Bestandsbewertung
Protokoll kontrollierter Substanzen
+ Aktueller Bestand und serialisierte Anlagegüter an jedem für Sie sichtbaren Standort, mit Charge, Seriennummer, Status und Ablaufdatum.
+ In einem Zeitraum verbrauchter oder abgeschriebener Bestand, mit dem Einsatz oder Bericht, der ihn verwendet hat.
+ Bestand und Anlagegüter, die in einem Zeitraum ablaufen, einschließlich bereits abgelaufener Artikel und der Tage bis zum Ablauf.
+ Aktive Artikel, die ihren Meldebestand oder Mindestbestand erreicht oder unterschritten haben, mit Fehlmenge und Bestellmenge.
+ Bestandsumbuchungen zwischen Standorten in einem Zeitraum, mit der ausführenden Person jeder Umbuchung und etwaigen Stornierungen.
+ In einem Zeitraum an Personal oder Einheiten ausgegebene Ausrüstung, mit zurückgegebenen und offenen Mengen.
+ Aktueller Bestandswert nach Artikel und Standort auf Basis erfasster Stückkosten, summiert je Währung.
+ Jede Bewegung kontrollierter Substanzen in einem Zeitraum, mit Mengen vorher und nachher, Zeuge und Bestätigung.
Tage bis zum Ablauf
Fehlmenge zum Meldebestand
Vorgeschlagene Nachbestellmenge
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx
index 09b2451e6..593fbc665 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx
@@ -648,6 +648,14 @@
Ιστορικό χορηγήσεων αποθέματος
Αποτίμηση αποθέματος
Μητρώο ελεγχόμενων ουσιών
+ Τρέχον απόθεμα και σειριοποιημένα πάγια σε κάθε τοποθεσία που μπορείτε να δείτε, με παρτίδα, σειριακό αριθμό, κατάσταση και ημερομηνία λήξης.
+ Απόθεμα που καταναλώθηκε ή διαγράφηκε σε ένα χρονικό διάστημα, με το συμβάν ή την εγγραφή που το χρησιμοποίησε.
+ Απόθεμα και πάγια που λήγουν σε ένα χρονικό διάστημα, συμπεριλαμβανομένων ήδη ληγμένων ειδών και των ημερών έως τη λήξη.
+ Ενεργά είδη στο σημείο αναπαραγγελίας ή στο ελάχιστο επίπεδο ή κάτω από αυτά, με το έλλειμμα και την ποσότητα αναπαραγγελίας.
+ Μεταφορές αποθέματος μεταξύ τοποθεσιών σε ένα χρονικό διάστημα, με το άτομο που εκτέλεσε κάθε μεταφορά και τυχόν αντιλογισμούς.
+ Εξοπλισμός που χορηγήθηκε σε προσωπικό ή μονάδες σε ένα χρονικό διάστημα, με τις επιστραμμένες και εκκρεμείς ποσότητες.
+ Τρέχουσα αξία αποθέματος ανά είδος και τοποθεσία βάσει του καταγεγραμμένου κόστους μονάδας, με σύνολα ανά νόμισμα.
+ Κάθε κίνηση ελεγχόμενων ουσιών σε ένα χρονικό διάστημα, με τις ποσότητες πριν και μετά, τον μάρτυρα και τη βεβαίωση.
Ημέρες μέχρι τη λήξη
Ποσότητα που υπολείπεται του σημείου αναπαραγγελίας
Προτεινόμενη ποσότητα αναπαραγγελίας
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx
index 532eee040..b09196ec6 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx
@@ -648,6 +648,14 @@
Inventory issuance history
Inventory valuation
Controlled substance log
+ Current stock and serialized assets at each location you can view, with batch, serial number, status and expiration.
+ Inventory consumed or written off during a date range, with the call or record that used it.
+ Stock and assets expiring within a date range, including already expired items and days until expiry.
+ Active items at or below their reorder point or minimum level, with the shortfall and reorder quantity.
+ Stock transfers between locations during a date range, with who performed each move and any reversals.
+ Equipment issued to personnel or units during a date range, with returned and outstanding quantities.
+ Current inventory value by item and location from recorded unit costs, totaled per currency.
+ Every controlled substance movement during a date range, with before and after quantities, witness and attestation.
Days until expiration
Quantity below reorder point
Suggested reorder quantity
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx
index 9146cbbe3..c5dcd1777 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx
@@ -589,6 +589,14 @@
Historial de entregas de inventario
Valoración del inventario
Registro de sustancias controladas
+ Existencias actuales y activos serializados en cada ubicación que puede ver, con lote, número de serie, estado y caducidad.
+ Inventario consumido o dado de baja durante un intervalo de fechas, con la llamada o el registro que lo utilizó.
+ Existencias y activos que caducan en un intervalo de fechas, incluidos los artículos ya caducados y los días hasta la caducidad.
+ Artículos activos en su punto de reorden o nivel mínimo, o por debajo, con el déficit y la cantidad de reorden.
+ Transferencias de existencias entre ubicaciones durante un intervalo de fechas, con quién realizó cada movimiento y las reversiones.
+ Equipo entregado a personal o unidades durante un intervalo de fechas, con las cantidades devueltas y pendientes.
+ Valor actual del inventario por artículo y ubicación según los costes unitarios registrados, con totales por moneda.
+ Cada movimiento de sustancias controladas durante un intervalo de fechas, con las cantidades antes y después, el testigo y la declaración.
Días hasta la caducidad
Cantidad que falta para el punto de reposición
Cantidad de reposición sugerida
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx
index d2639a3a0..faa14c6af 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx
@@ -589,6 +589,14 @@
Historique des dotations
Valorisation du stock
Registre des substances contrôlées
+ Stock actuel et équipements sérialisés dans chaque emplacement que vous pouvez consulter, avec lot, numéro de série, statut et date d’expiration.
+ Inventaire consommé ou mis au rebut pendant une période, avec l’appel ou le rapport qui l’a utilisé.
+ Stock et équipements arrivant à expiration pendant une période, y compris les articles déjà expirés et les jours restants avant expiration.
+ Articles actifs à leur seuil de réapprovisionnement ou niveau minimum, ou en dessous, avec le manque et la quantité à commander.
+ Transferts de stock entre emplacements pendant une période, avec l’auteur de chaque mouvement et les éventuelles annulations.
+ Équipement remis au personnel ou aux unités pendant une période, avec les quantités retournées et restantes.
+ Valeur actuelle de l’inventaire par article et emplacement selon les coûts unitaires enregistrés, totalisée par devise.
+ Chaque mouvement de substances contrôlées pendant une période, avec les quantités avant et après, le témoin et l’attestation.
Jours avant péremption
Quantité manquante pour atteindre le seuil de réapprovisionnement
Quantité de réapprovisionnement suggérée
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx
index 7bef66d7b..afb60e13d 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx
@@ -589,6 +589,14 @@
Cronologia delle assegnazioni dell’inventario
Valutazione delle scorte
Registro delle sostanze controllate
+ Giacenze attuali e beni serializzati in ogni ubicazione che puoi visualizzare, con lotto, numero di serie, stato e scadenza.
+ Inventario consumato o stralciato in un intervallo di date, con la chiamata o il rapporto che lo ha utilizzato.
+ Giacenze e beni in scadenza in un intervallo di date, inclusi gli articoli già scaduti e i giorni alla scadenza.
+ Articoli attivi al punto di riordino o al livello minimo, o al di sotto, con la carenza e la quantità di riordino.
+ Trasferimenti di giacenze tra ubicazioni in un intervallo di date, con chi ha eseguito ogni movimento ed eventuali storni.
+ Attrezzatura consegnata a personale o unità in un intervallo di date, con le quantità restituite e in sospeso.
+ Valore attuale dell’inventario per articolo e ubicazione in base ai costi unitari registrati, con totali per valuta.
+ Ogni movimento di sostanze controllate in un intervallo di date, con le quantità prima e dopo, il testimone e l’attestazione.
Giorni alla scadenza
Quantità mancante al punto di riordino
Quantità di riordino suggerita
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx
index 6eb3c1d5b..b0269dadf 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx
@@ -589,6 +589,14 @@
Historia wydań inwentarza
Wycena zapasów
Rejestr substancji kontrolowanych
+ Bieżące zapasy i zasoby z numerami seryjnymi w każdej lokalizacji, którą możesz wyświetlić, z partią, numerem seryjnym, stanem i datą ważności.
+ Zapasy zużyte lub odpisane w wybranym okresie, wraz ze zgłoszeniem lub raportem, w którym je wykorzystano.
+ Zapasy i zasoby, których termin ważności upływa w wybranym okresie, w tym pozycje już przeterminowane i liczba dni do upływu ważności.
+ Aktywne pozycje na poziomie punktu ponownego zamówienia lub stanu minimalnego albo poniżej, z niedoborem i ilością do zamówienia.
+ Przesunięcia zapasów między lokalizacjami w wybranym okresie, z osobą wykonującą każde przesunięcie i ewentualnymi stornami.
+ Sprzęt wydany personelowi lub jednostkom w wybranym okresie, z ilościami zwróconymi i pozostającymi do zwrotu.
+ Bieżąca wartość zapasów według pozycji i lokalizacji na podstawie zarejestrowanych kosztów jednostkowych, z sumami dla każdej waluty.
+ Każdy ruch substancji kontrolowanych w wybranym okresie, z ilościami przed i po, świadkiem i oświadczeniem.
Dni do upływu ważności
Brakująca ilość do progu ponownego zamówienia
Sugerowana ilość do ponownego zamówienia
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx
index ec4ba3b52..08de30bd0 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx
@@ -589,6 +589,14 @@
Historik över lagerutlämningar
Lagervärdering
Journal över kontrollerade substanser
+ Aktuellt lager och serienumrerade tillgångar på varje plats du kan visa, med batch, serienummer, status och utgångsdatum.
+ Lager som förbrukats eller skrivits av under ett datumintervall, med det larm eller den rapport som använde det.
+ Lager och tillgångar som löper ut inom ett datumintervall, inklusive redan utgångna artiklar och dagar kvar till utgång.
+ Aktiva artiklar på eller under sin beställningspunkt eller miniminivå, med underskott och beställningskvantitet.
+ Lagerflyttar mellan platser under ett datumintervall, med vem som utförde varje flytt och eventuella återföringar.
+ Utrustning som lämnats ut till personal eller enheter under ett datumintervall, med returnerade och utestående kvantiteter.
+ Aktuellt lagervärde per artikel och plats utifrån registrerade styckkostnader, summerat per valuta.
+ Varje förflyttning av kontrollerade substanser under ett datumintervall, med kvantiteter före och efter, vittne och intygande.
Dagar till utgångsdatum
Antal under beställningspunkten
Föreslaget beställningsantal
diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx
index 6d06b7f36..0d056c692 100644
--- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx
@@ -589,6 +589,14 @@
Історія видачі запасів
Оцінка запасів
Журнал контрольованих речовин
+ Поточні запаси та активи із серійними номерами в кожному доступному вам місці, з партією, серійним номером, станом і терміном придатності.
+ Запаси, використані або списані за вибраний період, разом із викликом чи звітом, у якому їх використано.
+ Запаси та активи, термін придатності яких спливає у вибраний період, зокрема вже прострочені позиції та кількість днів до закінчення терміну.
+ Активні позиції на рівні точки повторного замовлення чи мінімального рівня або нижче, з нестачею та кількістю для замовлення.
+ Переміщення запасів між місцями за вибраний період, із зазначенням виконавця кожного переміщення та можливих сторнувань.
+ Спорядження, видане персоналу або підрозділам за вибраний період, з поверненою та неповерненою кількістю.
+ Поточна вартість запасів за позиціями та місцями на основі зареєстрованої собівартості одиниці, з підсумками за кожною валютою.
+ Кожне переміщення контрольованих речовин за вибраний період, з кількістю до та після, свідком і засвідченням.
Днів до закінчення терміну придатності
Кількість, якої бракує до точки повторного замовлення
Рекомендована кількість для повторного замовлення
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.ar.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.ar.resx
index 59e64923c..7743cc865 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.ar.resx
@@ -538,4 +538,46 @@
* = مقترح كشرط إلزامي (يمنع الأعضاء غير المؤهلين) عند وجود دور الأفراد المطابق.
+
+ خريطة
+
+
+ الخريطة
+
+
+ الطاقم
+
+
+ موقع الوحدة
+
+
+ لا تُبلغ هذه الوحدة عن موقعها، لذلك تُضبط الخريطة على طاقمها.
+
+
+ لا يوجد موقع حالي لهذه الوحدة ولا لطاقمها.
+
+
+ لم يُعيَّن أحد لأدوار هذه الوحدة.
+
+
+ لا يوجد موقع حالي
+
+
+ تم تحديد الموقع {0}
+
+
+ إبقاء الطاقم ضمن العرض
+
+
+ ملاءمة الخريطة
+
+
+ آخر تحديث: {0}
+
+
+ تعذّر تحميل خريطة الوحدة.
+
+
+ لم يتم العثور على هذه الوحدة، أو لا تملك صلاحية عرضها.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.de.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.de.resx
index 3bc6e1b89..521153130 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.de.resx
@@ -480,4 +480,46 @@
* = als zwingende Voraussetzung vorgeschlagen (sperrt nicht qualifizierte Mitglieder), wenn die passende Personalrolle existiert.
+
+ Karte für
+
+
+ Karte
+
+
+ Besatzung
+
+
+ Position der Einheit
+
+
+ Diese Einheit meldet keine Position, daher ist die Karte an ihre Besatzung angepasst.
+
+
+ Weder diese Einheit noch ihre Besatzung hat eine aktuelle Position.
+
+
+ Den Rollen dieser Einheit ist niemand zugewiesen.
+
+
+ Kein aktueller Standort
+
+
+ Geortet {0}
+
+
+ Besatzung im Blick behalten
+
+
+ Karte anpassen
+
+
+ Letzte Aktualisierung: {0}
+
+
+ Die Karte der Einheit konnte nicht geladen werden.
+
+
+ Diese Einheit wurde nicht gefunden, oder Sie dürfen sie nicht anzeigen.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.el.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.el.resx
index 3b7e21892..232dbaa48 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.el.resx
@@ -538,4 +538,46 @@
* = προτείνεται ως αυστηρή απαίτηση (αποκλείει μη πιστοποιημένα μέλη) όταν υπάρχει ο αντίστοιχος ρόλος προσωπικού.
+
+ Χάρτης για
+
+
+ Χάρτης
+
+
+ Πλήρωμα
+
+
+ Θέση μονάδας
+
+
+ Αυτή η μονάδα δεν αναφέρει θέση, οπότε ο χάρτης προσαρμόζεται στο πλήρωμά της.
+
+
+ Ούτε αυτή η μονάδα ούτε το πλήρωμά της έχουν τρέχουσα θέση.
+
+
+ Κανείς δεν έχει ανατεθεί στους ρόλους αυτής της μονάδας.
+
+
+ Καμία τρέχουσα τοποθεσία
+
+
+ Εντοπίστηκε {0}
+
+
+ Διατήρηση του πληρώματος σε προβολή
+
+
+ Προσαρμογή χάρτη
+
+
+ Τελευταία ενημέρωση: {0}
+
+
+ Δεν ήταν δυνατή η φόρτωση του χάρτη της μονάδας.
+
+
+ Αυτή η μονάδα δεν βρέθηκε ή δεν έχετε δικαίωμα να τη δείτε.
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.en.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.en.resx
index 1e9847c84..1a16598c8 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.en.resx
@@ -538,4 +538,46 @@
* = suggested as a hard requirement (blocks unqualified members) when the matching personnel role exists.
+
+ Map for
+
+
+ Map
+
+
+ Crew
+
+
+ Unit position
+
+
+ This unit is not reporting a position, so the map is fitted to its crew.
+
+
+ Neither this unit nor its crew has a current position.
+
+
+ No one is assigned to this unit's roles.
+
+
+ No current location
+
+
+ Located {0}
+
+
+ Keep crew in view
+
+
+ Fit map
+
+
+ Last update: {0}
+
+
+ Unable to load the unit map.
+
+
+ This unit could not be found, or you may not view it.
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.es.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.es.resx
index d97158a13..ab21b5f15 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.es.resx
@@ -538,4 +538,46 @@
* = sugerido como requisito obligatorio (bloquea a los miembros no cualificados) cuando existe el rol de personal correspondiente.
+
+ Mapa de
+
+
+ Mapa
+
+
+ Dotación
+
+
+ Posición de la unidad
+
+
+ Esta unidad no informa su posición, así que el mapa se ajusta a su dotación.
+
+
+ Ni esta unidad ni su dotación tienen una posición actual.
+
+
+ Nadie está asignado a los roles de esta unidad.
+
+
+ Sin ubicación actual
+
+
+ Ubicado {0}
+
+
+ Mantener la dotación a la vista
+
+
+ Ajustar mapa
+
+
+ Última actualización: {0}
+
+
+ No se pudo cargar el mapa de la unidad.
+
+
+ No se encontró esta unidad o no tiene permiso para verla.
+
\ No newline at end of file
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.fr.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.fr.resx
index af8d2a20f..c6fdd89d0 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.fr.resx
@@ -480,4 +480,46 @@
* = suggéré comme exigence stricte (bloque les membres non qualifiés) lorsque le rôle de personnel correspondant existe.
+
+ Carte de
+
+
+ Carte
+
+
+ Équipage
+
+
+ Position de l'unité
+
+
+ Cette unité ne transmet pas sa position ; la carte est donc ajustée à son équipage.
+
+
+ Ni cette unité ni son équipage n'ont de position actuelle.
+
+
+ Personne n'est affecté aux rôles de cette unité.
+
+
+ Aucune position actuelle
+
+
+ Localisé {0}
+
+
+ Garder l'équipage en vue
+
+
+ Ajuster la carte
+
+
+ Dernière mise à jour : {0}
+
+
+ Impossible de charger la carte de l'unité.
+
+
+ Cette unité est introuvable ou vous n'êtes pas autorisé à la voir.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.it.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.it.resx
index 6121659cb..bc064c1b6 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.it.resx
@@ -480,4 +480,46 @@
* = suggerito come requisito vincolante (blocca i membri non qualificati) quando esiste il ruolo del personale corrispondente.
+
+ Mappa di
+
+
+ Mappa
+
+
+ Equipaggio
+
+
+ Posizione dell'unità
+
+
+ Questa unità non trasmette la sua posizione, quindi la mappa è adattata al suo equipaggio.
+
+
+ Né questa unità né il suo equipaggio hanno una posizione attuale.
+
+
+ Nessuno è assegnato ai ruoli di questa unità.
+
+
+ Nessuna posizione attuale
+
+
+ Localizzato {0}
+
+
+ Mantieni l'equipaggio in vista
+
+
+ Adatta mappa
+
+
+ Ultimo aggiornamento: {0}
+
+
+ Impossibile caricare la mappa dell'unità.
+
+
+ Questa unità non è stata trovata oppure non hai il permesso di visualizzarla.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.pl.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.pl.resx
index 264e98c38..5f3651d77 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.pl.resx
@@ -480,4 +480,46 @@
* = sugerowane jako wymóg bezwzględny (blokuje niewykwalifikowanych członków), gdy istnieje odpowiadająca rola personelu.
+
+ Mapa dla
+
+
+ Mapa
+
+
+ Załoga
+
+
+ Pozycja jednostki
+
+
+ Ta jednostka nie zgłasza pozycji, więc mapa jest dopasowana do jej załogi.
+
+
+ Ani ta jednostka, ani jej załoga nie mają aktualnej pozycji.
+
+
+ Nikt nie jest przypisany do ról tej jednostki.
+
+
+ Brak aktualnej lokalizacji
+
+
+ Zlokalizowano {0}
+
+
+ Utrzymuj załogę w widoku
+
+
+ Dopasuj mapę
+
+
+ Ostatnia aktualizacja: {0}
+
+
+ Nie można wczytać mapy jednostki.
+
+
+ Nie znaleziono tej jednostki lub nie masz uprawnień do jej wyświetlania.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.sv.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.sv.resx
index acf19beef..e9d1c90b4 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.sv.resx
@@ -480,4 +480,46 @@
* = föreslaget som ett hårt krav (spärrar obehöriga medlemmar) när motsvarande personalroll finns.
+
+ Karta för
+
+
+ Karta
+
+
+ Besättning
+
+
+ Enhetens position
+
+
+ Den här enheten rapporterar ingen position, så kartan anpassas efter besättningen.
+
+
+ Varken enheten eller dess besättning har någon aktuell position.
+
+
+ Ingen är tilldelad enhetens roller.
+
+
+ Ingen aktuell plats
+
+
+ Positionerad {0}
+
+
+ Håll besättningen i vyn
+
+
+ Anpassa kartan
+
+
+ Senast uppdaterad: {0}
+
+
+ Det gick inte att läsa in enhetens karta.
+
+
+ Enheten hittades inte, eller så har du inte behörighet att visa den.
+
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.uk.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.uk.resx
index d3cfae2d4..6358e9ea6 100644
--- a/Core/Resgrid.Localization/Areas/User/Units/Units.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.uk.resx
@@ -480,4 +480,46 @@
* = запропоновано як обов’язкову вимогу (блокує некваліфікованих членів), якщо відповідна роль персоналу існує.
+
+ Карта для
+
+
+ Карта
+
+
+ Екіпаж
+
+
+ Позиція підрозділу
+
+
+ Цей підрозділ не передає свою позицію, тому карту підлаштовано під його екіпаж.
+
+
+ Ні цей підрозділ, ні його екіпаж не мають поточної позиції.
+
+
+ Нікого не призначено на ролі цього підрозділу.
+
+
+ Немає поточного місцезнаходження
+
+
+ Визначено {0}
+
+
+ Тримати екіпаж у полі зору
+
+
+ Вмістити на карті
+
+
+ Останнє оновлення: {0}
+
+
+ Не вдалося завантажити карту підрозділу.
+
+
+ Цей підрозділ не знайдено, або у вас немає дозволу на його перегляд.
+
diff --git a/Core/Resgrid.Model/CallNumbering.cs b/Core/Resgrid.Model/CallNumbering.cs
index 0d282a154..ce7b6c386 100644
--- a/Core/Resgrid.Model/CallNumbering.cs
+++ b/Core/Resgrid.Model/CallNumbering.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.Linq;
using System.Text;
+using System.Text.RegularExpressions;
using ProtoBuf;
namespace Resgrid.Model
@@ -222,6 +223,84 @@ private static string Render(string token, DateTime localDate, int year)
}
}
+ ///
+ /// Keys that sort call numbers in the order they were issued, which their text does not ("26-10" reads before "26-5"):
+ /// by the year, month and day a number names, then by its sequence. A number is read with the department's
+ /// , else with the legacy one; text neither wrote (a pattern the department has since replaced)
+ /// sorts ahead of them, by its characters with each run of digits compared as a number. Keys compare ordinally.
+ ///
+ public static Func SortKeys(string pattern)
+ {
+ var readers = new[] { pattern, LegacyPattern }.Distinct().Select(NumberReader).Where(r => r != null).ToList();
+ return number => SortKey(readers, number);
+ }
+
+ private static Regex NumberReader(string pattern)
+ {
+ var parts = Parse(pattern);
+ if (parts == null)
+ return null;
+
+ var reader = new StringBuilder("^");
+ foreach (var part in parts)
+ {
+ switch (part.Token)
+ {
+ case null: reader.Append(Regex.Escape(part.Literal)); break;
+ case YearToken: reader.Append("(?[0-9]{4})"); break;
+ case ShortYearToken: reader.Append("(?[0-9]{2})"); break;
+ case MonthToken: reader.Append("(?[0-9]{2})"); break;
+ case DayToken: reader.Append("(?[0-9]{2})"); break;
+ case SequenceToken: reader.Append("(?[0-9]+)"); break;
+ }
+ }
+
+ return new Regex(reader.Append('$').ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+ }
+
+ private static string SortKey(List readers, string number)
+ {
+ if (string.IsNullOrWhiteSpace(number))
+ return string.Empty;
+
+ number = number.Trim();
+ foreach (var reader in readers)
+ {
+ var match = reader.Match(number);
+ if (!match.Success || !int.TryParse(match.Groups["seq"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var sequence))
+ continue;
+
+ var year = match.Groups["year"].Success ? Group(match, "year")
+ : match.Groups["yy"].Success ? CultureInfo.InvariantCulture.Calendar.ToFourDigitYear(Group(match, "yy")) : 0;
+
+ return "1" + year.ToString("D4", CultureInfo.InvariantCulture) + Group(match, "month").ToString("D2", CultureInfo.InvariantCulture) +
+ Group(match, "day").ToString("D2", CultureInfo.InvariantCulture) + sequence.ToString("D10", CultureInfo.InvariantCulture);
+ }
+
+ var key = new StringBuilder("0");
+ var i = 0;
+ while (i < number.Length)
+ {
+ if (number[i] < '0' || number[i] > '9')
+ {
+ key.Append(char.ToLowerInvariant(number[i++]));
+ continue;
+ }
+
+ var start = i;
+ while (i < number.Length && number[i] >= '0' && number[i] <= '9')
+ i++;
+ key.Append(number.Substring(start, i - start).TrimStart('0').PadLeft(10, '0'));
+ }
+
+ return key.ToString();
+ }
+
+ private static int Group(Match match, string name)
+ {
+ return match.Groups[name].Success ? int.Parse(match.Groups[name].Value, NumberStyles.None, CultureInfo.InvariantCulture) : 0;
+ }
+
private static bool IsLiteral(char c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '/';
/// Null when the pattern is not valid; otherwise literal runs and tokens in order.
diff --git a/Core/Resgrid.Model/EventingTypes.cs b/Core/Resgrid.Model/EventingTypes.cs
index 8ca3fa26f..6c8e5e43f 100644
--- a/Core/Resgrid.Model/EventingTypes.cs
+++ b/Core/Resgrid.Model/EventingTypes.cs
@@ -21,6 +21,9 @@ public enum EventingTypes
UnitStatusAlertUpdated = 13,
/// Calls awaiting resources changed, or a freed unit matches one; item id = call id (0 = several), payload = OutstandingRequirementsAlert.
- OutstandingRequirementsUpdated = 14
+ OutstandingRequirementsUpdated = 14,
+
+ /// Who holds a unit's active roles (its current crew) changed; item id = unit id (0 = several units).
+ UnitActiveRolesUpdated = 15
}
}
diff --git a/Core/Resgrid.Model/Events/UnitActiveRolesUpdatedEvent.cs b/Core/Resgrid.Model/Events/UnitActiveRolesUpdatedEvent.cs
new file mode 100644
index 000000000..9d6987e88
--- /dev/null
+++ b/Core/Resgrid.Model/Events/UnitActiveRolesUpdatedEvent.cs
@@ -0,0 +1,13 @@
+namespace Resgrid.Model.Events
+{
+ ///
+ /// Raised when the members seated in a unit's active roles change (staffing saved, seats cleared, unit deleted). Fans
+ /// out over the eventing topic as unitActiveRolesUpdated, so a map following a unit's crew reloads who rides it.
+ /// A of 0 means several units changed at once (the department staffing page).
+ ///
+ public class UnitActiveRolesUpdatedEvent
+ {
+ public int DepartmentId { get; set; }
+ public int UnitId { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/IdentifierSearchKeys.cs b/Core/Resgrid.Model/IdentifierSearchKeys.cs
new file mode 100644
index 000000000..82c20a12a
--- /dev/null
+++ b/Core/Resgrid.Model/IdentifierSearchKeys.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Search keys for numbers a department formats itself (record, call and document numbers): a number with its formatting
+ /// set aside, so a search finds it however the number was typed. A number reads as runs of letters and runs of digits; any
+ /// other character only separates them, a letter next to a digit starts a new run, letters are lower-cased and leading zeros
+ /// dropped. "RUN26-0001", "run-26-1" and "RUN 26 0001" all read "run-26-1", which a word analyzer cannot match: it keeps
+ /// "RUN26" whole, and "2026.0001" or "RUN_2026_0001" as one word. A number is indexed under every run of consecutive parts,
+ /// so "RUN", "26-1" and "0001" find it as well.
+ ///
+ public static class IdentifierSearchKeys
+ {
+ /// Longest run of consecutive parts indexed on its own; the whole number is always a key.
+ public const int MaxPartsPerKey = 8;
+
+ /// The key a search text is looked up by; null when the text has no letters or digits.
+ public static string Canonical(string text)
+ {
+ var parts = Parts(text);
+ return parts.Count == 0 ? null : string.Join("-", parts);
+ }
+
+ /// Every key that finds : the whole number and each shorter run of consecutive parts.
+ public static IEnumerable For(string number)
+ {
+ var parts = Parts(number);
+ var keys = new HashSet(StringComparer.Ordinal);
+ if (parts.Count == 0)
+ return keys;
+
+ keys.Add(string.Join("-", parts));
+ for (var start = 0; start < parts.Count; start++)
+ {
+ for (var length = 1; length <= MaxPartsPerKey && start + length <= parts.Count; length++)
+ keys.Add(string.Join("-", parts.GetRange(start, length)));
+ }
+
+ return keys;
+ }
+
+ /// True when has an ASCII digit: an identifier rather than a word.
+ public static bool HasDigit(string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ return false;
+ foreach (var c in text)
+ {
+ if (IsDigit(c))
+ return true;
+ }
+ return false;
+ }
+
+ /// Longest list filter search kept: no department number column holds more than 50 characters.
+ public const int MaxFilterLength = 50;
+
+ /// A list filter's search as the query takes it: trimmed, cut to , null when it has no letters or digits.
+ public static string FilterSearch(string search)
+ {
+ if (string.IsNullOrWhiteSpace(search))
+ return null;
+ search = search.Trim();
+ if (search.Length > MaxFilterLength)
+ search = search.Substring(0, MaxFilterLength);
+ return Parts(search, false).Count == 0 ? null : search;
+ }
+
+ ///
+ /// The letter and digit runs of a list filter's search as typed: lower-cased, zero padding kept. Empty when the search has
+ /// no letters or digits.
+ ///
+ public static IReadOnlyList TypedParts(string search) => Parts(search, false);
+
+ ///
+ /// The list filter match: holds the search's letter and digit runs in that order, ignoring case.
+ /// "wo26-0007", "WO-26-0007", "26 0007" and "0007" all find WO26-0007 whatever separators the department's pattern uses;
+ /// "26-7" finds it as well, along with any other number holding a 7 after its 26. A search with no letters or digits finds
+ /// nothing. Lists filtered in SQL run the same match as LIKE '%wo%26%0007%'.
+ ///
+ public static bool ContainsParts(string number, string search)
+ {
+ var parts = Parts(search, false);
+ if (parts.Count == 0 || string.IsNullOrEmpty(number))
+ return false;
+
+ var at = 0;
+ foreach (var part in parts)
+ {
+ var found = number.IndexOf(part, at, StringComparison.OrdinalIgnoreCase);
+ if (found < 0)
+ return false;
+ at = found + part.Length;
+ }
+ return true;
+ }
+
+ private static List Parts(string text, bool dropZeros = true)
+ {
+ var parts = new List();
+ if (string.IsNullOrWhiteSpace(text))
+ return parts;
+
+ var part = new StringBuilder();
+ var digits = false;
+ foreach (var c in text)
+ {
+ var isDigit = IsDigit(c);
+ if (!isDigit && !char.IsLetterOrDigit(c))
+ {
+ Flush(parts, part, digits, dropZeros);
+ continue;
+ }
+ if (part.Length > 0 && isDigit != digits)
+ Flush(parts, part, digits, dropZeros);
+ digits = isDigit;
+ part.Append(isDigit ? c : char.ToLowerInvariant(c));
+ }
+ Flush(parts, part, digits, dropZeros);
+
+ return parts;
+ }
+
+ private static void Flush(List parts, StringBuilder part, bool digits, bool dropZeros)
+ {
+ if (part.Length == 0)
+ return;
+
+ var text = part.ToString();
+ if (digits && dropZeros)
+ {
+ text = text.TrimStart('0');
+ if (text.Length == 0)
+ text = "0";
+ }
+ parts.Add(text);
+ part.Clear();
+ }
+
+ private static bool IsDigit(char c) => c >= '0' && c <= '9';
+ }
+}
diff --git a/Core/Resgrid.Model/NumberingYear.cs b/Core/Resgrid.Model/NumberingYear.cs
index 3b40a9b78..8cb980ade 100644
--- a/Core/Resgrid.Model/NumberingYear.cs
+++ b/Core/Resgrid.Model/NumberingYear.cs
@@ -80,4 +80,24 @@ public int YearOf(DateTime localDate)
private bool NamedByStartYear => IsCalendarYear || Label == NumberingYearLabel.StartYear;
}
+
+ ///
+ /// One numbering year as a half-open UTC window [FromUtc, BeforeUtc): what a year filter matches so it keeps the documents
+ /// whose numbers name that year (department-local, a fiscal year when the department sets one).
+ ///
+ public sealed class NumberingYearWindow
+ {
+ public NumberingYearWindow(int year, DateTime fromUtc, DateTime beforeUtc)
+ {
+ Year = year;
+ FromUtc = fromUtc;
+ BeforeUtc = beforeUtc;
+ }
+
+ public int Year { get; }
+
+ public DateTime FromUtc { get; }
+
+ public DateTime BeforeUtc { get; }
+ }
}
diff --git a/Core/Resgrid.Model/PersonnelMapLabel.cs b/Core/Resgrid.Model/PersonnelMapLabel.cs
new file mode 100644
index 000000000..d0f5c3cc3
--- /dev/null
+++ b/Core/Resgrid.Model/PersonnelMapLabel.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model
+{
+ ///
+ /// The name a person's map pin carries. A member holding an active role on a unit (a
+ /// row) is labelled with that unit, "Shawn Jackson (Engine 1)". A Not Occupied seat has no row, so it labels no one,
+ /// and a seat on a unit that is not in units (deleted, another department) or that the viewer may not see is ignored.
+ ///
+ public static class PersonnelMapLabel
+ {
+ ///
+ /// The unit names each seated user rides on, keyed by user id; two units are joined in name order. Only units
+ /// allows are named, and it is asked once per seated unit.
+ ///
+ public static async Task> BuildUnitNamesByUserAsync(IEnumerable activeRoles,
+ IEnumerable units, Func> canViewUnit)
+ {
+ var seats = (activeRoles ?? Enumerable.Empty())
+ .Where(r => r != null && !String.IsNullOrWhiteSpace(r.UserId))
+ .ToList();
+ var seatedUnitIds = seats.Select(r => r.UnitId).ToHashSet();
+
+ var unitNames = new Dictionary();
+ foreach (var unit in (units ?? Enumerable.Empty()).Where(u => u != null && seatedUnitIds.Contains(u.UnitId)))
+ {
+ if (unit.IsDeleted || String.IsNullOrWhiteSpace(unit.Name) || unitNames.ContainsKey(unit.UnitId))
+ continue;
+
+ if (await canViewUnit(unit.UnitId))
+ unitNames[unit.UnitId] = unit.Name.Trim();
+ }
+
+ return seats
+ .Where(r => unitNames.ContainsKey(r.UnitId))
+ .GroupBy(r => r.UserId, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key,
+ g => String.Join(", ", g.Select(r => unitNames[r.UnitId]).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(n => n, StringComparer.OrdinalIgnoreCase)),
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ /// The pin title: the name, with the unit in parentheses when the user is seated on one.
+ public static string Format(string name, string userId, IReadOnlyDictionary unitNamesByUser)
+ {
+ if (String.IsNullOrWhiteSpace(name) || String.IsNullOrWhiteSpace(userId) || unitNamesByUser == null ||
+ !unitNamesByUser.TryGetValue(userId, out var unitName) || String.IsNullOrWhiteSpace(unitName))
+ return name;
+
+ return $"{name} ({unitName})";
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs
index ed3a81894..22d7d031a 100644
--- a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs
+++ b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs
@@ -36,6 +36,9 @@ void RegisterForEvents(Func personnelStatusChanged,
/// Unit status timer acknowledgement changes (department id, unit id).
void RegisterForUnitStatusAlertEvents(Func unitStatusAlertEvent);
+ /// A unit's current crew (its active roles) changed (department id, unit id; 0 = several units).
+ void RegisterForUnitActiveRolesEvents(Func unitActiveRolesEvent);
+
/// Calls awaiting resources changed or a freed unit matches one (department id, call id, alert payload).
void RegisterForOutstandingRequirementsEvents(Func outstandingRequirementsEvent);
}
diff --git a/Core/Resgrid.Model/Records/RecordsSearchContracts.cs b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs
index 7fff38ac9..e82b899e0 100644
--- a/Core/Resgrid.Model/Records/RecordsSearchContracts.cs
+++ b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs
@@ -10,8 +10,8 @@ namespace Resgrid.Model
///
public static class RecordsSearchGeneration
{
- /// Bump when the document schema in RecordsSearchDocumentBuilder changes.
- public const int SchemaVersion = 1;
+ /// Bump when the document schema in RecordsSearchDocumentBuilder changes. 2: record and call number keys.
+ public const int SchemaVersion = 2;
public static string Compute(int protectedCatalogVersion, long policyEpoch)
{
@@ -51,7 +51,11 @@ public class RecordsSearchRequest
public string ViewerUserId { get; set; }
public List States { get; set; }
public string DefinitionKey { get; set; }
+ /// UTC calendar year of the occurrence; / take its place when set.
public int? Year { get; set; }
+ /// Half-open UTC occurrence window [OccurredFrom, OccurredBefore): a department numbering year (IRecordsService.GetYearWindowAsync).
+ public DateTime? OccurredFrom { get; set; }
+ public DateTime? OccurredBefore { get; set; }
public int? CallId { get; set; }
public bool IncludeLegacy { get; set; }
public int Skip { get; set; }
diff --git a/Core/Resgrid.Model/Repositories/IContractorRepositories.cs b/Core/Resgrid.Model/Repositories/IContractorRepositories.cs
index 2cbc581c4..91773143e 100644
--- a/Core/Resgrid.Model/Repositories/IContractorRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IContractorRepositories.cs
@@ -61,8 +61,9 @@ public interface IDepartmentComplianceDocumentRepository : IRepository
{
Task GetByIdForDepartmentAsync(string bidId, int departmentId);
- Task> GetForDepartmentAsync(int departmentId, int? status, int skip, int take);
- Task CountForDepartmentAsync(int departmentId, int? status);
+ /// Newest number first; (optional) is part of the bid number as issued (IdentifierSearchKeys.ContainsParts).
+ Task> GetForDepartmentAsync(int departmentId, int? status, string number, int skip, int take);
+ Task CountForDepartmentAsync(int departmentId, int? status, string number);
Task> GetByContactIdAsync(int departmentId, string contactId, int skip, int take);
Task> GetByContractAsync(string serviceContractId);
/// Submitted bids (all departments) whose ValidUntil is behind .
diff --git a/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs b/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs
index 74074a8ef..0e075f7da 100644
--- a/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs
@@ -57,6 +57,8 @@ public class InvoiceListFilter
public string ServiceContractId { get; set; }
public DateTime? IssuedFromUtc { get; set; }
public DateTime? IssuedToUtc { get; set; }
+ /// Part of the invoice number as issued, in the department's format (IdentifierSearchKeys.ContainsParts).
+ public string Number { get; set; }
public int Skip { get; set; }
public int Take { get; set; } = 50;
}
diff --git a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs
index 8407561a6..1a3bd9d43 100644
--- a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs
@@ -9,7 +9,15 @@ namespace Resgrid.Model.Repositories
public sealed class RmsIncidentReportQuery
{
public IList States { get; set; }
+ ///
+ /// A numbering year of the department: IIncidentReportsService turns it into /
+ /// (department-local, a fiscal year when one is set) so it keeps the reports whose numbers name it. Read directly by the
+ /// repository only when no window is set, as the UTC calendar year of COALESCE(CallCreatedOn, CreatedOn).
+ ///
public int? Year { get; set; }
+ /// Half-open UTC window [YearFrom, YearBefore) on COALESCE(CallCreatedOn, CreatedOn), the date a report is numbered by.
+ public DateTime? YearFrom { get; set; }
+ public DateTime? YearBefore { get; set; }
public int? CallId { get; set; }
public string OwnerUserId { get; set; }
public int? StationGroupId { get; set; }
@@ -51,7 +59,10 @@ public interface IRmsIncidentReportsRepository : IRepository
Task GetByNerisIncidentIdAsync(int departmentId, string nerisIncidentId);
Task> QueryAsync(int departmentId, RmsIncidentReportQuery query);
Task CountAsync(int departmentId, RmsIncidentReportQuery query);
+ /// UTC calendar years of COALESCE(CallCreatedOn, CreatedOn), newest first.
Task> GetYearsAsync(int departmentId);
+ /// The years of that COALESCE(CallCreatedOn, CreatedOn) of a live report falls in, newest first.
+ Task> GetNumberingYearsAsync(int departmentId, IReadOnlyList windows);
/// Highest sequence already issued between a number prefix and suffix, across Records and incident reports; 0 when none.
Task GetMaxRecordNumberSequenceAsync(int departmentId, string numberPrefix, string numberSuffix);
Task TryBumpRowVersionAsync(int departmentId, string reportId, long expectedRowVersion, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs
index 012a1b8b9..0627c0ee9 100644
--- a/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IRmsPreventionRepositories.cs
@@ -96,6 +96,8 @@ public sealed class RmsInspectionQuery
public IList States { get; set; }
public string InspectorUserId { get; set; }
public DateTime? ScheduledBefore { get; set; }
+ /// Part of the inspection number as issued, in the department's format (IdentifierSearchKeys.ContainsParts).
+ public string Number { get; set; }
public int Skip { get; set; }
public int Take { get; set; } = 50;
}
@@ -170,6 +172,8 @@ public sealed class RmsPermitQuery
public string PermitTypeId { get; set; }
public IList States { get; set; }
public DateTime? ExpiresBefore { get; set; }
+ /// Part of the permit number as issued, in the department's format (IdentifierSearchKeys.ContainsParts).
+ public string Number { get; set; }
public int Skip { get; set; }
public int Take { get; set; } = 50;
}
@@ -269,6 +273,8 @@ public interface IRmsInvestigationEvidenceRepository : IRepository GetByIdForDepartmentAsync(int departmentId, string evidenceId);
Task> GetForCaseAsync(int departmentId, string caseId);
+ /// The cases among holding live evidence whose number holds (IdentifierSearchKeys.ContainsParts).
+ Task> GetCaseIdsWithEvidenceNumberAsync(int departmentId, IEnumerable caseIds, string number);
}
public interface IRmsInvestigationCustodyRepository : IRepository
diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs
index 2bc387ecd..b92da9b7a 100644
--- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs
@@ -14,7 +14,15 @@ public sealed class RmsRecordQuery
{
public IList States { get; set; }
public string DefinitionKey { get; set; }
+ ///
+ /// A numbering year of the department: IRecordsService turns it into /
+ /// (department-local, a fiscal year when one is set) so it keeps the records whose numbers name it. Read directly by the
+ /// repository only when no window is set, as the UTC calendar year.
+ ///
public int? Year { get; set; }
+ /// Half-open UTC window [OccurredFrom, OccurredBefore) on COALESCE(OccurredOn, RecordCreatedOn).
+ public DateTime? OccurredFrom { get; set; }
+ public DateTime? OccurredBefore { get; set; }
/// Lower bound on COALESCE(FinalizedOn, OccurredOn, RecordCreatedOn): a report window belongs in the
/// query, never after the page has been cut, or matching Records fall off the end without any warning.
public DateTime? OccurredSince { get; set; }
@@ -193,7 +201,10 @@ public interface IRmsRecordSearchProjectionsRepository : IRepository GetByRecordIdAsync(int departmentId, string recordId);
Task> QueryAsync(int departmentId, RmsRecordQuery query);
Task CountAsync(int departmentId, RmsRecordQuery query);
+ /// UTC calendar years of COALESCE(OccurredOn, RecordCreatedOn), newest first.
Task> GetYearsAsync(int departmentId);
+ /// The years of that COALESCE(OccurredOn, RecordCreatedOn) of a live projection falls in, newest first.
+ Task> GetNumberingYearsAsync(int departmentId, IReadOnlyList windows);
/// Projections (including soft-deleted ones) modified after , oldest first; the search index catch-up feed.
///
/// Rows after the (ModifiedOn, id) cursor, oldest first. The id is part of the cursor because several rows
@@ -278,7 +289,8 @@ public interface IRmsDisclosureRequestsRepository : IRepository TryBumpRowVersionAsync(int departmentId, string requestId, long expectedVersion, CancellationToken cancellationToken = default);
Task GetByIdForDepartmentAsync(int departmentId, string requestId);
- Task> GetForDepartmentAsync(int departmentId, IEnumerable states, int skip, int take);
+ /// Oldest deadline first; (optional) is part of the request number as issued (IdentifierSearchKeys.ContainsParts).
+ Task> GetForDepartmentAsync(int departmentId, IEnumerable states, string number, int skip, int take);
Task CountByStateAsync(int departmentId, RmsDisclosureState state);
/// Open requests past their statutory due date; the number an officer is accountable for.
Task CountOverdueAsync(int departmentId, DateTime utcNow);
diff --git a/Core/Resgrid.Model/Search/SearchContracts.cs b/Core/Resgrid.Model/Search/SearchContracts.cs
index 932d3858c..72b92ad9d 100644
--- a/Core/Resgrid.Model/Search/SearchContracts.cs
+++ b/Core/Resgrid.Model/Search/SearchContracts.cs
@@ -67,10 +67,11 @@ public static class GlobalSearchGeneration
{
///
/// Bump when GlobalSearchDocumentBuilder or the projection allowlist changes. 3: call notes in the call full text.
+ /// 4: number keys of the keywords (department-formatted call, invoice and bid numbers by any part).
/// A bump blanks every department's results until its own rebuild runs (hits must carry the current generation), so
/// changes that only add rows go through a backfill instead (the M0261 call history backfill).
///
- public const int SchemaVersion = 3;
+ public const int SchemaVersion = 4;
public static string Compute(int protectedCatalogVersion, long policyEpoch)
{
diff --git a/Core/Resgrid.Model/Services/IBidsService.cs b/Core/Resgrid.Model/Services/IBidsService.cs
index 2c4b9bec2..495053683 100644
--- a/Core/Resgrid.Model/Services/IBidsService.cs
+++ b/Core/Resgrid.Model/Services/IBidsService.cs
@@ -16,6 +16,9 @@ public interface IBidsService
{
Task> GetBidsForDepartmentAsync(int departmentId, BidStatuses? status = null, int skip = 0, int take = 100);
Task CountBidsForDepartmentAsync(int departmentId, BidStatuses? status = null);
+ /// The bid list filtered by part of the bid number as issued (IdentifierSearchKeys.ContainsParts); null or blank lists every bid.
+ Task> GetBidsForDepartmentAsync(int departmentId, BidStatuses? status, string number, int skip, int take);
+ Task CountBidsForDepartmentAsync(int departmentId, BidStatuses? status, string number);
Task> GetBidsByContactIdAsync(string contactId, int departmentId, int skip = 0, int take = 100);
/// The department's bids raised under one contract (the contract detail page), newest first.
Task> GetBidsForContractAsync(string serviceContractId, int departmentId);
diff --git a/Core/Resgrid.Model/Services/IIncidentReportsService.cs b/Core/Resgrid.Model/Services/IIncidentReportsService.cs
index af67e388b..9d6c4e97f 100644
--- a/Core/Resgrid.Model/Services/IIncidentReportsService.cs
+++ b/Core/Resgrid.Model/Services/IIncidentReportsService.cs
@@ -63,6 +63,7 @@ public interface IIncidentReportsService
Task CountAsync(int departmentId, RmsIncidentReportQuery query);
+ /// The department's numbering years that hold a report (department-local, fiscal when it sets one), newest first.
Task> GetYearsAsync(int departmentId);
///
diff --git a/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs b/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs
index 26bb18fc9..4bd45771d 100644
--- a/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs
@@ -25,7 +25,8 @@ public interface IRecordsDisclosureService
Task GetAsync(int departmentId, string userId, string requestId);
- Task> QueryAsync(int departmentId, string userId, IEnumerable states, int skip = 0, int take = 50);
+ /// The request queue, oldest deadline first; filters by part of the request number as issued (IdentifierSearchKeys.ContainsParts).
+ Task> QueryAsync(int departmentId, string userId, IEnumerable states, int skip = 0, int take = 50, string number = null);
/// Saves the scope query and narrative; refused once a production exists, because the scope is what was produced against.
Task SaveScopeAsync(int departmentId, string userId, string requestId, string scopeNarrative, RmsRecordQuery scope, string redactionProfile, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/Services/IRecordsInvestigationsService.cs b/Core/Resgrid.Model/Services/IRecordsInvestigationsService.cs
index aa0d0a12f..ba65470a7 100644
--- a/Core/Resgrid.Model/Services/IRecordsInvestigationsService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsInvestigationsService.cs
@@ -29,7 +29,8 @@ public interface IRecordsInvestigationsService
Task IsModuleEnabledAsync(int departmentId);
/// Cases the caller is an active member of. Nobody lists cases they are not on.
- Task> ListMyCasesAsync(int departmentId, string userId, bool includeClosed);
+ /// The caller's cases; keeps those whose case number, or one of whose evidence numbers, holds it (IdentifierSearchKeys.ContainsParts).
+ Task> ListMyCasesAsync(int departmentId, string userId, bool includeClosed, string number = null);
Task GetAsync(int departmentId, string userId, string caseId, string ipAddress = null);
/// Opens a case; the opener becomes Lead. Needs RecordRestricted_View.
Task OpenAsync(int departmentId, string userId, string title, string occupancyId, int? callId, string incidentSummary, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/Services/IRecordsService.cs b/Core/Resgrid.Model/Services/IRecordsService.cs
index 5f1292ea3..f6788ed3d 100644
--- a/Core/Resgrid.Model/Services/IRecordsService.cs
+++ b/Core/Resgrid.Model/Services/IRecordsService.cs
@@ -62,8 +62,15 @@ public interface IRecordsService
/// Live projections by record id, in no particular order; the search path re-checks each before showing it.
Task> GetProjectionsByIdsAsync(int departmentId, IEnumerable recordIds);
+ /// The department's numbering years that hold a record (department-local, fiscal when it sets one), newest first.
Task> GetYearsAsync(int departmentId);
+ ///
+ /// The UTC window of the department's numbering year (Records numbering, setting 72): what a year
+ /// filter on the records index matches, so it keeps the records whose numbers name that year. QueryAsync/CountAsync apply it to Year themselves.
+ ///
+ Task GetYearWindowAsync(int departmentId, int year);
+
/// Delta cursor for clients (plan section 5.3): projections modified after , oldest first, including tombstones (cancelled, voided, deleted).
Task> GetChangesSinceAsync(int departmentId, DateTime? since, int take, string sinceId = null);
diff --git a/Core/Resgrid.Model/Services/IUnitsService.cs b/Core/Resgrid.Model/Services/IUnitsService.cs
index b81f80cb6..a502b2e69 100644
--- a/Core/Resgrid.Model/Services/IUnitsService.cs
+++ b/Core/Resgrid.Model/Services/IUnitsService.cs
@@ -363,6 +363,13 @@ Task AddUnitLocationAsync(UnitsLocation location, int d
Task DeleteActiveRolesForUnitAsync(int unitId, CancellationToken cancellationToken = default(CancellationToken));
+ ///
+ /// Publishes unitActiveRolesUpdated once a caller has finished replacing a unit's active roles, so live maps
+ /// reload the unit's crew. Pass a unit id of 0 when several units changed at once. Never throws: the seats are saved,
+ /// and a map that misses the push picks the crew up on its next reload.
+ ///
+ Task NotifyActiveRolesUpdatedAsync(int departmentId, int unitId);
+
Task> GetAllActiveRolesForUnitsByDepartmentIdAsync(int departmentId);
///
diff --git a/Core/Resgrid.Model/Services/IWorkOrdersService.cs b/Core/Resgrid.Model/Services/IWorkOrdersService.cs
index d8b80cc1a..08b806d9e 100644
--- a/Core/Resgrid.Model/Services/IWorkOrdersService.cs
+++ b/Core/Resgrid.Model/Services/IWorkOrdersService.cs
@@ -58,6 +58,8 @@ public sealed class WorkOrderFilter
public int? UnitId { get; set; }
public int? GroupId { get; set; }
public string AssetId { get; set; }
+ /// Part of the work order number as issued, in the department's format (IdentifierSearchKeys.ContainsParts).
+ public string Number { get; set; }
}
public sealed class WorkOrderReadScope
{
diff --git a/Core/Resgrid.Search/GlobalIndexFields.cs b/Core/Resgrid.Search/GlobalIndexFields.cs
index 4e165514e..0bcf3243c 100644
--- a/Core/Resgrid.Search/GlobalIndexFields.cs
+++ b/Core/Resgrid.Search/GlobalIndexFields.cs
@@ -29,6 +29,8 @@ public static class GlobalIndexFields
public const string KeywordsPrefix = "KeywordsPrefix";
/// Whole keywords lower-cased, one term each: exact identifier match.
public const string KeywordExact = "KeywordExact";
+ /// IdentifierSearchKeys of the keywords that hold a digit (call, invoice, bid numbers...), one term each.
+ public const string KeywordKeys = "KeywordKeys";
public const string Category = "Category";
public const string Status = "Status";
public const string Priority = "Priority";
diff --git a/Core/Resgrid.Search/GlobalSearchDocumentBuilder.cs b/Core/Resgrid.Search/GlobalSearchDocumentBuilder.cs
index 6e7da3007..1877b5f17 100644
--- a/Core/Resgrid.Search/GlobalSearchDocumentBuilder.cs
+++ b/Core/Resgrid.Search/GlobalSearchDocumentBuilder.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using Lucene.Net.Documents;
+using Resgrid.Model;
using Resgrid.Model.Search;
using Document = Lucene.Net.Documents.Document;
@@ -47,6 +48,8 @@ public static Document Build(SearchProjection p, string generation)
doc.Add(new TextField(GlobalIndexFields.KeywordsPrefix, p.Keywords, Field.Store.NO));
foreach (var keyword in SplitKeywords(p.Keywords))
doc.Add(new StringField(GlobalIndexFields.KeywordExact, keyword, Field.Store.NO));
+ foreach (var key in NumberKeys(p.Keywords))
+ doc.Add(new StringField(GlobalIndexFields.KeywordKeys, key, Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(p.Category))
doc.Add(new StringField(GlobalIndexFields.Category, p.Category, Field.Store.YES));
@@ -81,6 +84,21 @@ public static IEnumerable SplitCsv(string csv)
}
}
+ ///
+ /// The IdentifierSearchKeys of every keyword holding a digit: department-formatted numbers (call, invoice, bid...) by any
+ /// part, however they were typed. Names and other words carry no number and are left to the text fields.
+ ///
+ public static IEnumerable NumberKeys(string keywords)
+ {
+ var keys = new HashSet(StringComparer.Ordinal);
+ foreach (var keyword in SplitKeywords(keywords))
+ {
+ if (IdentifierSearchKeys.HasDigit(keyword))
+ keys.UnionWith(IdentifierSearchKeys.For(keyword));
+ }
+ return keys;
+ }
+
/// Keywords are separated by whitespace or commas; each becomes one lower-cased exact term.
public static IEnumerable SplitKeywords(string keywords)
{
diff --git a/Core/Resgrid.Search/LuceneGlobalSearchService.cs b/Core/Resgrid.Search/LuceneGlobalSearchService.cs
index 09040a154..371786d09 100644
--- a/Core/Resgrid.Search/LuceneGlobalSearchService.cs
+++ b/Core/Resgrid.Search/LuceneGlobalSearchService.cs
@@ -10,6 +10,7 @@
using Lucene.Net.Search;
using Resgrid.Config;
using Resgrid.Framework;
+using Resgrid.Model;
using Resgrid.Model.Search;
using Resgrid.Model.Services;
@@ -309,6 +310,10 @@ BooleanQuery ScopedTypes()
}
textQuery.Add(new TermQuery(new Term(GlobalIndexFields.KeywordExact, text.ToLowerInvariant())) { Boost = 6f }, Occur.SHOULD);
+ // A department's own number format: "INV2026-1", "2026-0001" or "0001" still find INV2026-0001.
+ var numberKey = IdentifierSearchKeys.Canonical(text);
+ if (numberKey != null)
+ textQuery.Add(new TermQuery(new Term(GlobalIndexFields.KeywordKeys, numberKey)) { Boost = 5f }, Occur.SHOULD);
query.Add(textQuery, Occur.MUST);
}
diff --git a/Core/Resgrid.Search/LuceneRecordsSearchService.cs b/Core/Resgrid.Search/LuceneRecordsSearchService.cs
index 76de9cc9b..dde3ea0a2 100644
Binary files a/Core/Resgrid.Search/LuceneRecordsSearchService.cs and b/Core/Resgrid.Search/LuceneRecordsSearchService.cs differ
diff --git a/Core/Resgrid.Search/RecordsIndexFields.cs b/Core/Resgrid.Search/RecordsIndexFields.cs
index eb33d4bb6..03b18b956 100644
--- a/Core/Resgrid.Search/RecordsIndexFields.cs
+++ b/Core/Resgrid.Search/RecordsIndexFields.cs
@@ -14,6 +14,8 @@ public static class RecordsIndexFields
public const string SourceId = "SourceId";
public const string RecordNumber = "RecordNumber";
public const string RecordNumberExact = "RecordNumberExact";
+ /// IdentifierSearchKeys of the record and call numbers, one term each: custom-format numbers by any part.
+ public const string NumberKeys = "NumberKeys";
public const string DraftReference = "DraftReference";
public const string DefinitionKey = "DefinitionKey";
public const string DefinitionVersion = "DefinitionVersion";
diff --git a/Core/Resgrid.Search/RecordsSearchDocumentBuilder.cs b/Core/Resgrid.Search/RecordsSearchDocumentBuilder.cs
index 3a4399dfc..1fb6fad9c 100644
--- a/Core/Resgrid.Search/RecordsSearchDocumentBuilder.cs
+++ b/Core/Resgrid.Search/RecordsSearchDocumentBuilder.cs
@@ -54,6 +54,10 @@ public static Document Build(RecordsSearchDocumentSource source)
doc.Add(new StringField(RecordsIndexFields.CallId, p.CallId.Value.ToString(), Field.Store.YES));
if (!string.IsNullOrWhiteSpace(p.CallNumber))
doc.Add(new TextField(RecordsIndexFields.CallNumber, p.CallNumber, Field.Store.YES));
+ var numberKeys = new HashSet(IdentifierSearchKeys.For(p.RecordNumber), StringComparer.Ordinal);
+ numberKeys.UnionWith(IdentifierSearchKeys.For(p.CallNumber));
+ foreach (var key in numberKeys)
+ doc.Add(new StringField(RecordsIndexFields.NumberKeys, key, Field.Store.NO));
if (!string.IsNullOrWhiteSpace(p.DisplaySummary))
doc.Add(new TextField(RecordsIndexFields.Summary, p.DisplaySummary, Field.Store.YES));
if (!string.IsNullOrWhiteSpace(p.SearchText))
diff --git a/Core/Resgrid.Services/CalendarService.cs b/Core/Resgrid.Services/CalendarService.cs
index 749574c70..296cf04c5 100644
--- a/Core/Resgrid.Services/CalendarService.cs
+++ b/Core/Resgrid.Services/CalendarService.cs
@@ -98,6 +98,10 @@ public async Task> GetUpcomingCalendarItemsAsync(int departme
{
// The stored row backs REDACTED-sentinel restoration: an editor without a grant posts
// placeholders back from the form, and those must not overwrite the real values.
+ // The add and update paths sanitize the description; this path (the calendar's JSON create/update) did not.
+ if (calendarItem != null)
+ calendarItem.Description = StringHelpers.SanitizeHtmlForStorage(calendarItem.Description);
+
CalendarItem existing = null;
if (calendarItem != null && calendarItem.CalendarItemId > 0)
existing = await _calendarItemRepository.GetCalendarItemByIdAsync(calendarItem.CalendarItemId);
diff --git a/Core/Resgrid.Services/ContactsService.cs b/Core/Resgrid.Services/ContactsService.cs
index 23fde41bc..4776ca040 100644
--- a/Core/Resgrid.Services/ContactsService.cs
+++ b/Core/Resgrid.Services/ContactsService.cs
@@ -108,6 +108,11 @@ public async Task> GetContactCategoriesForDepartmentAsync(
{
// Round-tripped REDACTED placeholder on an edit means "unchanged" — fetch the stored row
// before it is overwritten so the safety net can restore the stored envelopes.
+ // Rich text the contact views render as HTML: scripts, handlers and frames are never stored. Envelopes and the
+ // REDACTED placeholder hold no markup and pass through unchanged.
+ contact.Description = StringHelpers.SanitizeHtmlForStorage(contact.Description);
+ contact.OtherInfo = StringHelpers.SanitizeHtmlForStorage(contact.OtherInfo);
+
Contact existingContactForRestore = null;
if (!string.IsNullOrWhiteSpace(contact.ContactId) &&
ProtectedReadService.ContactFieldAccessors.Any(a => a.Value.Get(contact) == ProtectedDataEnvelope.RedactionValue))
@@ -243,6 +248,7 @@ public async Task DoesContactNoteTypeAlreadyExistAsync(int departmentId, s
public async Task SaveContactNoteAsync(ContactNote note, CancellationToken cancellationToken = default(CancellationToken))
{
+ note.Note = StringHelpers.SanitizeHtmlForStorage(note.Note);
var savedNote = await _contactNotesRepository.SaveOrUpdateAsync(note, cancellationToken);
var protectedWrite = await _protectedWriteService.Value.PrepareContactNoteWriteAsync(savedNote.DepartmentId,
diff --git a/Core/Resgrid.Services/InventoryReportDocuments.cs b/Core/Resgrid.Services/InventoryReportDocuments.cs
index e47122e47..7020a857c 100644
--- a/Core/Resgrid.Services/InventoryReportDocuments.cs
+++ b/Core/Resgrid.Services/InventoryReportDocuments.cs
@@ -24,6 +24,7 @@ private static CultureInfo ReportCulture(CultureInfo culture)
return Resgrid.Localization.SupportedLocales.GetSupportedCultures().Contains(culture.TwoLetterISOLanguageName) ? culture : CultureInfo.GetCultureInfo("en");
}
public static string Title(InventoryReportKind kind, CultureInfo culture = null) => Text("M5Report" + kind, culture);
+ public static string Summary(InventoryReportKind kind, CultureInfo culture = null) => Text("M5ReportSummary" + kind, culture);
private static string H(object value) => WebUtility.HtmlEncode(Convert.ToString(value, CultureInfo.InvariantCulture));
private static string Page(InventoryReportKind kind, string body, CultureInfo culture) => "Resgrid | " + H(Title(kind, culture)) + "" + H(Title(kind, culture)) + "
" + body + "";
diff --git a/Core/Resgrid.Services/InventoryService.cs b/Core/Resgrid.Services/InventoryService.cs
index 523526ab1..ba4eee71e 100644
--- a/Core/Resgrid.Services/InventoryService.cs
+++ b/Core/Resgrid.Services/InventoryService.cs
@@ -2,6 +2,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Resgrid.Framework;
using Resgrid.Model;
using Resgrid.Model.Repositories;
using Resgrid.Model.Services;
@@ -56,6 +57,7 @@ public async Task GetInventoryByIdAsync(int inventoryId)
public async Task SaveInventoryAsync(Inventory inventory, CancellationToken cancellationToken = default(CancellationToken))
{
await GuardLegacyWriteAsync(inventory.DepartmentId);
+ inventory.Note = StringHelpers.SanitizeHtmlForStorage(inventory.Note);
return await _inventoryRepository.SaveOrUpdateAsync(inventory, cancellationToken);
}
diff --git a/Core/Resgrid.Services/Invoicing/BidsService.cs b/Core/Resgrid.Services/Invoicing/BidsService.cs
index 03960e338..dfccc3e18 100644
--- a/Core/Resgrid.Services/Invoicing/BidsService.cs
+++ b/Core/Resgrid.Services/Invoicing/BidsService.cs
@@ -79,12 +79,17 @@ public BidsService(IBidRepository bids, IBidLineItemRepository lines, IBidNumber
#region Reads
- public async Task> GetBidsForDepartmentAsync(int departmentId, BidStatuses? status = null, int skip = 0, int take = 100)
+ public Task> GetBidsForDepartmentAsync(int departmentId, BidStatuses? status = null, int skip = 0, int take = 100) => GetBidsForDepartmentAsync(departmentId, status, null, skip, take);
+
+ public Task CountBidsForDepartmentAsync(int departmentId, BidStatuses? status = null) => CountBidsForDepartmentAsync(departmentId, status, null);
+
+ public async Task> GetBidsForDepartmentAsync(int departmentId, BidStatuses? status, string number, int skip, int take)
{
- return (await _bids.GetForDepartmentAsync(departmentId, status.HasValue ? (int?)status.Value : null, skip, take))?.ToList() ?? new List();
+ return (await _bids.GetForDepartmentAsync(departmentId, status.HasValue ? (int?)status.Value : null, IdentifierSearchKeys.FilterSearch(number), skip, take))?.ToList() ?? new List();
}
- public Task CountBidsForDepartmentAsync(int departmentId, BidStatuses? status = null) => _bids.CountForDepartmentAsync(departmentId, status.HasValue ? (int?)status.Value : null);
+ public Task CountBidsForDepartmentAsync(int departmentId, BidStatuses? status, string number) =>
+ _bids.CountForDepartmentAsync(departmentId, status.HasValue ? (int?)status.Value : null, IdentifierSearchKeys.FilterSearch(number));
public async Task> GetBidsByContactIdAsync(string contactId, int departmentId, int skip = 0, int take = 100)
{
diff --git a/Core/Resgrid.Services/NearestUnitService.cs b/Core/Resgrid.Services/NearestUnitService.cs
index 4624d73bc..5fb41963c 100644
--- a/Core/Resgrid.Services/NearestUnitService.cs
+++ b/Core/Resgrid.Services/NearestUnitService.cs
@@ -6,6 +6,7 @@
using System.Threading.Tasks;
using Resgrid.Model;
using Resgrid.Model.Helpers;
+using Resgrid.Model.Identity;
using Resgrid.Model.Reporting;
using Resgrid.Model.Services;
@@ -33,13 +34,16 @@ public class NearestUnitService : INearestUnitService
private readonly IDepartmentSettingsService _departmentSettingsService;
private readonly IGeoService _geoService;
private readonly IAuthorizationService _authorizationService;
+ private readonly IDepartmentsService _departmentsService;
public NearestUnitService(IDispatchScopeService dispatchScopeService, IDepartmentGroupsService departmentGroupsService,
IUnitsService unitsService, IUsersService usersService, IPersonnelRolesService personnelRolesService,
IActionLogsService actionLogsService, IUserStateService userStateService, ICustomStateService customStateService,
IPersonnelLocationResolver personnelLocationResolver, IShiftsService shiftsService,
- IDepartmentSettingsService departmentSettingsService, IGeoService geoService, IAuthorizationService authorizationService)
+ IDepartmentSettingsService departmentSettingsService, IGeoService geoService, IAuthorizationService authorizationService,
+ IDepartmentsService departmentsService)
{
+ _departmentsService = departmentsService;
_dispatchScopeService = dispatchScopeService;
_departmentGroupsService = departmentGroupsService;
_unitsService = unitsService;
@@ -341,6 +345,11 @@ private async Task> BuildPeopleAsync(int departmentId
Dictionary> crewByUnit, List units, DateTime now)
{
var rows = await _usersService.GetUserGroupAndRolesByDepartmentIdAsync(departmentId, false, false, false) ?? new List();
+ // Only the members the dispatch lists offer (GetAllUsersForDepartmentAsync, capped at the plan's personnel limit), so
+ // the board never ranks, maps or crews someone the dispatcher has no box to tick. The same cached call picks the
+ // same members when the department is over its limit.
+ var members = await _departmentsService.GetAllUsersForDepartmentAsync(departmentId) ?? new List();
+ var memberIds = new HashSet(members.Where(u => !string.IsNullOrWhiteSpace(u?.UserId)).Select(u => u.UserId), StringComparer.OrdinalIgnoreCase);
var rolesByUser = await _personnelRolesService.GetAllRolesForUsersInDepartmentAsync(departmentId) ?? new Dictionary>();
var actionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(departmentId) ?? new List();
var userStates = await _userStateService.GetLatestStatesForDepartmentAsync(departmentId) ?? new List();
@@ -377,7 +386,7 @@ await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(departmentId),
foreach (var row in rows.Where(r => !string.IsNullOrWhiteSpace(r?.UserId)))
{
- if (people.ContainsKey(row.UserId))
+ if (people.ContainsKey(row.UserId) || !memberIds.Contains(row.UserId))
continue;
logByUser.TryGetValue(row.UserId, out var lastLog);
diff --git a/Core/Resgrid.Services/ProtocolsService.cs b/Core/Resgrid.Services/ProtocolsService.cs
index 1a3849bcf..4c5528afc 100644
--- a/Core/Resgrid.Services/ProtocolsService.cs
+++ b/Core/Resgrid.Services/ProtocolsService.cs
@@ -3,6 +3,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Resgrid.Framework;
using Resgrid.Model;
using Resgrid.Model.Repositories;
using Resgrid.Model.Search;
@@ -68,6 +69,10 @@ public async Task> GetAllProtocolsForDepartmentAsync(int
public async Task SaveProtocolAsync(DispatchProtocol protocol, CancellationToken cancellationToken = default(CancellationToken))
{
+ // Rich text the call views render as HTML: scripts, handlers and frames are never stored.
+ protocol.Description = StringHelpers.SanitizeHtmlForStorage(protocol.Description);
+ protocol.ProtocolText = StringHelpers.SanitizeHtmlForStorage(protocol.ProtocolText);
+
var saved = await _dispatchProtocolRepository.SaveOrUpdateAsync(protocol, cancellationToken);
if (saved.Questions != null)
diff --git a/Core/Resgrid.Services/Records/IncidentReportsService.cs b/Core/Resgrid.Services/Records/IncidentReportsService.cs
index 90b6c66b6..519111f8d 100644
--- a/Core/Resgrid.Services/Records/IncidentReportsService.cs
+++ b/Core/Resgrid.Services/Records/IncidentReportsService.cs
@@ -906,18 +906,43 @@ private async Task RequireCloseOutAsync(RmsIncidentReport report, string userId)
public async Task> QueryAsync(int departmentId, RmsIncidentReportQuery query)
{
if (!string.IsNullOrEmpty(query?.ViewerUserId) && !await _authorization.IsActiveMemberAsync(query.ViewerUserId, departmentId)) return new List();
- return (await _reports.QueryAsync(departmentId, query ?? new RmsIncidentReportQuery()))?.ToList() ?? new List();
+ return (await _reports.QueryAsync(departmentId, await WithYearWindowAsync(departmentId, query ?? new RmsIncidentReportQuery())))?.ToList() ?? new List();
}
public async Task CountAsync(int departmentId, RmsIncidentReportQuery query)
{
if (!string.IsNullOrEmpty(query?.ViewerUserId) && !await _authorization.IsActiveMemberAsync(query.ViewerUserId, departmentId)) return 0;
- return await _reports.CountAsync(departmentId, query ?? new RmsIncidentReportQuery());
+ return await _reports.CountAsync(departmentId, await WithYearWindowAsync(departmentId, query ?? new RmsIncidentReportQuery()));
}
+ /// The department's numbering years that hold a report (department-local, fiscal when it sets one), newest first.
public async Task> GetYearsAsync(int departmentId)
{
- return (await _reports.GetYearsAsync(departmentId))?.ToList() ?? new List();
+ var calendarYears = (await _reports.GetYearsAsync(departmentId))?.ToList() ?? new List();
+ if (calendarYears.Count == 0)
+ return calendarYears;
+
+ var config = await _settings.GetRecordsNumberingConfigAsync(departmentId);
+ var timeZone = (await _departments.GetDepartmentByIdAsync(departmentId, false))?.TimeZone;
+ var windows = RecordsNumberingService.CandidateYearWindows(config, calendarYears, timeZone);
+ return (await _reports.GetNumberingYearsAsync(departmentId, windows))?.ToList() ?? new List();
+ }
+
+ ///
+ /// A year filter is the department's numbering year, so "2027" lists the reports numbered RUN-2027-* (or the department's
+ /// own pattern) even when its fiscal 2027 started in November 2026, and a call on a US December 31 evening stays in its year.
+ ///
+ private async Task WithYearWindowAsync(int departmentId, RmsIncidentReportQuery query)
+ {
+ if (query.Year.HasValue && !query.YearFrom.HasValue && !query.YearBefore.HasValue)
+ {
+ var config = await _settings.GetRecordsNumberingConfigAsync(departmentId);
+ var timeZone = (await _departments.GetDepartmentByIdAsync(departmentId, false))?.TimeZone;
+ var window = RecordsNumberingService.YearWindow(config, query.Year.Value, timeZone);
+ query.YearFrom = window.FromUtc;
+ query.YearBefore = window.BeforeUtc;
+ }
+ return query;
}
public Task RecordAccessAsync(int departmentId, string userId, string reportId, string revisionId, RmsAccessAuditAction action, string purpose = null, string ipAddress = null)
diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs
index 6f810a6b0..d3f1a2535 100644
--- a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs
+++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs
@@ -174,11 +174,11 @@ public async Task GetAsync(int departmentId, string userId
return row?.DeletedOn == null ? ProjectRequest(row, restricted) : null;
}
- public async Task> QueryAsync(int departmentId, string userId, IEnumerable states, int skip = 0, int take = 50)
+ public async Task> QueryAsync(int departmentId, string userId, IEnumerable states, int skip = 0, int take = 50, string number = null)
{
await RequireDisclosureAsync(departmentId, userId);
var stateValues = states?.Select(s => (int)s).ToList();
- var rows = (await _requests.GetForDepartmentAsync(departmentId, stateValues, skip, take))?.ToList() ?? new List();
+ var rows = (await _requests.GetForDepartmentAsync(departmentId, stateValues, IdentifierSearchKeys.FilterSearch(number), skip, take))?.ToList() ?? new List();
await _protection.RevealDisclosureRequestsAsync(departmentId, rows);
var restricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords);
await RequireDisclosureAsync(departmentId, userId);
diff --git a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs
index a30eae242..f9a7f489f 100644
--- a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs
+++ b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs
@@ -80,13 +80,20 @@ private static void RequireOpen(RmsInvestigationCase investigation)
#region Cases
- public async Task> ListMyCasesAsync(int departmentId, string userId, bool includeClosed)
+ public async Task> ListMyCasesAsync(int departmentId, string userId, bool includeClosed, string number = null)
{
await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Investigations);
await _gate.RequireRestrictedAsync(departmentId, userId);
var memberships = (await _members.GetActiveForUserAsync(departmentId, userId))?.Select(m => m.RmsInvestigationCaseId).Distinct().ToList() ?? new List();
if (memberships.Count == 0) return new List();
var cases = ((await _cases.GetByIdsAsync(departmentId, memberships)) ?? Enumerable.Empty()).Where(c => includeClosed || !c.IsClosed).ToList();
+ number = IdentifierSearchKeys.FilterSearch(number);
+ if (number != null && cases.Count > 0)
+ {
+ // A case is found by its own number or by the number of any evidence item it holds (EV-2026-0042 finds its case).
+ var byEvidence = new HashSet((await _evidence.GetCaseIdsWithEvidenceNumberAsync(departmentId, cases.Select(c => c.RmsInvestigationCaseId), number)) ?? Enumerable.Empty(), StringComparer.Ordinal);
+ cases = cases.Where(c => IdentifierSearchKeys.ContainsParts(c.CaseNumber, number) || byEvidence.Contains(c.RmsInvestigationCaseId)).ToList();
+ }
await _protection.RevealInvestigationCasesAsync(departmentId, cases);
return cases;
}
diff --git a/Core/Resgrid.Services/Records/RecordsNumberingService.cs b/Core/Resgrid.Services/Records/RecordsNumberingService.cs
index 0326b5a6d..67af06b81 100644
--- a/Core/Resgrid.Services/Records/RecordsNumberingService.cs
+++ b/Core/Resgrid.Services/Records/RecordsNumberingService.cs
@@ -12,6 +12,10 @@ namespace Resgrid.Services.Records
{
public class RecordsNumberingService : IRecordsNumberingService
{
+ /// The years a numbering-year window is built for; the window of a year next to DateTime's ends would overflow it.
+ private const int FirstWindowYear = 1000;
+ private const int LastWindowYear = 9000;
+
private readonly IDepartmentSettingsService _settings;
private readonly IRmsOperationalRecordsRepository _records;
private readonly IDepartmentGroupsService _groups;
@@ -50,6 +54,52 @@ public static int NumberingYear(RecordsNumberingConfig config, DateTime utcDate,
return (config ?? new RecordsNumberingConfig()).YearStart().YearOf(local);
}
+ ///
+ /// The UTC window of the numbering year named : department-local midnight of its first day up to the
+ /// next year's. A year filter over it keeps exactly the records numbers into that year, so
+ /// "2027" lists RUN-2027-* even when the department's fiscal 2027 started in November 2026.
+ ///
+ public static NumberingYearWindow YearWindow(RecordsNumberingConfig config, int year, string timeZone)
+ {
+ // A year no record can be numbered in (0 or 9999 typed into a list filter) is an empty window, so the filter finds
+ // nothing instead of overflowing DateTime.
+ if (year < FirstWindowYear || year > LastWindowYear)
+ {
+ var none = new DateTime(FirstWindowYear, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ return new NumberingYearWindow(year, none, none);
+ }
+
+ var start = (config ?? new RecordsNumberingConfig()).YearStart();
+ timeZone = KnownZone(timeZone);
+ return new NumberingYearWindow(year, LocalToUtc(start.StartOfYear(year), timeZone), LocalToUtc(start.StartOfYear(year + 1), timeZone));
+ }
+
+ ///
+ /// The numbering years that can hold a date of the given UTC calendar years (a department-local or fiscal year straddles
+ /// two calendar years), newest first. The caller keeps the ones a query finds dates in.
+ ///
+ public static List CandidateYearWindows(RecordsNumberingConfig config, IEnumerable calendarYears, string timeZone)
+ {
+ timeZone = KnownZone(timeZone);
+ var years = new SortedSet();
+ // Dates outside these years are bad data; their neighbouring years would overflow DateTime.
+ foreach (var year in (calendarYears ?? Enumerable.Empty()).Where(y => y >= FirstWindowYear && y <= LastWindowYear))
+ {
+ years.Add(NumberingYear(config, new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Utc), timeZone));
+ years.Add(NumberingYear(config, new DateTime(year, 12, 31, 23, 59, 59, DateTimeKind.Utc), timeZone));
+ }
+
+ return years.Reverse().Select(y => YearWindow(config, y, timeZone)).ToList();
+ }
+
+ /// No zone, or one that does not resolve, reads the dates as UTC, as does for no zone.
+ private static string KnownZone(string timeZone) => DateTimeHelpers.IsKnownTimeZone(timeZone) ? timeZone : null;
+
+ private static DateTime LocalToUtc(DateTime local, string timeZone)
+ {
+ return string.IsNullOrWhiteSpace(timeZone) ? DateTime.SpecifyKind(local, DateTimeKind.Utc) : DateTimeHelpers.ConvertToUtc(local, timeZone, true);
+ }
+
public async Task> GetSequencesAsync(int departmentId, RecordsNumberingConfig config, int year)
{
config ??= new RecordsNumberingConfig();
diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs
index 7f0a61118..076b2fa46 100644
--- a/Core/Resgrid.Services/Records/RecordsService.cs
+++ b/Core/Resgrid.Services/Records/RecordsService.cs
@@ -771,18 +771,48 @@ public async Task> GetDuplicateCandidatesAsync(int de
public async Task> QueryAsync(int departmentId, RmsRecordQuery query)
{
if (!string.IsNullOrEmpty(query?.ViewerUserId) && !await _authorization.IsActiveMemberAsync(query.ViewerUserId, departmentId)) return new List();
- return (await _projections.QueryAsync(departmentId, query ?? new RmsRecordQuery()))?.ToList() ?? new List();
+ return (await _projections.QueryAsync(departmentId, await WithYearWindowAsync(departmentId, query ?? new RmsRecordQuery())))?.ToList() ?? new List();
}
public async Task CountAsync(int departmentId, RmsRecordQuery query)
{
if (!string.IsNullOrEmpty(query?.ViewerUserId) && !await _authorization.IsActiveMemberAsync(query.ViewerUserId, departmentId)) return 0;
- return await _projections.CountAsync(departmentId, query ?? new RmsRecordQuery());
+ return await _projections.CountAsync(departmentId, await WithYearWindowAsync(departmentId, query ?? new RmsRecordQuery()));
}
+ /// The department's numbering years that hold a record (department-local, fiscal when it sets one), newest first.
public async Task> GetYearsAsync(int departmentId)
{
- return (await _projections.GetYearsAsync(departmentId))?.ToList() ?? new List();
+ var calendarYears = (await _projections.GetYearsAsync(departmentId))?.ToList() ?? new List();
+ if (calendarYears.Count == 0)
+ return calendarYears;
+
+ var config = await _settings.GetRecordsNumberingConfigAsync(departmentId);
+ var timeZone = (await _departments.GetDepartmentByIdAsync(departmentId, false))?.TimeZone;
+ var windows = RecordsNumberingService.CandidateYearWindows(config, calendarYears, timeZone);
+ return (await _projections.GetNumberingYearsAsync(departmentId, windows))?.ToList() ?? new List();
+ }
+
+ public async Task GetYearWindowAsync(int departmentId, int year)
+ {
+ var config = await _settings.GetRecordsNumberingConfigAsync(departmentId);
+ var timeZone = (await _departments.GetDepartmentByIdAsync(departmentId, false))?.TimeZone;
+ return RecordsNumberingService.YearWindow(config, year, timeZone);
+ }
+
+ ///
+ /// A year filter is the department's numbering year, so "2027" lists the records numbered RUN-2027-* (or the department's
+ /// own pattern) even when its fiscal 2027 started in November 2026, and a record started on a US December 31 evening stays in its year.
+ ///
+ private async Task WithYearWindowAsync(int departmentId, RmsRecordQuery query)
+ {
+ if (query.Year.HasValue && !query.OccurredFrom.HasValue && !query.OccurredBefore.HasValue)
+ {
+ var window = await GetYearWindowAsync(departmentId, query.Year.Value);
+ query.OccurredFrom = window.FromUtc;
+ query.OccurredBefore = window.BeforeUtc;
+ }
+ return query;
}
public async Task> GetChangesSinceAsync(int departmentId, DateTime? since, int take, string sinceId = null)
diff --git a/Core/Resgrid.Services/Search/SearchProjectionService.cs b/Core/Resgrid.Services/Search/SearchProjectionService.cs
index 3dc01c64a..f77f66ece 100644
--- a/Core/Resgrid.Services/Search/SearchProjectionService.cs
+++ b/Core/Resgrid.Services/Search/SearchProjectionService.cs
@@ -2,7 +2,6 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
-using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
@@ -32,9 +31,6 @@ public class SearchProjectionService : ISearchProjectionService
/// Calls carry their dispatch notes and every call note in the full text, so they get more room than other families.
private const int CallSearchTextMax = 32000;
- private static readonly Regex HtmlTags = new Regex("<[^>]+>", RegexOptions.Compiled);
- private static readonly Regex Whitespace = new Regex("\\s+", RegexOptions.Compiled);
-
private readonly ISearchProjectionsRepository _projections;
private readonly IDepartmentDataProtectionService _dataProtection;
private readonly IDeploymentPersonnelRepository _deploymentPersonnel;
@@ -352,7 +348,7 @@ public async Task BuildCallAsync(Call call)
var number = Safe(call.Number);
// Name, type, address and identifiers are operational, not ADP-protected (2026-10-17): projected for every department.
var name = Safe(call.Name);
- var nature = ctx.ProtectedTextAllowed ? Safe(call.NatureOfCall) : null;
+ var nature = ctx.ProtectedTextAllowed ? Strip(Safe(call.NatureOfCall)) : null;
var type = Safe(call.Type);
var address = Safe(call.Address);
var incident = Safe(call.IncidentNumber);
@@ -504,9 +500,9 @@ public async Task BuildContactAsync(Contact contact)
var p = New(contact.DepartmentId, SearchEntityTypes.Contact, contact.ContactId, ctx);
p.Title = Cap(title, TitleMax);
- p.Summary = Cap(Join(" · ", company != null && title != company ? company : null, physical, Safe(contact.Description)), SummaryMax);
+ p.Summary = Cap(Join(" · ", company != null && title != company ? company : null, physical, Strip(Safe(contact.Description))), SummaryMax);
p.Keywords = Cap(Join(" ", Safe(contact.Email), Digits(contact.CellPhoneNumber), Digits(contact.HomePhoneNumber), Digits(contact.OfficePhoneNumber), Digits(contact.FaxPhoneNumber)), KeywordsMax);
- p.SearchText = Cap(Join(" ", middle, other, company, category, physical, mailing, Safe(contact.Email), Safe(contact.OtherInfo), Safe(contact.Website), notes, custom), SearchTextMax);
+ p.SearchText = Cap(Join(" ", middle, other, company, category, physical, mailing, Safe(contact.Email), Strip(Safe(contact.OtherInfo)), Safe(contact.Website), notes, custom), SearchTextMax);
p.Category = contact.ContactType == 1 ? "Company" : "Person";
p.IsActive = true;
p.OccurredOn = DateTime.UtcNow;
@@ -522,7 +518,7 @@ public async Task BuildMessageAsync(Message message)
var ctx = await ContextAsync(message.DepartmentId.Value);
var subject = ctx.ProtectedTextAllowed ? Safe(message.Subject) : null;
- var body = ctx.ProtectedTextAllowed ? Safe(Strip(message.Body)) : null;
+ var body = ctx.ProtectedTextAllowed ? Strip(Safe(message.Body)) : null;
var recipients = new List();
if (!string.IsNullOrWhiteSpace(message.ReceivingUserId))
@@ -552,7 +548,7 @@ public async Task BuildDocumentAsync(Document document)
var ctx = await ContextAsync(document.DepartmentId);
// A document's name is operational (2026-10-17); its description and original file name stay protected text.
var name = Safe(document.Name);
- var description = ctx.ProtectedTextAllowed ? Safe(document.Description) : null;
+ var description = ctx.ProtectedTextAllowed ? Strip(Safe(document.Description)) : null;
var filename = ctx.ProtectedTextAllowed ? Safe(document.Filename) : null;
var p = New(document.DepartmentId, SearchEntityTypes.Document, document.DocumentId.ToString(), ctx);
@@ -578,7 +574,7 @@ public async Task BuildNoteAsync(Note note)
// Notes are not in the protected catalog; title and body are department-authored plain text.
var ctx = await ContextAsync(note.DepartmentId);
var title = Safe(note.Title);
- var body = Safe(Strip(note.Body));
+ var body = Strip(Safe(note.Body));
if (title == null && body == null)
return null;
@@ -1111,13 +1107,12 @@ private static string Digits(string value)
return digits.Length >= 4 ? digits : null;
}
+ /// The readable text of a rich-text column (inbound e-mail calls store the message HTML), or null when none is left.
+ /// Titles, summaries and full text are plain text: the search page encodes them, so stored tags would show as literal markup.
private static string Strip(string html)
{
- if (string.IsNullOrWhiteSpace(html))
- return null;
- var text = HtmlTags.Replace(html, " ");
- text = System.Net.WebUtility.HtmlDecode(text);
- return Whitespace.Replace(text, " ").Trim();
+ var text = StringHelpers.HtmlToPlainText(html);
+ return text.Length == 0 ? null : text;
}
private static string Join(string separator, params string[] parts)
diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.cs
index 178b5ae4d..188975a46 100644
--- a/Core/Resgrid.Services/Search/UnifiedSearchService.cs
+++ b/Core/Resgrid.Services/Search/UnifiedSearchService.cs
@@ -449,23 +449,34 @@ private static UnifiedSearchHit Map(SearchProjection hit, float score, IReadOnly
catch { /* stored by us; a parse failure only loses badges */ }
}
+ // Rows projected before the builders stripped markup still carry the HTML of e-mailed calls and rich-text notes;
+ // hits are plain text (the page and the palette encode them), so the tags would show as literal text.
+ var summary = PlainText(hit.Summary);
return new UnifiedSearchHit
{
EntityType = hit.EntityType,
EntityId = hit.EntityId,
Title = hit.Title,
- Summary = hit.Summary,
+ Summary = summary,
Url = hit.Url,
Score = score,
OccurredOn = hit.OccurredOn,
Category = hit.Category,
Status = hit.Status,
Snippet = snippetTerms == null || snippetTerms.Count == 0 ? null
- : SearchSnippets.Build(hit.SearchText, snippetTerms) ?? SearchSnippets.Build(hit.Summary, snippetTerms),
+ : SearchSnippets.Build(PlainText(hit.SearchText), snippetTerms) ?? SearchSnippets.Build(summary, snippetTerms),
Metadata = metadata
};
}
+ private static string PlainText(string text)
+ {
+ if (string.IsNullOrEmpty(text) || (text.IndexOf('<') < 0 && text.IndexOf('&') < 0))
+ return text;
+ var plain = StringHelpers.HtmlToPlainText(text);
+ return plain.Length == 0 ? null : plain;
+ }
+
private async Task<(List hits, int? total)> FederateRecordsAsync(string text, SearchAccess access, int window, CancellationToken cancellationToken)
{
var principal = access.Principal;
diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs
index 672fa9414..4262b7550 100644
--- a/Core/Resgrid.Services/UnitsService.cs
+++ b/Core/Resgrid.Services/UnitsService.cs
@@ -151,6 +151,9 @@ private void SendUnitVisibilityRefresh(int departmentId)
if (owningUnit != null)
await _recordsCutoverService.Value.EnsureLegacyWriteAllowedAsync(owningUnit.DepartmentId, "UnitsService.SaveUnitLogAsync");
+ // Rich text the unit log views render as HTML: scripts, handlers and frames are never stored.
+ unitLog.Narrative = StringHelpers.SanitizeHtmlForStorage(unitLog.Narrative);
+
var saved = await _unitLogsRepository.SaveOrUpdateAsync(unitLog, cancellationToken);
// ADP write safety net (plan 4.2/19.2, catalog v9). Runs AFTER the save because the AAD
@@ -253,6 +256,7 @@ public async Task GetUnitByIdAsync(int unitId)
// Nobody rides a deleted unit: release its current crew. Its seat definitions, states and logs stay.
await _unitActiveRolesRepository.DeleteActiveRolesByUnitIdAsync(unit.UnitId, cancellationToken);
+ await NotifyActiveRolesUpdatedAsync(unit.DepartmentId, unit.UnitId);
if (_searchProjections != null) await _searchProjections.Value.RemoveAsync(unit.DepartmentId, SearchEntityTypes.Unit, unit.UnitId.ToString(), cancellationToken);
await _limitsService.InvalidateDepartmentsEntityLimitsCache(unit.DepartmentId);
@@ -1006,6 +1010,21 @@ public async Task> GetAllActiveRolesForUnitsByDepartmentIdA
return await _unitActiveRolesRepository.DeleteActiveRolesByUnitIdAsync(unitId, cancellationToken);
}
+ public async Task NotifyActiveRolesUpdatedAsync(int departmentId, int unitId)
+ {
+ if (departmentId <= 0)
+ return;
+
+ try
+ {
+ await _eventAggregator.SendMessageAsync(new UnitActiveRolesUpdatedEvent { DepartmentId = departmentId, UnitId = unitId });
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"Unit active roles update could not be published for department {departmentId}, unit {unitId}.");
+ }
+ }
+
public async Task IsUserQualifiedForUnitRoleAsync(UnitRole role, string userId, int departmentId)
{
// No qualification configured for the seat -> anyone may fill it.
diff --git a/Core/Resgrid.Services/WorkLogsService.cs b/Core/Resgrid.Services/WorkLogsService.cs
index 059f09169..e1d3dff29 100644
--- a/Core/Resgrid.Services/WorkLogsService.cs
+++ b/Core/Resgrid.Services/WorkLogsService.cs
@@ -3,6 +3,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Resgrid.Framework;
using Resgrid.Model;
using Resgrid.Model.Repositories;
using Resgrid.Model.Search;
@@ -117,6 +118,10 @@ public async Task GetCallLogByIdAsync(int callLogId)
await _recordsCutoverService.Value.EnsureLegacyWriteAllowedAsync(log.DepartmentId, "WorkLogsService.SaveLogAsync", log.LoggedByUserId);
log.LoggedOn = DateTime.UtcNow;
+ // Rich text the log views and reports render as HTML: scripts, handlers and frames are never stored.
+ log.Narrative = StringHelpers.SanitizeHtmlForStorage(log.Narrative);
+ log.InitialReport = StringHelpers.SanitizeHtmlForStorage(log.InitialReport);
+ log.Cause = StringHelpers.SanitizeHtmlForStorage(log.Cause);
var savedLog = await _logsRepository.SaveOrUpdateAsync(log, cancellationToken, true);
diff --git a/Core/Resgrid.Services/WorkOrdersService.cs b/Core/Resgrid.Services/WorkOrdersService.cs
index 34b5f27dd..8831732b7 100644
--- a/Core/Resgrid.Services/WorkOrdersService.cs
+++ b/Core/Resgrid.Services/WorkOrdersService.cs
@@ -385,6 +385,7 @@ public async Task ChoicesAsync(ChecklistActor actor)
public async Task ListAsync(ChecklistActor actor, WorkOrderFilter filter)
{
if (filter == null || filter.Page < 0 || filter.Page > 10000 || filter.Status.HasValue && !Enum.IsDefined(filter.Status.Value) || filter.Priority.HasValue && !Enum.IsDefined(filter.Priority.Value) || filter.AssetId != null && !Guid.TryParseExact(filter.AssetId, "D", out _) || filter.ChecklistCompletionId != null && !Guid.TryParseExact(filter.ChecklistCompletionId, "D", out _)) throw new WorkOrderException(400, "InvalidInput");
+ filter.Number = IdentifierSearchKeys.FilterSearch(filter.Number);
var scope = await _authorization.ScopeAsync(actor); var rows = await _store.ListAsync(actor.DepartmentId, scope, filter);
var page = new WorkOrderPage { HasMore = rows.Count > 50, CanWrite = await _access.CanUseMaintenanceAsync(actor.DepartmentId) };
foreach (var row in rows.Take(50))
diff --git a/Core/Resgrid.Services/WorkShiftsService.cs b/Core/Resgrid.Services/WorkShiftsService.cs
index fc21904ed..73f4b6a79 100644
--- a/Core/Resgrid.Services/WorkShiftsService.cs
+++ b/Core/Resgrid.Services/WorkShiftsService.cs
@@ -43,6 +43,8 @@ public WorkShiftsService(IWorkshiftsRepository workshiftsRepository, IWorkshiftD
workshift.AddedOn = DateTime.UtcNow;
workshift.Start = workshift.Start.ToUniversalTime();
workshift.End = workshift.End.ToUniversalTime();
+ // Rich text the workshift views render as HTML: scripts, handlers and frames are never stored.
+ workshift.Description = StringHelpers.SanitizeHtmlForStorage(workshift.Description);
var savedWorkItem = await _workshiftsRepository.SaveOrUpdateAsync(workshift, cancellationToken, true);
@@ -76,6 +78,8 @@ public WorkShiftsService(IWorkshiftsRepository workshiftsRepository, IWorkshiftD
workshift.Start = workshift.Start.ToUniversalTime();
workshift.End = workshift.End.ToUniversalTime();
+ // Rich text the workshift views render as HTML: scripts, handlers and frames are never stored.
+ workshift.Description = StringHelpers.SanitizeHtmlForStorage(workshift.Description);
var savedWorkItem = await _workshiftsRepository.SaveOrUpdateAsync(workshift, cancellationToken, true);
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs
index 2031f2273..5b3116230 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs
@@ -32,6 +32,7 @@ public class RabbitInboundEventProvider : IRabbitInboundEventProvider
public Func ProcessChecklistEvent;
public Func ProcessSessionEvent;
public Func ProcessUnitStatusAlertEvent;
+ public Func ProcessUnitActiveRolesEvent;
public Func ProcessOutstandingRequirementsEvent;
public async Task Start(string clientName, string queueName)
@@ -182,6 +183,9 @@ await _channel.QueueBindAsync(queue: queue.QueueName,
case EventingTypes.UnitStatusAlertUpdated:
if (ProcessUnitStatusAlertEvent != null) await ProcessUnitStatusAlertEvent.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId);
break;
+ case EventingTypes.UnitActiveRolesUpdated:
+ if (ProcessUnitActiveRolesEvent != null) await ProcessUnitActiveRolesEvent.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId);
+ break;
case EventingTypes.OutstandingRequirementsUpdated:
if (ProcessOutstandingRequirementsEvent != null) await ProcessOutstandingRequirementsEvent.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId, eventingMessage.Payload);
break;
@@ -258,6 +262,8 @@ public void RegisterForEvents(Func personnelStatusChanged,
public void RegisterForUnitStatusAlertEvents(Func unitStatusAlertEvent) => ProcessUnitStatusAlertEvent = unitStatusAlertEvent;
+ public void RegisterForUnitActiveRolesEvents(Func unitActiveRolesEvent) => ProcessUnitActiveRolesEvent = unitActiveRolesEvent;
+
public void RegisterForOutstandingRequirementsEvents(Func outstandingRequirementsEvent) => ProcessOutstandingRequirementsEvent = outstandingRequirementsEvent;
public void RegisterForChatEvents(Func chatEvent)
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs
index 008e3a2af..23d359bd4 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs
@@ -91,6 +91,11 @@ public async Task CallUpdated(CallUpdatedEvent message)
Id = Guid.NewGuid(), Type = (int)EventingTypes.UnitStatusAlertUpdated, TimeStamp = DateTime.UtcNow, DepartmentId = departmentId, ItemId = unitId.ToString()
}.SerializeJson());
+ public Task UnitActiveRolesUpdated(int departmentId, int unitId) => SendMessage(Topics.EventingTopic, new EventingMessage
+ {
+ Id = Guid.NewGuid(), Type = (int)EventingTypes.UnitActiveRolesUpdated, TimeStamp = DateTime.UtcNow, DepartmentId = departmentId, ItemId = unitId.ToString()
+ }.SerializeJson());
+
public Task OutstandingRequirementsUpdated(int departmentId, int callId, string payload) => SendMessage(Topics.EventingTopic, new EventingMessage
{
Id = Guid.NewGuid(), Type = (int)EventingTypes.OutstandingRequirementsUpdated, TimeStamp = DateTime.UtcNow, DepartmentId = departmentId, ItemId = callId.ToString(), Payload = payload
diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs
index 8f5bb99cc..db8c49392 100644
--- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs
+++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs
@@ -69,6 +69,7 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
_eventAggregator.AddAsyncListener(unitLocationUpdatedTopicHandler);
_eventAggregator.AddListener(chatEventTopicHandler);
_eventAggregator.AddAsyncListener(unitStatusAlertUpdatedTopicHandler);
+ _eventAggregator.AddAsyncListener(unitActiveRolesUpdatedTopicHandler);
_eventAggregator.AddAsyncListener(outstandingRequirementsUpdatedTopicHandler);
_eventAggregator.AddAsyncListener(async message =>
{
@@ -713,6 +714,15 @@ await _outboundQueueProvider.EnqueueNotification(new NotificationItem
await _rabbitTopicProvider.UnitStatusAlertUpdated(message.DepartmentId, message.UnitId);
};
+ public Func unitActiveRolesUpdatedTopicHandler = async delegate (UnitActiveRolesUpdatedEvent message)
+ {
+ if (_rabbitTopicProvider == null)
+ _rabbitTopicProvider = new RabbitTopicProvider();
+
+ if (!await _rabbitTopicProvider.UnitActiveRolesUpdated(message.DepartmentId, message.UnitId))
+ Framework.Logging.LogError($"Unable to publish the unit active roles realtime update for department {message.DepartmentId}.");
+ };
+
public Func outstandingRequirementsUpdatedTopicHandler = async delegate (OutstandingRequirementsUpdatedEvent message)
{
if (_rabbitTopicProvider == null)
diff --git a/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs
index ca6e29aca..c5cad1328 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/ContractorRepositories.cs
@@ -3,6 +3,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Dapper;
using Resgrid.Model.Invoicing;
using Resgrid.Model.Repositories;
using Resgrid.Model.Repositories.Connection;
@@ -150,14 +151,24 @@ public BidRepository(IConnectionProvider connectionProvider, SqlConfiguration sq
public Task GetByIdForDepartmentAsync(string bidId, int departmentId) =>
QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("Bids")} WHERE {Col("BidId")} = {P}Id AND {Col("DepartmentId")} = {P}DepartmentId", new { Id = bidId, DepartmentId = departmentId });
- public Task> GetForDepartmentAsync(int departmentId, int? status, int skip, int take) =>
- QueryAsync(
- $"SELECT * FROM {Tbl("Bids")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (status.HasValue ? $" AND {Col("Status")} = {P}Status" : string.Empty) +
- $" ORDER BY {Col("BidNumber")} DESC {Paging()}",
- new { DepartmentId = departmentId, Status = status ?? 0, Skip = Math.Max(0, skip), Take = Math.Clamp(take, 1, 500) });
+ public Task> GetForDepartmentAsync(int departmentId, int? status, string number, int skip, int take)
+ {
+ var parameters = new DynamicParameters(new { DepartmentId = departmentId, Status = status ?? 0, Skip = Math.Max(0, skip), Take = Math.Clamp(take, 1, 500) });
+ return QueryAsync($"SELECT * FROM {Tbl("Bids")} WHERE {DepartmentFilter(status, number, parameters)} ORDER BY {Col("BidNumber")} DESC {Paging()}", parameters);
+ }
- public Task CountForDepartmentAsync(int departmentId, int? status) =>
- ScalarAsync($"SELECT COUNT(*) FROM {Tbl("Bids")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (status.HasValue ? $" AND {Col("Status")} = {P}Status" : string.Empty), new { DepartmentId = departmentId, Status = status ?? 0 });
+ public Task CountForDepartmentAsync(int departmentId, int? status, string number)
+ {
+ var parameters = new DynamicParameters(new { DepartmentId = departmentId, Status = status ?? 0 });
+ return ScalarAsync($"SELECT COUNT(*) FROM {Tbl("Bids")} WHERE {DepartmentFilter(status, number, parameters)}", parameters);
+ }
+
+ private string DepartmentFilter(int? status, string number, DynamicParameters parameters)
+ {
+ var where = $"{Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False}" + (status.HasValue ? $" AND {Col("Status")} = {P}Status" : string.Empty);
+ var numberMatch = NumberContains($"COALESCE({Col("DisplayNumber")}, {IntegerAsText(Col("BidNumber"))})", number, parameters);
+ return numberMatch == null ? where : where + " AND " + numberMatch;
+ }
public Task> GetByContactIdAsync(int departmentId, string contactId, int skip, int take) =>
QueryAsync($"SELECT * FROM {Tbl("Bids")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ContactId")} = {P}ContactId AND {Col("IsDeleted")} = {False} ORDER BY {Col("BidNumber")} DESC {Paging()}",
diff --git a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs
index 1789c6733..8ffa4fc9c 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs
@@ -171,6 +171,9 @@ private string FilterSql(InvoiceListFilter filter, DynamicParameters parameters)
sql.Append($" AND {Col("IssuedOn")} < {P}IssuedTo");
parameters.Add("IssuedTo", filter.IssuedToUtc.Value);
}
+ var number = NumberContains($"COALESCE({Col("DisplayNumber")}, {IntegerAsText(Col("InvoiceNumber"))})", filter.Number, parameters);
+ if (number != null)
+ sql.Append(" AND ").Append(number);
return sql.ToString();
}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs
index abc11e8de..cdafcd7c6 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs
@@ -95,9 +95,22 @@ public Task> GetRetentionCandidatesAsync(int depa
where.Append($" AND {InList("State", "States", R)}");
parameters.Add("States", InListValue(query.States));
}
- if (query.Year.HasValue)
+ if (query.YearFrom.HasValue || query.YearBefore.HasValue)
{
- where.Append($" AND {YearOf(C("CreatedOn"))} = {P}Year");
+ if (query.YearFrom.HasValue)
+ {
+ where.Append($" AND COALESCE({C("CallCreatedOn")}, {C("CreatedOn")}) >= {P}YearFrom");
+ parameters.Add("YearFrom", DatabaseTimestamp(query.YearFrom.Value), System.Data.DbType.DateTime2);
+ }
+ if (query.YearBefore.HasValue)
+ {
+ where.Append($" AND COALESCE({C("CallCreatedOn")}, {C("CreatedOn")}) < {P}YearBefore");
+ parameters.Add("YearBefore", DatabaseTimestamp(query.YearBefore.Value), System.Data.DbType.DateTime2);
+ }
+ }
+ else if (query.Year.HasValue)
+ {
+ where.Append($" AND {YearOf($"COALESCE({C("CallCreatedOn")}, {C("CreatedOn")})")} = {P}Year");
parameters.Add("Year", query.Year.Value);
}
if (query.CallId.HasValue)
@@ -177,10 +190,15 @@ public Task CountAsync(int departmentId, RmsIncidentReportQuery query)
public Task> GetYearsAsync(int departmentId)
{
return QueryAsync(
- $"SELECT DISTINCT {YearOf(Col("CreatedOn"))} AS Y FROM {Tbl("RmsIncidentReports")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL ORDER BY Y DESC",
+ $"SELECT DISTINCT {YearOf($"COALESCE({Col("CallCreatedOn")}, {Col("CreatedOn")})")} AS Y FROM {Tbl("RmsIncidentReports")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL ORDER BY Y DESC",
new { DepartmentId = departmentId });
}
+ public Task> GetNumberingYearsAsync(int departmentId, IReadOnlyList windows)
+ {
+ return NumberingYearsAsync("RmsIncidentReports", $"COALESCE({Col("CallCreatedOn")}, {Col("CreatedOn")})", departmentId, windows);
+ }
+
public Task GetMaxRecordNumberSequenceAsync(int departmentId, string numberPrefix, string numberSuffix)
{
return GetMaxRecordNumberSequenceAcrossRecordsAsync(departmentId, numberPrefix, numberSuffix);
diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs
index 472489083..94714d388 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs
@@ -54,8 +54,11 @@ private string Where(RmsOccupancyQuery query, DynamicParameters p)
if (query.ReviewOverdue == true) { clauses.Add($"{Col("NextReviewDue")} < {P}Now"); p.Add("Now", DateTime.UtcNow); }
if (!string.IsNullOrWhiteSpace(query.Search))
{
- clauses.Add($"({Col("Name")} LIKE {P}Search OR {Col("AddressText")} LIKE {P}Search OR {Col("OccupancyNumber")} LIKE {P}Search)");
- p.Add("Search", "%" + query.Search.Trim() + "%");
+ // Escaped: '_' is a literal in department number patterns (OCC_2026_0001), not a wildcard. Case-insensitive on
+ // PostgreSQL too (varchar columns), as on SQL Server's default collation: "occ-2026" finds OCC-2026-0001.
+ var like = IsPostgres ? "ILIKE" : "LIKE";
+ clauses.Add($"({Col("Name")} {like} {P}Search ESCAPE '!' OR {Col("AddressText")} {like} {P}Search ESCAPE '!' OR {Col("OccupancyNumber")} {like} {P}Search ESCAPE '!')");
+ p.Add("Search", "%" + Queries.Calls.SearchCallsQuery.EscapeLike(query.Search.Trim()) + "%");
}
return string.Join(" AND ", clauses);
}
@@ -265,6 +268,8 @@ private string Where(RmsInspectionQuery query, DynamicParameters p)
if (!string.IsNullOrWhiteSpace(query.InspectorUserId)) { clauses.Add($"{Col("InspectorUserId")} = {P}Inspector"); p.Add("Inspector", query.InspectorUserId); }
if (query.States != null && query.States.Count > 0) { clauses.Add(InList("State", "States")); p.Add("States", InListValue(query.States)); }
if (query.ScheduledBefore.HasValue) { clauses.Add($"{Col("ScheduledOn")} <= {P}Before"); p.Add("Before", query.ScheduledBefore.Value); }
+ var number = NumberContains(Col("InspectionNumber"), query.Number, p);
+ if (number != null) clauses.Add(number);
return string.Join(" AND ", clauses);
}
@@ -483,6 +488,8 @@ private string Where(RmsPermitQuery query, DynamicParameters p)
if (!string.IsNullOrWhiteSpace(query.PermitTypeId)) { clauses.Add($"{Col("RmsPermitTypeId")} = {P}TypeId"); p.Add("TypeId", query.PermitTypeId); }
if (query.States != null && query.States.Count > 0) { clauses.Add(InList("State", "States")); p.Add("States", InListValue(query.States)); }
if (query.ExpiresBefore.HasValue) { clauses.Add($"{Col("ExpiresOn")} <= {P}Before"); p.Add("Before", query.ExpiresBefore.Value); }
+ var number = NumberContains(Col("PermitNumber"), query.Number, p);
+ if (number != null) clauses.Add(number);
return string.Join(" AND ", clauses);
}
@@ -717,6 +724,17 @@ public Task GetByIdForDepartmentAsync(int departmentId
public Task> GetForCaseAsync(int departmentId, string caseId)
=> QueryAsync($"SELECT * FROM {Tbl("RmsInvestigationEvidence")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsInvestigationCaseId")} = {P}Id AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CollectedOn")}", new { DepartmentId = departmentId, Id = caseId });
+
+ public Task> GetCaseIdsWithEvidenceNumberAsync(int departmentId, IEnumerable caseIds, string number)
+ {
+ var ids = InListValue(caseIds);
+ var p = new DynamicParameters();
+ var match = NumberContains(Col("EvidenceNumber"), number, p);
+ if (ids.Length == 0 || match == null) return Task.FromResult>(new List());
+ p.Add("DepartmentId", departmentId);
+ p.Add("Ids", ids);
+ return QueryAsync($"SELECT DISTINCT {Col("RmsInvestigationCaseId")} FROM {Tbl("RmsInvestigationEvidence")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsInvestigationCaseId", "Ids")} AND {Col("DeletedOn")} IS NULL AND {match}", p);
+ }
}
public class RmsInvestigationCustodyRepository : RmsRepositoryBase, IRmsInvestigationCustodyRepository
diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs
index e45088fc6..44c65944a 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs
@@ -186,6 +186,53 @@ protected static string YearOf(string columnExpression)
return IsPostgres ? $"EXTRACT(YEAR FROM {columnExpression})::int" : $"YEAR({columnExpression})";
}
+ ///
+ /// A list filter on a department-formatted number: holds the search's letter and digit
+ /// runs in that order, ignoring case (IdentifierSearchKeys.ContainsParts), so "WO-26-0007", "26 0007" or "0007" find
+ /// WO26-0007 whatever separators the pattern uses. The runs hold only letters and digits, so nothing in them is a LIKE
+ /// wildcard. Null when the search has no letters or digits: the caller adds no clause.
+ ///
+ protected string NumberContains(string numberExpression, string search, DynamicParameters parameters, string name = "NumberSearch")
+ {
+ var parts = IdentifierSearchKeys.TypedParts(search);
+ if (parts.Count == 0)
+ return null;
+
+ parameters.Add(name, "%" + string.Join("%", parts) + "%");
+ return $"{numberExpression} {(IsPostgres ? "ILIKE" : "LIKE")} {P}{name}";
+ }
+
+ /// An integer column as text, for a number filter over rows that predate their stored DisplayNumber.
+ protected static string IntegerAsText(string columnExpression)
+ {
+ return IsPostgres ? $"CAST({columnExpression} AS text)" : $"CAST({columnExpression} AS nvarchar(20))";
+ }
+
+ ///
+ /// The numbering years among that falls in for some live row
+ /// of the department, newest first: a department-local or fiscal year is a window, not a YEAR() of the stored UTC date.
+ ///
+ protected Task> NumberingYearsAsync(string table, string dateExpression, int departmentId, IReadOnlyList windows)
+ {
+ if (windows == null || windows.Count == 0)
+ return Task.FromResult>(new List());
+
+ var parameters = new DynamicParameters();
+ parameters.Add("DepartmentId", departmentId);
+ var year = new StringBuilder("CASE");
+ for (var i = 0; i < windows.Count; i++)
+ {
+ year.Append($" WHEN {dateExpression} >= {P}YearFrom{i} AND {dateExpression} < {P}YearBefore{i} THEN {windows[i].Year.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
+ parameters.Add("YearFrom" + i, DatabaseTimestamp(windows[i].FromUtc), System.Data.DbType.DateTime2);
+ parameters.Add("YearBefore" + i, DatabaseTimestamp(windows[i].BeforeUtc), System.Data.DbType.DateTime2);
+ }
+ year.Append(" END");
+
+ return QueryAsync(
+ $"SELECT DISTINCT y FROM (SELECT {year} AS y FROM {Tbl(table)} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DeletedOn")} IS NULL) w WHERE y IS NOT NULL ORDER BY y DESC",
+ parameters);
+ }
+
protected static string Paging()
{
return IsPostgres ? "OFFSET @Skip LIMIT @Take" : "OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY";
@@ -1058,6 +1105,11 @@ public Task> GetYearsAsync(int departmentId)
new { DepartmentId = departmentId });
}
+ public Task> GetNumberingYearsAsync(int departmentId, IReadOnlyList windows)
+ {
+ return NumberingYearsAsync("RmsRecordSearchProjections", $"COALESCE({Col("OccurredOn")}, {Col("RecordCreatedOn")})", departmentId, windows);
+ }
+
///
/// Visibility is a join, never a post-filter, so paging counts cannot leak (plan section 5.7.1).
/// The always-visible cases (author, owner, reviewer, named participant) resolve inside the query.
@@ -1133,7 +1185,20 @@ public async Task> GetByIdsAsync(int depa
parameters.Add("DefinitionKey", query.DefinitionKey);
}
- if (query.Year.HasValue)
+ if (query.OccurredFrom.HasValue || query.OccurredBefore.HasValue)
+ {
+ if (query.OccurredFrom.HasValue)
+ {
+ sb.Append($" AND COALESCE(p.{Col("OccurredOn")}, p.{Col("RecordCreatedOn")}) >= {P}OccurredFrom");
+ parameters.Add("OccurredFrom", DatabaseTimestamp(query.OccurredFrom.Value), System.Data.DbType.DateTime2);
+ }
+ if (query.OccurredBefore.HasValue)
+ {
+ sb.Append($" AND COALESCE(p.{Col("OccurredOn")}, p.{Col("RecordCreatedOn")}) < {P}OccurredBefore");
+ parameters.Add("OccurredBefore", DatabaseTimestamp(query.OccurredBefore.Value), System.Data.DbType.DateTime2);
+ }
+ }
+ else if (query.Year.HasValue)
{
sb.Append($" AND {YearOf($"COALESCE(p.{Col("OccurredOn")}, p.{Col("RecordCreatedOn")})")} = {P}Year");
parameters.Add("Year", query.Year.Value);
@@ -1453,7 +1518,7 @@ public Task GetByIdForDepartmentAsync(int departmentId, st
new { DepartmentId = departmentId, RequestId = requestId });
}
- public Task> GetForDepartmentAsync(int departmentId, IEnumerable states, int skip, int take)
+ public Task