From 045c3efe64d476f83d2da9d6fc437c687becdb5b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 23 Oct 2026 13:22:15 -0700 Subject: [PATCH 1/3] RG-T148 Search and HTML Fixes, Custom Number Ordering, Dispatch fixes --- Core/Resgrid.Framework/StringHelpers.cs | 184 ++++- .../ContractorBilling.ar.resx | 1 + .../ContractorBilling.de.resx | 1 + .../ContractorBilling.el.resx | 1 + .../ContractorBilling.en.resx | 1 + .../ContractorBilling.es.resx | 1 + .../ContractorBilling.fr.resx | 1 + .../ContractorBilling.it.resx | 1 + .../ContractorBilling.pl.resx | 1 + .../ContractorBilling/ContractorBilling.resx | 1 + .../ContractorBilling.sv.resx | 1 + .../ContractorBilling.uk.resx | 1 + .../Areas/User/Dispatch/Call.ar.resx | 25 +- .../Areas/User/Dispatch/Call.de.resx | 25 +- .../Areas/User/Dispatch/Call.el.resx | 25 +- .../Areas/User/Dispatch/Call.en.resx | 25 +- .../Areas/User/Dispatch/Call.es.resx | 25 +- .../Areas/User/Dispatch/Call.fr.resx | 25 +- .../Areas/User/Dispatch/Call.it.resx | 25 +- .../Areas/User/Dispatch/Call.pl.resx | 25 +- .../Areas/User/Dispatch/Call.sv.resx | 25 +- .../Areas/User/Dispatch/Call.uk.resx | 25 +- .../Areas/User/Inventory/Inventory.ar.resx | 8 + .../Areas/User/Inventory/Inventory.de.resx | 8 + .../Areas/User/Inventory/Inventory.el.resx | 8 + .../Areas/User/Inventory/Inventory.en.resx | 8 + .../Areas/User/Inventory/Inventory.es.resx | 8 + .../Areas/User/Inventory/Inventory.fr.resx | 8 + .../Areas/User/Inventory/Inventory.it.resx | 8 + .../Areas/User/Inventory/Inventory.pl.resx | 8 + .../Areas/User/Inventory/Inventory.sv.resx | 8 + .../Areas/User/Inventory/Inventory.uk.resx | 8 + Core/Resgrid.Model/CallNumbering.cs | 79 ++ Core/Resgrid.Model/IdentifierSearchKeys.cs | 145 ++++ Core/Resgrid.Model/NumberingYear.cs | 20 + .../Records/RecordsSearchContracts.cs | 8 +- .../Repositories/IContractorRepositories.cs | 5 +- .../Repositories/IInvoicingRepositories.cs | 2 + .../Repositories/IRmsIncidentRepositories.cs | 11 + .../IRmsPreventionRepositories.cs | 6 + .../Repositories/IRmsRepositories.cs | 14 +- Core/Resgrid.Model/Search/SearchContracts.cs | 3 +- Core/Resgrid.Model/Services/IBidsService.cs | 3 + .../Services/IIncidentReportsService.cs | 1 + .../Services/IRecordsDisclosureService.cs | 3 +- .../Services/IRecordsInvestigationsService.cs | 3 +- .../Resgrid.Model/Services/IRecordsService.cs | 7 + .../Services/IWorkOrdersService.cs | 2 + Core/Resgrid.Search/GlobalIndexFields.cs | 2 + .../GlobalSearchDocumentBuilder.cs | 18 + .../LuceneGlobalSearchService.cs | 5 + .../LuceneRecordsSearchService.cs | Bin 8064 -> 8843 bytes Core/Resgrid.Search/RecordsIndexFields.cs | 2 + .../RecordsSearchDocumentBuilder.cs | 4 + Core/Resgrid.Services/CalendarService.cs | 4 + Core/Resgrid.Services/ContactsService.cs | 6 + .../InventoryReportDocuments.cs | 1 + Core/Resgrid.Services/InventoryService.cs | 2 + .../Resgrid.Services/Invoicing/BidsService.cs | 11 +- Core/Resgrid.Services/NearestUnitService.cs | 13 +- Core/Resgrid.Services/ProtocolsService.cs | 5 + .../Records/IncidentReportsService.cs | 31 +- .../Records/RecordsDisclosureService.cs | 4 +- .../Records/RecordsInvestigationsService.cs | 9 +- .../Records/RecordsNumberingService.cs | 38 + .../Records/RecordsService.cs | 36 +- .../Search/SearchProjectionService.cs | 25 +- .../Search/UnifiedSearchService.cs | 15 +- Core/Resgrid.Services/UnitsService.cs | 3 + Core/Resgrid.Services/WorkLogsService.cs | 5 + Core/Resgrid.Services/WorkOrdersService.cs | 1 + Core/Resgrid.Services/WorkShiftsService.cs | 4 + .../ContractorRepositories.cs | 25 +- .../InvoicingRepositories.cs | 3 + .../RmsIncidentRepositories.cs | 24 +- .../RmsPreventionRepositories.cs | 22 +- .../RmsRepositories.cs | 72 +- .../WorkOrderRepository.cs | 2 + .../Framework/StringHelperTests.cs | 98 +++ .../Localization/RazorOutputEncodingTests.cs | 4 +- .../Repositories/NumberFilterDatabaseTests.cs | 194 +++++ Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs | 10 + Tests/Resgrid.Tests/Rms/FakeRmsStore.cs | 5 +- .../Rms/IncidentReportsServiceTests.cs | 34 + .../Resgrid.Tests/Rms/RecordNumberingTests.cs | 35 + .../Rms/RecordsDisclosureServiceTests.cs | 14 + .../RecordsHydrantsAndPermitsServiceTests.cs | 20 + .../Rms/RecordsInvestigationsServiceTests.cs | 18 + ...cordsSearchIndexMaintenanceServiceTests.cs | 14 +- Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs | 40 + .../Resgrid.Tests/Rms/RecordsServiceTests.cs | 29 + Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs | 6 +- .../Rms/RmsRetentionDatabaseTests.cs | 42 ++ .../Resgrid.Tests/Search/GlobalSearchTests.cs | 21 + .../Search/IdentifierSearchKeysTests.cs | 105 +++ .../Search/SearchProjectionServiceTests.cs | 26 + .../Search/UnifiedSearchHtmlTextTests.cs | 44 ++ .../Audit20261005/CallsUnitsMvcTests.cs | 2 +- .../Security/StoredHtmlRenderingTests.cs | 171 +++++ .../Services/CallNumberingTests.cs | 51 ++ .../Services/ContractorBillingServiceTests.cs | 2 +- .../Services/InventoryM5ReportTests.cs | 15 + .../Services/NearestUnitServiceTests.cs | 23 +- .../Services/WorkOrderDatabaseTests.cs | 22 +- .../Controllers/v4/BidsController.cs | 4 +- .../Controllers/v4/DisclosuresController.cs | 4 +- .../Controllers/v4/InvoicesController.cs | 4 +- .../v4/RecordInspectionsController.cs | 4 +- .../v4/RecordInvestigationsController.cs | 4 +- .../Controllers/v4/RecordPermitsController.cs | 4 +- .../Controllers/v4/RecordsController.cs | 5 +- .../Resgrid.Web.Services.xml | 4 +- .../Areas/User/Controllers/BidsController.cs | 8 +- .../User/Controllers/ContactsController.cs | 4 +- .../User/Controllers/DisclosuresController.cs | 5 +- .../User/Controllers/DispatchController.cs | 24 +- .../User/Controllers/InvoicingController.cs | 6 +- .../Areas/User/Controllers/LinksController.cs | 9 +- .../User/Controllers/MappingController.cs | 2 +- .../User/Controllers/PersonnelController.cs | 6 +- .../User/Controllers/ProtocolsController.cs | 2 +- .../RecordDeploymentsController.cs | 2 +- .../RecordInspectionsController.cs | 6 +- .../RecordInvestigationsController.cs | 5 +- .../Controllers/RecordPermitsController.cs | 6 +- .../User/Controllers/RecordsController.cs | 4 + .../Areas/User/Controllers/UnitsController.cs | 8 +- .../User/Controllers/WorkforceController.cs | 2 +- .../Areas/User/Models/Calls/NewCallView.cs | 5 +- .../ContractorBilling/ContractorViews.cs | 2 + .../User/Models/Dispatch/CallListJson.cs | 3 + .../User/Models/Invoicing/InvoicingViews.cs | 2 + .../Models/Records/DisclosureViewModels.cs | 2 + .../Models/Records/RecordsRms5ViewModels.cs | 6 + .../User/Models/Units/UnitForListJson.cs | 3 + .../Areas/User/Views/Bids/Edit.cshtml | 2 +- .../Areas/User/Views/Bids/Index.cshtml | 15 +- .../Areas/User/Views/CalOesMars/Rate.cshtml | 12 +- .../Areas/User/Views/Calendar/Edit.cshtml | 2 +- .../Areas/User/Views/Calendar/New.cshtml | 2 +- .../Areas/User/Views/Calendar/View.cshtml | 2 +- .../User/Views/Certifications/Settings.cshtml | 2 +- .../Checklists/_ProtectionScripts.cshtml | 2 +- .../Areas/User/Views/Contacts/Add.cshtml | 4 +- .../Areas/User/Views/Contacts/Edit.cshtml | 4 +- .../Areas/User/Views/Contacts/View.cshtml | 16 +- .../Areas/User/Views/Contracts/Edit.cshtml | 4 +- .../User/Views/CustomMaps/RegionEditor.cshtml | 2 +- .../User/Views/CustomStatuses/New.cshtml | 2 +- .../User/Views/DataProtection/Index.cshtml | 36 +- .../User/Views/DeploymentWizard/Index.cshtml | 4 +- .../Areas/User/Views/Disclosures/Index.cshtml | 2 + .../Views/Dispatch/AddArchivedCall.cshtml | 14 +- .../User/Views/Dispatch/CallExport.cshtml | 8 +- .../User/Views/Dispatch/CallExportEx.cshtml | 8 +- .../User/Views/Dispatch/CloseCall.cshtml | 2 +- .../User/Views/Dispatch/Dashboard.cshtml | 26 +- .../User/Views/Dispatch/DeleteCall.cshtml | 2 +- .../User/Views/Dispatch/FlagCallFile.cshtml | 6 +- .../User/Views/Dispatch/FlagCallImage.cshtml | 6 +- .../User/Views/Dispatch/FlagCallNote.cshtml | 6 +- .../Areas/User/Views/Dispatch/NewCall.cshtml | 153 +--- .../User/Views/Dispatch/PendingCalls.cshtml | 2 +- .../User/Views/Dispatch/UpdateCall.cshtml | 18 +- .../Areas/User/Views/Dispatch/ViewCall.cshtml | 12 +- .../Views/Dispatch/_DispatchUnitLists.cshtml | 15 + .../Views/Dispatch/_NearestUnitsBoard.cshtml | 59 +- .../User/Views/Documents/EditDocument.cshtml | 2 +- .../User/Views/Documents/NewDocument.cshtml | 2 +- .../User/Views/Documents/ViewDocument.cshtml | 4 +- .../Areas/User/Views/Groups/Index.cshtml | 2 +- .../User/Views/Home/EditUserProfile.cshtml | 18 +- .../User/Views/IndoorMaps/ZoneEditor.cshtml | 2 +- .../User/Views/Inventory/ViewEntry.cshtml | 6 +- .../Areas/User/Views/Invoicing/Index.cshtml | 8 +- .../Areas/User/Views/Logs/LogExport.cshtml | 8 +- .../Areas/User/Views/Logs/NewLog.cshtml | 8 +- .../Areas/User/Views/Logs/ViewLog.cshtml | 8 +- .../User/Views/Mapping/LiveRouting.cshtml | 2 +- .../User/Views/Mapping/StationRouting.cshtml | 2 +- .../Areas/User/Views/Messages/Compose.cshtml | 2 +- .../User/Views/Messages/ViewMessage.cshtml | 6 +- .../Areas/User/Views/Notes/Edit.cshtml | 2 +- .../Areas/User/Views/Notes/NewNote.cshtml | 2 +- .../Areas/User/Views/Notes/View.cshtml | 2 +- .../User/Views/Profile/Certifications.cshtml | 20 +- .../Areas/User/Views/Protocols/New.cshtml | 4 +- .../Areas/User/Views/Protocols/View.cshtml | 4 +- .../User/Views/RateSchedules/Edit.cshtml | 2 +- .../User/Views/RecordHydrants/Index.cshtml | 2 +- .../User/Views/RecordInspections/Index.cshtml | 3 +- .../Views/RecordInvestigations/Index.cshtml | 2 + .../User/Views/RecordPermits/Index.cshtml | 3 +- .../Views/RecordsAnalytics/Readiness.cshtml | 6 +- .../Reports/FlaggedCallNotesReport.cshtml | 2 +- .../Areas/User/Views/Reports/Index.cshtml | 18 +- .../Areas/User/Views/Reports/LogReport.cshtml | 14 +- .../User/Views/Routes/ArchivedView.cshtml | 6 +- .../Areas/User/Views/Routes/Directions.cshtml | 8 +- .../Areas/User/Views/Routes/Edit.cshtml | 2 +- .../User/Views/Routes/InstanceDetail.cshtml | 2 +- .../Areas/User/Views/Routes/View.cshtml | 6 +- .../Areas/User/Views/Security/Sso.cshtml | 12 +- .../Views/Shared/_AdpRevealScripts.cshtml | 42 +- .../Areas/User/Views/Shifts/Index.cshtml | 2 +- .../Areas/User/Views/Trainings/Edit.cshtml | 4 +- .../Areas/User/Views/Trainings/Index.cshtml | 2 +- .../Areas/User/Views/Trainings/New.cshtml | 4 +- .../Areas/User/Views/Trainings/View.cshtml | 4 +- .../Areas/User/Views/TwoFactor/Index.cshtml | 16 +- .../Areas/User/Views/Units/AddLog.cshtml | 2 +- .../Areas/User/Views/Units/ViewLogs.cshtml | 2 +- .../Areas/User/Views/WorkOrders/Index.cshtml | 8 +- .../Views/WorkOrders/_FilterFields.cshtml | 1 + .../Workforce/CompensationProfile.cshtml | 8 +- .../User/Views/Workforce/ResourceCosts.cshtml | 6 +- .../Areas/User/Views/Workshifts/Edit.cshtml | 2 +- .../Areas/User/Views/Workshifts/New.cshtml | 2 +- .../User/Views/Workshifts/ViewDay.cshtml | 2 +- Web/Resgrid.Web/Helpers/HtmlHelpers.cs | 27 + ....Collections.Specialized.OrderedDictionary | 1 + .../resgrid.common.notifications.js | 8 +- .../internal/connect/resgrid.connect.posts.js | 4 +- .../resgrid.custommaps.regioneditor.js | 6 +- .../resgrid.department.recipientsgrid.js | 6 +- .../resgrid.department.smallstationsgrid.js | 2 +- .../resgrid.dispatch.addArchivedCall.js | 30 +- .../dispatch/resgrid.dispatch.archive.js | 6 +- .../dispatch/resgrid.dispatch.callData.js | 2 +- .../dispatch/resgrid.dispatch.dashboard.js | 10 +- .../dispatch/resgrid.dispatch.editcall.js | 109 +-- .../dispatch/resgrid.dispatch.nearestboard.js | 708 ++++++++++++------ .../dispatch/resgrid.dispatch.newcall.js | 68 +- .../resgrid.dispatch.scheduledcalls.js | 8 +- .../resgrid.dispatch.smallallcallgrid.js | 4 +- .../resgrid.dispatch.smallcallgrid.js | 4 +- .../dispatch/resgrid.dispatch.viewcall.js | 4 +- .../dispatch/resgrid.dispatch.waitingcalls.js | 4 +- .../internal/forms/resgrid.forms.newform.js | 4 +- .../groups/resgrid.groups.editgroup.js | 10 +- .../groups/resgrid.groups.newgroup.js | 10 +- .../resgrid.indoormaps.zoneeditor.js | 4 +- .../inventory/resgrid.inventory.adjust.js | 2 +- .../inventory/resgrid.inventory.byunit.js | 10 +- .../inventory/resgrid.inventory.history.js | 10 +- .../inventory/resgrid.inventory.index.js | 6 +- .../resgrid.inventory.manageTypes.js | 2 +- .../internal/links/resgrid.links.viewdata.js | 26 +- .../app/internal/logs/resgrid.logs.index.js | 10 +- .../messages/resgrid.messages.inbox.js | 4 +- .../messages/resgrid.messages.outbox.js | 2 +- .../resgrid.notifications.addNotification.js | 2 +- .../internal/orders/resgrid.orders.index.js | 4 +- .../personnel/resgrid.personnel.viewevents.js | 8 +- .../resgrid.protocols.newprotocol.js | 12 +- .../shifts/resgrid.shifts.shiftStaffing.js | 4 +- .../types/resgrid.types.listordering.js | 2 +- .../units/resgrid.units.viewevents.js | 8 +- Web/Resgrid.Web/wwwroot/js/app/resgrid.js | 31 + 259 files changed, 3220 insertions(+), 1150 deletions(-) create mode 100644 Core/Resgrid.Model/IdentifierSearchKeys.cs create mode 100644 Tests/Resgrid.Tests/Repositories/NumberFilterDatabaseTests.cs create mode 100644 Tests/Resgrid.Tests/Search/IdentifierSearchKeysTests.cs create mode 100644 Tests/Resgrid.Tests/Search/UnifiedSearchHtmlTextTests.cs create mode 100644 Tests/Resgrid.Tests/Security/StoredHtmlRenderingTests.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Dispatch/_DispatchUnitLists.cshtml create mode 100644 Web/Resgrid.Web/wwwroot/System.Collections.Specialized.OrderedDictionary diff --git a/Core/Resgrid.Framework/StringHelpers.cs b/Core/Resgrid.Framework/StringHelpers.cs index ec477286c..28a0d32c8 100644 --- a/Core/Resgrid.Framework/StringHelpers.cs +++ b/Core/Resgrid.Framework/StringHelpers.cs @@ -79,12 +79,166 @@ public static string SanitizeHtmlInString(string source) if (string.IsNullOrWhiteSpace(source)) return source; + var cleanHtml = SanitizeHtml(source); + if (cleanHtml == null) + { + Logging.LogError("Following string could not be stripped of Word HTML: " + source); + 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]*?(|$)", + 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(@"]*>[\s\S]*?"); // 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 +254,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 +300,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.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/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/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/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 76de9cc9b1383dbe8b77236c86cad8feed5a208b..dde3ea0a22d1829cbc5865016ddf87154bee7264 100644 GIT binary patch delta 746 zcmZWn!EO^V5WTXTOAj1i*aKvx?IuN450t8EOA!$w5$y&Ei34Um+gSD5t?g|ZwN?tBbm8QJ)~5+OW%Dr46?rXe#QVlQp;nnxvSCuo!Toa^LGh#Yt(9tLxCvRn;1Us=~rA z6g6}Vp0#LQF^nz#+UQSB@rKzCOtuJDSM;>oh&`;e!38AqObJ2EwAkgmGiV#~sXAop z7~hBL04IIrHa9|P{`=9&?EkW%%J0yVfM%yCMRj8DHskI^+nE+~sF}26Aq3Xq-RP*y zw!A2$GTeOQM@r7?{`qJ1^F5#P?(K~AME&y>=!p$;FCaY}4W4hVt*1GpboP3^^%S&$ zG~TQeQc|>vLj?MA@HROo?_)@BaSGee=k$?8Ezlg1jB4DcvMu(R(n?TRiy%t8a8ZBA zXG!s7oA{)RekYD#LvwJ!DmVYW(tS#{)Bp6G`EqTR!6oczZ5;{NsNs69A3m7BeEjM! DDh&Jp delta 20 ccmeBnZLr^PLTvJ0NuJGXB{DcByDPZ>09m95y8r+H 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..6e9075815 100644 --- a/Core/Resgrid.Services/Records/RecordsNumberingService.cs +++ b/Core/Resgrid.Services/Records/RecordsNumberingService.cs @@ -50,6 +50,44 @@ 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) + { + 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 >= 1000 && y <= 9000)) + { + 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..e68c61970 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 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/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> GetForDepartmentAsync(int departmentId, IEnumerable states, string number, int skip, int take) { var stateList = states?.ToList(); var parameters = new DynamicParameters(); @@ -1467,6 +1532,9 @@ public Task> GetForDepartmentAsync(int departm where += $" AND {InList("State", "States")}"; parameters.Add("States", InListValue(stateList)); } + var numberMatch = NumberContains(Col("RequestNumber"), number, parameters); + if (numberMatch != null) + where += " AND " + numberMatch; // Oldest deadline first: the request closest to breaching its statutory clock is the one that matters. return QueryAsync( diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs index 0da18ca4c..4c01b9a46 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkOrderRepository.cs @@ -51,6 +51,8 @@ public async Task> ListAsync(int departmentId, WorkOrderReadScop if (filter.AssignedToMe) conditions.Add(AssignmentScope()); foreach (var item in new[] { (filter.Status.HasValue, "Status", "Status"), (filter.Priority.HasValue, "Priority", "Priority"), (filter.UnitId.HasValue, "TargetUnitId", "UnitId"), (filter.GroupId.HasValue, "TargetGroupId", "GroupId"), (filter.AssetId != null, "InventoryAssetId", "AssetId"), (filter.ChecklistCompletionId != null, "SourceChecklistCompletionId", "ChecklistCompletionId") }) if (item.Item1) conditions.Add(Col(item.Item2) + "=" + P + item.Item3); + var number = NumberContains(Col("DisplayNumber"), filter.Number, parameters); + if (number != null) conditions.Add(number); return (await QueryAsync($"SELECT {Cols(Columns())} FROM {Tbl("WorkOrders")} WHERE {string.Join(" AND ", conditions)} ORDER BY {Col("NumberYear")} DESC,{Col("NumberSequence")} DESC {Paging()}", parameters, default)).ToList(); } private string AssignmentScope() diff --git a/Tests/Resgrid.Tests/Framework/StringHelperTests.cs b/Tests/Resgrid.Tests/Framework/StringHelperTests.cs index 589735722..801d2d2a7 100644 --- a/Tests/Resgrid.Tests/Framework/StringHelperTests.cs +++ b/Tests/Resgrid.Tests/Framework/StringHelperTests.cs @@ -200,6 +200,104 @@ public void SanitizeHtmlInString_ShouldKeepAllowedLinkSchemes(string href) result.Should().Be("

link

"); } + [Test] + public void SanitizeHtmlInString_ShouldDropStyleBlocksWithTheirCss() + { + var result = StringHelpers.SanitizeHtmlInString("

Fire at Main

"); + + result.Should().Be("

Fire at Main

"); + } + + [TestCase("

ab

")] + [TestCase("

a

b

")] + [TestCase("ab")] + [TestCase("

a

b

")] + [TestCase("

a

b

")] + [TestCase("

b

")] + public void SanitizeHtmlForDisplay_ShouldRemoveScriptHandlersAndActiveContent(string input) + { + var result = StringHelpers.SanitizeHtmlForDisplay(input); + + result.Should().NotContainAny("script", "alert", "onerror", "onload", "onclick", "iframe", "javascript:", "Just a test!

Shawn
") + .Should().Contain("Just a test!").And.Contain("Shawn").And.NotContain("dir="); + StringHelpers.SanitizeHtmlForDisplay("

The NORA program

").Should().Be("

The NORA program

"); + StringHelpers.SanitizeHtmlForDisplay("").Should().BeEmpty(); + StringHelpers.SanitizeHtmlForDisplay(null).Should().BeEmpty(); + StringHelpers.SanitizeHtmlForDisplay(" ").Should().BeEmpty(); + } + + [TestCase("Structure fire, 2nd floor & attic")] + [TestCase("Pressure 90 > 60")] + [TestCase("Plain text with no markup")] + public void SanitizeHtmlForDisplay_ShouldReturnTextWithoutMarkupUnchanged(string input) + { + StringHelpers.SanitizeHtmlForDisplay(input).Should().Be(input); + } + + [TestCase("pt bp 80", "pt <unconscious> bp 80")] + [TestCase("SpO2 <90, xhi

ok

")] + [TestCase("hi ok")] + [TestCase("hi ok")] + public void SanitizeHtmlForDisplay_ShouldTreatTagsWithAttributesAsMarkup(string input) + { + StringHelpers.SanitizeHtmlForDisplay(input).Should().NotContainAny("alert", "onfocus", "onclick").And.Contain("ok"); + } + + [Test] + public void SanitizeHtml_ShouldKeepNonAsciiTextAsCharacters() + { + // Exports, voice prompts and texts strip tags without decoding entities; "é" would be read out or printed. + var input = "

Incendie à l'école — Пожар 🚒 & ñ

"; + + StringHelpers.SanitizeHtmlForStorage(input).Should().Be("

Incendie à l'école — Пожар 🚒 & ñ

"); + StringHelpers.SanitizeHtmlForDisplay(input).Should().Be("

Incendie à l'école — Пожар 🚒 & ñ

"); + } + + [Test] + public void SanitizeHtmlForStorage_ShouldCleanMarkupAndLeaveTextAndEnvelopesAlone() + { + StringHelpers.SanitizeHtmlForStorage("

Smoke

") + .Should().StartWith("

Smoke").And.NotContainAny("onerror", "alert", "script"); + StringHelpers.SanitizeHtmlForStorage("").Should().BeEmpty(); + StringHelpers.SanitizeHtmlForStorage("pt bp 80").Should().Be("pt bp 80"); + StringHelpers.SanitizeHtmlForStorage("Fire at 5th & Main").Should().Be("Fire at 5th & Main"); + StringHelpers.SanitizeHtmlForStorage(Resgrid.Model.ProtectedDataEnvelope.Prefix + "AAAA").Should().Be(Resgrid.Model.ProtectedDataEnvelope.Prefix + "AAAA"); + StringHelpers.SanitizeHtmlForStorage(null).Should().BeNull(); + + var quill = "

The NORA program

"; + StringHelpers.SanitizeHtmlForStorage(quill).Should().Be(quill); + StringHelpers.SanitizeHtmlForStorage(StringHelpers.SanitizeHtmlForStorage(quill)).Should().Be(quill); + } + + [TestCase("
Just a test!

Shawn
", "Just a test! Shawn")] + [TestCase("
JUST A TEST!

Shawn
", "JUST A TEST! Shawn")] + [TestCase("

Door two sticks

", "Door two sticks")] + [TestCase("

Shawn

Next

", "Shawn Next")] + [TestCase("Fwd

Smoke showing

", "Smoke showing")] + [TestCase("

A & B <c>

", "A & B ")] + [TestCase("Pressure < 90 and x built from Html.Encode'd localized text, or empty (Contacts/Index) "callsCell", // built from an int call count, or empty (Contacts/Index) - "TypeIconHtml.Image(Model.UnitTypes, u.Type)" // whose only value, the icon URL, the helper HTML-encodes (Units/Index, Dispatch/NewCall) + "TypeIconHtml.Image(Model.UnitTypes, u.Type)" // whose only value, the icon URL, the helper HTML-encodes (Units/Index) }; private static readonly HashSet RazorKeywords = new HashSet(StringComparer.Ordinal) diff --git a/Tests/Resgrid.Tests/Repositories/NumberFilterDatabaseTests.cs b/Tests/Resgrid.Tests/Repositories/NumberFilterDatabaseTests.cs new file mode 100644 index 000000000..0b42c7046 --- /dev/null +++ b/Tests/Resgrid.Tests/Repositories/NumberFilterDatabaseTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Moq; +using Npgsql; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Repositories +{ + /// + /// Real-database proof, on both engines, that the list number filters find department-formatted numbers however they are + /// typed (IdentifierSearchKeys.ContainsParts as LIKE / ILIKE): invoices and bids (DisplayNumber, else the plain number), + /// records requests, inspections, permits and investigation evidence. Minimal stand-ins hold just the columns each list query + /// reads; their PostgreSQL types match the shipped ones (citext for M0268 and request numbers, varchar for prevention). + /// Set RESGRID_ADP_SQLSERVER_TEST_CONNECTION / RESGRID_ADP_POSTGRES_TEST_CONNECTION (server-level connections) to run. + /// + [TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable] + public class NumberFilterDatabaseTests(DatabaseTypes type) + { + private const string Prefix = "number_filter_"; + private const int Dept = 31; + private DatabaseTypes _previous; + private string _master, _connection, _database; + + private bool IsPostgres => type == DatabaseTypes.Postgres; + + private DbConnection Connect(string connection) => IsPostgres ? new NpgsqlConnection(connection) : new SqlConnection(connection); + + private IConnectionProvider Connections() + { + var connections = new Mock(); + connections.Setup(c => c.Create()).Returns(() => Connect(_connection)); + return connections.Object; + } + + private SqlConfiguration Configuration() => IsPostgres ? new PostgreSqlConfiguration() : new SqlServerConfiguration(); + + private T Repository(Func create) => create(Connections(), Configuration(), Mock.Of(), Mock.Of()); + + [OneTimeSetUp] + public async Task Create_isolated_database() + { + _master = Environment.GetEnvironmentVariable(IsPostgres ? "RESGRID_ADP_POSTGRES_TEST_CONNECTION" : "RESGRID_ADP_SQLSERVER_TEST_CONNECTION"); + if (string.IsNullOrWhiteSpace(_master)) Assert.Ignore("Set a test connection to run real database checks."); + + if (IsPostgres) AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + + _previous = DataConfig.DatabaseType; + DataConfig.DatabaseType = type; + _database = Prefix + Guid.NewGuid().ToString("N"); + await using (var master = Connect(_master)) + await master.ExecuteAsync("CREATE DATABASE " + _database); + _connection = IsPostgres + ? new NpgsqlConnectionStringBuilder(_master) { Database = _database }.ConnectionString + : new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }.ConnectionString; + + await using var database = Connect(_connection); + await database.OpenAsync(); + await database.ExecuteAsync(IsPostgres + ? @"CREATE EXTENSION IF NOT EXISTS citext; + CREATE TABLE invoices (invoiceid varchar(36) PRIMARY KEY, departmentid int NOT NULL, invoicenumber int NOT NULL, displaynumber citext NULL, status int NOT NULL, isdeleted boolean NOT NULL); + CREATE TABLE bids (bidid varchar(36) PRIMARY KEY, departmentid int NOT NULL, bidnumber int NOT NULL, displaynumber citext NULL, status int NOT NULL, isdeleted boolean NOT NULL); + CREATE TABLE rmsdisclosurerequests (rmsdisclosurerequestid varchar(36) PRIMARY KEY, departmentid int NOT NULL, requestnumber citext NULL, state int NOT NULL, receivedon timestamp NOT NULL, statutorydueon timestamp NULL, deletedon timestamp NULL); + CREATE TABLE rmsinspections (rmsinspectionid varchar(36) PRIMARY KEY, departmentid int NOT NULL, inspectionnumber varchar(32) NULL, state int NOT NULL, scheduledon timestamp NULL, createdon timestamp NOT NULL, deletedon timestamp NULL); + CREATE TABLE rmspermits (rmspermitid varchar(36) PRIMARY KEY, departmentid int NOT NULL, permitnumber varchar(32) NULL, state int NOT NULL, appliedon timestamp NOT NULL, deletedon timestamp NULL); + CREATE TABLE rmsinvestigationevidence (rmsinvestigationevidenceid varchar(36) PRIMARY KEY, departmentid int NOT NULL, rmsinvestigationcaseid varchar(36) NOT NULL, evidencenumber varchar(32) NULL, deletedon timestamp NULL);" + : @"CREATE TABLE Invoices (InvoiceId varchar(36) PRIMARY KEY, DepartmentId int NOT NULL, InvoiceNumber int NOT NULL, DisplayNumber nvarchar(50) NULL, Status int NOT NULL, IsDeleted bit NOT NULL); + CREATE TABLE Bids (BidId varchar(36) PRIMARY KEY, DepartmentId int NOT NULL, BidNumber int NOT NULL, DisplayNumber nvarchar(50) NULL, Status int NOT NULL, IsDeleted bit NOT NULL); + CREATE TABLE RmsDisclosureRequests (RmsDisclosureRequestId varchar(36) PRIMARY KEY, DepartmentId int NOT NULL, RequestNumber nvarchar(50) NULL, State int NOT NULL, ReceivedOn datetime2 NOT NULL, StatutoryDueOn datetime2 NULL, DeletedOn datetime2 NULL); + CREATE TABLE RmsInspections (RmsInspectionId varchar(36) PRIMARY KEY, DepartmentId int NOT NULL, InspectionNumber nvarchar(32) NULL, State int NOT NULL, ScheduledOn datetime2 NULL, CreatedOn datetime2 NOT NULL, DeletedOn datetime2 NULL); + CREATE TABLE RmsPermits (RmsPermitId varchar(36) PRIMARY KEY, DepartmentId int NOT NULL, PermitNumber nvarchar(32) NULL, State int NOT NULL, AppliedOn datetime2 NOT NULL, DeletedOn datetime2 NULL); + CREATE TABLE RmsInvestigationEvidence (RmsInvestigationEvidenceId varchar(36) PRIMARY KEY, DepartmentId int NOT NULL, RmsInvestigationCaseId varchar(36) NOT NULL, EvidenceNumber nvarchar(32) NULL, DeletedOn datetime2 NULL);"); + if (database is NpgsqlConnection npgsql) + await npgsql.ReloadTypesAsync(); + + var now = new DateTime(2026, 10, 1); + // INV2026.0042 and BID_26_0007 are one word to a word analyzer; a pre-M0268 row (no DisplayNumber) reads as its plain number. + await database.ExecuteAsync(("INSERT INTO Invoices (InvoiceId, DepartmentId, InvoiceNumber, DisplayNumber, Status, IsDeleted) VALUES (@Id, @Dept, @Seq, @Number, 0, @Deleted)"), new[] + { + new { Id = "inv-1", Dept, Seq = 42, Number = "INV2026.0042", Deleted = false }, + new { Id = "inv-2", Dept, Seq = 43, Number = "INV2026.0043", Deleted = false }, + new { Id = "inv-3", Dept, Seq = 1042, Number = (string)null, Deleted = false }, + new { Id = "inv-4", Dept, Seq = 44, Number = "INV2026.0044", Deleted = true }, + new { Id = "inv-5", Dept = Dept + 1, Seq = 42, Number = "INV2026.0042", Deleted = false } + }); + await database.ExecuteAsync(("INSERT INTO Bids (BidId, DepartmentId, BidNumber, DisplayNumber, Status, IsDeleted) VALUES (@Id, @Dept, @Seq, @Number, 0, @Deleted)"), new[] + { + new { Id = "bid-1", Dept, Seq = 7, Number = "BID_26_0007", Deleted = false }, + new { Id = "bid-2", Dept, Seq = 8, Number = "BID_26_0008", Deleted = false } + }); + await database.ExecuteAsync(("INSERT INTO RmsDisclosureRequests (RmsDisclosureRequestId, DepartmentId, RequestNumber, State, ReceivedOn) VALUES (@Id, @Dept, @Number, 0, @Now)"), new[] + { + new { Id = "prr-1", Dept, Number = "PRR27-0003", Now = now }, + new { Id = "prr-2", Dept, Number = "PRR27-0013", Now = now } + }); + await database.ExecuteAsync(("INSERT INTO RmsInspections (RmsInspectionId, DepartmentId, InspectionNumber, State, CreatedOn) VALUES (@Id, @Dept, @Number, 0, @Now)"), new[] + { + new { Id = "insp-1", Dept, Number = "INSP-2026-0001", Now = now }, + new { Id = "insp-2", Dept, Number = "FM/2026/0002", Now = now } + }); + await database.ExecuteAsync(("INSERT INTO RmsPermits (RmsPermitId, DepartmentId, PermitNumber, State, AppliedOn) VALUES (@Id, @Dept, @Number, 0, @Now)"), new[] + { + new { Id = "prm-1", Dept, Number = "prm-2026-0009", Now = now }, + new { Id = "prm-2", Dept, Number = "PRM-2026-0010", Now = now } + }); + await database.ExecuteAsync(("INSERT INTO RmsInvestigationEvidence (RmsInvestigationEvidenceId, DepartmentId, RmsInvestigationCaseId, EvidenceNumber, DeletedOn) VALUES (@Id, @Dept, @Case, @Number, @Deleted)"), new[] + { + new { Id = "ev-1", Dept, Case = "case-1", Number = "EV26-0042", Deleted = (DateTime?)null }, + new { Id = "ev-2", Dept, Case = "case-2", Number = "EV26-0043", Deleted = (DateTime?)null }, + new { Id = "ev-3", Dept, Case = "case-3", Number = "EV26-0042", Deleted = (DateTime?)now } + }); + } + + [OneTimeTearDown] + public async Task Remove_only_this_fixture_database() + { + if (_database == null) return; + DataConfig.DatabaseType = _previous; + if (!_database.StartsWith(Prefix, StringComparison.Ordinal) || !Guid.TryParseExact(_database.Substring(Prefix.Length), "N", out _)) + throw new InvalidOperationException("Unexpected test database name."); + if (IsPostgres) NpgsqlConnection.ClearAllPools(); else SqlConnection.ClearAllPools(); + await using var master = Connect(_master); + await master.ExecuteAsync(IsPostgres ? "DROP DATABASE " + _database + " WITH (FORCE)" + : "ALTER DATABASE " + _database + " SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE " + _database); + } + + [Test] + public async Task Invoices_filter_by_the_issued_number_however_typed() + { + var invoices = Repository((c, s, u, q) => new InvoiceRepository(c, s, u, q)); + async Task> Find(string number) => (await invoices.GetForDepartmentAsync(Dept, new InvoiceListFilter { Number = number })).Select(i => i.InvoiceId).ToList(); + + (await Find("INV2026.0042")).Should().Equal("inv-1"); + (await Find("inv-2026-0042")).Should().Equal(new[] { "inv-1" }, "separators are formatting"); + (await Find("2026 0043")).Should().Equal("inv-2"); + (await Find("1042")).Should().Equal(new[] { "inv-3" }, "a row with no DisplayNumber reads as its plain number"); + (await Find("inv2026")).Should().Equal(new[] { "inv-2", "inv-1" }, "newest number first; the deleted one and the other department's are left out"); + (await Find("2027")).Should().BeEmpty(); + (await invoices.CountForDepartmentAsync(Dept, new InvoiceListFilter { Number = "INV" })).Should().Be(2); + } + + [Test] + public async Task Bids_filter_by_the_issued_number_and_underscore_is_not_a_wildcard() + { + var bids = Repository((c, s, u, q) => new BidRepository(c, s, u, q)); + + (await bids.GetForDepartmentAsync(Dept, null, "bid-26-0007", 0, 50)).Select(b => b.BidId).Should().Equal("bid-1"); + (await bids.GetForDepartmentAsync(Dept, null, "26_0008", 0, 50)).Select(b => b.BidId).Should().Equal("bid-2"); + (await bids.GetForDepartmentAsync(Dept, null, null, 0, 50)).Should().HaveCount(2); + (await bids.CountForDepartmentAsync(Dept, null, "BID")).Should().Be(2); + (await bids.CountForDepartmentAsync(Dept, 1, "BID")).Should().Be(0, "the status filter still applies"); + } + + [Test] + public async Task Records_requests_inspections_and_permits_filter_by_the_issued_number() + { + var requests = Repository((c, s, u, q) => new RmsDisclosureRequestsRepository(c, s, u, q)); + var inspections = Repository((c, s, u, q) => new RmsInspectionsRepository(c, s, u, q)); + var permits = Repository((c, s, u, q) => new RmsPermitsRepository(c, s, u, q)); + + (await requests.GetForDepartmentAsync(Dept, null, "prr-27-0003", 0, 50)).Select(r => r.RmsDisclosureRequestId).Should().Equal("prr-1"); + (await requests.GetForDepartmentAsync(Dept, null, "27-3", 0, 50)).Select(r => r.RmsDisclosureRequestId).Should().BeEquivalentTo(new[] { "prr-1", "prr-2" }, "an unpadded sequence matches every number holding it"); + + (await inspections.QueryAsync(Dept, new RmsInspectionQuery { Number = "FM 2026 0002" })).Select(i => i.RmsInspectionId).Should().Equal("insp-2"); + (await inspections.CountAsync(Dept, new RmsInspectionQuery { Number = "2026" })).Should().Be(2); + + (await permits.QueryAsync(Dept, new RmsPermitQuery { Number = "PRM-2026-0009" })).Select(p => p.RmsPermitId).Should().Equal(new[] { "prm-1" }, "case is ignored on both engines"); + (await permits.CountAsync(Dept, new RmsPermitQuery { Number = "0010" })).Should().Be(1); + } + + [Test] + public async Task Investigation_cases_are_found_by_their_live_evidence_numbers() + { + var evidence = Repository((c, s, u, q) => new RmsInvestigationEvidenceRepository(c, s, u, q)); + + (await evidence.GetCaseIdsWithEvidenceNumberAsync(Dept, new[] { "case-1", "case-2", "case-3" }, "ev26-0042")).Should().Equal(new[] { "case-1" }, "deleted evidence does not find its case"); + (await evidence.GetCaseIdsWithEvidenceNumberAsync(Dept, new[] { "case-2" }, "0042")).Should().BeEmpty("only the listed cases are searched"); + (await evidence.GetCaseIdsWithEvidenceNumberAsync(Dept, new[] { "case-1" }, " - ")).Should().BeEmpty("a search with no letters or digits finds nothing"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs b/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs index ab01e0f6f..03f59230b 100644 --- a/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs +++ b/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs @@ -88,6 +88,10 @@ public FakeIncidentStore() .ReturnsAsync((int d, RmsIncidentReportQuery q) => MatchReports(d, q).Count()); ReportsRepo.Setup(r => r.GetYearsAsync(It.IsAny())) .ReturnsAsync((int d) => Reports.Where(x => x.DepartmentId == d).Select(x => (x.CallCreatedOn ?? x.CreatedOn).Year).Distinct().ToList()); + ReportsRepo.Setup(r => r.GetNumberingYearsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((int d, IReadOnlyList windows) => windows + .Where(w => Reports.Any(x => x.DepartmentId == d && x.DeletedOn == null && (x.CallCreatedOn ?? x.CreatedOn) >= w.FromUtc && (x.CallCreatedOn ?? x.CreatedOn) < w.BeforeUtc)) + .Select(w => w.Year).OrderByDescending(y => y).ToList()); ReportsRepo.Setup(r => r.GetMaxRecordNumberSequenceAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((int d, string prefix, string suffix) => FakeRmsStore.MaxRecordNumberSequence(Reports.Where(x => x.DepartmentId == d).Select(x => x.RecordNumber), prefix, suffix)); ReportsRepo.Setup(r => r.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) @@ -280,6 +284,12 @@ private IEnumerable MatchReports(int departmentId, RmsInciden rows = rows.Where(x => x.FinalizedOn != null && x.FinalizedOn.Value >= query.FinalizedOnStart.Value); if (query?.FinalizedOnEnd != null) rows = rows.Where(x => x.FinalizedOn != null && x.FinalizedOn.Value < query.FinalizedOnEnd.Value); + if (query?.YearFrom != null) + rows = rows.Where(x => (x.CallCreatedOn ?? x.CreatedOn) >= query.YearFrom.Value); + if (query?.YearBefore != null) + rows = rows.Where(x => (x.CallCreatedOn ?? x.CreatedOn) < query.YearBefore.Value); + if (query?.Year != null && query.YearFrom == null && query.YearBefore == null) + rows = rows.Where(x => (x.CallCreatedOn ?? x.CreatedOn).Year == query.Year.Value); return rows; } diff --git a/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs b/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs index 40d5e9391..d03afc57e 100644 --- a/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs +++ b/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs @@ -356,12 +356,13 @@ public FakeRmsStore() .ReturnsAsync((int d, string id) => DisclosureRequests.FirstOrDefault(x => x.DepartmentId == d && x.RmsDisclosureRequestId == id)); DisclosureRequestsRepo.Setup(r => r.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int d, string id, long version, CancellationToken ct) => { var row = DisclosureRequests.SingleOrDefault(r => r.DepartmentId == d && r.RmsDisclosureRequestId == id && r.RowVersion == version && r.ClosedOn == null && r.DeletedOn == null); if (row == null) return false; row.RowVersion++; return true; }); - DisclosureRequestsRepo.Setup(r => r.GetForDepartmentAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync((int d, IEnumerable states, int skip, int take) => + DisclosureRequestsRepo.Setup(r => r.GetForDepartmentAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, IEnumerable states, string number, int skip, int take) => { var list = states?.ToList(); return (IEnumerable)DisclosureRequests .Where(x => x.DepartmentId == d && x.DeletedOn == null && (list == null || list.Count == 0 || list.Contains(x.State))) + .Where(x => number == null || IdentifierSearchKeys.ContainsParts(x.RequestNumber, number)) .OrderBy(x => x.StatutoryDueOn).Skip(skip).Take(take).ToList(); }); DisclosureRequestsRepo.Setup(r => r.CountByStateAsync(It.IsAny(), It.IsAny())) diff --git a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs index bfcc3ee92..1b4bde24d 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using Resgrid.Model; using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; using Resgrid.Model.Services; using Resgrid.Providers.Neris; using Resgrid.Services; @@ -433,6 +434,39 @@ public async Task Finalize_reads_the_call_date_in_the_department_time_zone() final.Report.RecordNumber.Should().Be("INC26-0001"); } + [Test] + public async Task The_year_filter_and_year_list_follow_the_fiscal_numbering_year() + { + // The call was logged 1 September 2026, the first day of FY2027: the report is numbered EZKT27-0001, so the queue + // lists it under 2027, not under the calendar year its call falls in. + var config = new RecordsNumberingConfig { Pattern = "{PREFIX}{YY}-{SEQ}", YearStartMonth = 9, YearStartDay = 1 }; + config.SetPrefix(RmsDefinitionKeys.NerisIncidentReport, "EZKT"); + _settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(config); + var started = await _service.StartFromCallAsync(Dept, "author", CallId); + var final = await _service.FinalizeAsync(Dept, "author", started.Report.RmsIncidentReportId, started.Report.RowVersion, null, "10.0.0.1", null, null); + final.Report.RecordNumber.Should().Be("EZKT27-0001"); + + (await _service.GetYearsAsync(Dept)).Should().Equal(2027); + (await _service.QueryAsync(Dept, new RmsIncidentReportQuery { Year = 2027 })).Select(r => r.RecordNumber).Should().Equal("EZKT27-0001"); + (await _service.CountAsync(Dept, new RmsIncidentReportQuery { Year = 2027 })).Should().Be(1); + (await _service.QueryAsync(Dept, new RmsIncidentReportQuery { Year = 2026 })).Should().BeEmpty(); + } + + [Test] + public async Task The_year_filter_reads_the_call_date_in_the_department_time_zone() + { + // 08:00 UTC on 1 September is 31 August in Honolulu: still FY2026, the year the report is numbered in. + _departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, TimeZone = "Hawaiian Standard Time" }); + var config = new RecordsNumberingConfig { Pattern = "{PREFIX}{YY}-{SEQ}", YearStartMonth = 9, YearStartDay = 1 }; + _settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(config); + var started = await _service.StartFromCallAsync(Dept, "author", CallId); + var final = await _service.FinalizeAsync(Dept, "author", started.Report.RmsIncidentReportId, started.Report.RowVersion, null, "10.0.0.1", null, null); + + (await _service.GetYearsAsync(Dept)).Should().Equal(2026); + (await _service.QueryAsync(Dept, new RmsIncidentReportQuery { Year = 2026 })).Select(r => r.RecordNumber).Should().Equal(final.Report.RecordNumber); + (await _service.QueryAsync(Dept, new RmsIncidentReportQuery { Year = 2027 })).Should().BeEmpty(); + } + [Test] public async Task Finalize_writes_revision_signature_and_queues_the_submission() { diff --git a/Tests/Resgrid.Tests/Rms/RecordNumberingTests.cs b/Tests/Resgrid.Tests/Rms/RecordNumberingTests.cs index 2b4e55b25..4f0c2c050 100644 --- a/Tests/Resgrid.Tests/Rms/RecordNumberingTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordNumberingTests.cs @@ -610,6 +610,41 @@ public void A_records_numbering_year_is_the_departments_local_year(int y, int m, RecordsNumberingService.NumberingYear(new RecordsNumberingConfig(), new DateTime(y, m, d, h, min, 0, DateTimeKind.Utc), timeZone).Should().Be(expected, because); } + [Test] + public void A_year_window_is_the_departments_local_numbering_year_in_utc() + { + // Calendar year in Chicago: local midnights, 6 hours behind UTC in January. + var calendar = RecordsNumberingService.YearWindow(new RecordsNumberingConfig(), 2026, "Central Standard Time"); + calendar.FromUtc.Should().Be(new DateTime(2026, 1, 1, 6, 0, 0)); + calendar.BeforeUtc.Should().Be(new DateTime(2027, 1, 1, 6, 0, 0)); + + // FY2027 starting 1 November 2026, named by the year it ends in; Los Angeles is still on daylight time at midnight on both days. + var fiscal = RecordsNumberingService.YearWindow(new RecordsNumberingConfig { YearStartMonth = 11, YearStartDay = 1 }, 2027, "Pacific Standard Time"); + fiscal.Year.Should().Be(2027); + fiscal.FromUtc.Should().Be(new DateTime(2026, 11, 1, 7, 0, 0)); + fiscal.BeforeUtc.Should().Be(new DateTime(2027, 11, 1, 7, 0, 0)); + + var startNamed = RecordsNumberingService.YearWindow(new RecordsNumberingConfig { YearStartMonth = 11, YearStartDay = 1, YearLabel = (int)NumberingYearLabel.StartYear }, 2026, null); + startNamed.FromUtc.Should().Be(new DateTime(2026, 11, 1)); + startNamed.BeforeUtc.Should().Be(new DateTime(2027, 11, 1)); + + RecordsNumberingService.YearWindow(null, 2026, "Not A Zone").FromUtc.Should().Be(new DateTime(2026, 1, 1), "an unknown zone reads the dates as UTC"); + } + + [Test] + public void Every_numbering_year_a_window_check_could_find_comes_from_the_calendar_years() + { + // A record numbered by its numbering year always lands in one of the candidates: west of UTC the first hours of a UTC + // year are the previous local year; east of UTC the last hours are the next; a fiscal year straddles two calendar years. + Years(new RecordsNumberingConfig(), new[] { 2026 }, null).Should().Equal(2026); + Years(new RecordsNumberingConfig(), new[] { 2026, 2024 }, "Central Standard Time").Should().Equal(2026, 2025, 2024, 2023); + Years(new RecordsNumberingConfig(), new[] { 2026 }, "Tokyo Standard Time").Should().Equal(2027, 2026); + Years(new RecordsNumberingConfig { YearStartMonth = 11, YearStartDay = 1 }, new[] { 2026 }, null).Should().Equal(2027, 2026); + + IReadOnlyList Years(RecordsNumberingConfig config, int[] calendarYears, string timeZone) => + RecordsNumberingService.CandidateYearWindows(config, calendarYears, timeZone).Select(w => w.Year).ToList(); + } + [Test] public void The_records_year_start_round_trips_through_protobuf() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs index 13d07efa3..11f9617d4 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs @@ -179,6 +179,20 @@ public async Task A_stale_release_cannot_replace_another_officers_partial_delive _store.DisclosureProductions.Single().ReleasedByUserId.Should().Be("first"); _store.DisclosureProductions.Single().RowVersion.Should().Be(version); _store.Audits.Count(a => a.Purpose == "Disclosure released").Should().Be(1); } + [Test] + public async Task The_request_queue_filters_by_part_of_the_request_number() + { + var first = await OpenRequestAsync(); + var second = await OpenRequestAsync(); + var year = first.RequestNumber.Split('-')[1]; + + (await _service.QueryAsync(Dept, "clerk", null, number: $"prr {year} 2")).Select(r => r.RmsDisclosureRequestId).Should().Equal(second.RmsDisclosureRequestId); + (await _service.QueryAsync(Dept, "clerk", null, number: first.RequestNumber.ToLowerInvariant())).Select(r => r.RmsDisclosureRequestId).Should().Equal(first.RmsDisclosureRequestId); + (await _service.QueryAsync(Dept, "clerk", null, number: "PRR")).Should().HaveCount(2); + (await _service.QueryAsync(Dept, "clerk", null, number: " -- ")).Should().HaveCount(2, "a search with no letters or digits does not filter"); + (await _service.QueryAsync(Dept, "clerk", null, number: "OCC")).Should().BeEmpty(); + } + [Test] public async Task Request_reads_use_live_permissions_and_never_mutate_stored_requester_identity() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsHydrantsAndPermitsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsHydrantsAndPermitsServiceTests.cs index 948e913f1..f26f76952 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsHydrantsAndPermitsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsHydrantsAndPermitsServiceTests.cs @@ -101,6 +101,26 @@ public async Task SetUp() _tent = await _h.PermitsService.SaveTypeAsync(Dept, Admin, new RmsPermitType { Name = "Tent over 400 sq ft", Code = "TENT", DefaultValidityDays = 10, RequiresPlanReview = true, IsActive = true }); } + [Test] + public async Task The_permit_list_filters_by_a_department_formatted_number() + { + // The department's own pattern (Records Settings, Other record numbers) writes HW26-0007; any way of typing it finds it. + _h.NumberingConfig = new RecordsNumberingConfig(); + _h.NumberingConfig.DocumentPatterns.Add(new DocumentNumberPattern { Kind = DocumentNumberKinds.Permit, Pattern = "HW{YY}-{SEQ}", SequenceWidth = 4 }); + var yy = (DateTime.UtcNow.Year % 100).ToString("D2"); + var occupancy = _h.SeedOccupancy(); + foreach (var description in new[] { "Welding", "Grinding" }) + await _h.PermitsService.ApplyAsync(Dept, Admin, new RmsPermit { RmsPermitTypeId = _hotWork.RmsPermitTypeId, RmsOccupancyId = occupancy.RmsOccupancyId, ApplicantName = "Sam", Description = description }); + + async Task> Find(string number) => (await _h.PermitsService.ListAsync(Dept, Admin, new RmsPermitQuery { Number = number })).Select(p => p.PermitNumber).ToList(); + + (await Find($"hw-{yy}-0002")).Should().Equal($"HW{yy}-0002"); + (await Find("0001")).Should().Equal($"HW{yy}-0001"); + (await Find($"HW{yy}")).Should().HaveCount(2); + (await Find("PRM")).Should().BeEmpty("the built-in prefix is not what this department issues"); + (await _h.PermitsService.CountAsync(Dept, Admin, new RmsPermitQuery { Number = "0002" })).Should().Be(1); + } + [Test] public async Task A_permit_moves_through_review_issue_and_expiry_with_dates_from_its_type() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs index 078d0a6a4..03b0dc588 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs @@ -51,6 +51,24 @@ public async Task Opening_a_case_makes_the_opener_lead_and_department_admins_are _h.Audits.Rows.Should().Contain(a => a.Action == (int)RmsAccessAuditAction.Read && a.Purpose == RecordsInvestigationsService.AuditPurpose && a.CorrelationId == investigation.RmsInvestigationCaseId && a.IpAddress == "10.0.0.5" && a.RecordId == null); } + [Test] + public async Task My_cases_filter_by_case_number_or_by_an_evidence_number() + { + var year = DateTime.UtcNow.Year; + var first = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Warehouse fire", null, null, null); + var second = await _h.InvestigationsService.OpenAsync(Dept, Admin, "Vehicle fire", null, null, null); + var evidence = await _h.InvestigationsService.AddEvidenceAsync(Dept, Admin, second.RmsInvestigationCaseId, new RmsInvestigationEvidence { Kind = (int)RmsInvestigationEvidenceKind.Physical, Description = "Fuel can" }); + evidence.EvidenceNumber.Should().Be($"EV-{year}-0001"); + + async Task> Find(string number) => (await _h.InvestigationsService.ListMyCasesAsync(Dept, Admin, false, number)).Select(c => c.CaseNumber).ToList(); + + (await Find($"inv {year} 0002")).Should().Equal(second.CaseNumber); + (await Find($"ev-{year}")).Should().Equal(new[] { second.CaseNumber }, "a case is found by the evidence it holds"); + (await Find("0001")).Should().BeEquivalentTo(new[] { first.CaseNumber, second.CaseNumber }, "INV-…-0001 by its own number, the other by EV-…-0001"); + (await Find("INV-1999")).Should().BeEmpty(); + (await Find(" ")).Should().HaveCount(2, "a blank search lists every case"); + } + [Test] public async Task Members_notes_evidence_and_custody_follow_the_case_rules() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsSearchIndexMaintenanceServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsSearchIndexMaintenanceServiceTests.cs index 0fdc2b906..fa799b2fb 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsSearchIndexMaintenanceServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsSearchIndexMaintenanceServiceTests.cs @@ -84,11 +84,11 @@ public async Task First_sweep_rebuilds_the_department_and_records_the_generation result.Errors.Should().Be(0); _indexer.Verify(i => i.DeleteDepartmentAsync(Dept, It.IsAny()), Times.Once); _indexer.Verify(i => i.CommitAsync(It.IsAny()), Times.Once); - _indexed.Should().OnlyContain(d => d.Generation == "1.3.7" && d.Narrative.StartsWith("narrative of ")); + _indexed.Should().OnlyContain(d => d.Generation == RecordsSearchGeneration.Compute(3, 7) && d.Narrative.StartsWith("narrative of ")); _savedStates.Select(s => (RmsSearchIndexBuildState)s.State).Should().Equal(RmsSearchIndexBuildState.Rebuilding, RmsSearchIndexBuildState.Ready); var final = _savedStates.Last(); - final.Generation.Should().Be("1.3.7"); + final.Generation.Should().Be(RecordsSearchGeneration.Compute(3, 7)); final.SchemaVersion.Should().Be(RecordsSearchGeneration.SchemaVersion); final.ProtectedCatalogVersion.Should().Be(3); final.PolicyEpoch.Should().Be(7); @@ -100,7 +100,7 @@ public async Task First_sweep_rebuilds_the_department_and_records_the_generation public async Task Matching_generation_catches_up_modified_rows_and_removes_soft_deleted_ones() { var since = new DateTime(2026, 9, 1); - _states.Setup(s => s.GetAsync(Dept, RmsSearchIndexState.RecordsIndexName)).ReturnsAsync(new RmsSearchIndexState { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = "1.3.7", State = (int)RmsSearchIndexBuildState.Ready, LastIndexedModifiedOn = since }); + _states.Setup(s => s.GetAsync(Dept, RmsSearchIndexState.RecordsIndexName)).ReturnsAsync(new RmsSearchIndexState { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = RecordsSearchGeneration.Compute(3, 7), State = (int)RmsSearchIndexBuildState.Ready, LastIndexedModifiedOn = since }); var deleted = Projection("rec-9", new DateTime(2026, 9, 3)); deleted.DeletedOn = DateTime.UtcNow; _projections.SetupSequence(p => p.GetModifiedSinceAsync(Dept, since.AddSeconds(-1), It.IsAny(), It.IsAny())).ReturnsAsync(new[] { Projection("rec-5", new DateTime(2026, 9, 2)), deleted }); @@ -120,13 +120,13 @@ public async Task Matching_generation_catches_up_modified_rows_and_removes_soft_ [Test] public async Task A_changed_generation_or_a_failed_state_forces_a_rebuild() { - _states.Setup(s => s.GetAsync(Dept, RmsSearchIndexState.RecordsIndexName)).ReturnsAsync(new RmsSearchIndexState { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = "1.2.7", State = (int)RmsSearchIndexBuildState.Ready }); + _states.Setup(s => s.GetAsync(Dept, RmsSearchIndexState.RecordsIndexName)).ReturnsAsync(new RmsSearchIndexState { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = RecordsSearchGeneration.Compute(2, 7), State = (int)RmsSearchIndexBuildState.Ready }); _projections.Setup(p => p.QueryAsync(Dept, It.IsAny())).ReturnsAsync(new RmsRecordSearchProjection[0]); (await _service.SweepAsync()).DepartmentsRebuilt.Should().Be(1, "the catalog version moved from 2 to 3"); _savedStates.Clear(); - _states.Setup(s => s.GetAsync(Dept, RmsSearchIndexState.RecordsIndexName)).ReturnsAsync(new RmsSearchIndexState { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = "1.3.7", State = (int)RmsSearchIndexBuildState.Failed }); + _states.Setup(s => s.GetAsync(Dept, RmsSearchIndexState.RecordsIndexName)).ReturnsAsync(new RmsSearchIndexState { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = RecordsSearchGeneration.Compute(3, 7), State = (int)RmsSearchIndexBuildState.Failed }); (await _service.SweepAsync()).DepartmentsRebuilt.Should().Be(1, "a failed build is retried"); } @@ -173,7 +173,7 @@ public async Task Equal_timestamp_pages_include_a_late_purge_tombstone_at_the_ch SearchConfig.IndexBatchSize = 50; var stamp = new DateTime(2026, 9, 2); _states.Setup(s => s.GetAsync(Dept, RmsSearchIndexState.RecordsIndexName)).ReturnsAsync(new RmsSearchIndexState - { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = "1.3.7", State = (int)RmsSearchIndexBuildState.Ready, LastIndexedModifiedOn = stamp }); + { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = RecordsSearchGeneration.Compute(3, 7), State = (int)RmsSearchIndexBuildState.Ready, LastIndexedModifiedOn = stamp }); var rows = Enumerable.Range(0, 101).Select(i => Projection("rec-" + i.ToString("D3"), stamp)).ToList(); rows.Last().DeletedOn = stamp; _projections.Setup(p => p.GetModifiedSinceAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())) @@ -193,7 +193,7 @@ public async Task Equal_timestamp_pages_include_a_late_purge_tombstone_at_the_ch public async Task Failed_index_commit_does_not_advance_database_checkpoint_and_next_sweep_replays_the_tombstone() { var stamp = new DateTime(2026, 9, 2); - var state = new RmsSearchIndexState { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = "1.3.7", State = (int)RmsSearchIndexBuildState.Ready, LastIndexedModifiedOn = stamp }; + var state = new RmsSearchIndexState { DepartmentId = Dept, IndexName = RmsSearchIndexState.RecordsIndexName, Generation = RecordsSearchGeneration.Compute(3, 7), State = (int)RmsSearchIndexBuildState.Ready, LastIndexedModifiedOn = stamp }; _states.Setup(s => s.GetAsync(Dept, RmsSearchIndexState.RecordsIndexName)).ReturnsAsync(() => Clone(state)); var deleted = Projection("purged", stamp.AddMinutes(1)); deleted.DeletedOn = deleted.ModifiedOn; _projections.Setup(p => p.GetModifiedSinceAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new[] { deleted }); diff --git a/Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs b/Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs index fadda6cd7..208052434 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsSearchTests.cs @@ -120,6 +120,46 @@ public async Task Text_matches_record_numbers_summaries_call_numbers_and_safe_se (await _search.SearchAsync(1, new RecordsSearchRequest { Text = "((\"unbalanced" })).Available.Should().BeTrue("hostile syntax is escaped, never thrown"); } + [Test] + public async Task Department_number_formats_match_by_any_part_however_typed() + { + await _indexer.IndexAsync(new[] + { + Source(Projection(5, "fmt-1", "RUN26-0001", "Chimney fire", RmsRecordState.Finalized, "a1", groups: "11", participants: "", occurred: new DateTime(2026, 2, 1), definition: RmsDefinitionKeys.Run)), + Source(Projection(5, "fmt-2", "2026.0042", "Lift assist", RmsRecordState.Finalized, "a1", groups: "11", participants: "", occurred: new DateTime(2026, 2, 2), definition: RmsDefinitionKeys.Run)), + Source(Projection(5, "fmt-3", "TRN_2026_0007", "Ladder drill", RmsRecordState.Finalized, "a1", groups: "11", participants: "", occurred: new DateTime(2026, 2, 3), definition: RmsDefinitionKeys.Training)) + }); + await _indexer.CommitAsync(); + + async Task> Find(string text) => (await _search.SearchAsync(5, new RecordsSearchRequest { Text = text })).Hits.Select(h => h.SourceId).ToList(); + + (await Find("RUN26-0001")).Should().Equal("fmt-1"); + (await Find("run26-1")).Should().Equal(new[] { "fmt-1" }, "the sequence without its zero padding"); + (await Find("RUN-26-0001")).Should().Equal(new[] { "fmt-1" }, "separators typed where the pattern has none"); + (await Find("RUN")).Should().Equal(new[] { "fmt-1" }, "the type prefix alone, though the analyzer keeps RUN26 as one word"); + (await Find("0042")).Should().Equal(new[] { "fmt-2" }, "the sequence alone, though the analyzer keeps 2026.0042 as one word"); + (await Find("2026-42")).Should().Equal("fmt-2"); + (await Find("TRN 2026 7")).Should().Equal(new[] { "fmt-3" }, "'_' joins TRN_2026_0007 into one word for the analyzer"); + (await Find("trn_2026")).Should().Equal("fmt-3"); + (await Find("2026")).Should().BeEquivalentTo(new[] { "fmt-2", "fmt-3" }, "RUN26 names its year with two digits"); + (await Find("2026-43")).Should().BeEmpty(); + } + + [Test] + public async Task A_numbering_year_window_takes_the_place_of_the_calendar_year() + { + // A fiscal year starting 1 November 2025 (FY2026 named by the year it ends in): rec-3 (December 2025) and rec-1 (March 2026) + // are in it, rec-2 (May 2026) is not; the calendar year 2026 would have kept rec-1 and rec-2. + var fiscal = await _search.SearchAsync(1, new RecordsSearchRequest + { + Year = 2026, OccurredFrom = new DateTime(2025, 11, 1, 0, 0, 0, DateTimeKind.Utc), OccurredBefore = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc) + }); + + fiscal.Hits.Select(h => h.SourceId).Should().Equal("rec-1", "rec-3"); + fiscal.Total.Should().Be(2); + (await _search.SearchAsync(1, new RecordsSearchRequest { Year = 2026 })).Hits.Select(h => h.SourceId).Should().Equal("rec-2", "rec-1"); + } + [Test] public async Task Narrative_is_searchable_only_when_supplied_and_withdrawn_on_reindex_without_it() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs index f4c396f29..518417766 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs @@ -98,6 +98,35 @@ public void SetUp() _store.UnitOfWork.Object, _outboundQueue.Object, new Resgrid.Services.Records.NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), _protection, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); } + [Test] + public async Task The_year_filter_is_the_departments_numbering_year() + { + // A fiscal year starting 1 September, named by the year it ends in: records numbered RUN-2027-* occurred from + // 1 September 2026, so the queue's "2027" is that window, not the calendar year. + _settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsNumberingConfig { YearStartMonth = 9, YearStartDay = 1 }); + var queries = new List(); + _store.ProjectionsRepo.Setup(r => r.QueryAsync(Dept, It.IsAny())).Callback((int d, RmsRecordQuery q) => queries.Add(q)).ReturnsAsync(new List()); + _store.ProjectionsRepo.Setup(r => r.CountAsync(Dept, It.IsAny())).Callback((int d, RmsRecordQuery q) => queries.Add(q)).ReturnsAsync(0); + + await _service.QueryAsync(Dept, new RmsRecordQuery { Year = 2027 }); + await _service.CountAsync(Dept, new RmsRecordQuery { Year = 2027 }); + + queries.Should().HaveCount(2).And.OnlyContain(q => q.OccurredFrom == new DateTime(2026, 9, 1) && q.OccurredBefore == new DateTime(2027, 9, 1)); + } + + [Test] + public async Task The_year_list_is_the_numbering_years_that_hold_a_record() + { + _settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsNumberingConfig { YearStartMonth = 9, YearStartDay = 1 }); + _store.ProjectionsRepo.Setup(r => r.GetYearsAsync(Dept)).ReturnsAsync(new List { 2026 }); + IReadOnlyList asked = null; + _store.ProjectionsRepo.Setup(r => r.GetNumberingYearsAsync(Dept, It.IsAny>())) + .Callback((int d, IReadOnlyList w) => asked = w).ReturnsAsync(new List { 2027 }); + + (await _service.GetYearsAsync(Dept)).Should().Equal(2027); + asked.Select(w => w.Year).Should().Equal(new[] { 2027, 2026 }, "calendar 2026 holds the end of FY2026 and the start of FY2027"); + } + [Test] public async Task Historical_call_creation_is_authorized_versioned_linked_once_and_captured_in_the_run_revision() { diff --git a/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs index c50a5e9cc..7dabdc906 100644 --- a/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs +++ b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs @@ -130,7 +130,7 @@ public class FakePrograms : InMemoryRepo, IRmsInspectionPr public class FakeInspections : InMemoryRepo, IRmsInspectionsRepository { public Task GetByIdForDepartmentAsync(int departmentId, string inspectionId) => Task.FromResult(Rows.FirstOrDefault(i => i.DepartmentId == departmentId && i.RmsInspectionId == inspectionId)); - private IEnumerable Filter(int departmentId, RmsInspectionQuery q) => Live(departmentId).Where(i => (q.OccupancyId == null || i.RmsOccupancyId == q.OccupancyId) && (q.ProgramId == null || i.RmsInspectionProgramId == q.ProgramId) && (q.States == null || q.States.Count == 0 || q.States.Contains(i.State)) && (q.ScheduledBefore == null || i.ScheduledOn <= q.ScheduledBefore)); + private IEnumerable Filter(int departmentId, RmsInspectionQuery q) => Live(departmentId).Where(i => (q.OccupancyId == null || i.RmsOccupancyId == q.OccupancyId) && (q.ProgramId == null || i.RmsInspectionProgramId == q.ProgramId) && (q.States == null || q.States.Count == 0 || q.States.Contains(i.State)) && (q.ScheduledBefore == null || i.ScheduledOn <= q.ScheduledBefore) && (q.Number == null || IdentifierSearchKeys.ContainsParts(i.InspectionNumber, q.Number))); public Task> QueryAsync(int departmentId, RmsInspectionQuery query) => Task.FromResult>(Filter(departmentId, query).Skip(query.Skip).Take(query.Take).ToList()); public Task CountAsync(int departmentId, RmsInspectionQuery query) => Task.FromResult(Filter(departmentId, query).Count()); public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Live(departmentId).Where(i => i.RmsOccupancyId == occupancyId).ToList()); @@ -199,7 +199,7 @@ public class FakePermitTypes : InMemoryRepo, IRmsPermitTypesRepos public class FakePermits : InMemoryRepo, IRmsPermitsRepository { public Task GetByIdForDepartmentAsync(int departmentId, string permitId) => Task.FromResult(Rows.FirstOrDefault(p => p.DepartmentId == departmentId && p.RmsPermitId == permitId)); - private IEnumerable Filter(int departmentId, RmsPermitQuery q) => Live(departmentId).Where(p => (q.OccupancyId == null || p.RmsOccupancyId == q.OccupancyId) && (q.PermitTypeId == null || p.RmsPermitTypeId == q.PermitTypeId) && (q.States == null || q.States.Count == 0 || q.States.Contains(p.State)) && (q.ExpiresBefore == null || p.ExpiresOn <= q.ExpiresBefore)); + private IEnumerable Filter(int departmentId, RmsPermitQuery q) => Live(departmentId).Where(p => (q.OccupancyId == null || p.RmsOccupancyId == q.OccupancyId) && (q.PermitTypeId == null || p.RmsPermitTypeId == q.PermitTypeId) && (q.States == null || q.States.Count == 0 || q.States.Contains(p.State)) && (q.ExpiresBefore == null || p.ExpiresOn <= q.ExpiresBefore) && (q.Number == null || IdentifierSearchKeys.ContainsParts(p.PermitNumber, q.Number))); public Task> QueryAsync(int departmentId, RmsPermitQuery query) => Task.FromResult>(Filter(departmentId, query).Skip(query.Skip).Take(query.Take).ToList()); public Task CountAsync(int departmentId, RmsPermitQuery query) => Task.FromResult(Filter(departmentId, query).Count()); public Task> GetForOccupancyAsync(int departmentId, string occupancyId) => Task.FromResult>(Live(departmentId).Where(p => p.RmsOccupancyId == occupancyId).ToList()); @@ -278,6 +278,8 @@ public class FakeEvidence : InMemoryRepo, IRmsInvestig { public Task GetByIdForDepartmentAsync(int departmentId, string evidenceId) => Task.FromResult(Rows.FirstOrDefault(e => e.DepartmentId == departmentId && e.RmsInvestigationEvidenceId == evidenceId)); public Task> GetForCaseAsync(int departmentId, string caseId) => Task.FromResult>(Live(departmentId).Where(e => e.RmsInvestigationCaseId == caseId).ToList()); + public Task> GetCaseIdsWithEvidenceNumberAsync(int departmentId, IEnumerable caseIds, string number) => Task.FromResult>(Live(departmentId) + .Where(e => caseIds.Contains(e.RmsInvestigationCaseId) && IdentifierSearchKeys.ContainsParts(e.EvidenceNumber, number)).Select(e => e.RmsInvestigationCaseId).Distinct().ToList()); } public class FakeCustody : InMemoryRepo, IRmsInvestigationCustodyRepository diff --git a/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs b/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs index 0b5f4b53d..710737e37 100644 --- a/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsRetentionDatabaseTests.cs @@ -801,5 +801,47 @@ public async Task Outbox_live_content_guard_applies_only_to_record_aggregates() (await missing.Should().ThrowAsync(aggregateType + " names a Record that does not exist")).Which.Message.Should().Contain("missing or purged"); } } + + [Test] + public async Task Year_filters_and_year_lists_run_on_numbering_year_windows() + { + // FY2027 starting 1 September 2026 (UTC, no zone): one record and report on each side of the start, and a draft + // with no occurrence or call time that falls back to when it was created. + const int department = 1340; + var fy2026 = new DateTime(2026, 8, 31, 23, 0, 0); var fy2027 = new DateTime(2026, 9, 1, 8, 0, 0); var created = new DateTime(2027, 3, 1); + using var db = new SqlConnection(_connection); await db.OpenAsync(); + var call = 0; + foreach (var (id, occurred, recordCreated) in new[] { ("yr-1", (DateTime?)fy2026, fy2026), ("yr-2", fy2027, fy2027), ("yr-3", null, created) }) + { + call++; + await db.ExecuteAsync(@"INSERT RmsRecordSearchProjections (RmsRecordSearchProjectionId,DepartmentId,ProtectionId,SourceType,SourceId,DefinitionKey,State,OccurredOn,RecordCreatedOn,CreatedOn,ModifiedOn,DisplaySummary,RowVersion) +VALUES (@Id,@Department,@Id,1,@Id,'system.run',4,@Occurred,@RecordCreated,@RecordCreated,@RecordCreated,'summary',1); +INSERT RmsIncidentReports (RmsIncidentReportId,DepartmentId,ProtectionId,CallId,ReportingEntityId,DefinitionKey,DefinitionVersion,LifecyclePreset,State,AuthorUserId,CallCreatedOn,CreatedOn,ModifiedOn) +VALUES (@Id,@Department,@Id,@Call,'entity','neris.incident',1,0,1,'officer',@Occurred,@RecordCreated,@RecordCreated)", + new { Id = id, Department = department, Call = call, Occurred = occurred, RecordCreated = recordCreated }); + } + + var connections = Connections(); using var unit = new UnitOfWork(connections); + var projections = new RmsRecordSearchProjectionsRepository(connections, new SqlServerConfiguration(), unit, Mock.Of()); + var reports = new RmsIncidentReportsRepository(connections, new SqlServerConfiguration(), unit, Mock.Of()); + var config = new RecordsNumberingConfig { YearStartMonth = 9, YearStartDay = 1 }; + var window = Resgrid.Services.Records.RecordsNumberingService.YearWindow(config, 2027, null); + + (await projections.QueryAsync(department, new RmsRecordQuery { Year = 2026, OccurredFrom = window.FromUtc, OccurredBefore = window.BeforeUtc })).Select(p => p.RmsRecordSearchProjectionId) + .Should().BeEquivalentTo(new[] { "yr-2", "yr-3" }, "the window replaces the calendar year"); + (await projections.CountAsync(department, new RmsRecordQuery { OccurredFrom = window.FromUtc, OccurredBefore = window.BeforeUtc })).Should().Be(2); + (await projections.CountAsync(department, new RmsRecordQuery { Year = 2026 })).Should().Be(2, "no window still reads the UTC calendar year"); + (await reports.QueryAsync(department, new RmsIncidentReportQuery { YearFrom = window.FromUtc, YearBefore = window.BeforeUtc })).Select(r => r.RmsIncidentReportId) + .Should().BeEquivalentTo("yr-2", "yr-3"); + (await reports.CountAsync(department, new RmsIncidentReportQuery { Year = 2026 })).Should().Be(2); + + var calendarYears = (await projections.GetYearsAsync(department)).ToList(); + calendarYears.Should().Equal(2027, 2026); + var windows = Resgrid.Services.Records.RecordsNumberingService.CandidateYearWindows(config, calendarYears, null); + (await projections.GetNumberingYearsAsync(department, windows)).Should().Equal(2027, 2026); + (await reports.GetYearsAsync(department)).Should().Equal(2027, 2026); + (await reports.GetNumberingYearsAsync(department, windows)).Should().Equal(2027, 2026); + (await projections.GetNumberingYearsAsync(department, windows.Where(w => w.Year != 2026).ToList())).Should().Equal(new[] { 2027 }, "rows outside every window are left out"); + } } } diff --git a/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs b/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs index 4b65c7d51..549e998b4 100644 --- a/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs +++ b/Tests/Resgrid.Tests/Search/GlobalSearchTests.cs @@ -166,6 +166,27 @@ public async Task Exact_identifier_ranks_first_and_partial_last_token_still_matc partial.Hits.Select(h => h.EntityId).Should().Contain("10"); } + [TestCase(false)] + [TestCase(true)] + public async Task Department_number_formats_match_by_any_part_however_typed(bool prefix) + { + await _indexer.IndexAsync(new[] + { + Projection(3, SearchEntityTypes.Invoice, "inv-1", "Mutual aid billing", keywords: "INV2026.0042 42", occurred: new DateTime(2026, 2, 1)), + Projection(3, SearchEntityTypes.Bid, "bid-1", "Wildland standby", keywords: "BID_26_0007 7", occurred: new DateTime(2026, 2, 2)) + }, "1.25.3"); + await _indexer.CommitAsync(); + + async Task> Find(string text) => (await _search.SearchAsync(3, new GlobalSearchQuery { Text = text, ViewerUserId = "u1", Prefix = prefix })).Hits.Select(h => h.EntityId).ToList(); + + (await Find("INV2026.0042")).Should().Equal("inv-1"); + (await Find("inv-2026-42")).Should().Equal(new[] { "inv-1" }, "separators and zero padding are formatting"); + (await Find("2026-0042")).Should().Equal(new[] { "inv-1" }, "the analyzer keeps INV2026.0042 as one word"); + (await Find("BID-26-7")).Should().Equal(new[] { "bid-1" }, "'_' joins BID_26_0007 into one word for the analyzer"); + (await Find("26 0007")).Should().Equal("bid-1"); + (await Find("2026-0043")).Should().BeEmpty(); + } + [Test] public async Task Entity_type_filter_and_date_sort_without_text() { diff --git a/Tests/Resgrid.Tests/Search/IdentifierSearchKeysTests.cs b/Tests/Resgrid.Tests/Search/IdentifierSearchKeysTests.cs new file mode 100644 index 000000000..1ba915f7f --- /dev/null +++ b/Tests/Resgrid.Tests/Search/IdentifierSearchKeysTests.cs @@ -0,0 +1,105 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Search; + +namespace Resgrid.Tests.Search +{ + /// + /// IdentifierSearchKeys: department-formatted numbers (records, calls, invoices, bids...) read the same however they are + /// typed, and are found by any run of their parts. + /// + [TestFixture] + public class IdentifierSearchKeysTests + { + [TestCase("RUN26-0001", "run-26-1")] + [TestCase("run-26-1", "run-26-1")] + [TestCase("RUN 26 0001", "run-26-1")] + [TestCase("2026.0042", "2026-42")] + [TestCase("TRN_2026_0007", "trn-2026-7")] + [TestCase("FD/2026/07/0003", "fd-2026-7-3")] + [TestCase("#1042", "1042")] + [TestCase("0000", "0")] + [TestCase("WO-2027-000001", "wo-2027-1")] + public void Canonical_sets_the_formatting_aside(string text, string expected) + { + IdentifierSearchKeys.Canonical(text).Should().Be(expected); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" - . / ")] + public void Canonical_is_null_without_letters_or_digits(string text) + { + IdentifierSearchKeys.Canonical(text).Should().BeNull(); + } + + [Test] + public void A_number_is_keyed_by_every_run_of_consecutive_parts() + { + IdentifierSearchKeys.For("EZKT27-0001").Should().BeEquivalentTo("ezkt-27-1", "ezkt", "27", "1", "ezkt-27", "27-1"); + } + + [Test] + public void Every_way_of_typing_a_number_or_a_part_of_it_hits_one_of_its_keys() + { + var keys = IdentifierSearchKeys.For("RUN-2026-0153").ToList(); + + foreach (var typed in new[] { "RUN-2026-0153", "run-2026-153", "RUN 2026 153", "2026-153", "0153", "153", "RUN", "run-2026" }) + keys.Should().Contain(IdentifierSearchKeys.Canonical(typed), "\"{0}\" names part of the number", typed); + keys.Should().NotContain(IdentifierSearchKeys.Canonical("2026-0154")); + } + + [Test] + public void Long_numbers_keep_their_whole_key_and_bounded_part_runs() + { + var number = string.Join("-", Enumerable.Range(1, 12).Select(i => "A" + i)); + var keys = IdentifierSearchKeys.For(number).ToList(); + + keys.Should().Contain(IdentifierSearchKeys.Canonical(number)); + keys.Where(k => k != IdentifierSearchKeys.Canonical(number)).Should().OnlyContain(k => k.Split(new[] { '-' }).Length <= IdentifierSearchKeys.MaxPartsPerKey); + } + + [TestCase("WO26-0007", "wo26-0007", true)] + [TestCase("WO26-0007", "WO-26-0007", true)] + [TestCase("WO26-0007", "26 0007", true)] + [TestCase("WO26-0007", "0007", true)] + [TestCase("WO26-0007", "26-7", true)] + [TestCase("WO26-0017", "26-7", true)] + [TestCase("WO26-0007", "0017", false)] + [TestCase("WO26-0007", "7-26", false)] + [TestCase("INV2026.0042", "inv-2026-42", true)] + [TestCase("BID_26_0007", "bid 26 0007", true)] + [TestCase("OCC_2026_0001", "occx2026", false)] + [TestCase("WO26-0007", " - ", false)] + [TestCase(null, "7", false)] + public void List_filters_match_the_searched_runs_in_order(string number, string search, bool expected) + { + IdentifierSearchKeys.ContainsParts(number, search).Should().Be(expected); + } + + [Test] + public void Typed_parts_keep_zero_padding_for_the_sql_pattern() + { + IdentifierSearchKeys.TypedParts("WO-26_0007").Should().Equal("wo", "26", "0007"); + IdentifierSearchKeys.TypedParts("50%_[x]").Should().Equal(new[] { "50", "x" }, "LIKE wildcards never reach the pattern"); + } + + [Test] + public void A_filter_search_is_trimmed_capped_and_dropped_when_it_names_nothing() + { + IdentifierSearchKeys.FilterSearch(" WO26-0007 ").Should().Be("WO26-0007"); + IdentifierSearchKeys.FilterSearch(" -/ ").Should().BeNull(); + IdentifierSearchKeys.FilterSearch(null).Should().BeNull(); + IdentifierSearchKeys.FilterSearch(new string('7', 80)).Should().HaveLength(IdentifierSearchKeys.MaxFilterLength); + } + + [Test] + public void Global_keywords_key_only_the_ones_holding_a_digit() + { + GlobalSearchDocumentBuilder.NumberKeys("Jane Doe INV2026.0042 jane@example.com").Should() + .BeEquivalentTo("inv-2026-42", "inv", "2026", "42", "inv-2026", "2026-42"); + } + } +} diff --git a/Tests/Resgrid.Tests/Search/SearchProjectionServiceTests.cs b/Tests/Resgrid.Tests/Search/SearchProjectionServiceTests.cs index 7abb7982e..c7030a8d0 100644 --- a/Tests/Resgrid.Tests/Search/SearchProjectionServiceTests.cs +++ b/Tests/Resgrid.Tests/Search/SearchProjectionServiceTests.cs @@ -111,6 +111,32 @@ public async Task Notes_are_projected_regardless_with_html_stripped() p.IsAdminOnly.Should().BeTrue(); } + [Test] + public async Task Emailed_call_nature_is_projected_as_plain_text() + { + // Inbound e-mail stores the message HTML as the nature; the search page encodes what it shows, so tags left in + // the projection appeared as literal "
" text in every result. + var call = Call(); + call.NatureOfCall = "
Just a test!

Shawn
"; + call.Notes = "

Second & third

"; + + var p = await _service.BuildCallAsync(call); + + p.Summary.Should().Be("Just a test! Shawn · Fire"); + p.SearchText.Should().Contain("Just a test! Shawn").And.Contain("Second & third").And.NotContain("<").And.NotContain("color"); + } + + [Test] + public async Task Contact_and_document_rich_text_is_projected_as_plain_text() + { + var contact = await _service.BuildContactAsync(new Contact { ContactId = "c1", DepartmentId = 7, FirstName = "Ada", LastName = "Lovelace", Description = "

Alarm panel in lobby

", OtherInfo = "
  • Knox box
" }); + contact.Summary.Should().Be("Alarm panel in lobby"); + contact.SearchText.Should().Contain("Knox box").And.NotContain("<"); + + var document = await _service.BuildDocumentAsync(new Document { DocumentId = 4, DepartmentId = 7, Name = "SOP", Description = "

Hose testing

", AddedOn = new DateTime(2026, 1, 1) }); + document.Summary.Should().Be("Hose testing"); + } + [Test] public async Task Messages_carry_sender_and_recipients_for_query_scoping() { diff --git a/Tests/Resgrid.Tests/Search/UnifiedSearchHtmlTextTests.cs b/Tests/Resgrid.Tests/Search/UnifiedSearchHtmlTextTests.cs new file mode 100644 index 000000000..07faa13cf --- /dev/null +++ b/Tests/Resgrid.Tests/Search/UnifiedSearchHtmlTextTests.cs @@ -0,0 +1,44 @@ +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Search; + +namespace Resgrid.Tests.Search +{ + /// + /// Hits are plain text: the search page and the palette encode them. Rows projected before the builders stripped markup + /// still hold the HTML of e-mailed calls (`<div dir="ltr">...`), so the service strips it when it reads them, and the + /// tags never show up as literal text in the summary or the excerpt. + /// + public partial class UnifiedSearchServiceTests + { + [Test] + public async Task Stored_html_in_old_rows_reaches_the_hit_as_plain_text() + { + _rows[0].Summary = "
Just a test!

Shawn
· Fire"; + _rows[0].SearchText = "1 Main St
Just a test!

Shawn
Fire & rescue"; + + var result = await _service.SearchAsync(new UnifiedSearchRequest { Text = "shawn" }, Principal("Call:View")); + + var hit = result.Hits.Single(h => h.EntityId == "1"); + hit.Summary.Should().Be("Just a test! Shawn · Fire"); + hit.Snippet.Should().Be("1 Main St Just a test! Shawn Fire & rescue"); + hit.Title.Should().Be("Current title"); + } + + [Test] + public async Task Plain_text_hits_are_returned_unchanged() + { + _rows[0].Summary = "Smoke showing · Fire"; + _rows[0].SearchText = "1 Main St Smoke showing pressure < 90"; + + var result = await _service.SearchAsync(new UnifiedSearchRequest { Text = "pressure" }, Principal("Call:View")); + + var hit = result.Hits.Single(h => h.EntityId == "1"); + hit.Summary.Should().Be("Smoke showing · Fire"); + hit.Snippet.Should().Be("1 Main St Smoke showing pressure < 90"); + } + } +} diff --git a/Tests/Resgrid.Tests/Security/Audit20261005/CallsUnitsMvcTests.cs b/Tests/Resgrid.Tests/Security/Audit20261005/CallsUnitsMvcTests.cs index 0dacb56aa..93cc9e628 100644 --- a/Tests/Resgrid.Tests/Security/Audit20261005/CallsUnitsMvcTests.cs +++ b/Tests/Resgrid.Tests/Security/Audit20261005/CallsUnitsMvcTests.cs @@ -546,8 +546,8 @@ public async Task NewCall_offers_only_units_the_member_may_view_and_no_contacts_ var model = (await Build().NewCall()).Should().BeOfType().Which.Model.Should().BeOfType().Which; + // The page lists the units' statuses from Units/GetUnitsForCallGrid, which applies the same filter. model.Units.Select(u => u.UnitId).Should().Equal(1); - model.UnitStates.Select(s => s.UnitId).Should().Equal(1); model.Contacts.Should().BeEmpty(); model.ContactsList.Should().BeNull(); } diff --git a/Tests/Resgrid.Tests/Security/StoredHtmlRenderingTests.cs b/Tests/Resgrid.Tests/Security/StoredHtmlRenderingTests.cs new file mode 100644 index 000000000..065bdd9ee --- /dev/null +++ b/Tests/Resgrid.Tests/Security/StoredHtmlRenderingTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using FluentAssertions; +using NUnit.Framework; + +namespace Resgrid.Tests.Security +{ + /// + /// Stored user text must not reach a page as live markup. + /// + /// Rich-text columns (call nature and notes, messages, department notes, trainings, protocols, documents, calendar + /// items, logs, contact notes...) are written with Html.SanitizedHtml, which runs the allow-list sanitizer at + /// render time: values reach the database from the v4 API, inbound e-mail, imports and older save paths, so the save + /// path alone cannot be trusted. Plain values (names, file names, dates) are written with @value, which encodes. + /// @Html.Raw(Model.Something) does neither, so a stored <img onerror=...> ran in every viewer's browser. + /// + /// + /// JSON written into a page with Newtonsoft must escape HTML (Html.ScriptJson or settings with + /// StringEscapeHandling.EscapeHtml): by default Newtonsoft leaves </script> inside a string as it is, and a + /// group, unit or contact name containing it closed the script block and started the attacker's own. + /// + /// + [TestFixture] + public class StoredHtmlRenderingTests + { + private static readonly Regex RawMemberAccess = new Regex(@"@Html\.Raw\(\s*@?(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*(?:\?\?\s*""[^""]*""\s*)?\)", RegexOptions.Compiled); + private static readonly Regex RawNewtonsoft = new Regex(@"Html\.Raw\(\s*(?:Newtonsoft\.Json\.)?JsonConvert\.SerializeObject\(", RegexOptions.Compiled); + + // Values written with Html.Raw that are built by the server from encoded parts or HTML-escaped JSON, reviewed by name. + private static readonly HashSet ReviewedRawValues = new HashSet(StringComparer.Ordinal) + { + "Model.TreeData", // HtmlHelpers.ScriptJsonSettings JSON (Contacts, Personnel and Units controllers) + "Model.GeoJson", // HtmlHelpers.ScriptJsonSettings JSON (MappingController.EditLayer) + "row.MailingAddress", //
built from HtmlEncode'd parts (ReportsController.PersonnelReport) + "message", // EscapeHtml JSON (Account/SsoReturnGrant) + "ViewBag.BodyObjects" // layout hook, never set from user input + }; + + private static string WebRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + + Assert.That(directory, Is.Not.Null, "the repository root should be locatable from the test directory"); + return Path.Combine(directory!.FullName, "Web"); + } + + private static IEnumerable Views(string webRoot) => + Directory.EnumerateFiles(webRoot, "*.cshtml", SearchOption.AllDirectories) + .Where(p => !p.Split(Path.DirectorySeparatorChar).Any(s => s == "bin" || s == "obj" || s == "node_modules")) + .OrderBy(p => p, StringComparer.Ordinal); + + private static int Line(string text, int index) => text.Take(index).Count(c => c == '\n') + 1; + + private static bool IsServerBuilt(string path) + { + if (ReviewedRawValues.Contains(path)) + return true; + var last = path.Substring(path.LastIndexOf('.') + 1); + // ...Json values are serialized by the controller or the view; ...Html values are generated markup (UDF forms); + // ...Msg values are JavaScript-encoded confirm texts; ...Count values are numbers. + return last.EndsWith("Json", StringComparison.Ordinal) || last.EndsWith("Html", StringComparison.Ordinal) || + last.EndsWith("Msg", StringComparison.Ordinal) || last == "Count"; + } + + [Test] + public void stored_text_should_not_be_written_with_html_raw() + { + var webRoot = WebRoot(); + var problems = new List(); + var seen = 0; + + foreach (var view in Views(webRoot)) + { + var text = File.ReadAllText(view); + foreach (Match match in RawMemberAccess.Matches(text)) + { + seen++; + var path = match.Groups["path"].Value; + if (!IsServerBuilt(path)) + problems.Add($"{Path.GetRelativePath(webRoot, view)}:{Line(text, match.Index)} {match.Value}"); + } + } + + seen.Should().BeGreaterThan(10, "views write server-built JSON and UDF markup with Html.Raw; if none are found the scan is broken"); + if (problems.Count > 0) + { + var message = new StringBuilder(); + message.AppendLine($"{problems.Count} view(s) write a stored value with Html.Raw. Use @Html.SanitizedHtml(value) for rich text, @value for plain text, or Html.ScriptJson(value) for JSON:"); + foreach (var problem in problems) + message.AppendLine(" " + problem); + Assert.Fail(message.ToString()); + } + } + + [Test] + public void json_written_into_views_should_escape_html() + { + var webRoot = WebRoot(); + var problems = new List(); + var seen = 0; + + foreach (var view in Views(webRoot)) + { + var text = File.ReadAllText(view); + foreach (Match match in RawNewtonsoft.Matches(text)) + { + seen++; + var settings = LastArgument(text, match.Index + match.Length - 1); + if (settings == null || !EscapesHtml(settings.Trim(), text)) + problems.Add($"{Path.GetRelativePath(webRoot, view)}:{Line(text, match.Index)}"); + } + } + + seen.Should().BeGreaterThan(20, "views pass EscapeHtml settings to JsonConvert inside Html.Raw; if none are found the scan is broken"); + if (problems.Count > 0) + { + var message = new StringBuilder(); + message.AppendLine($"{problems.Count} Html.Raw(JsonConvert.SerializeObject(...)) call(s) do not escape HTML, so a stored \"\" closes the script block. Use @Html.ScriptJson(value):"); + foreach (var problem in problems) + message.AppendLine(" " + problem); + Assert.Fail(message.ToString()); + } + } + + /// + /// Text after the last top-level comma of the call whose '(' is at (the settings, when passed); + /// null when there is none. Generic argument lists are not tracked, so "new Dictionary<string, string> {...}" without + /// settings yields "string> {...}", which is not settings. + /// + private static string LastArgument(string text, int open) + { + var depth = 0; + var comma = -1; + for (var i = open; i < text.Length; i++) + { + var c = text[i]; + if (c == '"') + { + for (i++; i < text.Length && text[i] != '"'; i++) + if (text[i] == '\\') i++; + continue; + } + if (c == '(' || c == '{' || c == '[') depth++; + else if (c == ')' || c == '}' || c == ']') + { + depth--; + if (depth == 0) + return comma < 0 ? null : text.Substring(comma + 1, i - comma - 1); + } + else if (c == ',' && depth == 1) comma = i; + } + return null; + } + + private static bool EscapesHtml(string settings, string view) + { + if (settings.Contains("EscapeHtml", StringComparison.Ordinal) || settings.Contains("ScriptJsonSettings", StringComparison.Ordinal)) + return true; + if (!Regex.IsMatch(settings, @"^[A-Za-z_]\w*$")) + return false; + // A settings variable declared in the view. + return Regex.IsMatch(view, @"\b" + Regex.Escape(settings) + @"\s*=\s*new\s+(?:Newtonsoft\.Json\.)?JsonSerializerSettings\s*\{[^}]*EscapeHtml"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CallNumberingTests.cs b/Tests/Resgrid.Tests/Services/CallNumberingTests.cs index 82b3d4dc6..be6b3c865 100644 --- a/Tests/Resgrid.Tests/Services/CallNumberingTests.cs +++ b/Tests/Resgrid.Tests/Services/CallNumberingTests.cs @@ -125,6 +125,57 @@ public void The_year_start_round_trips_through_protobuf_and_unset_is_the_calenda #endregion + #region Sorting + + [Test] + public void Legacy_numbers_sort_by_year_then_sequence_not_by_text() + { + Sorted(CallNumberFormat.LegacyPattern, "26-10", "26-11", "26-12", "26-25", "26-38", "26-5", "26-6", "26-7", "26-8", "25-100", "99-4") + .Should().Equal("99-4", "25-100", "26-5", "26-6", "26-7", "26-8", "26-10", "26-11", "26-12", "26-25", "26-38"); + } + + [TestCase("FD{YYYY}-{SEQ}", "FD2026-0010,FD2026-12345,FD2026-0002,FD2025-0150,FD2026-9999", "FD2025-0150,FD2026-0002,FD2026-0010,FD2026-9999,FD2026-12345")] + [TestCase("{SEQ}/{YYYY}", "10/2025,2/2026,1/2026,9/2025", "9/2025,10/2025,1/2026,2/2026")] + [TestCase("{YY}{MM}{DD}{SEQ}", "26102310,2610239,2610241,2609309", "2609309,2610239,26102310,2610241")] + [TestCase("CAD.{YYYY}.{MM}.{SEQ}", "CAD.2026.10.10,CAD.2026.09.12,CAD.2026.10.7", "CAD.2026.09.12,CAD.2026.10.7,CAD.2026.10.10")] + [TestCase("{SEQ}", "100,9,10", "9,10,100")] + public void Custom_numbers_sort_in_the_order_they_were_issued(string pattern, string numbers, string expected) + { + // ',' is never a pattern literal, so it can separate the numbers here. + Sorted(pattern, numbers.Split(',')).Should().Equal(expected.Split(',')); + } + + [Test] + public void Legacy_numbers_issued_before_a_pattern_change_still_sort_by_year_and_sequence() + { + Sorted("FD{YYYY}-{SEQ}", "FD2026-0154", "26-153", "25-900", "FD2026-0155") + .Should().Equal("25-900", "26-153", "FD2026-0154", "FD2026-0155"); + } + + [Test] + public void Text_no_pattern_wrote_sorts_first_with_digit_runs_compared_as_numbers() + { + Sorted("{YYYY}-{SEQ}", "2026-1", "OLD-10", "old-9", null, "OLD-2-B") + .Should().Equal(null, "OLD-2-B", "old-9", "OLD-10", "2026-1"); + } + + [Test] + public void Sort_keys_compare_the_same_in_csharp_and_javascript() + { + // The grids compare keys with JavaScript's < (UTF-16 code units, like Ordinal) after DataTables lower-cases them, so a + // key that is already lower case sorts there exactly as asserted here. + var key = CallNumberFormat.SortKeys("Fd{YYYY}-{SEQ}"); + foreach (var number in new[] { "Fd2026-7", "26-153", "OLD-12", "X/Y_Z.9" }) + key(number).Should().Be(key(number).ToLowerInvariant()); + } + + private static string[] Sorted(string pattern, params string[] numbers) + { + return numbers.OrderBy(CallNumberFormat.SortKeys(pattern), StringComparer.Ordinal).ToArray(); + } + + #endregion + #region Numbering year [TestCase(2026, 10, 31, 2026, "the day before the fiscal year starts is still FY2026")] diff --git a/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs b/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs index 1c9923d6f..b4ab9ffd3 100644 --- a/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs @@ -90,7 +90,7 @@ public void SetUp() documents.Setup(r => r.GetExpiringAsync(It.IsAny())).ReturnsAsync((DateTime asOf) => _documents.Where(d => !d.IsDeleted && d.ExpiresOn.HasValue && d.ExpiresOn <= asOf.AddDays(d.AlertLeadDays)).ToList()); var bids = Repo(_bids, b => b.BidId, (b, id) => b.BidId = id); bids.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _bids.FirstOrDefault(b => b.BidId == id)); - bids.Setup(r => r.GetForDepartmentAsync(DeptId, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int _, int? status, int __, int ___) => _bids.Where(b => !b.IsDeleted && (!status.HasValue || b.Status == status)).ToList()); + bids.Setup(r => r.GetForDepartmentAsync(DeptId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int _, int? status, string number, int __, int ___) => _bids.Where(b => !b.IsDeleted && (!status.HasValue || b.Status == status) && (number == null || IdentifierSearchKeys.ContainsParts(b.NumberText(), number))).ToList()); bids.Setup(r => r.GetByContactIdAsync(DeptId, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int _, string contactId, int __, int ___) => _bids.Where(b => b.ContactId == contactId && !b.IsDeleted).ToList()); bids.Setup(r => r.GetByContractAsync(It.IsAny())).ReturnsAsync((string contractId) => _bids.Where(b => b.ServiceContractId == contractId && !b.IsDeleted).ToList()); bids.Setup(r => r.GetExpiryCandidatesAsync(It.IsAny())).ReturnsAsync((DateTime asOf) => _bids.Where(b => b.Status == (int)BidStatuses.Submitted && b.ValidUntil < asOf).ToList()); diff --git a/Tests/Resgrid.Tests/Services/InventoryM5ReportTests.cs b/Tests/Resgrid.Tests/Services/InventoryM5ReportTests.cs index 8ab2c65cb..5ef3f5bf8 100644 --- a/Tests/Resgrid.Tests/Services/InventoryM5ReportTests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryM5ReportTests.cs @@ -201,6 +201,21 @@ public void M5_report_documents_encode_authored_values_support_locales_and_have_ InventoryReportDocuments.Locked(InventoryReportKind.OnHand, CultureInfo.GetCultureInfo("ar")).Should().Contain("dir=\"rtl\"").And.NotContain("canary"); } + [Test] + public void M5_every_report_kind_has_a_title_and_reports_list_summary_in_each_supported_locale() + { + // Reports/Index lists every kind beside the classic reports; a missing key would silently show English or the raw key. + var strings = new System.Resources.ResourceManager("Resgrid.Localization.Areas.User.Inventory.Inventory", typeof(Resgrid.Localization.SupportedLocales).Assembly); + foreach (var language in Resgrid.Localization.SupportedLocales.GetSupportedCultures()) + { + var resources = strings.GetResourceSet(CultureInfo.GetCultureInfo(language), true, false); + resources.Should().NotBeNull(language); + foreach (var kind in Enum.GetValues()) + foreach (var key in new[] { "M5Report" + kind, "M5ReportSummary" + kind }) + resources.GetString(key).Should().NotBeNullOrWhiteSpace(key + " in " + language).And.NotBe(key); + } + } + private InventoryAsset M5Asset(InventoryItem item, InventoryLocation location, int status, string serial) => _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = item.Id, CurrentLocationId = location.Id, Status = status, Content = JsonConvert.SerializeObject(new InventoryAssetContent { SerialNumber = serial }) }); private InventoryTransaction M5Ledger(InventoryItem item, InventoryLocation from, InventoryLocation to, decimal quantity, InventoryTransactionType type) => _store.Seed(new InventoryTransaction { diff --git a/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs b/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs index f66ae0459..4d5665afb 100644 --- a/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/NearestUnitServiceTests.cs @@ -6,6 +6,7 @@ using Moq; using NUnit.Framework; using Resgrid.Model; +using Resgrid.Model.Identity; using Resgrid.Model.Services; using Resgrid.Services; @@ -55,6 +56,7 @@ public class NearestUnitServiceTests private Mock _departmentSettingsService; private Mock _geoService; private Mock _authorizationService; + private Mock _departmentsService; private DispatchScope _scope; private DispatchRecommendationConfig _config; @@ -75,6 +77,7 @@ public void SetUp() _departmentSettingsService = new Mock(); _geoService = new Mock(); _authorizationService = new Mock(); + _departmentsService = new Mock(); var now = DateTime.UtcNow; @@ -131,6 +134,9 @@ public void SetUp() new UserGroupRole { UserId = "medic-b", DepartmentGroupId = StationB, FirstName = "Bo", LastName = "Medic" }, new UserGroupRole { UserId = "night-c", DepartmentGroupId = StationC, FirstName = "Nia", LastName = "Night" } }); + // The dispatch lists' member set (plan-limited); everyone fits the plan here. + _departmentsService.Setup(x => x.GetAllUsersForDepartmentAsync(DepartmentId, false, false)) + .ReturnsAsync(() => new[] { "clin-a", "peer-a", "medic-b", "night-c" }.Select(id => new IdentityUser { Id = id }).ToList()); _personnelRolesService.Setup(x => x.GetAllRolesForUsersInDepartmentAsync(DepartmentId)).ReturnsAsync(new Dictionary> { { "clin-a", new List { new PersonnelRole { PersonnelRoleId = ClinicianRoleId, Name = "Clinician" } } }, @@ -169,7 +175,7 @@ private NearestUnitService BuildService() return new NearestUnitService(_dispatchScopeService.Object, _departmentGroupsService.Object, _unitsService.Object, _usersService.Object, _personnelRolesService.Object, _actionLogsService.Object, _userStateService.Object, _customStateService.Object, _personnelLocationResolver.Object, _shiftsService.Object, _departmentSettingsService.Object, _geoService.Object, - _authorizationService.Object); + _authorizationService.Object, _departmentsService.Object); } private Task GetBoardAsync(bool? useRoadEta = null) @@ -422,6 +428,21 @@ public async Task individual_responders_are_listed_with_the_unit_they_are_on() board.Personnel.Single(p => p.UserId == "medic-b").IsAvailable.Should().BeFalse(); } + [Test] + public async Task a_member_outside_the_plans_personnel_limit_is_left_off_the_board_as_the_dispatch_lists_leave_them_off() + { + // Over its plan's limit, the department's dispatch lists stop at the first three members; Nia is past the cap. + _departmentsService.Setup(x => x.GetAllUsersForDepartmentAsync(DepartmentId, false, false)) + .ReturnsAsync(new[] { "clin-a", "peer-a", "medic-b" }.Select(id => new IdentityUser { Id = id }).ToList()); + + var board = await GetBoardAsync(); + + board.Personnel.Select(p => p.UserId).Should().BeEquivalentTo(new[] { "clin-a", "peer-a", "medic-b" }); + // Nor is she crew: the far team's station shift has nobody left on it. + UnitRow(board, FarTeamUnit).CrewSource.Should().Be(UnitCrewSources.None); + UnitRow(board, FarTeamUnit).Crew.Should().BeEmpty(); + } + [Test] public async Task responders_carry_their_status_and_staffing_as_the_lists_show_them() { diff --git a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs index f6d5a4c6c..adcc3823f 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderDatabaseTests.cs @@ -53,7 +53,7 @@ public async Task CreateIsolatedDatabaseAndMigrate() if (_type == DatabaseTypes.Postgres) { var builder = new NpgsqlConnectionStringBuilder(_master) { Database = _database }; _connection = builder.ConnectionString; } else { var builder = new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }; _connection = builder.ConnectionString; } await using var db = Connect(_connection); - await db.ExecuteAsync(_type == DatabaseTypes.Postgres ? "CREATE TABLE departments(departmentid integer PRIMARY KEY); INSERT INTO departments VALUES(77),(88);" : "CREATE TABLE Departments(DepartmentId int PRIMARY KEY); INSERT INTO Departments VALUES(77),(88);"); + await db.ExecuteAsync(_type == DatabaseTypes.Postgres ? "CREATE TABLE departments(departmentid integer PRIMARY KEY); INSERT INTO departments VALUES(77),(88),(99);" : "CREATE TABLE Departments(DepartmentId int PRIMARY KEY); INSERT INTO Departments VALUES(77),(88),(99);"); await db.ExecuteAsync($"CREATE TABLE {Q("AuditLogs")} ({Q("AuditLogId")} int PRIMARY KEY, {Q("DepartmentId")} int, {Q("LogType")} int, {Q("Data")} {TextType}); CREATE TABLE {Q("DomainEventOutbox")} ({Q("DomainEventOutboxId")} bigint PRIMARY KEY, {Q("DepartmentId")} int, {Q("ProducerSubsystem")} varchar(100), {Q("PayloadJson")} {TextType}, {Q("LastError")} {TextType}, {Q("AggregateId")} varchar(36)); CREATE TABLE {Q("WorkflowRuns")} ({Q("WorkflowRunId")} varchar(36) PRIMARY KEY, {Q("WorkflowId")} varchar(36), {Q("DepartmentId")} int, {Q("TriggerEventType")} int, {Q("Status")} int, {Q("AttemptNumber")} int, {Q("InputPayload")} {TextType}, {Q("ErrorMessage")} varchar(4000)); CREATE TABLE {Q("WorkflowRunLogs")} ({Q("WorkflowRunLogId")} varchar(36) PRIMARY KEY, {Q("WorkflowRunId")} varchar(36), {Q("RenderedOutput")} {TextType}, {Q("ActionResult")} varchar(4000), {Q("ErrorMessage")} varchar(4000));"); await db.ExecuteAsync($"CREATE TABLE {Q("InventoryTransactions")} ({Q("Id")} varchar(36) PRIMARY KEY, {Q("DepartmentId")} int NOT NULL);"); var identity = _type == DatabaseTypes.Postgres ? "integer GENERATED BY DEFAULT AS IDENTITY" : "int IDENTITY(1,1)"; @@ -155,6 +155,26 @@ public async Task Scoped_queue_filters_role_assignments_and_keeps_page_boundarie (await store.ListAsync(77, new WorkOrderReadScope { UserId = "outsider" }, new WorkOrderFilter())).Should().BeEmpty(); } [Test, Order(10)] + public async Task Number_filter_finds_department_formatted_numbers_however_typed() + { + using var uow = new UnitOfWork(Connections()); var store = Orders(uow); + await uow.CreateOrGetConnectionAsync(); await store.LockDepartmentAsync(99); + foreach (var number in new[] { "MNT26-0007", "MNT26-0017", "MNT_2026_0042" }) + { + var row = Order(99); row.NumberSequence = await store.NextNumberAsync(99, row.NumberYear); row.DisplayNumber = number; + await store.AllocateAsync(row); row.Content = "{}"; await store.WriteAsync(row); + } + uow.CommitChanges(); + var scope = new WorkOrderReadScope { UserId = "author", All = true }; + async Task> Find(string number) => (await store.ListAsync(99, scope, new WorkOrderFilter { Number = number })).Select(o => o.DisplayNumber).ToList(); + (await Find("mnt26-0007")).Should().Equal("MNT26-0007"); + (await Find("MNT-26-0007")).Should().Equal(new[] { "MNT26-0007" }, "separators typed where the pattern has none"); + (await Find("0042")).Should().Equal("MNT_2026_0042"); + (await Find("26-7")).Should().Equal(new[] { "MNT26-0017", "MNT26-0007" }, "an unpadded sequence matches every number holding it, newest first"); + (await Find("2027")).Should().BeEmpty(); + (await Find(null)).Should().HaveCount(3); + } + [Test, Order(10)] public async Task File_metadata_omits_blobs_and_notification_leases_fence_retries() { using var uow = new UnitOfWork(Connections()); var store = Orders(uow); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs index 8eaf8b483..ace83cad6 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs @@ -76,11 +76,11 @@ public async Task> GetAccess() [HttpGet("GetBids")] [Authorize(Policy = ResgridResources.Bids_View)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetBids(int? status = null, string contactId = null, int skip = 0, int take = 100) + public async Task> GetBids(int? status = null, string contactId = null, int skip = 0, int take = 100, string number = null) { if (!await EnabledAsync()) return Failed("contractor_billing_disabled", StatusCodes.Status403Forbidden); var bids = string.IsNullOrWhiteSpace(contactId) - ? await _bids.GetBidsForDepartmentAsync(DepartmentId, status.HasValue && Enum.IsDefined(typeof(BidStatuses), status.Value) ? (BidStatuses?)status.Value : null, skip, take) + ? await _bids.GetBidsForDepartmentAsync(DepartmentId, status.HasValue && Enum.IsDefined(typeof(BidStatuses), status.Value) ? (BidStatuses?)status.Value : null, number, skip, take) : await _bids.GetBidsByContactIdAsync(contactId, DepartmentId, skip, take); var result = new BidsResult { Data = bids.Select(b => Map(b, false)).ToList(), PageSize = bids.Count, Status = ResponseHelper.Success }; ResponseHelper.PopulateV4ResponseData(result); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DisclosuresController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DisclosuresController.cs index 49b30eb0e..c7161ce30 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DisclosuresController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DisclosuresController.cs @@ -49,13 +49,13 @@ public DisclosuresController(IRecordsDisclosureService disclosures, IRecordsCuto /// The disclosure queue, newest first; filters to one state. [HttpGet("GetRequests")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetRequests(int? state, int skip = 0, int take = 50) + public async Task> GetRequests(int? state, int skip = 0, int take = 50, string number = null) { if (!await FlagOnAsync()) return NotFound(); var states = state.HasValue ? new List { (RmsDisclosureState)state.Value } : null; - var requests = await _disclosures.QueryAsync(DepartmentId, UserId, states, Math.Max(0, skip), Math.Max(1, Math.Min(RecordsController.MaxPageSize, take))); + var requests = await _disclosures.QueryAsync(DepartmentId, UserId, states, Math.Max(0, skip), Math.Max(1, Math.Min(RecordsController.MaxPageSize, take)), number); var canViewRestricted = await _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ViewRestrictedRecords); var result = new DisclosureRequestsResult diff --git a/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs index dab30673d..33ccc309d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs @@ -72,12 +72,12 @@ public async Task> GetAccess() [Authorize(Policy = ResgridResources.Invoicing_View)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetInvoices(int? status = null, string contactId = null, int skip = 0, int take = 50) + public async Task> GetInvoices(int? status = null, string contactId = null, int skip = 0, int take = 50, string number = null) { if (!await EnabledAsync()) return NotFound(); - var filter = new InvoiceListFilter { Statuses = status.HasValue ? new[] { status.Value } : null, ContactId = contactId, Skip = Math.Max(0, skip), Take = Math.Clamp(take, 1, 200) }; + var filter = new InvoiceListFilter { Statuses = status.HasValue ? new[] { status.Value } : null, ContactId = contactId, Number = IdentifierSearchKeys.FilterSearch(number), Skip = Math.Max(0, skip), Take = Math.Clamp(take, 1, 200) }; var invoices = await _invoicing.GetInvoicesForDepartmentAsync(DepartmentId, filter); var names = await ContactNamesAsync(invoices.Select(x => x.ContactId)); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordInspectionsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordInspectionsController.cs index 970f72354..bfa72df64 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordInspectionsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordInspectionsController.cs @@ -108,12 +108,12 @@ public async Task> SaveProgram([FromBody] [HttpGet("List")] [Authorize(Policy = ResgridResources.Record_View)] - public async Task> List(string occupancyId = null, string programId = null, [FromQuery] List states = null, string inspectorUserId = null, DateTime? scheduledBefore = null, int skip = 0, int take = 50) + public async Task> List(string occupancyId = null, string programId = null, [FromQuery] List states = null, string inspectorUserId = null, DateTime? scheduledBefore = null, int skip = 0, int take = 50, string number = null) { if (!await FlagOnAsync()) return NotFound(); try { - var query = new RmsInspectionQuery { OccupancyId = occupancyId, ProgramId = programId, States = states, InspectorUserId = inspectorUserId, ScheduledBefore = scheduledBefore, Skip = skip, Take = take }; + var query = new RmsInspectionQuery { OccupancyId = occupancyId, ProgramId = programId, States = states, InspectorUserId = inspectorUserId, ScheduledBefore = scheduledBefore, Number = IdentifierSearchKeys.FilterSearch(number), Skip = skip, Take = take }; var r = new InspectionsResult { Data = (await _inspections.ListAsync(DepartmentId, UserId, query)).Select(RecordsRms5ApiMapper.ToInspection).ToList(), TotalCount = await _inspections.CountAsync(DepartmentId, UserId, query) }; r.PageSize = r.Data.Count; return Ok(Done(r)); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordInvestigationsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordInvestigationsController.cs index 868abb3da..865ef1b5b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordInvestigationsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordInvestigationsController.cs @@ -35,10 +35,10 @@ public RecordInvestigationsController(IRecordsInvestigationsService investigatio private InvestigationCaseSavedResult Wrap(RmsInvestigationCase c) => Done(new InvestigationCaseSavedResult { Data = RecordsRms5ApiMapper.ToCase(c), PageSize = 1 }); [HttpGet("MyCases")] - public async Task> MyCases(bool includeClosed = false) + public async Task> MyCases(bool includeClosed = false, string number = null) { if (!await FlagOnAsync()) return NotFound(); - try { var r = new InvestigationCasesResult { Data = (await _investigations.ListMyCasesAsync(DepartmentId, UserId, includeClosed)).Select(RecordsRms5ApiMapper.ToCase).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } + try { var r = new InvestigationCasesResult { Data = (await _investigations.ListMyCasesAsync(DepartmentId, UserId, includeClosed, number)).Select(RecordsRms5ApiMapper.ToCase).ToList() }; r.PageSize = r.Data.Count; return Ok(Done(r)); } catch (Exception ex) { return Fail(ex); } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordPermitsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordPermitsController.cs index 8a02a8a6c..851b15f12 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordPermitsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordPermitsController.cs @@ -49,12 +49,12 @@ public async Task> SaveType([FromBody] PermitType [HttpGet("List")] [Authorize(Policy = ResgridResources.Record_View)] - public async Task> List(string occupancyId = null, string permitTypeId = null, [FromQuery] List states = null, DateTime? expiresBefore = null, int skip = 0, int take = 50) + public async Task> List(string occupancyId = null, string permitTypeId = null, [FromQuery] List states = null, DateTime? expiresBefore = null, int skip = 0, int take = 50, string number = null) { if (!await FlagOnAsync()) return NotFound(); try { - var query = new RmsPermitQuery { OccupancyId = occupancyId, PermitTypeId = permitTypeId, States = states, ExpiresBefore = expiresBefore, Skip = skip, Take = take }; + var query = new RmsPermitQuery { OccupancyId = occupancyId, PermitTypeId = permitTypeId, States = states, ExpiresBefore = expiresBefore, Number = IdentifierSearchKeys.FilterSearch(number), Skip = skip, Take = take }; var r = new PermitsResult { Data = (await _permits.ListAsync(DepartmentId, UserId, query)).Select(RecordsRms5ApiMapper.ToPermit).ToList(), TotalCount = await _permits.CountAsync(DepartmentId, UserId, query) }; r.PageSize = r.Data.Count; return Ok(Done(r)); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs index 9a06cf7a2..1a25b6b7b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs @@ -337,9 +337,12 @@ public async Task> Search(string text, int? year, st { try { + // The year filter is the department's numbering year, as on the queue without text. + var yearWindow = year.HasValue ? await _recordsService.GetYearWindowAsync(DepartmentId, year.Value) : null; search = await _recordsSearch.SearchAsync(DepartmentId, new RecordsSearchRequest { - Text = text.Trim(), VisibleGroupIds = visible, ViewerUserId = RecordsSystemPrincipal.IsSystemPrincipal(User) ? null : UserId, States = states, DefinitionKey = string.IsNullOrWhiteSpace(definitionKey) ? null : definitionKey, Year = year, Skip = skip, Take = take + Text = text.Trim(), VisibleGroupIds = visible, ViewerUserId = RecordsSystemPrincipal.IsSystemPrincipal(User) ? null : UserId, States = states, DefinitionKey = string.IsNullOrWhiteSpace(definitionKey) ? null : definitionKey, Year = year, + OccurredFrom = yearWindow?.FromUtc, OccurredBefore = yearWindow?.BeforeUtc, Skip = skip, Take = take }); } catch (Exception ex) diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 1ed299ac9..500a0576e 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -2398,7 +2398,7 @@
- + The disclosure queue, newest first; filters to one state. @@ -3180,7 +3180,7 @@ Whether invoicing is available to this department and what the add-on costs. Always answers. - + Invoices for the department, newest number first. Optional status filter (InvoiceStatus value) and contact filter. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs index 76e500619..fa9fc8434 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs @@ -119,12 +119,12 @@ private async Task ContactAsync(string contactId) #endregion [HttpGet] - public async Task Index(int? status = null, int page = 1) + public async Task Index(int? status = null, int page = 1, string number = null) { - var view = Page(new BidIndexView { StatusFilter = status, Page = Math.Max(1, page) }); + var view = Page(new BidIndexView { StatusFilter = status, Number = IdentifierSearchKeys.FilterSearch(number), Page = Math.Max(1, page) }); var filter = status.HasValue && Enum.IsDefined(typeof(BidStatuses), status.Value) ? (BidStatuses?)status.Value : null; - view.Total = await _bids.CountBidsForDepartmentAsync(DepartmentId, filter); - view.Bids = await _bids.GetBidsForDepartmentAsync(DepartmentId, filter, (view.Page - 1) * view.PageSize, view.PageSize); + view.Total = await _bids.CountBidsForDepartmentAsync(DepartmentId, filter, view.Number); + view.Bids = await _bids.GetBidsForDepartmentAsync(DepartmentId, filter, view.Number, (view.Page - 1) * view.PageSize, view.PageSize); var wanted = new HashSet(view.Bids.Select(b => b.ContactId), StringComparer.OrdinalIgnoreCase); if (wanted.Count > 0) foreach (var contact in await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId) ?? new List()) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index b72966e0a..92a780b25 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -140,7 +140,7 @@ public async Task Index() trees.Add(tree); } } - model.TreeData = Newtonsoft.Json.JsonConvert.SerializeObject(trees); + model.TreeData = Newtonsoft.Json.JsonConvert.SerializeObject(trees, HtmlHelpers.ScriptJsonSettings); return View(model); } @@ -1115,7 +1115,7 @@ public async Task GetNotesJson(string contactId) { var noteJson = new ContactNoteJson(); noteJson.ContactNoteId = note.ContactNoteId; - noteJson.Note = ProtectedDataEnvelope.SafeDisplay(note.Note); + noteJson.Note = StringHelpers.SanitizeHtmlForDisplay(ProtectedDataEnvelope.SafeDisplay(note.Note)); if (note.ExpiresOn.HasValue) noteJson.ExpiresOn = note.ExpiresOn.Value.FormatForDepartment(department); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs index 638c6d4bf..37d85f932 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs @@ -52,7 +52,7 @@ public DisclosuresController(IRecordsDisclosureService disclosures, IRecordsCuto #region Queue [HttpGet] - public async Task Index(int? state) + public async Task Index(int? state, string number = null) { var moduleState = await _cutoverService.GetModuleStateAsync(DepartmentId); if (!moduleState.FlagEnabled) @@ -64,6 +64,7 @@ public async Task Index(int? state) ModuleState = moduleState, Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), StateFilter = state, + NumberFilter = IdentifierSearchKeys.FilterSearch(number), States = StateItems(), RedactionProfiles = ProfileItems(), CanViewRestricted = await _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ViewRestrictedRecords), @@ -74,7 +75,7 @@ public async Task Index(int? state) .OrderBy(n => n.Name).Select(n => new SelectListItem { Value = n.UserId, Text = n.Name }).ToList(); if (moduleState.RecordsUsable) - model.Requests = await _disclosures.QueryAsync(DepartmentId, UserId, states, 0, 200); + model.Requests = await _disclosures.QueryAsync(DepartmentId, UserId, states, 0, 200, model.NumberFilter); if (TempData["RecordsMessage"] is string message) model.Message = message; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index c0a3bda83..5e029ecd4 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -2740,7 +2740,8 @@ public async Task GetCallNotes(int callId) note.IsFlagged = flaggedCallNoteIds.Contains(callNote.CallNoteId.ToString(CultureInfo.InvariantCulture)); note.Name = name.Name; note.Timestamp = callNote.Timestamp.TimeConverter(call.Department).FormatForDepartment(call.Department); - note.Note = ProtectedDataEnvelope.SafeDisplay(callNote.Note); + // Rendered as HTML by the call view; markup from any save path (v4 API, older clients) is sanitized here. + note.Note = StringHelpers.SanitizeHtmlForDisplay(ProtectedDataEnvelope.SafeDisplay(callNote.Note)); note.UserId = callNote.UserId; if (callNote.Latitude.HasValue && callNote.Longitude.HasValue) @@ -3248,7 +3249,8 @@ public async Task GetCallById(int callId) call.PriorityEnum = (CallPriority)savedCall.Priority; call.Name = ProtectedDataEnvelope.SafeDisplay(savedCall.Name); call.State = DispatchDisplayHelper.GetLocalizedCallState(savedCall.State, _dispatchLocalizer, _commonLocalizer); - call.Nature = ProtectedDataEnvelope.SafeDisplay(savedCall.NatureOfCall); + // The new-log page puts the nature into its editor as HTML. + call.Nature = StringHelpers.SanitizeHtmlForDisplay(ProtectedDataEnvelope.SafeDisplay(savedCall.NatureOfCall)); call.Address = ProtectedDataEnvelope.SafeDisplay(savedCall.Address); return Json(call); @@ -3502,6 +3504,7 @@ public async Task GetActiveCallsList() .OrderByDescending(x => x.LoggedOn); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var callTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); + var numberSort = await CallNumberSortKeysAsync(DepartmentId); foreach (var call in calls) { @@ -3509,6 +3512,7 @@ public async Task GetActiveCallsList() callJson.TypeIconUrl = TypeIconHtml.Url(CallTypeLink.Find(call, callTypes)); callJson.CallId = call.CallId; callJson.Number = call.Number; + callJson.NumberSort = numberSort(call.Number); callJson.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); @@ -3545,6 +3549,7 @@ public async Task GetArchivedCallsList(string year) var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var callTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId); + var numberSort = await CallNumberSortKeysAsync(DepartmentId); foreach (var call in calls) { @@ -3552,6 +3557,7 @@ public async Task GetArchivedCallsList(string year) callJson.TypeIconUrl = TypeIconHtml.Url(CallTypeLink.Find(call, callTypes)); callJson.CallId = call.CallId; callJson.Number = call.Number; + callJson.NumberSort = numberSort(call.Number); callJson.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); @@ -3582,12 +3588,14 @@ await _callsService.GetAllNonDispatchedScheduledCallsByDepartmentIdAsync(Departm .OrderBy(x => x.DispatchOn); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var numberSort = await CallNumberSortKeysAsync(DepartmentId); foreach (var call in calls) { var callJson = new CallListJson(); callJson.CallId = call.CallId; callJson.Number = call.Number; + callJson.NumberSort = numberSort(call.Number); callJson.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); @@ -3620,12 +3628,14 @@ public async Task GetPendingCallsList() await _callsService.GetPendingCallsByDepartmentIdAsync(DepartmentId)); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var numberSort = await CallNumberSortKeysAsync(DepartmentId); foreach (var call in calls.OrderBy(x => x.LoggedOn)) { var callJson = new CallListJson(); callJson.CallId = call.CallId; callJson.Number = call.Number; + callJson.NumberSort = numberSort(call.Number); callJson.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); callJson.Address = ProtectedDataEnvelope.SafeDisplay(call.Address); callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); @@ -3659,6 +3669,12 @@ public async Task GetWaitingCallCounts() return Json(new { pending = pending.Count(), scheduled = scheduled.Count() }); } + /// Sort keys for the department's call numbers, so the call grids order them as issued under any numbering pattern. + private async Task> CallNumberSortKeysAsync(int departmentId) + { + return CallNumberFormat.SortKeys(CallNumberFormat.EffectivePattern(await _departmentSettingsService.GetCallNumberingConfigAsync(departmentId))); + } + [HttpPost] [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Call_View)] @@ -3867,7 +3883,7 @@ public async Task GetAlertNotesForContact(string contactId) ContactNoteJson json = new ContactNoteJson(); json.Id = note.ContactNoteTypeId; json.ContactId = contactId; - json.Note = note.Note; + json.Note = StringHelpers.SanitizeHtmlForDisplay(ProtectedDataEnvelope.SafeDisplay(note.Note)); json.ShouldAlert = note.ShouldAlert; json.AddedOn = note.AddedOn.FormatForDepartment(department, true); json.AddedBy = await UserHelper.GetFullNameForUser(note.AddedByUserId); @@ -3992,8 +4008,6 @@ private async Task FillNewCallView(NewCallView model) model.CenterCoordinates = await _departmentSettingsService.GetMapCenterCoordinatesAsync(model.Department); model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(model.Department.DepartmentId); model.Units = await GetViewableUnitsAsync(await _unitsService.GetUnitsForDepartmentAsync(model.Department.DepartmentId)); - model.UnitTypes = await _unitsService.GetUnitTypesForDepartmentAsync(model.Department.DepartmentId) ?? new List(); - model.UnitStates = FilterToUnits(await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(model.Department.DepartmentId), model.Units); model.UnitStatuses = await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(model.Department.DepartmentId); var priorites = await _callsService.GetActiveCallPrioritiesForDepartmentAsync(model.Department.DepartmentId); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs index 8e519d4c8..56721f4a8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs @@ -164,9 +164,10 @@ private string ErrorText(Exception ex) [HttpGet] [Authorize(Policy = ResgridResources.Invoicing_View)] - public async Task Index(int? status = null, string contactId = null, int page = 0) + public async Task Index(int? status = null, string contactId = null, string number = null, int page = 0) { - var filter = new InvoiceListFilter { Statuses = status.HasValue ? new[] { status.Value } : null, ContactId = contactId, Skip = Math.Max(0, page) * PageSize, Take = PageSize }; + number = IdentifierSearchKeys.FilterSearch(number); + var filter = new InvoiceListFilter { Statuses = status.HasValue ? new[] { status.Value } : null, ContactId = contactId, Number = number, Skip = Math.Max(0, page) * PageSize, Take = PageSize }; var invoices = await _invoicing.GetInvoicesForDepartmentAsync(DepartmentId, filter); var aging = await _invoicing.GetAccountsReceivableAgingAsync(DepartmentId); var overdue = aging.Buckets.Where(b => b.Label != "Current").ToList(); @@ -176,6 +177,7 @@ public async Task Index(int? status = null, string contactId = nu Invoices = invoices, Status = status, ContactId = contactId, + Number = number, Page = Math.Max(0, page), PageSize = PageSize, TotalCount = await _invoicing.CountInvoicesForDepartmentAsync(DepartmentId, filter), diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs index c0ef0d616..b8182c2a7 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs @@ -33,12 +33,13 @@ public class LinksController : SecureBaseController private readonly IPersonnelRolesService _personnelRolesService; private readonly ILimitsService _limitsService; private readonly IMappingService _mappingService; + private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IStringLocalizer _localizer; - public LinksController(IDepartmentLinksService departmentLinksService, IDepartmentsService departmentsService, IEmailService emailService, + public LinksController(IDepartmentLinksService departmentLinksService, IDepartmentsService departmentsService, IEmailService emailService, ICallsService callsService, IUnitsService unitsService, IActionLogsService actionLogsService, IDepartmentGroupsService departmentGroupsService, IUserStateService userStateService, IPersonnelRolesService personnelRolesService, ILimitsService limitsService, IMappingService mappingService, - IStringLocalizer localizer) + IDepartmentSettingsService departmentSettingsService, IStringLocalizer localizer) { _departmentLinksService = departmentLinksService; _departmentsService = departmentsService; @@ -51,6 +52,7 @@ public LinksController(IDepartmentLinksService departmentLinksService, IDepartme _personnelRolesService = personnelRolesService; _limitsService = limitsService; _mappingService = mappingService; + _departmentSettingsService = departmentSettingsService; _localizer = localizer; } #endregion Private Members and Constructors @@ -217,12 +219,15 @@ public async Task GetActiveCallsList(int linkId) { var calls = (await _callsService.GetActiveCallsByDepartmentAsync(link.DepartmentId)).OrderByDescending(x => x.LoggedOn); var department = await _departmentsService.GetDepartmentByIdAsync(link.DepartmentId, false); + // Read with the sharing department's pattern: these are its numbers, not the viewer's. + var numberSort = CallNumberFormat.SortKeys(CallNumberFormat.EffectivePattern(await _departmentSettingsService.GetCallNumberingConfigAsync(link.DepartmentId))); foreach (var call in calls) { var callJson = new CallListJson(); callJson.CallId = call.CallId; callJson.Number = call.Number; + callJson.NumberSort = numberSort(call.Number); // ADP: a linked department is a DIFFERENT tenant — its members can never hold a // grant for the sharing department, so a protected call name degrades to the // system-generated number rather than leaking ciphertext across the link. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs index ce3d36996..aac187f14 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs @@ -302,7 +302,7 @@ public async Task EditLayer(string layerId) return Unauthorized(); var feature = layer.Data.Convert(); - model.GeoJson = JsonConvert.SerializeObject(feature); + model.GeoJson = JsonConvert.SerializeObject(feature, HtmlHelpers.ScriptJsonSettings); model.Id = layer.Id.ToString(); model.Name = layer.Name; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index ddde5ade0..1d6144aff 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -338,7 +338,7 @@ public async Task Index() trees.Add(tree1); trees.AddRange(BSTreeModel.ForDepartmentGroups(model.Groups)); - model.TreeData = Newtonsoft.Json.JsonConvert.SerializeObject(trees); + model.TreeData = Newtonsoft.Json.JsonConvert.SerializeObject(trees, HtmlHelpers.ScriptJsonSettings); return View(model); @@ -1394,7 +1394,7 @@ public async Task GetPersonnelStatusHtmlForDropdownByStateId(int foreach (var state in activeDetails.OrderBy(x => x.Order)) { - sb.Append($""); + sb.Append($""); } buttonHtml = sb.ToString(); @@ -1429,7 +1429,7 @@ public async Task GetPersonnelStaffingHtmlForDropdownByStateId(in foreach (var state in activeDetails.OrderBy(x => x.Order)) { - sb.Append($""); + sb.Append($""); } buttonHtml = sb.ToString(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs index 3ba364e86..a4cfe158a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProtocolsController.cs @@ -289,7 +289,7 @@ public async Task GetTextForProtocol(int id) var protocolText = "No Protocol Text Present"; if (!String.IsNullOrWhiteSpace(protocol.ProtocolText)) - protocolText = protocol.ProtocolText; + protocolText = StringHelpers.SanitizeHtmlForDisplay(protocol.ProtocolText); dynamic protocolJson = new { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs index 8c0a6ec0d..64119b68f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordDeploymentsController.cs @@ -198,7 +198,7 @@ public async Task Export(string id) var d = await AccessibleAsync(id); if (d == null) return NotFound(); // Explicit projection excludes protected notes, document bytes and ORM metadata. - var time = (await _time.GetTimeReportsWithEntriesAsync(id, DepartmentId)).Select(report => new { report.ReportNumber, report.ReportDate, report.Status, + var time = (await _time.GetTimeReportsWithEntriesAsync(id, DepartmentId)).Select(report => new { report.ReportNumber, Number = report.NumberText(), report.ReportDate, report.Status, Entries = report.Entries.Select(e => new { e.SubjectType, e.SubjectId, e.EntryType, e.StartTime, e.EndTime, e.Hours, e.MileageKm }) }).ToList(); var expenses = await _time.GetExpensesAsync(id, DepartmentId); var data = new diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs index ef1c8edc4..4abfd9e05 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordInspectionsController.cs @@ -34,13 +34,13 @@ public RecordInspectionsController(IRecordsInspectionsService inspections, IReco private const string Flag = FeatureFlagKeys.RecordsPreventionInspections; [HttpGet] - public async Task Index(int? state = null, string programId = null, int page = 1) + public async Task Index(int? state = null, string programId = null, int page = 1, string number = null) { if (!await ModuleOnAsync(Flag)) return NotFound(); try { - var model = Prepare(new RecordInspectionsIndexView { State = state, ProgramId = programId, Page = Math.Max(1, page) }); - var query = new RmsInspectionQuery { States = state.HasValue ? new List { state.Value } : null, ProgramId = programId, Skip = (model.Page - 1) * model.PageSize, Take = model.PageSize }; + var model = Prepare(new RecordInspectionsIndexView { State = state, ProgramId = programId, Number = IdentifierSearchKeys.FilterSearch(number), Page = Math.Max(1, page) }); + var query = new RmsInspectionQuery { States = state.HasValue ? new List { state.Value } : null, ProgramId = programId, Number = model.Number, Skip = (model.Page - 1) * model.PageSize, Take = model.PageSize }; model.Inspections = await _inspections.ListAsync(DepartmentId, UserId, query); model.Total = await _inspections.CountAsync(DepartmentId, UserId, query); model.Programs = await _inspections.GetProgramsAsync(DepartmentId, UserId, false); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs index 38dff4d32..d58ba7707 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs @@ -47,10 +47,11 @@ public RecordInvestigationsController(IRecordsInvestigationsService investigatio private string Ip => IpAddressHelper.GetRequestIP(Request, true); [HttpGet] - public async Task Index(bool includeClosed = false) + public async Task Index(bool includeClosed = false, string number = null) { if (!await ModuleOnAsync(Flag)) return NotFound(); - try { return View(Prepare(new RecordInvestigationsIndexView { IncludeClosed = includeClosed, Cases = await _investigations.ListMyCasesAsync(DepartmentId, UserId, includeClosed) })); } + number = IdentifierSearchKeys.FilterSearch(number); + try { return View(Prepare(new RecordInvestigationsIndexView { IncludeClosed = includeClosed, Number = number, Cases = await _investigations.ListMyCasesAsync(DepartmentId, UserId, includeClosed, number) })); } catch (UnauthorizedAccessException) { return Forbid(); } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs index 41d78e5a0..99331dd7d 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordPermitsController.cs @@ -33,13 +33,13 @@ public RecordPermitsController(IRecordsPermitsService permits, IRecordsOccupancy private const string Flag = FeatureFlagKeys.RecordsPreventionPermits; [HttpGet] - public async Task Index(int? state = null, string typeId = null, bool expiring = false, int page = 1) + public async Task Index(int? state = null, string typeId = null, bool expiring = false, int page = 1, string number = null) { if (!await ModuleOnAsync(Flag)) return NotFound(); try { - var model = Prepare(new RecordPermitsIndexView { State = state, PermitTypeId = typeId, ExpiringOnly = expiring, Page = Math.Max(1, page) }); - var query = new RmsPermitQuery { States = state.HasValue ? new List { state.Value } : null, PermitTypeId = typeId, ExpiresBefore = expiring ? DateTime.UtcNow.AddDays(30) : (DateTime?)null, Skip = (model.Page - 1) * model.PageSize, Take = model.PageSize }; + var model = Prepare(new RecordPermitsIndexView { State = state, PermitTypeId = typeId, ExpiringOnly = expiring, Number = IdentifierSearchKeys.FilterSearch(number), Page = Math.Max(1, page) }); + var query = new RmsPermitQuery { States = state.HasValue ? new List { state.Value } : null, PermitTypeId = typeId, ExpiresBefore = expiring ? DateTime.UtcNow.AddDays(30) : (DateTime?)null, Number = model.Number, Skip = (model.Page - 1) * model.PageSize, Take = model.PageSize }; model.Permits = await _permits.ListAsync(DepartmentId, UserId, query); model.Total = await _permits.CountAsync(DepartmentId, UserId, query); model.Types = await _permits.GetTypesAsync(DepartmentId, UserId, true); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index 794f48f4d..fdf4a5d91 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -1455,6 +1455,8 @@ private async Task SearchProjectionsAsync(string text, Lis RecordsSearchResult result; try { + // The year filter is the department's numbering year, as on the queue without text. + var yearWindow = year.HasValue ? await _recordsService.GetYearWindowAsync(DepartmentId, year.Value) : null; result = await _recordsSearch.SearchAsync(DepartmentId, new RecordsSearchRequest { Text = text, @@ -1463,6 +1465,8 @@ private async Task SearchProjectionsAsync(string text, Lis States = states, DefinitionKey = string.IsNullOrWhiteSpace(definitionKey) ? null : definitionKey, Year = year, + OccurredFrom = yearWindow?.FromUtc, + OccurredBefore = yearWindow?.BeforeUtc, Skip = skip, Take = take }); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs index bc5b4143c..c55524dd3 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs @@ -151,7 +151,7 @@ public async Task Index() trees.Add(tree1); trees.AddRange(BSTreeModel.ForDepartmentGroups(model.Groups)); - model.TreeData = Newtonsoft.Json.JsonConvert.SerializeObject(trees); + model.TreeData = Newtonsoft.Json.JsonConvert.SerializeObject(trees, HtmlHelpers.ScriptJsonSettings); return View(model); } @@ -1473,9 +1473,13 @@ public async Task GetUnitsForCallGrid(string callLat, string call unitJson.Type = unit.Type; unitJson.UnitId = unit.UnitId; unitJson.TypeIconUrl = TypeIconHtml.UrlForTypeName(unitTypes, unit.Type); - + // New Call and Update Call split the units into a tab per station; a unit whose station group no longer + // exists counts as ungrouped. if (unit.StationGroup != null) + { unitJson.Station = unit.StationGroup.Name; + unitJson.StationGroupId = unit.StationGroup.DepartmentGroupId; + } var state = states.FirstOrDefault(x => x.UnitId == unit.UnitId); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs index 2167cfcbb..0aa3e0163 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs @@ -661,7 +661,7 @@ public async Task CostRuns() var deployments = await _deployments.GetDeploymentsForDepartmentAsync(DepartmentId, false, 0, 200) ?? new List(); view.Deployments = deployments.OrderByDescending(d => d.StartOn).Select(d => new SelectListItem(d.Name, d.DeploymentId)).ToList(); var bids = await _bids.GetBidsForDepartmentAsync(DepartmentId, null, 0, 200) ?? new List(); - view.Bids = bids.OrderByDescending(b => b.BidNumber).Select(b => new SelectListItem($"#{b.BidNumber} {b.Title}", b.BidId)).ToList(); + view.Bids = bids.OrderByDescending(b => b.BidNumber).Select(b => new SelectListItem($"{b.NumberLabel()} {b.Title}", b.BidId)).ToList(); foreach (var d in deployments) view.ContextLabels["D:" + d.DeploymentId] = d.Name; foreach (var b in bids) view.ContextLabels["B:" + b.BidId] = $"{b.NumberLabel()} {b.Title}"; return View(view); diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs index 5f943bf03..9994ff73d 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs @@ -28,11 +28,8 @@ public class NewCallView : BaseUserModel public string MapCenterLatitude { get; set; } public string MapCenterLongitude { get; set; } public bool ReCalcuateCallNumbers { get; set; } + /// The units the form may dispatch; the post accepts only these. The page lists them from Units/GetUnitsForCallGrid. public List Units { get; set; } - - /// The department's unit types, for the type icon beside each unit. - public List UnitTypes { get; set; } = new List(); - public List UnitStates { get; set; } public List UnitStatuses { get; set; } public SelectList CallTemplates { get; set; } public int CallTemplateId { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/ContractorBilling/ContractorViews.cs b/Web/Resgrid.Web/Areas/User/Models/ContractorBilling/ContractorViews.cs index 6b28f8353..74b4282bc 100644 --- a/Web/Resgrid.Web/Areas/User/Models/ContractorBilling/ContractorViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/ContractorBilling/ContractorViews.cs @@ -204,6 +204,8 @@ public class BidIndexView : ContractorPageView public List Bids { get; set; } = new List(); public Dictionary ContactNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public int? StatusFilter { get; set; } + /// The number filter as applied. + public string Number { get; set; } public int Page { get; set; } = 1; public int PageSize { get; set; } = 50; public int Total { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs index b6dbcc603..126f68369 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Dispatch/CallListJson.cs @@ -10,6 +10,9 @@ public class CallListJson { public int CallId { get; set; } public string Number { get; set; } + + /// Orders as the numbers were issued (); the grids sort on it. + public string NumberSort { get; set; } public string Priority { get; set; } public string Color { get; set; } public string Name { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs b/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs index 89e4249e2..0b03c41f8 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Invoicing/InvoicingViews.cs @@ -23,6 +23,8 @@ public sealed class InvoiceIndexView : InvoicingPageView public List Invoices { get; set; } = new List(); public int? Status { get; set; } public string ContactId { get; set; } + /// The number filter as typed. + public string Number { get; set; } public int Page { get; set; } public int PageSize { get; set; } = 50; public int TotalCount { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/DisclosureViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/DisclosureViewModels.cs index d1f1d8a62..8854a24bd 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/DisclosureViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/DisclosureViewModels.cs @@ -14,6 +14,8 @@ public class DisclosureIndexView : RecordsBaseView public List Requests { get; set; } = new List(); public Dictionary PersonnelNames { get; set; } = new Dictionary(); public int? StateFilter { get; set; } + /// The request number filter as applied. + public string NumberFilter { get; set; } public List States { get; set; } = new List(); public List RedactionProfiles { get; set; } = new List(); public List Personnel { get; set; } = new List(); diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs index 1a6468389..a42b7abaf 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs @@ -163,6 +163,8 @@ public class RecordInspectionsIndexView : RecordsPreventionBaseView public Dictionary OccupancyNames { get; set; } = new Dictionary(); public int? State { get; set; } public string ProgramId { get; set; } + /// The inspection number filter as applied. + public string Number { get; set; } public int Page { get; set; } = 1; public int PageSize { get; set; } = 50; public List OpenViolations { get; set; } = new List(); @@ -268,6 +270,8 @@ public class RecordPermitsIndexView : RecordsPreventionBaseView public int? State { get; set; } public string PermitTypeId { get; set; } public bool ExpiringOnly { get; set; } + /// The permit number filter as applied. + public string Number { get; set; } public int Page { get; set; } = 1; public int PageSize { get; set; } = 50; public List States => RmsEnumDisplay.Items(State, false); @@ -382,6 +386,8 @@ public class RecordInvestigationsIndexView : RecordsPreventionBaseView { public List Cases { get; set; } = new List(); public bool IncludeClosed { get; set; } + /// The case or evidence number filter as applied. + public string Number { get; set; } } public class RecordInvestigationOpenView : RecordsPreventionBaseView diff --git a/Web/Resgrid.Web/Areas/User/Models/Units/UnitForListJson.cs b/Web/Resgrid.Web/Areas/User/Models/Units/UnitForListJson.cs index 35436f4c7..280636741 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Units/UnitForListJson.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Units/UnitForListJson.cs @@ -6,6 +6,9 @@ public class UnitForListJson public string Name { get; set; } public string Type { get; set; } public string Station { get; set; } + + /// The unit's station group; null for a unit without one. + public int? StationGroupId { get; set; } public int StateId { get; set; } public string State { get; set; } public string StateColor { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml index d7340bf19..b6482c06a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml @@ -81,7 +81,7 @@ diff --git a/Web/Resgrid.Web/Areas/User/Views/CustomStatuses/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/CustomStatuses/New.cshtml index ff5278fe9..7380fe632 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CustomStatuses/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CustomStatuses/New.cshtml @@ -71,7 +71,7 @@
- @Html.Raw(Model.State.Description) + @Html.SanitizedHtml(Model.State.Description)
diff --git a/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml index d5fc20b3f..eb9e8b310 100644 --- a/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml @@ -438,21 +438,21 @@ // The wizard's error text and confirmations are localized here and handed to the script, // keyed by the value-free codes the server returns. var adpWizardMessages = { - acknowledgements_incomplete: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ErrAcknowledgementsIncomplete"].Value)), - lock_consent_required: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ErrLockConsentRequired"].Value)), - protected_access_denied: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ErrProtectedAccessDenied"].Value)), - addon_required: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ErrAddonRequired"].Value)), - plan_required: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ErrPlanRequired"].Value)), - feature_not_available: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ErrFeatureNotAvailable"].Value)), - invalid_state: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ErrInvalidState"].Value)), - invalid_window: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ErrInvalidWindow"].Value)), - command_failed: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ErrCommandFailed"].Value)), - confirm_cancel_queued: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ConfirmCancelQueued"].Value)), - confirm_revoke_offboarding: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["ConfirmRevokeOffboarding"].Value)), - progress_rows: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["MigrationProgressRows"].Value)), - progress_table: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["MigrationProgressTable"].Value)), - progress_pending: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["MigrationProgressPending"].Value)), - progress_anomalies: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["MigrationProgressAnomalies"].Value)) + acknowledgements_incomplete: @Html.ScriptJson(localizer["ErrAcknowledgementsIncomplete"].Value), + lock_consent_required: @Html.ScriptJson(localizer["ErrLockConsentRequired"].Value), + protected_access_denied: @Html.ScriptJson(localizer["ErrProtectedAccessDenied"].Value), + addon_required: @Html.ScriptJson(localizer["ErrAddonRequired"].Value), + plan_required: @Html.ScriptJson(localizer["ErrPlanRequired"].Value), + feature_not_available: @Html.ScriptJson(localizer["ErrFeatureNotAvailable"].Value), + invalid_state: @Html.ScriptJson(localizer["ErrInvalidState"].Value), + invalid_window: @Html.ScriptJson(localizer["ErrInvalidWindow"].Value), + command_failed: @Html.ScriptJson(localizer["ErrCommandFailed"].Value), + confirm_cancel_queued: @Html.ScriptJson(localizer["ConfirmCancelQueued"].Value), + confirm_revoke_offboarding: @Html.ScriptJson(localizer["ConfirmRevokeOffboarding"].Value), + progress_rows: @Html.ScriptJson(localizer["MigrationProgressRows"].Value), + progress_table: @Html.ScriptJson(localizer["MigrationProgressTable"].Value), + progress_pending: @Html.ScriptJson(localizer["MigrationProgressPending"].Value), + progress_anomalies: @Html.ScriptJson(localizer["MigrationProgressAnomalies"].Value) }; @@ -480,7 +480,7 @@ // state, which is what someone reviewing a weakening actually needs to see. $(function () { $('#adpSaveStepUpExemptions').on('click', function () { - if (!window.confirm(@Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["StepUpSaveConfirm"].Value)))) + if (!window.confirm(@Html.ScriptJson(localizer["StepUpSaveConfirm"].Value))) return; var mask = 0; @@ -502,10 +502,10 @@ return; } - $('#adpStepUpExemptionsError').text(@Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["StepUpSaveFailed"].Value))); + $('#adpStepUpExemptionsError').text(@Html.ScriptJson(localizer["StepUpSaveFailed"].Value)); }).fail(function () { $button.prop('disabled', false); - $('#adpStepUpExemptionsError').text(@Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(localizer["StepUpSaveFailed"].Value))); + $('#adpStepUpExemptionsError').text(@Html.ScriptJson(localizer["StepUpSaveFailed"].Value)); }); }); }); diff --git a/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml index 9312a1c26..ce4ac420d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml @@ -99,13 +99,13 @@ diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/ViewEntry.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/ViewEntry.cshtml index 209711e54..591b51a70 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/ViewEntry.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/ViewEntry.cshtml @@ -54,7 +54,7 @@
@localizer["Group"]:
@if (Model.Inventory.Group != null) { - @Html.Raw(Model.Inventory.Group.Name) + @Model.Inventory.Group.Name } else { @@ -63,7 +63,7 @@
@localizer["Unit"]:
@if (Model.Inventory.Unit != null) { - @Html.Raw(Model.Inventory.Unit.Name) + @Model.Inventory.Unit.Name } else { @@ -87,7 +87,7 @@
-

@Html.Raw(Model.Inventory.Note)

+

@Html.SanitizedHtml(Model.Inventory.Note)

diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml index 60a3cccf7..ee1970a6e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml @@ -57,6 +57,10 @@
+
+ + +
- @Html.Raw(Model.Call.NatureOfCall) + @Html.SanitizedHtml(Model.Call.NatureOfCall)
@@ -537,7 +537,7 @@
- @Html.Raw(Model.Log.InitialReport) + @Html.SanitizedHtml(Model.Log.InitialReport)
@@ -549,7 +549,7 @@
- @Html.Raw(Model.Log.Narrative) + @Html.SanitizedHtml(Model.Log.Narrative)
@@ -561,7 +561,7 @@
- @Html.Raw(Model.Log.Cause) + @Html.SanitizedHtml(Model.Log.Cause)
diff --git a/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml b/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml index 6912d8a3e..84f475fe1 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml @@ -180,7 +180,7 @@
@localizer["NatureOfCall"]: -
@Html.Raw(Model.WorkLog.Call.NatureOfCall)
+
@Html.SanitizedHtml(Model.WorkLog.Call.NatureOfCall)
} @@ -338,7 +338,7 @@ {
-
@Html.Raw(Model.WorkLog.InitialReport)
+
@Html.SanitizedHtml(Model.WorkLog.InitialReport)
} @@ -346,7 +346,7 @@ {
-
@Html.Raw(Model.WorkLog.Narrative)
+
@Html.SanitizedHtml(Model.WorkLog.Narrative)
} @@ -354,7 +354,7 @@ {
-
@Html.Raw(Model.WorkLog.Cause)
+
@Html.SanitizedHtml(Model.WorkLog.Cause)
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Mapping/LiveRouting.cshtml b/Web/Resgrid.Web/Areas/User/Views/Mapping/LiveRouting.cshtml index 9a0c5a5a1..35ebdebf9 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Mapping/LiveRouting.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Mapping/LiveRouting.cshtml @@ -101,7 +101,7 @@ for (var i = 0; i < steps.length; i++) { var s = steps[i]; if (s.maneuver && s.maneuver.type !== 'arrive') { - html += '
  • ' + s.maneuver.type + (s.name ? ' onto ' + s.name : '') + + html += '
  • ' + resgrid.escapeHtml(s.maneuver.type) + (s.name ? ' onto ' + resgrid.escapeHtml(s.name) : '') + ' (' + (s.distance / 1000).toFixed(1) + ' km)
  • '; } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Mapping/StationRouting.cshtml b/Web/Resgrid.Web/Areas/User/Views/Mapping/StationRouting.cshtml index 4d680c04e..a15fb747e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Mapping/StationRouting.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Mapping/StationRouting.cshtml @@ -96,7 +96,7 @@ for (var i = 0; i < steps.length; i++) { var s = steps[i]; if (s.maneuver && s.maneuver.type !== 'arrive') { - html += '
  • ' + s.maneuver.type + (s.name ? ' onto ' + s.name : '') + + html += '
  • ' + resgrid.escapeHtml(s.maneuver.type) + (s.name ? ' onto ' + resgrid.escapeHtml(s.name) : '') + ' (' + (s.distance / 1000).toFixed(1) + ' km)
  • '; } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Messages/Compose.cshtml b/Web/Resgrid.Web/Areas/User/Views/Messages/Compose.cshtml index 0c5d9c14d..2bee1bef5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Messages/Compose.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Messages/Compose.cshtml @@ -81,7 +81,7 @@
    - @Html.Raw(Model.Message.Body) + @Html.SanitizedHtml(Model.Message.Body)
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Messages/ViewMessage.cshtml b/Web/Resgrid.Web/Areas/User/Views/Messages/ViewMessage.cshtml index 406471454..3dfc9c013 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Messages/ViewMessage.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Messages/ViewMessage.cshtml @@ -110,7 +110,7 @@
    @* The body is raw HTML for an unprotected message; a revealed value is written with text() by the reveal module, so decrypted content can never execute as markup. *@ - @Html.Raw(Model.Message.Body) + @Html.SanitizedHtml(Model.Message.Body)
    @if (!String.IsNullOrWhiteSpace(Model.ModerationMessage)) @@ -243,7 +243,7 @@
    @if (!String.IsNullOrWhiteSpace(recipient.Note)) { - @Html.Raw(recipient.Note) + @Html.SanitizedHtml(recipient.Note) }
    @@ -327,7 +327,7 @@ } - @Html.Raw(recipient.Note) + @Html.SanitizedHtml(recipient.Note) } diff --git a/Web/Resgrid.Web/Areas/User/Views/Notes/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Notes/Edit.cshtml index 12ca5d3ed..37c4d997c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Notes/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Notes/Edit.cshtml @@ -68,7 +68,7 @@
    - @Html.Raw(Model.Body) + @Html.SanitizedHtml(Model.Body)
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Notes/NewNote.cshtml b/Web/Resgrid.Web/Areas/User/Views/Notes/NewNote.cshtml index 6117536f0..600a59676 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Notes/NewNote.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Notes/NewNote.cshtml @@ -64,7 +64,7 @@
    - @Html.Raw(Model.Body) + @Html.SanitizedHtml(Model.Body)
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Notes/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Notes/View.cshtml index 55d963662..3422012b0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Notes/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Notes/View.cshtml @@ -52,7 +52,7 @@

    - @Html.Raw(Model.Note.Body) + @Html.SanitizedHtml(Model.Note.Body)


    diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/Certifications.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/Certifications.cshtml index 29b9b8371..e6c946f8f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/Certifications.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/Certifications.cshtml @@ -202,16 +202,16 @@ revealUrl: '@Url.Action("RevealCertifications", "Profile", new { area = "User" })', revealData: { userId: '@Model.UserId' }, messages: { - invalid_totp: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorInvalidCode"].Value)), - too_many_attempts: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorTooManyAttempts"].Value)), - mfa_not_enrolled: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorMfaNotEnrolled"].Value)), - grants_not_configured: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGrantsNotConfigured"].Value)), - step_up_required: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorStepUpRequired"].Value)), - grant_expired: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGrantExpired"].Value)), - grant_revoked: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGrantRevoked"].Value)), - protected_access_denied: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorAccessDenied"].Value)), - broker_unavailable: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorBrokerUnavailable"].Value)), - generic: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGeneric"].Value)) + invalid_totp: @Html.ScriptJson(commonLocalizer["AdpErrorInvalidCode"].Value), + too_many_attempts: @Html.ScriptJson(commonLocalizer["AdpErrorTooManyAttempts"].Value), + mfa_not_enrolled: @Html.ScriptJson(commonLocalizer["AdpErrorMfaNotEnrolled"].Value), + grants_not_configured: @Html.ScriptJson(commonLocalizer["AdpErrorGrantsNotConfigured"].Value), + step_up_required: @Html.ScriptJson(commonLocalizer["AdpErrorStepUpRequired"].Value), + grant_expired: @Html.ScriptJson(commonLocalizer["AdpErrorGrantExpired"].Value), + grant_revoked: @Html.ScriptJson(commonLocalizer["AdpErrorGrantRevoked"].Value), + protected_access_denied: @Html.ScriptJson(commonLocalizer["AdpErrorAccessDenied"].Value), + broker_unavailable: @Html.ScriptJson(commonLocalizer["AdpErrorBrokerUnavailable"].Value), + generic: @Html.ScriptJson(commonLocalizer["AdpErrorGeneric"].Value) }, antiForgeryToken: $('#adpAntiForgeryForm input[name="__RequestVerificationToken"]').val() }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Protocols/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Protocols/New.cshtml index e24326513..b7101c01f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Protocols/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Protocols/New.cshtml @@ -74,7 +74,7 @@
    - @Html.Raw(Model.Protocol.Description) + @Html.SanitizedHtml(Model.Protocol.Description)
    @@ -88,7 +88,7 @@
    - @Html.Raw(Model.Protocol.ProtocolText) + @Html.SanitizedHtml(Model.Protocol.ProtocolText)
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Protocols/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Protocols/View.cshtml index 361359d74..be54d553d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Protocols/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Protocols/View.cshtml @@ -75,7 +75,7 @@
    @localizer["DescriptionDtLabel"]
    - @Html.Raw(Model.Protocol.Description) + @Html.SanitizedHtml(Model.Protocol.Description)
    @@ -85,7 +85,7 @@
    @localizer["ProtocolTextDtLabel"]
    - @Html.Raw(Model.Protocol.ProtocolText) + @Html.SanitizedHtml(Model.Protocol.ProtocolText)
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Edit.cshtml index d23a3dbe8..db9ab17b2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Edit.cshtml @@ -260,7 +260,7 @@ { diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml index b80691668..59a15b2bb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Index.cshtml @@ -36,6 +36,7 @@ + @if (Model.Inspections.Count == 0) {

    @L["NoInspections"]

    } @@ -64,7 +65,7 @@ @if (Model.Total > Model.PageSize) {
      - @for (var p = 1; p <= (Model.Total + Model.PageSize - 1) / Model.PageSize; p++) {
    • @p
    • } + @for (var p = 1; p <= (Model.Total + Model.PageSize - 1) / Model.PageSize; p++) {
    • @p
    • }
    } } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml index 599f137f6..6c5c7a5a4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Index.cshtml @@ -27,6 +27,8 @@

    @L["InvestigationsIntro"]

    + @{ var numberLabel = L["CaseNumber"].Value + " / " + L["EvidenceNumber"].Value; } +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml index 29b1a3b41..16eb570f5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordPermits/Index.cshtml @@ -33,6 +33,7 @@
    +
    @@ -61,7 +62,7 @@
    @if (Model.Total > Model.PageSize) { -
      @for (var pg = 1; pg <= (Model.Total + Model.PageSize - 1) / Model.PageSize; pg++) {
    • @pg
    • }
    +
      @for (var pg = 1; pg <= (Model.Total + Model.PageSize - 1) / Model.PageSize; pg++) {
    • @pg
    • }
    } }
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml index b33a15f34..541b255bf 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml @@ -72,9 +72,9 @@
    } diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/FlaggedCallNotesReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/FlaggedCallNotesReport.cshtml index c6290a74d..2a48bfe3c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/FlaggedCallNotesReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/FlaggedCallNotesReport.cshtml @@ -130,7 +130,7 @@ @row.CallType @row.CallAddress @row.CallLoggedOn.FormatForDepartment(Model.Department) - @row.NoteText + @Html.SanitizedHtml(row.NoteText) @row.NoteAuthorName @row.NoteTimestamp.FormatForDepartment(Model.Department) diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml index 04590f9c7..846e76eb8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/Index.cshtml @@ -226,10 +226,20 @@ @localizer["ViewReport"] - - - @foreach (var kind in Enum.GetValues()) { - @Resgrid.Services.InventoryReportDocuments.Title(kind)@Resgrid.Services.InventoryReportDocuments.Text("M5ReportHelp") + @foreach (var kind in Enum.GetValues()) + { + var inventoryReportUrl = Url.Action("Operations", "Inventory", new { Area = "User", tab = "Reports", kind }); + + + @Resgrid.Services.InventoryReportDocuments.Title(kind) + + + @Resgrid.Services.InventoryReportDocuments.Summary(kind) + + + @localizer["ViewReport"] + + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Reports/LogReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Reports/LogReport.cshtml index 3a6662525..088bcc1c5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Reports/LogReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Reports/LogReport.cshtml @@ -188,7 +188,7 @@
    @localizer["NarrativeLabel"] -
    @Html.Raw(Model.Log.Narrative)
    +
    @Html.SanitizedHtml(Model.Log.Narrative)
    @@ -469,13 +469,13 @@
    @localizer["ConditionLabel"] -
    @Html.Raw(Model.Log.InitialReport)
    +
    @Html.SanitizedHtml(Model.Log.InitialReport)
    @localizer["NarrativeLabel"] -
    @Html.Raw(Model.Log.Narrative)
    +
    @Html.SanitizedHtml(Model.Log.Narrative)
    @@ -702,7 +702,7 @@
    @localizer["NarrativeLabel"] -
    @Html.Raw(Model.Log.Narrative)
    +
    @Html.SanitizedHtml(Model.Log.Narrative)
    @@ -866,7 +866,7 @@
    @localizer["MeetingMinutesLabel"] -
    @Html.Raw(Model.Log.Narrative)
    +
    @Html.SanitizedHtml(Model.Log.Narrative)
    @@ -1131,7 +1131,7 @@
    @localizer["MeetingMinutesLabel"] -
    @Html.Raw(Model.Log.Narrative)
    +
    @Html.SanitizedHtml(Model.Log.Narrative)
    @@ -1353,7 +1353,7 @@
    @localizer["NarrativeLabel"] -
    @Html.Raw(Model.Log.Narrative)
    +
    @Html.SanitizedHtml(Model.Log.Narrative)
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedView.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedView.cshtml index 1fb218c93..c7e012e3e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedView.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/ArchivedView.cshtml @@ -93,9 +93,9 @@ } diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/Directions.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/Directions.cshtml index e540a6c74..cd7a9a926 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/Directions.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/Directions.cshtml @@ -192,7 +192,7 @@ } diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml index 31bab5f9d..00420434c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/Edit.cshtml @@ -344,7 +344,7 @@ } diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml index 67f9151a3..45fb139ca 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/View.cshtml @@ -97,9 +97,9 @@ } diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Sso.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Sso.cshtml index e95b6395d..09f935b2e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Sso.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Sso.cshtml @@ -476,12 +476,12 @@ Content-Type: application/scim+json @section Scripts {