diff --git a/.gitignore b/.gitignore index 0c83e6a66..bad3ec60f 100644 --- a/.gitignore +++ b/.gitignore @@ -280,3 +280,4 @@ opencode.json /.claude /Web/Resgrid.Web/wwwroot/js/ng /tmp +/artifacts diff --git a/Core/Resgrid.Config/NerisConfig.cs b/Core/Resgrid.Config/NerisConfig.cs index 7338978f3..568900b79 100644 --- a/Core/Resgrid.Config/NerisConfig.cs +++ b/Core/Resgrid.Config/NerisConfig.cs @@ -13,7 +13,7 @@ public static class NerisConfig /// Production API root of the pinned contract. public static string BaseUrl = "https://api.neris.fsri.org/v1"; - /// Sandbox root used by profiles whose Environment is sandbox; empty means "same as BaseUrl". + /// Sandbox root used by sandbox profiles. Empty disables outbound requests; production is never a fallback. public static string SandboxBaseUrl = ""; /// Contract version the provider was generated against (Providers/Resgrid.Providers.Neris/Contract). diff --git a/Core/Resgrid.Framework/RecordNarrativeFormatter.cs b/Core/Resgrid.Framework/RecordNarrativeFormatter.cs new file mode 100644 index 000000000..9a33fa83f --- /dev/null +++ b/Core/Resgrid.Framework/RecordNarrativeFormatter.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using HtmlAgilityPack; + +namespace Resgrid.Framework +{ + /// Records permit basic narrative formatting, without links, media, attributes or active content. + public static class RecordNarrativeFormatter + { + private static readonly HashSet Allowed = new(StringComparer.OrdinalIgnoreCase) { "p", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "blockquote", "h1", "h2", "h3" }; + private static readonly HashSet Drop = new(StringComparer.OrdinalIgnoreCase) { "script", "style", "iframe", "object", "embed", "svg", "math", "template", "noscript" }; + private static bool IsHtml(string value) => Regex.IsMatch(value ?? "", @"<\s*/?[a-zA-Z][^>]*>", RegexOptions.None, TimeSpan.FromMilliseconds(250)); + public static string ForStorage(string value) => IsHtml(value) ? Render(value) : value; + public static string Render(string value) + { + if (!IsHtml(value)) return WebUtility.HtmlEncode(value ?? ""); + var document = new HtmlDocument(); document.LoadHtml(value); var html = new StringBuilder(); + void Append(HtmlNode node, int depth) + { + if (depth > 64) throw new ArgumentException("Narrative formatting is nested too deeply."); + if (node.NodeType == HtmlNodeType.Text) { html.Append(WebUtility.HtmlEncode(HtmlEntity.DeEntitize(node.InnerText))); return; } + if (node.NodeType == HtmlNodeType.Comment || Drop.Contains(node.Name)) return; + var allowed = Allowed.Contains(node.Name); + if (allowed) html.Append('<').Append(node.Name).Append('>'); + foreach (var child in node.ChildNodes) Append(child, depth + 1); + if (allowed && node.Name != "br") html.Append("'); + } + Append(document.DocumentNode, 0); return html.ToString(); + } + public static bool HasText(string value) + { + var document = new HtmlDocument(); document.LoadHtml(Render(value)); + return !string.IsNullOrWhiteSpace(HtmlEntity.DeEntitize(document.DocumentNode.InnerText).Replace('\u200b', ' ')); + } + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index f9a6e128d..71b7f6084 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ أوقات الإرسال إنشاء النداء الرد على النداء - أول وصول + وصول المكالمة إلى مركز الإرسال انتهاء الحادث مركز الإرسال رمز المحدد diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index 386e6977c..ea152aab6 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ Dispositionszeiten Einsatz erstellt Anruf angenommen - Erstes Eintreffen + Eingang des Anrufs in der Leitstelle Einsatz beendet Leitstelle Determinantencode diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index a1b591dbf..413b7c24a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ Χρόνοι αποστολής Δημιουργία κλήσης Απάντηση κλήσης - Πρώτη άφιξη + Λήψη κλήσης στο κέντρο επιχειρήσεων Λήξη συμβάντος Κέντρο αποστολής Κωδικός καθοριστή diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index f6fa6c34b..e7783e3f6 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ Dispatch times Call created Call answered - First arrival + Call received at dispatch Incident cleared Dispatch center Determinant code diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index 79abf0446..bb7f7eb2a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ Tiempos de despacho Llamada creada Llamada contestada - Primera llegada + Recepción de la llamada en la central Incidente despejado Centro de despacho Código determinante diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index 43045aada..75a0da89f 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ Horaires de régulation Appel créé Appel décroché - Première arrivée + Réception de l'appel au centre de répartition Incident terminé Centre de régulation Code déterminant diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index b805460c8..f9ed853fc 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ Tempi di dispatch Chiamata creata Chiamata risposta - Primo arrivo + Ricezione della chiamata in centrale Incidente concluso Centrale operativa Codice determinante diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index 5d6ca794f..d97eddaf3 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ Czasy dysponowania Utworzenie wezwania Odebranie zgłoszenia - Pierwsze przybycie + Odebranie zgłoszenia w dyspozytorni Zakończenie zdarzenia Centrum dyspozytorskie Kod determinanta diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index a24a9126a..c93439bcc 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ Utlarmningstider Larm skapat Samtal besvarat - Första ankomst + Samtal inkom till larmcentral Insats avslutad Larmcentral Determinantkod diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 49a0186ad..de605e40b 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx 2.0 @@ -258,7 +258,7 @@ Часи диспетчеризації Виклик створено Виклик прийнято - Перше прибуття + Надходження виклику до диспетчерської Інцидент завершено Диспетчерський центр Код детермінанти diff --git a/Core/Resgrid.Model/Providers/INerisProviders.cs b/Core/Resgrid.Model/Providers/INerisProviders.cs index baf1422ed..d48f9b45c 100644 --- a/Core/Resgrid.Model/Providers/INerisProviders.cs +++ b/Core/Resgrid.Model/Providers/INerisProviders.cs @@ -7,6 +7,8 @@ namespace Resgrid.Model.Providers /// Department NERIS profile, credential, value sets and crosswalks (RMS plan section 5.5). public interface INerisProfileService { + /// Credential-free identity of the configured entity, environment and effective endpoint. + string GetDestinationIdentity(RmsNerisProfile profile); Task GetProfileAsync(int departmentId); /// Saves the profile; a non-null credential replaces the stored one (encrypted per department), null keeps it. diff --git a/Core/Resgrid.Model/Providers/IPdfProvider.cs b/Core/Resgrid.Model/Providers/IPdfProvider.cs index 55babb9db..555deecd7 100644 --- a/Core/Resgrid.Model/Providers/IPdfProvider.cs +++ b/Core/Resgrid.Model/Providers/IPdfProvider.cs @@ -3,5 +3,7 @@ public interface IPdfProvider { byte[] ConvertHtmlToPdf(string html); + /// Departmental Records require an explicit page size; older providers retain their existing default. + byte[] ConvertHtmlToPdf(string html, string pageSize) => ConvertHtmlToPdf(html); } -} \ No newline at end of file +} diff --git a/Core/Resgrid.Model/Records/IncidentReportContracts.cs b/Core/Resgrid.Model/Records/IncidentReportContracts.cs index f8aa8c33a..e00efd838 100644 --- a/Core/Resgrid.Model/Records/IncidentReportContracts.cs +++ b/Core/Resgrid.Model/Records/IncidentReportContracts.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace Resgrid.Model @@ -6,6 +6,9 @@ namespace Resgrid.Model /// The incident report aggregate as the Web and the mapper read it: header plus the working-draft child rows (or a revision's copies). public class IncidentReportAggregate { + public RecordUdfSection CustomFields { get; set; } + public List Attachments { get; set; } = new List(); + public List Evidence { get; set; } = new List(); public RmsIncidentReport Report { get; set; } public RmsLocation Location { get; set; } public List Types { get; set; } = new List(); @@ -101,6 +104,7 @@ public class IncidentLocationInput /// public class IncidentReportDraftInput { + public RecordUdfInput CustomFields { get; set; } public string IncidentNumber { get; set; } public DateTime? CallCreatedOn { get; set; } public DateTime? CallAnsweredOn { get; set; } @@ -161,6 +165,7 @@ public class IncidentResourceInput /// public class IncidentCasualtyRescueInput { + public string CasualtyId { get; set; } public RmsCasualtyRescueKind Kind { get; set; } public string PersonType { get; set; } public string PersonnelUserId { get; set; } @@ -264,6 +269,7 @@ public class IncidentPropertyInput /// VIN and plate are restricted; the service drops them from a caller without RecordRestricted_View. public class IncidentVehicleInput { + public string VehicleId { get; set; } public string VehicleKind { get; set; } public string Make { get; set; } public string Model { get; set; } diff --git a/Core/Resgrid.Model/Records/NerisContracts.cs b/Core/Resgrid.Model/Records/NerisContracts.cs index 3fa46b8b5..3957a9d6d 100644 --- a/Core/Resgrid.Model/Records/NerisContracts.cs +++ b/Core/Resgrid.Model/Records/NerisContracts.cs @@ -10,6 +10,10 @@ namespace Resgrid.Model /// public class NerisIncidentSnapshot { + public RecordUdfSection CustomFields { get; set; } + /// Departmental revision content. The national mapper never emits these collections. + public List Attachments { get; set; } = new List(); + public List Evidence { get; set; } = new List(); public RmsIncidentReport Report { get; set; } public RmsLocation Location { get; set; } public List Types { get; set; } = new List(); @@ -117,6 +121,9 @@ public enum NerisOutcomeKind /// One exchange with the destination, reduced to what the submission row and the workflow triggers may see. public class NerisSubmissionOutcome { + /// The queued payload was refused locally before credentials or HTTP; no destination response is implied. + public bool LocalValidationFailure { get; set; } + public bool DeliveryUncertain { get; set; } public NerisOutcomeKind Kind { get; set; } public int? StatusCode { get; set; } public string ExternalId { get; set; } diff --git a/Core/Resgrid.Model/Records/RecordIdempotencyException.cs b/Core/Resgrid.Model/Records/RecordIdempotencyException.cs new file mode 100644 index 000000000..6f6005221 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordIdempotencyException.cs @@ -0,0 +1,9 @@ +using System; + +namespace Resgrid.Model +{ + public sealed class RecordIdempotencyException : InvalidOperationException + { + public RecordIdempotencyException(string message) : base(message) { } + } +} diff --git a/Core/Resgrid.Model/Records/RecordUdfContracts.cs b/Core/Resgrid.Model/Records/RecordUdfContracts.cs new file mode 100644 index 000000000..b1e4b07e0 --- /dev/null +++ b/Core/Resgrid.Model/Records/RecordUdfContracts.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Values only: labels, types, visibility and classification come from the pinned published definition. + public class RecordUdfInput + { + public string DefinitionId { get; set; } + public Dictionary Values { get; set; } = new Dictionary(); + } + public class RecordUdfSection + { + public string DefinitionId { get; set; } + public string RecordDefinitionKey { get; set; } + public int RecordDefinitionVersion { get; set; } + public int ExtensionVersion { get; set; } + public List Fields { get; set; } = new List(); + } + public class RecordUdfField + { + public UdfField Field { get; set; } + public string Value { get; set; } + } +} diff --git a/Core/Resgrid.Model/Records/RecordsApiContracts.cs b/Core/Resgrid.Model/Records/RecordsApiContracts.cs index f3931795c..b785949cb 100644 --- a/Core/Resgrid.Model/Records/RecordsApiContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsApiContracts.cs @@ -252,13 +252,17 @@ public interface IRecordAttachmentUploadService Task GetAsync(int departmentId, string userId, string uploadId); Task AppendAsync(int departmentId, string userId, string uploadId, long offset, byte[] data); /// Verifies size and SHA-256, then stores the attachment through the same hygiene/scanner path as a direct upload. - Task CompleteAsync(int departmentId, string userId, string uploadId, string description, CancellationToken cancellationToken = default); + Task CompleteAsync(int departmentId, string userId, string uploadId, string description, CancellationToken cancellationToken = default, int classification = 1); Task AbortAsync(int departmentId, string userId, string uploadId); } /// Scoped idempotency for v4 Records commands: the same (department, user, key) replays the first outcome instead of re-running the transition. public interface IRecordsApiIdempotencyService { + /// Commit an exclusive reservation before mutation. A false result must never execute the command. + Task TryReserveCommandAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId, string requestChecksum); + Task TryGetCommandAsync(int departmentId, string userId, string idempotencyKey, string command); + Task RememberCommandAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId, string requestChecksum); /// /// The record a previous call under this key produced, or null. scopes the key to /// one operation: a client that reuses a key across two different commands on the same record must not have @@ -268,4 +272,12 @@ public interface IRecordsApiIdempotencyService Task RememberAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId); } + + public class RecordCommandReceipt + { + public string RecordId { get; set; } + public string RequestChecksum { get; set; } + /// The command may be running or may have committed without a receipt. Do not automatically repeat it. + public bool IsPending { get; set; } + } } diff --git a/Core/Resgrid.Model/Records/RecordsContracts.cs b/Core/Resgrid.Model/Records/RecordsContracts.cs index 74640780e..9fee8587d 100644 --- a/Core/Resgrid.Model/Records/RecordsContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsContracts.cs @@ -145,9 +145,19 @@ public class RecordUnitResponseInput public DateTime? InQuarters { get; set; } } + /// Officer-authored historical Call created and linked from an existing Run draft. + public class RecordNewCallInput + { + public string Name { get; set; } + public string Address { get; set; } + public string Nature { get; set; } + public DateTime OccurredOnUtc { get; set; } + } + /// Draft create/save input for a locked Logs-parity definition. public class RecordDraftInput { + public RecordUdfInput CustomFields { get; set; } /// One of ; required on create. public string DefinitionKey { get; set; } public int? CallId { get; set; } @@ -170,6 +180,7 @@ public class RecordDraftInput /// A hydrated Record: header, working/revision details, participants, units, attachment metadata. public class RecordAggregate { + public RecordUdfSection CustomFields { get; set; } public RmsOperationalRecord Record { get; set; } public RmsOperationalRecordDetail Details { get; set; } public List Participants { get; set; } = new List(); @@ -185,7 +196,9 @@ public class RecordAggregate /// public class RecordSnapshot { + public RecordUdfSection CustomFields { get; set; } public int SnapshotVersion { get; set; } = 1; + public List Evidence { get; set; } = new List(); public string RecordId { get; set; } public int DepartmentId { get; set; } public string DefinitionKey { get; set; } @@ -210,6 +223,7 @@ public class RecordFieldDiff { public string Section { get; set; } public string FieldKey { get; set; } + public string FieldLabel { get; set; } public string OldValue { get; set; } public string NewValue { get; set; } /// True when the field is restricted and the viewer lacks RecordRestricted_View: values are withheld. diff --git a/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs b/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs index 16518b4a5..f0f2778ec 100644 --- a/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs +++ b/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using ProtoBuf; namespace Resgrid.Model @@ -107,6 +108,47 @@ public RecordsRetentionPolicy() [ProtoMember(4)] public DateTime? LastChangedOn { get; set; } + [ProtoMember(5)] + public List History { get; set; } = new List(); + + /// The policy in force when this revision became official. Unknown pre-history is retained permanently. + public int ResolveYears(string definitionKey, DateTime revisionOn) + { + var applicable = this; + if (LastChangedOn.HasValue && revisionOn < LastChangedOn.Value) + { + applicable = (History ?? new List()).Where(v => v.EffectiveOn <= revisionOn) + .OrderByDescending(v => v.EffectiveOn).Select(v => v.Policy).FirstOrDefault(); + if (applicable == null) return Permanent; + } + var rule = applicable.Overrides?.Where(o => o.DefinitionKey == definitionKey && o.AppliesFrom <= revisionOn) + .OrderByDescending(o => o.AppliesFrom).FirstOrDefault(); + if (rule != null) return Math.Max(Permanent, rule.RetentionYears); + return RmsDefinitionKeys.RestrictedClass.Contains(definitionKey ?? string.Empty) ? Permanent + : Math.Max(Permanent, applicable.DepartmentDefaultYears ?? StandardClassDefaultYears); + } + + /// Called while holding the department write lock; caller-supplied history is never accepted. + public void PreserveHistory(RecordsRetentionPolicy previous, DateTime now) + { + previous ??= new RecordsRetentionPolicy(); + History = new List(previous.History ?? new List()); + History.Add(new RecordsRetentionPolicyVersion + { + EffectiveOn = previous.LastChangedOn ?? DateTime.MinValue, + Policy = new RecordsRetentionPolicy { DepartmentDefaultYears = previous.DepartmentDefaultYears, + Overrides = (previous.Overrides ?? new List()).Select(o => new RecordsRetentionOverride + { DefinitionKey = o.DefinitionKey, RetentionYears = o.RetentionYears, AppliesFrom = o.AppliesFrom }).ToList(), + LastChangedByUserId = previous.LastChangedByUserId } + }); + LastChangedOn = now; + foreach (var rule in Overrides ?? new List()) + { + var old = previous.Overrides?.FirstOrDefault(o => o.DefinitionKey == rule.DefinitionKey && o.RetentionYears == rule.RetentionYears); + rule.AppliesFrom = old?.AppliesFrom ?? now; + } + } + /// /// Retention years for a definition under this policy (legal hold is evaluated by the caller /// first). Restricted-class definitions never inherit the department default; they need an @@ -133,6 +175,13 @@ public int ResolveYears(string definitionKey) } } + [ProtoContract] + public class RecordsRetentionPolicyVersion + { + [ProtoMember(1)] public DateTime EffectiveOn { get; set; } + [ProtoMember(2)] public RecordsRetentionPolicy Policy { get; set; } + } + /// Department setting 77 (RecordsDisclosureConfig). RMS-3 consumes it; the shape ships now so the value is claimed. [ProtoContract] public class RecordsDisclosureConfig diff --git a/Core/Resgrid.Model/Records/RecordsSearchContracts.cs b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs index d81fbe1d2..74304fc1f 100644 --- a/Core/Resgrid.Model/Records/RecordsSearchContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsSearchContracts.cs @@ -88,6 +88,7 @@ public class RecordsSearchIndexSweepResult public int DepartmentsRebuilt { get; set; } public int DocumentsIndexed { get; set; } public int DocumentsDeleted { get; set; } + public int SearchErasuresCompleted { get; set; } public int Errors { get; set; } public bool Skipped { get; set; } public string Message { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsDisclosure.cs b/Core/Resgrid.Model/Records/RmsDisclosure.cs index 81692c1b0..5a580f3e8 100644 --- a/Core/Resgrid.Model/Records/RmsDisclosure.cs +++ b/Core/Resgrid.Model/Records/RmsDisclosure.cs @@ -180,6 +180,8 @@ public class RmsDisclosureProduction : IEntity public DateTime PreparedOn { get; set; } public string ReleasedByUserId { get; set; } + public string DeliveryMethod { get; set; } + public string DeliveryReference { get; set; } public DateTime? ReleasedOn { get; set; } @@ -207,13 +209,14 @@ public class RmsDisclosureProduction : IEntity /// One record in a disclosure scope preview, before anything is produced. public class RmsDisclosureScopeItem { + public RmsRecordKind RecordKind { get; set; } = RmsRecordKind.Operational; public string RecordId { get; set; } public string RecordNumber { get; set; } public string DefinitionKey { get; set; } public string Summary { get; set; } public DateTime? OccurredOn { get; set; } public string CurrentRevisionId { get; set; } - /// False when the record is not finalized; a draft is not a public record and is never produced. + /// False when no saved revision is available for automatic production. public bool Producible { get; set; } public string NotProducibleReason { get; set; } } @@ -231,10 +234,79 @@ public class RmsDisclosureScopePreview /// One withheld field in a production's redaction log. public class RmsRedactionEntry { + public string Authority { get; set; } public string RecordId { get; set; } public string Section { get; set; } public string Field { get; set; } /// Why it was withheld — the classification or profile rule relied on. public string Basis { get; set; } } + + public class RmsDisclosureFieldValue + { + public string Path { get; set; } + public string Value { get; set; } + } + public class RmsDisclosureFieldDecision + { + public string Path { get; set; } + public bool Withhold { get; set; } + public string Authority { get; set; } + public string Basis { get; set; } + } + + public class RmsDisclosureDownload + { + public byte[] Data { get; set; } + public string ContentType { get; set; } + public string FileName { get; set; } + } + public class RmsDisclosureReview + { + public string RequestId { get; set; } + public string Profile { get; set; } + public string ScopeChecksum { get; set; } + public bool Reviewed { get; set; } + public string Authority { get; set; } + public string Basis { get; set; } + /// Recorded handling of unfinished or inaccessible scope items; never silently omitted. + public string UnresolvedScopeHandling { get; set; } + public List Records { get; set; } = new List(); + } + public class RmsDisclosureRecordReview + { + public RmsRecordKind RecordKind { get; set; } + public string RecordId { get; set; } + public string RecordNumber { get; set; } + public string RevisionId { get; set; } + public string RevisionChecksum { get; set; } + public string ContentChecksum { get; set; } + public bool WithholdWhole { get; set; } + public string Authority { get; set; } + public string Basis { get; set; } + public List AutomaticWithholds { get; set; } = new List(); + public List Fields { get; set; } = new List(); + public List Decisions { get; set; } = new List(); + public List Attachments { get; set; } = new List(); + } + public class RmsDisclosureAttachmentDecision + { + public RmsDisclosureAttachmentDerivative Derivative { get; set; } + public List Metadata { get; set; } = new List(); + public string AttachmentId { get; set; } + public string FileName { get; set; } + public string Checksum { get; set; } + public bool Include { get; set; } + public bool Reviewed { get; set; } + public string Authority { get; set; } + public string Basis { get; set; } + } + /// A custodian-reviewed replacement file; its source remains unchanged. + public class RmsDisclosureAttachmentDerivative + { + public string FileName { get; set; } + public string ContentType { get; set; } + public byte[] Data { get; set; } + public string Checksum { get; set; } + } } diff --git a/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs b/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs index 598358f89..5a616fef8 100644 --- a/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs +++ b/Core/Resgrid.Model/Records/RmsDueStateAndRetention.cs @@ -192,6 +192,8 @@ public class RecordsRetentionSweepResult public int DepartmentsEvaluated { get; set; } public int RecordsEvaluated { get; set; } public int RecordsPurged { get; set; } + /// SQL purges from this sweep still awaiting committed index erasure; not a total backlog count. + public int SearchErasuresPending { get; set; } public int AttachmentsPurged { get; set; } public int HeldByLegalHold { get; set; } public int AttachmentsRescanned { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs b/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs index 074748fba..9f981f4be 100644 --- a/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs +++ b/Core/Resgrid.Model/Records/RmsEvidenceArtifact.cs @@ -103,6 +103,8 @@ public class RmsEvidenceArtifact : IEntity /// Version or schema of the source at capture time, where the source carries one. public string SourceVersion { get; set; } + /// Identity of the original capture request for safe API replay. + public string CaptureRequestChecksum { get; set; } /// Start of the period the artifact covers (a tracking window, a readiness period). public DateTime? CoverageStart { get; set; } @@ -172,6 +174,8 @@ public class RmsEvidenceArtifact : IEntity /// What a caller asks an evidence adapter to capture. The adapter decides what it can honour. public class RecordEvidenceCaptureRequest { + /// Optional caller version; the service pins the observed parent version when omitted. + public long? ExpectedRowVersion { get; set; } public int DepartmentId { get; set; } public string RecordId { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsIncidentReport.cs b/Core/Resgrid.Model/Records/RmsIncidentReport.cs index 91d3b6ac2..537ad91bc 100644 --- a/Core/Resgrid.Model/Records/RmsIncidentReport.cs +++ b/Core/Resgrid.Model/Records/RmsIncidentReport.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using ProtoBuf; @@ -37,6 +37,7 @@ public class RmsIncidentReport : IEntity [ProtoMember(7)] public int DefinitionVersion { get; set; } + public string UdfDefinitionId { get; set; } /// Pinned NERIS contract version (value sets and payload shape) this report was authored against. [ProtoMember(8)] @@ -162,7 +163,7 @@ public class RmsIncidentReport : IEntity [ProtoMember(54)] public DateTime? CallAnsweredOn { get; set; } - /// First unit on scene (NERIS call_arrival). + /// Call arrival at the PSAP/dispatch center (NERIS call_arrival), before answer/create; never unit on-scene. [ProtoMember(55)] public DateTime? CallArrivalOn { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsOperationalRecord.cs b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs index 04ae647f7..da4a69ab9 100644 --- a/Core/Resgrid.Model/Records/RmsOperationalRecord.cs +++ b/Core/Resgrid.Model/Records/RmsOperationalRecord.cs @@ -28,6 +28,7 @@ public class RmsOperationalRecord : IEntity public string DefinitionKey { get; set; } public int DefinitionVersion { get; set; } + public string UdfDefinitionId { get; set; } /// for locked definitions; null for department-owned ones. public int? RecordType { get; set; } @@ -111,6 +112,9 @@ public class RmsOperationalRecord : IEntity /// Scoped idempotency key for create; unique per department when present. public string IdempotencyKey { get; set; } + /// Original create command fingerprint; never changed by subsequent draft edits. + public string OriginalRequestChecksum { get; set; } + /// . public int OriginClient { get; set; } diff --git a/Core/Resgrid.Model/Records/RmsRecordAttachment.cs b/Core/Resgrid.Model/Records/RmsRecordAttachment.cs index 04093c01b..cac5496d5 100644 --- a/Core/Resgrid.Model/Records/RmsRecordAttachment.cs +++ b/Core/Resgrid.Model/Records/RmsRecordAttachment.cs @@ -48,6 +48,11 @@ public class RmsRecordAttachment : IEntity /// True when location/device metadata was stripped on upload (plan section 4.7, media hygiene). public bool MetadataStripped { get; set; } + /// RmsEvidenceClassification. Null means legacy/unclassified and requires restricted access. + public int? Classification { get; set; } + [NotMapped, JsonIgnore] + public bool RequiresRestrictedAccess => IsProtected || Classification != (int)RmsEvidenceClassification.Unrestricted; + public bool IsProtected { get; set; } public int ProtectedCatalogVersion { get; set; } @@ -78,6 +83,6 @@ public object IdValue public int IdType => 1; [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "RequiresRestrictedAccess" }; } } diff --git a/Core/Resgrid.Model/Records/RmsSubmission.cs b/Core/Resgrid.Model/Records/RmsSubmission.cs index dbdc62eeb..c4a4f3b61 100644 --- a/Core/Resgrid.Model/Records/RmsSubmission.cs +++ b/Core/Resgrid.Model/Records/RmsSubmission.cs @@ -3,6 +3,14 @@ namespace Resgrid.Model { + public class RmsSubmissionReconciliationInput + { + public long RowVersion { get; set; } + public string ExternalId { get; set; } + public string Reason { get; set; } + public bool ConfirmedNotCreated { get; set; } + public string VerificationReference { get; set; } + } public enum RmsSubmissionState { Queued = 0, @@ -46,6 +54,11 @@ public class RmsSubmission : IEntity public string RevisionId { get; set; } public string Destination { get; set; } public string DestinationVersion { get; set; } + public string DestinationIdentity { get; set; } + /// A create may have reached the destination without a usable receipt; automatic create retries are blocked. + public bool RequiresReconciliation { get; set; } + /// A create was dispatched and has not received a definitive, applied outcome. + public bool CreatePendingReceipt { get; set; } /// Scoped idempotency key: one per (record, revision); reused on every retry. public string IdempotencyKey { get; set; } /// . diff --git a/Core/Resgrid.Model/Records/RmsSubmissionExchange.cs b/Core/Resgrid.Model/Records/RmsSubmissionExchange.cs new file mode 100644 index 000000000..97e9c8af4 --- /dev/null +++ b/Core/Resgrid.Model/Records/RmsSubmissionExchange.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// Append-only delivery journal. Started, Response, and Applied entries share ExchangeId; no response is overwritten. + public class RmsSubmissionExchange : IEntity + { + public string RmsSubmissionExchangeId { get; set; } + public int DepartmentId { get; set; } + public string SubmissionId { get; set; } + public string RecordId { get; set; } + public string RevisionId { get; set; } + public string ExchangeId { get; set; } + public string Stage { get; set; } + public string Operation { get; set; } + public string DestinationIdentity { get; set; } + public string PayloadChecksum { get; set; } + public string OutcomeJson { get; set; } + public string OutcomeChecksum { get; set; } + public int AttemptNumber { get; set; } + public DateTime OccurredOn { get; set; } + public object IdValue { get => RmsSubmissionExchangeId; set => RmsSubmissionExchangeId = (string)value; } + public string TableName => "RmsSubmissionExchanges"; + public string IdName => "RmsSubmissionExchangeId"; + public int IdType => 1; + public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/Repositories/IDepartmentSettingsRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentSettingsRepository.cs index 2c7e1ba0e..9da39a7ec 100644 --- a/Core/Resgrid.Model/Repositories/IDepartmentSettingsRepository.cs +++ b/Core/Resgrid.Model/Repositories/IDepartmentSettingsRepository.cs @@ -10,6 +10,7 @@ namespace Resgrid.Model.Repositories /// public interface IDepartmentSettingsRepository: IRepository { + Task SaveRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, System.Threading.CancellationToken cancellationToken = default); /// /// Gets the department setting by user identifier type asynchronous. /// diff --git a/Core/Resgrid.Model/Repositories/IRmsCommandReceiptsRepository.cs b/Core/Resgrid.Model/Repositories/IRmsCommandReceiptsRepository.cs new file mode 100644 index 000000000..dee6d77ab --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsCommandReceiptsRepository.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using Resgrid.Model.Services; + +namespace Resgrid.Model.Repositories +{ + /// Durable, metadata-only command reservations. Pending commands never expire into automatic retries. + public interface IRmsCommandReceiptsRepository + { + Task GetAsync(int departmentId, string keyHash); + Task ReserveAsync(int departmentId, string keyHash, string recordId, string requestChecksum, string reservationId); + Task CompleteAsync(int departmentId, string keyHash, string recordId, string requestChecksum, string reservationId); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs index d63c677ae..13522ea6a 100644 --- a/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsIncidentRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -40,7 +40,7 @@ public interface IRmsIncidentReportsRepository : IRepository Task GetMaxRecordNumberSequenceAsync(int departmentId, string numberPrefix); Task TryBumpRowVersionAsync(int departmentId, string reportId, long expectedRowVersion, CancellationToken cancellationToken = default); /// Retention candidates (RMS-3, worker 43): live, closed reports finalized before the cutoff, oldest first. - Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take); + Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take, string afterId = null); } /// Shared contract of every per-report child row set: a working draft (RevisionId null) and immutable revision copies. @@ -74,12 +74,14 @@ public interface IRmsIncidentVehiclesRepository : IRmsIncidentChildRepository { + Task TryBumpRowVersionAsync(int departmentId, string analysisId, long expectedRowVersion, CancellationToken cancellationToken = default); Task GetByIdForDepartmentAsync(int departmentId, string analysisId); /// The analysis for an incident report; one per report, null when none has been started. Task GetForReportAsync(int departmentId, string incidentReportId); /// Analyses finalized but not yet filed because their incident had no NERIS id at the time. Task> GetAwaitingIncidentAsync(int departmentId, int take); Task CountByStateAsync(int departmentId, RmsIncidentAnalysisState state); + Task CountVisibleByStateAsync(int departmentId, RmsIncidentAnalysisState state, List visibleGroupIds, string userId); } public interface IRmsValidationIssuesRepository : IRepository @@ -91,6 +93,11 @@ public interface IRmsValidationIssuesRepository : IRepository { + Task TryReconcileReceiptAsync(int departmentId, string submissionId, long expectedVersion, string externalId, string destinationIdentity, DateTime now, CancellationToken cancellationToken = default); + Task TryBindUnsentAsync(int departmentId, string submissionId, long expectedVersion, string destinationIdentity, DateTime now, CancellationToken cancellationToken = default); + Task TryConfirmNotCreatedAsync(int departmentId, string submissionId, long expectedVersion, string destinationIdentity, DateTime now, CancellationToken cancellationToken = default); + /// Transaction-local completion/dispatch fence: locks and bumps only the current, unexpired lease. + Task TryFenceLeaseAsync(int departmentId, string submissionId, long expectedVersion, string leaseOwner, DateTime now, CancellationToken cancellationToken = default); Task GetByIdForDepartmentAsync(int departmentId, string submissionId); Task> GetForRecordAsync(int departmentId, string recordId); Task GetByIdempotencyKeyAsync(string idempotencyKey); @@ -102,6 +109,11 @@ public interface IRmsSubmissionsRepository : IRepository Task SupersedeOpenForRecordAsync(int departmentId, string recordId, string exceptSubmissionId, DateTime utcNow, CancellationToken cancellationToken = default); } + public interface IRmsSubmissionExchangesRepository : IRepository + { + Task> GetForSubmissionAsync(int departmentId, string submissionId); + } + public interface IRmsSignaturesRepository : IRepository { Task> GetForRecordAsync(int departmentId, string recordId); diff --git a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs index 38f3f5a84..56331d0c5 100644 --- a/Core/Resgrid.Model/Repositories/IRmsRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IRmsRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -38,6 +38,7 @@ public interface IRmsOperationalRecordsRepository : IRepository> GetByOwnerAndStatesAsync(int departmentId, string ownerUserId, IEnumerable states); Task> GetByDepartmentAndStatesAsync(int departmentId, IEnumerable states, int? year, int skip, int take); Task CountByDepartmentAsync(int departmentId, IEnumerable states); + Task CountVisibleAsync(int departmentId, IEnumerable states, List visibleGroupIds, string userId); Task CountCreatedSinceAsync(int departmentId, DateTime sinceUtc); Task CountFinalizedSinceAsync(int departmentId, DateTime sinceUtc); /// Every live Record in the department, any state. @@ -47,7 +48,7 @@ public interface IRmsOperationalRecordsRepository : IRepositoryLive Finalized/Amended Records whose FinalizedOn is at or after the instant. Task> GetFinalizedSinceAsync(int departmentId, DateTime sinceUtc); /// Retention candidates (RMS-3, worker 43): live, closed Records finalized before the cutoff, oldest first. - Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take); + Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take, string afterId = null); /// Live Records with no RmsRecordGroupScope row: they stay department-wide under group scoping (plan 5.7.1). Task CountWithoutGroupScopeAsync(int departmentId); Task> GetYearsAsync(int departmentId); @@ -89,6 +90,9 @@ public interface IRmsRecordUnitResponsesRepository : IRepository { + /// Includes attachments removed from the draft. Caller must verify immutable revision membership and authorize the live parent. + Task GetHistoricalByIdForDepartmentAsync(int departmentId, string attachmentId); + Task ApplyScanResultAsync(int departmentId, string attachmentId, long expectedVersion, RmsAttachmentScanState state, DateTime now, CancellationToken cancellationToken = default); /// Metadata only: never loads Data. Task> GetMetadataForRecordAsync(int departmentId, string recordId); /// Loads Data; authorized per Record by the caller on every request. @@ -134,6 +138,7 @@ public interface IRmsDepartmentCutoverEventsRepository : IRepository { + Task> GetByIdsForDepartmentAsync(int departmentId, IEnumerable revisionIds); Task> GetForRecordAsync(int departmentId, string recordId); Task GetByIdForDepartmentAsync(int departmentId, string revisionId); } @@ -167,6 +172,8 @@ public interface IRmsSearchIndexStatesRepository : IRepository { + /// Live share identities for cache-scope invalidation. Excludes free-text reasons and expired/revoked grants. + Task> GetEffectiveSharesAsync(int departmentId, IEnumerable groupIds); Task> GetForRecordAsync(int departmentId, string recordId); Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds); /// Deletes the Record's scope rows and inserts the supplied set; run inside the owning transaction. @@ -190,6 +197,8 @@ public interface IRmsRecordSharesRepository : IRepository /// public interface IRmsEvidenceArtifactsRepository : IRepository { + /// Metadata-only history across draft and signed revisions, including superseded evidence. + Task> GetHistoryAsync(int departmentId, string recordId, int skip, int take); Task GetByIdForDepartmentAsync(int departmentId, string artifactId); /// null returns the working-draft artifacts. Task> GetForRecordAsync(int departmentId, string recordId, string revisionId, bool includeSuperseded); @@ -208,12 +217,14 @@ public interface IRmsRecordDueStatesRepository : IRepository Task> GetOpenForDepartmentAsync(int departmentId, int take); /// Count of obligations currently sitting overdue; the accountability view and dashboards read it. Task CountOverdueAsync(int departmentId); + Task CountVisibleOverdueAsync(int departmentId, List visibleGroupIds, string userId); Task ClearForRecordAsync(int departmentId, string recordId, DateTime utcNow, CancellationToken cancellationToken = default); } /// Public-records requests (registry M0171, RMS-3); the statutory clock is what these are read by. public interface IRmsDisclosureRequestsRepository : IRepository { + Task 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); Task CountByStateAsync(int departmentId, RmsDisclosureState state); @@ -228,6 +239,7 @@ public interface IRmsDisclosureRequestsRepository : IRepository public interface IRmsDisclosureProductionsRepository : IRepository { + Task TryReleaseAsync(int departmentId, string productionId, long expectedVersion, string userId, DateTime releasedOn, string deliveryMethod, string deliveryReference, CancellationToken cancellationToken = default); Task GetByIdForDepartmentAsync(int departmentId, string productionId); Task> GetForRequestAsync(int departmentId, string requestId); Task GetMaxProductionNumberAsync(int departmentId, string requestId); @@ -235,6 +247,7 @@ public interface IRmsDisclosureProductionsRepository : IRepository { + Task TryReleaseAsync(int departmentId, string holdId, long expectedVersion, string userId, string reason, DateTime releasedOn, CancellationToken cancellationToken = default); Task GetByIdForDepartmentAsync(int departmentId, string holdId); /// Every hold still in force for the department; the retention sweep loads them once per pass. Task> GetActiveForDepartmentAsync(int departmentId); diff --git a/Core/Resgrid.Model/Repositories/IRmsRetentionRepository.cs b/Core/Resgrid.Model/Repositories/IRmsRetentionRepository.cs new file mode 100644 index 000000000..4b086975a --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsRetentionRepository.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public sealed class RmsPurgeResult + { + /// SQL content was removed. Search storage is complete only after its separate durable acknowledgement. + public bool Purged { get; set; } + public bool SearchErasurePending { get; set; } + public bool Held { get; set; } + public int AttachmentsPurged { get; set; } + public string Reason { get; set; } + } + + public sealed class RmsSearchErasureTarget + { + public int DepartmentId { get; set; } + public int RecordKind { get; set; } + public string RecordId { get; set; } + public DateTime PurgedOn { get; set; } + public List SourceIds { get; set; } = new List(); + } + + public interface IRmsRetentionRepository + { + /// Rechecks the current policy, all holds, and aggregate version under one department transaction lock. + Task PurgeAsync(int departmentId, string recordId, RmsRecordKind kind, long expectedVersion, DateTime now, CancellationToken cancellationToken = default); + Task> GetPendingSearchErasuresAsync(int take, RmsSearchErasureTarget after = null, CancellationToken cancellationToken = default); + /// Called only after all source keys have been removed, deleted Lucene documents expunged and the index committed. + Task CompleteSearchErasureAsync(RmsSearchErasureTarget target, DateTime completedOn, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsSearchWriteFence.cs b/Core/Resgrid.Model/Repositories/IRmsSearchWriteFence.cs new file mode 100644 index 000000000..5d98c2807 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsSearchWriteFence.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// Serializes each index mutation with retention and verifies the current SQL source before invoking the synchronous index writer. + public interface IRmsSearchWriteFence + { + Task WithLiveSourceAsync(RecordsSearchDocumentSource source, Func write, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IRmsUdfDefinitionsRepository.cs b/Core/Resgrid.Model/Repositories/IRmsUdfDefinitionsRepository.cs new file mode 100644 index 000000000..53e5c4d38 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IRmsUdfDefinitionsRepository.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IRmsUdfDefinitionsRepository : IRepository + { + Task GetActiveAsync(int departmentId, string key, int version); + Task GetScopedAsync(int departmentId, string definitionId, string key, int version); + Task DeactivateAsync(int departmentId, string key, int version, CancellationToken ct); + Task LockDepartmentAsync(int departmentId, CancellationToken ct); + Task GuardRecordAsync(int departmentId, string recordId, CancellationToken ct); + Task DeleteRecordValuesAsync(int departmentId, string recordId, CancellationToken ct); + } +} diff --git a/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs b/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs index a1a1dcce1..7fa00d5d6 100644 --- a/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs +++ b/Core/Resgrid.Model/Services/IIncidentAnalysisService.cs @@ -25,9 +25,10 @@ public interface IIncidentAnalysisService /// /// ETag-guarded draft save. false keeps the stored VIN and plate on - /// each vehicle row rather than accepting or erasing them. + /// each vehicle row rather than accepting or erasing them. It defaults to false: a caller that has not + /// resolved the claim must not be granted restricted writes by omission. /// - Task SaveDraftAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, IncidentAnalysisDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default); + Task SaveDraftAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, IncidentAnalysisDraftInput input, bool canWriteRestricted = false, CancellationToken cancellationToken = default); /// Local validation against the pinned contract; never touches the incident's own issue list. Task> ValidateAsync(int departmentId, string analysisId, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Services/IIncidentAttachmentsService.cs b/Core/Resgrid.Model/Services/IIncidentAttachmentsService.cs new file mode 100644 index 000000000..1c6444e6e --- /dev/null +++ b/Core/Resgrid.Model/Services/IIncidentAttachmentsService.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IIncidentAttachmentsService + { + Task RemoveAsync(int departmentId, string userId, string reportId, string attachmentId, long expectedVersion, CancellationToken cancellationToken = default); + Task AddAsync(int departmentId, string userId, string reportId, long expectedVersion, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default, int classification = 1); + Task GetAsync(int departmentId, string userId, string reportId, string attachmentId, string revisionId = null); + } +} diff --git a/Core/Resgrid.Model/Services/IIncidentReportsService.cs b/Core/Resgrid.Model/Services/IIncidentReportsService.cs index bf25691f5..6d644c020 100644 --- a/Core/Resgrid.Model/Services/IIncidentReportsService.cs +++ b/Core/Resgrid.Model/Services/IIncidentReportsService.cs @@ -22,9 +22,10 @@ public interface IIncidentReportsService /// /// ETag-guarded draft save. false keeps the restricted halves of the /// casualty rows as they stand instead of accepting or erasing them, so a reviewer without - /// RecordRestricted_View can still correct the rest of the report (RMS-3). + /// RecordRestricted_View can still correct the rest of the report (RMS-3). It defaults to false: a caller + /// that has not resolved the claim must not be granted restricted writes by omission. /// - Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default); + Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, bool canWriteRestricted = false, CancellationToken cancellationToken = default); /// Runs local validation (and the destination's validate endpoint when asked and configured) and stores the issues on the report. Task> ValidateAsync(int departmentId, string reportId, bool includeDestination, CancellationToken cancellationToken = default); @@ -88,6 +89,11 @@ public class RecordsSubmissionSweepResult /// public interface IRecordsSubmissionService { + Task> GetHistoryAsync(int departmentId, string userId, string submissionId, CancellationToken cancellationToken = default); + /// Bind an ambiguous create to a verified destination filing without issuing another POST. + Task ReconcileAsync(int departmentId, string userId, string submissionId, long expectedVersion, string externalId, string reason, CancellationToken cancellationToken = default); + /// Administrator records externally verified absence of a filing. Clears ambiguity without delivering or automatically retrying. + Task ConfirmNotCreatedAsync(int departmentId, string userId, string submissionId, long expectedVersion, string verificationReference, string reason, CancellationToken cancellationToken = default); Task SweepAsync(CancellationToken cancellationToken = default); /// Processes one claimed submission; public so a single delivery can be driven and tested directly. diff --git a/Core/Resgrid.Model/Services/IRecordEvidenceSelectionService.cs b/Core/Resgrid.Model/Services/IRecordEvidenceSelectionService.cs new file mode 100644 index 000000000..2b96f85c2 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordEvidenceSelectionService.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IRecordEvidenceSelectionService + { + Task GetContextAsync(int departmentId, string userId, string recordId, RmsRecordKind recordKind); + Task GetAsync(int departmentId, string userId, string recordId, RmsRecordKind recordKind, + RmsEvidenceKind sourceKind, string channelId = null, long afterSequence = 0); + } + + public class RecordEvidenceContext + { + public string RecordId { get; set; } + public RmsRecordKind RecordKind { get; set; } + public string RecordNumber { get; set; } + public long RowVersion { get; set; } + public int? CallId { get; set; } + public DateTime? StartUtc { get; set; } + public DateTime? EndUtc { get; set; } + public bool CanCapture { get; set; } + public bool CanViewRestricted { get; set; } + public bool CanExport { get; set; } + } + + public class RecordEvidenceSelection + { + public RecordEvidenceContext Context { get; set; } + public RmsEvidenceKind SourceKind { get; set; } + public List Sources { get; set; } = new(); + public List Choices { get; set; } = new(); + public List Channels { get; set; } = new(); + public string ChannelId { get; set; } + public long? NextSequence { get; set; } + } + + public class RecordEvidenceChoice + { + public string Id { get; set; } + public string Label { get; set; } + public string Body { get; set; } + public DateTime? OccurredOn { get; set; } + public DateTime? EditedOn { get; set; } + public long? Sequence { get; set; } + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs index 7396060b2..41690f909 100644 --- a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs +++ b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs @@ -12,6 +12,13 @@ namespace Resgrid.Model.Services /// public interface IRecordsAuthorizationService { + Task IsActiveMemberAsync(string userId, int departmentId); + Task IsDepartmentAdminAsync(string userId, int departmentId); + Task HasPermissionAsync(string userId, int departmentId, PermissionTypes permissionType); + Task CanReadSourceCallAsync(string userId, int departmentId, Call call); + Task CanCreateSourceCallAsync(string userId, int departmentId); + Task CanUseSourceInventoryAsync(string userId, int departmentId, int? groupId = null); + /// Whether cross-group scoping is in force for the department (setting 75 GroupScoped and ViewGroupRecords LockToGroup). Task IsGroupScopedAsync(int departmentId); @@ -22,6 +29,9 @@ public interface IRecordsAuthorizationService /// Task> GetVisibleGroupIdsAsync(string userId, int departmentId); + /// Opaque fingerprint of current membership, roles, group and permission policy for local-cache invalidation. Null means read access cannot be established. + Task GetReadScopeStampAsync(string userId, int departmentId); + /// Per-Record check applied on every detail, attachment and deep-link read. Task CanUserViewRecordAsync(string userId, string recordId, int departmentId); diff --git a/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs b/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs index 05f808572..26bb18fc9 100644 --- a/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs +++ b/Core/Resgrid.Model/Services/IRecordsDisclosureService.cs @@ -15,24 +15,24 @@ namespace Resgrid.Model.Services /// snapshot so a later amendment cannot silently change what was handed over. /// /// - /// Callers must hold RecordDisclosure_Update; this service assumes the policy check already happened - /// at the controller and enforces the workflow rules, not the permission. + /// Callers must hold RecordDisclosure_Update; mutations and production also check live authorization + /// in the service. /// /// public interface IRecordsDisclosureService { Task CreateRequestAsync(int departmentId, string userId, RmsDisclosureRequest request, CancellationToken cancellationToken = default); - Task GetAsync(int departmentId, string requestId); + Task GetAsync(int departmentId, string userId, string requestId); - Task> QueryAsync(int departmentId, IEnumerable states, int skip = 0, int take = 50); + Task> QueryAsync(int departmentId, string userId, IEnumerable states, int skip = 0, int take = 50); /// 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); /// /// What the scope resolves to right now, through the same authorization and group-scope path as the - /// Records queue. Drafts are listed but never producible: an unfinished report is not a public record. + /// Records queue. Unfinished records require a separate review; automatic production uses saved revisions. /// Task PreviewScopeAsync(int departmentId, string userId, string requestId, int take = 200); @@ -40,12 +40,16 @@ public interface IRecordsDisclosureService /// Builds a new immutable production: redacted content, the produced-set snapshot, a redaction log and a /// checksum. Never mutates a source revision. /// - Task ProduceAsync(int departmentId, string userId, string requestId, string redactionProfile = null, CancellationToken cancellationToken = default); + Task GetReviewAsync(int departmentId, string userId, string requestId, string redactionProfile = null); + Task ProduceAsync(int departmentId, string userId, string requestId, string redactionProfile = null, CancellationToken cancellationToken = default, RmsDisclosureReview review = null); /// Releases a prepared production to the requester and closes the statutory clock. - Task ReleaseAsync(int departmentId, string userId, string productionId, CancellationToken cancellationToken = default); + Task ReleaseAsync(int departmentId, string userId, string productionId, CancellationToken cancellationToken = default, string deliveryMethod = null, string deliveryReference = null); - Task> GetProductionsAsync(int departmentId, string requestId); + Task> GetProductionsAsync(int departmentId, string userId, string requestId); + Task GetAuthorizedProductionAsync(int departmentId, string userId, string productionId); + Task DownloadAsync(int departmentId, string userId, string productionId, string format); + Task GetReviewAttachmentAsync(int departmentId, string userId, string requestId, string recordId, string revisionId, string attachmentId, string profile); /// Closes a request without release — denied under an exemption, or withdrawn. A reason is required. Task CloseAsync(int departmentId, string userId, string requestId, RmsDisclosureState disposition, string reason, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Services/IRecordsDocumentService.cs b/Core/Resgrid.Model/Services/IRecordsDocumentService.cs new file mode 100644 index 000000000..28efc784b --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsDocumentService.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public sealed class RecordDocument + { + public string Format { get; set; } = "resgrid.department-record.v2"; + public string RecordId { get; set; } + public RmsRecordKind RecordKind { get; set; } + public string RecordNumber { get; set; } + public string RevisionId { get; set; } + public int RevisionNumber { get; set; } + public string OriginalChecksum { get; set; } + public string ContentChecksum { get; set; } + public DateTime FinalizedOn { get; set; } + public string AttestedBy { get; set; } + public string AttestationVersion { get; set; } + public string ContentJson { get; set; } + public List WithheldFields { get; set; } = new List(); + } + public interface IRecordsDocumentService + { + /// Defaults to the official current revision, even while an amendment is open. Live authorization is always required. + Task GetAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, string revisionId = null, bool exporting = false); + Task RenderHtmlAsync(int departmentId, string userId, RecordDocument document); + Task RenderPdfAsync(int departmentId, string userId, RecordDocument document); + Task RenderDiffPdfAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, string fromRevisionId, string toRevisionId); + Task> DiffAsync(int departmentId, string userId, RecordDocument from, RecordDocument to); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs b/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs index ef3ca2955..a55280089 100644 --- a/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs +++ b/Core/Resgrid.Model/Services/IRecordsEvidenceService.cs @@ -35,6 +35,7 @@ public interface IRecordEvidenceAdapter /// public interface IRecordsEvidenceService { + Task> GetHistoryAsync(int departmentId, string recordId, int skip, int take); /// Which of the six sources can actually produce evidence for this department right now. Task> GetSourceStatesAsync(int departmentId); @@ -54,6 +55,7 @@ public interface IRecordsEvidenceService /// Re-computes the checksum from the stored manifest; false means the artifact was tampered with. Task VerifyAsync(int departmentId, string artifactId); + Task RequireInventoryCoverageAsync(int departmentId, string recordId, IEnumerable captured); } /// Whether one evidence source can produce anything for a department, and why not when it cannot. diff --git a/Core/Resgrid.Model/Services/IRecordsLegalHoldService.cs b/Core/Resgrid.Model/Services/IRecordsLegalHoldService.cs new file mode 100644 index 000000000..f83714be7 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsLegalHoldService.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IRecordsLegalHoldService + { + Task> GetAsync(int departmentId, string userId); + Task PlaceAsync(int departmentId, string userId, RmsRecordLegalHold input, CancellationToken cancellationToken = default); + Task ReleaseAsync(int departmentId, string userId, string holdId, long expectedVersion, string reason, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs b/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs index 753f17ea1..be1af190b 100644 --- a/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs +++ b/Core/Resgrid.Model/Services/IRecordsSearchIndexer.cs @@ -18,6 +18,9 @@ public interface IRecordsSearchIndexer Task CommitAsync(CancellationToken cancellationToken = default); + /// Expunges deleted documents from the configured index, commits replacement segments and refreshes this process's reader. + Task ExpungeDeletesAsync(CancellationToken cancellationToken = default); + Task CountDocumentsAsync(int departmentId); } } diff --git a/Core/Resgrid.Model/Services/IRecordsService.cs b/Core/Resgrid.Model/Services/IRecordsService.cs index e5bce3715..5bb447f3d 100644 --- a/Core/Resgrid.Model/Services/IRecordsService.cs +++ b/Core/Resgrid.Model/Services/IRecordsService.cs @@ -18,6 +18,7 @@ public interface IRecordsService /// ETag-guarded draft save; throws on a stale RowVersion. Task SaveDraftAsync(int departmentId, string userId, string recordId, long expectedRowVersion, RecordDraftInput input, CancellationToken cancellationToken = default); + Task CreateRunCallAsync(int departmentId, string userId, string recordId, long expectedRowVersion, RecordNewCallInput input, CancellationToken cancellationToken = default); Task SubmitForReviewAsync(int departmentId, string userId, string recordId, long expectedRowVersion, CancellationToken cancellationToken = default); @@ -63,10 +64,10 @@ public interface IRecordsService /// 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); - Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default); + Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default, int classification = 1); /// Loads bytes; the caller authorizes per Record before serving. - Task GetAttachmentAsync(int departmentId, string attachmentId); + Task GetAttachmentAsync(int departmentId, string userId, string attachmentId); Task RemoveAttachmentAsync(int departmentId, string userId, string recordId, string attachmentId, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Services/IRecordsUdfService.cs b/Core/Resgrid.Model/Services/IRecordsUdfService.cs new file mode 100644 index 000000000..e971989ec --- /dev/null +++ b/Core/Resgrid.Model/Services/IRecordsUdfService.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface IRecordsUdfService + { + Task GetForDesignerAsync(int departmentId, string userId, string key, int version); + Task GetNewFormAsync(int departmentId, string userId, string key, int version); + Task GetVisibilityLevelAsync(int departmentId, string userId); + Task PublishAsync(int departmentId, string userId, string key, int version, string expectedDefinitionId, List fields, CancellationToken ct = default); + /// Internal capture; entry points must project with current caller permissions before returning it. + Task CaptureAsync(int departmentId, string recordId, string key, int version, string definitionId); + Task ProjectAsync(int departmentId, string userId, RecordUdfSection section, bool mobile = false, bool reportLayout = false); + /// Called only under the owning RMS header CAS transaction. Returns the immutable definition pin. + Task SaveInTransactionAsync(int departmentId, string userId, string recordId, string key, int version, string pinnedDefinitionId, RecordUdfInput input, CancellationToken ct); + Task RestoreInTransactionAsync(int departmentId, string recordId, string key, int version, RecordUdfSection section, string userId, CancellationToken ct); + void ValidateForFinalization(RecordUdfSection section); + } +} diff --git a/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs index aa04672e5..1ceab0edd 100644 --- a/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs +++ b/Core/Resgrid.Model/Services/IRmsInventoryUsageAdapter.cs @@ -19,6 +19,10 @@ public class RmsInventoryUsage public int InventoryId { get; set; } public decimal Quantity { get; set; } public string Note { get; set; } + public string ItemName { get; set; } + public string UnitOfMeasure { get; set; } + public string SourceChecksum { get; set; } + public string ReferenceChecksum { get; set; } public string CapturedByUserId { get; set; } public DateTime CapturedOn { get; set; } } @@ -33,6 +37,7 @@ public class RmsInventoryUsage /// public interface IRmsInventoryUsageAdapter { + Task ConsumeAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, long expectedRowVersion, int typeId, int groupId, int? unitId, decimal quantity, string note, CancellationToken cancellationToken = default); Task> GetUsageForRecordAsync(int departmentId, string recordId); Task> GetUsageForLegacyLogAsync(int departmentId, int logId); diff --git a/Core/Resgrid.Model/UdfDefinition.cs b/Core/Resgrid.Model/UdfDefinition.cs index 385b3e9f4..16187cd39 100644 --- a/Core/Resgrid.Model/UdfDefinition.cs +++ b/Core/Resgrid.Model/UdfDefinition.cs @@ -16,6 +16,9 @@ public class UdfDefinition : IEntity /// The entity type this definition applies to. See . /// public int EntityType { get; set; } + /// Required for Record extensions; null for other entity types. + public string RecordDefinitionKey { get; set; } + public int? RecordDefinitionVersion { get; set; } /// /// Auto-incrementing version number per department + entity type. Each save creates a new version. diff --git a/Core/Resgrid.Model/UdfField.cs b/Core/Resgrid.Model/UdfField.cs index 5603a039e..cdbc8ac81 100644 --- a/Core/Resgrid.Model/UdfField.cs +++ b/Core/Resgrid.Model/UdfField.cs @@ -72,6 +72,8 @@ public class UdfField : IEntity /// Everyone=0, DepartmentAndGroupAdmins=1, DepartmentAdminsOnly=2. /// public int Visibility { get; set; } + /// Published RMS classification: 0 unrestricted, 1 restricted; unknown legacy fields require restricted access. + public int? RmsClassification { get; set; } [NotMapped] [JsonIgnore] diff --git a/Core/Resgrid.Search/LuceneRecordsIndexHost.cs b/Core/Resgrid.Search/LuceneRecordsIndexHost.cs index 35c219511..bfe8093d5 100644 --- a/Core/Resgrid.Search/LuceneRecordsIndexHost.cs +++ b/Core/Resgrid.Search/LuceneRecordsIndexHost.cs @@ -20,16 +20,20 @@ namespace Resgrid.Search public sealed class LuceneRecordsIndexHost : IDisposable { private readonly object _sync = new object(); - private readonly Directory _directory; private readonly bool _ownsDirectory; + private Directory _directory; private IndexWriter _writer; private SearcherManager _searcherManager; private bool _searcherIsNrt; private bool _disposed; public LuceneRecordsIndexHost() - : this(OpenConfiguredDirectory(), true) { + // Deliberately no I/O here. This type is a container singleton, so opening the configured path from the + // constructor makes every process that merely composes its container depend on the shared volume being + // present and writable — a process with search disabled must still start. The directory opens on use. + _ownsDirectory = true; + Analyzer = new StandardAnalyzer(RecordsIndexFields.Version); } /// Test seam: host any directory (e.g. RAMDirectory) without touching the configured path. @@ -46,11 +50,24 @@ public LuceneRecordsIndexHost(Directory directory, bool ownsDirectory = false) public bool Enabled => SearchConfig.Enabled; + /// The backing store, opening the configured path on first use. + private Directory Store + { + get + { + if (_directory != null) + return _directory; + + lock (_sync) + return _directory ??= OpenConfiguredDirectory(); + } + } + public bool IndexExists { get { - try { return DirectoryReader.IndexExists(_directory); } + try { return DirectoryReader.IndexExists(Store); } catch (Exception ex) { Logging.LogException(ex, "Records search index existence check failed."); @@ -71,9 +88,10 @@ public IndexWriter GetWriter() var config = new IndexWriterConfig(RecordsIndexFields.Version, Analyzer) { OpenMode = OpenMode.CREATE_OR_APPEND, + MergePolicy = new TieredMergePolicy { ForceMergeDeletesPctAllowed = 0 }, RAMBufferSizeMB = Math.Max(1, SearchConfig.RamBufferSizeMb) }; - _writer = new IndexWriter(_directory, config); + _writer = new IndexWriter(Store, config); // A reader opened before the writer existed keeps working; from here on prefer the NRT view. if (_searcherManager != null && !_searcherIsNrt) @@ -102,10 +120,10 @@ public SearcherManager GetSearcherManager() return _searcherManager; } - if (!DirectoryReader.IndexExists(_directory)) + if (!DirectoryReader.IndexExists(Store)) return null; - _searcherManager = new SearcherManager(_directory, null); + _searcherManager = new SearcherManager(Store, null); _searcherIsNrt = false; return _searcherManager; } @@ -123,6 +141,27 @@ public void MaybeRefresh() catch (Exception ex) { Logging.LogException(ex, "Records search reader refresh failed."); } } + /// Serializes mutations with the committed-segment erasure pass. + public int Write(Func mutation) + { + lock (_sync) { ThrowIfDisposed(); return mutation(GetWriter()); } + } + + public void ExpungeDeletes() + { + lock (_sync) + { + ThrowIfDisposed(); + var writer = GetWriter(); + writer.ForceMergeDeletes(true); + writer.Commit(); + _searcherManager?.MaybeRefreshBlocking(); + writer.DeleteUnusedFiles(); + using var committed = DirectoryReader.Open(Store); + if (committed.HasDeletions) throw new InvalidOperationException("Deleted records remain in the committed index; erasure cannot be acknowledged."); + } + } + private static Directory OpenConfiguredDirectory() { var path = Path.Combine(SearchConfig.IndexPath ?? string.Empty, RecordsIndexFields.IndexName); @@ -146,7 +185,9 @@ public void Dispose() try { _searcherManager?.Dispose(); } catch (Exception ex) { Logging.LogException(ex); } try { _writer?.Dispose(); } catch (Exception ex) { Logging.LogException(ex); } - if (_ownsDirectory) + // _directory, never Store: a host that was disposed without ever indexing must not open the + // configured path on its way out. + if (_ownsDirectory && _directory != null) { try { _directory.Dispose(); } catch (Exception ex) { Logging.LogException(ex); } } diff --git a/Core/Resgrid.Search/LuceneRecordsIndexer.cs b/Core/Resgrid.Search/LuceneRecordsIndexer.cs index 81e4f2e28..11c8ff370 100644 --- a/Core/Resgrid.Search/LuceneRecordsIndexer.cs +++ b/Core/Resgrid.Search/LuceneRecordsIndexer.cs @@ -5,6 +5,7 @@ using Lucene.Net.Index; using Lucene.Net.Search; using Resgrid.Model; +using Resgrid.Model.Repositories; using Resgrid.Model.Services; namespace Resgrid.Search @@ -16,15 +17,16 @@ namespace Resgrid.Search public class LuceneRecordsIndexer : IRecordsSearchIndexer { private readonly LuceneRecordsIndexHost _host; + private readonly IRmsSearchWriteFence _fence; - public LuceneRecordsIndexer(LuceneRecordsIndexHost host) + public LuceneRecordsIndexer(LuceneRecordsIndexHost host, IRmsSearchWriteFence fence) { _host = host ?? throw new ArgumentNullException(nameof(host)); + _fence = fence ?? throw new ArgumentNullException(nameof(fence)); } - public Task IndexAsync(IEnumerable documents, CancellationToken cancellationToken = default) + public async Task IndexAsync(IEnumerable documents, CancellationToken cancellationToken = default) { - var writer = _host.GetWriter(); var count = 0; foreach (var source in documents ?? Array.Empty()) @@ -33,41 +35,56 @@ public Task IndexAsync(IEnumerable documents, if (source?.Projection == null) continue; - var p = source.Projection; - var key = RecordsIndexFields.BuildKey(p.DepartmentId, p.SourceType, p.SourceId); - - if (p.DeletedOn.HasValue) + count += await _fence.WithLiveSourceAsync(source, current => _host.Write(writer => { - writer.DeleteDocuments(new Term(RecordsIndexFields.Key, key)); - continue; - } - - writer.UpdateDocument(new Term(RecordsIndexFields.Key, key), RecordsSearchDocumentBuilder.Build(source)); - count++; + var p = current.Projection; + var key = RecordsIndexFields.BuildKey(p.DepartmentId, p.SourceType, p.SourceId); + if (p.DeletedOn.HasValue) + { + writer.DeleteDocuments(new Term(RecordsIndexFields.Key, key)); + return 0; + } + writer.UpdateDocument(new Term(RecordsIndexFields.Key, key), RecordsSearchDocumentBuilder.Build(current)); + return 1; + }), cancellationToken); } - return Task.FromResult(count); + return count; } - public Task DeleteAsync(int departmentId, int sourceType, string sourceId, CancellationToken cancellationToken = default) + public async Task DeleteAsync(int departmentId, int sourceType, string sourceId, CancellationToken cancellationToken = default) { - _host.GetWriter().DeleteDocuments(new Term(RecordsIndexFields.Key, RecordsIndexFields.BuildKey(departmentId, sourceType, sourceId))); - return Task.CompletedTask; + await _fence.WithLiveSourceAsync(new RecordsSearchDocumentSource { Projection = new RmsRecordSearchProjection + { DepartmentId = departmentId, SourceType = sourceType, SourceId = sourceId, DeletedOn = DateTime.UtcNow } }, current => + { + if (!current.Projection.DeletedOn.HasValue) throw new InvalidOperationException("A live record cannot be erased from the index by a stale deletion request."); + _host.Write(writer => { writer.DeleteDocuments(new Term(RecordsIndexFields.Key, RecordsIndexFields.BuildKey(departmentId, sourceType, sourceId))); return 0; }); + return 0; + }, cancellationToken); } public Task DeleteDepartmentAsync(int departmentId, CancellationToken cancellationToken = default) { - _host.GetWriter().DeleteDocuments(new Term(RecordsIndexFields.DepartmentId, departmentId.ToString())); + cancellationToken.ThrowIfCancellationRequested(); + _host.Write(writer => { writer.DeleteDocuments(new Term(RecordsIndexFields.DepartmentId, departmentId.ToString())); return 0; }); return Task.CompletedTask; } public Task CommitAsync(CancellationToken cancellationToken = default) { - _host.GetWriter().Commit(); + cancellationToken.ThrowIfCancellationRequested(); + _host.Write(writer => { writer.Commit(); return 0; }); _host.MaybeRefresh(); return Task.CompletedTask; } + public Task ExpungeDeletesAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _host.ExpungeDeletes(); + return Task.CompletedTask; + } + public Task CountDocumentsAsync(int departmentId) { var manager = _host.GetSearcherManager(); diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index f4fa2503a..bfc882415 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -1504,14 +1504,18 @@ public Task GetRecordsSearchConfigAsync(int departmentId, b return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsSearchConfig, ObjectSerialization.Serialize(config ?? new RecordsSearchConfig()), cancellationToken); } - public Task GetRecordsRetentionPolicyAsync(int departmentId, bool bypassCache = false) + public async Task GetRecordsRetentionPolicyAsync(int departmentId, bool bypassCache = false) { - return GetRecordsSettingObjectAsync(departmentId, DepartmentSettingTypes.RecordsRetentionPolicy, bypassCache); + var value = await GetRecordsSettingStringAsync(departmentId, DepartmentSettingTypes.RecordsRetentionPolicy, bypassCache); + if (string.IsNullOrWhiteSpace(value)) return new RecordsRetentionPolicy(); + return ObjectSerialization.Deserialize(value) ?? throw new InvalidOperationException("The retention policy cannot be read. Automatic purge is suspended."); } - public Task SetRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, CancellationToken cancellationToken = default(CancellationToken)) + public async Task SetRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, CancellationToken cancellationToken = default(CancellationToken)) { - return SetRecordsSettingAsync(departmentId, DepartmentSettingTypes.RecordsRetentionPolicy, ObjectSerialization.Serialize(policy ?? new RecordsRetentionPolicy()), cancellationToken); + var result = await _departmentSettingsRepository.SaveRecordsRetentionPolicyAsync(departmentId, policy ?? new RecordsRetentionPolicy(), cancellationToken); + _cacheProvider.Remove(string.Format(RecordsSettingCacheKey, (int)DepartmentSettingTypes.RecordsRetentionPolicy, departmentId)); + return result; } public async Task GetRecordsGroupVisibilityModeAsync(int departmentId, bool bypassCache = false) diff --git a/Core/Resgrid.Services/Records/DisclosureContentPolicy.cs b/Core/Resgrid.Services/Records/DisclosureContentPolicy.cs new file mode 100644 index 000000000..4e8a3d404 --- /dev/null +++ b/Core/Resgrid.Services/Records/DisclosureContentPolicy.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; + +namespace Resgrid.Services.Records +{ + /// Exact field selections for a reviewed disclosure. A decision may only withhold content, never introduce or broaden it. + public static class DisclosureContentPolicy + { + public static JObject Prepare(JObject authorizedContent) + { + var content = (JObject)authorizedContent.DeepClone(); + void Expand(JToken token, int depth) + { + if (depth > 32) throw new ArgumentException("Disclosure content is too deeply nested."); + if (token is JObject obj) + foreach (var property in obj.Properties().ToList()) + { + if (property.Value.Type == JTokenType.String && property.Name.EndsWith("Json", StringComparison.Ordinal)) + { + var text = (string)property.Value; + if (text?.TrimStart().StartsWith("{") == true || text?.TrimStart().StartsWith("[") == true) + try { property.Value = JToken.Parse(text); } catch (JsonException) { } + } + Expand(property.Value, depth + 1); + } + else if (token is JArray array) foreach (var item in array) Expand(item, depth + 1); + } + Expand(content, 0); return content; + } + + public static List Fields(JObject content) + { + var fields = new List(); + void Walk(JToken token, string path) + { + if (token is JObject obj) foreach (var p in obj.Properties()) Walk(p.Value, path + "/" + Escape(p.Name)); + else if (token is JArray array) for (var i = 0; i < array.Count; i++) Walk(array[i], path + "/" + i); + else if (token.Type != JTokenType.Null) fields.Add(new RmsDisclosureFieldValue { Path = path, Value = token.ToString() }); + } + Walk(content, ""); return fields; + } + + public static void Apply(JObject content, string recordId, IEnumerable decisions, List log) + { + var selected = (decisions ?? Enumerable.Empty()).Where(d => d.Withhold).ToList(); + if (selected.Select(d => d.Path).Distinct(StringComparer.Ordinal).Count() != selected.Count) throw new ArgumentException("A field has duplicate redaction decisions."); + // Resolve and validate every decision before changing anything; a stale path cannot leave a partial redaction. + var resolved = selected.Select(d => + { + if (string.IsNullOrWhiteSpace(d.Authority) || string.IsNullOrWhiteSpace(d.Basis)) throw new ArgumentException("Record the applicable authority and reason for each redaction."); + var token = Resolve(content, d.Path) ?? throw new ArgumentException("A selected field no longer exists. Reload the reviewed revision."); + return (Decision: d, Token: token); + }).ToList(); + foreach (var entry in resolved.OrderByDescending(e => e.Decision.Path.Length)) + { + entry.Token.Replace(new JValue("[WITHHELD]")); + log.Add(new RmsRedactionEntry { RecordId = recordId, Section = "Record", Field = entry.Decision.Path, Authority = entry.Decision.Authority.Trim(), Basis = entry.Decision.Basis.Trim() }); + } + } + + private static JToken Resolve(JToken root, string path) + { + if (string.IsNullOrEmpty(path) || !path.StartsWith("/", StringComparison.Ordinal) || path.Length > 2048) throw new ArgumentException("A redaction must name an exact field path."); + JToken current = root; + foreach (var encoded in path.Substring(1).Split('/')) + { + for (var i = 0; i < encoded.Length; i++) if (encoded[i] == '~' && (++i >= encoded.Length || encoded[i] != '0' && encoded[i] != '1')) throw new ArgumentException("The redaction path is invalid."); + var part = encoded.Replace("~1", "/").Replace("~0", "~"); + if (current is JObject obj) current = obj.Property(part, StringComparison.Ordinal)?.Value; + else if (current is JArray array && int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out var index) && index >= 0 && index < array.Count && part == index.ToString(CultureInfo.InvariantCulture)) current = array[index]; + else return null; + if (current == null) return null; + } + return current; + } + private static string Escape(string key) => key.Replace("~", "~0").Replace("/", "~1"); + } +} diff --git a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs index f26ca2405..69d15b58d 100644 --- a/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs +++ b/Core/Resgrid.Services/Records/Evidence/RecordEvidenceAdapters.cs @@ -32,14 +32,17 @@ internal static class EvidenceLimits /// Most source rows a single artifact may carry; a manifest is evidence, not an export. public const int MaxItems = 500; - public static (DateTime start, DateTime end) ClampWindow(DateTime? start, DateTime? end) + // Source IDs remain in the manifest. This bounded identity distinguishes independently selected + // evidence; a later page/window must not supersede a different selection from the same subsystem. + public static string SelectionIdentity(object selection) => "selection-sha256:" + + RecordSnapshotSerializer.Checksum(RecordsEvidenceService.Serialize(selection)); + + public static (DateTime start, DateTime end) RequireWindow(DateTime? start, DateTime? end) { var to = end ?? DateTime.UtcNow; var from = start ?? to - MaxCoverage; - if (to < from) - (from, to) = (to, from); - if (to - from > MaxCoverage) - from = to - MaxCoverage; + if (to < from || to - from > MaxCoverage) + throw new ArgumentException("Choose a tracking window in chronological order, at most 24 hours. Capture additional windows separately."); return (from, to); } } @@ -99,10 +102,11 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque return RecordEvidenceCapture.Unavailable("Run card evidence needs the Call the record hangs off."); var activations = (await _activations.GetActivationsByCallIdAsync(request.CallId.Value))? - .Where(a => a != null && a.DepartmentId == request.DepartmentId) + .Where(a => a != null && a.DepartmentId == request.DepartmentId && a.CallId == request.CallId) .OrderBy(a => a.CreatedOn) - .Take(EvidenceLimits.MaxItems) + .Take(EvidenceLimits.MaxItems + 1) .ToList() ?? new List(); + if (activations.Count > EvidenceLimits.MaxItems) throw new ArgumentException("The Call has too many activations for one evidence capture."); if (activations.Count == 0) return RecordEvidenceCapture.Unavailable("No run card activation was recorded for this call."); @@ -117,7 +121,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque activated_on = a.CreatedOn, activated_by_user_id = a.CreatedByUserId, // The card's own recorded outcome, verbatim: what it selected and any shortfall it reported. - result = TryParse(a.ResultJson) + result = ParseRecordedResult(a.ResultJson) }).ToList(); return new RecordEvidenceCapture @@ -125,7 +129,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque Title = $"Run card activation for call {request.CallId.Value}", SourceSubsystem = SourceSubsystem, SourceEntityType = nameof(RunCardActivation), - SourceEntityId = string.Join(",", activations.Select(a => a.RunCardActivationId)), + SourceEntityId = EvidenceLimits.SelectionIdentity(activations.Select(a => a.RunCardActivationId).OrderBy(x => x).ToArray()), IdentifierScheme = IdentifierScheme, CoverageStart = activations.First().CreatedOn, CoverageEnd = activations.Last().CreatedOn, @@ -135,13 +139,13 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque }; } - private static JToken TryParse(string json) + private static JToken ParseRecordedResult(string json) { if (string.IsNullOrWhiteSpace(json)) return null; try { return JToken.Parse(json); } - catch (JsonReaderException) { return null; } + catch (JsonReaderException ex) { throw new InvalidOperationException("A recorded Run Card decision is unreadable; repair the source before capturing evidence.", ex); } } } @@ -163,10 +167,13 @@ public class TrackingFixEvidenceAdapter : IRecordEvidenceAdapter public const int SamplesPerUnit = 24; private readonly IUnitLocationRepository _locations; + private readonly IUnitsService _units; + private readonly Lazy _authorization; - public TrackingFixEvidenceAdapter(IUnitLocationRepository locations) + public TrackingFixEvidenceAdapter(IUnitLocationRepository locations, IUnitsService units, Lazy authorization) { _locations = locations; + _units = units; _authorization = authorization; } public RmsEvidenceKind Kind => RmsEvidenceKind.TrackingFix; @@ -176,10 +183,11 @@ public TrackingFixEvidenceAdapter(IUnitLocationRepository locations) public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) { var units = (request.UnitIds ?? new List()).Where(u => u > 0).Distinct().ToList(); + if (units.Count > EvidenceLimits.MaxItems / SamplesPerUnit) throw new ArgumentException("Select at most " + EvidenceLimits.MaxItems / SamplesPerUnit + " units per tracking capture."); if (units.Count == 0) return RecordEvidenceCapture.Unavailable("Tracking evidence needs at least one unit."); - var (from, to) = EvidenceLimits.ClampWindow(request.CoverageStart, request.CoverageEnd); + var (from, to) = EvidenceLimits.RequireWindow(request.CoverageStart, request.CoverageEnd); var capturedOn = DateTime.UtcNow; var step = (to - from).TotalSeconds / Math.Max(1, SamplesPerUnit - 1); var manifestUnits = new List(); @@ -188,6 +196,8 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque foreach (var unitId in units) { cancellationToken.ThrowIfCancellationRequested(); + var unit = await _units.GetUnitByIdAsync(unitId); + if (unit?.DepartmentId != request.DepartmentId || !await _authorization.Value.CanUserViewUnitLocationAsync(request.CapturedByUserId, unitId, request.DepartmentId)) throw new UnauthorizedAccessException("Tracking source access is not authorized."); var seen = new HashSet(); var fixes = new List(); @@ -198,7 +208,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque // Nothing before the sample point, or the same fix the previous sample already returned: // the unit had not moved, and repeating the row would inflate the manifest without adding fact. - if (location == null || location.Timestamp < from || !seen.Add(location.UnitLocationId)) + if (location == null || location.UnitId != unitId || location.Timestamp < from || location.Timestamp > at || !seen.Add(location.UnitLocationId)) continue; fixes.Add(new @@ -219,6 +229,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque total++; } + if (!await _authorization.Value.CanUserViewUnitLocationAsync(request.CapturedByUserId, unitId, request.DepartmentId)) throw new UnauthorizedAccessException(); if (fixes.Count > 0) manifestUnits.Add(new { unit_id = unitId, fixes }); } @@ -231,7 +242,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque Title = $"Tracking fixes for {units.Count} unit(s)", SourceSubsystem = SourceSubsystem, SourceEntityType = nameof(UnitLocation), - SourceEntityId = string.Join(",", units), + SourceEntityId = EvidenceLimits.SelectionIdentity(new { units = units.OrderBy(x => x).ToArray(), from, to }), IdentifierScheme = IdentifierScheme, CoverageStart = from, CoverageEnd = to, @@ -262,11 +273,13 @@ public class ChatPromotionEvidenceAdapter : IRecordEvidenceAdapter private readonly IChatMessageRepository _messages; private readonly IChatChannelRepository _channels; + private readonly Lazy _permissions; - public ChatPromotionEvidenceAdapter(IChatMessageRepository messages, IChatChannelRepository channels) + public ChatPromotionEvidenceAdapter(IChatMessageRepository messages, IChatChannelRepository channels, Lazy permissions) { _messages = messages; _channels = channels; + _permissions = permissions; } public RmsEvidenceKind Kind => RmsEvidenceKind.ChatPromotion; @@ -275,7 +288,8 @@ public ChatPromotionEvidenceAdapter(IChatMessageRepository messages, IChatChanne public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) { - var ids = (request.SourceIds ?? new List()).Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.Ordinal).Take(EvidenceLimits.MaxItems).ToList(); + var ids = (request.SourceIds ?? new List()).Where(i => !string.IsNullOrWhiteSpace(i)).Distinct(StringComparer.Ordinal).ToList(); + if (ids.Count > EvidenceLimits.MaxItems) throw new ArgumentException("Select at most " + EvidenceLimits.MaxItems + " messages per capture."); if (ids.Count == 0) return RecordEvidenceCapture.Unavailable("Chat evidence needs the messages the member selected."); @@ -284,8 +298,8 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque var allowedChannels = new HashSet(StringComparer.Ordinal); if (request.CallId.HasValue && request.CallId.Value > 0) { - foreach (var channel in (await _channels.GetByCallIdAsync(request.CallId.Value))?.Where(c => c != null && c.DepartmentId == request.DepartmentId) ?? Enumerable.Empty()) - allowedChannels.Add(channel.ChatChannelId); + foreach (var channel in (await _channels.GetByCallIdAsync(request.CallId.Value))?.Where(c => c != null && c.DepartmentId == request.DepartmentId && c.CallId == request.CallId) ?? Enumerable.Empty()) + if (await _permissions.Value.CanAccessChannelAsync(channel, request.CapturedByUserId, null)) allowedChannels.Add(channel.ChatChannelId); } if (allowedChannels.Count == 0) @@ -300,7 +314,8 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque cancellationToken.ThrowIfCancellationRequested(); var message = await _messages.GetByIdAsync(id) as ChatMessage; if (message == null || message.DepartmentId != request.DepartmentId || !allowedChannels.Contains(message.ChatChannelId)) - continue; + throw new UnauthorizedAccessException("A selected message is outside the authorized incident channel."); + if (message.DeletedOn.HasValue || message.IsModerated) throw new UnauthorizedAccessException("Deleted or moderated messages cannot be promoted through ordinary channel access."); channelIds.Add(message.ChatChannelId); if (first == null || message.SentOn < first) first = message.SentOn; @@ -327,19 +342,23 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque if (promoted.Count == 0) return RecordEvidenceCapture.Unavailable("None of the selected messages belong to this call's chat."); + var currentChannels = (await _channels.GetByCallIdAsync(request.CallId.Value) ?? Enumerable.Empty()).Where(c => c != null && channelIds.Contains(c.ChatChannelId)).ToList(); + if (currentChannels.Count != channelIds.Count) throw new UnauthorizedAccessException(); + foreach (var channel in currentChannels) + if (channel.DepartmentId != request.DepartmentId || channel.CallId != request.CallId || !await _permissions.Value.CanAccessChannelAsync(channel, request.CapturedByUserId, null)) throw new UnauthorizedAccessException(); return new RecordEvidenceCapture { Title = $"{promoted.Count} promoted chat message(s)", SourceSubsystem = SourceSubsystem, SourceEntityType = nameof(ChatMessage), - SourceEntityId = string.Join(",", channelIds), + SourceEntityId = EvidenceLimits.SelectionIdentity(ids.OrderBy(x => x, StringComparer.Ordinal).ToArray()), IdentifierScheme = IdentifierScheme, CoverageStart = first, CoverageEnd = last, SourceItemCount = promoted.Count, Classification = RmsEvidenceClassification.Restricted, - Manifest = new { call_id = request.CallId, channel_ids = channelIds.ToList(), messages = promoted } + Manifest = new { call_id = request.CallId, channel_ids = channelIds.OrderBy(x => x, StringComparer.Ordinal).ToList(), messages = promoted } }; } } @@ -359,10 +378,12 @@ public class InventoryUsageEvidenceAdapter : IRecordEvidenceAdapter public const string IdentifierScheme = "resgrid:inventory"; private readonly IRmsInventoryUsageAdapter _usage; + private readonly IRecordsAuthorizationService _authorization; - public InventoryUsageEvidenceAdapter(IRmsInventoryUsageAdapter usage) + public InventoryUsageEvidenceAdapter(IRmsInventoryUsageAdapter usage, IRecordsAuthorizationService authorization) { _usage = usage; + _authorization = authorization; } public RmsEvidenceKind Kind => RmsEvidenceKind.InventoryUsage; @@ -371,8 +392,10 @@ public InventoryUsageEvidenceAdapter(IRmsInventoryUsageAdapter usage) public async Task CaptureAsync(RecordEvidenceCaptureRequest request, CancellationToken cancellationToken = default) { + if (!await _authorization.CanUseSourceInventoryAsync(request.CapturedByUserId, request.DepartmentId, null)) throw new UnauthorizedAccessException(); var usage = (await _usage.GetUsageForRecordAsync(request.DepartmentId, request.RecordId))? - .Take(EvidenceLimits.MaxItems).ToList() ?? new List(); + .Take(EvidenceLimits.MaxItems + 1).ToList() ?? new List(); + if (usage.Count > EvidenceLimits.MaxItems) throw new ArgumentException("The record has too many inventory entries for one evidence capture."); if (usage.Count == 0) return RecordEvidenceCapture.Unavailable("No inventory usage is recorded against this record."); @@ -380,8 +403,12 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque var items = usage.Select(u => new { reference_id = u.ReferenceId, + reference_checksum = u.ReferenceChecksum, source = u.Source, inventory_id = u.InventoryId, + item_name = u.ItemName, + unit_of_measure = u.UnitOfMeasure, + source_checksum = u.SourceChecksum, quantity = u.Quantity, note = u.Note, captured_by_user_id = u.CapturedByUserId, @@ -420,11 +447,13 @@ public class CertificationSnapshotEvidenceAdapter : IRecordEvidenceAdapter private readonly ICertificationService _certifications; private readonly IRmsRecordParticipantsRepository _participants; + private readonly Lazy _authorization; - public CertificationSnapshotEvidenceAdapter(ICertificationService certifications, IRmsRecordParticipantsRepository participants) + public CertificationSnapshotEvidenceAdapter(ICertificationService certifications, IRmsRecordParticipantsRepository participants, Lazy authorization) { _certifications = certifications; _participants = participants; + _authorization = authorization; } public RmsEvidenceKind Kind => RmsEvidenceKind.CertificationSnapshot; @@ -446,6 +475,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque if (userIds.Count == 0) return RecordEvidenceCapture.Unavailable("Certification evidence needs at least one participant."); + if (userIds.Count > EvidenceLimits.MaxItems) throw new ArgumentException("Select fewer participants for this capture."); // Validity is asserted as at the incident time, not as at capture: a certificate that expired last // month was still valid on the night, and the record has to be able to say so. @@ -456,10 +486,13 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque foreach (var userId in userIds) { cancellationToken.ThrowIfCancellationRequested(); + if (!await _authorization.Value.CanUserViewPersonAsync(request.CapturedByUserId, userId, request.DepartmentId)) throw new UnauthorizedAccessException("Personnel source access is not authorized."); var certifications = (await _certifications.GetCertificationsByUserIdAsync(userId))? - .Where(c => c != null && c.DepartmentId == request.DepartmentId) - .Take(EvidenceLimits.MaxItems).ToList() ?? new List(); + .Where(c => c != null && c.DepartmentId == request.DepartmentId && c.UserId == userId) + .Take(EvidenceLimits.MaxItems - total + 1).ToList() ?? new List(); + if (total + certifications.Count > EvidenceLimits.MaxItems) throw new ArgumentException("The selection has too many certifications for one capture."); + if (!await _authorization.Value.CanUserViewPersonAsync(request.CapturedByUserId, userId, request.DepartmentId)) throw new UnauthorizedAccessException(); if (certifications.Count == 0) continue; @@ -470,13 +503,14 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque { // Type, status and validity only. Number, file name and document bytes stay with // Certifications, which owns them and their protection. + source_id = c.PersonnelCertificationId, type = c.Type, name = c.Name, area = c.Area, issued_by = c.IssuedBy, received_on = c.RecievedOn, expires_on = c.ExpiresOn, - valid_at_incident = !c.ExpiresOn.HasValue || c.ExpiresOn.Value >= asOf + valid_at_incident = (!c.RecievedOn.HasValue || c.RecievedOn.Value <= asOf) && (!c.ExpiresOn.HasValue || c.ExpiresOn.Value >= asOf) }).ToList() }); total += certifications.Count; @@ -490,7 +524,7 @@ public async Task CaptureAsync(RecordEvidenceCaptureReque Title = $"Certification snapshot for {people.Count} member(s)", SourceSubsystem = SourceSubsystem, SourceEntityType = nameof(PersonnelCertification), - SourceEntityId = request.RecordId, + SourceEntityId = EvidenceLimits.SelectionIdentity(new { users = userIds.Select(x => x.ToUpperInvariant()).OrderBy(x => x, StringComparer.Ordinal).ToArray(), asOf }), IdentifierScheme = IdentifierScheme, CoverageStart = asOf, CoverageEnd = asOf, diff --git a/Core/Resgrid.Services/Records/IncidentAnalysisService.cs b/Core/Resgrid.Services/Records/IncidentAnalysisService.cs index 3501d0372..9e40506a8 100644 --- a/Core/Resgrid.Services/Records/IncidentAnalysisService.cs +++ b/Core/Resgrid.Services/Records/IncidentAnalysisService.cs @@ -40,12 +40,13 @@ public class IncidentAnalysisService : IIncidentAnalysisService private readonly INerisProfileService _neris; private readonly INerisMappingService _mapping; private readonly INerisValidationService _validation; + private readonly IRecordsAuthorizationService _authorization; public IncidentAnalysisService(IRmsIncidentAnalysesRepository analyses, IRmsIncidentReportsRepository reports, IRmsIncidentModulesRepository modules, IRmsIncidentPropertiesRepository properties, IRmsIncidentVehiclesRepository vehicles, IRmsValidationIssuesRepository issues, IRmsSubmissionsRepository submissions, IRmsRevisionsRepository revisions, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork, INerisProfileService neris, INerisMappingService mapping, - INerisValidationService validation) + INerisValidationService validation, IRecordsAuthorizationService authorization) { _analyses = analyses; _reports = reports; @@ -60,12 +61,14 @@ public IncidentAnalysisService(IRmsIncidentAnalysesRepository analyses, IRmsInci _neris = neris; _mapping = mapping; _validation = validation; + _authorization = authorization; } public async Task StartForReportAsync(int departmentId, string userId, string incidentReportId, RmsOriginClient origin = RmsOriginClient.Web, CancellationToken cancellationToken = default) { + await RequirePermissionAsync(departmentId, userId, incidentReportId, PermissionTypes.CreateRecord); var report = await _reports.GetByIdForDepartmentAsync(departmentId, incidentReportId); - if (report == null || report.DeletedOn.HasValue) + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) throw new InvalidOperationException("The incident report does not exist."); // One analysis per report; a second start returns the first, exactly like starting a report from a Call. @@ -108,31 +111,33 @@ public async Task GetAsync(int departmentId, string a return null; var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); - return await HydrateAsync(analysis, report, null, includeHistory); + return report == null || report.PurgedOn.HasValue || report.DeletedOn.HasValue ? null : await HydrateAsync(analysis, report, null, includeHistory); } public async Task GetForReportAsync(int departmentId, string incidentReportId, bool includeHistory = false) { var analysis = await _analyses.GetForReportAsync(departmentId, incidentReportId); - if (analysis == null) + if (analysis == null || analysis.DeletedOn.HasValue) return null; var report = await _reports.GetByIdForDepartmentAsync(departmentId, incidentReportId); - return await HydrateAsync(analysis, report, null, includeHistory); + return report == null || report.PurgedOn.HasValue || report.DeletedOn.HasValue ? null : await HydrateAsync(analysis, report, null, includeHistory); } - public async Task SaveDraftAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, IncidentAnalysisDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default) + public async Task SaveDraftAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, IncidentAnalysisDraftInput input, bool canWriteRestricted = false, CancellationToken cancellationToken = default) { if (input == null) throw new ArgumentNullException(nameof(input)); var analysis = await LoadAsync(departmentId, analysisId); + await RequirePermissionAsync(departmentId, userId, analysis.IncidentReportId, PermissionTypes.CreateRecord); + canWriteRestricted = canWriteRestricted && await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); if ((RmsIncidentAnalysisState)analysis.State != RmsIncidentAnalysisState.Draft && (RmsIncidentAnalysisState)analysis.State != RmsIncidentAnalysisState.Rejected) throw new InvalidOperationException("Only a draft or rejected analysis can be edited."); var now = DateTime.UtcNow; await InTransactionAsync(async () => { - if (analysis.RowVersion != expectedRowVersion) + if (analysis.RowVersion != expectedRowVersion || !await _analyses.TryBumpRowVersionAsync(departmentId, analysisId, expectedRowVersion, cancellationToken)) throw new RecordConcurrencyException(analysisId, expectedRowVersion, analysis.RowVersion); analysis.GeneralCause = Trim(input.GeneralCause)?.ToUpperInvariant(); @@ -144,8 +149,8 @@ await InTransactionAsync(async () => await ReplaceModulesAsync(analysis, input.Modules, now, cancellationToken); // The analysis's headline totals are the sum of what it enumerates; a department never types them. - analysis.EstimatedValueTotal = Sum(properties.Select(p => p.EstimatedValue), properties.Select(p => p.ContentsValue), vehicles.Select(v => v.EstimatedValue)); - analysis.EstimatedLossTotal = Sum(properties.Select(p => p.EstimatedLoss), properties.Select(p => p.ContentsLoss), vehicles.Select(v => v.EstimatedLoss)); + analysis.EstimatedValueTotal = Sum(properties.Select(p => PropertyTotal(p, p.EstimatedValue, "estimated_property_value")), properties.Select(p => PropertyTotal(p, p.ContentsValue, "estimated_contents_value")), vehicles.Select(v => v.EstimatedValue)); + analysis.EstimatedLossTotal = Sum(properties.Select(p => PropertyTotal(p, p.EstimatedLoss, "estimated_property_loss_value")), properties.Select(p => PropertyTotal(p, p.ContentsLoss, "estimated_contents_loss_value")), vehicles.Select(v => v.EstimatedLoss)); analysis.ModifiedOn = now; analysis.ModifiedByUserId = userId; @@ -172,6 +177,7 @@ public async Task> ValidateAsync(int departmentId, stri public async Task FinalizeAsync(int departmentId, string userId, string analysisId, long expectedRowVersion, CancellationToken cancellationToken = default) { var analysis = await LoadAsync(departmentId, analysisId); + await RequirePermissionAsync(departmentId, userId, analysis.IncidentReportId, PermissionTypes.FinalizeRecords); var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); var issues = await ValidateAsync(departmentId, analysisId, cancellationToken); @@ -186,7 +192,7 @@ public async Task FinalizeAsync(int departmentId, str await InTransactionAsync(async () => { - if (analysis.RowVersion != expectedRowVersion) + if (analysis.RowVersion != expectedRowVersion || !await _analyses.TryBumpRowVersionAsync(departmentId, analysisId, expectedRowVersion, cancellationToken)) throw new RecordConcurrencyException(analysisId, expectedRowVersion, analysis.RowVersion); var aggregate = await HydrateAsync(analysis, report, null, false); @@ -205,6 +211,7 @@ await InTransactionAsync(async () => // Queue immediately when the incident is already filed; otherwise worker 41 picks it up when it is. if (report != null && !string.IsNullOrWhiteSpace(report.NerisIncidentId) && await _neris.IsSubmissionEnabledAsync(departmentId)) await QueueCoreAsync(analysis, report, revision, userId, now, cancellationToken); + await _analyses.UpdateAsync(analysis, cancellationToken, true); await AuditAsync(departmentId, userId, analysisId, revision.RmsRevisionId, RmsAccessAuditAction.Change, "Finalize incident analysis", RmsOriginClient.Web, cancellationToken, new { revision.Checksum }); }); @@ -216,6 +223,7 @@ await InTransactionAsync(async () => public async Task QueueSubmissionAsync(int departmentId, string userId, string analysisId, CancellationToken cancellationToken = default) { var analysis = await LoadAsync(departmentId, analysisId); + await RequirePermissionAsync(departmentId, userId, analysis.IncidentReportId, PermissionTypes.SubmitRecords); var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); if (string.IsNullOrWhiteSpace(analysis.CurrentRevisionId)) @@ -228,7 +236,10 @@ public async Task QueueSubmissionAsync(int department var now = DateTime.UtcNow; await InTransactionAsync(async () => { - var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, analysis.CurrentRevisionId); + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, analysis.CurrentRevisionId) + ?? throw new InvalidOperationException("The finalized revision this analysis points at is missing; it cannot be filed."); + if (!await _analyses.TryBumpRowVersionAsync(departmentId, analysisId, analysis.RowVersion, cancellationToken)) + throw new RecordConcurrencyException(analysisId, analysis.RowVersion, analysis.RowVersion + 1); await QueueCoreAsync(analysis, report, revision, userId, now, cancellationToken); analysis.ModifiedOn = now; analysis.ModifiedByUserId = userId; @@ -279,6 +290,7 @@ public async Task VoidAsync(int departmentId, string throw new ArgumentException("A void reason is required.", nameof(reasonCode)); var analysis = await LoadAsync(departmentId, analysisId); + await RequirePermissionAsync(departmentId, userId, analysis.IncidentReportId, PermissionTypes.DeleteRecord); if ((RmsIncidentAnalysisState)analysis.State == RmsIncidentAnalysisState.Submitted) throw new InvalidOperationException("The analysis is in flight to the destination and cannot be voided until it settles."); @@ -286,6 +298,8 @@ public async Task VoidAsync(int departmentId, string await InTransactionAsync(async () => { analysis.State = (int)RmsIncidentAnalysisState.Voided; + if (!await _analyses.TryBumpRowVersionAsync(departmentId, analysisId, analysis.RowVersion, cancellationToken)) + throw new RecordConcurrencyException(analysisId, analysis.RowVersion, analysis.RowVersion + 1); analysis.VoidedOn = now; analysis.VoidedByUserId = userId; analysis.VoidReasonCode = reasonCode; @@ -304,10 +318,21 @@ await InTransactionAsync(async () => public async Task BuildSnapshotAsync(int departmentId, string analysisId, string revisionId = null) { var analysis = await _analyses.GetByIdForDepartmentAsync(departmentId, analysisId); - if (analysis == null) + if (analysis == null || analysis.DeletedOn.HasValue) return null; var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); + if (report == null || report.PurgedOn.HasValue || report.DeletedOn.HasValue) return null; + if (revisionId != null) + { + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); + if (revision == null || revision.RecordId != analysisId || revision.RecordKind != (int)RmsRecordKind.IncidentAnalysis) return null; + if (RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("The analysis revision checksum does not match."); + var frozen = JsonConvert.DeserializeObject(revision.SnapshotJson); + if (frozen?.Analysis == null || frozen.Report == null) throw new InvalidOperationException("This legacy analysis revision did not capture its headers. Finalize a corrected revision before submitting it."); + if (frozen.Analysis.RmsIncidentAnalysisId != analysisId || frozen.Analysis.DepartmentId != departmentId || frozen.Report.RmsIncidentReportId != analysis.IncidentReportId || frozen.Report.DepartmentId != departmentId) throw new InvalidOperationException("The analysis revision does not belong to this incident."); + return frozen; + } var aggregate = await HydrateAsync(analysis, report, revisionId, false); return ToSnapshot(aggregate); } @@ -336,7 +361,9 @@ private async Task LoadAsync(int departmentId, string analy var analysis = await _analyses.GetByIdForDepartmentAsync(departmentId, analysisId); if (analysis == null || analysis.DeletedOn.HasValue) throw new InvalidOperationException("The incident analysis does not exist."); - return analysis; + var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis.IncidentReportId); + if (report == null || report.PurgedOn.HasValue || report.DeletedOn.HasValue) throw new InvalidOperationException("The parent incident report is no longer available."); + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(analysis)); } private async Task HydrateAsync(RmsIncidentAnalysis analysis, RmsIncidentReport report, string revisionId, bool includeHistory) @@ -395,22 +422,28 @@ private async Task> ReplaceVehiclesAsync(RmsIncidentAna if (inputs == null) return existing; + var byId = existing.ToDictionary(v => v.RmsIncidentVehicleId, StringComparer.Ordinal); + var suppliedIds = inputs.Where(v => !string.IsNullOrWhiteSpace(v.VehicleId)).Select(v => v.VehicleId).ToList(); + if (suppliedIds.Distinct(StringComparer.Ordinal).Count() != suppliedIds.Count || suppliedIds.Any(id => !byId.ContainsKey(id))) + throw new ArgumentException("A vehicle row does not belong to this draft or was supplied more than once."); + if (!canWriteRestricted && existing.Any(v => !suppliedIds.Contains(v.RmsIncidentVehicleId))) + throw new UnauthorizedAccessException("Existing vehicles must be retained by their row identifiers when restricted fields are hidden."); await _vehicles.DeleteDraftForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, cancellationToken); var result = new List(); var ordinal = 0; foreach (var input in inputs) { - var prior = ordinal < existing.Count ? existing[ordinal] : null; + var prior = !string.IsNullOrWhiteSpace(input.VehicleId) ? byId[input.VehicleId] : null; var row = new RmsIncidentVehicle { - RmsIncidentVehicleId = Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RmsIncidentVehicleId = prior?.RmsIncidentVehicleId ?? Guid.NewGuid().ToString(), DepartmentId = analysis.DepartmentId, ProtectionId = prior?.ProtectionId ?? Guid.NewGuid().ToString(), RecordId = analysis.RmsIncidentAnalysisId, VehicleKind = Trim(input.VehicleKind)?.ToUpperInvariant() ?? "AUTOMOBILE", Make = Trim(input.Make)?.ToUpperInvariant(), Model = Trim(input.Model), ModelYear = input.ModelYear, BodyStyle = Trim(input.BodyStyle)?.ToUpperInvariant(), Powertrain = Trim(input.Powertrain)?.ToUpperInvariant(), DamageType = Trim(input.DamageType)?.ToUpperInvariant(), WasOccupied = input.WasOccupied, EstimatedValue = input.EstimatedValue, EstimatedLoss = input.EstimatedLoss, CurrencyCode = analysis.CurrencyCode, - DetailJson = Trim(input.DetailJson), Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 + DetailJson = canWriteRestricted ? Trim(input.DetailJson) : prior?.DetailJson, Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; // VIN, plate and registration state identify a person's vehicle: restricted, so a caller without the @@ -459,13 +492,16 @@ private async Task WriteRevisionAsync(RmsIncidentAnalysis analysis, { var snapshotJson = JsonConvert.SerializeObject(new { + SnapshotVersion = 2, + Analysis = analysis, + Report = draft.Report, analysis.RmsIncidentAnalysisId, analysis.IncidentReportId, analysis.GeneralCause, analysis.InvestigationTypesCsv, analysis.EstimatedValueTotal, analysis.EstimatedLossTotal, - Modules = draft.Modules.OrderBy(m => m.Ordinal).Select(m => new { m.ModuleKind, m.PrimaryCode, m.SecondaryCode, m.Quantity, m.DetailJson }), + Modules = draft.Modules.OrderBy(m => m.Ordinal), Properties = draft.Properties.OrderBy(p => p.Ordinal), Vehicles = draft.Vehicles.OrderBy(v => v.Ordinal) }); @@ -500,12 +536,25 @@ private async Task WriteRevisionAsync(RmsIncidentAnalysis analysis, private async Task QueueCoreAsync(RmsIncidentAnalysis analysis, RmsIncidentReport report, RmsRevision revision, string userId, DateTime now, CancellationToken cancellationToken) { + var priorSubmissions = (await _submissions.GetForRecordAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId))?.ToList() ?? new List(); + RecordsSubmissionService.RequireResolvedCreates(priorSubmissions); var profile = await _neris.GetProfileAsync(analysis.DepartmentId); - var aggregate = await HydrateAsync(analysis, report, revision.RmsRevisionId, false); - var payload = _mapping.BuildIncidentAnalysisPayloadJson(ToSnapshot(aggregate), profile); + var snapshot = await BuildSnapshotAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, revision.RmsRevisionId) ?? throw new InvalidOperationException("The analysis revision is unavailable."); + // The parent receipt may arrive after local attestation. It is destination identity, + // not authored incident content; all incident numbers and analysis facts stay frozen. + if (string.IsNullOrWhiteSpace(snapshot.Report.NerisIncidentId)) snapshot.Report.NerisIncidentId = report.NerisIncidentId; + var payload = _mapping.BuildIncidentAnalysisPayloadJson(snapshot, profile); var key = IdempotencyKey(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, revision.RmsRevisionId); var submission = await _submissions.GetByIdempotencyKeyAsync(key); + if (submission?.RequiresReconciliation == true) + throw new InvalidOperationException("Reconcile the prior destination delivery before retrying this revision."); + var destination = _neris.GetDestinationIdentity(profile); + var externalId = RecordsSubmissionService.ResolveDestinationId(priorSubmissions, destination, analysis.NerisAnalysisId); + if (submission != null && submission.DestinationIdentity != destination) + throw new InvalidOperationException("This analysis revision was queued for another destination."); + if (submission != null && submission.State != (int)RmsSubmissionState.Failed && submission.State != (int)RmsSubmissionState.Superseded && submission.State != (int)RmsSubmissionState.Rejected) + throw new InvalidOperationException("This analysis revision is already queued or delivered."); if (submission == null) { submission = new RmsSubmission @@ -518,6 +567,8 @@ private async Task QueueCoreAsync(RmsIncidentAnalysis analysis, RmsIncidentRepor RevisionId = revision.RmsRevisionId, Destination = RmsSubmissionDestinations.NerisIncidentAnalysis, DestinationVersion = profile?.ContractVersion ?? _neris.ContractVersion, + DestinationIdentity = destination, + ExternalId = externalId, IdempotencyKey = key, MaxAttempts = Math.Max(1, Config.NerisConfig.MaxAttempts), PayloadJson = payload, @@ -535,7 +586,7 @@ private async Task QueueCoreAsync(RmsIncidentAnalysis analysis, RmsIncidentRepor { // Same revision, same key: a retry, never a new payload. submission.State = (int)RmsSubmissionState.Queued; - submission.Attempts = 0; + submission.MaxAttempts = submission.Attempts + Math.Max(1, Config.NerisConfig.MaxAttempts); submission.NextAttemptOn = null; submission.LeaseOwner = null; submission.LeaseExpiresOn = null; @@ -567,6 +618,24 @@ private static T CopyTo(T source, Action assignId, string revisionId, Date return copy; } + private async Task RequirePermissionAsync(int departmentId, string userId, string reportId, PermissionTypes permission) + { + if (!await _authorization.HasPermissionAsync(userId, departmentId, permission) + || !await _authorization.CanUserViewRecordAsync(userId, reportId, departmentId)) throw new UnauthorizedAccessException("Incident analysis access is not authorized."); + } + + private static decimal? PropertyTotal(RmsIncidentProperty property, decimal? firstValue, string key) + { + if (string.IsNullOrWhiteSpace(property.DetailJson)) return firstValue; + try + { + var structures = (Newtonsoft.Json.Linq.JObject.Parse(property.DetailJson)["structures"] as Newtonsoft.Json.Linq.JArray)?.OfType().ToList(); + if (structures == null || structures.Count == 0) return firstValue; + return Sum(new[] { firstValue ?? (decimal?)structures[0][key] }, structures.Skip(1).Select(s => (decimal?)s[key])); + } + catch (Newtonsoft.Json.JsonException) { throw new ArgumentException("The property fields could not be read."); } + } + private static decimal? Sum(params IEnumerable[] sets) { decimal? total = null; diff --git a/Core/Resgrid.Services/Records/IncidentAttachmentsService.cs b/Core/Resgrid.Services/Records/IncidentAttachmentsService.cs new file mode 100644 index 000000000..a4c93f03e --- /dev/null +++ b/Core/Resgrid.Services/Records/IncidentAttachmentsService.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + public sealed class IncidentAttachmentsService : IIncidentAttachmentsService + { + private readonly IRmsIncidentReportsRepository _reports; + private readonly IRmsRecordAttachmentsRepository _attachments; + private readonly IRmsRevisionsRepository _revisions; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordAttachmentScanner _scanner; + private readonly IUnitOfWork _unitOfWork; + public IncidentAttachmentsService(IRmsIncidentReportsRepository reports, IRmsRecordAttachmentsRepository attachments, IRmsRevisionsRepository revisions, + IRmsAccessAuditsRepository audits, IRecordsAuthorizationService authorization, IRecordAttachmentScanner scanner, IUnitOfWork unitOfWork) + { _reports = reports; _attachments = attachments; _revisions = revisions; _audits = audits; _authorization = authorization; _scanner = scanner; _unitOfWork = unitOfWork; } + + private async Task Authorize(int departmentId, string userId, string reportId, bool write) + { + if (!await _authorization.CanUserViewRecordAsync(userId, reportId, departmentId)) throw new UnauthorizedAccessException(); + var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId); + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) throw new KeyNotFoundException(); + if (write) + { + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord)) throw new UnauthorizedAccessException(); + if (!string.Equals(report.AuthorUserId, userId, StringComparison.Ordinal) && !string.Equals(report.OwnerUserId, userId, StringComparison.Ordinal) + && !await _authorization.IsDepartmentAdminAsync(userId, departmentId) + && !(report.AmendsRevisionId != null && await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.AmendRecords))) throw new UnauthorizedAccessException(); + if (!(RmsLifecycle.IsEditable((RmsRecordState)report.State) || (RmsRecordState)report.State == RmsRecordState.Rejected || report.AmendsRevisionId != null) + || RmsLifecycle.IsTerminal((RmsRecordState)report.State)) throw new InvalidOperationException("Add attachments to a draft or open amendment."); + } + return report; + } + + public async Task AddAsync(int departmentId, string userId, string reportId, long expectedVersion, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default, int classification = 1) + { + var report = await Authorize(departmentId, userId, reportId, true); + if (!Enum.IsDefined(typeof(RmsEvidenceClassification), classification)) throw new ArgumentException("Choose an attachment classification."); + if (classification != 0 && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + if (report.RowVersion != expectedVersion) throw new RecordConcurrencyException(reportId, expectedVersion, report.RowVersion); + var clean = RecordAttachmentHygiene.Sanitize(fileName, contentType, data); + var scan = await _scanner.ScanAsync(clean.FileName, clean.ContentType, clean.Data, cancellationToken) ?? new RecordAttachmentScanResult(); + if (scan.State == RmsAttachmentScanState.Rejected) throw new RecordAttachmentRejectedException("The attachment was rejected by the scanner."); + var now = DateTime.UtcNow; + var attachment = new RmsRecordAttachment { RmsRecordAttachmentId = Guid.NewGuid().ToString(), ProtectionId = Guid.NewGuid().ToString(), DepartmentId = departmentId, + RecordId = reportId, FileName = clean.FileName, ContentType = clean.ContentType, Data = clean.Data, ByteSize = clean.Data.LongLength, + Checksum = RecordSnapshotSerializer.Checksum(clean.Data), Description = description, UploadedByUserId = userId, UploadedOn = now, + ScanState = (int)scan.State, MetadataStripped = clean.MetadataStripped, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + attachment.Classification = classification; + _unitOfWork.CreateOrGetConnection(); + try + { + report = await Authorize(departmentId, userId, reportId, true); + if (classification != 0 && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + if (report.RowVersion != expectedVersion || !await _reports.TryBumpRowVersionAsync(departmentId, reportId, expectedVersion, cancellationToken)) + throw new RecordConcurrencyException(reportId, expectedVersion, report.RowVersion); + await _attachments.InsertAsync(attachment, cancellationToken, true); + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = reportId, ActorUserId = userId, Action = (int)RmsAccessAuditAction.Change, + Purpose = "Incident attachment uploaded", Successful = true, OccurredOn = now, DetailJson = JsonConvert.SerializeObject(new { attachment.RmsRecordAttachmentId, attachment.Checksum, attachment.ByteSize }) }, cancellationToken, true); + _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + // Never clear the object handed to a repository: in-memory stores can retain that instance. + var metadata = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(attachment)); metadata.Data = null; return metadata; + } + + public async Task GetAsync(int departmentId, string userId, string reportId, string attachmentId, string revisionId = null) + { + await Authorize(departmentId, userId, reportId, false); + var attachment = revisionId == null ? await _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId) : await _attachments.GetHistoricalByIdForDepartmentAsync(departmentId, attachmentId); + if (attachment == null || attachment.RecordId != reportId || revisionId == null && attachment.DeletedOn.HasValue) return null; + if (attachment.ScanState != (int)RmsAttachmentScanState.Clean) throw new InvalidOperationException("The attachment has not passed scanning."); + if (attachment.RequiresRestrictedAccess && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + if (attachment.Data == null || RecordSnapshotSerializer.Checksum(attachment.Data) != attachment.Checksum) throw new InvalidOperationException("Attachment content is unavailable or its checksum does not match."); + if (revisionId != null) + { + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); + if (revision == null || revision.RecordId != reportId || revision.RecordKind != (int)RmsRecordKind.IncidentReport) return null; + if (RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("The revision checksum does not match."); + var snapshot = JsonConvert.DeserializeObject(revision.SnapshotJson); + if (snapshot?.Attachments?.Any(a => a.RmsRecordAttachmentId == attachmentId && a.Checksum == attachment.Checksum) != true) return null; + } + return attachment; + } + + public async Task RemoveAsync(int departmentId, string userId, string reportId, string attachmentId, long expectedVersion, CancellationToken cancellationToken = default) + { + await Authorize(departmentId, userId, reportId, true); + _unitOfWork.CreateOrGetConnection(); + try + { + var report = await Authorize(departmentId, userId, reportId, true); + if (report.RowVersion != expectedVersion || !await _reports.TryBumpRowVersionAsync(departmentId, reportId, expectedVersion, cancellationToken)) throw new RecordConcurrencyException(reportId, expectedVersion, report.RowVersion); + var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId); + if (attachment == null || attachment.RecordId != reportId) { _unitOfWork.DiscardChanges(); return false; } + if (attachment.RequiresRestrictedAccess && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + attachment.DeletedOn = DateTime.UtcNow; attachment.ModifiedOn = attachment.DeletedOn.Value; attachment.RowVersion++; + await _attachments.UpdateAsync(attachment, cancellationToken, true); + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = reportId, ActorUserId = userId, Action = (int)RmsAccessAuditAction.Change, + Purpose = "Incident attachment removed from draft", Successful = true, OccurredOn = DateTime.UtcNow, DetailJson = JsonConvert.SerializeObject(new { attachmentId, attachment.Checksum }) }, cancellationToken, true); + _unitOfWork.CommitChanges(); return true; + } + catch { _unitOfWork.DiscardChanges(); throw; } + } + } +} diff --git a/Core/Resgrid.Services/Records/IncidentReportsService.cs b/Core/Resgrid.Services/Records/IncidentReportsService.cs index dc79b034a..47d6c7427 100644 --- a/Core/Resgrid.Services/Records/IncidentReportsService.cs +++ b/Core/Resgrid.Services/Records/IncidentReportsService.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.Data.Common; using System.Globalization; using System.Linq; using System.Security.Cryptography; @@ -62,6 +63,11 @@ public class IncidentReportsService : IIncidentReportsService private readonly INerisProfileService _neris; private readonly INerisMappingService _mapping; private readonly INerisValidationService _validation; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsUdfService _udf; + private readonly IRmsRecordAttachmentsRepository _attachments; + private readonly IRmsEvidenceArtifactsRepository _evidence; + private readonly IRecordsEvidenceService _evidenceService; public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceFactsRepository facts, IRmsUnitResponsesRepository units, IRmsIncidentTypesRepository types, IRmsActionTacticsRepository tactics, IRmsAidsRepository aids, IRmsLocationsRepository locations, @@ -71,7 +77,7 @@ public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceF IRmsRecordSearchProjectionsRepository projections, IDomainEventOutboxService outbox, IDepartmentSettingsService settings, IDepartmentGroupsService groups, IUserProfileService profiles, IPersonnelRolesService roles, IUnitsService unitsService, ICallsService calls, IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, INerisProfileService neris, INerisMappingService mapping, - INerisValidationService validation) + INerisValidationService validation, IRecordsAuthorizationService authorization, IRmsRecordAttachmentsRepository attachments, IRmsEvidenceArtifactsRepository evidence, IRecordsUdfService udf, IRecordsEvidenceService evidenceService) { _reports = reports; _facts = facts; @@ -105,6 +111,11 @@ public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceF _neris = neris; _mapping = mapping; _validation = validation; + _authorization = authorization; + _udf = udf; + _attachments = attachments; + _evidence = evidence; + _evidenceService = evidenceService; } #region Start / read @@ -113,6 +124,8 @@ public async Task StartFromCallAsync(int departmentId, { if (callId <= 0) throw new ArgumentException("A call is required.", nameof(callId)); if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentException("A user is required.", nameof(userId)); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord)) + throw new UnauthorizedAccessException("Incident report creation is not authorized."); var profile = await _neris.GetProfileAsync(departmentId); var entity = ReportingEntityFor(departmentId, profile); @@ -124,11 +137,13 @@ public async Task StartFromCallAsync(int departmentId, var existing = await _reports.GetByCallAsync(departmentId, callId, entity) ?? (await _reports.GetByCallAnyEntityAsync(departmentId, callId))?.FirstOrDefault(r => !r.DeletedOn.HasValue); if (existing != null && !existing.DeletedOn.HasValue) - return await GetAsync(departmentId, existing.RmsIncidentReportId, false); + return await GetStartedReportAsync(departmentId, userId, existing.RmsIncidentReportId); var call = await _calls.GetCallByIdAsync(callId); if (call == null || call.DepartmentId != departmentId) throw new ArgumentException($"Call {callId} does not belong to this department."); + if (!await _authorization.CanReadSourceCallAsync(userId, departmentId, call)) + throw new UnauthorizedAccessException("Source Call access is not authorized."); call = await _calls.PopulateCallData(call, true, false, true, false, true, false, false, false, false) ?? call; var now = DateTime.UtcNow; @@ -152,7 +167,6 @@ public async Task StartFromCallAsync(int departmentId, IncidentNumber = string.IsNullOrWhiteSpace(call.Number) ? null : call.Number, DispatchIncidentCode = string.IsNullOrWhiteSpace(call.Type) ? null : call.Type, CallCreatedOn = call.LoggedOn, - CallAnsweredOn = call.LoggedOn, IncidentClearedOn = call.ClosedOn, StationGroupId = authorGroup?.DepartmentGroupId, AuthorUserId = userId, @@ -167,8 +181,8 @@ public async Task StartFromCallAsync(int departmentId, facts.Add(Fact(report, NerisFactKeys.IncidentNumber, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), call.Number, call.LoggedOn, now)); facts.Add(Fact(report, NerisFactKeys.IncidentCode, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), call.Type, call.LoggedOn, now)); facts.Add(Fact(report, NerisFactKeys.CallCreate, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), Iso(call.LoggedOn), call.LoggedOn, now)); - // Resgrid holds no PSAP answered time; the create time stands in and is marked Derived so the author sees it. - facts.Add(Fact(report, NerisFactKeys.CallAnswered, RmsSourceKind.Derived, "Calls", "Call", callId.ToString(), Iso(call.LoggedOn), call.LoggedOn, now)); + // PSAP arrival and answer times are not held by the source Call. The officer must supply them; + // neither the record creation time nor a unit's on-scene time is a substitute. if (call.ClosedOn.HasValue) facts.Add(Fact(report, NerisFactKeys.IncidentClear, RmsSourceKind.Dispatch, "Calls", "Call", callId.ToString(), Iso(call.ClosedOn), call.ClosedOn, now)); @@ -181,9 +195,6 @@ public async Task StartFromCallAsync(int departmentId, } var units = await BuildUnitsFromCallAsync(report, call, facts, now); - report.CallArrivalOn = units.Where(u => u.OnSceneOn.HasValue).Select(u => u.OnSceneOn).OrderBy(t => t).FirstOrDefault(); - if (report.CallArrivalOn.HasValue) - facts.Add(Fact(report, NerisFactKeys.CallArrival, RmsSourceKind.App, "UnitStates", "Call", callId.ToString(), Iso(report.CallArrivalOn), report.CallArrivalOn, now)); var types = new List(); var mappedType = await _neris.ResolveCrosswalkAsync(departmentId, "incident_type", NerisCrosswalkSources.CallType, call.Type); @@ -210,9 +221,13 @@ public async Task StartFromCallAsync(int departmentId, report.DisplaySummary = BuildSummary(report, types, call.Name); var outboxIds = new List(); + try + { await InTransactionAsync(async () => { await _reports.InsertAsync(report, cancellationToken, true); + report.UdfDefinitionId = await _udf.SaveInTransactionAsync(departmentId, userId, reportId, report.DefinitionKey, report.DefinitionVersion, null, null, cancellationToken); + await _reports.UpdateAsync(report, cancellationToken, true); if (location != null) await _locations.InsertAsync(location, cancellationToken, true); foreach (var unit in units) @@ -229,15 +244,30 @@ await InTransactionAsync(async () => outboxIds.Add((await EnqueueLifecycleEventAsync(report, null, WorkflowTriggerEventType.RecordCreated, RmsRecordState.Draft, RmsRecordState.Draft, null, null, cancellationToken)).DomainEventOutboxId); await AuditAsync(departmentId, userId, reportId, null, RmsAccessAuditAction.Change, "Start incident report from call", origin, cancellationToken, new { callId, prefilledFacts = facts.Count }); }); + } + catch (DbException) + { + var winner = await _reports.GetByCallAsync(departmentId, callId, entity) + ?? (await _reports.GetByCallAnyEntityAsync(departmentId, callId))?.FirstOrDefault(r => !r.DeletedOn.HasValue); + if (winner == null) throw; + return await GetStartedReportAsync(departmentId, userId, winner.RmsIncidentReportId); + } await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); + return await GetStartedReportAsync(departmentId, userId, reportId); + } + + private async Task GetStartedReportAsync(int departmentId, string userId, string reportId) + { + if (!await _authorization.CanUserViewRecordAsync(userId, reportId, departmentId)) + throw new UnauthorizedAccessException("Incident report access is not authorized."); return await GetAsync(departmentId, reportId, false); } public async Task GetAsync(int departmentId, string reportId, bool includeHistory = false) { var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId); - if (report == null || report.DeletedOn.HasValue) + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) return null; return await HydrateAsync(report, null, includeHistory); @@ -248,7 +278,7 @@ public async Task GetForCallAsync(int departmentId, int var profile = await _neris.GetProfileAsync(departmentId); var report = await _reports.GetByCallAsync(departmentId, callId, ReportingEntityFor(departmentId, profile)) ?? (await _reports.GetByCallAnyEntityAsync(departmentId, callId))?.FirstOrDefault(r => !r.DeletedOn.HasValue); - return report == null ? null : await HydrateAsync(report, null, false); + return report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue ? null : await HydrateAsync(report, null, false); } public async Task> GetSectionRequirementsAsync(int departmentId, string reportId) @@ -260,17 +290,35 @@ public async Task> GetSectionRequirementsAsync(int public async Task BuildSnapshotAsync(int departmentId, string reportId, string revisionId = null) { var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId); - if (report == null) + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) return null; - var aggregate = await HydrateAsync(report, revisionId, false); - return ToSnapshot(aggregate); + if (!string.IsNullOrWhiteSpace(revisionId)) + { + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); + if (revision == null || revision.RecordId != reportId || revision.RecordKind != (int)RmsRecordKind.IncidentReport) return null; + if (RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("The incident revision checksum does not match."); + var frozen = JsonConvert.DeserializeObject(revision.SnapshotJson); + if (frozen?.Report == null) throw new InvalidOperationException("The incident revision is incomplete."); + // Version 1 omitted RMS-3 sections from JSON, but stored revision-bound copies. Never use draft rows. + if (((int?)Newtonsoft.Json.Linq.JObject.Parse(revision.SnapshotJson)["SnapshotVersion"] ?? 1) < 2) + { + var copies = await HydrateAsync(frozen.Report, revisionId, false); + frozen.Modules = copies.Modules; frozen.Resources = copies.Resources; + frozen.Casualties = copies.Casualties; frozen.Exposures = copies.Exposures; + } + return ToSnapshot(frozen); + } + return ToSnapshot(await HydrateAsync(report, null, false)); } public static NerisIncidentSnapshot ToSnapshot(IncidentReportAggregate aggregate) { return new NerisIncidentSnapshot { + CustomFields = aggregate.CustomFields, + Attachments = aggregate.Attachments, + Evidence = aggregate.Evidence, Report = aggregate.Report, Location = aggregate.Location, Types = aggregate.Types, @@ -293,10 +341,13 @@ public static NerisIncidentSnapshot ToSnapshot(IncidentReportAggregate aggregate #region Draft / validate - public async Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, bool canWriteRestricted = true, CancellationToken cancellationToken = default) + public async Task SaveDraftAsync(int departmentId, string userId, string reportId, long expectedRowVersion, IncidentReportDraftInput input, bool canWriteRestricted = false, CancellationToken cancellationToken = default) { if (input == null) throw new ArgumentNullException(nameof(input)); var report = await LoadAsync(departmentId, reportId); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord) + || !await _authorization.CanUserViewRecordAsync(userId, reportId, departmentId)) throw new UnauthorizedAccessException("Incident report access is not authorized."); + canWriteRestricted = canWriteRestricted && await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); RequireEditable(report); var now = DateTime.UtcNow; @@ -306,6 +357,7 @@ await InTransactionAsync(async () => var facts = (await _facts.GetForRecordAsync(departmentId, reportId, null))?.ToList() ?? new List(); ApplyHeader(report, input, facts, userId, now); + report.UdfDefinitionId = await _udf.SaveInTransactionAsync(departmentId, userId, reportId, report.DefinitionKey, report.DefinitionVersion, report.UdfDefinitionId, input.CustomFields, cancellationToken); var location = await ReplaceLocationAsync(report, input.Location, facts, userId, now, cancellationToken); var types = await ReplaceTypesAsync(report, input.Types, now, cancellationToken); var units = await ReplaceUnitsAsync(report, input.Units, facts, userId, now, cancellationToken); @@ -467,6 +519,8 @@ await InTransactionAsync(async () => { await GuardVersionAsync(report, expectedRowVersion, cancellationToken); var draft = await HydrateAsync(report, null, false); + _udf.ValidateForFinalization(draft.CustomFields); + await _evidenceService.RequireInventoryCoverageAsync(departmentId, reportId, draft.Evidence); // Validation blocks the signature (plan 4.2 "progressive validation"): the issues stay on the report for the author. var local = _validation.ValidateLocal(ToSnapshot(draft), profile); @@ -550,10 +604,18 @@ await InTransactionAsync(async () => private async Task<(RmsSubmission submission, long outboxId)> QueueSubmissionCoreAsync(RmsIncidentReport report, IncidentReportAggregate aggregate, RmsRevision revision, RmsNerisProfile profile, string userId, DateTime now, CancellationToken cancellationToken) { + var priorSubmissions = (await _submissions.GetForRecordAsync(report.DepartmentId, report.RmsIncidentReportId))?.ToList() ?? new List(); + RecordsSubmissionService.RequireResolvedCreates(priorSubmissions); var payload = _mapping.BuildIncidentPayloadJson(ToSnapshot(aggregate), profile); var key = IdempotencyKey(report.DepartmentId, report.RmsIncidentReportId, revision.RmsRevisionId); var submission = await _submissions.GetByIdempotencyKeyAsync(key); + if (submission?.RequiresReconciliation == true) + throw new InvalidOperationException("Reconcile the prior destination delivery before retrying this revision."); + var destination = _neris.GetDestinationIdentity(profile); + var externalId = RecordsSubmissionService.ResolveDestinationId(priorSubmissions, destination, report.NerisIncidentId); + if (submission != null && submission.DestinationIdentity != destination) + throw new InvalidOperationException("This revision was queued for another destination. Correct the profile or finalize a new revision."); if (submission != null && submission.State != (int)RmsSubmissionState.Failed && submission.State != (int)RmsSubmissionState.Superseded && submission.State != (int)RmsSubmissionState.Rejected) throw new InvalidOperationException("This revision is already queued or delivered."); @@ -569,6 +631,8 @@ await InTransactionAsync(async () => RevisionId = revision.RmsRevisionId, Destination = RmsSubmissionDestinations.Neris, DestinationVersion = profile?.ContractVersion ?? _neris.ContractVersion, + DestinationIdentity = destination, + ExternalId = externalId, IdempotencyKey = key, MaxAttempts = Math.Max(1, Config.NerisConfig.MaxAttempts), PayloadJson = payload, @@ -586,7 +650,7 @@ await InTransactionAsync(async () => { // A failed or rejected delivery of the same revision is re-queued with the same key: retry, not a new payload. submission.State = (int)RmsSubmissionState.Queued; - submission.Attempts = 0; + submission.MaxAttempts = submission.Attempts + Math.Max(1, Config.NerisConfig.MaxAttempts); submission.NextAttemptOn = null; submission.LeaseOwner = null; submission.LeaseExpiresOn = null; @@ -635,6 +699,7 @@ await InTransactionAsync(async () => public async Task AbandonAmendmentAsync(int departmentId, string userId, string reportId, CancellationToken cancellationToken = default) { var report = await LoadAsync(departmentId, reportId); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.AmendRecords) || !await _authorization.CanUserViewRecordAsync(userId, reportId, departmentId)) throw new UnauthorizedAccessException(); if (report.AmendsRevisionId == null) throw new RecordTransitionException(reportId, (RmsRecordState)report.State, (RmsRecordState)report.State, "no amendment draft is open"); @@ -643,13 +708,37 @@ await InTransactionAsync(async () => { await GuardVersionAsync(report, report.RowVersion, cancellationToken); // The draft rows are rebuilt from the current revision's copies so the finalized content is what the author sees again. - var current = await HydrateAsync(report, report.CurrentRevisionId, false); + var frozen = await BuildSnapshotAsync(departmentId, reportId, report.CurrentRevisionId) ?? throw new InvalidOperationException("The current revision is unavailable."); + var current = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(frozen)); await ReplaceDraftRowsFromAsync(report, current, now, cancellationToken); + await _udf.RestoreInTransactionAsync(departmentId, reportId, report.DefinitionKey, report.DefinitionVersion, current.CustomFields, userId, cancellationToken); + report.UdfDefinitionId = current.CustomFields?.DefinitionId; + foreach (var field in new[] { "IncidentNumber", "DispatchIncidentCode", "CallCreatedOn", "CallAnsweredOn", "CallArrivalOn", "IncidentClearedOn", "DispatchCenterId", "DeterminantCode", "Disposition", "PeoplePresent", "DisplacementCount", "AnimalsRescued", "SpecialModifiersCsv", "StationGroupId", "DisplaySummary" }) + { var property = typeof(RmsIncidentReport).GetProperty(field); property.SetValue(report, property.GetValue(current.Report)); } + var savedRevision = await _revisions.GetByIdForDepartmentAsync(departmentId, report.CurrentRevisionId); + foreach (var savedAttachment in current.Attachments) + { + var stored = await _attachments.GetHistoricalByIdForDepartmentAsync(departmentId, savedAttachment.RmsRecordAttachmentId); + if (stored?.RecordId == reportId && stored.DeletedOn.HasValue && stored.Checksum == savedAttachment.Checksum && stored.ScanState == (int)RmsAttachmentScanState.Clean) + { stored.DeletedOn = null; stored.ModifiedOn = now; stored.RowVersion++; await _attachments.UpdateAsync(stored, cancellationToken, true); } + } + foreach (var attachment in (await _attachments.GetMetadataForRecordAsync(departmentId, reportId)) ?? Enumerable.Empty()) + if (!current.Attachments.Any(a => a.RmsRecordAttachmentId == attachment.RmsRecordAttachmentId) && attachment.UploadedOn > savedRevision.CreatedOn) + { + // Load bytes before the metadata-only row is updated; otherwise an abandoned upload would erase storage without its retention audit. + var stored = await _attachments.GetByIdForDepartmentAsync(departmentId, attachment.RmsRecordAttachmentId); + stored.DeletedOn = now; stored.ModifiedOn = now; stored.RowVersion++; + await _attachments.UpdateAsync(stored, cancellationToken, true); + } + foreach (var evidence in (await _evidence.GetForRecordAsync(departmentId, reportId, null, true)) ?? Enumerable.Empty()) + { evidence.DeletedOn = now; evidence.ModifiedOn = now; evidence.RowVersion++; await _evidence.UpdateAsync(evidence, cancellationToken, true); } report.AmendsRevisionId = null; report.ModifiedOn = now; report.ModifiedByUserId = userId; await _reports.UpdateAsync(report, cancellationToken, true); await AuditAsync(departmentId, userId, reportId, report.CurrentRevisionId, RmsAccessAuditAction.Change, "Abandon amendment", RmsOriginClient.Web, cancellationToken); + await RecomputeGroupScopeAsync(await HydrateAsync(report, null, false), null, cancellationToken); + await RefreshProjectionAsync(report, cancellationToken); }); return await GetAsync(departmentId, reportId, true); } @@ -668,7 +757,8 @@ public async Task VoidAsync(int departmentId, string us await InTransactionAsync(async () => { await GuardVersionAsync(report, report.RowVersion, cancellationToken); - var current = await HydrateAsync(report, report.CurrentRevisionId, false); + var frozen = await BuildSnapshotAsync(departmentId, reportId, report.CurrentRevisionId) ?? throw new InvalidOperationException("The current revision is unavailable."); + var current = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(frozen)); var revision = await WriteRevisionAsync(report, current, RmsRevisionTransition.Voided, userId, reasonCode, reasonText, null, now, cancellationToken); report.State = (int)RmsRecordState.Voided; report.VoidedOn = now; @@ -720,12 +810,14 @@ await InTransactionAsync(async () => 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(); } - public Task CountAsync(int departmentId, RmsIncidentReportQuery query) + public async Task CountAsync(int departmentId, RmsIncidentReportQuery query) { - return _reports.CountAsync(departmentId, query ?? new RmsIncidentReportQuery()); + if (!string.IsNullOrEmpty(query?.ViewerUserId) && !await _authorization.IsActiveMemberAsync(query.ViewerUserId, departmentId)) return 0; + return await _reports.CountAsync(departmentId, query ?? new RmsIncidentReportQuery()); } public async Task> GetYearsAsync(int departmentId) @@ -1093,15 +1185,21 @@ private async Task> ReplaceCasualtiesAsync(RmsIncidentRe if (inputs == null) return existing; + var byId = existing.ToDictionary(c => c.RmsCasualtyRescueId, StringComparer.Ordinal); + var suppliedIds = inputs.Where(c => !string.IsNullOrWhiteSpace(c.CasualtyId)).Select(c => c.CasualtyId).ToList(); + if (suppliedIds.Distinct(StringComparer.Ordinal).Count() != suppliedIds.Count || suppliedIds.Any(id => !byId.ContainsKey(id))) + throw new ArgumentException("A casualty row does not belong to this draft or was supplied more than once."); + if (!canWriteRestricted && existing.Any(c => !suppliedIds.Contains(c.RmsCasualtyRescueId))) + throw new UnauthorizedAccessException("Existing casualties must be retained by their row identifiers when restricted fields are hidden."); await _casualties.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); var result = new List(); var ordinal = 0; foreach (var input in inputs) { - var prior = ordinal < existing.Count ? existing[ordinal] : null; + var prior = !string.IsNullOrWhiteSpace(input.CasualtyId) ? byId[input.CasualtyId] : null; var row = new RmsCasualtyRescue { - RmsCasualtyRescueId = Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = Guid.NewGuid().ToString(), + RmsCasualtyRescueId = prior?.RmsCasualtyRescueId ?? Guid.NewGuid().ToString(), DepartmentId = report.DepartmentId, ProtectionId = prior?.ProtectionId ?? Guid.NewGuid().ToString(), RecordId = report.RmsIncidentReportId, Kind = (int)input.Kind, PersonType = Trim(input.PersonType)?.ToUpperInvariant() ?? RmsCasualtyPersonTypes.Civilian, WasInjured = input.WasInjured, @@ -1120,7 +1218,7 @@ private async Task> ReplaceCasualtiesAsync(RmsIncidentRe RescueElevation = Trim(input.RescueElevation)?.ToUpperInvariant(), PresenceKnown = Trim(input.PresenceKnown)?.ToUpperInvariant(), YearsOfService = input.YearsOfService, - DetailJson = Trim(input.DetailJson), + DetailJson = canWriteRestricted ? Trim(input.DetailJson) : prior?.DetailJson, OccurredOn = input.OccurredOn, Ordinal = ordinal++, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; @@ -1224,6 +1322,10 @@ private async Task ReplaceNarrativeAsync(RmsIncidentReport report, private async Task ReplaceDraftRowsFromAsync(RmsIncidentReport report, IncidentReportAggregate source, DateTime now, CancellationToken cancellationToken) { + await _modules.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); + await _resources.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); + await _casualties.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); + await _exposures.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); await _locations.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); await _types.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); await _units.DeleteDraftForRecordAsync(report.DepartmentId, report.RmsIncidentReportId, cancellationToken); @@ -1239,6 +1341,10 @@ private async Task ReplaceDraftRowsFromAsync(RmsIncidentReport report, IncidentR foreach (var t in source.Tactics) await _tactics.InsertAsync(Copy(t, x => x.RmsActionTacticId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); if (source.Narrative != null) await _narratives.InsertAsync(Copy(source.Narrative, n => n.RmsNarrativeId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); foreach (var f in source.Facts) await _facts.InsertAsync(Copy(f, x => x.RmsSourceFactId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); + foreach (var m in source.Modules) await _modules.InsertAsync(Copy(m, x => x.RmsIncidentModuleId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); + foreach (var r in source.Resources) await _resources.InsertAsync(Copy(r, x => x.RmsIncidentResourceId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); + foreach (var c in source.Casualties) await _casualties.InsertAsync(Copy(c, x => x.RmsCasualtyRescueId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); + foreach (var e in source.Exposures) await _exposures.InsertAsync(Copy(e, x => x.RmsExposureId = Guid.NewGuid().ToString(), null, now), cancellationToken, true); } #endregion @@ -1271,6 +1377,7 @@ private async Task WriteRevisionAsync(RmsIncidentReport report, Inc CreatedOn = now }; await _revisions.InsertAsync(revision, cancellationToken, true); + await _evidence.BindDraftToRevisionAsync(report.DepartmentId, report.RmsIncidentReportId, revision.RmsRevisionId, now, cancellationToken); // Revision-bound copies keep finalized data queryable without touching the draft rows. var id = revision.RmsRevisionId; @@ -1353,6 +1460,9 @@ private async Task HydrateAsync(RmsIncidentReport repor var aggregate = new IncidentReportAggregate { Report = report, + Attachments = revisionId == null ? ((await _attachments.GetMetadataForRecordAsync(dept, id))?.ToList() ?? new List()) : new List(), + CustomFields = revisionId == null ? await _udf.CaptureAsync(dept, id, report.DefinitionKey, report.DefinitionVersion, report.UdfDefinitionId) : null, + Evidence = (await _evidence.GetForRecordAsync(dept, id, revisionId, false))?.ToList() ?? new List(), Location = (await _locations.GetForRecordAsync(dept, id, revisionId))?.FirstOrDefault(), Types = (await _types.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), Units = (await _units.GetForRecordAsync(dept, id, revisionId))?.ToList() ?? new List(), @@ -1367,6 +1477,16 @@ private async Task HydrateAsync(RmsIncidentReport repor Issues = (await _issues.GetForRecordAsync(dept, id))?.ToList() ?? new List(), GroupScope = (await _scopes.GetForRecordAsync(dept, id))?.ToList() ?? new List() }; + if (revisionId == null && report.CurrentRevisionId != null) + { + var revision = await _revisions.GetByIdForDepartmentAsync(dept, report.CurrentRevisionId); + if (revision != null && revision.RecordId == id && revision.Checksum == RecordSnapshotSerializer.Checksum(revision.SnapshotJson)) + { + var previous = JsonConvert.DeserializeObject(revision.SnapshotJson)?.Evidence ?? new List(); + aggregate.Evidence = previous.Where(p => !aggregate.Evidence.Any(e => e.Kind == p.Kind && e.SourceEntityId == p.SourceEntityId)) + .Concat(aggregate.Evidence).GroupBy(e => e.RmsEvidenceArtifactId).Select(g => g.Last()).ToList(); + } + } if (includeHistory) { aggregate.Submissions = (await _submissions.GetForRecordAsync(dept, id))?.ToList() ?? new List(); @@ -1547,7 +1667,7 @@ public static object SubmissionBlock(RmsSubmission submission) private async Task LoadAsync(int departmentId, string reportId) { var report = string.IsNullOrWhiteSpace(reportId) ? null : await _reports.GetByIdForDepartmentAsync(departmentId, reportId); - if (report == null || report.DeletedOn.HasValue) + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) throw new ArgumentException($"Incident report {reportId} was not found.", nameof(reportId)); return report; } @@ -1648,7 +1768,8 @@ private static string SerializeSnapshot(IncidentReportAggregate aggregate) { var snapshot = new { - SnapshotVersion = 1, + SnapshotVersion = 2, + aggregate.CustomFields, aggregate.Report, aggregate.Location, aggregate.Types, @@ -1656,7 +1777,13 @@ private static string SerializeSnapshot(IncidentReportAggregate aggregate) aggregate.Aids, aggregate.Tactics, aggregate.Narrative, - aggregate.Facts + aggregate.Facts, + aggregate.Modules, + aggregate.Resources, + aggregate.Casualties, + aggregate.Exposures, + Attachments = aggregate.Attachments.Select(a => { var copy = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(a)); copy.Data = null; copy.StorageReference = null; return copy; }).ToList(), + aggregate.Evidence }; return JsonConvert.SerializeObject(snapshot, Formatting.None); } diff --git a/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs b/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs index ce1d1f34e..3ae38adf8 100644 --- a/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs +++ b/Core/Resgrid.Services/Records/RecordAttachmentHygiene.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using Resgrid.Framework; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats.Bmp; @@ -61,7 +62,9 @@ public static AttachmentHygieneResult Sanitize(string fileName, string contentTy if (data.Length > MaxBytes) throw new RecordAttachmentRejectedException($"Attachment '{fileName}' exceeds the {MaxBytes / (1024 * 1024)} MB limit."); - var safeName = Path.GetFileName(fileName ?? string.Empty); + // Path.GetFileName alone is host-relative: on Linux a backslash is an ordinary filename character, so + // "C:\temp\..\report.pdf" would survive whole. GetSafeFileName normalises the separator first. + var safeName = FileHelper.GetSafeFileName(fileName); var extension = Path.GetExtension(safeName) ?? string.Empty; var declaredType = (contentType ?? string.Empty).Trim(); diff --git a/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs b/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs new file mode 100644 index 000000000..928448ba1 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs @@ -0,0 +1,146 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// Source-authorized choices for both officer evidence forms. Selection never captures content. + public class RecordEvidenceSelectionService : IRecordEvidenceSelectionService + { + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsIncidentReportsRepository _incidents; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsCutoverService _cutover; + private readonly IRecordsEvidenceService _evidence; + private readonly ICallsService _calls; + private readonly IUnitsService _units; + private readonly IDepartmentsService _departments; + private readonly Lazy _sourceAuthorization; + private readonly IChatChannelRepository _channels; + private readonly IChatMessageRepository _messages; + private readonly Lazy _chat; + + public RecordEvidenceSelectionService(IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidents, + IRecordsAuthorizationService authorization, IRecordsCutoverService cutover, IRecordsEvidenceService evidence, + ICallsService calls, IUnitsService units, IDepartmentsService departments, Lazy sourceAuthorization, + IChatChannelRepository channels, IChatMessageRepository messages, Lazy chat) + { + _records = records; _incidents = incidents; _authorization = authorization; _cutover = cutover; + _evidence = evidence; _calls = calls; _units = units; _departments = departments; + _sourceAuthorization = sourceAuthorization; _channels = channels; _messages = messages; _chat = chat; + } + + public async Task GetContextAsync(int departmentId, string userId, string recordId, RmsRecordKind recordKind) + { + if (string.IsNullOrWhiteSpace(userId) || !(await _cutover.GetModuleStateAsync(departmentId)).RecordsUsable || + !await _authorization.CanUserViewRecordAsync(userId, recordId, departmentId)) throw new UnauthorizedAccessException(); + var context = new RecordEvidenceContext { RecordId = recordId, RecordKind = recordKind }; + string author, owner, amendment; int state; + if (recordKind == RmsRecordKind.Operational) + { + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); + if (record == null || record.DepartmentId != departmentId || record.DeletedOn.HasValue || record.PurgedOn.HasValue) throw new UnauthorizedAccessException(); + context.RecordNumber = record.RecordNumber ?? record.DraftReference; context.RowVersion = record.RowVersion; + context.CallId = record.CallId; context.StartUtc = record.StartedOn; context.EndUtc = record.EndedOn; + author = record.AuthorUserId; owner = record.OwnerUserId; amendment = record.AmendsRevisionId; state = record.State; + } + else if (recordKind == RmsRecordKind.IncidentReport) + { + var report = await _incidents.GetByIdForDepartmentAsync(departmentId, recordId); + if (report == null || report.DepartmentId != departmentId || report.DeletedOn.HasValue || report.PurgedOn.HasValue) throw new UnauthorizedAccessException(); + context.RecordNumber = report.RecordNumber ?? report.DraftReference; context.RowVersion = report.RowVersion; + context.CallId = report.CallId; context.StartUtc = report.CallCreatedOn; context.EndUtc = report.IncidentClearedOn; + author = report.AuthorUserId; owner = report.OwnerUserId; amendment = report.AmendsRevisionId; state = report.State; + } + else throw new ArgumentException("Choose an operational record or incident report."); + context.CanCapture = !RmsLifecycle.IsTerminal((RmsRecordState)state) && (RmsLifecycle.IsEditable((RmsRecordState)state) || amendment != null) + && await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord) + && (author == userId || owner == userId || await _authorization.IsDepartmentAdminAsync(userId, departmentId) + || (amendment != null && await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.AmendRecords))); + context.CanViewRestricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); + context.CanExport = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ExportRecords); + if (!await _authorization.CanUserViewRecordAsync(userId, recordId, departmentId)) throw new UnauthorizedAccessException(); + return context; + } + + public async Task GetAsync(int departmentId, string userId, string recordId, RmsRecordKind recordKind, + RmsEvidenceKind sourceKind, string channelId = null, long afterSequence = 0) + { + if (!Enum.IsDefined(typeof(RmsEvidenceKind), sourceKind) || afterSequence < 0) throw new ArgumentException("Choose a supported evidence source and page."); + var context = await GetContextAsync(departmentId, userId, recordId, recordKind); + if (!context.CanCapture) throw new UnauthorizedAccessException(); + var selection = new RecordEvidenceSelection { Context = context, SourceKind = sourceKind, ChannelId = channelId, + Sources = await _evidence.GetSourceStatesAsync(departmentId) }; + await RequireSourceCallAsync(context, departmentId, userId); + var source = selection.Sources.FirstOrDefault(s => s.Kind == sourceKind); + if (source?.Available == true) + { + if (Restricted(sourceKind) && !context.CanViewRestricted) throw new UnauthorizedAccessException(); + if (sourceKind == RmsEvidenceKind.TrackingFix) + { + foreach (var unit in (await _units.GetUnitsForDepartmentAsync(departmentId)).Where(u => u.DepartmentId == departmentId)) + if (await _sourceAuthorization.Value.CanUserViewUnitLocationAsync(userId, unit.UnitId, departmentId)) + selection.Choices.Add(new RecordEvidenceChoice { Id = unit.UnitId.ToString(CultureInfo.InvariantCulture), Label = unit.Name }); + } + else if (sourceKind == RmsEvidenceKind.CertificationSnapshot) + { + foreach (var person in await _departments.GetAllPersonnelNamesForDepartmentAsync(departmentId)) + if (await _sourceAuthorization.Value.CanUserViewPersonAsync(userId, person.UserId, departmentId)) + selection.Choices.Add(new RecordEvidenceChoice { Id = person.UserId, Label = person.Name }); + } + else if (sourceKind == RmsEvidenceKind.ChatPromotion && context.CallId.HasValue) + { + foreach (var channel in (await _channels.GetByCallIdAsync(context.CallId.Value) ?? Enumerable.Empty()) + .Where(c => c.DepartmentId == departmentId && c.CallId == context.CallId)) + if (await _chat.Value.CanAccessChannelAsync(channel, userId, null)) + selection.Channels.Add(new RecordEvidenceChoice { Id = channel.ChatChannelId, Label = channel.Name }); + if (!string.IsNullOrWhiteSpace(channelId)) + { + if (!selection.Channels.Any(c => c.Id == channelId)) throw new UnauthorizedAccessException(); + // Includes thread replies. Advance over withheld rows too, so a deleted page cannot trap the officer. + var rows = (await _messages.GetAfterSeqAsync(channelId, afterSequence, 101) ?? Enumerable.Empty()).ToList(); + var page = rows.Take(100).ToList(); + if (rows.Count > 100 && page.Count > 0) selection.NextSequence = page.Max(m => m.MessageSeq); + foreach (var message in page.Where(m => m.DepartmentId == departmentId && m.ChatChannelId == channelId && !m.DeletedOn.HasValue && !m.IsModerated)) + selection.Choices.Add(new RecordEvidenceChoice { Id = message.ChatMessageId, Label = message.SenderDisplayName, + Body = message.Body, OccurredOn = message.SentOn, EditedOn = message.EditedOn, Sequence = message.MessageSeq }); + } + } + else if (sourceKind == RmsEvidenceKind.InventoryUsage && !await _authorization.CanUseSourceInventoryAsync(userId, departmentId, null)) + throw new UnauthorizedAccessException(); + } + + // Recheck earlier choices after the last awaited source read. No caller-provided IDs grant access. + foreach (var choice in selection.Choices) + { + if (sourceKind == RmsEvidenceKind.TrackingFix && !await _sourceAuthorization.Value.CanUserViewUnitLocationAsync(userId, int.Parse(choice.Id, CultureInfo.InvariantCulture), departmentId)) throw new UnauthorizedAccessException(); + if (sourceKind == RmsEvidenceKind.CertificationSnapshot && !await _sourceAuthorization.Value.CanUserViewPersonAsync(userId, choice.Id, departmentId)) throw new UnauthorizedAccessException(); + } + if (selection.Channels.Count > 0) + { + var current = (await _channels.GetByCallIdAsync(context.CallId.Value) ?? Enumerable.Empty()).ToList(); + foreach (var item in selection.Channels) + { + var channel = current.FirstOrDefault(c => c.ChatChannelId == item.Id && c.DepartmentId == departmentId && c.CallId == context.CallId); + if (channel == null || !await _chat.Value.CanAccessChannelAsync(channel, userId, null)) throw new UnauthorizedAccessException(); + } + } + await RequireSourceCallAsync(context, departmentId, userId); + var final = await GetContextAsync(departmentId, userId, recordId, recordKind); + if (!final.CanCapture || (Restricted(sourceKind) && !final.CanViewRestricted)) throw new UnauthorizedAccessException(); + if (context.RowVersion != final.RowVersion || context.CallId != final.CallId) throw new RecordConcurrencyException(recordId, context.RowVersion, final.RowVersion); + return selection; + } + + private async Task RequireSourceCallAsync(RecordEvidenceContext context, int departmentId, string userId) + { + if (context.CallId.HasValue && !await _authorization.CanReadSourceCallAsync(userId, departmentId, await _calls.GetCallByIdAsync(context.CallId.Value))) + throw new UnauthorizedAccessException(); + } + private static bool Restricted(RmsEvidenceKind kind) => kind is RmsEvidenceKind.ChatPromotion or RmsEvidenceKind.CertificationSnapshot or RmsEvidenceKind.InventoryUsage; + } +} diff --git a/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs b/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs index ae433fe8a..ec4ab3347 100644 --- a/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs +++ b/Core/Resgrid.Services/Records/RecordSnapshotSerializer.cs @@ -59,6 +59,7 @@ public static RecordSnapshot Build(RecordAggregate aggregate) var r = aggregate.Record; return new RecordSnapshot { + CustomFields = aggregate.CustomFields, RecordId = r.RmsOperationalRecordId, DepartmentId = r.DepartmentId, DefinitionKey = r.DefinitionKey, @@ -208,6 +209,7 @@ private static RmsRecordAttachment StripBytes(RmsRecordAttachment attachment) UploadedOn = attachment.UploadedOn, ScanState = attachment.ScanState, MetadataStripped = attachment.MetadataStripped, + Classification = attachment.Classification, IsProtected = attachment.IsProtected, ProtectedCatalogVersion = attachment.ProtectedCatalogVersion, CreatedOn = attachment.CreatedOn, diff --git a/Core/Resgrid.Services/Records/RecordsApiSupport.cs b/Core/Resgrid.Services/Records/RecordsApiSupport.cs index d709594ea..11ee62aa7 100644 --- a/Core/Resgrid.Services/Records/RecordsApiSupport.cs +++ b/Core/Resgrid.Services/Records/RecordsApiSupport.cs @@ -9,6 +9,7 @@ using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; using Resgrid.Model.Services; namespace Resgrid.Services.Records @@ -21,6 +22,8 @@ public class RecordsApiStateStore : IRecordsApiStateStore { private static readonly ConcurrentDictionary Local = new ConcurrentDictionary(StringComparer.Ordinal); private static int _localWarned; + // Low-volume nodes still expire uploaded bytes; expiry must not depend on reaching 512 cached entries. + private static readonly Timer ExpiryTimer = new Timer(_ => Sweep(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); private readonly ICacheProvider _cache; public RecordsApiStateStore(ICacheProvider cache) @@ -80,8 +83,6 @@ public async Task RemoveAsync(string key) private static void Sweep() { - if (Local.Count < 512) - return; var now = DateTime.UtcNow; foreach (var kv in Local.Where(kv => kv.Value.ExpiresOn <= now).ToList()) Local.TryRemove(kv.Key, out _); @@ -91,15 +92,39 @@ private static void Sweep() public static void ResetLocal() => Local.Clear(); } - /// Scoped command idempotency (plan section 5.3): remembered for a day, keyed by department, user and the client's key. + /// Durable command reservations scoped by department, actor, command and client key. Reads older cached receipts during rollout. public class RecordsApiIdempotencyService : IRecordsApiIdempotencyService { public static readonly TimeSpan Retention = TimeSpan.FromHours(24); private readonly IRecordsApiStateStore _store; + private readonly IRmsCommandReceiptsRepository _receipts; + private readonly ConcurrentDictionary _reservations = new ConcurrentDictionary(StringComparer.Ordinal); - public RecordsApiIdempotencyService(IRecordsApiStateStore store) + public RecordsApiIdempotencyService(IRecordsApiStateStore store, IRmsCommandReceiptsRepository receipts) { _store = store; + _receipts = receipts; + } + + public static string DurableKey(int departmentId, string userId, string idempotencyKey, string command) => + RecordSnapshotSerializer.Checksum(JsonConvert.SerializeObject(new { DepartmentId = departmentId, UserId = userId, Command = command?.Trim(), Key = idempotencyKey?.Trim() })); + + public async Task TryReserveCommandAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId, string requestChecksum) + { + ValidateIdentity(userId, idempotencyKey, command, recordId, requestChecksum); + // Preserve an older receipt rather than treating deployment or cache migration as permission to repeat. + if (await TryGetCommandAsync(departmentId, userId, idempotencyKey, command) != null) return false; + var key = DurableKey(departmentId, userId, idempotencyKey, command); + var reservation = Guid.NewGuid().ToString(); + if (!await _receipts.ReserveAsync(departmentId, key, recordId, requestChecksum, reservation)) return false; + _reservations[key] = reservation; + return true; + } + + private static void ValidateIdentity(string userId, string idempotencyKey, string command, string recordId, string requestChecksum) + { + if (string.IsNullOrWhiteSpace(idempotencyKey) || string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(command) || string.IsNullOrWhiteSpace(recordId) || string.IsNullOrWhiteSpace(requestChecksum)) + throw new ArgumentException("A command receipt requires its actor, command, key, record and request checksum."); } /// @@ -122,6 +147,28 @@ public Task RememberAsync(int departmentId, string userId, string idempotencyKey return Task.CompletedTask; return _store.SetAsync(Key(departmentId, userId, idempotencyKey.Trim(), command), recordId, Retention); } + + public async Task TryGetCommandAsync(int departmentId, string userId, string idempotencyKey, string command) + { + if (string.IsNullOrWhiteSpace(idempotencyKey) || string.IsNullOrWhiteSpace(userId)) return null; + var durable = await _receipts.GetAsync(departmentId, DurableKey(departmentId, userId, idempotencyKey, command)); + if (durable != null) return durable; + var value = await TryGetRecordIdAsync(departmentId, userId, idempotencyKey, command); + if (value == null) return null; + try { return JsonConvert.DeserializeObject(value) ?? new RecordCommandReceipt(); } + catch (JsonException) { return new RecordCommandReceipt { RecordId = value }; } + // A legacy/corrupt unbound receipt is deliberately present with no request checksum. Callers must + // reject it, rather than treating it as a cache miss and repeating a possibly completed operation. + } + + public async Task RememberCommandAsync(int departmentId, string userId, string idempotencyKey, string command, string recordId, string requestChecksum) + { + ValidateIdentity(userId, idempotencyKey, command, recordId, requestChecksum); + var key = DurableKey(departmentId, userId, idempotencyKey, command); + if (!_reservations.TryGetValue(key, out var reservation) || !await _receipts.CompleteAsync(departmentId, key, recordId, requestChecksum, reservation)) + throw new RecordIdempotencyException("The command outcome could not be acknowledged. Review the current record before issuing another command."); + _reservations.TryRemove(key, out _); + } } /// @@ -171,7 +218,7 @@ public async Task BeginAsync(int departmentId, st DepartmentId = departmentId, RecordId = recordId, UserId = userId, - FileName = System.IO.Path.GetFileName(fileName.Trim()), + FileName = FileHelper.GetSafeFileName(fileName?.Trim()), ContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType.Trim(), DeclaredSize = declaredSize, Sha256 = sha256.Trim().ToLowerInvariant(), @@ -211,13 +258,13 @@ public async Task AppendAsync(int departmentId, s if (data.Length < session.ChunkSize && index != session.ChunkCount - 1) throw new RecordUploadSessionException("bad_offset", "Only the last chunk may be shorter than the chunk size."); - await _store.SetAsync(ChunkKey(departmentId, uploadId, index), Convert.ToBase64String(data), SessionLifetime); + await _store.SetAsync(ChunkKey(departmentId, uploadId, index), Convert.ToBase64String(data), Remaining(session)); session.ReceivedBytes += data.Length; await SaveAsync(session); return session; } - public async Task CompleteAsync(int departmentId, string userId, string uploadId, string description, CancellationToken cancellationToken = default) + public async Task CompleteAsync(int departmentId, string userId, string uploadId, string description, CancellationToken cancellationToken = default, int classification = 1) { var session = await RequireOpenAsync(departmentId, userId, uploadId); if (!session.IsComplete) @@ -244,7 +291,7 @@ public async Task CompleteAsync(int departmentId, string us RmsRecordAttachment attachment; try { - attachment = await _records.AddAttachmentAsync(departmentId, userId, session.RecordId, session.FileName, session.ContentType, buffer, description, cancellationToken); + attachment = await _records.AddAttachmentAsync(departmentId, userId, session.RecordId, session.FileName, session.ContentType, buffer, description, cancellationToken, classification); } catch (RecordAttachmentRejectedException ex) { @@ -278,6 +325,14 @@ private async Task RequireOpenAsync(int departmen throw new RecordUploadSessionException("expired", "The upload session has expired; restart the upload."); if (session.State != RecordUploadSessionState.Open) throw new RecordUploadSessionException("closed", $"The upload session is {session.State}."); + var aggregate = await _records.GetAsync(departmentId, session.RecordId); + if (aggregate == null || aggregate.Record.PurgedOn.HasValue || aggregate.Record.DeletedOn.HasValue + || !RmsLifecycle.IsEditable((RmsRecordState)aggregate.Record.State) && aggregate.Record.AmendsRevisionId == null) + { + await RemoveChunksAsync(session); + await _store.RemoveAsync(SessionKey(departmentId, uploadId)); + throw new RecordUploadSessionException("closed", "The record is no longer editable; the unfinished upload was removed."); + } return session; } @@ -291,9 +346,11 @@ private async Task LoadAsync(int departmentId, st private Task SaveAsync(RecordAttachmentUploadSession session) { - return _store.SetAsync(SessionKey(session.DepartmentId, session.UploadId), JsonConvert.SerializeObject(session), SessionLifetime); + return _store.SetAsync(SessionKey(session.DepartmentId, session.UploadId), JsonConvert.SerializeObject(session), Remaining(session)); } + private static TimeSpan Remaining(RecordAttachmentUploadSession session) => TimeSpan.FromMilliseconds(Math.Max(1, (session.ExpiresOn - DateTime.UtcNow).TotalMilliseconds)); + private async Task RemoveChunksAsync(RecordAttachmentUploadSession session) { for (var index = 0; index < session.ChunkCount; index++) diff --git a/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs b/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs index 139eaf714..d2e578fed 100644 --- a/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs +++ b/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs @@ -19,9 +19,6 @@ namespace Resgrid.Services.Records /// public class RecordsAuthorizationService : IRecordsAuthorizationService { - private const string VisibleGroupsCacheKey = "RmsVisibleGroups_{0}_{1}"; - private static readonly TimeSpan VisibleGroupsCacheLength = TimeSpan.FromMinutes(2); - private readonly IPermissionsService _permissionsService; private readonly IDepartmentsService _departmentsService; private readonly IDepartmentGroupsService _departmentGroupsService; @@ -33,11 +30,13 @@ public class RecordsAuthorizationService : IRecordsAuthorizationService private readonly ICacheProvider _cacheProvider; private readonly IRmsLegacyStatsRepository _legacyStats; private readonly IRmsIncidentReportsRepository _incidentReports; + private readonly Lazy _sourceAuthorization; public RecordsAuthorizationService(IPermissionsService permissionsService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, IDepartmentSettingsService departmentSettingsService, IRmsOperationalRecordsRepository recordsRepository, IRmsRecordGroupScopesRepository groupScopesRepository, - IRmsRecordParticipantsRepository participantsRepository, ICacheProvider cacheProvider, IRmsLegacyStatsRepository legacyStats, IRmsIncidentReportsRepository incidentReports) + IRmsRecordParticipantsRepository participantsRepository, ICacheProvider cacheProvider, IRmsLegacyStatsRepository legacyStats, IRmsIncidentReportsRepository incidentReports, + Lazy sourceAuthorization) { _legacyStats = legacyStats; _incidentReports = incidentReports; @@ -50,11 +49,67 @@ public RecordsAuthorizationService(IPermissionsService permissionsService, IDepa _groupScopesRepository = groupScopesRepository; _participantsRepository = participantsRepository; _cacheProvider = cacheProvider; + _sourceAuthorization = sourceAuthorization; + } + + public async Task IsActiveMemberAsync(string userId, int departmentId) + { + if (string.IsNullOrWhiteSpace(userId)) return false; + var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, true); + return member != null && !member.IsDeleted && !member.IsDisabled.GetValueOrDefault(); + } + + public async Task IsDepartmentAdminAsync(string userId, int departmentId) + { + try + { + var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, true); + if (member == null || member.IsDeleted || member.IsDisabled.GetValueOrDefault()) return false; + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, true); + return member.IsAdmin.GetValueOrDefault() || string.Equals(department?.ManagingUserId, userId, StringComparison.Ordinal); + } + catch (Exception ex) { Logging.LogException(ex); return false; } + } + + public async Task HasPermissionAsync(string userId, int departmentId, PermissionTypes permissionType) + { + try + { + if (string.IsNullOrWhiteSpace(userId)) return false; + var descriptor = RecordPermissionCatalog.Get(permissionType); + if (descriptor == null) return false; + var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, true); + if (member == null || member.IsDeleted || member.IsDisabled.GetValueOrDefault()) return false; + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, true); + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, permissionType); + var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); + var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId); + return RecordPermissionEvaluation.IsSatisfied(permission?.Action ?? (int)descriptor.NoRowDefault, permission?.Data, + member != null && !member.IsDeleted && !member.IsDisabled.GetValueOrDefault() && (member.IsAdmin.GetValueOrDefault() || string.Equals(department?.ManagingUserId, userId, StringComparison.Ordinal)), + group != null && group.IsUserGroupAdmin(userId), roles); + } + catch (Exception ex) { Logging.LogException(ex); return false; } + } + + public async Task CanReadSourceCallAsync(string userId, int departmentId, Call call) + { + return call != null && call.DepartmentId == departmentId && await IsActiveMemberAsync(userId, departmentId) + && await _sourceAuthorization.Value.CanUserViewCallAsync(userId, call.CallId); + } + public async Task CanCreateSourceCallAsync(string userId, int departmentId) => + await IsActiveMemberAsync(userId, departmentId) && await _sourceAuthorization.Value.CanUserCreateCallAsync(userId, departmentId); + public async Task CanUseSourceInventoryAsync(string userId, int departmentId, int? groupId = null) + { + if (!await IsActiveMemberAsync(userId, departmentId)) return false; + var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.AdjustInventory); + var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); + return _permissionsService.IsUserAllowed(permission, departmentId, groupId, group?.DepartmentGroupId, + await IsDepartmentAdminAsync(userId, departmentId), group?.IsUserGroupAdmin(userId) == true, await _personnelRolesService.GetRolesForUserAsync(userId, departmentId)); } public async Task IsGroupScopedAsync(int departmentId) { - if (await _departmentSettingsService.GetRecordsGroupVisibilityModeAsync(departmentId) != RecordsGroupVisibilityMode.GroupScoped) + if (await _departmentSettingsService.GetRecordsGroupVisibilityModeAsync(departmentId, true) != RecordsGroupVisibilityMode.GroupScoped) return false; var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.ViewGroupRecords); @@ -67,14 +122,15 @@ async Task> resolve() { try { + if (!await IsActiveMemberAsync(userId, departmentId)) return new List(); if (!await IsGroupScopedAsync(departmentId)) return null; - var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, true); if (department == null) return new List(); - if (department.IsUserAnAdmin(userId)) + if (await IsDepartmentAdminAsync(userId, departmentId)) return null; var permission = await _permissionsService.GetPermissionByDepartmentTypeAsync(departmentId, PermissionTypes.ViewGroupRecords); @@ -96,35 +152,55 @@ async Task> resolve() } } - if (Config.SystemBehaviorConfig.CacheEnabled) - { - var cached = await _cacheProvider.RetrieveAsync(string.Format(VisibleGroupsCacheKey, departmentId, userId), - async () => new VisibleGroupsCacheEntry { DepartmentId = departmentId, Unrestricted = (await resolve()) == null, GroupIds = await resolve() ?? new List() }, - VisibleGroupsCacheLength); + return await resolve(); + } - if (cached != null && cached.DepartmentId == departmentId) - return cached.Unrestricted ? null : cached.GroupIds; + public async Task GetReadScopeStampAsync(string userId, int departmentId) + { + try + { + if (!await IsActiveMemberAsync(userId, departmentId)) return null; + var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); + var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId) ?? new List(); + var permissions = await _permissionsService.GetAllPermissionsForDepartmentAsync(departmentId) ?? new List(); + var visible = await GetVisibleGroupIdsAsync(userId, departmentId); + var shares = visible == null ? Enumerable.Empty() + : await _groupScopesRepository.GetEffectiveSharesAsync(departmentId, visible) ?? Enumerable.Empty(); + var stamp = RecordSnapshotSerializer.Checksum(Newtonsoft.Json.JsonConvert.SerializeObject(new + { + DepartmentId = departmentId, UserId = userId, + Administrator = await IsDepartmentAdminAsync(userId, departmentId), + GroupId = group?.DepartmentGroupId, GroupAdministrator = group?.IsUserGroupAdmin(userId) == true, + Groups = visible?.Distinct().OrderBy(id => id).ToArray(), + Shares = shares.OrderBy(s => s.RmsRecordShareId, StringComparer.Ordinal) + .Select(s => new { s.RmsRecordShareId, s.RecordId, s.DepartmentGroupId, s.ExpiresOn, s.RowVersion }).ToArray(), + Roles = roles.Select(role => role.PersonnelRoleId).Distinct().OrderBy(id => id).ToArray(), + Permissions = permissions.Where(p => RecordPermissionCatalog.Get((PermissionTypes)p.PermissionType) != null) + .OrderBy(p => p.PermissionType).ThenBy(p => p.Action).ThenBy(p => p.Data, StringComparer.Ordinal) + .Select(p => new { p.PermissionType, p.Action, p.Data, p.LockToGroup }).ToArray() + })); + return await IsActiveMemberAsync(userId, departmentId) ? stamp : null; } - - return await resolve(); + catch (Exception ex) { Logging.LogException(ex, "Record cache scope evaluation failed; refusing synchronization."); return null; } } public async Task CanUserViewRecordAsync(string userId, string recordId, int departmentId) { try { + if (!await IsActiveMemberAsync(userId, departmentId)) return false; // Both aggregates share the id space, the group-scope table and this rule (RMS-2 incident reports have no participants). string author, owner, reviewer, approver; var isOperational = true; var record = await _recordsRepository.GetByIdForDepartmentAsync(departmentId, recordId); - if (record != null && !record.DeletedOn.HasValue) + if (record != null && !record.DeletedOn.HasValue && !record.PurgedOn.HasValue) { author = record.AuthorUserId; owner = record.OwnerUserId; reviewer = record.ReviewerUserId; approver = record.ApproverUserId; } else { var report = await _incidentReports.GetByIdForDepartmentAsync(departmentId, recordId); - if (report == null || report.DeletedOn.HasValue) + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) return false; isOperational = false; author = report.AuthorUserId; owner = report.OwnerUserId; reviewer = report.ReviewerUserId; approver = null; @@ -167,11 +243,11 @@ public async Task CanSystemPrincipalViewRecordAsync(SystemPrincipalRecordG { // The record must exist, live, in the granted department. Both aggregates share the id space. var record = await _recordsRepository.GetByIdForDepartmentAsync(grant.DepartmentId, recordId); - var exists = record != null && !record.DeletedOn.HasValue; + var exists = record != null && !record.DeletedOn.HasValue && !record.PurgedOn.HasValue; if (!exists) { var report = await _incidentReports.GetByIdForDepartmentAsync(grant.DepartmentId, recordId); - exists = report != null && !report.DeletedOn.HasValue; + exists = report != null && !report.DeletedOn.HasValue && !report.PurgedOn.HasValue; } if (!exists) diff --git a/Core/Resgrid.Services/Records/RecordsCutoverService.cs b/Core/Resgrid.Services/Records/RecordsCutoverService.cs index f17211a56..625a0460d 100644 --- a/Core/Resgrid.Services/Records/RecordsCutoverService.cs +++ b/Core/Resgrid.Services/Records/RecordsCutoverService.cs @@ -145,8 +145,10 @@ public async Task GetActivationPreviewAsync(int depart if (!preview.FlagEnabled) preview.Blockers.Add("The Records.System feature flag is not enabled for this department."); - if (preview.ProtectedDataPreflight != "NotApplicable" && preview.ProtectedDataPreflight != "Enabled") - preview.Blockers.Add($"Protected Data is in state {preview.ProtectedDataPreflight}; activation is blocked until it settles."); + if (preview.ProtectedDataPreflight != "NotApplicable") + preview.Blockers.Add(preview.ProtectedDataPreflight == "Unknown" + ? "Advanced Data Protection status could not be verified. Records activation is blocked." + : $"Records cannot be activated while Advanced Data Protection is in state {preview.ProtectedDataPreflight}."); return preview; } @@ -411,7 +413,7 @@ private async Task ResolveProtectedDataPreflightAsync(int departmentId) { try { - var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId, true); if (policy == null) return "NotApplicable"; @@ -420,9 +422,8 @@ private async Task ResolveProtectedDataPreflightAsync(int departmentId) } catch (Exception ex) { - // The subsystem being unreachable is treated as absent, per decision 18; the state is logged. - Logging.LogException(ex, $"Protected Data preflight could not be resolved for department {departmentId}; treating as NotApplicable."); - return "NotApplicable"; + Logging.LogException(ex, $"Protected Data preflight could not be resolved for department {departmentId}; activation is blocked."); + return "Unknown"; } } diff --git a/Core/Resgrid.Services/Records/RecordsDashboardService.cs b/Core/Resgrid.Services/Records/RecordsDashboardService.cs index e5be47ba3..e22d1e820 100644 --- a/Core/Resgrid.Services/Records/RecordsDashboardService.cs +++ b/Core/Resgrid.Services/Records/RecordsDashboardService.cs @@ -28,10 +28,11 @@ public class RecordsDashboardService : IRecordsDashboardService private readonly IRmsDisclosureRequestsRepository _disclosures; private readonly INerisProfileService _neris; private readonly ICallsService _calls; + private readonly IRecordsAuthorizationService _authorization; public RecordsDashboardService(IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidentReports, IRmsIncidentAnalysesRepository analyses, IRmsRecordDueStatesRepository dueStates, IRmsDisclosureRequestsRepository disclosures, - INerisProfileService neris, ICallsService calls) + INerisProfileService neris, ICallsService calls, IRecordsAuthorizationService authorization) { _records = records; _incidentReports = incidentReports; @@ -40,41 +41,45 @@ public RecordsDashboardService(IRmsOperationalRecordsRepository records, IRmsInc _disclosures = disclosures; _neris = neris; _calls = calls; + _authorization = authorization; } public async Task GetAsync(int departmentId, string userId, CancellationToken cancellationToken = default) { var dashboard = new RecordsDashboard(); var now = DateTime.UtcNow; + if (!await _authorization.IsActiveMemberAsync(userId, departmentId)) throw new UnauthorizedAccessException(); + var visible = (await _authorization.GetVisibleGroupIdsAsync(userId, departmentId))?.ToList(); await SafeAsync(dashboard, "operational queues", async () => { - dashboard.OperationalDrafts = await _records.CountByDepartmentAsync(departmentId, new[] { (int)RmsRecordState.Draft }); - dashboard.OperationalAwaitingReview = await _records.CountByDepartmentAsync(departmentId, new[] { (int)RmsRecordState.ReadyForReview }); - dashboard.OperationalReturned = await _records.CountByDepartmentAsync(departmentId, new[] { (int)RmsRecordState.Returned }); + dashboard.OperationalDrafts = await _records.CountVisibleAsync(departmentId, new[] { (int)RmsRecordState.Draft }, visible, userId); + dashboard.OperationalAwaitingReview = await _records.CountVisibleAsync(departmentId, new[] { (int)RmsRecordState.ReadyForReview }, visible, userId); + dashboard.OperationalReturned = await _records.CountVisibleAsync(departmentId, new[] { (int)RmsRecordState.Returned }, visible, userId); }); await SafeAsync(dashboard, "incident report queues", async () => { - dashboard.IncidentIncomplete = await CountReportsAsync(departmentId, RmsRecordState.Draft, RmsRecordState.Returned); - dashboard.IncidentAwaitingReview = await CountReportsAsync(departmentId, RmsRecordState.ReadyForReview, RmsRecordState.Approved); - dashboard.IncidentSubmitted = await CountReportsAsync(departmentId, RmsRecordState.Submitted); - dashboard.IncidentAccepted = await CountReportsAsync(departmentId, RmsRecordState.Accepted); - dashboard.IncidentRejected = await CountReportsAsync(departmentId, RmsRecordState.Rejected); + dashboard.IncidentIncomplete = await CountReportsAsync(departmentId, visible, userId, RmsRecordState.Draft, RmsRecordState.Returned); + dashboard.IncidentAwaitingReview = await CountReportsAsync(departmentId, visible, userId, RmsRecordState.ReadyForReview, RmsRecordState.Approved); + dashboard.IncidentSubmitted = await CountReportsAsync(departmentId, visible, userId, RmsRecordState.Submitted); + dashboard.IncidentAccepted = await CountReportsAsync(departmentId, visible, userId, RmsRecordState.Accepted); + dashboard.IncidentRejected = await CountReportsAsync(departmentId, visible, userId, RmsRecordState.Rejected); }); await SafeAsync(dashboard, "overdue obligations", async () => { - dashboard.Overdue = await _dueStates.CountOverdueAsync(departmentId); + dashboard.Overdue = await _dueStates.CountVisibleOverdueAsync(departmentId, visible, userId); }); await SafeAsync(dashboard, "incident analyses", async () => { - dashboard.AnalysesAwaitingFiling = await _analyses.CountByStateAsync(departmentId, RmsIncidentAnalysisState.Finalized); + dashboard.AnalysesAwaitingFiling = await _analyses.CountVisibleByStateAsync(departmentId, RmsIncidentAnalysisState.Finalized, visible, userId); }); await SafeAsync(dashboard, "disclosure requests", async () => { + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordDisclosures)) return; var open = 0; foreach (var state in new[] { RmsDisclosureState.Received, RmsDisclosureState.Scoping, RmsDisclosureState.InReview, RmsDisclosureState.Produced }) open += await _disclosures.CountByStateAsync(departmentId, state); @@ -83,6 +88,12 @@ await SafeAsync(dashboard, "disclosure requests", async () => dashboard.DisclosuresOverdue = await _disclosures.CountOverdueAsync(departmentId, now); }); + var currentScope = await _authorization.GetVisibleGroupIdsAsync(userId, departmentId); + if (!await _authorization.IsActiveMemberAsync(userId, departmentId) + || (visible == null) != (currentScope == null) || visible != null && !visible.ToHashSet().SetEquals(currentScope)) + throw new UnauthorizedAccessException("Record access changed while the dashboard was loading. Reload the dashboard."); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordDisclosures)) + { dashboard.DisclosuresOpen = 0; dashboard.DisclosuresOverdue = 0; } return dashboard; } @@ -152,11 +163,12 @@ public async Task GetCrosswalkCoverageAsync(int departme return coverage; } - private async Task CountReportsAsync(int departmentId, params RmsRecordState[] states) + private async Task CountReportsAsync(int departmentId, List visible, string userId, params RmsRecordState[] states) { return await _incidentReports.CountAsync(departmentId, new RmsIncidentReportQuery { States = states.Select(s => (int)s).ToList(), + VisibleGroupIds = visible, ViewerUserId = userId, Take = 1 }); } diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.Download.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.Download.cs new file mode 100644 index 000000000..f85b0c4c4 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.Download.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; + +namespace Resgrid.Services.Records +{ + public partial class RecordsDisclosureService + { + public async Task GetReviewAttachmentAsync(int departmentId, string userId, string requestId, string recordId, string revisionId, string attachmentId, string profile) + { + var review = await GetReviewAsync(departmentId, userId, requestId, profile); + var expected = review.Records.SingleOrDefault(r => r.RecordId == recordId && r.RevisionId == revisionId)?.Attachments.SingleOrDefault(a => a.AttachmentId == attachmentId); + if (expected == null) return null; + var file = await _attachments.GetHistoricalByIdForDepartmentAsync(departmentId, attachmentId); + if (file == null || file.RecordId != recordId || file.Checksum != expected.Checksum || file.Data == null || file.ScanState != (int)RmsAttachmentScanState.Clean || RecordSnapshotSerializer.Checksum(file.Data) != file.Checksum) return null; + if (file.RequiresRestrictedAccess && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + await RequireDisclosureAsync(departmentId, userId); + if (!await CanViewDisclosureRecordAsync(departmentId, userId, recordId, review.Records.Single(r => r.RecordId == recordId).RecordKind)) throw new UnauthorizedAccessException(); + await InTransactionAsync(() => AuditAsync(departmentId, userId, recordId, RmsAccessAuditAction.Read, "Disclosure attachment reviewed", new { requestId, revisionId, attachmentId, file.Checksum }, CancellationToken.None)); + return file; + } + public async Task DownloadAsync(int departmentId, string userId, string productionId, string format) + { + if (format != "pdf" && format != "zip" && format != "json") throw new ArgumentException("Choose PDF, ZIP, or JSON."); + var production = await GetAuthorizedProductionAsync(departmentId, userId, productionId) ?? throw new UnauthorizedAccessException(); + var artifact = JObject.Parse(production.ArtifactJson); var name = "disclosure-" + production.ProductionNumber; + byte[] bytes; string contentType; + if (format == "json") { bytes = Encoding.UTF8.GetBytes(production.ArtifactJson); contentType = "application/json"; } + else + { + if ((string)artifact["format"] != "resgrid.disclosure.v2" || (string)artifact["pdf_base64"] == null) throw new InvalidOperationException("This legacy production has a JSON artifact only; its original contents remain available as JSON."); + var pdf = Convert.FromBase64String((string)artifact["pdf_base64"]); + if (RecordSnapshotSerializer.Checksum(pdf) != (string)artifact["pdf_checksum"]) throw new InvalidOperationException("The packet PDF failed its integrity check."); + if (format == "pdf") { bytes = pdf; contentType = "application/pdf"; } + else + { + using var output = new MemoryStream(); + using (var zip = new ZipArchive(output, ZipArchiveMode.Create, true)) + { + void Add(string path, byte[] data) { var entry = zip.CreateEntry(path, CompressionLevel.Fastest); entry.LastWriteTime = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); using var stream = entry.Open(); stream.Write(data, 0, data.Length); } + Add("packet.pdf", pdf); artifact.Remove("pdf_base64"); var index = 0; + foreach (var file in (artifact["attachments"] as JArray ?? new JArray()).OfType()) + { + var data = Convert.FromBase64String((string)file["data_base64"]); + if (RecordSnapshotSerializer.Checksum(data) != (string)file["checksum"]) throw new InvalidOperationException("A packet attachment failed its integrity check."); + var fileName = (string)file["name"] ?? "attachment"; + foreach (var invalid in Path.GetInvalidFileNameChars().Concat(new[] { '/', '\\' })) fileName = fileName.Replace(invalid, '_'); + var path = "attachments/" + (++index).ToString("D4") + "-" + fileName; + Add(path, data); file.Remove("data_base64"); file["packet_path"] = path; + } + Add("manifest.json", Encoding.UTF8.GetBytes(artifact.ToString(Formatting.Indented))); + } + bytes = output.ToArray(); contentType = "application/zip"; + } + } + // Download remains an officer action. Delivery to a requester is recorded separately on release. + if (await GetAuthorizedProductionAsync(departmentId, userId, productionId) == null) throw new UnauthorizedAccessException(); + await InTransactionAsync(() => AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Export, "Disclosure packet downloaded", new { productionId, production.Checksum, format }, CancellationToken.None)); + return new RmsDisclosureDownload { Data = bytes, ContentType = contentType, FileName = name + "." + format }; + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs new file mode 100644 index 000000000..46df078c7 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.Packet.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + public partial class RecordsDisclosureService + { + private readonly IRmsIncidentReportsRepository _reports; + private readonly IRmsIncidentAnalysesRepository _analyses; + private readonly IRecordsDocumentService _documents; + private readonly IRmsRecordAttachmentsRepository _attachments; + private readonly IPdfProvider _pdf; + private readonly IRecordAttachmentScanner _scanner; + private const int PacketRecordLimit = 64; + private const int PacketByteLimit = 64 * 1024 * 1024; + + public async Task PreviewScopeAsync(int departmentId, string userId, string requestId, int take = 200) + { + await RequireDisclosureAsync(departmentId, userId); + var request = await LoadAsync(departmentId, requestId); + var scope = ParseScope(request.ScopeQueryJson); var result = new RmsDisclosureScopePreview(); + if (scope == null) return result; + var limit = Math.Clamp(take, 1, 1000); + // Match the official revision's facts, never mutable amendment dates or Call associations. + // The bounded scan fails visibly if it cannot account for the complete scope. + async Task Add(string id, string number, string definition, string revisionId, JObject header, bool deleted, RmsRecordKind kind = RmsRecordKind.Operational) + { + if (deleted || scope.DefinitionKey != null && scope.DefinitionKey != definition) return; + if (revisionId != null) + { + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); + if (revision == null || revision.RecordId != id || revision.RecordKind != (int)kind || RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("A disclosure source failed its revision integrity check."); + var saved = JObject.Parse(revision.SnapshotJson); header = kind != RmsRecordKind.Operational ? (JObject)saved["Report"] ?? header : saved; + } + var occurred = (DateTime?)new[] { header?["StartedOn"], header?["CallCreatedOn"], header?["CreatedOn"] }.FirstOrDefault(t => t != null && t.Type != JTokenType.Null); + if (scope.Year.HasValue && occurred?.Year != scope.Year.Value || scope.CallId.HasValue && (int?)header?["CallId"] != scope.CallId || scope.StationGroupId.HasValue && (int?)header?["StationGroupId"] != scope.StationGroupId) return; + if (!await CanViewDisclosureRecordAsync(departmentId, userId, id, kind)) { result.WithheldWholeRecordCount++; return; } + result.MatchedCount++; + if (result.Items.Count >= limit) { result.Truncated = true; return; } + result.Items.Add(new RmsDisclosureScopeItem { RecordId = id, RecordKind = kind, RecordNumber = number, DefinitionKey = definition, + Summary = (string)header?["DisplaySummary"] ?? (string)header?["Details"]?["Type"], OccurredOn = occurred, CurrentRevisionId = revisionId, + Producible = revisionId != null, NotProducibleReason = revisionId == null ? "Automatic production requires a saved revision. Record how unfinished records will be reviewed separately." : null }); + if (revisionId != null) result.ProducibleCount++; + } + if (scope.DefinitionKey != RmsDefinitionKeys.NerisIncidentReport) + for (var skip = 0; skip < 10000; skip += 250) + { + var page = (await _records.GetByDepartmentAndStatesAsync(departmentId, scope.States, null, skip, 250))?.ToList() ?? new List(); + foreach (var r in page) await Add(r.RmsOperationalRecordId, r.RecordNumber ?? r.DraftReference, r.DefinitionKey, r.CurrentRevisionId, JObject.FromObject(r), r.PurgedOn.HasValue || r.DeletedOn.HasValue); + if (page.Count < 250 || result.Truncated) break; + if (skip == 9750) result.Truncated = true; + } + if (scope.DefinitionKey == null || scope.DefinitionKey == RmsDefinitionKeys.NerisIncidentReport) + for (var skip = 0; skip < 10000; skip += 250) + { + var page = (await _reports.QueryAsync(departmentId, new RmsIncidentReportQuery { States = scope.States, Skip = skip, Take = 250 }))?.ToList() ?? new List(); + foreach (var r in page) + { + var deleted = r.PurgedOn.HasValue || r.DeletedOn.HasValue; + await Add(r.RmsIncidentReportId, r.RecordNumber ?? r.DraftReference, RmsDefinitionKeys.NerisIncidentReport, r.CurrentRevisionId, JObject.FromObject(r), deleted, RmsRecordKind.IncidentReport); + if (deleted) continue; + var analysis = await _analyses.GetForReportAsync(departmentId, r.RmsIncidentReportId); + if (analysis == null || analysis.DeletedOn.HasValue) continue; + var parentHeader = JObject.FromObject(r); + if (r.CurrentRevisionId != null) parentHeader = (JObject)JObject.Parse((await _revisions.GetByIdForDepartmentAsync(departmentId, r.CurrentRevisionId)).SnapshotJson)["Report"] ?? parentHeader; + await Add(analysis.RmsIncidentAnalysisId, (r.RecordNumber ?? r.DraftReference) + " · analysis", RmsDefinitionKeys.NerisIncidentReport, analysis.CurrentRevisionId, parentHeader, false, RmsRecordKind.IncidentAnalysis); + } + if (page.Count < 250 || result.Truncated) break; + if (skip == 9750) result.Truncated = true; + } + await RequireDisclosureAsync(departmentId, userId); + foreach (var item in result.Items) if (!await CanViewDisclosureRecordAsync(departmentId, userId, item.RecordId, item.RecordKind)) throw new UnauthorizedAccessException("Record access changed during scope review."); + return result; + } + + public async Task GetReviewAsync(int departmentId, string userId, string requestId, string redactionProfile = null) + { + await RequireDisclosureAsync(departmentId, userId); + var restricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); + var request = await LoadAsync(departmentId, requestId); RequireOpen(request); + var profile = Profile(redactionProfile, request); var preview = await PreviewScopeAsync(departmentId, userId, requestId, 1000); + if (preview.Truncated || preview.ProducibleCount > PacketRecordLimit) throw new InvalidOperationException("The disclosure scope exceeds the packet limit. Narrow the scope or track supplemental requests; no partial packet was created."); + var review = new RmsDisclosureReview { RequestId = requestId, Profile = profile, ScopeChecksum = ScopeChecksum(request, preview) }; + var visibilityRequired=0; + foreach (var item in preview.Items.Where(i => i.Producible)) + { + var doc = await _documents.GetAsync(departmentId, userId, item.RecordId, item.RecordKind, item.CurrentRevisionId) ?? throw new InvalidOperationException("A reviewed revision is unavailable."); + var hidden = new List(doc.WithheldFields); var content = PrepareDisclosure(doc, profile, hidden); + visibilityRequired=Math.Max(visibilityRequired, RequiredUdfVisibility(content)); + review.Records.Add(new RmsDisclosureRecordReview { RecordId = item.RecordId, RecordKind = item.RecordKind, RecordNumber = item.RecordNumber, RevisionId = doc.RevisionId, + RevisionChecksum = doc.OriginalChecksum, ContentChecksum = RecordSnapshotSerializer.Checksum(content.ToString(Formatting.None)), AutomaticWithholds = hidden.Distinct().ToList(), Fields = DisclosureContentPolicy.Fields(content).Where(f => !f.Path.StartsWith("/Attachments/", StringComparison.Ordinal)).ToList(), + Attachments = AttachmentManifest(content).Select(a => new RmsDisclosureAttachmentDecision { AttachmentId = (string)a["RmsRecordAttachmentId"], FileName = (string)a["FileName"], Checksum = (string)a["Checksum"], Metadata = DisclosureContentPolicy.Fields(a) }).ToList() }); + } + var finalPreview = await PreviewScopeAsync(departmentId, userId, requestId, 1000); + if (ScopeChecksum(await LoadAsync(departmentId, requestId), finalPreview) != review.ScopeChecksum) throw new InvalidOperationException("The disclosure scope changed during review. Reload it."); + if (restricted != await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException("Restricted access changed during review."); + await RequireDisclosureAsync(departmentId, userId); + if (visibilityRequired>0 && await _udf.GetVisibilityLevelAsync(departmentId,userId) ProduceAsync(int departmentId, string userId, string requestId, string redactionProfile = null, CancellationToken cancellationToken = default, RmsDisclosureReview review = null) + { + await RequireDisclosureAsync(departmentId, userId); + if (review?.Reviewed != true || string.IsNullOrWhiteSpace(review.Authority) || string.IsNullOrWhiteSpace(review.Basis)) throw new ArgumentException("Complete the officer review and record the applicable authority and disclosure basis before producing a packet."); + var request = await LoadAsync(departmentId, requestId); RequireOpen(request); + var profile = Profile(redactionProfile, request); + var current = await GetReviewAsync(departmentId, userId, requestId, profile); + if (review.RequestId != requestId || review.Profile != profile || review.ScopeChecksum != current.ScopeChecksum) throw new InvalidOperationException("The scope or access changed. Reload the review."); + var preview = await PreviewScopeAsync(departmentId, userId, requestId, 1000); + if ((preview.WithheldWholeRecordCount > 0 || preview.Items.Any(i => !i.Producible)) && string.IsNullOrWhiteSpace(review.UnresolvedScopeHandling)) throw new ArgumentException("Record how unfinished or inaccessible scope items will be resolved; they cannot be silently omitted."); + if (current.Records.Count == 0) throw new InvalidOperationException("Nothing in scope has a saved revision to produce from."); + if (review.Records == null || review.Records.Count != current.Records.Count || review.Records.Select(r => r.RecordId).Distinct().Count() != review.Records.Count) throw new ArgumentException("Review every record in scope exactly once."); + var withheld = new List(); var produced = new JArray(); var documents = new JArray(); var files = new JArray(); + long bytesTotal = 0; var visibilityRequired=0; var restricted = profile == RmsRedactionProfiles.FullDisclosure && await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); + foreach (var expected in current.Records) + { + cancellationToken.ThrowIfCancellationRequested(); + var decision = review.Records.SingleOrDefault(r => r.RecordId == expected.RecordId && r.RecordKind == expected.RecordKind); + if (decision == null || decision.RevisionId != expected.RevisionId || decision.RevisionChecksum != expected.RevisionChecksum || decision.ContentChecksum != expected.ContentChecksum) throw new InvalidOperationException("A reviewed record or access changed. Reload the review."); + var doc = await _documents.GetAsync(departmentId, userId, expected.RecordId, expected.RecordKind, expected.RevisionId) ?? throw new UnauthorizedAccessException(); + var hidden = new List(doc.WithheldFields); var content = PrepareDisclosure(doc, profile, hidden); + if (RecordSnapshotSerializer.Checksum(content.ToString(Formatting.None)) != expected.ContentChecksum) throw new UnauthorizedAccessException("Record access changed during production."); + if (decision.WithholdWhole) + { + RequireBasis(decision.Authority, decision.Basis); + withheld.Add(new RmsRedactionEntry { RecordId = expected.RecordId, Section = "Record", Field = "*", Authority = decision.Authority.Trim(), Basis = decision.Basis.Trim() }); + content = new JObject { ["Withheld"] = true }; + } + else + { + foreach (var field in hidden.Distinct()) withheld.Add(new RmsRedactionEntry { RecordId = expected.RecordId, Section = "Access/profile", Field = field, Authority = review.Authority.Trim(), Basis = review.Basis.Trim() }); + visibilityRequired=Math.Max(visibilityRequired, RequiredUdfVisibility(content)); + var manifest = AttachmentManifest(content).ToList(); + if (decision.Attachments == null || decision.Attachments.Count != manifest.Count || decision.Attachments.Select(a => a.AttachmentId).Distinct().Count() != manifest.Count) throw new ArgumentException("Review every attachment exactly once."); + foreach (var metadata in manifest) + { + var id = (string)metadata["RmsRecordAttachmentId"]; var fileDecision = decision.Attachments.SingleOrDefault(a => a.AttachmentId == id); + if (fileDecision?.Reviewed != true || fileDecision.Checksum != (string)metadata["Checksum"]) throw new ArgumentException("Review the exact attachment contents before including or withholding it."); + if (fileDecision.Derivative != null && !fileDecision.Include) throw new ArgumentException("Select include to release a reviewed redacted replacement."); + if (!fileDecision.Include) + { + RequireBasis(fileDecision.Authority, fileDecision.Basis); + withheld.Add(new RmsRedactionEntry { RecordId = expected.RecordId, Section = "Attachment", Field = id, Authority = fileDecision.Authority.Trim(), Basis = fileDecision.Basis.Trim() }); + metadata.Replace(new JObject { ["Withheld"] = true }); continue; + } + var file = await _attachments.GetHistoricalByIdForDepartmentAsync(departmentId, id); + if (file == null || file.RecordId != expected.RecordId || file.Checksum != fileDecision.Checksum || file.ScanState != (int)RmsAttachmentScanState.Clean || file.Data == null || RecordSnapshotSerializer.Checksum(file.Data) != file.Checksum) throw new InvalidOperationException("A reviewed attachment is unavailable, changed, or has not passed scanning."); + if (file.RequiresRestrictedAccess && !restricted) throw new UnauthorizedAccessException(); + byte[] releasedBytes = file.Data; var releasedName = (string)metadata["FileName"]; var releasedType = (string)metadata["ContentType"]; var releasedChecksum = file.Checksum; + if (fileDecision.Derivative != null) + { + RequireBasis(fileDecision.Authority, fileDecision.Basis); + var derivative = fileDecision.Derivative; + if (derivative.Data == null || string.IsNullOrWhiteSpace(derivative.Checksum) || RecordSnapshotSerializer.Checksum(derivative.Data) != derivative.Checksum) throw new ArgumentException("The reviewed replacement file checksum does not match."); + var clean = RecordAttachmentHygiene.Sanitize(derivative.FileName, derivative.ContentType, derivative.Data); + var scan = await _scanner.ScanAsync(clean.FileName, clean.ContentType, clean.Data, cancellationToken); + if (scan?.State != RmsAttachmentScanState.Clean) throw new ArgumentException("The replacement file must pass scanning before production."); + releasedBytes = clean.Data; releasedName = clean.FileName; releasedType = clean.ContentType; releasedChecksum = RecordSnapshotSerializer.Checksum(clean.Data); + // Replace original metadata as well; filename/description/author can contain withheld information. + metadata.Replace(JObject.FromObject(new { SourceAttachmentId = id, SourceChecksum = file.Checksum, FileName = releasedName, ContentType = releasedType, Checksum = releasedChecksum, ByteSize = clean.Data.LongLength, RedactedReplacement = true, ReviewedInputChecksum = derivative.Checksum, ReviewedByUserId = userId })); + withheld.Add(new RmsRedactionEntry { RecordId = expected.RecordId, Section = "Attachment replacement", Field = id, Authority = fileDecision.Authority.Trim(), Basis = fileDecision.Basis.Trim() }); + } + bytesTotal += releasedBytes.LongLength; if (bytesTotal > PacketByteLimit) throw new InvalidOperationException("The packet exceeds the 64 MB content limit. Track a supplemental production."); + files.Add(JObject.FromObject(new { record_id = expected.RecordId, attachment_id = id, name = releasedName, checksum = releasedChecksum, source_checksum = file.Checksum, redacted_replacement = fileDecision.Derivative != null, content_type = releasedType, data_base64 = Convert.ToBase64String(releasedBytes) })); + } + if ((decision.Decisions ?? new List()).Any(d => d.Withhold && (d.Path == "/Attachments" || d.Path?.StartsWith("/Attachments/", StringComparison.Ordinal) == true))) throw new ArgumentException("Use the attachment decision to include or withhold a complete file and its metadata."); + DisclosureContentPolicy.Apply(content, expected.RecordId, decision.Decisions, withheld); + } + produced.Add(JObject.FromObject(new { record_id = expected.RecordId, record_kind = (int)expected.RecordKind, record_number = expected.RecordNumber, revision_id = doc.RevisionId, revision_number = doc.RevisionNumber, revision_checksum = doc.OriginalChecksum })); + documents.Add(new JObject { ["record_id"] = expected.RecordId, ["content"] = content }); + } + var now = DateTime.UtcNow; + var pdf = _pdf.ConvertHtmlToPdf(PacketHtml(request, produced, documents, withheld, now), "Letter"); + if (pdf == null || pdf.Length < 4 || Encoding.ASCII.GetString(pdf, 0, 4) != "%PDF") throw new InvalidOperationException("The PDF provider did not produce a valid packet."); + var artifact = new JObject { ["format"] = "resgrid.disclosure.v2", ["request_number"] = request.RequestNumber, ["jurisdiction_profile"] = request.JurisdictionProfile, + ["redaction_profile"] = profile, ["restricted_content_included"] = restricted, ["produced_on"] = now, + ["udf_visibility_required"] = visibilityRequired, + ["authority"] = review.Authority.Trim(), ["basis"] = review.Basis.Trim(), ["unresolved_scope_handling"] = review.UnresolvedScopeHandling, + ["scope_fully_resolved"] = preview.WithheldWholeRecordCount == 0 && preview.Items.All(i => i.Producible), + ["manifest"] = new JArray(produced.Select((p, i) => new JObject { ["record_order"] = i + 1, ["record"] = p.DeepClone() })), ["documents"] = documents, ["attachments"] = files, + ["redactions"] = JArray.FromObject(withheld), ["pdf_checksum"] = RecordSnapshotSerializer.Checksum(pdf), ["pdf_base64"] = Convert.ToBase64String(pdf) }; + var json = artifact.ToString(Formatting.None); if (Encoding.UTF8.GetByteCount(json) > PacketByteLimit * 2) throw new InvalidOperationException("The packet exceeds the stored artifact limit."); + var production = new RmsDisclosureProduction { RmsDisclosureProductionId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), DisclosureRequestId = requestId, + RedactionProfile = profile, ProducedSetJson = produced.ToString(Formatting.None), ArtifactJson = json, Checksum = RecordSnapshotSerializer.Checksum(json), ByteSize = Encoding.UTF8.GetByteCount(json), + RecordCount = produced.Count, WithheldFieldsJson = JsonConvert.SerializeObject(withheld), WithheldFieldCount = withheld.Count, PreparedByUserId = userId, PreparedOn = now, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + await InTransactionAsync(async () => + { + await RequireDisclosureAsync(departmentId, userId); + if (restricted && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + var finalReview = await GetReviewAsync(departmentId, userId, requestId, profile); + if (finalReview.ScopeChecksum != current.ScopeChecksum || finalReview.Records.Any(r => !current.Records.Any(c => c.RecordId == r.RecordId && c.RevisionId == r.RevisionId && c.ContentChecksum == r.ContentChecksum))) throw new InvalidOperationException("The scope or access changed during production. Reload the review."); + await GuardRequestAsync(request, request.RowVersion, cancellationToken); + production.ProductionNumber = await _productions.GetMaxProductionNumberAsync(departmentId, requestId) + 1; + await _productions.InsertAsync(production, cancellationToken, true); + request.State = (int)RmsDisclosureState.Produced; request.ModifiedOn = now; request.ModifiedByUserId = userId; request.RowVersion++; + await _requests.UpdateAsync(request, cancellationToken, true); + foreach (var record in current.Records) await AuditAsync(departmentId, userId, record.RecordId, RmsAccessAuditAction.Export, "Disclosure production reviewed", new { production.RmsDisclosureProductionId, production.Checksum }, cancellationToken); + }); + return production; + } + + private async Task RequireDisclosureAsync(int departmentId, string userId) + { if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordDisclosures)) throw new UnauthorizedAccessException(); } + private static int RequiredUdfVisibility(JObject content) => ((content["CustomFields"] as JObject)?["Fields"] as JArray ?? new JArray()).OfType().Select(f=>(int?)(f["Field"] as JObject)?["Visibility"] ?? 3).DefaultIfEmpty(0).Max(); + private async Task CanViewDisclosureRecordAsync(int departmentId, string userId, string id, RmsRecordKind kind) + { + if (kind == RmsRecordKind.IncidentAnalysis) + { + var analysis = await _analyses.GetByIdForDepartmentAsync(departmentId, id); + if (analysis == null || analysis.DeletedOn.HasValue) return false; + id = analysis.IncidentReportId; + } + return await _authorization.CanUserViewRecordAsync(userId, id, departmentId); + } + private static void RequireBasis(string authority, string basis) + { if (string.IsNullOrWhiteSpace(authority) || string.IsNullOrWhiteSpace(basis)) throw new ArgumentException("Record the applicable authority and case-specific reason for withholding."); } + private static string Profile(string value, RmsDisclosureRequest request) + { + var profile = Blank(value) ?? request.RedactionProfile ?? RmsRedactionProfiles.Standard; + if (profile != RmsRedactionProfiles.Standard && profile != RmsRedactionProfiles.FullDisclosure && profile != RmsRedactionProfiles.NoPersonalIdentifiers) throw new ArgumentException("Unknown redaction profile."); + return profile; + } + private static string ScopeChecksum(RmsDisclosureRequest request, RmsDisclosureScopePreview preview) => RecordSnapshotSerializer.Checksum(JsonConvert.SerializeObject(new { request.ScopeQueryJson, request.RowVersion, preview.WithheldWholeRecordCount, Items = preview.Items.OrderBy(i => i.RecordId) })); + private static IEnumerable AttachmentManifest(JObject content) => (content["Attachments"] as JArray ?? new JArray()).OfType().Where(a => (string)a["RmsRecordAttachmentId"] != null); + private static JObject PrepareDisclosure(RecordDocument doc, string profile, List hidden) + { + var content = JObject.Parse(doc.ContentJson); + content["RevisionAttestation"] = JObject.FromObject(new { doc.RevisionNumber, SavedOn = doc.FinalizedOn, RecordedBy = doc.AttestedBy, StatementVersion = doc.AttestationVersion }); + if (profile != RmsRedactionProfiles.FullDisclosure) RecordsDocumentService.Project(content, false, hidden); + if (profile == RmsRedactionProfiles.NoPersonalIdentifiers) + { + foreach (var p in content.Descendants().OfType().Where(p => new[] { "Participants", "AuthorUserId", "PersonnelUserId", "ContactName", "ContactNumber", "InvestigatedByUserId", "Instructors", "Facilitator", "OtherPersonnel", "RecordedBy" }.Contains(p.Name)).ToList()) + { hidden.Add(p.Path); p.Remove(); } + } + return DisclosureContentPolicy.Prepare(content); + } + private static string PacketHtml(RmsDisclosureRequest request, JArray produced, JArray documents, List withheld, DateTime now) + { + string E(string value) => WebUtility.HtmlEncode(value ?? ""); + var html = new StringBuilder("Records disclosure

Records disclosure "); + html.Append(E(request.RequestNumber)).Append("

").Append(E(request.JurisdictionProfile)).Append(" · Prepared ").Append(now.ToString("u")).Append("

Contents

    "); + foreach (var item in produced) html.Append("
  1. ").Append(E((string)item["record_number"])).Append(" · revision ").Append((int)item["revision_number"]).Append("
  2. "); + html.Append("

Attachments are separate files in the packet. The manifest records each file and checksum.

"); + for (var i = 0; i < documents.Count; i++) + { + html.Append("

Record ").Append(i + 1).Append(" · ").Append(E((string)produced[i]["record_number"])).Append("

Revision checksum ").Append(E((string)produced[i]["revision_checksum"])).Append("

"); + RecordsDocumentService.RenderSections(html, (JObject)documents[i]["content"]); html.Append("
"); + } + html.Append("

Withholding log

"); + foreach (var entry in withheld) html.Append(""); + return html.Append("
Record / fieldAuthority and reason
").Append(E(entry.RecordId + " / " + entry.Field)).Append("").Append(E(entry.Authority + ": " + entry.Basis)).Append("
").ToString(); + } + } +} diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs index 594257002..b0c195063 100644 --- a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs @@ -5,8 +5,10 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; @@ -23,7 +25,7 @@ namespace Resgrid.Services.Records /// catalog will consume, so this is built once and enrolls cleanly when Protected Data lands (plan 5.9). /// ///
- public class RecordsDisclosureService : IRecordsDisclosureService + public partial class RecordsDisclosureService : IRecordsDisclosureService { public const string NumberPrefix = "PRR-"; @@ -33,12 +35,14 @@ public class RecordsDisclosureService : IRecordsDisclosureService private readonly IRmsRevisionsRepository _revisions; private readonly IRmsAccessAuditsRepository _audits; private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsUdfService _udf; private readonly IDepartmentSettingsService _settings; private readonly IUnitOfWork _unitOfWork; public RecordsDisclosureService(IRmsDisclosureRequestsRepository requests, IRmsDisclosureProductionsRepository productions, IRmsOperationalRecordsRepository records, IRmsRevisionsRepository revisions, IRmsAccessAuditsRepository audits, - IRecordsAuthorizationService authorization, IDepartmentSettingsService settings, IUnitOfWork unitOfWork) + IRecordsAuthorizationService authorization, IDepartmentSettingsService settings, IUnitOfWork unitOfWork, + IRmsIncidentReportsRepository reports, IRecordsDocumentService documents, IRmsRecordAttachmentsRepository attachments, Resgrid.Model.Providers.IPdfProvider pdf, IRmsIncidentAnalysesRepository analyses, IRecordAttachmentScanner scanner, IRecordsUdfService udf) { _requests = requests; _productions = productions; @@ -46,12 +50,15 @@ public RecordsDisclosureService(IRmsDisclosureRequestsRepository requests, IRmsD _revisions = revisions; _audits = audits; _authorization = authorization; + _udf = udf; _settings = settings; _unitOfWork = unitOfWork; + _reports = reports; _documents = documents; _attachments = attachments; _pdf = pdf; _analyses = analyses; _scanner = scanner; } public async Task CreateRequestAsync(int departmentId, string userId, RmsDisclosureRequest request, CancellationToken cancellationToken = default) { + await RequireDisclosureAsync(departmentId, userId); if (request == null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(request.RequesterName)) throw new ArgumentException("A requester is required.", nameof(request)); @@ -85,19 +92,36 @@ await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclo return request; } - public Task GetAsync(int departmentId, string requestId) + public async Task GetAsync(int departmentId, string userId, string requestId) { - return _requests.GetByIdForDepartmentAsync(departmentId, requestId); + await RequireDisclosureAsync(departmentId, userId); + var row = await _requests.GetByIdForDepartmentAsync(departmentId, requestId); + var restricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); + await RequireDisclosureAsync(departmentId, userId); + return row?.DeletedOn == null ? ProjectRequest(row, restricted) : null; } - public async Task> QueryAsync(int departmentId, IEnumerable states, int skip = 0, int take = 50) + public async Task> QueryAsync(int departmentId, string userId, IEnumerable states, int skip = 0, int take = 50) { + await RequireDisclosureAsync(departmentId, userId); var stateValues = states?.Select(s => (int)s).ToList(); - return (await _requests.GetForDepartmentAsync(departmentId, stateValues, skip, take))?.ToList() ?? new List(); + var rows = (await _requests.GetForDepartmentAsync(departmentId, stateValues, skip, take))?.ToList() ?? new List(); + var restricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); + await RequireDisclosureAsync(departmentId, userId); + return rows.Select(r => ProjectRequest(r, restricted)).ToList(); + } + private static RmsDisclosureRequest ProjectRequest(RmsDisclosureRequest row, bool restricted) + { + if (row == null) return null; + var copy = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(row)); + if (!restricted) { copy.RequesterName = null; copy.RequesterOrganization = null; copy.RequesterContact = null; } + return copy; } public async Task SaveScopeAsync(int departmentId, string userId, string requestId, string scopeNarrative, RmsRecordQuery scope, string redactionProfile, CancellationToken cancellationToken = default) { + await RequireDisclosureAsync(departmentId, userId); + if (scope?.IncludeLegacy == true) throw new ArgumentException("Legacy Logs require a separately recorded review; this packet scope contains RMS records only."); var request = await LoadAsync(departmentId, requestId); RequireOpen(request); @@ -118,6 +142,7 @@ public async Task SaveScopeAsync(int departmentId, string await InTransactionAsync(async () => { + await GuardRequestAsync(request, request.RowVersion - 1, cancellationToken); await _requests.UpdateAsync(request, cancellationToken, true); await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclosure scope saved", new { requestId, request.RedactionProfile }, cancellationToken); }); @@ -125,205 +150,106 @@ await InTransactionAsync(async () => return request; } - public async Task PreviewScopeAsync(int departmentId, string userId, string requestId, int take = 200) - { - var request = await LoadAsync(departmentId, requestId); - var preview = new RmsDisclosureScopePreview(); - - var scope = ParseScope(request.ScopeQueryJson); - if (scope == null) - return preview; - - // The same authorization and group-scope path as the Records queue. A disclosure officer does not get - // a wider view of the department than they have anywhere else in the product. - scope.VisibleGroupIds = await _authorization.GetVisibleGroupIdsAsync(userId, departmentId); - scope.ViewerUserId = userId; - scope.Skip = 0; - scope.Take = Math.Clamp(take, 1, 1000); - - var matched = (await _records.GetByDepartmentAndStatesAsync(departmentId, scope.States, scope.Year, scope.Skip, scope.Take + 1))?.ToList() - ?? new List(); - - preview.Truncated = matched.Count > scope.Take; - foreach (var record in matched.Take(scope.Take)) - { - if (!string.IsNullOrWhiteSpace(scope.DefinitionKey) && !string.Equals(record.DefinitionKey, scope.DefinitionKey, StringComparison.Ordinal)) - continue; - - preview.MatchedCount++; - - // Group scoping still applies to a disclosure preview; an officer outside the group sees the same - // nothing they would see in the queue. - if (scope.VisibleGroupIds != null && !await _authorization.CanUserViewRecordAsync(userId, record.RmsOperationalRecordId, departmentId)) - { - preview.WithheldWholeRecordCount++; - continue; - } - - var producible = IsProducible(record, out var reason); - if (producible) - preview.ProducibleCount++; - - preview.Items.Add(new RmsDisclosureScopeItem - { - RecordId = record.RmsOperationalRecordId, - RecordNumber = record.RecordNumber ?? record.DraftReference, - DefinitionKey = record.DefinitionKey, - Summary = record.DisplaySummary, - OccurredOn = record.StartedOn ?? record.CreatedOn, - CurrentRevisionId = record.CurrentRevisionId, - Producible = producible, - NotProducibleReason = reason - }); - } - - return preview; - } - - public async Task ProduceAsync(int departmentId, string userId, string requestId, string redactionProfile = null, CancellationToken cancellationToken = default) - { - var request = await LoadAsync(departmentId, requestId); - RequireOpen(request); - - var profile = Blank(redactionProfile) ?? request.RedactionProfile ?? RmsRedactionProfiles.Standard; - var preview = await PreviewScopeAsync(departmentId, userId, requestId, 1000); - var producible = preview.Items.Where(i => i.Producible).ToList(); - if (producible.Count == 0) - throw new InvalidOperationException("The scope resolves to nothing that can be produced; a draft record is not a public record."); - - var withheld = new List(); - var produced = new List(); - var documents = new List(); - - foreach (var item in producible) - { - cancellationToken.ThrowIfCancellationRequested(); - - var revision = string.IsNullOrWhiteSpace(item.CurrentRevisionId) - ? null - : await _revisions.GetByIdForDepartmentAsync(departmentId, item.CurrentRevisionId); - - if (revision == null || string.IsNullOrWhiteSpace(revision.SnapshotJson)) - { - withheld.Add(new RmsRedactionEntry { RecordId = item.RecordId, Section = "Record", Field = "*", Basis = "No finalized revision to produce from." }); - continue; - } - - var snapshot = RecordSnapshotSerializer.Deserialize(revision.SnapshotJson); - documents.Add(Redact(snapshot, item, profile, withheld)); - - // The produced set is the point: exactly which revision, and its checksum at release time. - produced.Add(new - { - record_id = item.RecordId, - record_number = item.RecordNumber, - revision_id = revision.RmsRevisionId, - revision_number = revision.RevisionNumber, - revision_checksum = revision.Checksum - }); - } - - if (documents.Count == 0) - throw new InvalidOperationException("Nothing in scope has a finalized revision to produce from."); - - var now = DateTime.UtcNow; - var artifactJson = RecordsEvidenceService.Serialize(new - { - request_number = request.RequestNumber, - jurisdiction_profile = request.JurisdictionProfile, - redaction_profile = profile, - produced_on = now, - // The manifest and page numbering the plan asks for; a packet a requester can navigate. - manifest = documents.Select((d, i) => new { page = i + 1, record = produced[i] }).ToList(), - documents - }); - - var production = new RmsDisclosureProduction - { - RmsDisclosureProductionId = Guid.NewGuid().ToString(), - DepartmentId = departmentId, - ProtectionId = Guid.NewGuid().ToString(), - DisclosureRequestId = requestId, - RedactionProfile = profile, - ProducedSetJson = RecordsEvidenceService.Serialize(produced), - ArtifactJson = artifactJson, - Checksum = RecordSnapshotSerializer.Checksum(artifactJson), - ByteSize = Encoding.UTF8.GetByteCount(artifactJson), - RecordCount = documents.Count, - WithheldFieldsJson = RecordsEvidenceService.Serialize(withheld), - WithheldFieldCount = withheld.Count, - PreparedByUserId = userId, - PreparedOn = now, - CreatedOn = now, - ModifiedOn = now, - RowVersion = 1 - }; - - await InTransactionAsync(async () => - { - production.ProductionNumber = await _productions.GetMaxProductionNumberAsync(departmentId, requestId) + 1; - await _productions.InsertAsync(production, cancellationToken, true); - - request.State = (int)RmsDisclosureState.Produced; - request.ModifiedOn = now; - request.ModifiedByUserId = userId; - request.RowVersion += 1; - await _requests.UpdateAsync(request, cancellationToken, true); - - // Every produced record is audited individually: "what did we hand out about this record" has to - // be answerable from the record, not only from the request. - foreach (var item in producible.Take(documents.Count)) - await AuditAsync(departmentId, userId, item.RecordId, RmsAccessAuditAction.Export, "Disclosure production " + request.RequestNumber, - new { production.RmsDisclosureProductionId, production.ProductionNumber, profile }, cancellationToken); - - await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Export, "Disclosure produced", - new { requestId, production.RmsDisclosureProductionId, production.RecordCount, production.WithheldFieldCount, production.Checksum }, cancellationToken); - }); - - return production; - } - - public async Task ReleaseAsync(int departmentId, string userId, string productionId, CancellationToken cancellationToken = default) + public async Task ReleaseAsync(int departmentId, string userId, string productionId, CancellationToken cancellationToken = default, string deliveryMethod = null, string deliveryReference = null) { - var production = await _productions.GetByIdForDepartmentAsync(departmentId, productionId) - ?? throw new InvalidOperationException("The production does not exist."); + var production = await GetAuthorizedProductionAsync(departmentId, userId, productionId) + ?? throw new UnauthorizedAccessException("The production is not accessible with your current permissions."); if (production.ReleasedOn.HasValue) throw new InvalidOperationException("The production has already been released."); + if (string.IsNullOrWhiteSpace(deliveryMethod) || string.IsNullOrWhiteSpace(deliveryReference)) throw new ArgumentException("Record how the packet was delivered and its receipt or delivery reference."); + if (deliveryMethod.Length > 200 || deliveryReference.Length > 1000) throw new ArgumentException("Delivery method is limited to 200 characters and reference to 1,000 characters."); var request = await LoadAsync(departmentId, production.DisclosureRequestId); + RequireOpen(request); + var unresolved = (bool?)JObject.Parse(production.ArtifactJson)["scope_fully_resolved"] == false; var now = DateTime.UtcNow; await InTransactionAsync(async () => { + await GuardRequestAsync(request, request.RowVersion, cancellationToken); + production = await GetAuthorizedProductionAsync(departmentId, userId, productionId) ?? throw new UnauthorizedAccessException(); + if (production.ReleasedOn.HasValue || !await _productions.TryReleaseAsync(departmentId, productionId, production.RowVersion, userId, now, deliveryMethod.Trim(), deliveryReference.Trim(), cancellationToken)) + throw new InvalidOperationException("The production has already been released or changed. Reload it before continuing."); + production = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(production)); production.ReleasedByUserId = userId; production.ReleasedOn = now; + production.DeliveryMethod = deliveryMethod.Trim(); production.DeliveryReference = deliveryReference.Trim(); production.ModifiedOn = now; production.RowVersion += 1; - await _productions.UpdateAsync(production, cancellationToken, true); - request.State = (int)RmsDisclosureState.Released; - request.ClosedOn = now; - request.ClosedByUserId = userId; + request.State = (int)(unresolved ? RmsDisclosureState.InReview : RmsDisclosureState.Released); + request.ClosedOn = unresolved ? null : now; + request.ClosedByUserId = unresolved ? null : userId; request.ModifiedOn = now; request.ModifiedByUserId = userId; request.RowVersion += 1; await _requests.UpdateAsync(request, cancellationToken, true); await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Share, "Disclosure released", - new { request.RmsDisclosureRequestId, request.RequestNumber, production.RmsDisclosureProductionId, production.Checksum }, cancellationToken); + new { request.RmsDisclosureRequestId, request.RequestNumber, production.RmsDisclosureProductionId, production.Checksum, deliveryMethod = deliveryMethod.Trim(), deliveryReference = deliveryReference.Trim(), unresolvedScope = unresolved }, cancellationToken); }); return production; } - public async Task> GetProductionsAsync(int departmentId, string requestId) + public async Task> GetProductionsAsync(int departmentId, string userId, string requestId) { - return (await _productions.GetForRequestAsync(departmentId, requestId))?.ToList() ?? new List(); + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordDisclosures)) return new List(); + var visible = new List(); + foreach (var row in (await _productions.GetForRequestAsync(departmentId, requestId)) ?? Enumerable.Empty()) + { + var authorized = await GetAuthorizedProductionAsync(departmentId, userId, row.RmsDisclosureProductionId); + if (authorized?.DisclosureRequestId == requestId) visible.Add(authorized); + } + // Reading a later packet can outlive the permissions used for an earlier one. Re-project the + // completed collection instead of returning objects authorized during source hydration. + var current = new List(); + foreach (var row in visible) + { + var authorized = await GetAuthorizedProductionAsync(departmentId, userId, row.RmsDisclosureProductionId); + if (authorized?.DisclosureRequestId == requestId) current.Add(authorized); + } + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordDisclosures)) return new List(); + return current; + } + + public async Task GetAuthorizedProductionAsync(int departmentId, string userId, string productionId) + { + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordDisclosures)) return null; + var production = await _productions.GetByIdForDepartmentAsync(departmentId, productionId); + if (production == null || production.DepartmentId != departmentId || string.IsNullOrEmpty(production.ArtifactJson) + || production.Checksum != RecordSnapshotSerializer.Checksum(production.ArtifactJson)) return null; + try + { + var artifact = JObject.Parse(production.ArtifactJson); + var produced = JArray.Parse(production.ProducedSetJson); + var manifest = artifact["manifest"] as JArray; + if (produced.Count == 0 || produced.Count != production.RecordCount || manifest == null || manifest.Count != produced.Count) return null; + var restricted = (bool?)artifact["restricted_content_included"] ?? production.RedactionProfile == RmsRedactionProfiles.FullDisclosure; + var visibilityRequired=(int?)artifact["udf_visibility_required"] ?? 0; + var containsUdf=(artifact["documents"] as JArray ?? new JArray()).OfType().Any(d=>(((d["content"] as JObject)?["CustomFields"] as JObject)?["Fields"] as JArray)?.Count>0); + if ((containsUdf || visibilityRequired>0) && await _udf.GetVisibilityLevelAsync(departmentId,userId)0) && await _udf.GetVisibilityLevelAsync(departmentId,userId) CloseAsync(int departmentId, string userId, string requestId, RmsDisclosureState disposition, string reason, CancellationToken cancellationToken = default) { + await RequireDisclosureAsync(departmentId, userId); if (disposition != RmsDisclosureState.Denied && disposition != RmsDisclosureState.Withdrawn && disposition != RmsDisclosureState.Closed) throw new ArgumentException("A request closes as denied, withdrawn or closed.", nameof(disposition)); if (string.IsNullOrWhiteSpace(reason)) @@ -343,6 +269,7 @@ public async Task CloseAsync(int departmentId, string user await InTransactionAsync(async () => { + await GuardRequestAsync(request, request.RowVersion - 1, cancellationToken); await _requests.UpdateAsync(request, cancellationToken, true); await AuditAsync(departmentId, userId, null, RmsAccessAuditAction.Admin, "Disclosure closed: " + disposition, new { requestId, reason = request.DispositionReason }, cancellationToken); }); @@ -361,83 +288,6 @@ public async Task VerifyProductionAsync(int departmentId, string productio // ── internals ──────────────────────────────────────────────────────────────── - /// - /// Redacts one revision snapshot for release. Restricted detail fields come out under the standard - /// profile; participant identity comes out as well under the no-identifiers profile. Withholding is - /// logged rather than silent, because a requester is entitled to know something was withheld even when - /// they are not entitled to the content. - /// - private static object Redact(RecordSnapshot snapshot, RmsDisclosureScopeItem item, string profile, List withheld) - { - var full = string.Equals(profile, RmsRedactionProfiles.FullDisclosure, StringComparison.Ordinal); - var hideIdentities = string.Equals(profile, RmsRedactionProfiles.NoPersonalIdentifiers, StringComparison.Ordinal); - - var details = new Dictionary(StringComparer.Ordinal); - foreach (var field in RecordSnapshotSerializer.DetailFieldOrder) - { - var value = ReadDetail(snapshot?.Details, field); - if (value == null) - continue; - - if (!full && RecordSnapshotSerializer.RestrictedDetailFields.Contains(field)) - { - withheld.Add(new RmsRedactionEntry { RecordId = item.RecordId, Section = "Details", Field = field, Basis = "Restricted class" }); - continue; - } - - details[field] = value; - } - - var participants = new List(); - foreach (var participant in snapshot?.Participants ?? new List()) - { - if (hideIdentities) - { - withheld.Add(new RmsRedactionEntry { RecordId = item.RecordId, Section = "Participants", Field = "Identity", Basis = "Personal identifiers withheld by profile" }); - continue; - } - - participants.Add(new { name = participant.DisplayNameSnapshot, role = participant.Role, group = participant.GroupNameSnapshot }); - } - - return new - { - record_id = item.RecordId, - record_number = item.RecordNumber, - definition_key = item.DefinitionKey, - occurred_on = item.OccurredOn, - summary = item.Summary, - details, - participants, - units = (snapshot?.Units ?? new List()).Select(u => new { unit = u.UnitNameSnapshot }).ToList() - }; - } - - private static string ReadDetail(RmsOperationalRecordDetail details, string field) - { - if (details == null) - return null; - - var property = typeof(RmsOperationalRecordDetail).GetProperty(field); - return property?.PropertyType == typeof(string) ? (string)property.GetValue(details) : null; - } - - /// A public record is a finalized one. Drafts and voided records are listed, never produced. - private static bool IsProducible(RmsOperationalRecord record, out string reason) - { - var state = (RmsRecordState)record.State; - if (state == RmsRecordState.Finalized || state == RmsRecordState.Amended) - { - reason = null; - return true; - } - - reason = state == RmsRecordState.Voided || state == RmsRecordState.Cancelled - ? "The record was voided or cancelled." - : "The record is not finalized; a draft is not a public record."; - return false; - } - /// /// A scope arriving from a client is never trusted with the viewer fields: those are set from the caller's /// own authorization, or a request could be scoped to see somebody else's groups. @@ -469,7 +319,7 @@ private async Task LoadAsync(int departmentId, string requ var request = await _requests.GetByIdForDepartmentAsync(departmentId, requestId); if (request == null || request.DeletedOn.HasValue) throw new InvalidOperationException("The disclosure request does not exist."); - return request; + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(request)); } private static void RequireOpen(RmsDisclosureRequest request) @@ -492,6 +342,8 @@ private async Task SafeConfigAsync(int departmentId) } private static string Blank(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private async Task GuardRequestAsync(RmsDisclosureRequest request, long expectedVersion, CancellationToken ct) + { if (!await _requests.TryBumpRowVersionAsync(request.DepartmentId, request.RmsDisclosureRequestId, expectedVersion, ct)) throw new InvalidOperationException("The disclosure request changed. Reload it before continuing."); } private Task AuditAsync(int departmentId, string userId, string recordId, RmsAccessAuditAction action, string purpose, object detail, CancellationToken cancellationToken) { diff --git a/Core/Resgrid.Services/Records/RecordsDocumentService.cs b/Core/Resgrid.Services/Records/RecordsDocumentService.cs new file mode 100644 index 000000000..66d976e80 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsDocumentService.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + /// Departmental copies use immutable content and current access. National payloads are a separate contract. + public sealed class RecordsDocumentService : IRecordsDocumentService + { + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsIncidentReportsRepository _reports; + private readonly IRmsIncidentAnalysesRepository _analyses; + private readonly IRmsRevisionsRepository _revisions; + private readonly IIncidentReportsService _incidents; + private readonly IDepartmentProfileMediaService _branding; + private readonly IRecordsPrintLayoutService _layouts; + private readonly IPdfProvider _pdf; + private readonly IRecordsEvidenceService _evidence; + private readonly IRecordsUdfService _udf; + public RecordsDocumentService(IRecordsAuthorizationService authorization, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository reports, + IRmsIncidentAnalysesRepository analyses, IRmsRevisionsRepository revisions, IIncidentReportsService incidents, + IDepartmentProfileMediaService branding, IRecordsPrintLayoutService layouts, IPdfProvider pdf, IRecordsEvidenceService evidence, IRecordsUdfService udf) + { _authorization = authorization; _records = records; _reports = reports; _analyses = analyses; _revisions = revisions; _incidents = incidents; _branding = branding; _layouts = layouts; _pdf = pdf; _evidence = evidence; _udf = udf; } + + public async Task GetAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, string revisionId = null, bool exporting = false) + { + string parentId = recordId, currentRevisionId = null, number = null; + if (kind == RmsRecordKind.IncidentAnalysis) + { + var analysis = await _analyses.GetByIdForDepartmentAsync(departmentId, recordId); + if (analysis == null || analysis.DeletedOn.HasValue) return null; + parentId = analysis.IncidentReportId; currentRevisionId = analysis.CurrentRevisionId; + } + if (!await _authorization.CanUserViewRecordAsync(userId, parentId, departmentId) + || exporting && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ExportRecords)) throw new UnauthorizedAccessException(); + if (kind == RmsRecordKind.Operational) + { + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); + if (record == null || record.DeletedOn.HasValue || record.PurgedOn.HasValue) return null; + currentRevisionId = record.CurrentRevisionId; number = record.RecordNumber ?? record.DraftReference; + } + else if (kind == RmsRecordKind.IncidentReport || kind == RmsRecordKind.IncidentAnalysis) + { + var report = await _reports.GetByIdForDepartmentAsync(departmentId, parentId); + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) return null; + if (kind == RmsRecordKind.IncidentReport) currentRevisionId = report.CurrentRevisionId; + number = report.RecordNumber ?? report.DraftReference; + } + else throw new ArgumentException("Unsupported record kind."); + revisionId ??= currentRevisionId; + if (string.IsNullOrWhiteSpace(revisionId)) return null; + var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); + if (revision == null || revision.RecordId != recordId || revision.RecordKind != (int)kind) return null; + if (RecordSnapshotSerializer.Checksum(revision.SnapshotJson) != revision.Checksum) throw new InvalidOperationException("The revision checksum does not match."); + JObject content; + if (kind == RmsRecordKind.IncidentReport) + { + var snapshot = await _incidents.BuildSnapshotAsync(departmentId, recordId, revisionId); + if (snapshot == null) return null; + content = JObject.FromObject(snapshot); + } + else content = JObject.Parse(revision.SnapshotJson); + if (kind == RmsRecordKind.Operational && ((int?)content["SnapshotVersion"] ?? 1) < 2) + content["Evidence"] = JArray.FromObject(await _evidence.GetForRecordAsync(departmentId, recordId, revisionId, true) ?? new List()); + var document = new RecordDocument { RecordId = recordId, RecordKind = kind, RecordNumber = number, RevisionId = revisionId, RevisionNumber = revision.RevisionNumber, + OriginalChecksum = revision.Checksum, FinalizedOn = revision.CreatedOn, AttestedBy = revision.ActorUserId, AttestationVersion = revision.AttestationStatementVersion }; + await ProjectCustomFieldsAsync(departmentId, userId, document.WithheldFields, content); + Project(content, await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords), document.WithheldFields); + document.ContentJson = content.ToString(Formatting.None); document.ContentChecksum = RecordSnapshotSerializer.Checksum(document.ContentJson); + if (!await _authorization.CanUserViewRecordAsync(userId, parentId, departmentId) + || exporting && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ExportRecords)) throw new UnauthorizedAccessException(); + return document; + } + + /// Clone before calling. Opaque restricted JSON is withheld as a whole, including all nested aliases. + public static void Project(JObject content, bool restricted, List withheld) + { + void Hide(JObject obj, string field) { var p = obj?.Property(field); if (p == null) return; if (p.Value.Type != JTokenType.Null) withheld.Add(p.Path); p.Remove(); } + if (!restricted) + { + foreach (var field in ((content["CustomFields"] as JObject)?["Fields"] as JArray ?? new JArray()).OfType().ToList()) + if ((int?)(field["Field"] as JObject)?["RmsClassification"] != 0) { withheld.Add(field.Path); field.Remove(); } + foreach (var field in RecordSnapshotSerializer.RestrictedDetailFields) Hide(content["Details"] as JObject, field); + foreach (var casualty in (content["Casualties"] as JArray ?? new JArray()).OfType()) + foreach (var field in new[] { "PersonnelUserId", "Rank", "BirthMonthYear", "Gender", "Race", "InjuryDetailJson", "DetailJson" }) Hide(casualty, field); + foreach (var vehicle in (content["Vehicles"] as JArray ?? new JArray()).OfType()) + foreach (var field in new[] { "Vin", "LicensePlate", "LicenseState", "DetailJson" }) Hide(vehicle, field); + foreach (var evidence in (content["Evidence"] as JArray ?? new JArray()).OfType().ToList()) + if ((int?)evidence["Classification"] != (int)RmsEvidenceClassification.Unrestricted) { withheld.Add(evidence.Path); evidence.Replace(new JObject { ["Withheld"] = true }); } + foreach (var attachment in (content["Attachments"] as JArray ?? new JArray()).OfType().ToList()) + if ((bool?)attachment["IsProtected"] == true || (int?)attachment["Classification"] != (int)RmsEvidenceClassification.Unrestricted) { withheld.Add(attachment.Path); attachment.Replace(new JObject { ["Withheld"] = true }); } + } + // These are storage/runtime fields, not authored departmental content. + foreach (var property in content.Descendants().OfType().Where(p => new[] { "Data", "StorageReference", "ProtectionId", "ProtectedEnvelope", "IdValue", "TableName", "IdName", "IdType", "IgnoredProperties" }.Contains(p.Name)).ToList()) property.Remove(); + } + + public async Task RenderHtmlAsync(int departmentId, string userId, RecordDocument document) + { + // Re-read before rendering so a previously built object is not an authorization token. + var current = await GetAsync(departmentId, userId, document.RecordId, document.RecordKind, document.RevisionId); + if (current == null || current.ContentChecksum != document.ContentChecksum) throw new UnauthorizedAccessException("Record access or content changed; reload the revision."); + document = current; + var branding = await _branding.GetBrandingAsync(departmentId); + var layout = await _layouts.GetDepartmentDefaultAsync(departmentId); var config = layout?.Config ?? RecordsPrintLayoutConfig.Default(); + var html = new StringBuilder("Department record"); + if (config.ShowLogo && branding?.HasLogo == true) + { + var logo = await _branding.GetMediaAsync(departmentId, DepartmentProfileMediaKind.PrintHeader); + if (logo?.Data?.Length > 0 && new[] { "image/png", "image/jpeg" }.Contains(logo.ContentType)) html.Append("\"Department"); + } + html.Append("

").Append(E(config.UseShortName ? branding?.ShortName : branding?.DisplayName)).Append("

"); + foreach (var line in new[] { config.ShowAddress ? branding?.AddressText : null, config.ShowPhone ? branding?.PhoneNumber : null, config.ShowWebsite ? branding?.Website : null, config.LetterheadLine1, config.LetterheadLine2 }) + if (!string.IsNullOrWhiteSpace(line)) html.Append("
").Append(E(line)).Append("
"); + html.Append("

Complete department record ").Append(E(document.RecordNumber)).Append(" — revision ").Append(document.RevisionNumber).Append("

Saved ").Append(E(document.FinalizedOn.ToString("u"))).Append(" · Attested by ").Append(E(document.AttestedBy)).Append(" · Statement ").Append(E(document.AttestationVersion)).Append("

"); + if (document.WithheldFields.Count > 0) html.Append("

Some fields are withheld under your current access permissions.

"); + if (!string.IsNullOrWhiteSpace(config.WatermarkLabel)) html.Append("

").Append(E(config.WatermarkLabel)).Append("

"); + RenderSections(html, JObject.Parse(document.ContentJson)); + html.Append("
").Append(E(config.FooterText)).Append("

Revision ").Append(E(document.RevisionId)).Append(" · Original checksum ").Append(E(document.OriginalChecksum)).Append("

Copy checksum ").Append(E(document.ContentChecksum)).Append(" · Layout ").Append(E(layout?.LayoutVersion)).Append(" · Printed by ").Append(E(userId)).Append(" at ").Append(DateTime.UtcNow.ToString("u")).Append("

"); + await RequireCurrentDocumentAsync(departmentId, userId, document); + return html.ToString(); + } + public async Task RenderPdfAsync(int departmentId, string userId, RecordDocument document) + { + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ExportRecords)) throw new UnauthorizedAccessException(); + var pageSize = (await _layouts.GetDepartmentDefaultAsync(departmentId))?.Config?.PageSize; + var bytes = _pdf.ConvertHtmlToPdf(await RenderHtmlAsync(departmentId, userId, document), RecordsPrintLayoutConfig.NormalizePageSize(pageSize)); + await RequireCurrentDocumentAsync(departmentId, userId, document, true); + return bytes; + } + + private async Task RequireCurrentDocumentAsync(int departmentId, string userId, RecordDocument document, bool exporting = false) + { + var current = await GetAsync(departmentId, userId, document.RecordId, document.RecordKind, document.RevisionId, exporting); + if (current == null || current.ContentChecksum != document.ContentChecksum) throw new UnauthorizedAccessException("Record access or content changed; reload the revision."); + } + + public async Task RenderDiffPdfAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, string fromRevisionId, string toRevisionId) + { + if (string.IsNullOrWhiteSpace(fromRevisionId) || string.IsNullOrWhiteSpace(toRevisionId)) throw new ArgumentException("Choose both revisions to compare."); + var branding = await _branding.GetBrandingAsync(departmentId); + var layout = await _layouts.GetDepartmentDefaultAsync(departmentId); + var config = layout?.Config ?? RecordsPrintLayoutConfig.Default(); + var from = await GetAsync(departmentId, userId, recordId, kind, fromRevisionId, true); + var to = await GetAsync(departmentId, userId, recordId, kind, toRevisionId, true); + if (from == null || to == null) throw new InvalidOperationException("A requested revision is unavailable."); + var changes = await DiffAsync(departmentId, userId, from, to); + var html = new StringBuilder("Report revision changes"); + html.Append("

").Append(E(config.UseShortName ? branding?.ShortName : branding?.DisplayName)).Append("

Report ").Append(E(to.RecordNumber)).Append(" — revision ").Append(from.RevisionNumber).Append(" to ").Append(to.RevisionNumber).Append("

"); + html.Append("

Only changes visible under your current permissions are shown. Department custom fields are included.

"); + if (from.WithheldFields.Count > 0 || to.WithheldFields.Count > 0) html.Append("

Some fields are withheld under your current permissions.

"); + if (!string.IsNullOrWhiteSpace(config.WatermarkLabel)) html.Append("

").Append(E(config.WatermarkLabel)).Append("

"); + if (changes.Count == 0) html.Append("

No visible content changed.

"); + else + { + html.Append(""); + foreach (var change in changes) html.Append(""); + html.Append("
FieldBeforeAfter
").Append(E(change.FieldLabel ?? change.FieldKey)).Append("").Append(E(change.OldValue)).Append("").Append(E(change.NewValue)).Append("
"); + } + html.Append("
").Append(E(config.FooterText)).Append("

From ").Append(E(from.RevisionId)).Append(" · Original checksum ").Append(E(from.OriginalChecksum)).Append("

To ").Append(E(to.RevisionId)).Append(" · Original checksum ").Append(E(to.OriginalChecksum)).Append("

Printed by ").Append(E(userId)).Append(" at ").Append(DateTime.UtcNow.ToString("u")).Append("

"); + var bytes = _pdf.ConvertHtmlToPdf(html.ToString(), RecordsPrintLayoutConfig.NormalizePageSize(config.PageSize)); + await RequireCurrentDocumentAsync(departmentId, userId, from, true); + await RequireCurrentDocumentAsync(departmentId, userId, to, true); + return bytes; + } + private static string E(string text) => WebUtility.HtmlEncode(text ?? ""); + private static string Label(string text) => Regex.Replace(text.Replace('_', ' '), "([a-z])([A-Z])", "$1 $2"); + private static readonly HashSet PrintMetadata = new HashSet(StringComparer.Ordinal) + { + "DepartmentId", "RecordId", "RevisionId", "SnapshotVersion", "DefinitionVersion", "DefinitionKey", "UdfDefinitionId", "CreatedOn", "ModifiedOn", "RowVersion", "DeletedOn", "IsProtected", "ProtectedCatalogVersion", "Ordinal", "IsCurrent", "SupersededByArtifactId", "SupersededOn", + "OwnerUserId", "ReviewerUserId", "ApproverUserId", "ReviewDueOn", "SubmittedForReviewOn", "ReturnedOn", "ReturnCount", "ApprovedOn", "CurrentRevisionId", "RevisionCount", "AmendsRevisionId", "LastSubmissionId", "LastSubmissionState", "LastSubmittedOn", "AcceptedOn", "RejectedOn", "RejectionSummary", "LifecyclePreset", "OriginClient", "ModifiedByUserId", "PurgedOn", "PurgedByUserId" + }; + private static bool IsPrintMetadata(JProperty property) => PrintMetadata.Contains(property.Name) + || property.Name.StartsWith("Rms", StringComparison.Ordinal) && property.Name.EndsWith("Id", StringComparison.Ordinal) + // State on an address is customer content. Only the aggregate header's lifecycle state is metadata. + || property.Name == "State" && (property.Parent?.Path is "" or "Report" or "Analysis"); + internal static void RenderSections(StringBuilder html, JObject content) + { + var order = new[] { "Report", "Analysis", "Location", "Types", "Units", "Aids", "Tactics", "Narrative", "Details", "Participants", "Modules", "Resources", "Casualties", "Exposures", "Properties", "Vehicles", "Facts", "DispatchComments", "SpecialModifiers", "Evidence", "Attachments" }; + foreach (var section in content.Properties().OrderBy(p => { var i = Array.IndexOf(order, p.Name); return i < 0 ? order.Length : i; })) + { + if (IsPrintMetadata(section) || section.Value.Type == JTokenType.Null) continue; + if (section.Name == "CustomFields") + { + var custom = section.Value.ToObject(); + if (custom?.Fields.Count > 0) + { + html.Append("

Department custom fields

Captured form version ").Append(custom.ExtensionVersion).Append(". These fields are excluded from NERIS submission.

"); + foreach (var field in custom.Fields) html.Append(""); + html.Append("
").Append(E(field.Field.Label)).Append("").Append(E(field.Value ?? "")).Append("
"); + } + continue; + } + var rows = new List<(string Label, string Value)>(); PrintableRows(section.Value, "", rows, 0); + if (rows.Count == 0) continue; + html.Append("

").Append(E(Label(section.Name))).Append("

"); + foreach (var row in rows) html.Append(""); + html.Append("
").Append(E(row.Label)).Append("").Append(section.Name == "Details" && row.Label == "Narrative" ? Resgrid.Framework.RecordNarrativeFormatter.Render(row.Value) : E(row.Value)).Append("
"); + } + } + private static void PrintableRows(JToken value, string path, List<(string Label, string Value)> rows, int depth) + { + if (depth > 32) throw new InvalidOperationException("Record content exceeds the supported nesting depth."); + if (value is JObject obj) + { + foreach (var property in obj.Properties()) + if (!IsPrintMetadata(property)) + PrintableRows(property.Value, string.IsNullOrEmpty(path) ? Label(property.Name) : path + " / " + Label(property.Name), rows, depth + 1); + } + else if (value is JArray array) for (var i = 0; i < array.Count; i++) PrintableRows(array[i], path + "Item " + (i + 1), rows, depth + 1); + else if (value.Type != JTokenType.Null) + { + var text = value.Type == JTokenType.Date ? value.ToObject().ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) + : value.Type == JTokenType.Boolean ? value.Value() ? "Yes" : "No" : value.ToString(); + if (value.Type == JTokenType.Integer && value.Parent is JProperty property) + { + Type enumType = property.Name switch { "SourceKind" or "TimesSourceKind" => typeof(RmsSourceKind), "RecordKind" => typeof(RmsRecordKind), "Classification" => typeof(RmsEvidenceClassification), _ => null }; + if (enumType != null && Enum.IsDefined(enumType, value.Value())) text = Label(Enum.GetName(enumType, value.Value())); + } + if (string.IsNullOrWhiteSpace(text) || value.Type == JTokenType.Date && value.ToObject().Year == 1) return; + if (value.Type == JTokenType.String && (text.TrimStart().StartsWith("{") || text.TrimStart().StartsWith("["))) + { try { var parsed = JToken.Parse(text); PrintableRows(parsed, path.Replace(" Json", ""), rows, depth + 1); return; } catch (JsonException) { } } + rows.Add((string.IsNullOrEmpty(path) ? "Value" : path, text)); + } + } + public async Task> DiffAsync(int departmentId, string userId, RecordDocument from, RecordDocument to) + { + if (from.RecordId != to.RecordId || from.RecordKind != to.RecordKind) throw new ArgumentException("Compare revisions of the same record."); + // Reload both, then project them together under the last live field scope. A revoked + // value must never appear as a removal merely because the two reads saw different grants. + var currentFrom = await GetAsync(departmentId, userId, from.RecordId, from.RecordKind, from.RevisionId); + var currentTo = await GetAsync(departmentId, userId, to.RecordId, to.RecordKind, to.RevisionId); + if (currentFrom == null || currentTo == null) throw new UnauthorizedAccessException(); + var leftContent = JObject.Parse(currentFrom.ContentJson); var rightContent = JObject.Parse(currentTo.ContentJson); + await ProjectCustomFieldsAsync(departmentId, userId, new List(), leftContent, rightContent); + var restricted = await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords); + Project(leftContent, restricted, new List()); Project(rightContent, restricted, new List()); + var parentId = from.RecordId; + if (from.RecordKind == RmsRecordKind.IncidentAnalysis) + { + var analysis = await _analyses.GetByIdForDepartmentAsync(departmentId, from.RecordId); + if (analysis == null || analysis.DeletedOn.HasValue) throw new UnauthorizedAccessException(); + parentId = analysis.IncidentReportId; + } + if (!await _authorization.CanUserViewRecordAsync(userId, parentId, departmentId)) throw new UnauthorizedAccessException(); + var left = Leaves(leftContent); var right = Leaves(rightContent); + return left.Keys.Union(right.Keys).OrderBy(k => k, StringComparer.Ordinal).Where(k => left.GetValueOrDefault(k) != right.GetValueOrDefault(k)) + .Select(k => new RecordFieldDiff { FieldKey = k, FieldLabel = DiffLabel(k, leftContent, rightContent), Section = k.Split('.')[0], OldValue = left.GetValueOrDefault(k), NewValue = right.GetValueOrDefault(k) }).ToList(); + } + private static string DiffLabel(string path, JObject left, JObject right) + { + var match = Regex.Match(path, @"^CustomFields\.Fields\[\d+\]\.Value$"); + if (!match.Success) return null; + var labelPath = path.Substring(0, path.Length - "Value".Length) + "Field.Label"; + var label = (string)(right.SelectToken(labelPath) ?? left.SelectToken(labelPath)); + return string.IsNullOrWhiteSpace(label) ? null : "Department custom field: " + label; + } + private async Task ProjectCustomFieldsAsync(int department, string user, List withheld, params JObject[] contents) + { + var originals=contents.Select(c=>c["CustomFields"]).OfType().ToList(); + if(originals.Count==0) return; + var combined=new RecordUdfSection {Fields=originals.SelectMany(o=>o.ToObject().Fields).ToList()}; + var projected=await _udf.ProjectAsync(department,user,combined); + var allowed=(projected?.Fields ?? new List()).Select(f=>f.Field.UdfFieldId).ToHashSet(StringComparer.Ordinal); + if(projected==null || projected.Fields.Count().ToList()) + if(!allowed.Contains((string)(field["Field"] as JObject)?["UdfFieldId"])) field.Remove(); + } + private static Dictionary Leaves(JObject obj) => obj.Descendants().OfType() + .Where(v => !v.Ancestors().OfType().Any(IsPrintMetadata)) + .ToDictionary(v => v.Path, v => v.Type == JTokenType.Null ? null : v.ToString(CultureInfo.InvariantCulture)); + } +} + diff --git a/Core/Resgrid.Services/Records/RecordsDueStateService.cs b/Core/Resgrid.Services/Records/RecordsDueStateService.cs index 31a0c7453..cfb9d61c6 100644 --- a/Core/Resgrid.Services/Records/RecordsDueStateService.cs +++ b/Core/Resgrid.Services/Records/RecordsDueStateService.cs @@ -291,12 +291,16 @@ private async Task ApplyAsync(int departmentId, Observation observation, DateTim // A moved deadline re-arms emission: the obligation the department is now tracking is a different one. var alreadyEmitted = (RmsDueState)row.LastEmittedState == RmsDueState.Overdue && !deadlineMoved; var becameOverdue = observation.State == RmsDueState.Overdue && !alreadyEmitted; + // The sweep cap is checked before the row is marked, never after. A row marked Overdue that never + // produced an emission would read as already-notified on the next sweep and the obligation would + // go out silently; leaving it unmarked lets the next sweep pick it up. + var capped = becameOverdue && emissions.Count >= MaxEmissionsPerDepartment; row.DueOn = observation.DueOn; row.ResponsibleUserId = observation.ResponsibleUserId; row.ModifiedOn = now; - if (becameOverdue) + if (becameOverdue && !capped) { row.LastEmittedState = (int)RmsDueState.Overdue; row.LastEmittedOn = now; @@ -316,7 +320,7 @@ private async Task ApplyAsync(int departmentId, Observation observation, DateTim row.RowVersion += 1; await _dueStates.UpdateAsync(row, cancellationToken, true); - if (!becameOverdue || emissions.Count >= MaxEmissionsPerDepartment) + if (!becameOverdue || capped) return; var emission = new Emission { RecordId = observation.RecordId }; diff --git a/Core/Resgrid.Services/Records/RecordsEvidenceService.cs b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs index ef40ae5be..1dbb3c0ed 100644 --- a/Core/Resgrid.Services/Records/RecordsEvidenceService.cs +++ b/Core/Resgrid.Services/Records/RecordsEvidenceService.cs @@ -30,10 +30,13 @@ public class RecordsEvidenceService : IRecordsEvidenceService private readonly IRmsAccessAuditsRepository _audits; private readonly IUnitOfWork _unitOfWork; private readonly IEnumerable _adapters; + private readonly IRecordsAuthorizationService _authorization; + private readonly ICallsService _calls; + private readonly IRmsExternalReferencesRepository _references; public RecordsEvidenceService(IRmsEvidenceArtifactsRepository artifacts, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidentReports, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork, - IEnumerable adapters) + IEnumerable adapters, IRecordsAuthorizationService authorization, ICallsService calls, IRmsExternalReferencesRepository references) { _artifacts = artifacts; _records = records; @@ -41,6 +44,27 @@ public RecordsEvidenceService(IRmsEvidenceArtifactsRepository artifacts, IRmsOpe _audits = audits; _unitOfWork = unitOfWork; _adapters = adapters ?? Enumerable.Empty(); + _authorization = authorization; _calls = calls; + _references = references; + } + + public async Task RequireInventoryCoverageAsync(int departmentId, string recordId, IEnumerable captured) + { + captured = (captured ?? Enumerable.Empty()).ToList(); + if (captured.Any(a=>a.DepartmentId!=departmentId || a.RecordId!=recordId || a.Checksum!=RecordSnapshotSerializer.Checksum(a.ManifestJson ?? ""))) throw new InvalidOperationException("Supporting evidence failed its integrity check."); + var references = ((await _references.GetForRecordAsync(departmentId, recordId)) ?? Enumerable.Empty()) + .Where(r=>r.DepartmentId==departmentId && r.RecordId==recordId && !r.DeletedOn.HasValue && r.SemanticRole==RmsInventoryUsageAdapter.SemanticRole).ToList(); + if (references.Count==0) return; + var covered=new Dictionary(StringComparer.Ordinal); + foreach(var artifact in (captured ?? Enumerable.Empty()).Where(a=>a.Kind==(int)RmsEvidenceKind.InventoryUsage)) + { + if (artifact.DepartmentId!=departmentId || artifact.RecordId!=recordId || artifact.Checksum!=RecordSnapshotSerializer.Checksum(artifact.ManifestJson ?? "")) throw new InvalidOperationException("The inventory evidence failed its integrity check."); + var manifest=Newtonsoft.Json.Linq.JObject.Parse(artifact.ManifestJson); + foreach(var entry in (manifest["usage"] as Newtonsoft.Json.Linq.JArray ?? new Newtonsoft.Json.Linq.JArray()).OfType()) + if((string)entry["reference_id"] is string id) covered[id]=(string)entry["reference_checksum"]; + } + if(references.Any(r=>r.Checksum!=RecordSnapshotSerializer.Checksum(r.SnapshotJson ?? "") || !covered.TryGetValue(r.RmsExternalReferenceId,out var checksum) || checksum!=r.Checksum)) + throw new ArgumentException("Refresh the inventory evidence before finalizing; every recorded consumption must appear in the signed report."); } public async Task> GetSourceStatesAsync(int departmentId) @@ -76,9 +100,14 @@ public async Task> GetSourceStatesAsync(int depa public async Task CaptureAsync(RecordEvidenceCaptureRequest request, bool canCaptureRestricted = true, CancellationToken cancellationToken = default) { if (request == null) throw new ArgumentNullException(nameof(request)); + request = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(request)); + var requestChecksum = ComputeRequestChecksum(request); + if (string.IsNullOrWhiteSpace(request.CapturedByUserId)) throw new UnauthorizedAccessException(); + if (!Enum.IsDefined(typeof(RmsEvidenceKind), request.Kind) || request.RecordKind is not (RmsRecordKind.Operational or RmsRecordKind.IncidentReport)) throw new ArgumentException("Choose a supported evidence source and record kind."); if (string.IsNullOrWhiteSpace(request.RecordId)) throw new ArgumentException("A record is required.", nameof(request)); if (string.IsNullOrWhiteSpace(request.CaptureReason)) throw new ArgumentException("A capture reason is required; evidence never enters an official record anonymously.", nameof(request)); + if (request.CaptureReason.Trim().Length > 500) throw new ArgumentException("The capture reason must be at most 500 characters."); await RequireOpenRecordAsync(request); @@ -94,8 +123,8 @@ public async Task CaptureAsync(RecordEvidenceCaptureRequest // Classification is the adapter's judgement about its own content, but the grant check is not: a member // without RecordRestricted_View must not be able to pull restricted content into a record they can read. - if (capture.Classification != RmsEvidenceClassification.Unrestricted && !canCaptureRestricted) - throw new InvalidOperationException("Capturing restricted evidence requires the restricted-records grant."); + if (capture.Classification != RmsEvidenceClassification.Unrestricted && (!canCaptureRestricted || !await _authorization.HasPermissionAsync(request.CapturedByUserId, request.DepartmentId, PermissionTypes.ViewRestrictedRecords))) + throw new UnauthorizedAccessException("Capturing restricted evidence requires the restricted-records grant."); var manifestJson = Serialize(capture.Manifest); var now = DateTime.UtcNow; @@ -115,7 +144,8 @@ public async Task CaptureAsync(RecordEvidenceCaptureRequest SourceEntityType = Trim(capture.SourceEntityType), SourceEntityId = Trim(capture.SourceEntityId), IdentifierScheme = Trim(capture.IdentifierScheme), - SourceVersion = Trim(capture.SourceVersion), + SourceVersion = Trim(capture.SourceVersion) ?? "content-sha256:" + RecordSnapshotSerializer.Checksum(manifestJson), + CaptureRequestChecksum = requestChecksum, CoverageStart = capture.CoverageStart, CoverageEnd = capture.CoverageEnd, ManifestJson = manifestJson, @@ -134,6 +164,8 @@ public async Task CaptureAsync(RecordEvidenceCaptureRequest await InTransactionAsync(async () => { + await RequireOpenRecordAsync(request, fence: true, cancellationToken); + if (capture.Classification != RmsEvidenceClassification.Unrestricted && !await _authorization.HasPermissionAsync(request.CapturedByUserId, request.DepartmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); // A re-capture of the same source supersedes rather than replaces: the earlier artifact is what an // earlier revision attested to, and deleting it would rewrite history. var current = await _artifacts.GetCurrentDraftOfKindAsync(request.DepartmentId, request.RecordId, request.Kind, artifact.SourceEntityId); @@ -153,6 +185,9 @@ await InTransactionAsync(async () => return artifact; } + public async Task> GetHistoryAsync(int departmentId, string recordId, int skip, int take) => + (await _artifacts.GetHistoryAsync(departmentId, recordId, Math.Max(0, skip), Math.Clamp(take, 1, 200)))?.ToList() ?? new List(); + public async Task> GetForRecordAsync(int departmentId, string recordId, string revisionId = null, bool includeSuperseded = false) { return (await _artifacts.GetForRecordAsync(departmentId, recordId, revisionId, includeSuperseded))?.ToList() ?? new List(); @@ -184,23 +219,41 @@ public async Task VerifyAsync(int departmentId, string artifactId) /// Evidence attaches to a Record that exists and is still open. Attaching to a voided or cancelled Record /// would put supporting material behind a filing nobody stands behind any more. ///
- private async Task RequireOpenRecordAsync(RecordEvidenceCaptureRequest request) + private async Task RequireOpenRecordAsync(RecordEvidenceCaptureRequest request, bool fence = false, CancellationToken cancellationToken = default) { + if (!await _authorization.CanUserViewRecordAsync(request.CapturedByUserId, request.RecordId, request.DepartmentId) || !await _authorization.HasPermissionAsync(request.CapturedByUserId, request.DepartmentId, PermissionTypes.CreateRecord)) throw new UnauthorizedAccessException(); + async Task Guard(string author, string owner, string amendment, int state, int? callId, long version) + { + if (author != request.CapturedByUserId && owner != request.CapturedByUserId && !await _authorization.IsDepartmentAdminAsync(request.CapturedByUserId, request.DepartmentId) + && !(amendment != null && await _authorization.HasPermissionAsync(request.CapturedByUserId, request.DepartmentId, PermissionTypes.AmendRecords))) throw new UnauthorizedAccessException(); + if (RmsLifecycle.IsTerminal((RmsRecordState)state) || !(RmsLifecycle.IsEditable((RmsRecordState)state) || amendment != null)) throw new InvalidOperationException("Capture evidence through an editable draft or amendment."); + if (request.ExpectedRowVersion.HasValue && request.ExpectedRowVersion != version) throw new RecordConcurrencyException(request.RecordId, request.ExpectedRowVersion.Value, version); + request.ExpectedRowVersion = version; + if (request.CallId.HasValue && request.CallId != callId) throw new UnauthorizedAccessException("The source Call does not match this record."); + request.CallId = callId; + if (callId.HasValue && !await _authorization.CanReadSourceCallAsync(request.CapturedByUserId, request.DepartmentId, await _calls.GetCallByIdAsync(callId.Value))) throw new UnauthorizedAccessException(); + } if (request.RecordKind == RmsRecordKind.IncidentReport) { var report = await _incidentReports.GetByIdForDepartmentAsync(request.DepartmentId, request.RecordId); - if (report == null || report.DeletedOn.HasValue) + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) throw new InvalidOperationException($"Incident report {request.RecordId} does not exist in department {request.DepartmentId}."); + await Guard(report.AuthorUserId, report.OwnerUserId, report.AmendsRevisionId, report.State, report.CallId, report.RowVersion); if (RmsLifecycle.IsTerminal((RmsRecordState)report.State)) throw new InvalidOperationException("Evidence cannot be captured against a voided or cancelled report."); + if (fence && !await _incidentReports.TryBumpRowVersionAsync(request.DepartmentId, request.RecordId, report.RowVersion, cancellationToken)) + throw new RecordConcurrencyException(request.RecordId, report.RowVersion, report.RowVersion + 1); return; } var record = await _records.GetByIdForDepartmentAsync(request.DepartmentId, request.RecordId); - if (record == null || record.DeletedOn.HasValue) + if (record == null || record.DeletedOn.HasValue || record.PurgedOn.HasValue) throw new InvalidOperationException($"Record {request.RecordId} does not exist in department {request.DepartmentId}."); + await Guard(record.AuthorUserId, record.OwnerUserId, record.AmendsRevisionId, record.State, record.CallId, record.RowVersion); if (RmsLifecycle.IsTerminal((RmsRecordState)record.State)) throw new InvalidOperationException("Evidence cannot be captured against a voided or cancelled Record."); + if (fence && !await _records.TryBumpRowVersionAsync(request.DepartmentId, request.RecordId, record.RowVersion, cancellationToken)) + throw new RecordConcurrencyException(request.RecordId, record.RowVersion, record.RowVersion + 1); } /// @@ -220,6 +273,14 @@ public static string Serialize(object manifest) NullValueHandling = NullValueHandling.Ignore }); } + public static string ComputeRequestChecksum(RecordEvidenceCaptureRequest request) => RecordSnapshotSerializer.Checksum(Serialize(new + { + request.DepartmentId, request.CapturedByUserId, request.RecordId, request.RecordKind, request.Kind, request.CallId, + request.ExpectedRowVersion, request.CoverageStart, request.CoverageEnd, request.OriginClient, CaptureReason=Trim(request.CaptureReason), + SourceIds=(request.SourceIds ?? new List()).Distinct(StringComparer.Ordinal).OrderBy(x=>x,StringComparer.Ordinal).ToArray(), + UnitIds=(request.UnitIds ?? new List()).Distinct().OrderBy(x=>x).ToArray(), + UserIds=(request.UserIds ?? new List()).Distinct(StringComparer.Ordinal).OrderBy(x=>x,StringComparer.Ordinal).ToArray() + })); private static string Trim(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs b/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs new file mode 100644 index 000000000..86bf0a24f --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsLegalHoldService.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + public sealed class RecordsLegalHoldService : IRecordsLegalHoldService + { + private readonly IRmsRecordLegalHoldsRepository _holds; + private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsIncidentReportsRepository _reports; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRmsAccessAuditsRepository _audits; + private readonly IUnitOfWork _unitOfWork; + public RecordsLegalHoldService(IRmsRecordLegalHoldsRepository holds, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository reports, + IRecordsAuthorizationService authorization, IRmsAccessAuditsRepository audits, IUnitOfWork unitOfWork) + { _holds = holds; _records = records; _reports = reports; _authorization = authorization; _audits = audits; _unitOfWork = unitOfWork; } + private async Task RequireAsync(int departmentId, string userId, string recordId = null) + { + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ManageRecordLegalHold) + || recordId != null && !await _authorization.CanUserViewRecordAsync(userId, recordId, departmentId)) throw new UnauthorizedAccessException(); + } + public async Task> GetAsync(int departmentId, string userId) + { + await RequireAsync(departmentId, userId); var result = new List(); + foreach (var hold in await _holds.GetAllForDepartmentAsync(departmentId) ?? Enumerable.Empty()) + if (hold.RecordId == null || await _authorization.CanUserViewRecordAsync(userId, hold.RecordId, departmentId)) result.Add(hold); + await RequireAsync(departmentId, userId); + foreach (var hold in result) await RequireAsync(departmentId, userId, hold.RecordId); + return result; + } + public async Task PlaceAsync(int departmentId, string userId, RmsRecordLegalHold input, CancellationToken cancellationToken = default) + { + await RequireAsync(departmentId, userId); + if (input == null || string.IsNullOrWhiteSpace(input.Reason) || string.IsNullOrWhiteSpace(input.ReferenceNumber) || string.IsNullOrWhiteSpace(input.Notes)) throw new ArgumentException("Record the hold reason, authority or case reference, and preservation instructions."); + if (input.Reason.Length > 50 || input.ReferenceNumber.Length > 100 || input.Notes.Length > 4000) throw new ArgumentException("The hold details exceed the permitted length."); + if (input.PeriodStart > input.PeriodEnd) throw new ArgumentException("The hold end must be on or after its start."); + var recordId = string.IsNullOrWhiteSpace(input.RecordId) ? null : input.RecordId.Trim(); + var definition = string.IsNullOrWhiteSpace(input.DefinitionKey) ? null : input.DefinitionKey.Trim(); + if (recordId != null && (definition != null || input.PeriodStart.HasValue || input.PeriodEnd.HasValue)) throw new ArgumentException("Choose one record or a definition/date scope."); + if (definition != null && definition != RmsDefinitionKeys.NerisIncidentReport && !RmsDefinitionKeys.LockedTypes.ContainsKey(definition)) throw new ArgumentException("Choose an available record definition."); + if (recordId != null) + { + await RequireAsync(departmentId, userId, recordId); + var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); var report = await _reports.GetByIdForDepartmentAsync(departmentId, recordId); + if (!(record != null && record.DeletedOn == null && record.PurgedOn == null || report != null && report.DeletedOn == null && report.PurgedOn == null)) throw new ArgumentException("The record is unavailable. A purged record cannot be placed on hold."); + } + var now = DateTime.UtcNow; + var hold = new RmsRecordLegalHold { RmsRecordLegalHoldId = Guid.NewGuid().ToString(), DepartmentId = departmentId, RecordId = recordId, DefinitionKey = definition, + PeriodStart = input.PeriodStart, PeriodEnd = input.PeriodEnd, Reason = input.Reason.Trim(), ReferenceNumber = input.ReferenceNumber.Trim(), Notes = input.Notes.Trim(), PlacedByUserId = userId, PlacedOn = now, CreatedOn = now, ModifiedOn = now, RowVersion = 1 }; + _unitOfWork.CreateOrGetConnection(); + try + { + await RequireAsync(departmentId, userId, recordId); + // Repository shares the retention department/parent lock; placement cannot race a content purge. + await _holds.InsertAsync(hold, cancellationToken, true); + await AuditAsync(hold, userId, "Legal hold placed", hold.Notes, cancellationToken); _unitOfWork.CommitChanges(); return hold; + } + catch { _unitOfWork.DiscardChanges(); throw; } + } + public async Task ReleaseAsync(int departmentId, string userId, string holdId, long expectedVersion, string reason, CancellationToken cancellationToken = default) + { + await RequireAsync(departmentId, userId); + if (string.IsNullOrWhiteSpace(reason) || reason.Length > 4000) throw new ArgumentException("Record the authority and reason for releasing preservation (up to 4,000 characters)."); + var hold = await _holds.GetByIdForDepartmentAsync(departmentId, holdId) ?? throw new ArgumentException("The hold does not exist."); + await RequireAsync(departmentId, userId, hold.RecordId); + _unitOfWork.CreateOrGetConnection(); + try + { + await RequireAsync(departmentId, userId, hold.RecordId); + if (!await _holds.TryReleaseAsync(departmentId, holdId, expectedVersion, userId, reason.Trim(), DateTime.UtcNow, cancellationToken)) throw new InvalidOperationException("The hold changed or was already released. Reload it before continuing."); + await AuditAsync(hold, userId, "Legal hold released", reason.Trim(), cancellationToken); _unitOfWork.CommitChanges(); + } + catch { _unitOfWork.DiscardChanges(); throw; } + } + private Task AuditAsync(RmsRecordLegalHold hold, string userId, string purpose, string reason, CancellationToken ct) => _audits.InsertAsync(new RmsAccessAudit { DepartmentId = hold.DepartmentId, RecordId = hold.RecordId, + ActorUserId = userId, Action = (int)RmsAccessAuditAction.Admin, Successful = true, OccurredOn = DateTime.UtcNow, Purpose = purpose, DetailJson = JsonConvert.SerializeObject(new { hold.RmsRecordLegalHoldId, hold.ReferenceNumber, reason }) }, ct, true); + } +} diff --git a/Core/Resgrid.Services/Records/RecordsReportingService.cs b/Core/Resgrid.Services/Records/RecordsReportingService.cs index 240da5495..c7d81c66e 100644 --- a/Core/Resgrid.Services/Records/RecordsReportingService.cs +++ b/Core/Resgrid.Services/Records/RecordsReportingService.cs @@ -22,22 +22,18 @@ public class RecordsReportingService : IRecordsReportingService private readonly IWorkLogsService _legacyLogs; private readonly IRecordsCutoverService _cutover; private readonly IRmsOperationalRecordsRepository _records; - private readonly IRmsRecordValueService _details; - private readonly IRmsRecordParticipantsRepository _participants; - private readonly IRmsRecordUnitResponsesRepository _units; + private readonly IRmsRevisionsRepository _revisions; private readonly IRmsRecordGroupScopesRepository _scopes; private readonly IRecordsAuthorizationService _authorization; public RecordsReportingService(IWorkLogsService legacyLogs, IRecordsCutoverService cutover, IRmsOperationalRecordsRepository records, - IRmsRecordValueService details, IRmsRecordParticipantsRepository participants, IRmsRecordUnitResponsesRepository units, + IRmsRevisionsRepository revisions, IRmsRecordGroupScopesRepository scopes, IRecordsAuthorizationService authorization) { _legacyLogs = legacyLogs; _cutover = cutover; _records = records; - _details = details; - _participants = participants; - _units = units; + _revisions = revisions; _scopes = scopes; _authorization = authorization; } @@ -66,7 +62,7 @@ public async Task> GetActivityAsync(int departmentId, if (records.Count == 0) return Order(entries); - entries.AddRange(await MapVisibleAsync(departmentId, viewerUserId, records)); + entries.AddRange((await MapVisibleAsync(departmentId, viewerUserId, records)).Where(e => e.StartedOn >= start && e.StartedOn <= end)); return Order(entries); } @@ -76,22 +72,34 @@ private async Task> MapVisibleAsync(int departmentId, var ids = records.Select(r => r.RmsOperationalRecordId).ToList(); var visible = await _authorization.GetVisibleGroupIdsAsync(viewerUserId, departmentId); - var details = (await _details.GetDraftsForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()) - .GroupBy(d => d.RecordId, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); - var participants = (await _participants.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()).ToLookup(p => p.RecordId, StringComparer.Ordinal); - var units = (await _units.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()).ToLookup(u => u.RecordId, StringComparer.Ordinal); + var revisionIds = records.Where(r => !r.PurgedOn.HasValue && !string.IsNullOrEmpty(r.CurrentRevisionId)).Select(r => r.CurrentRevisionId).Distinct().ToList(); + if (revisionIds.Count == 0) return entries; + var revisions = (await _revisions.GetByIdsForDepartmentAsync(departmentId, revisionIds) ?? Enumerable.Empty()) + .ToDictionary(r => r.RmsRevisionId, StringComparer.Ordinal); var scopes = visible == null ? null : (await _scopes.GetForRecordsAsync(departmentId, ids) ?? Enumerable.Empty()).ToLookup(s => s.RecordId, StringComparer.Ordinal); foreach (var record in records) { - var recordParticipants = participants[record.RmsOperationalRecordId].ToList(); + if (record.PurgedOn.HasValue || record.CurrentRevisionId == null || !revisions.TryGetValue(record.CurrentRevisionId, out var revision) + || revision.DepartmentId != departmentId || revision.RecordId != record.RmsOperationalRecordId) continue; + if (string.IsNullOrWhiteSpace(revision.SnapshotJson) || revision.Checksum != RecordSnapshotSerializer.Checksum(revision.SnapshotJson)) + throw new InvalidOperationException("An official revision failed its integrity check. Reporting cannot safely total this result set."); + var snapshot = RecordSnapshotSerializer.Deserialize(revision.SnapshotJson); + if (snapshot == null || snapshot.DepartmentId != departmentId || snapshot.RecordId != record.RmsOperationalRecordId) continue; + var recordParticipants = snapshot.Participants ?? new List(); if (!IsVisible(record, recordParticipants, scopes?[record.RmsOperationalRecordId], visible, viewerUserId)) continue; - details.TryGetValue(record.RmsOperationalRecordId, out var detail); - entries.Add(FromRecord(record, detail, recordParticipants, units[record.RmsOperationalRecordId])); + var entry = FromRecord(record, snapshot.Details, recordParticipants, snapshot.Units); + entry.StartedOn = snapshot.StartedOn; + entry.EndedOn = snapshot.EndedOn; + entry.CallId = snapshot.CallId; + entry.StationGroupId = snapshot.StationGroupId; + entry.LoggedByUserId = snapshot.AuthorUserId; + entry.LoggedOn = revision.CreatedOn; + entries.Add(entry); } return entries; @@ -112,7 +120,7 @@ public async Task> GetCallActivityAsync(int department if (records.Count == 0) return Order(entries); - entries.AddRange(await MapVisibleAsync(departmentId, viewerUserId, records)); + entries.AddRange((await MapVisibleAsync(departmentId, viewerUserId, records)).Where(e => e.CallId == callId)); return Order(entries); } @@ -179,7 +187,7 @@ public static ReportActivityEntry FromRecord(RmsOperationalRecord record, RmsOpe CallName = detail?.CallName, StationGroupId = record.StationGroupId, Participants = (participants ?? Enumerable.Empty()).Where(p => p != null && !string.IsNullOrWhiteSpace(p.UserId)) - .Select(p => new ReportActivityParticipant { UserId = p.UserId }).ToList(), + .Select(p => new ReportActivityParticipant { UserId = p.UserId, UnitId = p.UnitId }).ToList(), Units = (units ?? Enumerable.Empty()).Where(u => u != null).Select(u => new ReportActivityUnit { UnitId = u.UnitId, Dispatched = u.Dispatched, Enroute = u.Enroute, OnScene = u.OnScene, Released = u.Released, InQuarters = u.InQuarters diff --git a/Core/Resgrid.Services/Records/RecordsRetentionService.cs b/Core/Resgrid.Services/Records/RecordsRetentionService.cs index 6aa8a9fb2..2a4b68b54 100644 --- a/Core/Resgrid.Services/Records/RecordsRetentionService.cs +++ b/Core/Resgrid.Services/Records/RecordsRetentionService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -45,11 +45,12 @@ public class RecordsRetentionService : IRecordsRetentionService private readonly IRmsRecordSearchProjectionsRepository _projections; private readonly IRecordAttachmentScanner _scanner; private readonly IDepartmentSettingsService _settings; + private readonly IRmsRetentionRepository _purge; public RecordsRetentionService(IRmsDepartmentCutoversRepository cutovers, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidentReports, IRmsOperationalRecordDetailsRepository details, IRmsRecordAttachmentsRepository attachments, IRmsRecordLegalHoldsRepository legalHolds, IRmsAccessAuditsRepository audits, IRmsRecordSearchProjectionsRepository projections, - IRecordAttachmentScanner scanner, IDepartmentSettingsService settings) + IRecordAttachmentScanner scanner, IDepartmentSettingsService settings, IRmsRetentionRepository purge) { _cutovers = cutovers; _records = records; @@ -61,6 +62,7 @@ public RecordsRetentionService(IRmsDepartmentCutoversRepository cutovers, IRmsOp _projections = projections; _scanner = scanner; _settings = settings; + _purge = purge; } public async Task SweepAsync(CancellationToken cancellationToken = default) @@ -78,6 +80,7 @@ public async Task SweepAsync(CancellationToken canc var department = await ProcessDepartmentAsync(cutover.DepartmentId, cancellationToken); result.RecordsEvaluated += department.RecordsEvaluated; result.RecordsPurged += department.RecordsPurged; + result.SearchErasuresPending += department.SearchErasuresPending; result.AttachmentsPurged += department.AttachmentsPurged; result.HeldByLegalHold += department.HeldByLegalHold; result.AttachmentsRescanned += department.AttachmentsRescanned; @@ -91,7 +94,7 @@ public async Task SweepAsync(CancellationToken canc } } - result.Message = $"{result.DepartmentsEvaluated} departments, {result.RecordsPurged} purged, {result.HeldByLegalHold} held, {result.AttachmentsRescanned} rescanned."; + result.Message = $"{result.DepartmentsEvaluated} departments, {result.RecordsPurged} database content purges, {result.SearchErasuresPending} awaiting committed search erasure, {result.HeldByLegalHold} held, {result.AttachmentsRescanned} rescanned."; return result; } @@ -103,174 +106,65 @@ public async Task ProcessDepartmentAsync(int depart var policy = await _settings.GetRecordsRetentionPolicyAsync(departmentId) ?? new RecordsRetentionPolicy(); var holds = (await _legalHolds.GetActiveForDepartmentAsync(departmentId))?.ToList() ?? new List(); - // The widest period any definition could still be retained for bounds the candidate query; anything - // finalized after it cannot be past retention under any policy, so it is not worth loading. - var longestYears = LongestRetentionYears(policy); - if (longestYears > 0) + // Walk every closed candidate in stable ID order. A permanent/held first page must not starve later records, + // and history may contain a shorter policy than the one currently displayed in department settings. + string after = null; + while (true) { - var cutoff = now.AddYears(-longestYears); - - foreach (var record in (await _records.GetRetentionCandidatesAsync(departmentId, cutoff, MaxRecordsPerDepartment))?.ToList() ?? new List()) + cancellationToken.ThrowIfCancellationRequested(); + var page = (await _records.GetRetentionCandidatesAsync(departmentId, now, MaxRecordsPerDepartment, after))?.ToList() ?? new List(); + if (page.Count == 0) break; + after = page.Last().RmsOperationalRecordId; + foreach (var record in page) { - cancellationToken.ThrowIfCancellationRequested(); result.RecordsEvaluated++; await ConsiderOperationalAsync(departmentId, record, policy, holds, now, result, cancellationToken); } - - foreach (var report in (await _incidentReports.GetRetentionCandidatesAsync(departmentId, cutoff, MaxRecordsPerDepartment))?.ToList() ?? new List()) + if (page.Count < MaxRecordsPerDepartment) break; + } + after = null; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = (await _incidentReports.GetRetentionCandidatesAsync(departmentId, now, MaxRecordsPerDepartment, after))?.ToList() ?? new List(); + if (page.Count == 0) break; + after = page.Last().RmsIncidentReportId; + foreach (var report in page) { - cancellationToken.ThrowIfCancellationRequested(); result.RecordsEvaluated++; await ConsiderIncidentReportAsync(departmentId, report, policy, holds, now, result, cancellationToken); } + if (page.Count < MaxRecordsPerDepartment) break; } - await RescanPendingAttachmentsAsync(departmentId, now, result, cancellationToken); return result; } - /// - /// Zero means permanent for the definitions that resolve to it, so it never bounds the query; the answer is - /// the longest finite period any definition could resolve to, or 0 when everything is permanent. - /// - private static int LongestRetentionYears(RecordsRetentionPolicy policy) - { - var years = new List(); - if (policy.DepartmentDefaultYears.HasValue && policy.DepartmentDefaultYears.Value > 0) - years.Add(policy.DepartmentDefaultYears.Value); - else if (!policy.DepartmentDefaultYears.HasValue) - years.Add(RecordsRetentionPolicy.StandardClassDefaultYears); - - foreach (var over in policy.Overrides ?? new List()) - { - if (over.RetentionYears > 0) - years.Add(over.RetentionYears); - } - - return years.Count == 0 ? 0 : years.Max(); - } - - private async Task ConsiderOperationalAsync(int departmentId, RmsOperationalRecord record, RecordsRetentionPolicy policy, + private Task ConsiderOperationalAsync(int departmentId, RmsOperationalRecord record, RecordsRetentionPolicy policy, List holds, DateTime now, RecordsRetentionSweepResult result, CancellationToken cancellationToken) - { - var years = policy.ResolveYears(record.DefinitionKey); - if (years <= RecordsRetentionPolicy.Permanent) - return; + => ConsiderAsync(departmentId, record.RmsOperationalRecordId, RmsRecordKind.Operational, record.RowVersion, now, result, cancellationToken); - var expiresOn = (record.FinalizedOn ?? record.CreatedOn).AddYears(years); - if (now < expiresOn) - return; - - var hold = holds.FirstOrDefault(h => h.Covers(record.RmsOperationalRecordId, record.DefinitionKey, record.StartedOn ?? record.CreatedOn)); - if (hold != null) - { - result.HeldByLegalHold++; - await AuditAsync(departmentId, record.RmsOperationalRecordId, HeldAuditPurpose, new { hold.RmsRecordLegalHoldId, hold.Reason, hold.ReferenceNumber, expiresOn }, now, cancellationToken); - return; - } + private Task ConsiderIncidentReportAsync(int departmentId, RmsIncidentReport report, RecordsRetentionPolicy policy, + List holds, DateTime now, RecordsRetentionSweepResult result, CancellationToken cancellationToken) + => ConsiderAsync(departmentId, report.RmsIncidentReportId, RmsRecordKind.IncidentReport, report.RowVersion, now, result, cancellationToken); + private async Task ConsiderAsync(int departmentId, string recordId, RmsRecordKind kind, long version, DateTime now, + RecordsRetentionSweepResult result, CancellationToken cancellationToken) + { try { - result.AttachmentsPurged += await PurgeAttachmentsAsync(departmentId, record.RmsOperationalRecordId, now, cancellationToken); - - // Content goes; identity, number and lifecycle history stay. This is the tombstone of plan 4.9. - var detail = await _details.GetDraftAsync(departmentId, record.RmsOperationalRecordId); - if (detail != null) + var outcome = await _purge.PurgeAsync(departmentId, recordId, kind, version, now, cancellationToken); + if (outcome.Purged) { result.RecordsPurged++; result.AttachmentsPurged += outcome.AttachmentsPurged; } + if (outcome.SearchErasurePending) result.SearchErasuresPending++; + if (outcome.Held) { - BlankContent(detail); - detail.ModifiedOn = now; - await _details.UpdateAsync(detail, cancellationToken, true); + result.HeldByLegalHold++; + await AuditAsync(departmentId, recordId, HeldAuditPurpose, new { outcome.Reason }, now, cancellationToken); } - - record.DisplaySummary = PurgedPlaceholder; - record.PurgedOn = now; - record.ModifiedOn = now; - record.RowVersion += 1; - await _records.UpdateAsync(record, cancellationToken, true); - await TombstoneProjectionAsync(departmentId, record.RmsOperationalRecordId, now, cancellationToken); - - result.RecordsPurged++; - await AuditAsync(departmentId, record.RmsOperationalRecordId, PurgeAuditPurpose, new { record.DefinitionKey, years, expiresOn }, now, cancellationToken); - } - catch (Exception ex) - { - Logging.LogException(ex, $"Retention purge failed for record {record.RmsOperationalRecordId}."); - result.Errors++; - } - } - - private async Task ConsiderIncidentReportAsync(int departmentId, RmsIncidentReport report, RecordsRetentionPolicy policy, - List holds, DateTime now, RecordsRetentionSweepResult result, CancellationToken cancellationToken) - { - var years = policy.ResolveYears(report.DefinitionKey); - if (years <= RecordsRetentionPolicy.Permanent) - return; - - var expiresOn = (report.FinalizedOn ?? report.CreatedOn).AddYears(years); - if (now < expiresOn) - return; - - var hold = holds.FirstOrDefault(h => h.Covers(report.RmsIncidentReportId, report.DefinitionKey, report.CallCreatedOn ?? report.CreatedOn)); - if (hold != null) - { - result.HeldByLegalHold++; - await AuditAsync(departmentId, report.RmsIncidentReportId, HeldAuditPurpose, new { hold.RmsRecordLegalHoldId, hold.Reason, hold.ReferenceNumber, expiresOn }, now, cancellationToken); - return; - } - - try - { - result.AttachmentsPurged += await PurgeAttachmentsAsync(departmentId, report.RmsIncidentReportId, now, cancellationToken); - - report.DisplaySummary = PurgedPlaceholder; - report.ModifiedOn = now; - report.RowVersion += 1; - await _incidentReports.UpdateAsync(report, cancellationToken, true); - await TombstoneProjectionAsync(departmentId, report.RmsIncidentReportId, now, cancellationToken); - - result.RecordsPurged++; - await AuditAsync(departmentId, report.RmsIncidentReportId, PurgeAuditPurpose, new { report.DefinitionKey, years, expiresOn }, now, cancellationToken); - } - catch (Exception ex) - { - Logging.LogException(ex, $"Retention purge failed for incident report {report.RmsIncidentReportId}."); - result.Errors++; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) { Logging.LogException(ex, "RMS retention purge failed."); result.Errors++; } } - - private async Task PurgeAttachmentsAsync(int departmentId, string recordId, DateTime now, CancellationToken cancellationToken) - { - var purged = 0; - foreach (var metadata in (await _attachments.GetMetadataForRecordAsync(departmentId, recordId))?.ToList() ?? new List()) - { - var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, metadata.RmsRecordAttachmentId); - if (attachment == null) - continue; - - attachment.Data = null; - attachment.StorageReference = null; - attachment.DeletedOn = now; - attachment.ModifiedOn = now; - await _attachments.UpdateAsync(attachment, cancellationToken, true); - purged++; - } - - return purged; - } - - private async Task TombstoneProjectionAsync(int departmentId, string recordId, DateTime now, CancellationToken cancellationToken) - { - var projection = await _projections.GetByRecordIdAsync(departmentId, recordId); - if (projection == null) - return; - - // The queue and the index must stop showing content the department no longer holds. - projection.DisplaySummary = PurgedPlaceholder; - projection.SearchText = null; - projection.ModifiedOn = now; - await _projections.UpdateAsync(projection, cancellationToken, true); - } - /// /// Re-submits Pending attachments to the scanner. A clean result promotes the row; a rejection deletes the /// bytes and marks it Rejected, because a Pending attachment that turns out to be malware has been @@ -304,19 +198,10 @@ private async Task RescanPendingAttachmentsAsync(int departmentId, DateTime now, if (scan == null || scan.State == RmsAttachmentScanState.Pending) continue; - attachment.ScanState = (int)scan.State; - if (scan.State == RmsAttachmentScanState.Rejected) - { - attachment.Data = null; - attachment.StorageReference = null; - attachment.DeletedOn = now; - result.AttachmentsRejectedOnRescan++; - } - - attachment.ModifiedOn = now; - await _attachments.UpdateAsync(attachment, cancellationToken, true); + if (!await _attachments.ApplyScanResultAsync(departmentId, attachment.RmsRecordAttachmentId, attachment.RowVersion, scan.State, now, cancellationToken)) + continue; // It was deleted, purged or changed while the scanner was working. result.AttachmentsRescanned++; - + if (scan.State == RmsAttachmentScanState.Rejected) result.AttachmentsRejectedOnRescan++; if (scan.State == RmsAttachmentScanState.Rejected) await AuditAsync(departmentId, attachment.RecordId, "Attachment rejected on rescan", new { attachment.RmsRecordAttachmentId, attachment.FileName, scan.Detail }, now, cancellationToken); } diff --git a/Core/Resgrid.Services/Records/RecordsSearchIndexMaintenanceService.cs b/Core/Resgrid.Services/Records/RecordsSearchIndexMaintenanceService.cs index 3c7f79cf0..d62b31e50 100644 --- a/Core/Resgrid.Services/Records/RecordsSearchIndexMaintenanceService.cs +++ b/Core/Resgrid.Services/Records/RecordsSearchIndexMaintenanceService.cs @@ -27,10 +27,11 @@ public class RecordsSearchIndexMaintenanceService : IRecordsSearchIndexMaintenan private readonly IRecordsSearchIndexer _indexer; private readonly IDepartmentDataProtectionService _dataProtection; private readonly IDepartmentSettingsService _departmentSettings; + private readonly IRmsRetentionRepository _retention; public RecordsSearchIndexMaintenanceService(IRmsDepartmentCutoversRepository cutovers, IRmsRecordSearchProjectionsRepository projections, IRmsSearchIndexStatesRepository states, IRmsOperationalRecordDetailsRepository details, IRecordsSearchIndexer indexer, - IDepartmentDataProtectionService dataProtection, IDepartmentSettingsService departmentSettings) + IDepartmentDataProtectionService dataProtection, IDepartmentSettingsService departmentSettings, IRmsRetentionRepository retention) { _cutovers = cutovers; _projections = projections; @@ -39,15 +40,17 @@ public RecordsSearchIndexMaintenanceService(IRmsDepartmentCutoversRepository cut _indexer = indexer; _dataProtection = dataProtection; _departmentSettings = departmentSettings; + _retention = retention; } public async Task SweepAsync(CancellationToken cancellationToken = default) { var result = new RecordsSearchIndexSweepResult(); + await ErasePurgedSourcesAsync(result, cancellationToken); if (!SearchConfig.Enabled) { result.Skipped = true; - result.Message = "Search host disabled."; + result.Message = $"Search indexing disabled; committed erasure acknowledged for {result.SearchErasuresCompleted} report(s); errors {result.Errors}."; return result; } @@ -89,13 +92,53 @@ public async Task SweepAsync(CancellationToken ca } } - if (result.DocumentsIndexed > 0 || result.DocumentsDeleted > 0 || result.DepartmentsRebuilt > 0) - await _indexer.CommitAsync(cancellationToken); - - result.Message = $"Checked {result.DepartmentsChecked} department(s); rebuilt {result.DepartmentsRebuilt}; indexed {result.DocumentsIndexed}; deleted {result.DocumentsDeleted}; errors {result.Errors}."; + result.Message = $"Checked {result.DepartmentsChecked} department(s); rebuilt {result.DepartmentsRebuilt}; indexed {result.DocumentsIndexed}; deleted {result.DocumentsDeleted}; committed erasures {result.SearchErasuresCompleted}; errors {result.Errors}."; return result; } + private async Task ErasePurgedSourcesAsync(RecordsSearchIndexSweepResult result, CancellationToken cancellationToken) + { + RmsSearchErasureTarget after = null; + while (true) + { + var page = await _retention.GetPendingSearchErasuresAsync(100, after, cancellationToken) ?? new List(); + if (page.Count == 0) return; + var completed = new List(); + foreach (var target in page) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + foreach (var id in target.SourceIds.Append(target.RecordId).Distinct(StringComparer.Ordinal)) + await _indexer.DeleteAsync(target.DepartmentId, (int)RmsSearchSourceType.Record, id, cancellationToken); + completed.Add(target); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) { result.Errors++; Logging.LogException(ex, "A purged record's search erasure remains pending."); } + } + if (completed.Count > 0) + { + var committed = false; + try + { + await _indexer.ExpungeDeletesAsync(cancellationToken); + committed = true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) { result.Errors++; Logging.LogException(ex, "Search erasure commit failed; it will be retried."); } + if (committed) foreach (var target in completed) + { + try { if (await _retention.CompleteSearchErasureAsync(target, DateTime.UtcNow, cancellationToken)) result.SearchErasuresCompleted++; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (Exception ex) { result.Errors++; Logging.LogException(ex, "A search erasure acknowledgement failed; it will be retried."); } + } + } + // Failure does not starve later parents. Unacknowledged targets remain durable for the next sweep. + after = page[page.Count - 1]; + if (page.Count < 100) return; + } + } + public async Task RebuildDepartmentAsync(int departmentId, CancellationToken cancellationToken = default) { var result = new RecordsSearchIndexSweepResult { DepartmentsChecked = 1 }; @@ -109,7 +152,6 @@ public async Task RebuildDepartmentAsync(int depa var generation = await ComputeGenerationAsync(departmentId); var state = await _states.GetAsync(departmentId, RmsSearchIndexState.RecordsIndexName); await RebuildAsync(departmentId, generation, state, result, cancellationToken); - await _indexer.CommitAsync(cancellationToken); result.Message = $"Rebuilt department {departmentId}: {result.DocumentsIndexed} document(s)."; return result; } @@ -150,10 +192,14 @@ private async Task RebuildAsync(int departmentId, string generation, RmsSearchIn break; } + // A durable database checkpoint must never lead the committed Lucene segments. Replaying committed + // documents after a failed state save is safe; advancing state before an index commit loses changes. + await _indexer.CommitAsync(cancellationToken); state.State = (int)RmsSearchIndexBuildState.Ready; state.DocumentCount = indexed; state.LastRebuiltOn = DateTime.UtcNow; - state.LastIndexedModifiedOn = lastModified; + // Catch up writes made while rebuilding, including a row changed after its page was read. + state.LastIndexedModifiedOn = lastModified.HasValue && lastModified.Value > now ? now : lastModified; state.ModifiedOn = DateTime.UtcNow; await _states.SaveOrUpdateAsync(state, cancellationToken, true); @@ -172,14 +218,18 @@ private async Task RebuildAsync(int departmentId, string generation, RmsSearchIn private async Task CatchUpAsync(int departmentId, string generation, RmsSearchIndexState state, RecordsSearchIndexSweepResult result, CancellationToken cancellationToken) { var includeNarrative = await NarrativeAllowedAsync(departmentId); - var since = state.LastIndexedModifiedOn; - var batch = Math.Max(50, SearchConfig.IndexBatchSize); + // Replay the checkpoint's timestamp boundary to include rows committed at the same database timestamp + // after the preceding sweep drained it. Within a sweep use the repository's timestamp/id keyset. + var checkpoint = state.LastIndexedModifiedOn; + var since = checkpoint.HasValue && checkpoint.Value > DateTime.MinValue.AddSeconds(1) ? checkpoint.Value.AddSeconds(-1) : checkpoint; + string sinceId = null; + var batch = Math.Max(50, Math.Min(5000, SearchConfig.IndexBatchSize)); var touched = false; while (true) { cancellationToken.ThrowIfCancellationRequested(); - var page = (await _projections.GetModifiedSinceAsync(departmentId, since, batch))?.ToList() ?? new List(); + var page = (await _projections.GetModifiedSinceAsync(departmentId, since, batch, sinceId))?.ToList() ?? new List(); if (page.Count == 0) break; @@ -194,10 +244,12 @@ private async Task CatchUpAsync(int departmentId, string generation, RmsSearchIn result.DocumentsDeleted += deleted.Count; touched = true; - var pageMax = page.Max(p => p.ModifiedOn); - if (since.HasValue && pageMax <= since.Value) - break; - since = pageMax; + var last = page[page.Count - 1]; + if (since.HasValue && (last.ModifiedOn < since.Value || last.ModifiedOn == since.Value && string.Equals(last.RmsRecordSearchProjectionId, sinceId, StringComparison.Ordinal))) + throw new InvalidOperationException("The search change cursor did not advance; its checkpoint was not saved."); + since = last.ModifiedOn; + sinceId = last.RmsRecordSearchProjectionId; + checkpoint = Max(checkpoint, last.ModifiedOn); if (page.Count < batch) break; @@ -205,7 +257,8 @@ private async Task CatchUpAsync(int departmentId, string generation, RmsSearchIn if (touched) { - state.LastIndexedModifiedOn = since; + await _indexer.CommitAsync(cancellationToken); + state.LastIndexedModifiedOn = checkpoint; state.DocumentCount = await _indexer.CountDocumentsAsync(departmentId); state.ModifiedOn = DateTime.UtcNow; await _states.SaveOrUpdateAsync(state, cancellationToken, true); diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs index 934bc7a21..f8aa9f127 100644 --- a/Core/Resgrid.Services/Records/RecordsService.cs +++ b/Core/Resgrid.Services/Records/RecordsService.cs @@ -51,6 +51,8 @@ public class RecordsService : IRecordsService private readonly IUnitOfWork _unitOfWork; private readonly IOutboundQueueProvider _outboundQueue; private readonly IRecordAttachmentScanner _attachmentScanner; + private readonly IRecordsAuthorizationService _authorization; + private readonly IRecordsUdfService _udf; public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueService details, IRmsRecordParticipantsRepository participants, IRmsRecordUnitResponsesRepository units, IRmsRecordAttachmentsRepository attachments, @@ -58,7 +60,7 @@ public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueS IRmsRecordSearchProjectionsRepository projections, IRmsAccessAuditsRepository audits, IDomainEventOutboxService outbox, IRecordsCutoverService cutover, IDepartmentSettingsService settings, IDepartmentGroupsService groups, IUserProfileService profiles, IUnitsService unitsService, ICallsService calls, IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, - IOutboundQueueProvider outboundQueue, IRecordAttachmentScanner attachmentScanner) + IOutboundQueueProvider outboundQueue, IRecordAttachmentScanner attachmentScanner, IRecordsAuthorizationService authorization, IRecordsUdfService udf) { _records = records; _details = details; @@ -82,10 +84,40 @@ public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueS _unitOfWork = unitOfWork; _outboundQueue = outboundQueue; _attachmentScanner = attachmentScanner; + _authorization = authorization; + _udf = udf; } #region Create / save + public async Task CreateRunCallAsync(int departmentId, string userId, string recordId, long expectedRowVersion, RecordNewCallInput input, CancellationToken cancellationToken = default) + { + if (input == null || string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 200 || input.Address?.Length > 500 || input.Nature?.Length > 16000 || input.OccurredOnUtc == default || input.OccurredOnUtc > DateTime.UtcNow.AddMinutes(5)) + throw new ArgumentException("Enter a Call name and a valid past occurrence time; name, address and nature are limited to 200, 500 and 16,000 characters."); + await EnsureRecordsUsableAsync(departmentId); + await InTransactionAsync(async () => + { + var record = await LoadRecordAsync(departmentId, recordId); + await RequireAttachmentWriteAsync(record, userId, false); + if (record.DefinitionKey != RmsDefinitionKeys.Run || record.CallId.HasValue) throw new ArgumentException("Create a Call from a Run draft that is not already linked to a Call."); + if (!await _authorization.CanCreateSourceCallAsync(userId, departmentId)) throw new UnauthorizedAccessException(); + if (await _dataProtection.GetStateAsync(departmentId, true) != DepartmentDataProtectionState.Disabled) throw new InvalidOperationException("Creating a historical Call from RMS requires protected source capture support during Advanced Data Protection operation."); + await GuardVersionAsync(record, expectedRowVersion, cancellationToken); + var now = DateTime.UtcNow; + var call = await _calls.SaveCallAsync(new Call { DepartmentId = departmentId, ReportingUserId = userId, Name = input.Name.Trim(), Address = input.Address, + NatureOfCall = input.Nature, LoggedOn = DateTime.SpecifyKind(input.OccurredOnUtc, DateTimeKind.Utc), State = (int)CallStates.Closed, ClosedOn = now, ClosedByUserId = userId, Priority = (int)CallPriority.Low }, cancellationToken); + if (call == null || call.DepartmentId != departmentId || call.CallId <= 0) throw new InvalidOperationException("The Call could not be created."); + var details = await _details.GetDraftAsync(departmentId, recordId) ?? throw new InvalidOperationException("The Run draft is unavailable."); + await ApplyCallSnapshotAsync(departmentId, userId, details, call.CallId); + record.CallId = call.CallId; record.ModifiedByUserId = userId; record.ModifiedOn = now; + details.ModifiedOn = now; details.RowVersion++; + await _details.UpdateAsync(details, cancellationToken); await _records.UpdateAsync(record, cancellationToken, true); + await RefreshProjectionAsync(record, cancellationToken); + await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Created and linked historical Call " + call.CallId, RmsOriginClient.Web, cancellationToken); + }); + return await GetAsync(departmentId, recordId, false); + } + public async Task CreateDraftAsync(int departmentId, string userId, RecordDraftInput input, CancellationToken cancellationToken = default) { if (input == null) throw new ArgumentNullException(nameof(input)); @@ -94,12 +126,19 @@ public async Task CreateDraftAsync(int departmentId, string use throw new ArgumentException($"'{input.DefinitionKey}' is not a published definition.", nameof(input)); await EnsureRecordsUsableAsync(departmentId); - - if (!string.IsNullOrWhiteSpace(input.IdempotencyKey)) + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord)) + throw new UnauthorizedAccessException("Record creation is not authorized."); + + var scopedKey = string.IsNullOrWhiteSpace(input.IdempotencyKey) ? null + : RecordSnapshotSerializer.Checksum(JsonConvert.SerializeObject(new { departmentId, userId, operation = "CreateDraft", key = input.IdempotencyKey.Trim() })); + var request = Newtonsoft.Json.Linq.JObject.FromObject(input); + request.Remove(nameof(RecordDraftInput.IdempotencyKey)); + var requestChecksum = RecordSnapshotSerializer.Checksum(request.ToString(Formatting.None)); + if (scopedKey != null) { - var existing = await _records.GetByIdempotencyKeyAsync(departmentId, input.IdempotencyKey); + var existing = await _records.GetByIdempotencyKeyAsync(departmentId, scopedKey); if (existing != null) - return await HydrateAsync(existing, false); + return await ReplayCreateAsync(departmentId, userId, existing, requestChecksum); } var now = DateTime.UtcNow; @@ -125,7 +164,8 @@ public async Task CreateDraftAsync(int departmentId, string use OwnerUserId = userId, StartedOn = input.StartedOn, EndedOn = input.EndedOn, - IdempotencyKey = string.IsNullOrWhiteSpace(input.IdempotencyKey) ? null : input.IdempotencyKey, + IdempotencyKey = scopedKey, + OriginalRequestChecksum = scopedKey == null ? null : requestChecksum, OriginClient = (int)input.OriginClient, CreatedOn = now, CreatedByUserId = userId, @@ -144,8 +184,8 @@ public async Task CreateDraftAsync(int departmentId, string use ModifiedOn = now, RowVersion = 1 }; - ApplyDetails(details, input.Details); - await ApplyCallSnapshotAsync(departmentId, details, input.CallId); + await ApplyAuthorizedDetailsAsync(departmentId, userId, record.DefinitionKey, details, input.Details); + await ApplyCallSnapshotAsync(departmentId, userId, details, input.CallId); ValidateDefinitionRequirements(recordType, details); var participants = await BuildParticipantsAsync(departmentId, recordId, input.Participants, now); @@ -153,9 +193,13 @@ public async Task CreateDraftAsync(int departmentId, string use record.DisplaySummary = BuildDisplaySummary(recordType, record, details, units); var outboxIds = new List(); + try + { await InTransactionAsync(async () => { await _records.InsertAsync(record, cancellationToken, true); + record.UdfDefinitionId = await _udf.SaveInTransactionAsync(departmentId, userId, recordId, record.DefinitionKey, record.DefinitionVersion, null, input.CustomFields, cancellationToken); + await _records.UpdateAsync(record, cancellationToken, true); await _details.InsertAsync(details, cancellationToken); foreach (var participant in participants) await _participants.InsertAsync(participant, cancellationToken, true); @@ -170,15 +214,34 @@ await InTransactionAsync(async () => await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Create draft", input.OriginClient, cancellationToken, string.IsNullOrWhiteSpace(input.DuplicateContinueReason) ? null : new { duplicateContinueReason = input.DuplicateContinueReason }); }); + } + catch (DbException) when (scopedKey != null) + { + var winner = await _records.GetByIdempotencyKeyAsync(departmentId, scopedKey); + if (winner == null) throw; + return await ReplayCreateAsync(departmentId, userId, winner, requestChecksum); + } await _outbox.DispatchAfterCommitAsync(outboxIds, cancellationToken); return await GetAsync(departmentId, recordId, false); } + private async Task ReplayCreateAsync(int departmentId, string userId, RmsOperationalRecord existing, string requestChecksum) + { + if (existing.DeletedOn.HasValue || existing.PurgedOn.HasValue || existing.AuthorUserId != userId + || !await _authorization.CanUserViewRecordAsync(userId, existing.RmsOperationalRecordId, departmentId)) + throw new UnauthorizedAccessException("The original record is not accessible."); + if (!string.Equals(existing.OriginalRequestChecksum, requestChecksum, StringComparison.Ordinal)) + throw new RecordIdempotencyException("This idempotency key was already used for a different create request."); + return await HydrateAsync(existing, false); + } + public async Task SaveDraftAsync(int departmentId, string userId, string recordId, long expectedRowVersion, RecordDraftInput input, CancellationToken cancellationToken = default) { if (input == null) throw new ArgumentNullException(nameof(input)); var record = await LoadRecordAsync(departmentId, recordId); + if (!await _authorization.CanUserViewRecordAsync(userId, recordId, departmentId)) + throw new UnauthorizedAccessException("Record access is not authorized."); var state = (RmsRecordState)record.State; if (!RmsLifecycle.IsEditable(state) && record.AmendsRevisionId == null) @@ -189,6 +252,7 @@ public async Task SaveDraftAsync(int departmentId, string userI await InTransactionAsync(async () => { + await RequireAttachmentWriteAsync(record, userId, false); await GuardVersionAsync(record, expectedRowVersion, cancellationToken); var details = await _details.GetDraftAsync(departmentId, recordId) ?? new RmsOperationalRecordDetail @@ -200,18 +264,19 @@ await InTransactionAsync(async () => CreatedOn = now, RowVersion = 0 }; - ApplyDetails(details, input.Details); + await ApplyAuthorizedDetailsAsync(departmentId, userId, record.DefinitionKey, details, input.Details); + record.UdfDefinitionId = await _udf.SaveInTransactionAsync(departmentId, userId, recordId, record.DefinitionKey, record.DefinitionVersion, record.UdfDefinitionId, input.CustomFields, cancellationToken); if (input.CallId != record.CallId) - await ApplyCallSnapshotAsync(departmentId, details, input.CallId); + await ApplyCallSnapshotAsync(departmentId, userId, details, input.CallId); ValidateDefinitionRequirements(recordType, details); details.ModifiedOn = now; details.RowVersion += 1; await _details.SaveOrUpdateAsync(details, cancellationToken); - await _participants.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); - await _units.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); var participants = await BuildParticipantsAsync(departmentId, recordId, input.Participants, now); var units = await BuildUnitsAsync(departmentId, recordId, input.Units, now); + await _participants.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); + await _units.DeleteDraftForRecordAsync(departmentId, recordId, cancellationToken); foreach (var participant in participants) await _participants.InsertAsync(participant, cancellationToken, true); foreach (var unit in units) @@ -359,6 +424,7 @@ await InTransactionAsync(async () => var draft = await HydrateDraftAsync(record); ValidateDefinitionRequirements(recordType, draft.Details); ValidateForFinalization(recordType, draft); + _udf.ValidateForFinalization(draft.CustomFields); if (string.IsNullOrWhiteSpace(record.RecordNumber)) record.RecordNumber = await AllocateRecordNumberAsync(record, cancellationToken); @@ -573,12 +639,14 @@ 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(); } - public Task CountAsync(int departmentId, RmsRecordQuery query) + public async Task CountAsync(int departmentId, RmsRecordQuery query) { - return _projections.CountAsync(departmentId, query ?? new RmsRecordQuery()); + if (!string.IsNullOrEmpty(query?.ViewerUserId) && !await _authorization.IsActiveMemberAsync(query.ViewerUserId, departmentId)) return 0; + return await _projections.CountAsync(departmentId, query ?? new RmsRecordQuery()); } public async Task> GetYearsAsync(int departmentId) @@ -600,7 +668,10 @@ public async Task> GetRevisionsAsync(int departmentId, string public async Task GetRevisionSnapshotAsync(int departmentId, string revisionId) { var revision = await _revisions.GetByIdForDepartmentAsync(departmentId, revisionId); - return revision == null ? null : RecordSnapshotSerializer.Deserialize(revision.SnapshotJson); + if (revision == null) return null; + var record = await _records.GetByIdForDepartmentAsync(departmentId, revision.RecordId); + if (record == null || record.PurgedOn.HasValue || record.DeletedOn.HasValue) return null; + return RecordSnapshotSerializer.Deserialize(revision.SnapshotJson); } public async Task> DiffRevisionsAsync(int departmentId, string fromRevisionId, string toRevisionId, bool canViewRestricted) @@ -639,10 +710,16 @@ public async Task> GetProjectionsByIdsAsync(int #region Attachments - public async Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default) + public async Task AddAttachmentAsync(int departmentId, string userId, string recordId, string fileName, string contentType, byte[] data, string description, CancellationToken cancellationToken = default, int classification = 1) { if (data == null || data.Length == 0) throw new ArgumentException("Attachment content is required.", nameof(data)); var record = await LoadRecordAsync(departmentId, recordId); + if (!Enum.IsDefined(typeof(RmsEvidenceClassification), classification)) throw new ArgumentException("Choose an attachment classification."); + if (RmsDefinitionKeys.RestrictedClass.Contains(record.DefinitionKey)) classification = Math.Max(1, classification); + await RequireAttachmentWriteAsync(record, userId, classification != 0); + if (RmsLifecycle.IsFinalizedFamily((RmsRecordState)record.State) && record.AmendsRevisionId == null) + throw new InvalidOperationException("Add attachments through an amendment to preserve the finalized record."); + var expectedVersion = record.RowVersion; if (RmsLifecycle.IsTerminal((RmsRecordState)record.State)) throw new RecordTransitionException(recordId, (RmsRecordState)record.State, (RmsRecordState)record.State, "attachments cannot be added to a voided or cancelled Record"); @@ -661,6 +738,7 @@ public async Task AddAttachmentAsync(int departmentId, stri ProtectionId = Guid.NewGuid().ToString(), RecordId = recordId, FileName = hygiene.FileName, + Classification = classification, ContentType = hygiene.ContentType, ByteSize = hygiene.Data.LongLength, Checksum = RecordSnapshotSerializer.Checksum(hygiene.Data), @@ -677,6 +755,9 @@ public async Task AddAttachmentAsync(int departmentId, stri await InTransactionAsync(async () => { + record = await LoadRecordAsync(departmentId, recordId); + await RequireAttachmentWriteAsync(record, userId, classification != 0); + await GuardVersionAsync(record, expectedVersion, cancellationToken); await _attachments.InsertAsync(attachment, cancellationToken, true); record.ModifiedOn = now; record.ModifiedByUserId = userId; @@ -684,13 +765,24 @@ await InTransactionAsync(async () => await AuditAsync(departmentId, userId, recordId, null, RmsAccessAuditAction.Change, "Add attachment", RmsOriginClient.Web, cancellationToken, new { attachment.RmsRecordAttachmentId, attachment.ByteSize, attachment.Checksum }); }); - attachment.Data = null; - return attachment; + var metadata = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(attachment)); metadata.Data = null; return metadata; } - public Task GetAttachmentAsync(int departmentId, string attachmentId) + public async Task GetAttachmentAsync(int departmentId, string userId, string attachmentId) { - return _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId); + var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId); + if (attachment == null || attachment.DeletedOn.HasValue || !await _authorization.CanUserViewRecordAsync(userId, attachment.RecordId, departmentId)) return null; + if (attachment.RequiresRestrictedAccess && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) return null; + if (attachment.ScanState != (int)RmsAttachmentScanState.Clean || attachment.Data == null || RecordSnapshotSerializer.Checksum(attachment.Data) != attachment.Checksum) return null; + return attachment; + } + private async Task RequireAttachmentWriteAsync(RmsOperationalRecord record, string userId, bool restricted) + { + if (!await _authorization.CanUserViewRecordAsync(userId, record.RmsOperationalRecordId, record.DepartmentId) || !await _authorization.HasPermissionAsync(userId, record.DepartmentId, PermissionTypes.CreateRecord)) throw new UnauthorizedAccessException(); + if (restricted && !await _authorization.HasPermissionAsync(userId, record.DepartmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + if (record.AuthorUserId != userId && record.OwnerUserId != userId && !await _authorization.IsDepartmentAdminAsync(userId, record.DepartmentId) + && !(record.AmendsRevisionId != null && await _authorization.HasPermissionAsync(userId, record.DepartmentId, PermissionTypes.AmendRecords))) throw new UnauthorizedAccessException(); + if (!(RmsLifecycle.IsEditable((RmsRecordState)record.State) || record.AmendsRevisionId != null) || RmsLifecycle.IsTerminal((RmsRecordState)record.State)) throw new RecordTransitionException(record.RmsOperationalRecordId, (RmsRecordState)record.State, (RmsRecordState)record.State, "change attachments through an editable draft or amendment"); } public async Task RemoveAttachmentAsync(int departmentId, string userId, string recordId, string attachmentId, CancellationToken cancellationToken = default) @@ -702,9 +794,14 @@ public async Task RemoveAttachmentAsync(int departmentId, string userId, s var attachment = await _attachments.GetByIdForDepartmentAsync(departmentId, attachmentId); if (attachment == null || !string.Equals(attachment.RecordId, recordId, StringComparison.Ordinal)) return false; + await RequireAttachmentWriteAsync(record, userId, attachment.RequiresRestrictedAccess); + var expectedVersion = record.RowVersion; await InTransactionAsync(async () => { + record = await LoadRecordAsync(departmentId, recordId); + await RequireAttachmentWriteAsync(record, userId, attachment.RequiresRestrictedAccess); + await GuardVersionAsync(record, expectedVersion, cancellationToken); attachment.DeletedOn = DateTime.UtcNow; attachment.ModifiedOn = attachment.DeletedOn.Value; attachment.RowVersion += 1; @@ -729,7 +826,7 @@ private async Task EnsureRecordsUsableAsync(int departmentId) private async Task LoadRecordAsync(int departmentId, string recordId) { var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); - if (record == null || record.DeletedOn.HasValue) + if (record == null || record.DeletedOn.HasValue || record.PurgedOn.HasValue) throw new KeyNotFoundException($"Record {recordId} does not exist in this department."); return record; } @@ -780,6 +877,7 @@ private async Task HydrateDraftAsync(RmsOperationalRecord recor { return new RecordAggregate { + CustomFields = await _udf.CaptureAsync(record.DepartmentId, record.RmsOperationalRecordId, record.DefinitionKey, record.DefinitionVersion, record.UdfDefinitionId), Record = record, Details = await _details.GetDraftAsync(record.DepartmentId, record.RmsOperationalRecordId), Participants = (await _participants.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, null))?.ToList() ?? new List(), @@ -792,6 +890,18 @@ private async Task WriteRevisionAsync(RmsOperationalRecord record, string reasonCode, string reasonText, string attestationVersion, DateTime now, CancellationToken cancellationToken) { var snapshot = RecordSnapshotSerializer.Build(draft); + snapshot.SnapshotVersion = 2; + snapshot.Evidence = (await _evidence.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId))?.ToList() ?? new List(); + if (record.CurrentRevisionId != null) + { + var prior = await _revisions.GetByIdForDepartmentAsync(record.DepartmentId, record.CurrentRevisionId); + if (prior == null || prior.RecordId != record.RmsOperationalRecordId || RecordSnapshotSerializer.Checksum(prior.SnapshotJson) != prior.Checksum) throw new InvalidOperationException("The prior revision failed its integrity check."); + var priorSnapshot = RecordSnapshotSerializer.Deserialize(prior.SnapshotJson); + var priorEvidence = priorSnapshot.SnapshotVersion >= 2 ? priorSnapshot.Evidence : await _evidence.GetForRecordAsync(record.DepartmentId, record.RmsOperationalRecordId, prior.RmsRevisionId, true); + snapshot.Evidence.AddRange((priorEvidence ?? new List()).Where(e => !snapshot.Evidence.Any(n => n.Kind == e.Kind && n.SourceEntityId == e.SourceEntityId))); + } + snapshot.Evidence = snapshot.Evidence.OrderBy(e => e.RmsEvidenceArtifactId, StringComparer.Ordinal).ToList(); + if (transition != RmsRevisionTransition.Voided) await _evidence.RequireInventoryCoverageAsync(record.DepartmentId, record.RmsOperationalRecordId, snapshot.Evidence); snapshot.RecordNumber = record.RecordNumber; var json = RecordSnapshotSerializer.Serialize(snapshot); @@ -856,6 +966,8 @@ private async Task WriteRevisionAsync(RmsOperationalRecord record, private async Task RestoreDraftFromSnapshotAsync(RmsOperationalRecord record, RecordSnapshot snapshot, DateTime now, CancellationToken cancellationToken) { + await _udf.RestoreInTransactionAsync(record.DepartmentId, record.RmsOperationalRecordId, record.DefinitionKey, record.DefinitionVersion, snapshot.CustomFields, record.ModifiedByUserId, cancellationToken); + record.UdfDefinitionId = snapshot.CustomFields?.DefinitionId; var details = await _details.GetDraftAsync(record.DepartmentId, record.RmsOperationalRecordId); if (details != null && snapshot.Details != null) { @@ -1135,9 +1247,18 @@ private Task AuditAsync(int departmentId, string userId, string recordId, string private async Task> BuildParticipantsAsync(int departmentId, string recordId, IEnumerable inputs, DateTime now) { var result = new List(); + var existing = (await _participants.GetForRecordAsync(departmentId, recordId, null))?.ToList() ?? new List(); var ordinal = 0; foreach (var input in (inputs ?? Enumerable.Empty()).Where(i => !string.IsNullOrWhiteSpace(i.UserId))) { + if (result.Any(p => p.UserId == input.UserId)) throw new ArgumentException("List each participant only once."); + var prior = existing.FirstOrDefault(p => p.UserId == input.UserId); + if (prior == null && !await _authorization.IsActiveMemberAsync(input.UserId, departmentId)) throw new ArgumentException("The participant is not an active member of this department."); + if (input.UnitId.HasValue && (prior == null || input.UnitId != prior.UnitId)) + { + var assigned = await _unitsService.GetUnitByIdAsync(input.UnitId.Value); + if (assigned == null || assigned.DepartmentId != departmentId) throw new ArgumentException("The participant's assigned unit does not belong to this department."); + } var profile = await _profiles.GetProfileByUserIdAsync(input.UserId); var group = await _groups.GetGroupForUserAsync(input.UserId, departmentId); result.Add(new RmsRecordParticipant @@ -1197,7 +1318,7 @@ private async Task> BuildUnitsAsync(int departmentId return result; } - private async Task ApplyCallSnapshotAsync(int departmentId, RmsOperationalRecordDetail details, int? callId) + private async Task ApplyCallSnapshotAsync(int departmentId, string userId, RmsOperationalRecordDetail details, int? callId) { if (!callId.HasValue) { @@ -1214,6 +1335,8 @@ private async Task ApplyCallSnapshotAsync(int departmentId, RmsOperationalRecord var call = await _calls.GetCallByIdAsync(callId.Value); if (call == null || call.DepartmentId != departmentId) throw new ArgumentException($"Call {callId} does not belong to this department."); + if (!await _authorization.CanReadSourceCallAsync(userId, departmentId, call)) + throw new UnauthorizedAccessException("Source Call access is not authorized."); details.CallNumber = call.Number; details.CallName = call.Name; @@ -1224,12 +1347,30 @@ private async Task ApplyCallSnapshotAsync(int departmentId, RmsOperationalRecord details.CallNature = call.NatureOfCall; } + private async Task ApplyAuthorizedDetailsAsync(int departmentId, string userId, string definitionKey, RmsOperationalRecordDetail target, RmsOperationalRecordDetail source) + { + if (source == null) return; + if (RmsDefinitionKeys.RestrictedClass.Contains(definitionKey) && !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) + { + var copy = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source)); + foreach (var name in RecordSnapshotSerializer.RestrictedDetailFields) + { + var property = typeof(RmsOperationalRecordDetail).GetProperty(name); + if (!string.IsNullOrEmpty(property.GetValue(source) as string)) + throw new UnauthorizedAccessException("Restricted Record fields cannot be changed with your current permissions."); + property.SetValue(copy, property.GetValue(target)); + } + source = copy; + } + ApplyDetails(target, source); + } + private static void ApplyDetails(RmsOperationalRecordDetail target, RmsOperationalRecordDetail source) { if (source == null) return; - target.Narrative = source.Narrative; + target.Narrative = Resgrid.Framework.RecordNarrativeFormatter.ForStorage(source.Narrative); target.InitialReport = source.InitialReport; target.Type = source.Type; target.Course = source.Course; @@ -1261,7 +1402,7 @@ private static void ValidateDefinitionRequirements(RmsOperationalRecordType type /// Logs parity: a narrative is required on every legacy type; Unit Activity requires its timestamp. private static void ValidateForFinalization(RmsOperationalRecordType type, RecordAggregate draft) { - if (draft.Details == null || string.IsNullOrWhiteSpace(draft.Details.Narrative)) + if (draft.Details == null || !Resgrid.Framework.RecordNarrativeFormatter.HasText(draft.Details.Narrative)) throw new ArgumentException("A narrative is required before a Record can be finalized."); if (type == RmsOperationalRecordType.UnitActivity && !draft.Details.ActivityOn.HasValue) throw new ArgumentException("A Unit Activity record requires an activity time before it can be finalized."); diff --git a/Core/Resgrid.Services/Records/RecordsSubmissionService.cs b/Core/Resgrid.Services/Records/RecordsSubmissionService.cs index 0e40644af..6d4b6f9b8 100644 --- a/Core/Resgrid.Services/Records/RecordsSubmissionService.cs +++ b/Core/Resgrid.Services/Records/RecordsSubmissionService.cs @@ -1,9 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model; @@ -28,6 +30,8 @@ public class RecordsSubmissionService : IRecordsSubmissionService public const string RejectionNotificationPurpose = "Submission rejected"; private readonly IRmsSubmissionsRepository _submissions; + private readonly IRmsSubmissionExchangesRepository _exchanges; + private readonly IRecordsAuthorizationService _authorization; private readonly IRmsIncidentReportsRepository _reports; private readonly IRmsIncidentAnalysesRepository _analyses; private readonly IRmsDepartmentCutoversRepository _cutovers; @@ -42,9 +46,12 @@ public class RecordsSubmissionService : IRecordsSubmissionService public RecordsSubmissionService(IRmsSubmissionsRepository submissions, IRmsIncidentReportsRepository reports, IRmsIncidentAnalysesRepository analyses, IRmsRecordSearchProjectionsRepository projections, IRmsAccessAuditsRepository audits, INerisProfileService profiles, INerisSubmissionService delivery, IDomainEventOutboxService outbox, - IOutboundQueueProvider outboundQueue, IUnitOfWork unitOfWork, IRmsDepartmentCutoversRepository cutovers, IIncidentAnalysisService analysisService) + IOutboundQueueProvider outboundQueue, IUnitOfWork unitOfWork, IRmsDepartmentCutoversRepository cutovers, IIncidentAnalysisService analysisService, + IRmsSubmissionExchangesRepository exchanges, IRecordsAuthorizationService authorization) { _submissions = submissions; + _exchanges = exchanges; + _authorization = authorization; _reports = reports; _analyses = analyses; _cutovers = cutovers; @@ -83,12 +90,13 @@ public async Task SweepAsync(CancellationToken can } var owner = $"{Environment.MachineName}:{Guid.NewGuid():N}"; - var claimed = (await _submissions.ClaimDueBatchAsync(owner, TimeSpan.FromSeconds(Math.Max(30, NerisConfig.LeaseSeconds)), Math.Max(1, NerisConfig.BatchSize), DateTime.UtcNow, cancellationToken))?.ToList() ?? new List(); - result.Claimed = claimed.Count; - - foreach (var submission in claimed) + for (var i = 0; i < Math.Max(1, NerisConfig.BatchSize); i++) { cancellationToken.ThrowIfCancellationRequested(); + // Claim when ready to work; a serial batch must not spend the last item's lease waiting on its predecessors. + var submission = (await _submissions.ClaimDueBatchAsync(owner, TimeSpan.FromSeconds(Math.Max(30, NerisConfig.LeaseSeconds)), 1, DateTime.UtcNow, cancellationToken))?.FirstOrDefault(); + if (submission == null) break; + result.Claimed++; try { var processed = await ProcessAsync(submission, cancellationToken); @@ -116,10 +124,180 @@ public async Task SweepAsync(CancellationToken can return result; } + public async Task> GetHistoryAsync(int departmentId, string userId, string submissionId, CancellationToken cancellationToken = default) + { + var submission = await _submissions.GetByIdForDepartmentAsync(departmentId, submissionId); + if (submission == null) throw new InvalidOperationException("The submission does not exist."); + var analysis = submission.Destination == RmsSubmissionDestinations.NerisIncidentAnalysis ? await _analyses.GetByIdForDepartmentAsync(departmentId, submission.RecordId) : null; + async Task Authorize() + { + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.SubmitRecords) + || !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords) + || !await _authorization.CanUserViewRecordAsync(userId, analysis?.IncidentReportId ?? submission.RecordId, departmentId)) throw new UnauthorizedAccessException(); + } + await Authorize(); + var history = (await _exchanges.GetForSubmissionAsync(departmentId, submissionId))?.ToList() ?? new List(); + if (history.Any(e => e.DepartmentId != departmentId || e.SubmissionId != submissionId || e.OutcomeJson != null && e.OutcomeChecksum != RecordSnapshotSerializer.Checksum(e.OutcomeJson))) + throw new InvalidOperationException("The submission exchange history failed its integrity check."); + await InTransactionAsync(async () => await _audits.InsertAsync(new RmsAccessAudit + { + DepartmentId = departmentId, RecordId = submission.RecordId, RevisionId = submission.RevisionId, ActorUserId = userId, + Action = (int)RmsAccessAuditAction.Export, Purpose = "Submission exchange history", OriginClient = (int)RmsOriginClient.Web, Successful = true, OccurredOn = DateTime.UtcNow + }, cancellationToken, true)); + await Authorize(); + return history; + } + + public async Task ReconcileAsync(int departmentId, string userId, string submissionId, long expectedVersion, string externalId, string reason, CancellationToken cancellationToken = default) + { + if (externalId?.Length > 100 || string.IsNullOrWhiteSpace(reason) || reason.Length > 2000) + throw new ArgumentException("A reconciliation reason and a valid destination identifier are required."); + var submission = await _submissions.GetByIdForDepartmentAsync(departmentId, submissionId); + if (submission == null) throw new InvalidOperationException("The submission does not exist."); + var analysis = submission.Destination == RmsSubmissionDestinations.NerisIncidentAnalysis + ? await _analyses.GetByIdForDepartmentAsync(departmentId, submission.RecordId) : null; + var reportId = analysis?.IncidentReportId ?? submission.RecordId; + async Task Authorize() + { + if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.SubmitRecords) + || !await _authorization.CanUserViewRecordAsync(userId, reportId, departmentId)) throw new UnauthorizedAccessException(); + } + await Authorize(); + if (submission.RowVersion != expectedVersion) throw new RecordConcurrencyException(submission.RecordId, expectedVersion, submission.RowVersion); + var bindUnsent = submission.DestinationIdentity == null && submission.SentOn == null && submission.Attempts == 0 + && submission.ExternalId == null && !submission.RequiresReconciliation && !submission.CreatePendingReceipt; + if (!bindUnsent && !submission.RequiresReconciliation && !submission.CreatePendingReceipt) throw new InvalidOperationException("This submission does not need reconciliation."); + if (submission.PayloadChecksum != RecordSnapshotSerializer.Checksum(submission.PayloadJson)) throw new InvalidOperationException("The queued payload failed its integrity check."); + var profile = await _profiles.GetProfileAsync(departmentId); + var destination = _profiles.GetDestinationIdentity(profile); + var report = await _reports.GetByIdForDepartmentAsync(departmentId, reportId); + if (profile == null || report == null || report.ReportingEntityId != profile.NerisEntityId || + (!string.IsNullOrEmpty(submission.DestinationIdentity) && submission.DestinationIdentity != destination)) + throw new InvalidOperationException("Restore the original reporting entity and destination profile before reconciliation."); + if (bindUnsent) + { + if (!string.IsNullOrWhiteSpace(externalId)) throw new ArgumentException("Use receipt verification for an existing destination filing."); + if (report.DeletedOn.HasValue || RmsLifecycle.IsTerminal((RmsRecordState)report.State) + || (analysis?.CurrentRevisionId ?? report.CurrentRevisionId) != submission.RevisionId) + throw new InvalidOperationException("Only the current, active revision can be bound for delivery."); + await InTransactionAsync(async () => + { + await Authorize(); + if (!await _submissions.TryBindUnsentAsync(departmentId, submissionId, expectedVersion, destination, DateTime.UtcNow, cancellationToken)) + throw new RecordConcurrencyException(submission.RecordId, expectedVersion, expectedVersion + 1); + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = submission.RecordId, RevisionId = submission.RevisionId, + ActorUserId = userId, Action = (int)RmsAccessAuditAction.Submit, Purpose = "Bind unsent legacy submission", OriginClient = (int)RmsOriginClient.Web, + Successful = true, OccurredOn = DateTime.UtcNow, DetailJson = JsonConvert.SerializeObject(new { submissionId, reason = reason.Trim(), destination }) }, cancellationToken, true); + }); + return; + } + if (string.IsNullOrWhiteSpace(externalId)) throw new ArgumentException("A destination identifier is required to verify a filing that may already exist."); + var outcome = analysis == null ? await _delivery.CheckStatusAsync(profile, externalId.Trim(), cancellationToken) + : await _delivery.CheckAnalysisStatusAsync(profile, externalId.Trim(), cancellationToken); + JObject receipt = null; + try { receipt = string.IsNullOrEmpty(outcome?.ResponseJson) ? null : JObject.Parse(outcome.ResponseJson); } catch (JsonException) { } + var payload = JObject.Parse(submission.PayloadJson); + var matchField = analysis == null ? "incident_number" : "neris_id_incident"; + if (outcome?.StatusCode != 200 || (string)receipt?["neris_id"] != externalId.Trim() + || string.IsNullOrEmpty((string)payload["base"]?[matchField]) || !JToken.DeepEquals(receipt?["base"]?[matchField], payload["base"]?[matchField]) + || !JToken.DeepEquals(receipt?["base"]?["incident_number"], payload["base"]?["incident_number"]) + || analysis == null && ((string)receipt?["base"]?["department_neris_id"] != profile.NerisEntityId + || !SameInstant(receipt?["dispatch"]?["call_create"], payload["dispatch"]?["call_create"]))) + throw new InvalidOperationException("The destination receipt does not match the queued report. No filing was changed."); + await InTransactionAsync(async () => + { + await Authorize(); + if (!await _submissions.TryReconcileReceiptAsync(departmentId, submissionId, expectedVersion, externalId.Trim(), destination, DateTime.UtcNow, cancellationToken)) + throw new RecordConcurrencyException(submission.RecordId, expectedVersion, expectedVersion + 1); + var history = (await _exchanges.GetForSubmissionAsync(departmentId, submissionId))?.ToList() ?? new List(); + foreach (var started in history.Where(e => e.Stage == "Started" && !history.Any(a => a.ExchangeId == e.ExchangeId && (a.Stage == "Applied" || a.Stage == "Reconciled")))) + await AppendExchangeAsync(started, "Reconciled", outcome, cancellationToken); + await AppendExchangeAsync(new RmsSubmissionExchange { DepartmentId = departmentId, SubmissionId = submissionId, RecordId = submission.RecordId, + RevisionId = submission.RevisionId, ExchangeId = Guid.NewGuid().ToString(), Operation = "Reconcile", DestinationIdentity = destination, + PayloadChecksum = submission.PayloadChecksum, AttemptNumber = submission.Attempts }, "Reconciled", outcome, cancellationToken); + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = submission.RecordId, RevisionId = submission.RevisionId, + ActorUserId = userId, Action = (int)RmsAccessAuditAction.Submit, Purpose = "Reconcile destination receipt", OriginClient = (int)RmsOriginClient.Web, + Successful = true, OccurredOn = DateTime.UtcNow, DetailJson = JsonConvert.SerializeObject(new { submissionId, externalId = externalId.Trim(), reason = reason.Trim(), destination }) }, cancellationToken, true); + }); + } + + public async Task ConfirmNotCreatedAsync(int departmentId, string userId, string submissionId, long expectedVersion, string verificationReference, string reason, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(verificationReference) || verificationReference.Length > 500 || string.IsNullOrWhiteSpace(reason) || reason.Length > 2000) + throw new ArgumentException("Record the destination verification reference and the reason it confirms no filing was created."); + var submission = Copy(await _submissions.GetByIdForDepartmentAsync(departmentId, submissionId)); + if (submission == null) throw new InvalidOperationException("The submission does not exist."); + var isAnalysis = submission.Destination == RmsSubmissionDestinations.NerisIncidentAnalysis; + if (!isAnalysis && submission.Destination != RmsSubmissionDestinations.Neris) throw new InvalidOperationException("Unsupported destination."); + var destination = _profiles.GetDestinationIdentity(await _profiles.GetProfileAsync(departmentId)); + async Task ValidateAsync() + { + if (!await _authorization.IsDepartmentAdminAsync(userId, departmentId) || !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.SubmitRecords)) throw new UnauthorizedAccessException(); + var analysis = isAnalysis ? await _analyses.GetByIdForDepartmentAsync(departmentId, submission.RecordId) : null; + if (isAnalysis && (analysis == null || analysis.DeletedOn.HasValue || analysis.State == (int)RmsIncidentAnalysisState.Voided)) throw new InvalidOperationException("The analysis is unavailable."); + var report = await _reports.GetByIdForDepartmentAsync(departmentId, analysis?.IncidentReportId ?? submission.RecordId); + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue || RmsLifecycle.IsTerminal((RmsRecordState)report.State)) throw new InvalidOperationException("The report is unavailable."); + if (!await _authorization.CanUserViewRecordAsync(userId, report.RmsIncidentReportId, departmentId)) throw new UnauthorizedAccessException(); + var profile = await _profiles.GetProfileAsync(departmentId); + if (string.IsNullOrWhiteSpace(destination) || profile == null || profile.NerisEntityId != report.ReportingEntityId || destination != _profiles.GetDestinationIdentity(profile) + || !string.IsNullOrWhiteSpace(submission.DestinationIdentity) && submission.DestinationIdentity != destination) + throw new InvalidOperationException("Restore the original reporting entity and destination profile before recording the verification."); + var prior = (await _submissions.GetForRecordAsync(departmentId, submission.RecordId)) ?? Enumerable.Empty(); + if (!string.IsNullOrWhiteSpace(isAnalysis ? analysis.NerisAnalysisId : report.NerisIncidentId) || prior.Any(s => !string.IsNullOrWhiteSpace(s.ExternalId))) + throw new InvalidOperationException("A destination filing is already recorded. Verify its receipt instead of declaring it absent."); + } + await ValidateAsync(); + if (submission.RowVersion != expectedVersion) throw new RecordConcurrencyException(submission.RecordId, expectedVersion, submission.RowVersion); + if (submission.PayloadChecksum != RecordSnapshotSerializer.Checksum(submission.PayloadJson)) throw new InvalidOperationException("The queued payload failed its integrity check."); + if (!submission.RequiresReconciliation && !submission.CreatePendingReceipt && submission.State != (int)RmsSubmissionState.Failed && submission.State != (int)RmsSubmissionState.Rejected) + throw new InvalidOperationException("Only an ambiguous or failed delivery can be resolved as not created."); + await InTransactionAsync(async () => + { + // Department lock and CAS exclude a worker receipt, requeue or concurrent recovery decision. + if (!await _submissions.TryConfirmNotCreatedAsync(departmentId, submissionId, expectedVersion, destination, DateTime.UtcNow, cancellationToken)) + throw new RecordConcurrencyException(submission.RecordId, expectedVersion, expectedVersion + 1); + await ValidateAsync(); + var outcome = new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Rejected, + Message = "Administrator recorded external verification that no filing was created; a deliberate submission action is required.", + ResponseJson = JsonConvert.SerializeObject(new { administrator = userId, verificationReference = verificationReference.Trim(), reason = reason.Trim() }) }; + var history = (await _exchanges.GetForSubmissionAsync(departmentId, submissionId))?.ToList() ?? new List(); + foreach (var started in history.Where(e => e.Stage == "Started" && !history.Any(a => a.ExchangeId == e.ExchangeId && (a.Stage == "Applied" || a.Stage == "Reconciled")))) + await AppendExchangeAsync(started, "Reconciled", outcome, cancellationToken); + await AppendExchangeAsync(new RmsSubmissionExchange { DepartmentId = departmentId, SubmissionId = submissionId, RecordId = submission.RecordId, + RevisionId = submission.RevisionId, ExchangeId = Guid.NewGuid().ToString(), Operation = "ConfirmNotCreated", DestinationIdentity = destination, + PayloadChecksum = submission.PayloadChecksum, AttemptNumber = submission.Attempts }, "Reconciled", outcome, cancellationToken); + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = submission.RecordId, RevisionId = submission.RevisionId, + ActorUserId = userId, Action = (int)RmsAccessAuditAction.Submit, Purpose = "Destination absence externally verified", OriginClient = (int)RmsOriginClient.Web, + Successful = true, OccurredOn = DateTime.UtcNow, DetailJson = JsonConvert.SerializeObject(new { submissionId, verificationReference = verificationReference.Trim(), reason = reason.Trim(), destination }) }, cancellationToken, true); + }); + } + + private static bool SameInstant(JToken left, JToken right) + { + DateTimeOffset? Read(JToken token) + { + if (token == null || token.Type == JTokenType.Null) return null; + if (token.Type == JTokenType.Date) return token.ToObject(); + var value = token.Value(); + if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var seconds)) + { + try { return DateTimeOffset.UnixEpoch.AddSeconds((double)seconds); } catch (ArgumentOutOfRangeException) { return null; } + } + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var instant) ? instant : null; + } + var a = Read(left); var b = Read(right); + return a.HasValue && b.HasValue && a.Value == b.Value; + } + public async Task ProcessAsync(RmsSubmission submission, CancellationToken cancellationToken = default) { if (submission == null) throw new ArgumentNullException(nameof(submission)); + submission = Copy(submission); var now = DateTime.UtcNow; + var current = await _submissions.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RmsSubmissionId); + if (current == null || current.RowVersion != submission.RowVersion || current.LeaseOwner != submission.LeaseOwner + || string.IsNullOrEmpty(submission.LeaseOwner) || current.LeaseExpiresOn <= now || !current.LeaseExpiresOn.HasValue) + return current ?? submission; // The incident-analysis filing (RMS-3) rides the same queue and the same lease, but it is a different // endpoint against a different aggregate, and it raises none of triggers 108-111: those describe the @@ -127,12 +305,12 @@ public async Task ProcessAsync(RmsSubmission submission, Cancella if (string.Equals(submission.Destination, RmsSubmissionDestinations.NerisIncidentAnalysis, StringComparison.Ordinal)) return await ProcessAnalysisAsync(submission, now, cancellationToken); - var report = await _reports.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RecordId); - if (report == null || report.DeletedOn.HasValue) + var report = Copy(await _reports.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RecordId)); + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue) return await PersistAsync(submission, report, Fatal("The report no longer exists."), now, false, cancellationToken); // A superseding revision or a void may have arrived while this row waited; never deliver stale content. - if (submission.State == (int)RmsSubmissionState.Superseded || RmsLifecycle.IsTerminal((RmsRecordState)report.State)) + if (submission.State == (int)RmsSubmissionState.Superseded || RmsLifecycle.IsTerminal((RmsRecordState)report.State) || report.CurrentRevisionId != submission.RevisionId) { submission.State = (int)RmsSubmissionState.Superseded; submission.CompletedOn = now; @@ -146,23 +324,19 @@ public async Task ProcessAsync(RmsSubmission submission, Cancella return await ReleaseAsync(submission, now, cancellationToken); } - NerisSubmissionOutcome outcome; var wasDelivery = submission.State != (int)RmsSubmissionState.AwaitingDestination; - if (!wasDelivery) - { - var nerisId = submission.ExternalId ?? report.NerisIncidentId; - outcome = string.IsNullOrWhiteSpace(nerisId) - ? Fatal("The submission is awaiting the destination but carries no incident ID.") - : await _delivery.CheckStatusAsync(profile, nerisId, cancellationToken); - } - else + string externalId; + try { externalId = ResolveDestinationId(await _submissions.GetForRecordAsync(submission.DepartmentId, submission.RecordId), submission.DestinationIdentity, submission.ExternalId ?? report.NerisIncidentId); } + catch (InvalidOperationException ex) { return await PersistAsync(submission, report, Fatal(ex.Message), now, false, cancellationToken); } + var operation = !wasDelivery ? "Poll" : string.IsNullOrEmpty(externalId) ? "Create" : "Update"; + try { - submission.Attempts += 1; - submission.SentOn = now; - outcome = await _delivery.DeliverAsync(profile, submission, report.NerisIncidentId, cancellationToken); + var exchange = await ExchangeAsync(submission, profile, operation, () => !wasDelivery + ? _delivery.CheckStatusAsync(profile, externalId, cancellationToken) + : _delivery.DeliverAsync(profile, submission, externalId, cancellationToken), cancellationToken); + return await PersistAsync(submission, report, exchange.outcome, DateTime.UtcNow, wasDelivery, cancellationToken, exchange.entry); } - - return await PersistAsync(submission, report, outcome, now, wasDelivery, cancellationToken); + catch (SubmissionLeaseLostException) { return await _submissions.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RmsSubmissionId) ?? submission; } } /// @@ -171,11 +345,11 @@ public async Task ProcessAsync(RmsSubmission submission, Cancella /// private async Task ProcessAnalysisAsync(RmsSubmission submission, DateTime now, CancellationToken cancellationToken) { - var analysis = await _analyses.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RecordId); + var analysis = Copy(await _analyses.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RecordId)); if (analysis == null || analysis.DeletedOn.HasValue) return await PersistAnalysisAsync(submission, null, Fatal("The incident analysis no longer exists."), now, false, cancellationToken); - if (submission.State == (int)RmsSubmissionState.Superseded || (RmsIncidentAnalysisState)analysis.State == RmsIncidentAnalysisState.Voided) + if (submission.State == (int)RmsSubmissionState.Superseded || (RmsIncidentAnalysisState)analysis.State == RmsIncidentAnalysisState.Voided || analysis.CurrentRevisionId != submission.RevisionId) { submission.State = (int)RmsSubmissionState.Superseded; submission.CompletedOn = now; @@ -191,29 +365,34 @@ private async Task ProcessAnalysisAsync(RmsSubmission submission, var report = await _reports.GetByIdForDepartmentAsync(submission.DepartmentId, analysis.IncidentReportId); - NerisSubmissionOutcome outcome; var wasDelivery = submission.State != (int)RmsSubmissionState.AwaitingDestination; - if (!wasDelivery) + string externalId; + string parentExternalId; + try { - var analysisId = submission.ExternalId ?? analysis.NerisAnalysisId; - outcome = string.IsNullOrWhiteSpace(analysisId) - ? Fatal("The analysis submission is awaiting the destination but carries no analysis ID.") - : await _delivery.CheckAnalysisStatusAsync(profile, analysisId, cancellationToken); + externalId = ResolveDestinationId(await _submissions.GetForRecordAsync(submission.DepartmentId, submission.RecordId), submission.DestinationIdentity, submission.ExternalId ?? analysis.NerisAnalysisId); + parentExternalId = ResolveDestinationId(await _submissions.GetForRecordAsync(submission.DepartmentId, analysis.IncidentReportId), submission.DestinationIdentity, report?.NerisIncidentId); } - else + catch (InvalidOperationException ex) { return await PersistAnalysisAsync(submission, analysis, Fatal(ex.Message), now, false, cancellationToken); } + var operation = !wasDelivery ? "Poll" : string.IsNullOrEmpty(externalId) ? "Create" : "Update"; + try { - submission.Attempts += 1; - submission.SentOn = now; - outcome = await _delivery.DeliverAnalysisAsync(profile, submission, report?.NerisIncidentId, analysis.NerisAnalysisId, cancellationToken); + var exchange = await ExchangeAsync(submission, profile, operation, () => !wasDelivery + ? _delivery.CheckAnalysisStatusAsync(profile, externalId, cancellationToken) + : _delivery.DeliverAnalysisAsync(profile, submission, parentExternalId, externalId, cancellationToken), cancellationToken); + return await PersistAnalysisAsync(submission, analysis, exchange.outcome, DateTime.UtcNow, wasDelivery, cancellationToken, exchange.entry); } - - return await PersistAnalysisAsync(submission, analysis, outcome, now, wasDelivery, cancellationToken); + catch (SubmissionLeaseLostException) { return await _submissions.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RmsSubmissionId) ?? submission; } } - private async Task PersistAnalysisAsync(RmsSubmission submission, RmsIncidentAnalysis analysis, NerisSubmissionOutcome outcome, DateTime now, bool wasDelivery, CancellationToken cancellationToken) + private async Task PersistAnalysisAsync(RmsSubmission submission, RmsIncidentAnalysis analysis, NerisSubmissionOutcome outcome, DateTime now, bool wasDelivery, CancellationToken cancellationToken, RmsSubmissionExchange exchange = null) { await InTransactionAsync(async () => { + await FenceAsync(submission, cancellationToken); + analysis = await CurrentAnalysisAsync(submission, cancellationToken); + if (outcome.DeliveryUncertain || submission.CreatePendingReceipt && outcome.Kind == NerisOutcomeKind.Fatal && exchange?.Stage != "Response") { outcome.Kind = NerisOutcomeKind.Fatal; submission.RequiresReconciliation = true; } + else if (exchange?.Stage == "Response") { submission.RequiresReconciliation = false; submission.CreatePendingReceipt = false; } if (outcome.ResponseJson != null) { submission.ResponseJson = outcome.ResponseJson; @@ -294,6 +473,7 @@ await InTransactionAsync(async () => submission.ModifiedOn = now; submission.RowVersion += 1; await _submissions.UpdateAsync(submission, cancellationToken, true); + if (exchange?.Stage == "Response") await AppendExchangeAsync(exchange, "Applied", null, cancellationToken); if (analysis != null) { @@ -329,13 +509,17 @@ await _audits.InsertAsync(new RmsAccessAudit return submission; } - private async Task PersistAsync(RmsSubmission submission, RmsIncidentReport report, NerisSubmissionOutcome outcome, DateTime now, bool wasDelivery, CancellationToken cancellationToken) + private async Task PersistAsync(RmsSubmission submission, RmsIncidentReport report, NerisSubmissionOutcome outcome, DateTime now, bool wasDelivery, CancellationToken cancellationToken, RmsSubmissionExchange exchange = null) { var outboxIds = new List(); NotificationItem notification = null; await InTransactionAsync(async () => { + await FenceAsync(submission, cancellationToken); + report = await CurrentReportAsync(submission, cancellationToken); + if (outcome.DeliveryUncertain || submission.CreatePendingReceipt && outcome.Kind == NerisOutcomeKind.Fatal && exchange?.Stage != "Response") { outcome.Kind = NerisOutcomeKind.Fatal; submission.RequiresReconciliation = true; } + else if (exchange?.Stage == "Response") { submission.RequiresReconciliation = false; submission.CreatePendingReceipt = false; } if (outcome.ResponseJson != null) { submission.ResponseJson = outcome.ResponseJson; @@ -421,6 +605,7 @@ await InTransactionAsync(async () => submission.ModifiedOn = now; submission.RowVersion += 1; await _submissions.UpdateAsync(submission, cancellationToken, true); + if (exchange?.Stage == "Response") await AppendExchangeAsync(exchange, "Applied", null, cancellationToken); if (report != null) { @@ -429,7 +614,7 @@ await InTransactionAsync(async () => report.LastSubmissionId = submission.RmsSubmissionId; report.LastSubmissionState = submission.State; var from = (RmsRecordState)report.State; - if (reportState.HasValue && reportState.Value != from) + if (reportState.HasValue && reportState.Value != from && string.IsNullOrEmpty(report.AmendsRevisionId)) { report.State = (int)reportState.Value; if (reportState == RmsRecordState.Accepted) report.AcceptedOn = now; @@ -493,6 +678,7 @@ await _audits.InsertAsync(new RmsAccessAudit private static RmsRecordState? ReportStateAfterDelivery(RmsIncidentReport report) { + if (report == null || !string.IsNullOrEmpty(report.AmendsRevisionId)) return null; var state = (RmsRecordState)report.State; return state == RmsRecordState.Finalized || state == RmsRecordState.Amended || state == RmsRecordState.Corrected || state == RmsRecordState.Rejected ? RmsRecordState.Submitted @@ -516,14 +702,151 @@ public static string Summarize(NerisSubmissionOutcome outcome) private async Task ReleaseAsync(RmsSubmission submission, DateTime now, CancellationToken cancellationToken) { - submission.LeaseOwner = null; - submission.LeaseExpiresOn = null; - submission.ModifiedOn = now; - submission.RowVersion += 1; - await _submissions.UpdateAsync(submission, cancellationToken, true); + try + { + await InTransactionAsync(async () => + { + await FenceAsync(submission, cancellationToken); + submission.LeaseOwner = null; + submission.LeaseExpiresOn = null; + submission.ModifiedOn = now; + await _submissions.UpdateAsync(submission, cancellationToken, true); + }); + } + catch (SubmissionLeaseLostException) { return await _submissions.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RmsSubmissionId) ?? submission; } return submission; } + private sealed class SubmissionLeaseLostException : Exception { } + internal static void RequireResolvedCreates(IEnumerable submissions) + { + if ((submissions ?? Enumerable.Empty()).Any(s => s.RequiresReconciliation || s.CreatePendingReceipt)) + throw new InvalidOperationException("An earlier delivery may have created this report. Resolve that delivery before queuing another revision."); + } + + internal static string ResolveDestinationId(IEnumerable submissions, string destination, string declaredId) + { + var receipts = (submissions ?? Enumerable.Empty()).Where(s => !string.IsNullOrWhiteSpace(s.ExternalId)).ToList(); + if (receipts.Any(s => s.DestinationIdentity != destination)) + throw new InvalidOperationException("This report has a filing in another destination. Restore that profile or explicitly reconcile the destination before submitting."); + var ids = receipts.Select(s => s.ExternalId).Distinct(StringComparer.Ordinal).ToList(); + if (ids.Count > 1 || !string.IsNullOrEmpty(declaredId) && !ids.Contains(declaredId, StringComparer.Ordinal)) + throw new InvalidOperationException("The report's destination identifier is not bound to a verified filing. Reconcile it before submitting."); + return ids.FirstOrDefault(); + } + private static T Copy(T value) where T : class => value == null ? null : JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value)); + + private async Task FenceAsync(RmsSubmission submission, CancellationToken cancellationToken) + { + if (!await _submissions.TryFenceLeaseAsync(submission.DepartmentId, submission.RmsSubmissionId, submission.RowVersion, submission.LeaseOwner, DateTime.UtcNow, cancellationToken)) + throw new SubmissionLeaseLostException(); + submission.RowVersion++; + } + + // Reload after HTTP and take a short optimistic write lock. An edit, void, or newer revision must win over a late response. + private async Task CurrentReportAsync(RmsSubmission submission, CancellationToken cancellationToken) + { + var report = Copy(await _reports.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RecordId)); + if (report == null || report.DeletedOn.HasValue || report.PurgedOn.HasValue || RmsLifecycle.IsTerminal((RmsRecordState)report.State) + || report.CurrentRevisionId != submission.RevisionId || report.LastSubmissionId != submission.RmsSubmissionId) return null; + if (!await _reports.TryBumpRowVersionAsync(report.DepartmentId, report.RmsIncidentReportId, report.RowVersion, cancellationToken)) throw new SubmissionLeaseLostException(); + report.RowVersion++; + return report; + } + + private async Task CurrentAnalysisAsync(RmsSubmission submission, CancellationToken cancellationToken) + { + var analysis = Copy(await _analyses.GetByIdForDepartmentAsync(submission.DepartmentId, submission.RecordId)); + if (analysis == null || analysis.DeletedOn.HasValue || analysis.State == (int)RmsIncidentAnalysisState.Voided + || analysis.CurrentRevisionId != submission.RevisionId || analysis.LastSubmissionId != submission.RmsSubmissionId) return null; + if (!await _analyses.TryBumpRowVersionAsync(analysis.DepartmentId, analysis.RmsIncidentAnalysisId, analysis.RowVersion, cancellationToken)) throw new SubmissionLeaseLostException(); + analysis.RowVersion++; + return analysis; + } + + private async Task<(NerisSubmissionOutcome outcome, RmsSubmissionExchange entry)> ExchangeAsync(RmsSubmission submission, RmsNerisProfile profile, string operation, + Func> send, CancellationToken cancellationToken) + { + if (profile == null || string.IsNullOrWhiteSpace(submission.DestinationIdentity) + || submission.DestinationIdentity != _profiles.GetDestinationIdentity(profile)) + return (Fatal("The queued destination does not match the current profile. Restore the pinned profile before retrying."), null); + if (string.IsNullOrWhiteSpace(submission.PayloadJson) || submission.PayloadChecksum != RecordSnapshotSerializer.Checksum(submission.PayloadJson)) + return (Fatal("The queued payload failed its integrity check."), null); + + var history = (await _exchanges.GetForSubmissionAsync(submission.DepartmentId, submission.RmsSubmissionId))?.ToList() ?? new List(); + var unfinished = history.Where(e => e.Stage == "Started" && !history.Any(a => a.ExchangeId == e.ExchangeId && (a.Stage == "Applied" || a.Stage == "Reconciled"))).OrderByDescending(e => e.OccurredOn).FirstOrDefault(); + if (unfinished != null) + { + var receipt = history.SingleOrDefault(e => e.ExchangeId == unfinished.ExchangeId && e.Stage == "Response"); + if (receipt != null) + { + if (receipt.DestinationIdentity != submission.DestinationIdentity || receipt.PayloadChecksum != submission.PayloadChecksum + || receipt.OutcomeChecksum != RecordSnapshotSerializer.Checksum(receipt.OutcomeJson)) return (Fatal("The saved response failed its integrity check."), null); + return (JsonConvert.DeserializeObject(receipt.OutcomeJson), receipt); + } + // No receipt means a process may have stopped after the remote POST committed. Never issue a second create blindly. + if (unfinished.Operation == "Create") return (Uncertain(), unfinished); + } + if (submission.RequiresReconciliation || submission.CreatePendingReceipt) return (Uncertain(), null); + + var entry = new RmsSubmissionExchange + { + DepartmentId = submission.DepartmentId, SubmissionId = submission.RmsSubmissionId, RecordId = submission.RecordId, + RevisionId = submission.RevisionId, ExchangeId = Guid.NewGuid().ToString(), Operation = operation, + DestinationIdentity = submission.DestinationIdentity, PayloadChecksum = submission.PayloadChecksum + }; + await InTransactionAsync(async () => + { + await FenceAsync(submission, cancellationToken); + if (submission.Destination == RmsSubmissionDestinations.NerisIncidentAnalysis) + { + if (await CurrentAnalysisAsync(submission, cancellationToken) == null) throw new SubmissionLeaseLostException(); + } + else if (await CurrentReportAsync(submission, cancellationToken) == null) throw new SubmissionLeaseLostException(); + if (operation != "Poll") { submission.Attempts++; submission.SentOn ??= DateTime.UtcNow; } + if (operation == "Create") submission.CreatePendingReceipt = true; + entry.AttemptNumber = submission.Attempts; + await _submissions.UpdateAsync(submission, cancellationToken, true); + await AppendExchangeAsync(entry, "Started", null, cancellationToken); + }); + + NerisSubmissionOutcome outcome; + try { outcome = await send(); } + catch (Exception ex) when (!(ex is OperationCanceledException && cancellationToken.IsCancellationRequested)) + { + Logging.LogException(ex, "RMS destination exchange failed."); + outcome = operation == "Create" ? Uncertain() : new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Transient, Message = "Destination exchange failed." }; + } + outcome ??= operation == "Create" ? Uncertain() : Fatal("The destination returned no outcome."); + if (operation == "Create" && (outcome.StatusCode >= 200 && outcome.StatusCode < 300 || outcome.Kind == NerisOutcomeKind.Created || outcome.Kind == NerisOutcomeKind.Pending || outcome.Kind == NerisOutcomeKind.Accepted) + && string.IsNullOrWhiteSpace(outcome.ExternalId)) + { + outcome.DeliveryUncertain = true; + outcome.Message = Uncertain().Message; + } + // Commit the receipt independently. If the state transaction fails, the next lease replays this receipt, not HTTP. + await InTransactionAsync(async () => await AppendExchangeAsync(entry, "Response", outcome, cancellationToken)); + entry.Stage = "Response"; + return (outcome, entry); + } + + private async Task AppendExchangeAsync(RmsSubmissionExchange source, string stage, NerisSubmissionOutcome outcome, CancellationToken cancellationToken) + { + var entry = Copy(source); + entry.RmsSubmissionExchangeId = Guid.NewGuid().ToString(); + entry.Stage = stage; + entry.OccurredOn = DateTime.UtcNow; + entry.OutcomeJson = outcome == null ? null : JsonConvert.SerializeObject(outcome); + entry.OutcomeChecksum = entry.OutcomeJson == null ? null : RecordSnapshotSerializer.Checksum(entry.OutcomeJson); + await _exchanges.InsertAsync(entry, cancellationToken, true); + } + + private static NerisSubmissionOutcome Uncertain() => new NerisSubmissionOutcome + { + Kind = NerisOutcomeKind.Fatal, DeliveryUncertain = true, + Message = "The destination may have created this report. Reconcile its destination identifier before another delivery." + }; + private async Task UpdateProjectionStateAsync(RmsIncidentReport report, CancellationToken cancellationToken) { var projection = await _projections.GetByRecordIdAsync(report.DepartmentId, report.RmsIncidentReportId); diff --git a/Core/Resgrid.Services/Records/RecordsUdfService.cs b/Core/Resgrid.Services/Records/RecordsUdfService.cs new file mode 100644 index 000000000..16259ba35 --- /dev/null +++ b/Core/Resgrid.Services/Records/RecordsUdfService.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; + +namespace Resgrid.Services.Records +{ + public sealed class RecordsUdfService : IRecordsUdfService + { + private readonly IRmsUdfDefinitionsRepository _definitions; + private readonly IUdfFieldRepository _fields; + private readonly IUdfFieldValueRepository _values; + private readonly IRecordsAuthorizationService _auth; + private readonly IDepartmentGroupsService _groups; + private readonly IUnitOfWork _unit; + private readonly IDepartmentDataProtectionService _protection; + public RecordsUdfService(IRmsUdfDefinitionsRepository definitions, IUdfFieldRepository fields, IUdfFieldValueRepository values, + IRecordsAuthorizationService auth, IDepartmentGroupsService groups, IUnitOfWork unit, IDepartmentDataProtectionService protection) + { _definitions=definitions; _fields=fields; _values=values; _auth=auth; _groups=groups; _unit=unit; _protection=protection; } + private async Task RequireSupportedProtectionAsync(int department) + { + if (await _protection.GetStateAsync(department, true) != DepartmentDataProtectionState.Disabled) + throw new InvalidOperationException("RMS custom fields require the protected record read/write integration before use during Advanced Data Protection enrollment or operation."); + } + private static T Copy(T item) => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(item)); + private async Task RequireDesigner(int department, string user, string key, int version) + { + await RequireSupportedProtectionAsync(department); + if (!await _auth.IsDepartmentAdminAsync(user, department) || !await _auth.HasPermissionAsync(user, department, PermissionTypes.ManageRecordDefinitions)) throw new UnauthorizedAccessException(); + if (version != RmsDefinitionKeys.LockedDefinitionVersion || !(RmsDefinitionKeys.LockedTypes.ContainsKey(key ?? "") || key == RmsDefinitionKeys.NerisIncidentReport)) throw new ArgumentException("Choose a published record definition and version."); + } + public async Task GetForDesignerAsync(int departmentId, string userId, string key, int version) + { + await RequireDesigner(departmentId,userId,key,version); + var definition=Copy(await _definitions.GetActiveAsync(departmentId,key,version)); + if (definition != null) definition.Fields=(await _fields.GetFieldsByDefinitionIdAsync(definition.UdfDefinitionId)).Select(Copy).ToList(); + await RequireDesigner(departmentId,userId,key,version); return definition; + } + public async Task PublishAsync(int departmentId, string userId, string key, int version, string expectedDefinitionId, List fields, CancellationToken ct=default) + { + await RequireDesigner(departmentId,userId,key,version); + fields=Copy(fields ?? new List()); + if (fields.Count>100 || fields.Any(f=>f==null)) throw new ArgumentException("A record extension supports at most 100 fields."); + var errors=UdfValidationHelper.ValidateFieldNamesUnique(fields); + foreach(var field in fields) + { + if (string.IsNullOrWhiteSpace(field.Label) || field.Label.Length>200 || field.Name.Length>200 || field.Description?.Length>500 || field.Placeholder?.Length>200 || field.GroupName?.Length>100 || field.DefaultValue?.Length>16000 || field.ValidationRules?.Length>16000) errors.Add("A field label or setting is missing or too long."); + if (!Enum.IsDefined(typeof(UdfFieldDataType),field.FieldDataType) || field.Visibility<0 || field.Visibility>2 || field.RmsClassification is not (0 or 1)) errors.Add("Choose a supported type, visibility and explicit information classification."); + if (!string.IsNullOrWhiteSpace(field.ValidationRules)) + { + var rules=JsonConvert.DeserializeObject(field.ValidationRules) ?? throw new ArgumentException("Validation rules are invalid."); + if (rules.Regex != null) _ = new System.Text.RegularExpressions.Regex(rules.Regex, System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromMilliseconds(250)); + } + var optional=Copy(field); optional.IsRequired=false; errors.AddRange(UdfValidationHelper.ValidateFieldValue(optional,field.DefaultValue)); + if (field.IsRequired && field.IsReadOnly && string.IsNullOrWhiteSpace(field.DefaultValue)) errors.Add("A required read-only field needs a valid default value."); + } + if (errors.Count>0) throw new ArgumentException(string.Join(" ",errors.Distinct())); + _unit.CreateOrGetConnection(); + try + { + await _definitions.LockDepartmentAsync(departmentId,ct); + var active=await _definitions.GetActiveAsync(departmentId,key,version); + if ((active?.UdfDefinitionId ?? "") != (expectedDefinitionId ?? "")) throw new InvalidOperationException("This form was republished. Reload before publishing changes."); + await RequireDesigner(departmentId,userId,key,version); + await _definitions.DeactivateAsync(departmentId,key,version,ct); + var definition=new UdfDefinition { UdfDefinitionId=Guid.NewGuid().ToString(), DepartmentId=departmentId, EntityType=(int)UdfEntityType.Record, RecordDefinitionKey=key, RecordDefinitionVersion=version, Version=(active?.Version??0)+1, IsActive=true, CreatedBy=userId, CreatedOn=DateTime.UtcNow }; + await _definitions.InsertAsync(definition,ct,true); + for(var i=0;i CaptureAsync(int departmentId, string recordId, string key, int version, string definitionId) + { + if (string.IsNullOrWhiteSpace(definitionId)) return null; + await RequireSupportedProtectionAsync(departmentId); + var definition=await _definitions.GetScopedAsync(departmentId,definitionId,key,version) ?? throw new InvalidOperationException("The captured custom-field definition is unavailable."); + var fields=(await _fields.GetFieldsByDefinitionIdAsync(definitionId)).Where(f=>f.IsEnabled).OrderBy(f=>f.SortOrder).ToList(); + var values=(await _values.GetFieldValuesByEntityAsync((int)UdfEntityType.Record,recordId,definitionId)).ToDictionary(v=>v.UdfFieldId,v=>v.Value,StringComparer.Ordinal); + return new RecordUdfSection { DefinitionId=definitionId,RecordDefinitionKey=key,RecordDefinitionVersion=version,ExtensionVersion=definition.Version, + Fields=fields.Select(f=>new RecordUdfField {Field=Copy(f),Value=values.TryGetValue(f.UdfFieldId,out var value)?value:f.DefaultValue}).ToList() }; + } + public async Task GetNewFormAsync(int departmentId, string userId, string key, int version) + { + if (!await _auth.HasPermissionAsync(userId,departmentId,PermissionTypes.CreateRecord)) throw new UnauthorizedAccessException(); + var definition=await _definitions.GetActiveAsync(departmentId,key,version); + var section=definition==null?null:await CaptureAsync(departmentId,null,key,version,definition.UdfDefinitionId); + return await ProjectAsync(departmentId,userId,section); + } + public async Task ProjectAsync(int departmentId, string userId, RecordUdfSection section, bool mobile=false, bool reportLayout=false) + { + if (section==null) return null; + await RequireSupportedProtectionAsync(departmentId); + if (!await _auth.IsActiveMemberAsync(userId,departmentId)) throw new UnauthorizedAccessException(); + var restricted=await _auth.HasPermissionAsync(userId,departmentId,PermissionTypes.ViewRestrictedRecords); + var admin=await _auth.IsDepartmentAdminAsync(userId,departmentId); + var group=(await _groups.GetGroupForUserAsync(userId,departmentId))?.IsUserGroupAdmin(userId)==true; + var projected=Copy(section); + // Intersect the initial and final live grants; role changes during projection cannot reveal a value. + restricted = restricted && await _auth.HasPermissionAsync(userId,departmentId,PermissionTypes.ViewRestrictedRecords); + admin = admin && await _auth.IsDepartmentAdminAsync(userId,departmentId); + group = group && (await _groups.GetGroupForUserAsync(userId,departmentId))?.IsUserGroupAdmin(userId)==true; + if (!await _auth.IsActiveMemberAsync(userId,departmentId)) throw new UnauthorizedAccessException(); + projected.Fields=projected.Fields.Where(v=>CanSee(v.Field,restricted,admin,group) && (!mobile || v.Field.IsVisibleOnMobile) && (!reportLayout || v.Field.IsVisibleOnReports)).ToList(); + return projected; + } + public async Task GetVisibilityLevelAsync(int departmentId, string userId) + { + await RequireSupportedProtectionAsync(departmentId); + if (!await _auth.IsActiveMemberAsync(userId,departmentId)) throw new UnauthorizedAccessException(); + if (await _auth.IsDepartmentAdminAsync(userId,departmentId)) return 2; + return (await _groups.GetGroupForUserAsync(userId,departmentId))?.IsUserGroupAdmin(userId)==true ? 1 : 0; + } + public static bool CanSee(UdfField field, bool restricted, bool admin, bool group) => field!=null && (field.RmsClassification==0 || restricted) && (field.Visibility==0 || field.Visibility==1 && (admin||group) || field.Visibility==2 && admin); + public async Task SaveInTransactionAsync(int departmentId, string userId, string recordId, string key, int version, string pinnedDefinitionId, RecordUdfInput input, CancellationToken ct) + { + // The calling aggregate owns both the transaction and header version fence. Never commit it here. + await _definitions.GuardRecordAsync(departmentId,recordId,ct); + var definition=pinnedDefinitionId==null ? await _definitions.GetActiveAsync(departmentId,key,version) : await _definitions.GetScopedAsync(departmentId,pinnedDefinitionId,key,version); + if (definition==null) + { + if (pinnedDefinitionId!=null || input?.DefinitionId!=null || input?.Values?.Count>0) throw new ArgumentException("The custom-field definition does not match this record."); + return null; + } + if (input!=null && input.DefinitionId!=definition.UdfDefinitionId) throw new ArgumentException("This record uses a different custom-field version. Reload its form."); + var section=await CaptureAsync(departmentId,recordId,key,version,definition.UdfDefinitionId); + var visible=await ProjectAsync(departmentId,userId,section); + var allowed=visible.Fields.ToDictionary(f=>f.Field.UdfFieldId); + foreach(var value in input?.Values ?? new Dictionary()) + { + if (!allowed.TryGetValue(value.Key,out var field) || field.Field.IsReadOnly) throw new UnauthorizedAccessException("A custom field cannot be edited under your current permissions."); + if (value.Value?.Length>16000) throw new ArgumentException("A custom-field value is too long."); + var optional=Copy(field.Field); optional.IsRequired=false; + var errors=UdfValidationHelper.ValidateFieldValue(optional,value.Value); if(errors.Count>0) throw new ArgumentException(string.Join(" ",errors)); + section.Fields.Single(f=>f.Field.UdfFieldId==value.Key).Value=value.Value; + } + await ReplaceValues(departmentId,recordId,section,userId,ct); return definition.UdfDefinitionId; + } + public async Task RestoreInTransactionAsync(int departmentId, string recordId, string key, int version, RecordUdfSection section, string userId, CancellationToken ct) + { + await _definitions.GuardRecordAsync(departmentId,recordId,ct); + if (section!=null && (section.RecordDefinitionKey!=key || section.RecordDefinitionVersion!=version || await _definitions.GetScopedAsync(departmentId,section.DefinitionId,key,version)==null)) throw new InvalidOperationException("The revision custom-field definition is invalid."); + await _definitions.DeleteRecordValuesAsync(departmentId,recordId,ct); + if (section!=null) await ReplaceValues(departmentId,recordId,section,userId,ct); + } + private async Task ReplaceValues(int department, string recordId, RecordUdfSection section, string user, CancellationToken ct) + { + await RequireSupportedProtectionAsync(department); + if (section.Fields.Any(f=>ProtectedDataEnvelope.HasEnvelopePrefix(f.Value) || f.Value==ProtectedDataEnvelope.RedactionValue)) throw new InvalidOperationException("Protected custom-field values must retain their original protected identity."); + await _values.DeleteFieldValuesByEntityAndDefinitionAsync((int)UdfEntityType.Record,recordId,section.DefinitionId,ct); + foreach(var field in section.Fields) + await _values.InsertAsync(new UdfFieldValue { UdfFieldValueId=Guid.NewGuid().ToString(), UdfFieldId=field.Field.UdfFieldId, UdfDefinitionId=section.DefinitionId, EntityId=recordId,EntityType=(int)UdfEntityType.Record,Value=field.Value,CreatedOn=DateTime.UtcNow,CreatedBy=user },ct,true); + } + public void ValidateForFinalization(RecordUdfSection section) + { + var errors=(section?.Fields ?? new List()).SelectMany(f=>UdfValidationHelper.ValidateFieldValue(f.Field,f.Value)).ToList(); + if (errors.Count>0) throw new ArgumentException("Custom fields must be completed before finalization. An authorized editor must complete any required fields outside your access."); + } + } +} diff --git a/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs b/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs index 57cf571ad..2cb01a11a 100644 --- a/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs +++ b/Core/Resgrid.Services/Records/RmsInventoryUsageAdapter.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json; using Resgrid.Model; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; namespace Resgrid.Services.Records @@ -24,18 +25,75 @@ public class RmsInventoryUsageAdapter : IRmsInventoryUsageAdapter private readonly IRmsExternalReferencesRepository _references; private readonly IRmsOperationalRecordsRepository _records; + private readonly IRmsIncidentReportsRepository _incidents; + private readonly IInventoryService _inventory; + private readonly IRecordsAuthorizationService _authorization; + private readonly IDepartmentGroupsService _groups; + private readonly IUnitsService _units; + private readonly IUnitOfWork _unit; + private readonly IRmsAccessAuditsRepository _audits; - public RmsInventoryUsageAdapter(IRmsExternalReferencesRepository references, IRmsOperationalRecordsRepository records) + public RmsInventoryUsageAdapter(IRmsExternalReferencesRepository references, IRmsOperationalRecordsRepository records, IRmsIncidentReportsRepository incidents, + IInventoryService inventory, IRecordsAuthorizationService authorization, IDepartmentGroupsService groups, IUnitsService units, IUnitOfWork unit, IRmsAccessAuditsRepository audits) { _references = references; _records = records; + _incidents = incidents; _inventory = inventory; _authorization = authorization; _groups = groups; _units = units; _unit = unit; _audits = audits; + } + + public async Task ConsumeAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, long expectedRowVersion, int typeId, int groupId, int? unitId, decimal quantity, string note, CancellationToken cancellationToken = default) + { + ValidateQuantity(quantity, note); + if (kind is not (RmsRecordKind.Operational or RmsRecordKind.IncidentReport)) throw new ArgumentException("Choose an operational or incident record."); + _unit.CreateOrGetConnection(); + try + { + await GuardAsync(departmentId, userId, recordId, kind, expectedRowVersion, cancellationToken); + var type = await _inventory.GetTypeByIdAsync(typeId); + if (type?.DepartmentId != departmentId || (await _groups.GetGroupByIdAsync(groupId, true))?.DepartmentId != departmentId || (unitId.HasValue && (await _units.GetUnitByIdAsync(unitId.Value))?.DepartmentId != departmentId)) throw new UnauthorizedAccessException("Inventory type, station and unit must belong to this department."); + if (!await _authorization.CanUseSourceInventoryAsync(userId, departmentId, groupId)) throw new UnauthorizedAccessException(); + var ledger = await _inventory.SaveInventoryAsync(new Inventory { DepartmentId = departmentId, TypeId = typeId, GroupId = groupId, UnitId = unitId, + Amount = -(double)quantity, Note = note, TimeStamp = DateTime.UtcNow, AddedByUserId = userId }, cancellationToken); + if (ledger?.DepartmentId != departmentId || ledger.InventoryId <= 0) throw new InvalidOperationException("The inventory consumption could not be recorded."); + var usage = await WriteReferenceAsync(departmentId, userId, recordId, kind, ledger, type, quantity, note, cancellationToken); + _unit.CommitChanges(); return usage; + } + catch { _unit.DiscardChanges(); throw; } + } + + private async Task GuardAsync(int department, string user, string recordId, RmsRecordKind kind, long? expected, CancellationToken ct) + { + if (!await _authorization.CanUserViewRecordAsync(user, recordId, department) || !await _authorization.HasPermissionAsync(user, department, PermissionTypes.CreateRecord) || !await _authorization.HasPermissionAsync(user, department, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); + string author, owner, amendment; int state; long version; + if (kind == RmsRecordKind.Operational) + { + var r = await _records.GetByIdForDepartmentAsync(department, recordId); + if (r == null || r.DeletedOn.HasValue || r.PurgedOn.HasValue) throw new InvalidOperationException("The record is unavailable."); + author=r.AuthorUserId; owner=r.OwnerUserId; amendment=r.AmendsRevisionId; state=r.State; version=r.RowVersion; + } + else + { + var r = await _incidents.GetByIdForDepartmentAsync(department, recordId); + if (r == null || r.DeletedOn.HasValue || r.PurgedOn.HasValue) throw new InvalidOperationException("The incident report is unavailable."); + author=r.AuthorUserId; owner=r.OwnerUserId; amendment=r.AmendsRevisionId; state=r.State; version=r.RowVersion; + } + if (RmsLifecycle.IsTerminal((RmsRecordState)state) || !(RmsLifecycle.IsEditable((RmsRecordState)state) || amendment != null)) throw new InvalidOperationException("Record inventory usage through a draft or amendment."); + if (author != user && owner != user && !await _authorization.IsDepartmentAdminAsync(user, department) && !(amendment != null && await _authorization.HasPermissionAsync(user, department, PermissionTypes.AmendRecords))) throw new UnauthorizedAccessException(); + if (expected.HasValue && expected.Value != version) throw new RecordConcurrencyException(recordId, expected.Value, version); + var bumped = kind == RmsRecordKind.Operational ? await _records.TryBumpRowVersionAsync(department, recordId, version, ct) : await _incidents.TryBumpRowVersionAsync(department, recordId, version, ct); + if (!bumped) throw new RecordConcurrencyException(recordId, version, version + 1); + } + private static void ValidateQuantity(decimal quantity, string note) + { + if (quantity <= 0 || quantity > 100000000 || decimal.Round(quantity, 6) != quantity) throw new ArgumentException("Quantity must be positive, at most 100,000,000, with at most six decimal places."); + if (note?.Length > 16000) throw new ArgumentException("The usage note is limited to 16,000 characters."); } public async Task> GetUsageForRecordAsync(int departmentId, string recordId) { var references = await _references.GetForRecordAsync(departmentId, recordId) ?? Enumerable.Empty(); return references - .Where(r => r != null && !r.DeletedOn.HasValue && string.Equals(r.SemanticRole, SemanticRole, StringComparison.Ordinal)) + .Where(r => r != null && r.DepartmentId == departmentId && r.RecordId == recordId && !r.DeletedOn.HasValue && string.Equals(r.SemanticRole, SemanticRole, StringComparison.Ordinal)) .Select(FromReference) .Where(u => u != null) .OrderBy(u => u.CapturedOn) @@ -55,27 +113,36 @@ public async Task RecordUsageAsync(int departmentId, string u { if (string.IsNullOrWhiteSpace(recordId)) throw new ArgumentException("A record is required.", nameof(recordId)); if (inventoryId <= 0) throw new ArgumentException("An inventory item is required.", nameof(inventoryId)); - if (quantity <= 0) throw new ArgumentException("Quantity must be positive.", nameof(quantity)); - - var record = await _records.GetByIdForDepartmentAsync(departmentId, recordId); - if (record == null || record.DeletedOn.HasValue) - throw new InvalidOperationException($"Record {recordId} does not exist in department {departmentId}."); - - if (RmsLifecycle.IsTerminal((RmsRecordState)record.State)) - throw new InvalidOperationException("Inventory usage cannot be recorded against a voided or cancelled Record."); - + ValidateQuantity(quantity, note); + _unit.CreateOrGetConnection(); + try + { + await GuardAsync(departmentId, userId, recordId, RmsRecordKind.Operational, null, cancellationToken); + var ledger = await _inventory.GetInventoryByIdAsync(inventoryId); + if (ledger?.DepartmentId != departmentId || !await _authorization.CanUseSourceInventoryAsync(userId, departmentId, ledger.GroupId)) throw new UnauthorizedAccessException(); + var type = await _inventory.GetTypeByIdAsync(ledger.TypeId); + if (type?.DepartmentId != departmentId) throw new UnauthorizedAccessException(); + var usage = await WriteReferenceAsync(departmentId, userId, recordId, RmsRecordKind.Operational, ledger, type, quantity, note, cancellationToken); + _unit.CommitChanges(); return usage; + } + catch { _unit.DiscardChanges(); throw; } + } + private async Task WriteReferenceAsync(int departmentId, string userId, string recordId, RmsRecordKind kind, Inventory ledger, InventoryType type, decimal quantity, string note, CancellationToken cancellationToken) + { + if (!await _authorization.CanUseSourceInventoryAsync(userId, departmentId, ledger.GroupId) || !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord) || !await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.ViewRestrictedRecords)) throw new UnauthorizedAccessException(); var now = DateTime.UtcNow; - var snapshot = JsonConvert.SerializeObject(new UsageSnapshot { InventoryId = inventoryId, Quantity = quantity, Note = note }); + var source = new { ledger.InventoryId, ledger.TypeId, ledger.GroupId, ledger.UnitId, ledger.Amount, ledger.TimeStamp, ledger.Batch, ledger.Note, ledger.Location, ledger.AddedByUserId }; + var snapshot = JsonConvert.SerializeObject(new UsageSnapshot { InventoryId = ledger.InventoryId, Quantity = quantity, Note = note, ItemName = type.Type, UnitOfMeasure = type.UnitOfMesasure, Source = source, SourceChecksum = Checksum(JsonConvert.SerializeObject(source)) }); var reference = new RmsExternalReference { RmsExternalReferenceId = Guid.NewGuid().ToString(), DepartmentId = departmentId, ProtectionId = Guid.NewGuid().ToString(), RecordId = recordId, - RecordKind = (int)RmsRecordKind.Operational, + RecordKind = (int)kind, SourceSubsystem = SourceSubsystem, SourceEntityType = "Inventory", - SourceEntityId = inventoryId.ToString(), + SourceEntityId = ledger.InventoryId.ToString(), IdentifierScheme = IdentifierScheme, SemanticRole = SemanticRole, CapturedOn = now, @@ -88,11 +155,13 @@ public async Task RecordUsageAsync(int departmentId, string u }; await _references.InsertAsync(reference, cancellationToken, true); + await _audits.InsertAsync(new RmsAccessAudit { DepartmentId = departmentId, RecordId = recordId, ActorUserId = userId, Action = (int)RmsAccessAuditAction.Change, Successful = true, OccurredOn = now, Purpose = "Inventory usage recorded", DetailJson = JsonConvert.SerializeObject(new { reference.RmsExternalReferenceId, ledger.InventoryId, reference.Checksum }) }, cancellationToken, true); return FromReference(reference); } private static RmsInventoryUsage FromReference(RmsExternalReference reference) { + if (string.IsNullOrWhiteSpace(reference.Checksum) || Checksum(reference.SnapshotJson ?? "") != reference.Checksum) throw new InvalidOperationException("Inventory usage failed its integrity check."); UsageSnapshot snapshot; try { @@ -100,20 +169,22 @@ private static RmsInventoryUsage FromReference(RmsExternalReference reference) } catch (JsonException) { - return null; + throw new InvalidOperationException("The inventory usage snapshot is unreadable."); } - if (!int.TryParse(reference.SourceEntityId, out var inventoryId)) - inventoryId = snapshot.InventoryId; + if (!int.TryParse(reference.SourceEntityId, out var inventoryId) || inventoryId != snapshot.InventoryId || snapshot.Quantity <= 0) + throw new InvalidOperationException("The inventory usage source identity is invalid."); return new RmsInventoryUsage { ReferenceId = reference.RmsExternalReferenceId, + ReferenceChecksum = reference.Checksum, Source = RmsInventoryUsage.SourceRecord, RecordId = reference.RecordId, InventoryId = inventoryId, Quantity = snapshot.Quantity, Note = snapshot.Note, + ItemName = snapshot.ItemName, UnitOfMeasure = snapshot.UnitOfMeasure, SourceChecksum = snapshot.SourceChecksum, CapturedByUserId = reference.CapturedByUserId, CapturedOn = reference.CapturedOn }; @@ -130,6 +201,10 @@ private sealed class UsageSnapshot public int InventoryId { get; set; } public decimal Quantity { get; set; } public string Note { get; set; } + public string ItemName { get; set; } + public string UnitOfMeasure { get; set; } + public string SourceChecksum { get; set; } + public object Source { get; set; } } } } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 58de2d6be..552767222 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -244,6 +244,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); // RMS-2: NERIS incident reports and the submission worker logic builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); // RMS-1B v4 Records API support: keyed short-lived state, command idempotency, resumable attachment sessions builder.RegisterType().As().InstancePerLifetimeScope(); @@ -259,6 +261,7 @@ protected override void Load(ContainerBuilder builder) // RMS-3c evidence: the service owns checksum, classification, retention and audit; the six adapters // only decide what a bounded snapshot of their own subsystem looks like (plan section 4.5, all six ship). builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -267,6 +270,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); // RMS-3d: public-records workflow (M0171) and the Records queue dashboards. builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); // Default attachment scanner: no engine, rows stay Skipped. A real scanner provider replaces this registration. diff --git a/Core/Resgrid.Services/UserDefinedFieldsService.cs b/Core/Resgrid.Services/UserDefinedFieldsService.cs index 8d09715e4..1265e1b38 100644 --- a/Core/Resgrid.Services/UserDefinedFieldsService.cs +++ b/Core/Resgrid.Services/UserDefinedFieldsService.cs @@ -33,11 +33,13 @@ public UserDefinedFieldsService( public async Task GetActiveDefinitionAsync(int departmentId, int entityType) { + if (entityType == (int)UdfEntityType.Record) throw new UnauthorizedAccessException("Record extensions require the RMS definition and record authorization workflow."); return await _definitionRepository.GetActiveDefinitionByDepartmentAndEntityTypeAsync(departmentId, entityType); } public async Task> GetFieldsForActiveDefinitionAsync(int departmentId, int entityType) { + if (entityType == (int)UdfEntityType.Record) throw new UnauthorizedAccessException("Record extensions require the RMS definition and record authorization workflow."); var definition = await _definitionRepository.GetActiveDefinitionByDepartmentAndEntityTypeAsync(departmentId, entityType); if (definition == null) return new List(); @@ -49,6 +51,7 @@ public async Task> GetFieldsForActiveDefinitionAsync(int departme public async Task> GetVisibleFieldsForActiveDefinitionAsync(int departmentId, int entityType, bool isDepartmentAdmin, bool isGroupAdmin) { + if (entityType == (int)UdfEntityType.Record) throw new UnauthorizedAccessException("Record extensions require the RMS definition and record authorization workflow."); var allFields = await GetFieldsForActiveDefinitionAsync(departmentId, entityType); return allFields.Where(f => IsFieldVisibleToRole(f, isDepartmentAdmin, isGroupAdmin)).ToList(); @@ -69,6 +72,7 @@ private static bool IsFieldVisibleToRole(UdfField field, bool isDepartmentAdmin, public async Task SaveDefinitionAsync(int departmentId, int entityType, List fields, string userId, CancellationToken cancellationToken = default) { + if (entityType == (int)UdfEntityType.Record) throw new UnauthorizedAccessException("Record extensions require the RMS definition and record authorization workflow."); // Enforce machine-name uniqueness at the service layer so callers (web, API, workers) // all get the same guarantee. if (fields != null && fields.Count > 0) @@ -106,16 +110,14 @@ public async Task SaveDefinitionAsync(int departmentId, int entit await _definitionRepository.SaveOrUpdateAsync(definition, cancellationToken); - // Save fields linked to the new definition version. - // Preserve any pre-set UdfFieldId so callers can reference fields by stable IDs. - // If no UdfFieldId is set, the repository will assign a new GUID. - // When cloning fields for a new version (e.g. DeleteFieldFromDefinitionAsync), - // the caller leaves UdfFieldId empty so fresh IDs are assigned there. + // A publication owns new field rows. A submitted ID must never move or update a field + // from an older definition, another entity type (including RMS), or another department. if (fields != null) { for (int i = 0; i < fields.Count; i++) { - var field = fields[i]; + var field = Newtonsoft.Json.JsonConvert.DeserializeObject(Newtonsoft.Json.JsonConvert.SerializeObject(fields[i])); + field.UdfFieldId = null; field.UdfDefinitionId = definition.UdfDefinitionId; field.SortOrder = i; await _fieldRepository.SaveOrUpdateAsync(field, cancellationToken); @@ -135,6 +137,7 @@ public async Task SaveDefinitionAsync(int departmentId, int entit public async Task> GetFieldValuesForEntityAsync(int departmentId, int entityType, string entityId) { + if (entityType == (int)UdfEntityType.Record) throw new UnauthorizedAccessException("Record extensions require the RMS definition and record authorization workflow."); var definition = await _definitionRepository.GetActiveDefinitionByDepartmentAndEntityTypeAsync(departmentId, entityType); if (definition == null) return new List(); @@ -145,6 +148,7 @@ public async Task> GetFieldValuesForEntityAsync(int departme public async Task> GetFieldValuesForEntitiesAsync(int departmentId, int entityType, IEnumerable entityIds) { + if (entityType == (int)UdfEntityType.Record) throw new UnauthorizedAccessException("Record extensions require the RMS definition and record authorization workflow."); var idList = entityIds?.ToList() ?? new List(); if (idList.Count == 0) return new List(); @@ -162,6 +166,7 @@ public async Task>> SaveFieldValuesForEntityAsyn bool isDepartmentAdmin = false, bool isGroupAdmin = false, CancellationToken cancellationToken = default) { + if (entityType == (int)UdfEntityType.Record) throw new UnauthorizedAccessException("Record extensions require the RMS definition and record authorization workflow."); var definition = await _definitionRepository.GetActiveDefinitionByDepartmentAndEntityTypeAsync(departmentId, entityType); if (definition == null) return new Dictionary>(); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0173_RmsReleaseHardening.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0173_RmsReleaseHardening.cs new file mode 100644 index 000000000..e86a1365e --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0173_RmsReleaseHardening.cs @@ -0,0 +1,86 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// RMS release hardening: immutable create identity and recoverable destination exchanges. + [Migration(173)] + public class M0173_RmsReleaseHardening : Migration + { + public override void Up() + { + if (!Schema.Table("RmsCommandReceipts").Exists()) + Create.Table("RmsCommandReceipts") + .WithColumn("DepartmentId").AsInt32().NotNullable().PrimaryKey() + .WithColumn("KeyHash").AsString(64).NotNullable().PrimaryKey() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RequestChecksum").AsString(64).NotNullable() + .WithColumn("ReservationId").AsString(36).NotNullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CompletedOn").AsDateTime2().Nullable(); + foreach (var table in new[] { "RmsOperationalRecords", "RmsIncidentReports" }) + if (!Schema.Table(table).Column("SearchErasedOn").Exists()) + Alter.Table(table).AddColumn("SearchErasedOn").AsDateTime2().Nullable(); + if (!Schema.Table("RmsEvidenceArtifacts").Column("CaptureRequestChecksum").Exists()) + Alter.Table("RmsEvidenceArtifacts").AddColumn("CaptureRequestChecksum").AsString(80).Nullable(); + if (!Schema.Table("UdfDefinitions").Column("RecordDefinitionKey").Exists()) + Alter.Table("UdfDefinitions").AddColumn("RecordDefinitionKey").AsString(200).Nullable(); + if (!Schema.Table("UdfDefinitions").Column("RecordDefinitionVersion").Exists()) + Alter.Table("UdfDefinitions").AddColumn("RecordDefinitionVersion").AsInt32().Nullable(); + if (!Schema.Table("UdfFields").Column("RmsClassification").Exists()) + Alter.Table("UdfFields").AddColumn("RmsClassification").AsInt32().Nullable(); + if (!Schema.Table("RmsOperationalRecords").Column("UdfDefinitionId").Exists()) + Alter.Table("RmsOperationalRecords").AddColumn("UdfDefinitionId").AsString(128).Nullable(); + if (!Schema.Table("RmsIncidentReports").Column("UdfDefinitionId").Exists()) + Alter.Table("RmsIncidentReports").AddColumn("UdfDefinitionId").AsString(128).Nullable(); + if (!Schema.Table("RmsRecordLegalHoldMembers").Exists()) + { + Create.Table("RmsRecordLegalHoldMembers").WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("HoldId").AsString(36).NotNullable().PrimaryKey().WithColumn("RecordId").AsString(36).NotNullable().PrimaryKey().WithColumn("MatchedOn").AsDateTime2().NotNullable(); + Create.Index("IX_RmsLegalHoldMembers_Record").OnTable("RmsRecordLegalHoldMembers").OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending(); + } + if (!Schema.Table("RmsDisclosureProductions").Column("DeliveryMethod").Exists()) + Alter.Table("RmsDisclosureProductions").AddColumn("DeliveryMethod").AsString(200).Nullable(); + if (!Schema.Table("RmsDisclosureProductions").Column("DeliveryReference").Exists()) + Alter.Table("RmsDisclosureProductions").AddColumn("DeliveryReference").AsString(1000).Nullable(); + if (!Schema.Table("RmsRecordAttachments").Column("Classification").Exists()) + Alter.Table("RmsRecordAttachments").AddColumn("Classification").AsInt32().Nullable(); + if (!Schema.Table("RmsOperationalRecords").Column("OriginalRequestChecksum").Exists()) + Alter.Table("RmsOperationalRecords").AddColumn("OriginalRequestChecksum").AsString(80).Nullable(); + if (!Schema.Table("RmsSubmissions").Column("DestinationIdentity").Exists()) + Alter.Table("RmsSubmissions").AddColumn("DestinationIdentity").AsString(int.MaxValue).Nullable(); + if (!Schema.Table("RmsSubmissions").Column("RequiresReconciliation").Exists()) + Alter.Table("RmsSubmissions").AddColumn("RequiresReconciliation").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table("RmsSubmissions").Column("CreatePendingReceipt").Exists()) + { + Alter.Table("RmsSubmissions").AddColumn("CreatePendingReceipt").AsBoolean().NotNullable().WithDefaultValue(false); + // Pre-journal uncertain creates must not silently become new POSTs after upgrading. + Execute.Sql("UPDATE RmsSubmissions SET CreatePendingReceipt = 1, RequiresReconciliation = 1 WHERE SentOn IS NOT NULL AND ExternalId IS NULL AND (ResponseStatusCode IS NULL OR ResponseStatusCode >= 500 OR ResponseStatusCode BETWEEN 200 AND 299)"); + Execute.Sql("UPDATE RmsSubmissions SET RequiresReconciliation = 1 WHERE DestinationIdentity IS NULL AND ExternalId IS NOT NULL"); + } + if (!Schema.Table("RmsSubmissionExchanges").Exists()) + { + Create.Table("RmsSubmissionExchanges") + .WithColumn("RmsSubmissionExchangeId").AsString(36).NotNullable().PrimaryKey() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("SubmissionId").AsString(36).NotNullable() + .WithColumn("RecordId").AsString(36).NotNullable() + .WithColumn("RevisionId").AsString(36).NotNullable() + .WithColumn("ExchangeId").AsString(36).NotNullable() + .WithColumn("Stage").AsString(20).NotNullable() + .WithColumn("Operation").AsString(20).NotNullable() + .WithColumn("DestinationIdentity").AsString(int.MaxValue).NotNullable() + .WithColumn("PayloadChecksum").AsString(80).NotNullable() + .WithColumn("OutcomeJson").AsString(int.MaxValue).Nullable() + .WithColumn("OutcomeChecksum").AsString(80).Nullable() + .WithColumn("AttemptNumber").AsInt32().NotNullable() + .WithColumn("OccurredOn").AsDateTime2().NotNullable(); + Create.Index("UX_RmsSubmissionExchanges_Stage").OnTable("RmsSubmissionExchanges") + .OnColumn("DepartmentId").Ascending().OnColumn("SubmissionId").Ascending().OnColumn("ExchangeId").Ascending().OnColumn("Stage").Ascending().WithOptions().Unique(); + Create.Index("IX_RmsSubmissionExchanges_Record").OnTable("RmsSubmissionExchanges") + .OnColumn("DepartmentId").Ascending().OnColumn("RecordId").Ascending().OnColumn("OccurredOn").Ascending(); + } + } + + public override void Down() => throw new System.NotSupportedException("Delivery receipts must be retained. Roll back the application without deleting RMS hardening data."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0173_RmsReleaseHardeningPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0173_RmsReleaseHardeningPg.cs new file mode 100644 index 000000000..f19d9ee67 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0173_RmsReleaseHardeningPg.cs @@ -0,0 +1,85 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// RMS release hardening: immutable create identity and recoverable destination exchanges. + [Migration(173)] + public class M0173_RmsReleaseHardeningPg : Migration + { + public override void Up() + { + if (!Schema.Table("rmscommandreceipts").Exists()) + Create.Table("rmscommandreceipts") + .WithColumn("departmentid").AsInt32().NotNullable().PrimaryKey() + .WithColumn("keyhash").AsString(64).NotNullable().PrimaryKey() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("requestchecksum").AsString(64).NotNullable() + .WithColumn("reservationid").AsString(36).NotNullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("completedon").AsDateTime2().Nullable(); + foreach (var table in new[] { "rmsoperationalrecords", "rmsincidentreports" }) + if (!Schema.Table(table).Column("searcherasedon").Exists()) + Alter.Table(table).AddColumn("searcherasedon").AsDateTime2().Nullable(); + if (!Schema.Table("rmsevidenceartifacts").Column("capturerequestchecksum").Exists()) + Alter.Table("rmsevidenceartifacts").AddColumn("capturerequestchecksum").AsString(80).Nullable(); + if (!Schema.Table("udfdefinitions").Column("recorddefinitionkey").Exists()) + Alter.Table("udfdefinitions").AddColumn("recorddefinitionkey").AsString(200).Nullable(); + if (!Schema.Table("udfdefinitions").Column("recorddefinitionversion").Exists()) + Alter.Table("udfdefinitions").AddColumn("recorddefinitionversion").AsInt32().Nullable(); + if (!Schema.Table("udffields").Column("rmsclassification").Exists()) + Alter.Table("udffields").AddColumn("rmsclassification").AsInt32().Nullable(); + if (!Schema.Table("rmsoperationalrecords").Column("udfdefinitionid").Exists()) + Alter.Table("rmsoperationalrecords").AddColumn("udfdefinitionid").AsString(128).Nullable(); + if (!Schema.Table("rmsincidentreports").Column("udfdefinitionid").Exists()) + Alter.Table("rmsincidentreports").AddColumn("udfdefinitionid").AsString(128).Nullable(); + if (!Schema.Table("rmsrecordlegalholdmembers").Exists()) + { + Create.Table("rmsrecordlegalholdmembers").WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("holdid").AsString(36).NotNullable().PrimaryKey().WithColumn("recordid").AsString(36).NotNullable().PrimaryKey().WithColumn("matchedon").AsDateTime2().NotNullable(); + Create.Index("ix_rmslegalholdmembers_record").OnTable("rmsrecordlegalholdmembers").OnColumn("departmentid").Ascending().OnColumn("recordid").Ascending(); + } + if (!Schema.Table("rmsdisclosureproductions").Column("deliverymethod").Exists()) + Alter.Table("rmsdisclosureproductions").AddColumn("deliverymethod").AsString(200).Nullable(); + if (!Schema.Table("rmsdisclosureproductions").Column("deliveryreference").Exists()) + Alter.Table("rmsdisclosureproductions").AddColumn("deliveryreference").AsString(1000).Nullable(); + if (!Schema.Table("rmsrecordattachments").Column("classification").Exists()) + Alter.Table("rmsrecordattachments").AddColumn("classification").AsInt32().Nullable(); + if (!Schema.Table("rmsoperationalrecords").Column("originalrequestchecksum").Exists()) + Alter.Table("rmsoperationalrecords").AddColumn("originalrequestchecksum").AsString(80).Nullable(); + if (!Schema.Table("rmssubmissions").Column("destinationidentity").Exists()) + Alter.Table("rmssubmissions").AddColumn("destinationidentity").AsCustom("text").Nullable(); + if (!Schema.Table("rmssubmissions").Column("requiresreconciliation").Exists()) + Alter.Table("rmssubmissions").AddColumn("requiresreconciliation").AsBoolean().NotNullable().WithDefaultValue(false); + if (!Schema.Table("rmssubmissions").Column("creatependingreceipt").Exists()) + { + Alter.Table("rmssubmissions").AddColumn("creatependingreceipt").AsBoolean().NotNullable().WithDefaultValue(false); + Execute.Sql("UPDATE rmssubmissions SET creatependingreceipt = TRUE, requiresreconciliation = TRUE WHERE senton IS NOT NULL AND externalid IS NULL AND (responsestatuscode IS NULL OR responsestatuscode >= 500 OR responsestatuscode BETWEEN 200 AND 299)"); + Execute.Sql("UPDATE rmssubmissions SET requiresreconciliation = TRUE WHERE destinationidentity IS NULL AND externalid IS NOT NULL"); + } + if (!Schema.Table("rmssubmissionexchanges").Exists()) + { + Create.Table("rmssubmissionexchanges") + .WithColumn("rmssubmissionexchangeid").AsString(36).NotNullable().PrimaryKey() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("submissionid").AsString(36).NotNullable() + .WithColumn("recordid").AsString(36).NotNullable() + .WithColumn("revisionid").AsString(36).NotNullable() + .WithColumn("exchangeid").AsString(36).NotNullable() + .WithColumn("stage").AsString(20).NotNullable() + .WithColumn("operation").AsString(20).NotNullable() + .WithColumn("destinationidentity").AsCustom("text").NotNullable() + .WithColumn("payloadchecksum").AsString(80).NotNullable() + .WithColumn("outcomejson").AsCustom("text").Nullable() + .WithColumn("outcomechecksum").AsString(80).Nullable() + .WithColumn("attemptnumber").AsInt32().NotNullable() + .WithColumn("occurredon").AsDateTime2().NotNullable(); + Create.Index("ux_rmssubmissionexchanges_stage").OnTable("rmssubmissionexchanges") + .OnColumn("departmentid").Ascending().OnColumn("submissionid").Ascending().OnColumn("exchangeid").Ascending().OnColumn("stage").Ascending().WithOptions().Unique(); + Create.Index("ix_rmssubmissionexchanges_record").OnTable("rmssubmissionexchanges") + .OnColumn("departmentid").Ascending().OnColumn("recordid").Ascending().OnColumn("occurredon").Ascending(); + } + } + + public override void Down() => throw new System.NotSupportedException("Delivery receipts must be retained. Roll back the application without deleting RMS hardening data."); + } +} diff --git a/Providers/Resgrid.Providers.Neris/NerisApiClient.cs b/Providers/Resgrid.Providers.Neris/NerisApiClient.cs index 7402f8120..65c73a7d7 100644 --- a/Providers/Resgrid.Providers.Neris/NerisApiClient.cs +++ b/Providers/Resgrid.Providers.Neris/NerisApiClient.cs @@ -40,11 +40,19 @@ public NerisApiClient(HttpClient http) public static string BaseUrlFor(RmsNerisProfile profile) { - if (!string.IsNullOrWhiteSpace(profile?.BaseUrlOverride)) - return profile.BaseUrlOverride.TrimEnd('/'); - if (profile?.Environment == NerisEnvironments.Sandbox && !string.IsNullOrWhiteSpace(NerisConfig.SandboxBaseUrl)) - return NerisConfig.SandboxBaseUrl.TrimEnd('/'); - return (NerisConfig.BaseUrl ?? string.Empty).TrimEnd('/'); + if (profile == null || (profile.Environment != NerisEnvironments.Sandbox && profile.Environment != NerisEnvironments.Production)) + throw new InvalidOperationException("A NERIS environment must be explicitly selected."); + var url = string.IsNullOrWhiteSpace(profile.BaseUrlOverride) + ? (profile.Environment == NerisEnvironments.Sandbox ? NerisConfig.SandboxBaseUrl : NerisConfig.BaseUrl) + : profile.BaseUrlOverride; + if (!Uri.TryCreate(url?.Trim(), UriKind.Absolute, out var endpoint) || endpoint.Scheme != Uri.UriSchemeHttps + || !string.IsNullOrEmpty(endpoint.UserInfo) || !string.IsNullOrEmpty(endpoint.Query) || !string.IsNullOrEmpty(endpoint.Fragment)) + throw new InvalidOperationException("Configure an HTTPS NERIS endpoint for the selected environment before submitting."); + if (profile.Environment == NerisEnvironments.Sandbox && + (endpoint.Host.Equals("api.neris.fsri.org", StringComparison.OrdinalIgnoreCase) + || (Uri.TryCreate(NerisConfig.BaseUrl, UriKind.Absolute, out var production) && endpoint.Host.Equals(production.Host, StringComparison.OrdinalIgnoreCase)))) + throw new InvalidOperationException("The NERIS sandbox endpoint must use a host distinct from production."); + return endpoint.AbsoluteUri.TrimEnd('/'); } public Task ValidateAsync(RmsNerisProfile profile, NerisCredential credential, string payloadJson, CancellationToken cancellationToken = default) @@ -80,7 +88,7 @@ public Task UpdateIncidentAnalysisAsync(RmsNerisProfile public Task GetIncidentAnalysisStatusAsync(RmsNerisProfile profile, NerisCredential credential, string nerisAnalysisId, CancellationToken cancellationToken = default) { - return SendAsync(profile, credential, HttpMethod.Get, () => $"/incident_analysis/{Entity(profile)}/{Uri.EscapeDataString(nerisAnalysisId ?? string.Empty)}", null, StatusOutcome(nerisAnalysisId), cancellationToken); + return SendAsync(profile, credential, HttpMethod.Get, () => $"/incident_analysis/{Entity(profile)}?neris_id_ia={Uri.EscapeDataString(nerisAnalysisId ?? string.Empty)}", null, StatusOutcome(nerisAnalysisId, "incident_analysis_status"), cancellationToken); } private static string Entity(RmsNerisProfile profile) => Uri.EscapeDataString(profile.NerisEntityId); @@ -92,6 +100,8 @@ private async Task SendAsync(RmsNerisProfile profile, Ne return Fatal("The department has no NERIS entity ID configured."); if (credential == null) return Fatal("The department has no NERIS credential configured."); + try { BaseUrlFor(profile); } + catch (InvalidOperationException ex) { return Fatal(ex.Message); } var path = pathFactory(); @@ -112,6 +122,7 @@ private async Task SendAsync(RmsNerisProfile profile, Ne try { using var request = new HttpRequestMessage(method, BaseUrlFor(profile) + path); + request.Headers.UserAgent.ParseAdd("Resgrid-RMS/1.0 (+https://resgrid.com)"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); if (body != null) @@ -127,13 +138,25 @@ private async Task SendAsync(RmsNerisProfile profile, Ne } if ((int)response.StatusCode == 429 || (int)response.StatusCode >= 500) - return WithStatus(Transient($"NERIS returned {(int)response.StatusCode}."), response.StatusCode, text); + { + var failure = WithStatus(Transient($"NERIS returned {(int)response.StatusCode}."), response.StatusCode, text); + failure.DeliveryUncertain = (int)response.StatusCode >= 500 && method == HttpMethod.Post && !path.EndsWith("/validate", StringComparison.Ordinal); + return failure; + } - return interpret(response.StatusCode, text); + var interpreted = interpret(response.StatusCode, text); + if (method == HttpMethod.Post && !path.EndsWith("/validate", StringComparison.Ordinal) && response.IsSuccessStatusCode && string.IsNullOrWhiteSpace(interpreted.ExternalId)) + { + interpreted.DeliveryUncertain = true; + interpreted.Message = "The create reply has no usable destination identifier; reconciliation is required."; + } + return interpreted; } catch (Exception ex) when (IsTransientTransportFailure(ex, cancellationToken)) { - return Transient("NERIS unreachable: " + ex.Message); + var failure = Transient("NERIS unreachable: " + ex.Message); + failure.DeliveryUncertain = method == HttpMethod.Post && !path.EndsWith("/validate", StringComparison.Ordinal); + return failure; } } @@ -194,12 +217,12 @@ private static Func UpdateOutcom if ((int)status == 422) return WithStatus(Rejected(text), status, text); if (status == HttpStatusCode.NotFound) - return WithStatus(Fatal("NERIS no longer knows the incident; the next submission must create it again."), status, text); + return WithStatus(Fatal("NERIS could not locate the saved destination identifier. Reconcile the filing before retrying."), status, text); return WithStatus(Transient($"Unexpected NERIS update reply {(int)status}."), status, text); }; } - private static Func StatusOutcome(string nerisIncidentId) + private static Func StatusOutcome(string nerisIncidentId, string statusProperty = "incident_status") { return (status, text) => { @@ -207,7 +230,7 @@ private static Func StatusOutcom return WithStatus(Transient($"Unexpected NERIS status reply {(int)status}."), status, text); var json = TryParse(text); - var outcome = new NerisSubmissionOutcome { ExternalId = nerisIncidentId, ExternalStatus = (string)json?["incident_status"]?["status"], Kind = NerisOutcomeKind.Pending }; + var outcome = new NerisSubmissionOutcome { ExternalId = nerisIncidentId, ExternalStatus = (string)json?[statusProperty]?["status"], Kind = NerisOutcomeKind.Pending }; return WithStatus(Promote(outcome), status, text); }; } @@ -303,6 +326,7 @@ private async Task GetTokenAsync(RmsNerisProfile profile, NerisCredentia } using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrlFor(profile) + "/token") { Content = new FormUrlEncodedContent(form) }; + request.Headers.UserAgent.ParseAdd("Resgrid-RMS/1.0 (+https://resgrid.com)"); // The pinned contract's TokenBody carries username/password for the password and MFA flows only; a // client-credentials integration account authenticates with HTTP Basic, so sending the id and secret as @@ -331,7 +355,7 @@ private async Task GetTokenAsync(RmsNerisProfile profile, NerisCredentia throw new NerisAuthException($"NERIS did not issue a token ({error})."); } - private static string TokenKey(RmsNerisProfile profile) => $"{profile.DepartmentId}:{profile.RmsNerisProfileId}:{profile.RowVersion}"; + private static string TokenKey(RmsNerisProfile profile) => $"{profile.DepartmentId}:{profile.RmsNerisProfileId}:{profile.RowVersion}:{profile.Environment}:{BaseUrlFor(profile)}"; private static NerisSubmissionOutcome WithStatus(NerisSubmissionOutcome outcome, HttpStatusCode status, string text) { diff --git a/Providers/Resgrid.Providers.Neris/NerisContractCatalog.cs b/Providers/Resgrid.Providers.Neris/NerisContractCatalog.cs new file mode 100644 index 000000000..86592049c --- /dev/null +++ b/Providers/Resgrid.Providers.Neris/NerisContractCatalog.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Json.Schema; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; + +namespace Resgrid.Providers.Neris +{ + /// The same pinned schema powers guided controls and complete destination-payload validation. + public sealed class NerisContractCatalog + { + private static readonly Lazy Embedded = new Lazy(Load); + private readonly JObject _schemas; + private readonly ConcurrentDictionary _compiled = new ConcurrentDictionary(StringComparer.Ordinal); + private NerisContractCatalog(JObject schemas) { _schemas = schemas; } + public static NerisContractCatalog Instance => Embedded.Value; + public string SchemasJson => _schemas.ToString(Formatting.None); + public JObject GetSchema(string name) => _schemas[name]?.DeepClone() as JObject; + + public List Validate(string schemaName, string json, int departmentId, string recordId, bool allowPendingIncident = false) + { + var issues = new List(); + void Add(string path, string keyword) + { + var message = keyword switch + { + "required" => "Complete the required fields in this section.", + "additionalProperties" => "This section contains a field that is not supported by the reporting contract.", + "enum" or "const" => "Choose a supported reporting value.", + "type" => "Enter a value of the required type.", + "format" or "pattern" => "Enter a value in the required format.", + "minItems" => "Add the required entries to this section.", + "maxItems" or "uniqueItems" => "Remove duplicate or excess entries from this section.", + _ => "This value does not meet the reporting contract's requirements." + }; + issues.Add(new RmsValidationIssue { RmsValidationIssueId = Guid.NewGuid().ToString(), DepartmentId = departmentId, RecordId = recordId, + RuleKey = "neris.schema." + keyword, FieldPath = string.IsNullOrEmpty(path) ? "/" : path, Message = message, + Severity = (int)RmsValidationSeverity.Error, Source = (int)RmsValidationSource.Local, + ProfileVersion = NerisValueSetCatalog.Instance.ContractVersion, CreatedOn = DateTime.UtcNow }); + } + try + { + using var instance = JsonDocument.Parse(json); + var key = allowPendingIncident && schemaName == "IncidentAnalysisPayload" ? "PendingIncidentAnalysisPayload" : schemaName; + var evaluation = _compiled.GetOrAdd(key, Compile).Evaluate(instance.RootElement, + new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical, RequireFormatValidation = true }); + if (evaluation.IsValid) return issues; + FindMissingFields(GetSchema(schemaName), JToken.Parse(json), "", Add, allowPendingIncident); + void Visit(EvaluationResults result) + { + if (result.IsValid) return; + if (result.Errors != null) + foreach (var error in result.Errors) + if (!string.IsNullOrEmpty(error.Key) && !new[] { "required", "properties", "items", "anyOf", "oneOf", "$ref" }.Contains(error.Key)) + Add(result.InstanceLocation.ToString(), error.Key); + foreach (var detail in result.Details ?? new List()) Visit(detail); + } + Visit(evaluation); + if (issues.Count == 0) Add("/", "contract"); + } + catch (System.Text.Json.JsonException) { Add("/", "type"); } + return issues.GroupBy(i => (i.FieldPath, i.RuleKey)).Select(g => g.First()).ToList(); + } + + private void FindMissingFields(JObject schema, JToken value, string path, Action add, bool pending, int depth = 0) + { + if (schema == null || depth > 40 || value == null || value.Type == JTokenType.Null) return; + if (schema["$ref"] is JValue reference) + { + FindMissingFields(GetSchema(((string)reference).Split('/').Last()), value, path, add, pending, depth + 1); + return; + } + if ((schema["anyOf"] ?? schema["oneOf"]) is JArray alternatives) + { + var choices = alternatives.OfType().Select(s => s["$ref"] == null ? s : GetSchema(((string)s["$ref"]).Split('/').Last())).Where(s => (string)s["type"] != "null").ToList(); + var discriminator = (string)schema["discriminator"]?["propertyName"] ?? "type"; + var actual = (value as JObject)?[discriminator]; + var chosen = choices.Count == 1 ? choices[0] : choices.FirstOrDefault(s => + actual != null && (JToken.DeepEquals(s["properties"]?[discriminator]?["const"], actual) || (s["properties"]?[discriminator]?["enum"] as JArray)?.Any(v => JToken.DeepEquals(v, actual)) == true)); + if (chosen != null) FindMissingFields(chosen, value, path, add, pending, depth + 1); + return; + } + if (value is JObject obj) + { + foreach (var field in (schema["required"] as JArray ?? new JArray()).Values()) + if (obj[field] == null && !(pending && path == "/base" && field == "neris_id_incident")) add(path + "/" + field, "required"); + foreach (var property in (schema["properties"] as JObject ?? new JObject()).Properties()) + if (obj[property.Name] != null) FindMissingFields(property.Value as JObject, obj[property.Name], path + "/" + property.Name, add, pending, depth + 1); + } + if (value is JArray array) + for (var i = 0; i < array.Count; i++) FindMissingFields(schema["items"] as JObject, array[i], path + "/" + i, add, pending, depth + 1); + } + + private JsonSchema Compile(string schemaName) + { + var pendingIncident = schemaName == "PendingIncidentAnalysisPayload"; + if (pendingIncident) schemaName = "IncidentAnalysisPayload"; + if (_schemas[schemaName] == null) throw new ArgumentException("Unknown pinned NERIS section.", nameof(schemaName)); + // OpenAPI components become JSON Schema definitions. All references stay inside the embedded document. + var definitions = JObject.Parse(SchemasJson.Replace("#/components/schemas/", "#/$defs/")); + // A local analysis may be signed while its parent is awaiting a destination ID. Only this one required + // field is deferred; submission validation always uses the unmodified contract, including its pattern. + if (pendingIncident) + definitions["IncidentAnalysisBasePayload"]["required"] = new JArray(((JArray)definitions["IncidentAnalysisBasePayload"]["required"]).Where(t => (string)t != "neris_id_incident")); + var schema = new JObject { ["$schema"] = "https://json-schema.org/draft/2020-12/schema", ["$defs"] = definitions, ["$ref"] = "#/$defs/" + schemaName }; + return JsonSchema.FromText(schema.ToString(Formatting.None)); + } + + private static NerisContractCatalog Load() + { + using var stream = typeof(NerisContractCatalog).Assembly.GetManifestResourceStream("Resgrid.Providers.Neris.Contract.openapi.json") + ?? throw new InvalidOperationException("The pinned NERIS contract is not embedded."); + using var reader = new StreamReader(stream); + var contract = JObject.Parse(reader.ReadToEnd()); + if ((string)contract["info"]?["version"] != NerisValueSetCatalog.Instance.ContractVersion) + throw new InvalidOperationException("The NERIS schema and value-set versions do not match."); + return new NerisContractCatalog((JObject)contract["components"]["schemas"]); + } + } +} diff --git a/Providers/Resgrid.Providers.Neris/NerisMappingService.cs b/Providers/Resgrid.Providers.Neris/NerisMappingService.cs index 512b59ff5..86ef2af32 100644 --- a/Providers/Resgrid.Providers.Neris/NerisMappingService.cs +++ b/Providers/Resgrid.Providers.Neris/NerisMappingService.cs @@ -123,10 +123,8 @@ public string BuildIncidentAnalysisPayloadJson(NerisIncidentAnalysisSnapshot sna { ["base"] = Compact(new JObject { - ["department_neris_id"] = profile.NerisEntityId, - ["incident_neris_id"] = Blank(snapshot.Report?.NerisIncidentId), - ["general_cause"] = Blank(analysis.GeneralCause), - ["investigation_types"] = Csv(analysis.InvestigationTypesCsv) + ["neris_id_incident"] = Blank(snapshot.Report?.NerisIncidentId), + ["incident_number"] = Blank(snapshot.Report?.IncidentNumber) }) }; @@ -137,6 +135,17 @@ public string BuildIncidentAnalysisPayloadJson(NerisIncidentAnalysisSnapshot sna payload["vehicles"] = new JArray(snapshot.Vehicles.OrderBy(v => v.Ordinal).Select(MapVehicle)); ApplyModules(payload, snapshot.Modules, analysis: true); + // The analysis base has no cause/investigation-code fields. Preserve the recorded findings in its + // narrative and put the coded cause on the fire-origin sections supported by the pinned contract. + var findings = new List(); + if (!string.IsNullOrWhiteSpace(analysis.GeneralCause)) + { + findings.Add("General cause: " + analysis.GeneralCause); + foreach (var path in new[] { "structure_fire_origin", "outside_fire" }) + if (payload[path] is JObject origin) origin["general_cause"] = analysis.GeneralCause; + } + if (!string.IsNullOrWhiteSpace(analysis.InvestigationTypesCsv)) findings.Add("Investigation types: " + analysis.InvestigationTypesCsv); + if (findings.Count > 0) payload["base"]["narrative"] = string.Join("\n", findings); return payload.ToString(Formatting.None); } @@ -148,7 +157,9 @@ public string BuildIncidentAnalysisPayloadJson(NerisIncidentAnalysisSnapshot sna /// private static void ApplyModules(JObject payload, List modules, bool analysis) { - foreach (var group in modules.Where(m => m != null).GroupBy(m => (RmsIncidentModuleKind)m.ModuleKind)) + // Parent sections must be written before their separately authored children, regardless of row order. + foreach (var group in modules.Where(m => m != null).GroupBy(m => (RmsIncidentModuleKind)m.ModuleKind) + .OrderBy(g => RmsIncidentModuleCatalog.Get(g.Key)?.PayloadPath.Count(c => c == '.') ?? 0).ThenBy(g => (int)g.Key)) { var descriptor = RmsIncidentModuleCatalog.Get(group.Key); if (descriptor == null || descriptor.BelongsToAnalysis != analysis) @@ -185,25 +196,32 @@ private static void SetAtPath(JObject root, string path, JToken value) current[segments[segments.Length - 1]] = value; } - private static JObject MapExposure(RmsExposure exposure) + public static JObject MapExposure(RmsExposure exposure) { var body = ParseDetail(exposure.DetailJson) ?? new JObject(); body["damage_type"] = Blank(exposure.DamageType); body["people_present"] = exposure.PeoplePresent; body["displacement_count"] = exposure.DisplacementCount; - body["location_detail"] = NullIfEmpty(Compact(new JObject { ["type"] = Blank(exposure.LocationKind), ["item_type"] = Blank(exposure.ItemType) })); - body["location"] = NullIfEmpty(Compact(new JObject - { - ["street"] = Blank(exposure.Street), - ["incorporated_municipality"] = Blank(exposure.Municipality), - ["state"] = Blank(exposure.State), - ["postal_code"] = Blank(exposure.PostalCode), - ["additional_info"] = Blank(exposure.AddressText) - })); + var locationKind = Blank(exposure.LocationKind) switch { "EXTERNAL" => "EXTERNAL_EXPOSURE", "INTERNAL" => "INTERNAL_EXPOSURE", var kind => kind }; + var locationDetail = body["location_detail"] as JObject ?? new JObject(); + Put(locationDetail, "type", locationKind); + if ((string)locationDetail["type"] == "EXTERNAL_EXPOSURE") Put(locationDetail, "item_type", Blank(exposure.ItemType)); + else if ((string)locationDetail["type"] == "INTERNAL_EXPOSURE") locationDetail.Remove("item_type"); + body["location_detail"] = NullIfEmpty(locationDetail); + var location = body["location"] as JObject ?? new JObject(); + Put(location, "street", Blank(exposure.Street)); + Put(location, "incorporated_municipality", Blank(exposure.Municipality)); + Put(location, "state", Blank(exposure.State)); + Put(location, "postal_code", Blank(exposure.PostalCode)); + Put(location, "additional_info", Blank(exposure.AddressText)); + body["location"] = NullIfEmpty(location); if (!string.IsNullOrWhiteSpace(exposure.LocationUse)) - body["location_use"] = new JObject { ["use_type"] = exposure.LocationUse }; + { + var use = body["location_use"] as JObject ?? new JObject(); + use["use_type"] = exposure.LocationUse; body["location_use"] = use; + } var causes = Csv(exposure.DisplacementCausesCsv); if (causes != null) @@ -219,14 +237,15 @@ private static JObject MapExposure(RmsExposure exposure) /// The casualty/rescue entry. The department's own personnel link never leaves Resgrid — the destination /// gets the reported demographics it asks for and nothing that identifies the member in our system. /// - private static JObject MapCasualtyRescue(RmsCasualtyRescue casualty) + public static JObject MapCasualtyRescue(RmsCasualtyRescue casualty) { var body = ParseDetail(casualty.DetailJson) ?? new JObject(); body["type"] = Blank(casualty.PersonType); body["rank"] = Blank(casualty.Rank); body["years_of_service"] = casualty.YearsOfService; - body["birth_month_year"] = Blank(casualty.BirthMonthYear); + body["birth_month_year"] = DateTime.TryParseExact(casualty.BirthMonthYear, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out var birth) + ? birth.ToString("MM/yyyy", CultureInfo.InvariantCulture) : Blank(casualty.BirthMonthYear); body["gender"] = Blank(casualty.Gender); body["race"] = Blank(casualty.Race); @@ -250,7 +269,7 @@ private static JObject MapInjury(RmsCasualtyRescue casualty) var injury = new JObject { - ["type"] = casualty.WasFatal ? "INJURED_FATAL" : "INJURED_NONFATAL", + ["type"] = casualty.WasFatal ? "INJURED_FATAL" : casualty.WasInjured == true ? "INJURED_NONFATAL" : null, ["cause"] = Blank(casualty.CasualtyCause) }; @@ -279,7 +298,7 @@ private static JObject MapInjury(RmsCasualtyRescue casualty) /// private static JObject MapRescue(RmsCasualtyRescue casualty) { - var rescue = new JObject(); + var rescue = ParseDetail(casualty.DetailJson)?["rescue"] as JObject ?? new JObject(); if (!string.IsNullOrWhiteSpace(casualty.PresenceKnown)) rescue["presence_known"] = new JObject { ["presence_known_type"] = casualty.PresenceKnown }; @@ -292,7 +311,8 @@ private static JObject MapRescue(RmsCasualtyRescue casualty) return Compact(rescue); } - var ff = new JObject { ["type"] = type ?? "RESCUED_BY_FIREFIGHTER" }; + var ff = rescue["ffrescue_or_nonffrescue"] as JObject ?? new JObject(); + ff["type"] = type; var actions = Csv(casualty.RescueActionsCsv); if (actions != null) ff["actions"] = actions; @@ -303,16 +323,15 @@ private static JObject MapRescue(RmsCasualtyRescue casualty) var mode = Blank(casualty.RescueMode); if (string.Equals(mode, RemovalMode, StringComparison.Ordinal)) { - ff["removal_or_nonremoval"] = Compact(new JObject - { - ["type"] = RemovalMode, - ["elevation_type"] = Blank(casualty.RescueElevation), - ["rescue_path_type"] = Blank(casualty.RescuePath) - }); + var removal = ff["removal_or_nonremoval"] as JObject ?? new JObject(); + removal["type"] = RemovalMode; + Put(removal, "elevation_type", Blank(casualty.RescueElevation)); + Put(removal, "rescue_path_type", Blank(casualty.RescuePath)); + ff["removal_or_nonremoval"] = Compact(removal); } else { - ff["removal_or_nonremoval"] = new JObject { ["type"] = mode ?? "OTHER" }; + ff["removal_or_nonremoval"] = Compact(new JObject { ["type"] = mode }); } rescue["ffrescue_or_nonffrescue"] = Compact(ff); @@ -326,26 +345,26 @@ private static JObject MapRescue(RmsCasualtyRescue casualty) "RESCUED_BY_FIREFIGHTER", "RESCUED_BY_FF_RIT", "EVAC_ASSISTED_BY_FIREFIGHTER" }; - private static JObject MapProperty(RmsIncidentProperty property) + public static JObject MapProperty(RmsIncidentProperty property) { var body = ParseDetail(property.DetailJson) ?? new JObject(); - - body["location_use"] = Blank(property.LocationUse); - body["construction_type"] = Blank(property.ConstructionType); - body["foundation"] = Blank(property.Foundation); - body["exterior_finish"] = Blank(property.ExteriorFinish); - body["roof_material"] = Blank(property.RoofMaterial); - body["stories_above_grade"] = property.StoriesAboveGrade; - body["stories_below_grade"] = property.StoriesBelowGrade; - body["year_built"] = property.YearBuilt; - body["vacancy"] = Blank(property.Vacancy); - body["damage_type"] = Blank(property.DamageType); - body["fire_spread"] = Blank(property.FireSpread); - body["estimated_value"] = property.EstimatedValue; - body["estimated_loss"] = property.EstimatedLoss; - body["contents_value"] = property.ContentsValue; - body["contents_loss"] = property.ContentsLoss; - + var structure = (body["structures"] as JArray)?.FirstOrDefault() as JObject ?? new JObject(); + var use = structure["location_use"] as JObject ?? new JObject(); + Put(use, "use_type", Blank(property.LocationUse)); + Put(use, "vacancy_cause", Blank(property.Vacancy)); + if (use.HasValues) structure["location_use"] = use; + Put(structure, "construction_type", Blank(property.ConstructionType)); + Put(structure, "foundation", Blank(property.Foundation)); + Put(structure, "exterior_finish", Blank(property.ExteriorFinish)); + Put(structure, "roof_material", Blank(property.RoofMaterial)); + Put(structure, "year_built", property.YearBuilt); + Put(structure, "damage_assessment", Blank(property.DamageType)); + Put(structure, "estimated_property_value", property.EstimatedValue); + Put(structure, "estimated_property_loss_value", property.EstimatedLoss); + Put(structure, "estimated_contents_value", property.ContentsValue); + Put(structure, "estimated_contents_loss_value", property.ContentsLoss); + if (structure.HasValues && !(body["structures"] is JArray { Count: > 0 })) body["structures"] = new JArray(structure); + // Stories and fire spread remain in the department snapshot; this analysis contract has no such fields. return Compact(body); } @@ -354,22 +373,24 @@ private static JObject MapVehicle(RmsIncidentVehicle vehicle) var body = ParseDetail(vehicle.DetailJson) ?? new JObject(); body["type"] = Blank(vehicle.VehicleKind); - body["make"] = Blank(vehicle.Make); + if (vehicle.VehicleKind == "AUTOMOBILE") body["make"] = Blank(vehicle.Make); body["model"] = Blank(vehicle.Model); - body["model_year"] = vehicle.ModelYear; - body["body_style"] = Blank(vehicle.BodyStyle); + body["manufacture_year"] = vehicle.ModelYear; + if (vehicle.VehicleKind == "AUTOMOBILE") body["auto_body_style"] = Blank(vehicle.BodyStyle); body["powertrain"] = Blank(vehicle.Powertrain); - body["damage_type"] = Blank(vehicle.DamageType); - body["vin"] = Blank(vehicle.Vin); - body["license_plate"] = Blank(vehicle.LicensePlate); - body["license_state"] = Blank(vehicle.LicenseState); - body["occupied"] = vehicle.WasOccupied ? true : (bool?)null; - body["estimated_value"] = vehicle.EstimatedValue; - body["estimated_loss"] = vehicle.EstimatedLoss; + body["damage"] = Blank(vehicle.DamageType); + body["identification_num"] = Blank(vehicle.Vin); + body["state"] = Blank(vehicle.LicenseState); + // Plate, occupancy and valuation are department facts, not fields in the destination's vehicle schema. return Compact(body); } + private static void Put(JObject target, string key, object value) + { + if (value != null) target[key] = JToken.FromObject(value); + } + private static JObject ParseDetail(string json) { if (string.IsNullOrWhiteSpace(json)) diff --git a/Providers/Resgrid.Providers.Neris/NerisPayloadRules.cs b/Providers/Resgrid.Providers.Neris/NerisPayloadRules.cs new file mode 100644 index 000000000..a27d2139c --- /dev/null +++ b/Providers/Resgrid.Providers.Neris/NerisPayloadRules.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Providers.Neris +{ + /// Cross-field rules described in the pinned OpenAPI that JSON Schema alone cannot express. + public static class NerisPayloadRules + { + public static void Validate(JObject payload, bool analysis, Action add) + { + bool Present(JToken value) => value != null && value.Type != JTokenType.Null && (!(value is JArray list) || list.Count > 0); + void Require(JObject obj, string field, string path, string message) { if (!Present(obj?[field])) add(path + "/" + field, message); } + if (analysis) + { + if (!new[] { "structure_fire_origin", "outside_fire", "hazsit", "products", "batteries", "properties", "vehicles" }.Any(k => Present(payload[k]))) + add("/", "Add at least one analysis section, property, or vehicle."); + var properties = (payload["properties"] as JArray ?? new JArray()).OfType().ToList(); + var ignitionCount = 0; + for (var i = 0; i < properties.Count; i++) + { + var property = properties[i]; var propertyPath = "/properties/" + i; + if (!new[] { "parcel_id", "location", "point" }.Any(k => Present(property[k]))) add(propertyPath, "Identify the property by parcel, address, or map point."); + var structures = (property["structures"] as JArray ?? new JArray()).OfType().ToList(); + for (var j = 0; j < structures.Count; j++) + { + var structure = structures[j]; var path = propertyPath + "/structures/" + j; + if (!Present(structure["location"]) && !Present(structure["point"])) add(path + "/location", "Provide the structure's address or map point."); + if ((bool?)structure["ignition_source"] == true) + { + ignitionCount++; + foreach (var field in new[] { "exterior_ignition_location", "exterior_ignition_causes" }) + if (Present(structure[field])) add(path + "/" + field, "An ignition-source structure cannot also be ignited from the exterior."); + } + if (Present(structure["occupants_displaced"]) && !Present(structure["occupant_count"])) add(path + "/occupant_count", "Enter the occupant count when recording displacement."); + if (Present(structure["displacement_causes"]) && !Present(structure["occupants_displaced"]) && !Present(structure["occupant_count"])) add(path + "/displacement_causes", "Record occupants before entering displacement causes."); + } + } + if (Present(payload["structure_fire_origin"]) && ignitionCount != 1) add("/properties", "A structure-fire origin analysis requires exactly one structure marked as the ignition source."); + if (ignitionCount > 0 && !Present(payload["structure_fire_origin"])) add("/structure_fire_origin", "Complete the structure-fire origin section for the ignition-source structure."); + if (ignitionCount > 0 && Present(payload["outside_fire"]?["ignition_point"])) add("/outside_fire/ignition_point", "An outdoor ignition point cannot also have an ignition-source structure."); + if ((payload["products"] as JArray ?? new JArray()).Count(p => (bool?)p["item_first_ignited"] == true) > 1) add("/products", "Only one product may be marked as first ignited."); + var vehicles = (payload["vehicles"] as JArray ?? new JArray()).OfType().ToList(); + for (var i = 0; i < vehicles.Count; i++) + if ((string)vehicles[i]["type"] == "AUTOMOBILE") + { + Require(vehicles[i], "make", "/vehicles/" + i, "Choose the automobile make."); + Require(vehicles[i], "auto_body_style", "/vehicles/" + i, "Choose the automobile body style."); + } + } + else + { + var types = (payload["incident_types"] as JArray ?? new JArray()).Select(t => (string)t["type"]).Where(t => t != null).ToList(); + foreach (var pair in new[] { ("fire_detail", "FIRE"), ("hazsit_detail", "HAZSIT"), ("medical_details", "MEDICAL") }) + if (Present(payload[pair.Item1]) && !types.Any(t => t.StartsWith(pair.Item2 + "||", StringComparison.Ordinal))) add("/" + pair.Item1, "This section requires a matching incident type."); + if (types.Distinct().Count() != types.Count) add("/incident_types", "Select each incident type only once."); + var casualties = (payload["casualty_rescues"] as JArray ?? new JArray()).OfType().ToList(); + for (var i = 0; i < casualties.Count; i++) + { + var person = casualties[i]; var path = "/casualty_rescues/" + i; + if ((string)person["type"] != "FF") + { + foreach (var key in new[] { "rank", "years_of_service" }) + if (Present(person[key])) add(path + "/" + key, "This field applies only to a firefighter."); + if (Present(person["rescue"]?["mayday"])) add(path + "/rescue/mayday", "Mayday details apply only to a firefighter being rescued."); + if (Present(person["casualty"]?["injury_or_noninjury"]?["ff_injury_details"])) + add(path + "/casualty/injury_or_noninjury/ff_injury_details", "Firefighter injury details apply only to a firefighter."); + } + if ((string)person["type"] != "NONFF" && Present(person["rescue"]?["presence_known"])) + add(path + "/rescue/presence_known", "Occupant presence details apply only to a nonfirefighter being rescued."); + } + var aids = (payload["aids"] as JArray ?? new JArray()).OfType().ToList(); + if (aids.Select(a => (string)a["department_neris_id"]).Distinct().Count() != aids.Count) add("/aids", "List each aid department only once."); + if (aids.Any(a => (string)a["department_neris_id"] == (string)payload["base"]?["department_neris_id"])) add("/aids", "The reporting department cannot be its own aid department."); + var supportOnly = aids.Count > 0 && aids.All(a => (string)a["aid_type"] == "SUPPORT_AID" && (string)a["aid_direction"] == "GIVEN"); + if (types.Any(t => t.StartsWith("FIRE||STRUCTURE_FIRE||", StringComparison.Ordinal)) && !supportOnly) + { + foreach (var field in new[] { "smoke_alarm", "fire_alarm", "other_alarm", "fire_suppression" }) Require(payload, field, "", "Complete alarm and suppression information for this structure fire."); + if (types.Contains("FIRE||STRUCTURE_FIRE||CONFINED_COOKING_APPLIANCE_FIRE")) Require(payload, "cooking_fire_suppression", "", "Complete cooking-fire suppression information."); + } + } + void Walk(JToken token, string path) + { + if (token is JObject obj) + { + foreach (var property in obj.Properties()) + { + var p = path + "/" + property.Name; + if (property.Value is JArray list && new[] { "suppression_appliances", "investigation_types", "ppe_items" }.Contains(property.Name) + && list.Count > 1 && list.Any(v => (string)v == "NONE")) add(p, "None must be selected by itself."); + Walk(property.Value, p); + } + if (obj["release_occurred"]?.Type == JTokenType.Boolean && (bool)obj["release_occurred"] == false && Present(obj["release"])) add(path + "/release", "Release details require a reported release."); + if (path.StartsWith("/electric_hazards/", StringComparison.Ordinal) && Present(obj["involved_in_crash"]) && (string)obj["type"] != "ELECTRIC_VEHICLE") add(path + "/involved_in_crash", "Crash involvement applies only to electric vehicles."); + } + else if (token is JArray array) for (var i = 0; i < array.Count; i++) Walk(array[i], path + "/" + i); + } + Walk(payload, ""); + } + } +} diff --git a/Providers/Resgrid.Providers.Neris/NerisProfileService.cs b/Providers/Resgrid.Providers.Neris/NerisProfileService.cs index ed42ec351..e027c7d4e 100644 --- a/Providers/Resgrid.Providers.Neris/NerisProfileService.cs +++ b/Providers/Resgrid.Providers.Neris/NerisProfileService.cs @@ -41,6 +41,12 @@ public NerisProfileService(IRmsNerisProfilesRepository profiles, IRmsNerisValueS public string ContractVersion => NerisValueSetCatalog.Instance.ContractVersion; + public string GetDestinationIdentity(RmsNerisProfile profile) => Newtonsoft.Json.JsonConvert.SerializeObject(new + { + profile.DepartmentId, profile.RmsNerisProfileId, profile.NerisEntityId, profile.Environment, + Endpoint = NerisApiClient.BaseUrlFor(profile), Contract = profile.ContractVersion ?? ContractVersion + }); + public IReadOnlyList ValueSetKeys => NerisValueSetCatalog.Instance.SetKeys; public Task GetProfileAsync(int departmentId) diff --git a/Providers/Resgrid.Providers.Neris/NerisSectionRules.cs b/Providers/Resgrid.Providers.Neris/NerisSectionRules.cs index b914c3b46..91ff3c220 100644 --- a/Providers/Resgrid.Providers.Neris/NerisSectionRules.cs +++ b/Providers/Resgrid.Providers.Neris/NerisSectionRules.cs @@ -48,7 +48,7 @@ public SectionRequirement(RmsIncidentModuleKind kind, bool required, string reas /// The sections that apply to a set of incident type codes, most specific first. The result is what the /// authoring surface renders and what checks; there is no second list. /// - public static IReadOnlyList For(IEnumerable incidentTypeCodes) + public static IReadOnlyList For(IEnumerable incidentTypeCodes, bool supportAidOnly = false) { var codes = (incidentTypeCodes ?? Enumerable.Empty()) .Where(c => !string.IsNullOrWhiteSpace(c)) @@ -70,9 +70,12 @@ void Add(RmsIncidentModuleKind kind, bool required, string reason) if (Any(StructureFirePrefix)) { Add(RmsIncidentModuleKind.StructureFireLocation, true, "The fire was in a structure."); - Add(RmsIncidentModuleKind.SmokeAlarm, false, "Structure fires report whether a smoke alarm was present."); - Add(RmsIncidentModuleKind.FireAlarm, false, "Structure fires report whether a fire alarm system was present."); - Add(RmsIncidentModuleKind.FireSuppression, false, "Structure fires report whether automatic suppression was present."); + Add(RmsIncidentModuleKind.SmokeAlarm, !supportAidOnly, "Structure fires report whether a smoke alarm was present."); + Add(RmsIncidentModuleKind.FireAlarm, !supportAidOnly, "Structure fires report whether a fire alarm system was present."); + Add(RmsIncidentModuleKind.OtherAlarm, !supportAidOnly, "Structure fires report other alarm presence."); + Add(RmsIncidentModuleKind.FireSuppression, !supportAidOnly, "Structure fires report whether automatic suppression was present."); + if (codes.Contains("FIRE||STRUCTURE_FIRE||CONFINED_COOKING_APPLIANCE_FIRE")) + Add(RmsIncidentModuleKind.CookingFireSuppression, !supportAidOnly, "Confined cooking appliance fires report cooking-fire suppression."); } if (Any(OutsideFirePrefix) || Any(SpecialFirePrefix) || Any(TransportationFirePrefix)) diff --git a/Providers/Resgrid.Providers.Neris/NerisSubmissionService.cs b/Providers/Resgrid.Providers.Neris/NerisSubmissionService.cs index 91e57a25d..b6c8e20fc 100644 --- a/Providers/Resgrid.Providers.Neris/NerisSubmissionService.cs +++ b/Providers/Resgrid.Providers.Neris/NerisSubmissionService.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using Resgrid.Model; using Resgrid.Model.Providers; @@ -25,15 +27,13 @@ public NerisSubmissionService(INerisApiClient client, INerisProfileService profi public async Task DeliverAsync(RmsNerisProfile profile, RmsSubmission submission, string existingNerisIncidentId, CancellationToken cancellationToken = default) { if (submission == null) throw new ArgumentNullException(nameof(submission)); - if (string.IsNullOrWhiteSpace(submission.PayloadJson)) - return new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Fatal, Message = "The submission carries no payload." }; + var invalid = ValidateQueuedPayload(profile, submission, false); + if (invalid != null) return invalid; var credential = await _profiles.GetCredentialAsync(profile); if (!string.IsNullOrWhiteSpace(existingNerisIncidentId)) { var update = await _client.UpdateIncidentAsync(profile, credential, existingNerisIncidentId, submission.PayloadJson, cancellationToken); - if (update.Kind == NerisOutcomeKind.Fatal && update.StatusCode == 404) - return await _client.CreateIncidentAsync(profile, credential, submission.PayloadJson, cancellationToken); return update; } @@ -49,8 +49,8 @@ public async Task CheckStatusAsync(RmsNerisProfile profi public async Task DeliverAnalysisAsync(RmsNerisProfile profile, RmsSubmission submission, string nerisIncidentId, string existingNerisAnalysisId, CancellationToken cancellationToken = default) { if (submission == null) throw new ArgumentNullException(nameof(submission)); - if (string.IsNullOrWhiteSpace(submission.PayloadJson)) - return new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Fatal, Message = "The submission carries no payload." }; + var invalid = ValidateQueuedPayload(profile, submission, true); + if (invalid != null) return invalid; // The analysis files against an incident. Without the incident's id there is nothing to file against, // and that is a wait, not a failure: the incident's own submission is still in flight. @@ -61,8 +61,6 @@ public async Task DeliverAnalysisAsync(RmsNerisProfile p if (!string.IsNullOrWhiteSpace(existingNerisAnalysisId)) { var update = await _client.UpdateIncidentAnalysisAsync(profile, credential, existingNerisAnalysisId, submission.PayloadJson, cancellationToken); - if (update.Kind == NerisOutcomeKind.Fatal && update.StatusCode == 404) - return await _client.CreateIncidentAnalysisAsync(profile, credential, nerisIncidentId, submission.PayloadJson, cancellationToken); return update; } @@ -74,5 +72,27 @@ public async Task CheckAnalysisStatusAsync(RmsNerisProfi var credential = await _profiles.GetCredentialAsync(profile); return await _client.GetIncidentAnalysisStatusAsync(profile, credential, nerisAnalysisId, cancellationToken); } + + private static NerisSubmissionOutcome ValidateQueuedPayload(RmsNerisProfile profile, RmsSubmission submission, bool analysis) + { + var errors = new List(); + void Add(string path, string message, string code = "neris.local.condition") => errors.Add(new NerisSubmissionError { FieldPath = path, Message = message, Code = code }); + if (profile == null || profile.DepartmentId != submission.DepartmentId) + Add("/", "The submission profile does not belong to this department.", "neris.local.profile"); + if (string.IsNullOrWhiteSpace(submission.PayloadJson)) Add("/", "The queued submission contains no payload.", "neris.local.payload"); + else + { + var schemaIssues = NerisContractCatalog.Instance.Validate(analysis ? "IncidentAnalysisPayload" : "IncidentPayload", submission.PayloadJson, submission.DepartmentId, submission.RecordId); + foreach (var issue in schemaIssues) Add(issue.FieldPath, issue.Message, "neris.local." + issue.RuleKey); + if (schemaIssues.Count == 0) NerisPayloadRules.Validate(JObject.Parse(submission.PayloadJson), analysis, (path, message) => Add(path, message)); + } + if (errors.Count == 0) return null; + return new NerisSubmissionOutcome + { + Kind = NerisOutcomeKind.Rejected, LocalValidationFailure = true, + Message = "The queued payload failed local contract validation. Nothing was sent to NERIS; correct the report before submitting again.", + Errors = errors + }; + } } } diff --git a/Providers/Resgrid.Providers.Neris/NerisValidationService.cs b/Providers/Resgrid.Providers.Neris/NerisValidationService.cs index 9c6b0224f..c1da77589 100644 --- a/Providers/Resgrid.Providers.Neris/NerisValidationService.cs +++ b/Providers/Resgrid.Providers.Neris/NerisValidationService.cs @@ -101,9 +101,9 @@ void Add(string rule, RmsValidationSeverity severity, string path, string messag if (!report.CallAnsweredOn.HasValue) Add("neris.dispatch.call_answered", RmsValidationSeverity.Error, "dispatch.call_answered", "The call answered time is required."); if (!report.CallArrivalOn.HasValue) - Add("neris.dispatch.call_arrival", RmsValidationSeverity.Error, "dispatch.call_arrival", "The first arrival time is required."); - if (report.CallCreatedOn.HasValue && report.CallArrivalOn.HasValue && report.CallArrivalOn < report.CallCreatedOn) - Add("neris.dispatch.sequence", RmsValidationSeverity.Error, "dispatch.call_arrival", "First arrival cannot be before the call was created."); + Add("neris.dispatch.call_arrival", RmsValidationSeverity.Error, "dispatch.call_arrival", "The call's arrival time at the dispatch center is required."); + if (!InOrder(report.CallArrivalOn, report.CallAnsweredOn, report.CallCreatedOn)) + Add("neris.dispatch.sequence", RmsValidationSeverity.Error, "dispatch.call_arrival", "Call arrival at dispatch, call answered, and call creation must be in time order. Unit arrival is recorded separately."); if (report.CallCreatedOn.HasValue && report.IncidentClearedOn.HasValue && report.IncidentClearedOn < report.CallCreatedOn) Add("neris.dispatch.clear_sequence", RmsValidationSeverity.Error, "dispatch.incident_clear", "Incident clear cannot be before the call was created."); @@ -152,6 +152,13 @@ void Add(string rule, RmsValidationSeverity severity, string path, string messag ValidateSections(Add, snapshot, catalog); ValidateExposures(Add, snapshot, catalog); ValidateCasualties(Add, snapshot, catalog); + if (profile != null) + { + var payload = new NerisMappingService().BuildIncidentPayloadJson(snapshot, profile); + var contractIssues = NerisContractCatalog.Instance.Validate("IncidentPayload", payload, report.DepartmentId, report.RmsIncidentReportId); + issues.AddRange(contractIssues); + if (contractIssues.Count == 0) NerisPayloadRules.Validate(Newtonsoft.Json.Linq.JObject.Parse(payload), false, (path, message) => Add("neris.contract.condition", RmsValidationSeverity.Error, path, message)); + } return issues; } @@ -164,7 +171,9 @@ private static void ValidateSections(Action (RmsIncidentModuleKind)m.ModuleKind).Distinct().ToList(); - foreach (var requirement in NerisSectionRules.For(snapshot.Types.Select(t => t.TypeCode))) + var fdAids = snapshot.Aids.Where(a => !a.IsNonFireDepartment).ToList(); + var supportOnly = fdAids.Count > 0 && fdAids.All(a => a.AidType == "SUPPORT_AID" && a.Direction == "GIVEN"); + foreach (var requirement in NerisSectionRules.For(snapshot.Types.Select(t => t.TypeCode), supportOnly)) { if (present.Contains(requirement.Kind)) continue; @@ -332,7 +341,7 @@ void Add(string rule, RmsValidationSeverity severity, string path, string messag Add("neris.profile.entity", RmsValidationSeverity.Error, "base.department_neris_id", "The department has no NERIS entity ID configured."); if (string.IsNullOrWhiteSpace(snapshot.Report?.NerisIncidentId)) - Add("neris.analysis.incident", RmsValidationSeverity.Error, "base.incident_neris_id", "The incident must be filed with NERIS before its analysis can be."); + Add("neris.analysis.incident", RmsValidationSeverity.Error, "base.neris_id_incident", "The incident must be filed with NERIS before its analysis can be."); if (!string.IsNullOrWhiteSpace(analysis.GeneralCause) && !catalog.Contains("fire_cause_general", analysis.GeneralCause)) Add("neris.analysis.general_cause", RmsValidationSeverity.Error, "base.general_cause", $"'{analysis.GeneralCause}' is not a NERIS general fire cause."); @@ -388,6 +397,13 @@ void Add(string rule, RmsValidationSeverity severity, string path, string messag Add("neris.analysis.section.primary_code", RmsValidationSeverity.Error, descriptor.PayloadPath, $"'{module.PrimaryCode}' is not a NERIS {primarySet} value."); } + if (profile != null) + { + var payload = new NerisMappingService().BuildIncidentAnalysisPayloadJson(snapshot, profile); + var contractIssues = NerisContractCatalog.Instance.Validate("IncidentAnalysisPayload", payload, analysis.DepartmentId, analysis.RmsIncidentAnalysisId, string.IsNullOrWhiteSpace(snapshot.Report?.NerisIncidentId)); + issues.AddRange(contractIssues); + if (contractIssues.Count == 0) NerisPayloadRules.Validate(Newtonsoft.Json.Linq.JObject.Parse(payload), true, (path, message) => Add("neris.contract.condition", RmsValidationSeverity.Error, path, message)); + } return issues; } @@ -446,8 +462,10 @@ public static List ToIssues(NerisSubmissionOutcome outcome, { issues.Add(new RmsValidationIssue { - RmsValidationIssueId = Guid.NewGuid().ToString(), DepartmentId = departmentId, RecordId = recordId, RuleKey = "neris.destination." + (error.Code ?? "error"), - Severity = (int)RmsValidationSeverity.Error, FieldPath = error.FieldPath, Message = error.Message, Source = (int)RmsValidationSource.Destination, CreatedOn = now + RmsValidationIssueId = Guid.NewGuid().ToString(), DepartmentId = departmentId, RecordId = recordId, + RuleKey = outcome.LocalValidationFailure ? error.Code ?? "neris.local.error" : "neris.destination." + (error.Code ?? "error"), + Severity = (int)RmsValidationSeverity.Error, FieldPath = error.FieldPath, Message = error.Message, + Source = (int)(outcome.LocalValidationFailure ? RmsValidationSource.Local : RmsValidationSource.Destination), CreatedOn = now }); } } diff --git a/Providers/Resgrid.Providers.Neris/Resgrid.Providers.Neris.csproj b/Providers/Resgrid.Providers.Neris/Resgrid.Providers.Neris.csproj index d202d7a2e..eeeb4f5a8 100644 --- a/Providers/Resgrid.Providers.Neris/Resgrid.Providers.Neris.csproj +++ b/Providers/Resgrid.Providers.Neris/Resgrid.Providers.Neris.csproj @@ -9,13 +9,14 @@ - + + diff --git a/Providers/Resgrid.Providers.Pdf/NRecoProvider.cs b/Providers/Resgrid.Providers.Pdf/NRecoProvider.cs index 331a955ea..849867fe0 100644 --- a/Providers/Resgrid.Providers.Pdf/NRecoProvider.cs +++ b/Providers/Resgrid.Providers.Pdf/NRecoProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using NReco.PdfGenerator; using Resgrid.Config; using Resgrid.Framework; @@ -9,8 +9,21 @@ namespace Resgrid.Providers.PdfProvider public class NRecoProvider : IPdfProvider { public byte[] ConvertHtmlToPdf(string html) + => Convert(html, null); + + public byte[] ConvertHtmlToPdf(string html, string pageSize) + => Convert(html, pageSize); + + private byte[] Convert(string html, string pageSize) { var converter = new HtmlToPdfConverter(); + if (pageSize != null) + { + converter.Size = string.Equals(pageSize, "A4", StringComparison.OrdinalIgnoreCase) ? PageSize.A4 : PageSize.Letter; + // RMS generates escaped, self-contained HTML. Disable script execution and local-file access; the generated template contains no remote assets. + converter.CustomWkHtmlArgs = "--disable-javascript --disable-local-file-access --footer-right \"Page [page] of [topage]\" --footer-font-size 8"; + converter.ExecutionTimeout = TimeSpan.FromSeconds(60); + } if (OS.IsLinux() || OS.IsMacOS()) { diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionPolicyRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionPolicyRepository.cs index 183ecb685..5bbe9ce9e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionPolicyRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionPolicyRepository.cs @@ -12,7 +12,7 @@ namespace Resgrid.Repositories.DataRepository { - public class DepartmentDataProtectionPolicyRepository : RepositoryBase, IDepartmentDataProtectionPolicyRepository + public class DepartmentDataProtectionPolicyRepository : RmsRepositoryBase, IDepartmentDataProtectionPolicyRepository { private readonly IConnectionProvider _connectionProvider; private readonly IUnitOfWork _unitOfWork; @@ -31,6 +31,27 @@ public DepartmentDataProtectionPolicyRepository(IConnectionProvider connectionPr : $"{sqlConfiguration.SchemaName}.[DepartmentDataProtectionPolicies]"; } + public override Task InsertAsync(DepartmentDataProtectionPolicy entity, CancellationToken cancellationToken, bool firstLevelOnly = false) => + WriteWithAdmissionAsync(entity, true, cancellationToken, firstLevelOnly); + + public override Task UpdateAsync(DepartmentDataProtectionPolicy entity, CancellationToken cancellationToken, bool firstLevelOnly = false) => + WriteWithAdmissionAsync(entity, false, cancellationToken, firstLevelOnly); + + private async Task WriteWithAdmissionAsync(DepartmentDataProtectionPolicy entity, bool insert, CancellationToken ct, bool firstLevelOnly) + { + var owns = _unitOfWork.Transaction == null; _unitOfWork.CreateOrGetConnection(); + try + { + await LockRecordsDepartmentAsync(entity.DepartmentId, ct); + var previous = insert ? null : await GetByDepartmentIdAsync(entity.DepartmentId); + if (entity.State != (int)DepartmentDataProtectionState.Disabled && (previous == null || previous.State == (int)DepartmentDataProtectionState.Disabled)) + await GuardProtectionEnrollmentWithoutRmsAsync(entity.DepartmentId, ct); + var result = insert ? await base.InsertAsync(entity, ct, firstLevelOnly) : await base.UpdateAsync(entity, ct, firstLevelOnly); + if (owns) _unitOfWork.CommitChanges(); return result; + } + catch { if (owns) _unitOfWork.DiscardChanges(); throw; } + } + public Task GetByDepartmentIdAsync(int departmentId) { var sql = _isPostgres @@ -40,7 +61,7 @@ public Task GetByDepartmentIdAsync(int departmen sql, new { DepartmentId = departmentId }, _unitOfWork?.Transaction)); } - public Task TryTransitionStateAsync(int departmentId, DepartmentDataProtectionState expectedState, + public async Task TryTransitionStateAsync(int departmentId, DepartmentDataProtectionState expectedState, DepartmentDataProtectionState newState, int? activeMigrationKind, string updatedByUserId, CancellationToken cancellationToken) { @@ -48,7 +69,13 @@ public Task TryTransitionStateAsync(int departmentId, DepartmentDataProtect ? $"UPDATE {_table} SET state = @NewState, activemigrationkind = @ActiveMigrationKind, updatedon = @UtcNow, updatedbyuserid = @UpdatedByUserId WHERE departmentid = @DepartmentId AND state = @ExpectedState" : $"UPDATE {_table} SET [State] = @NewState, [ActiveMigrationKind] = @ActiveMigrationKind, [UpdatedOn] = @UtcNow, [UpdatedByUserId] = @UpdatedByUserId WHERE [DepartmentId] = @DepartmentId AND [State] = @ExpectedState"; - return WithConnectionAsync(connection => connection.ExecuteAsync(new Dapper.CommandDefinition(sql, new + var admission = expectedState == DepartmentDataProtectionState.Disabled && newState != DepartmentDataProtectionState.Disabled; + var owns = admission && _unitOfWork.Transaction == null; + if (admission) _unitOfWork.CreateOrGetConnection(); + try + { + if (admission) await GuardProtectionEnrollmentWithoutRmsAsync(departmentId, cancellationToken); + var changed = await WithConnectionAsync(connection => connection.ExecuteAsync(new Dapper.CommandDefinition(sql, new { DepartmentId = departmentId, ExpectedState = (int)expectedState, @@ -57,6 +84,9 @@ public Task TryTransitionStateAsync(int departmentId, DepartmentDataProtect UtcNow = DateTime.UtcNow, UpdatedByUserId = updatedByUserId }, _unitOfWork?.Transaction, cancellationToken: cancellationToken))); + if (owns) _unitOfWork.CommitChanges(); return changed; + } + catch { if (owns) _unitOfWork.DiscardChanges(); throw; } } public async Task IncrementPolicyEpochAsync(int departmentId, string updatedByUserId, CancellationToken cancellationToken) diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSettingsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSettingsRepository.cs index cc5d9b57f..8e812c140 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSettingsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSettingsRepository.cs @@ -13,8 +13,27 @@ namespace Resgrid.Repositories.DataRepository { - public class DepartmentSettingsRepository : RepositoryBase, IDepartmentSettingsRepository + public class DepartmentSettingsRepository : RmsRepositoryBase, IDepartmentSettingsRepository { + public async Task SaveRecordsRetentionPolicyAsync(int departmentId, RecordsRetentionPolicy policy, System.Threading.CancellationToken cancellationToken = default) + { + var owns = UnitOfWork.Transaction == null; + UnitOfWork.CreateOrGetConnection(); + try + { + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + var existing = await GetDepartmentSettingByIdTypeAsync(departmentId, DepartmentSettingTypes.RecordsRetentionPolicy); + // Corrupt history must stop policy replacement, never erase an older retention obligation. + var previous = string.IsNullOrEmpty(existing?.Setting) ? new RecordsRetentionPolicy() : ObjectSerialization.Deserialize(existing.Setting); + policy.PreserveHistory(previous, DateTime.UtcNow); + var row = existing ?? new DepartmentSetting { DepartmentId = departmentId, SettingType = (int)DepartmentSettingTypes.RecordsRetentionPolicy }; + row.Setting = ObjectSerialization.Serialize(policy); + var result = existing == null ? await InsertAsync(row, cancellationToken, true) : await UpdateAsync(row, cancellationToken, true); + if (owns) UnitOfWork.CommitChanges(); + return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index b514f3ce4..f21438e74 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -1,4 +1,4 @@ -using Autofac; +using Autofac; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; @@ -328,6 +328,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -343,6 +344,10 @@ protected override void Load(ContainerBuilder builder) // RMS-3 due state and legal holds (registry M0170) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index d1acc71bf..c3f6cef0b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -1,4 +1,4 @@ -using Autofac; +using Autofac; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; @@ -281,6 +281,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -296,6 +297,10 @@ protected override void Load(ContainerBuilder builder) // RMS-3 due state and legal holds (registry M0170) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsCommandReceiptsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsCommandReceiptsRepository.cs new file mode 100644 index 000000000..e1f29718f --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsCommandReceiptsRepository.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class RmsCommandReceiptsRepository : RmsRepositoryBase, IRmsCommandReceiptsRepository + { + public RmsCommandReceiptsRepository(IConnectionProvider connections, SqlConfiguration configuration, IUnitOfWork unitOfWork, IQueryFactory queries) + : base(connections, configuration, unitOfWork, queries) { } + + public Task GetAsync(int departmentId, string keyHash) => QueryFirstOrDefaultAsync( + $"SELECT {Cols("RecordId", "RequestChecksum")}, CASE WHEN {Col("CompletedOn")} IS NULL THEN 1 ELSE 0 END AS {Col("IsPending")} FROM {Tbl("RmsCommandReceipts")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("KeyHash")}={P}KeyHash", + new { DepartmentId = departmentId, KeyHash = keyHash }); + + public async Task ReserveAsync(int departmentId, string keyHash, string recordId, string requestChecksum, string reservationId) + { + if (UnitOfWork.Transaction != null) throw new InvalidOperationException("A command reservation must commit before executing the command."); + UnitOfWork.CreateOrGetConnection(); + try + { + await LockLiveContentParentAsync(departmentId, recordId, CancellationToken.None); + if (await GetAsync(departmentId, keyHash) != null) { UnitOfWork.CommitChanges(); return false; } + await ExecuteAsync($"INSERT INTO {Tbl("RmsCommandReceipts")} ({Cols("DepartmentId", "KeyHash", "RecordId", "RequestChecksum", "ReservationId", "CreatedOn")}) VALUES ({P}DepartmentId,{P}KeyHash,{P}RecordId,{P}RequestChecksum,{P}ReservationId,{UtcNowSql})", + new { DepartmentId = departmentId, KeyHash = keyHash, RecordId = recordId, RequestChecksum = requestChecksum, ReservationId = reservationId }); + UnitOfWork.CommitChanges(); return true; + } + catch { UnitOfWork.DiscardChanges(); throw; } + } + + public async Task CompleteAsync(int departmentId, string keyHash, string recordId, string requestChecksum, string reservationId) + { + if (UnitOfWork.Transaction != null) throw new InvalidOperationException("A command receipt cannot complete before the command transaction commits."); + return await ExecuteAsync($"UPDATE {Tbl("RmsCommandReceipts")} SET {Col("CompletedOn")}={UtcNowSql} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("KeyHash")}={P}KeyHash AND {Col("RecordId")}={P}RecordId AND {Col("RequestChecksum")}={P}RequestChecksum AND {Col("ReservationId")}={P}ReservationId AND {Col("CompletedOn")} IS NULL", + new { DepartmentId = departmentId, KeyHash = keyHash, RecordId = recordId, RequestChecksum = requestChecksum, ReservationId = reservationId }) == 1; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs index e2519d4a1..9027d0407 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsIncidentRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -57,7 +57,7 @@ public Task GetByNerisIncidentIdAsync(int departmentId, strin new { DepartmentId = departmentId, NerisId = nerisIncidentId }); } - public Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take) + public Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take, string afterId = null) { // Closed states only: an open filing is never a retention candidate, however old. var states = new[] { (int)RmsRecordState.Finalized, (int)RmsRecordState.Amended, (int)RmsRecordState.Accepted, (int)RmsRecordState.Voided, (int)RmsRecordState.Cancelled }; @@ -65,13 +65,15 @@ public Task> GetRetentionCandidatesAsync(int depa parameters.Add("DepartmentId", departmentId); parameters.Add("States", InListValue(states)); parameters.Add("Cutoff", cutoffUtc); + parameters.Add("AfterId", afterId); parameters.Add("Skip", 0); parameters.Add("Take", Math.Clamp(take, 1, 10000)); return QueryAsync( $@"SELECT * FROM {Tbl("RmsIncidentReports")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("State", "States")} AND {Col("FinalizedOn")} IS NOT NULL AND {Col("FinalizedOn")} < {P}Cutoff AND {Col("DeletedOn")} IS NULL - ORDER BY {Col("FinalizedOn")} {Paging()}", parameters); + AND {Col("PurgedOn")} IS NULL AND ({P}AfterId IS NULL OR {Col("RmsIncidentReportId")} > {P}AfterId) + ORDER BY {Col("RmsIncidentReportId")} {Paging()}", parameters); } /// @@ -84,7 +86,7 @@ public Task> GetRetentionCandidatesAsync(int depa const string R = "r"; string C(string column) => R + "." + Col(column); - var where = new StringBuilder($"{C("DepartmentId")} = {P}DepartmentId AND {C("DeletedOn")} IS NULL"); + var where = new StringBuilder($"{C("DepartmentId")} = {P}DepartmentId AND {C("DeletedOn")} IS NULL AND {C("PurgedOn")} IS NULL"); var parameters = new DynamicParameters(); parameters.Add("DepartmentId", departmentId); @@ -126,7 +128,7 @@ public Task> GetRetentionCandidatesAsync(int depa if (query.VisibleGroupIds.Count > 0) { - where.Append($" OR EXISTS (SELECT 1 FROM {Tbl("RmsRecordGroupScopes")} s WHERE s.{Col("DepartmentId")} = {C("DepartmentId")} AND s.{Col("RecordId")} = {C("RmsIncidentReportId")} AND {InList("DepartmentGroupId", "VisibleGroupIds", "s")})"); + where.Append($" OR EXISTS (SELECT 1 FROM {Tbl("RmsRecordGroupScopes")} s WHERE s.{Col("DepartmentId")} = {C("DepartmentId")} AND s.{Col("RecordId")} = {C("RmsIncidentReportId")} AND {InList("DepartmentGroupId", "VisibleGroupIds", "s")} AND {EffectiveGroupScope("s")})"); parameters.Add("VisibleGroupIds", InListValue(query.VisibleGroupIds)); } @@ -143,7 +145,7 @@ public Task> QueryAsync(int departmentId, RmsInci parameters.Add("Skip", Math.Max(0, query.Skip)); parameters.Add("Take", Math.Clamp(query.Take, 1, 10000)); return QueryAsync( - $"SELECT r.* FROM {Tbl("RmsIncidentReports")} r WHERE {where} ORDER BY r.{Col("CreatedOn")} DESC {Paging()}", parameters); + $"SELECT r.* FROM {Tbl("RmsIncidentReports")} r WHERE {where} ORDER BY r.{Col("CreatedOn")} DESC, r.{Col("RmsIncidentReportId")} {Paging()}", parameters); } public Task CountAsync(int departmentId, RmsIncidentReportQuery query) @@ -161,18 +163,22 @@ public Task> GetYearsAsync(int departmentId) public Task GetMaxRecordNumberSequenceAsync(int departmentId, string numberPrefix) { - // Numbers are "{prefix}-{sequence}"; the sequence is the trailing numeric segment. + // Numbers are "{prefix}-{sequence}"; the sequence is the trailing numeric segment. A row whose suffix + // is missing or non-numeric is ignored on both dialects: PostgreSQL's pattern simply does not match, + // and SQL Server needs the CASE plus TRY_CAST or it raises a conversion error instead. return ScalarAsync( IsPostgres ? $"SELECT COALESCE(MAX(CAST(SUBSTRING({Col("RecordNumber")} FROM '[0-9]+$') AS INTEGER)), 0) FROM {Tbl("RmsIncidentReports")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordNumber")} LIKE {P}Prefix" - : $"SELECT ISNULL(MAX(CAST(RIGHT({Col("RecordNumber")}, CHARINDEX('-', REVERSE({Col("RecordNumber")})) - 1) AS INT)), 0) FROM {Tbl("RmsIncidentReports")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordNumber")} LIKE {P}Prefix", + : $"SELECT ISNULL(MAX(CASE WHEN CHARINDEX('-', REVERSE({Col("RecordNumber")})) > 1 THEN TRY_CAST(RIGHT({Col("RecordNumber")}, CHARINDEX('-', REVERSE({Col("RecordNumber")})) - 1) AS INT) END), 0) FROM {Tbl("RmsIncidentReports")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordNumber")} LIKE {P}Prefix", new { DepartmentId = departmentId, Prefix = numberPrefix + "-%" }); } public async Task TryBumpRowVersionAsync(int departmentId, string reportId, long expectedRowVersion, CancellationToken cancellationToken = default) { + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + await PreserveCurrentHoldMembershipAsync(departmentId, reportId, false, cancellationToken); var affected = await ExecuteAsync( - $"UPDATE {Tbl("RmsIncidentReports")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentReportId")} = {P}ReportId AND {Col("RowVersion")} = {P}Expected", + $"UPDATE {Tbl("RmsIncidentReports")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentReportId")} = {P}ReportId AND {Col("RowVersion")} = {P}Expected AND {Col("PurgedOn")} IS NULL AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, ReportId = reportId, Expected = expectedRowVersion }, cancellationToken); return affected == 1; } @@ -283,6 +289,14 @@ public RmsIncidentVehiclesRepository(IConnectionProvider c, SqlConfiguration s, /// The separate incident-analysis filing (registry M0167): one per incident report, own lifecycle and submissions. public class RmsIncidentAnalysesRepository : RmsRepositoryBase, IRmsIncidentAnalysesRepository { + public async Task TryBumpRowVersionAsync(int departmentId, string analysisId, long expectedRowVersion, CancellationToken cancellationToken = default) + { + var analysis = await GetByIdForDepartmentAsync(departmentId, analysisId); + if (analysis == null || analysis.DeletedOn.HasValue) return false; + await LockLiveContentParentAsync(departmentId, analysis.IncidentReportId, cancellationToken); + return await ExecuteAsync($"UPDATE {Tbl("RmsIncidentAnalyses")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentAnalysisId")} = {P}Id AND {Col("RowVersion")} = {P}Expected AND {Col("DeletedOn")} IS NULL", + new { DepartmentId = departmentId, Id = analysisId, Expected = expectedRowVersion }, cancellationToken) == 1; + } public RmsIncidentAnalysesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } @@ -317,6 +331,10 @@ public Task CountByStateAsync(int departmentId, RmsIncidentAnalysisState st $"SELECT COUNT(1) FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("State")} = {P}State AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, State = (int)state }); } + + public Task CountVisibleByStateAsync(int departmentId, RmsIncidentAnalysisState state, List visibleGroupIds, string userId) => + ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsIncidentAnalyses")} a JOIN {Tbl("RmsIncidentReports")} r ON r.{Col("DepartmentId")} = a.{Col("DepartmentId")} AND r.{Col("RmsIncidentReportId")} = a.{Col("IncidentReportId")} WHERE a.{Col("DepartmentId")} = {P}DepartmentId AND a.{Col("State")} = {P}State AND a.{Col("DeletedOn")} IS NULL AND r.{Col("DeletedOn")} IS NULL AND r.{Col("PurgedOn")} IS NULL AND {VisibleRecord("r", "RmsIncidentReportId", visibleGroupIds, false)}", + new { DepartmentId = departmentId, State = (int)state, VisibleGroupIds = InListValue(visibleGroupIds), Viewer = userId }); } public class RmsValidationIssuesRepository : RmsRepositoryBase, IRmsValidationIssuesRepository @@ -344,6 +362,34 @@ await ExecuteAsync( public class RmsSubmissionsRepository : RmsRepositoryBase, IRmsSubmissionsRepository { + public async Task TryConfirmNotCreatedAsync(int departmentId, string submissionId, long expectedVersion, string destinationIdentity, DateTime now, CancellationToken cancellationToken = default) + { + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + return await ExecuteAsync($"UPDATE {Tbl("RmsSubmissions")} SET {Col("DestinationIdentity")} = {P}Destination, {Col("RequiresReconciliation")} = {P}False, {Col("CreatePendingReceipt")} = {P}False, {Col("State")} = {(int)RmsSubmissionState.Rejected}, {Col("NextAttemptOn")} = NULL, {Col("CompletedOn")} = {P}Now, {Col("LeaseOwner")} = NULL, {Col("LeaseExpiresOn")} = NULL, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsSubmissionId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("ExternalId")} IS NULL AND ({Col("DestinationIdentity")} IS NULL OR {Col("DestinationIdentity")} = {P}Destination) AND ({Col("LeaseExpiresOn")} IS NULL OR {Col("LeaseExpiresOn")} <= {P}Now) AND ({Col("RequiresReconciliation")} = {P}True OR {Col("CreatePendingReceipt")} = {P}True OR {Col("State")} IN ({(int)RmsSubmissionState.Failed}, {(int)RmsSubmissionState.Rejected}))", + new { DepartmentId = departmentId, Id = submissionId, Version = expectedVersion, Destination = destinationIdentity, False = false, True = true, Now = now }, cancellationToken) == 1; + } + + public async Task TryBindUnsentAsync(int departmentId, string submissionId, long expectedVersion, string destinationIdentity, DateTime now, CancellationToken cancellationToken = default) + { + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + return await ExecuteAsync($"UPDATE {Tbl("RmsSubmissions")} SET {Col("DestinationIdentity")} = {P}Destination, {Col("State")} = {(int)RmsSubmissionState.Queued}, {Col("NextAttemptOn")} = {P}Now, {Col("CompletedOn")} = NULL, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsSubmissionId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("DestinationIdentity")} IS NULL AND {Col("SentOn")} IS NULL AND {Col("Attempts")} = 0 AND {Col("ExternalId")} IS NULL AND {Col("RequiresReconciliation")} = {P}False AND {Col("CreatePendingReceipt")} = {P}False AND ({Col("LeaseExpiresOn")} IS NULL OR {Col("LeaseExpiresOn")} <= {P}Now)", + new { DepartmentId = departmentId, Id = submissionId, Version = expectedVersion, Destination = destinationIdentity, False = false, Now = now }, cancellationToken) == 1; + } + public async Task TryReconcileReceiptAsync(int departmentId, string submissionId, long expectedVersion, string externalId, string destinationIdentity, DateTime now, CancellationToken cancellationToken = default) + { + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + return await ExecuteAsync($"UPDATE {Tbl("RmsSubmissions")} SET {Col("ExternalId")} = {P}ExternalId, {Col("DestinationIdentity")} = {P}Destination, {Col("RequiresReconciliation")} = {P}False, {Col("CreatePendingReceipt")} = {P}False, {Col("State")} = {(int)RmsSubmissionState.AwaitingDestination}, {Col("NextAttemptOn")} = {P}Now, {Col("CompletedOn")} = NULL, {Col("LeaseOwner")} = NULL, {Col("LeaseExpiresOn")} = NULL, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsSubmissionId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND ({Col("LeaseExpiresOn")} IS NULL OR {Col("LeaseExpiresOn")} <= {P}Now) AND ({Col("RequiresReconciliation")} = {P}True OR {Col("CreatePendingReceipt")} = {P}True)", + new { DepartmentId = departmentId, Id = submissionId, Version = expectedVersion, ExternalId = externalId, Destination = destinationIdentity, False = false, True = true, Now = now }, cancellationToken) == 1; + } + public async Task TryFenceLeaseAsync(int departmentId, string submissionId, long expectedVersion, string leaseOwner, DateTime now, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(leaseOwner)) return false; + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + return await ExecuteAsync($"UPDATE {Tbl("RmsSubmissions")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsSubmissionId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("LeaseOwner")} = {P}Owner AND {Col("LeaseExpiresOn")} > {P}Now AND {InList("State", "States")}", + new { DepartmentId = departmentId, Id = submissionId, Version = expectedVersion, Owner = leaseOwner, Now = now, + States = InListValue(new[] { (int)RmsSubmissionState.Queued, (int)RmsSubmissionState.AwaitingDestination, (int)RmsSubmissionState.Failed }) }, cancellationToken) == 1; + } + public RmsSubmissionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } @@ -371,10 +417,11 @@ public Task GetByIdempotencyKeyAsync(string idempotencyKey) public async Task> ClaimDueBatchAsync(string leaseOwner, TimeSpan leaseDuration, int batchSize, DateTime utcNow, CancellationToken cancellationToken = default) { var claimable = new[] { (int)RmsSubmissionState.Queued, (int)RmsSubmissionState.AwaitingDestination }; + var recoverable = $"s.{Col("State")} = {(int)RmsSubmissionState.Failed} AND s.{Col("RequiresReconciliation")} = {P}True AND EXISTS (SELECT 1 FROM {Tbl("RmsSubmissionExchanges")} e WHERE e.{Col("DepartmentId")} = s.{Col("DepartmentId")} AND e.{Col("SubmissionId")} = s.{Col("RmsSubmissionId")} AND e.{Col("Stage")} = 'Response' AND NOT EXISTS (SELECT 1 FROM {Tbl("RmsSubmissionExchanges")} a WHERE a.{Col("DepartmentId")} = e.{Col("DepartmentId")} AND a.{Col("SubmissionId")} = e.{Col("SubmissionId")} AND a.{Col("ExchangeId")} = e.{Col("ExchangeId")} AND a.{Col("Stage")} = 'Applied'))"; var candidates = (await QueryAsync( - $"SELECT * FROM {Tbl("RmsSubmissions")} WHERE {InList("State", "States")} AND ({Col("NextAttemptOn")} IS NULL OR {Col("NextAttemptOn")} <= {P}Now) " + + $"SELECT s.* FROM {Tbl("RmsSubmissions")} s WHERE ({InList("State", "States")} OR ({recoverable})) AND ({Col("NextAttemptOn")} IS NULL OR {Col("NextAttemptOn")} <= {P}Now) " + $"AND ({Col("LeaseExpiresOn")} IS NULL OR {Col("LeaseExpiresOn")} < {P}Now) ORDER BY {Col("QueuedOn")} {Paging()}", - new { States = InListValue(claimable), Now = utcNow, Skip = 0, Take = Math.Clamp(batchSize, 1, 500) }, cancellationToken)).ToList(); + new { States = InListValue(claimable), True = true, Now = utcNow, Skip = 0, Take = Math.Clamp(batchSize, 1, 500) }, cancellationToken)).ToList(); var claimed = new List(); var leaseUntil = utcNow.Add(leaseDuration); @@ -406,7 +453,7 @@ public Task CountByStateAsync(int departmentId, int state) public Task SupersedeOpenForRecordAsync(int departmentId, string recordId, string exceptSubmissionId, DateTime utcNow, CancellationToken cancellationToken = default) { - var open = new[] { (int)RmsSubmissionState.Queued, (int)RmsSubmissionState.InFlight, (int)RmsSubmissionState.AwaitingDestination, (int)RmsSubmissionState.Rejected, (int)RmsSubmissionState.Failed }; + var open = new[] { (int)RmsSubmissionState.Queued, (int)RmsSubmissionState.InFlight, (int)RmsSubmissionState.AwaitingDestination }; return ExecuteAsync( $"UPDATE {Tbl("RmsSubmissions")} SET {Col("State")} = {P}Superseded, {Col("CompletedOn")} = {P}Now, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 " + $"WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {InList("State", "States")} AND {Col("RmsSubmissionId")} <> {P}Except", @@ -414,6 +461,15 @@ public Task SupersedeOpenForRecordAsync(int departmentId, string recordId, } } + public class RmsSubmissionExchangesRepository : RmsRepositoryBase, IRmsSubmissionExchangesRepository + { + public RmsSubmissionExchangesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + public Task> GetForSubmissionAsync(int departmentId, string submissionId) => + QueryAsync($"SELECT * FROM {Tbl("RmsSubmissionExchanges")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("SubmissionId")} = {P}SubmissionId ORDER BY {Col("OccurredOn")}, {Col("RmsSubmissionExchangeId")}", + new { DepartmentId = departmentId, SubmissionId = submissionId }); + } + public class RmsSignaturesRepository : RmsRepositoryBase, IRmsSignaturesRepository { public RmsSignaturesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index 1fa9a8c33..650778d01 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data.Common; using System.Linq; @@ -28,6 +28,74 @@ public abstract class RmsRepositoryBase : RepositoryBase where T : class, protected readonly SqlConfiguration SqlConfiguration; protected readonly IUnitOfWork UnitOfWork; + public override async Task InsertAsync(T entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + if (entity is RmsOperationalRecord || entity is RmsIncidentReport) return await WritePreservingHoldsAsync(entity, true, firstLevelOnly, cancellationToken); + var guarded = RmsRetentionRepository.ContentTables.Contains(entity.TableName) || entity.TableName == "RmsRevisions" || entity.TableName == "RmsIncidentAnalyses" || entity.TableName == "RmsSubmissions" || entity.TableName == "RmsSubmissionExchanges"; + if (!guarded) return await base.InsertAsync(entity, cancellationToken, firstLevelOnly); + var recordId = (string)entity.GetType().GetProperty(entity.TableName == "RmsIncidentAnalyses" ? "IncidentReportId" : "RecordId").GetValue(entity); + var departmentId = (int)entity.GetType().GetProperty("DepartmentId").GetValue(entity); + var owns = UnitOfWork.Transaction == null; + UnitOfWork.CreateOrGetConnection(); + try + { + await LockLiveContentParentAsync(departmentId, recordId, cancellationToken); + var result = await base.InsertAsync(entity, cancellationToken, firstLevelOnly); + if (owns) UnitOfWork.CommitChanges(); + return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } + + public override Task UpdateAsync(T entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + => entity is RmsOperationalRecord || entity is RmsIncidentReport ? WritePreservingHoldsAsync(entity, false, firstLevelOnly, cancellationToken) : base.UpdateAsync(entity, cancellationToken, firstLevelOnly); + + private async Task WritePreservingHoldsAsync(T entity, bool insert, bool firstLevelOnly, CancellationToken ct) + { + var op = entity as RmsOperationalRecord; var incident = entity as RmsIncidentReport; + var department = op?.DepartmentId ?? incident.DepartmentId; var id = op?.RmsOperationalRecordId ?? incident.RmsIncidentReportId; + var owns = UnitOfWork.Transaction == null; UnitOfWork.CreateOrGetConnection(); + try + { + await LockRecordsDepartmentAsync(department, ct); + if (!insert) await PreserveCurrentHoldMembershipAsync(department, id, op != null, ct); + var result = insert ? await base.InsertAsync(entity, ct, firstLevelOnly) : await base.UpdateAsync(entity, ct, firstLevelOnly); + await PreserveCurrentHoldMembershipAsync(department, id, op != null, ct); + if (owns) UnitOfWork.CommitChanges(); return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } + + protected async Task PreserveCurrentHoldMembershipAsync(int department, string recordId, bool operational, CancellationToken ct) + { + var table = operational ? "RmsOperationalRecords" : "RmsIncidentReports"; var id = operational ? "RmsOperationalRecordId" : "RmsIncidentReportId"; + var occurred = $"COALESCE(r.{Col(operational ? "StartedOn" : "CallCreatedOn")}, r.{Col("CreatedOn")})"; + var recordFilter = recordId == null ? "" : $" AND r.{Col(id)}={P}Id"; + await ExecuteAsync($@"INSERT INTO {Tbl("RmsRecordLegalHoldMembers")} ({Cols("DepartmentId", "HoldId", "RecordId", "MatchedOn")}) +SELECT h.{Col("DepartmentId")},h.{Col("RmsRecordLegalHoldId")},r.{Col(id)},{P}Now FROM {Tbl("RmsRecordLegalHolds")} h JOIN {Tbl(table)} r ON r.{Col("DepartmentId")}=h.{Col("DepartmentId")} +WHERE h.{Col("DepartmentId")}={P}DepartmentId {recordFilter} AND r.{Col("PurgedOn")} IS NULL AND r.{Col("DeletedOn")} IS NULL AND h.{Col("ReleasedOn")} IS NULL +AND (h.{Col("RecordId")}=r.{Col(id)} OR (h.{Col("RecordId")} IS NULL AND (h.{Col("DefinitionKey")} IS NULL OR h.{Col("DefinitionKey")}=r.{Col("DefinitionKey")}) AND (h.{Col("PeriodStart")} IS NULL OR {occurred}>=h.{Col("PeriodStart")}) AND (h.{Col("PeriodEnd")} IS NULL OR {occurred}<=h.{Col("PeriodEnd")}))) +AND NOT EXISTS (SELECT 1 FROM {Tbl("RmsRecordLegalHoldMembers")} m WHERE m.{Col("DepartmentId")}=h.{Col("DepartmentId")} AND m.{Col("HoldId")}=h.{Col("RmsRecordLegalHoldId")} AND m.{Col("RecordId")}=r.{Col(id)})", new { DepartmentId = department, Id = recordId, Now = DateTime.UtcNow }, ct); + } + + protected async Task LockLiveContentParentAsync(int departmentId, string recordId, CancellationToken cancellationToken) + { + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + var key = new { DepartmentId = departmentId, Id = recordId }; + foreach (var table in new[] { "RmsOperationalRecords", "RmsIncidentReports" }) + { + var id = table == "RmsOperationalRecords" ? "RmsOperationalRecordId" : "RmsIncidentReportId"; + if (await ExecuteAsync($"UPDATE {Tbl(table)} SET {Col("RowVersion")} = {Col("RowVersion")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col(id)} = {P}Id AND {Col("PurgedOn")} IS NULL AND {Col("DeletedOn")} IS NULL", key, cancellationToken) == 1) return; + } + var parent = await QueryFirstOrDefaultAsync($"SELECT {Cols("IncidentReportId")} FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentAnalysisId")} = {P}Id AND {Col("DeletedOn")} IS NULL", key, cancellationToken); + if (parent != null) + { + await LockLiveContentParentAsync(departmentId, parent.IncidentReportId, cancellationToken); + if (await ExecuteAsync($"UPDATE {Tbl("RmsIncidentAnalyses")} SET {Col("RowVersion")} = {Col("RowVersion")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentAnalysisId")} = {P}Id AND {Col("DeletedOn")} IS NULL", key, cancellationToken) == 1) return; + } + throw new InvalidOperationException("Content cannot be written to a missing or purged RMS record."); + } + protected RmsRepositoryBase(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { @@ -69,12 +137,8 @@ protected string InList(string column, string parameterName, string alias = null : $"{col} IN {P}{parameterName}"; } - /// Parameter value for an IN-list: an array on PostgreSQL, an enumerable on SQL Server. - protected static object InListValue(IEnumerable values) - { - var list = (values ?? Enumerable.Empty()).ToArray(); - return IsPostgres ? (object)list : list.ToList(); - } + /// Keep the declared parameter type enumerable so Dapper expands SQL Server lists; Npgsql binds the same array to ANY. + protected static int[] InListValue(IEnumerable values) => (values ?? Enumerable.Empty()).ToArray(); protected static string Concat(params string[] parts) { @@ -91,6 +155,59 @@ protected static string Paging() return IsPostgres ? "OFFSET @Skip LIMIT @Take" : "OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY"; } + /// Serializes retention, policy changes, and legal-hold writes in the current transaction. + protected async Task LockRecordsDepartmentAsync(int departmentId, CancellationToken cancellationToken) + { + if (UnitOfWork?.Transaction == null) throw new InvalidOperationException("A transaction is required for the RMS retention lock."); + var sql = IsPostgres ? $"SELECT {Col("DepartmentId")} FROM {Tbl("Departments")} WHERE {Col("DepartmentId")} = {P}Id FOR UPDATE" + : $"SELECT {Col("DepartmentId")} FROM {Tbl("Departments")} WITH (UPDLOCK,HOLDLOCK) WHERE {Col("DepartmentId")} = {P}Id"; + if (await ScalarAsync(sql, new { Id = departmentId }, cancellationToken) != departmentId) throw new InvalidOperationException("The department does not exist."); + } + + protected static string UtcNowSql => IsPostgres ? "(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')" : "SYSUTCDATETIME()"; + + protected async Task HasTableAsync(string table, CancellationToken ct) + { + var result = await ScalarAsync(IsPostgres ? "SELECT CASE WHEN to_regclass(@Name) IS NULL THEN 0 ELSE 1 END" + : "SELECT CASE WHEN OBJECT_ID(@Name, 'U') IS NOT NULL THEN 1 WHEN HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'VIEW DEFINITION')=1 THEN 0 ELSE -1 END", + new { Name = SqlConfiguration.SchemaName + "." + (IsPostgres ? table.ToLowerInvariant() : table) }, ct); + if (result < 0) throw new InvalidOperationException("The database principal cannot verify whether the protection admission schema is absent."); + return result == 1; + } + + /// Decision 18 admission barrier; a confirmed absent schema is different from a failed lookup. + protected async Task GuardUnprotectedRmsActivationAsync(int departmentId, CancellationToken ct) + { + await LockRecordsDepartmentAsync(departmentId, ct); + if (await HasTableAsync("DepartmentDataProtectionPolicies", ct) && await ScalarAsync( + $"SELECT COUNT(*) FROM {Tbl("DepartmentDataProtectionPolicies")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("State")}<>{(int)DepartmentDataProtectionState.Disabled}", new { DepartmentId = departmentId }, ct) > 0) + throw new InvalidOperationException("RMS activation requires completed protected RMS support before this department can use Advanced Data Protection."); + } + + protected async Task GuardProtectionEnrollmentWithoutRmsAsync(int departmentId, CancellationToken ct) + { + await LockRecordsDepartmentAsync(departmentId, ct); + var blocked = await HasTableAsync("RmsDepartmentCutovers", ct) && await ScalarAsync( + $"SELECT COUNT(*) FROM {Tbl("RmsDepartmentCutovers")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("State")}={(int)RmsDepartmentCutoverState.Active}", new { DepartmentId = departmentId }, ct) > 0; + foreach (var table in new[] { "RmsOperationalRecords", "RmsIncidentReports" }) + if (await HasTableAsync(table, ct) && await ScalarAsync($"SELECT COUNT(*) FROM {Tbl(table)} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("PurgedOn")} IS NULL", new { DepartmentId = departmentId }, ct) > 0) blocked = true; + if (blocked) throw new InvalidOperationException("Advanced Data Protection enrollment requires protected RMS support for departments with active Records or retained RMS content."); + } + + /// A materialized share anchor cannot outlive its live, tenant-bound grant. + protected string EffectiveGroupScope(string alias) => + $"({alias}.{Col("AnchorType")} <> {(int)RmsGroupScopeAnchorType.Share} OR EXISTS (SELECT 1 FROM {Tbl("RmsRecordShares")} liveShare WHERE liveShare.{Col("DepartmentId")} = {alias}.{Col("DepartmentId")} AND liveShare.{Col("RecordId")} = {alias}.{Col("RecordId")} AND liveShare.{Col("DepartmentGroupId")} = {alias}.{Col("DepartmentGroupId")} AND liveShare.{Col("RevokedOn")} IS NULL AND (liveShare.{Col("ExpiresOn")} IS NULL OR liveShare.{Col("ExpiresOn")} > {UtcNowSql})))"; + + /// Same live header, participant, and effective group exceptions as RecordsAuthorizationService. + protected string VisibleRecord(string alias, string idColumn, List groups, bool operational) + { + if (groups == null) return "1=1"; + var identity = $"{alias}.{Col("AuthorUserId")} = {P}Viewer OR {alias}.{Col("OwnerUserId")} = {P}Viewer OR {alias}.{Col("ReviewerUserId")} = {P}Viewer"; + if (operational) identity += $" OR {alias}.{Col("ApproverUserId")} = {P}Viewer OR EXISTS (SELECT 1 FROM {Tbl("RmsRecordParticipants")} v WHERE v.{Col("DepartmentId")} = {alias}.{Col("DepartmentId")} AND v.{Col("RecordId")} = {alias}.{Col(idColumn)} AND v.{Col("RevisionId")} IS NULL AND v.{Col("DeletedOn")} IS NULL AND v.{Col("UserId")} = {P}Viewer)"; + if (groups.Count > 0) identity += $" OR EXISTS (SELECT 1 FROM {Tbl("RmsRecordGroupScopes")} g WHERE g.{Col("DepartmentId")} = {alias}.{Col("DepartmentId")} AND g.{Col("RecordId")} = {alias}.{Col(idColumn)} AND {InList("DepartmentGroupId", "VisibleGroupIds", "g")} AND {EffectiveGroupScope("g")})"; + return "(" + identity + ")"; + } + protected async Task RunAsync(Func> work, CancellationToken cancellationToken = default) { try @@ -153,9 +270,10 @@ public Task GetByIdempotencyKeyAsync(int departmentId, str public Task> GetByCallAsync(int departmentId, int callId) { + var snapshotCall = IsPostgres ? $"CAST(v.{Col("SnapshotJson")} AS jsonb)->>'CallId'" : $"JSON_VALUE(v.{Col("SnapshotJson")}, '$.CallId')"; return QueryAsync( - $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("CallId")} = {P}CallId AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CreatedOn")} DESC", - new { DepartmentId = departmentId, CallId = callId }); + $"SELECT r.* FROM {Tbl("RmsOperationalRecords")} r LEFT JOIN {Tbl("RmsRevisions")} v ON v.{Col("RmsRevisionId")} = r.{Col("CurrentRevisionId")} AND v.{Col("DepartmentId")} = r.{Col("DepartmentId")} WHERE r.{Col("DepartmentId")} = {P}DepartmentId AND (r.{Col("CallId")} = {P}CallId OR {snapshotCall} = {P}CallText) AND r.{Col("DeletedOn")} IS NULL ORDER BY r.{Col("CreatedOn")} DESC", + new { DepartmentId = departmentId, CallId = callId, CallText = callId.ToString(System.Globalization.CultureInfo.InvariantCulture) }); } public Task> GetByDefinitionAndStartedRangeAsync(int departmentId, string definitionKey, IEnumerable states, DateTime start, DateTime end) @@ -166,8 +284,9 @@ public Task> GetByDefinitionAndStartedRangeAsy parameters.Add("States", InListValue(states)); parameters.Add("Start", start); parameters.Add("End", end); + var finalizedStart = IsPostgres ? $"CAST(CAST(v.{Col("SnapshotJson")} AS jsonb)->>'StartedOn' AS timestamp)" : $"TRY_CONVERT(datetime2, JSON_VALUE(v.{Col("SnapshotJson")}, '$.StartedOn'), 127)"; return QueryAsync( - $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("DefinitionKey")} = {P}DefinitionKey AND {InList("State", "States")} AND {Col("StartedOn")} >= {P}Start AND {Col("StartedOn")} <= {P}End AND {Col("DeletedOn")} IS NULL ORDER BY {Col("StartedOn")}", + $"SELECT r.* FROM {Tbl("RmsOperationalRecords")} r JOIN {Tbl("RmsRevisions")} v ON v.{Col("RmsRevisionId")} = r.{Col("CurrentRevisionId")} AND v.{Col("DepartmentId")} = r.{Col("DepartmentId")} WHERE r.{Col("DepartmentId")} = {P}DepartmentId AND r.{Col("DefinitionKey")} = {P}DefinitionKey AND {InList("State", "States")} AND {finalizedStart} >= {P}Start AND {finalizedStart} <= {P}End AND r.{Col("DeletedOn")} IS NULL AND r.{Col("PurgedOn")} IS NULL ORDER BY {finalizedStart}", parameters); } @@ -182,7 +301,7 @@ public Task> GetByDepartmentAndStatesAsync(int { var yearClause = year.HasValue ? $" AND {YearOf($"COALESCE({Col("StartedOn")}, {Col("CreatedOn")})")} = {P}Year" : string.Empty; return QueryAsync( - $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("State", "States")}{yearClause} AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CreatedOn")} DESC {Paging()}", + $"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("State", "States")}{yearClause} AND {Col("DeletedOn")} IS NULL ORDER BY {Col("CreatedOn")} DESC, {Col("RmsOperationalRecordId")} {Paging()}", new { DepartmentId = departmentId, States = InListValue(states), Year = year, Skip = skip, Take = take }); } @@ -193,6 +312,10 @@ public Task CountByDepartmentAsync(int departmentId, IEnumerable state new { DepartmentId = departmentId, States = InListValue(states) }); } + public Task CountVisibleAsync(int departmentId, IEnumerable states, List visibleGroupIds, string userId) => + ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsOperationalRecords")} r WHERE r.{Col("DepartmentId")} = {P}DepartmentId AND {InList("State", "States", "r")} AND r.{Col("DeletedOn")} IS NULL AND r.{Col("PurgedOn")} IS NULL AND {VisibleRecord("r", "RmsOperationalRecordId", visibleGroupIds, true)}", + new { DepartmentId = departmentId, States = InListValue(states), VisibleGroupIds = InListValue(visibleGroupIds), Viewer = userId }); + public Task> GetOpenAsync(int departmentId) { var open = new[] { (int)RmsRecordState.Draft, (int)RmsRecordState.ReadyForReview, (int)RmsRecordState.Returned, (int)RmsRecordState.Approved }; @@ -209,7 +332,7 @@ public Task> GetFinalizedSinceAsync(int depart new { DepartmentId = departmentId, States = InListValue(states), Since = sinceUtc }); } - public Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take) + public Task> GetRetentionCandidatesAsync(int departmentId, DateTime cutoffUtc, int take, string afterId = null) { // Closed states only: a Record still being authored or reviewed is never a retention candidate, however old. var states = new[] { (int)RmsRecordState.Finalized, (int)RmsRecordState.Amended, (int)RmsRecordState.Voided, (int)RmsRecordState.Cancelled }; @@ -217,8 +340,9 @@ public Task> GetRetentionCandidatesAsync(int d $@"SELECT * FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("State", "States")} AND {Col("FinalizedOn")} IS NOT NULL AND {Col("FinalizedOn")} < {P}Cutoff AND {Col("DeletedOn")} IS NULL - ORDER BY {Col("FinalizedOn")} {Paging()}", - new { DepartmentId = departmentId, States = InListValue(states), Cutoff = cutoffUtc, Skip = 0, Take = Math.Clamp(take, 1, 10000) }); + AND {Col("PurgedOn")} IS NULL AND ({P}AfterId IS NULL OR {Col("RmsOperationalRecordId")} > {P}AfterId) + ORDER BY {Col("RmsOperationalRecordId")} {Paging()}", + new { DepartmentId = departmentId, States = InListValue(states), Cutoff = cutoffUtc, AfterId = afterId, Skip = 0, Take = Math.Clamp(take, 1, 10000) }); } public Task CountAllAsync(int departmentId) @@ -273,8 +397,10 @@ public async Task GetMaxRecordNumberSequenceAsync(int departmentId, string public async Task TryBumpRowVersionAsync(int departmentId, string recordId, long expectedRowVersion, CancellationToken cancellationToken = default) { + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + await PreserveCurrentHoldMembershipAsync(departmentId, recordId, true, cancellationToken); var affected = await ExecuteAsync( - $"UPDATE {Tbl("RmsOperationalRecords")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1, {Col("ModifiedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOperationalRecordId")} = {P}RecordId AND {Col("RowVersion")} = {P}Expected", + $"UPDATE {Tbl("RmsOperationalRecords")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1, {Col("ModifiedOn")} = {P}Now WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOperationalRecordId")} = {P}RecordId AND {Col("RowVersion")} = {P}Expected AND {Col("PurgedOn")} IS NULL AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, RecordId = recordId, Expected = expectedRowVersion, Now = DateTime.UtcNow }, cancellationToken); return affected == 1; @@ -401,11 +527,22 @@ public Task DeleteDraftForRecordAsync(int departmentId, string recordId, Ca public class RmsRecordAttachmentsRepository : RmsRepositoryBase, IRmsRecordAttachmentsRepository { + public Task GetHistoricalByIdForDepartmentAsync(int departmentId, string attachmentId) => QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsRecordAttachments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordAttachmentId")} = {P}AttachmentId", + new { DepartmentId = departmentId, AttachmentId = attachmentId }); + public async Task ApplyScanResultAsync(int departmentId, string attachmentId, long expectedVersion, RmsAttachmentScanState state, DateTime now, CancellationToken cancellationToken = default) + { + if (state != RmsAttachmentScanState.Clean && state != RmsAttachmentScanState.Rejected) return false; + var rejected = state == RmsAttachmentScanState.Rejected; + var erase = rejected ? $", {Col("Data")} = NULL, {Col("StorageReference")} = NULL, {Col("DeletedOn")} = {P}Now" : string.Empty; + return await ExecuteAsync($"UPDATE {Tbl("RmsRecordAttachments")} SET {Col("ScanState")} = {P}State, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 {erase} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordAttachmentId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("DeletedOn")} IS NULL AND {Col("ScanState")} = {(int)RmsAttachmentScanState.Pending}", + new { DepartmentId = departmentId, Id = attachmentId, Version = expectedVersion, State = (int)state, Now = now }, cancellationToken) == 1; + } private static readonly string[] MetadataColumns = { "RmsRecordAttachmentId", "DepartmentId", "ProtectionId", "RecordId", "FileName", "ContentType", "ByteSize", "Checksum", "StorageReference", "Description", "UploadedByUserId", "UploadedOn", "ScanState", "MetadataStripped", "IsProtected", - "ProtectedCatalogVersion", "CreatedOn", "ModifiedOn", "RowVersion", "DeletedOn" + "ProtectedCatalogVersion", "Classification", "CreatedOn", "ModifiedOn", "RowVersion", "DeletedOn" }; public RmsRecordAttachmentsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) @@ -454,6 +591,24 @@ public class DomainEventOutboxRepository : RmsRepositoryBase InsertAsync(DomainEventOutboxEntry entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + if (entity?.ProducerSubsystem != DomainEventProducers.Records) return await base.InsertAsync(entity, cancellationToken, firstLevelOnly); + var owns = UnitOfWork.Transaction == null; + UnitOfWork.CreateOrGetConnection(); + try + { + await LockLiveContentParentAsync(entity.DepartmentId, entity.AggregateId, cancellationToken); + var result = await base.InsertAsync(entity, cancellationToken, firstLevelOnly); + if (owns) UnitOfWork.CommitChanges(); + return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } + + public override Task UpdateAsync(DomainEventOutboxEntry entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + => throw new InvalidOperationException("Outbox payloads are immutable; use the dedicated delivery-state operations."); + public Task GetNextSequenceAsync(int departmentId, string aggregateId) { return ScalarAsync( @@ -500,8 +655,8 @@ public async Task MarkFailedAsync(long domainEventOutboxId, string error, { var state = terminal ? (int)DomainEventOutboxState.Failed : (int)DomainEventOutboxState.Pending; var affected = await ExecuteAsync( - $"UPDATE {Tbl("DomainEventOutbox")} SET {Col("State")} = {P}State, {Col("NextAttemptOn")} = {P}Next, {Col("LastError")} = {P}Error, {Col("LeaseOwner")} = NULL, {Col("LeaseExpiresOn")} = NULL WHERE {Col("DomainEventOutboxId")} = {P}Id", - new { Id = domainEventOutboxId, State = state, Next = nextAttemptOn, Error = error }, cancellationToken); + $"UPDATE {Tbl("DomainEventOutbox")} SET {Col("State")} = {P}State, {Col("NextAttemptOn")} = {P}Next, {Col("LastError")} = {P}Error, {Col("LeaseOwner")} = NULL, {Col("LeaseExpiresOn")} = NULL WHERE {Col("DomainEventOutboxId")} = {P}Id AND {Col("DispatchedOn")} IS NULL AND {Col("State")}={P}Pending", + new { Id = domainEventOutboxId, State = state, Pending = (int)DomainEventOutboxState.Pending, Next = nextAttemptOn, Error = error }, cancellationToken); return affected == 1; } @@ -528,6 +683,22 @@ public class RmsDepartmentCutoversRepository : RmsRepositoryBase InsertAsync(RmsDepartmentCutover entity, CancellationToken ct, bool firstLevelOnly = false) => WriteAdmittedAsync(entity, true, ct, firstLevelOnly); + public override Task UpdateAsync(RmsDepartmentCutover entity, CancellationToken ct, bool firstLevelOnly = false) => WriteAdmittedAsync(entity, false, ct, firstLevelOnly); + + private async Task WriteAdmittedAsync(RmsDepartmentCutover entity, bool insert, CancellationToken ct, bool firstLevelOnly) + { + var owns = UnitOfWork.Transaction == null; UnitOfWork.CreateOrGetConnection(); + try + { + await LockRecordsDepartmentAsync(entity.DepartmentId, ct); + if (entity.IsActive) await GuardUnprotectedRmsActivationAsync(entity.DepartmentId, ct); + var result = insert ? await base.InsertAsync(entity, ct, firstLevelOnly) : await base.UpdateAsync(entity, ct, firstLevelOnly); + if (owns) UnitOfWork.CommitChanges(); return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } + public Task> GetActiveAsync() { return QueryAsync( @@ -558,6 +729,14 @@ public Task> GetForCutoverAsync(int depar public class RmsRevisionsRepository : RmsRepositoryBase, IRmsRevisionsRepository { + public async Task> GetByIdsForDepartmentAsync(int departmentId, IEnumerable revisionIds) + { + var rows = new List(); + foreach (var ids in (revisionIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().Chunk(1000)) + rows.AddRange(await QueryAsync($"SELECT * FROM {Tbl("RmsRevisions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RmsRevisionId", "Ids")}", new { DepartmentId = departmentId, Ids = ids })); + return rows; + } + public RmsRevisionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } @@ -581,6 +760,34 @@ public class RmsAccessAuditsRepository : RmsRepositoryBase, IRms public RmsAccessAuditsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + public override async Task InsertAsync(RmsAccessAudit entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + if (string.IsNullOrWhiteSpace(entity?.RecordId)) return await base.InsertAsync(entity, cancellationToken, firstLevelOnly); + var owns = UnitOfWork.Transaction == null; + UnitOfWork.CreateOrGetConnection(); + try + { + await LockRecordsDepartmentAsync(entity.DepartmentId, cancellationToken); + var key = new { entity.DepartmentId, Id = entity.RecordId }; + var live = await ScalarAsync($"SELECT COUNT(1) FROM (SELECT {Col("RmsOperationalRecordId")} AS id FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("RmsOperationalRecordId")}={P}Id AND {Col("PurgedOn")} IS NULL AND {Col("DeletedOn")} IS NULL UNION ALL SELECT {Col("RmsIncidentReportId")} AS id FROM {Tbl("RmsIncidentReports")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("RmsIncidentReportId")}={P}Id AND {Col("PurgedOn")} IS NULL AND {Col("DeletedOn")} IS NULL UNION ALL SELECT a.{Col("RmsIncidentAnalysisId")} AS id FROM {Tbl("RmsIncidentAnalyses")} a JOIN {Tbl("RmsIncidentReports")} r ON r.{Col("DepartmentId")}=a.{Col("DepartmentId")} AND r.{Col("RmsIncidentReportId")}=a.{Col("IncidentReportId")} WHERE a.{Col("DepartmentId")}={P}DepartmentId AND a.{Col("RmsIncidentAnalysisId")}={P}Id AND a.{Col("DeletedOn")} IS NULL AND r.{Col("PurgedOn")} IS NULL AND r.{Col("DeletedOn")} IS NULL) live", key, cancellationToken); + var stored = entity; + if (live == 0) + { + // A delayed audit cannot repopulate a purged record's free text after the purge inventory ran. + stored = new RmsAccessAudit { DepartmentId = entity.DepartmentId, RecordId = entity.RecordId, RevisionId = entity.RevisionId, + Action = entity.Action, ActorUserId = entity.ActorUserId, OriginClient = entity.OriginClient, Successful = entity.Successful, + OccurredOn = entity.OccurredOn, Purpose = "Audit for unavailable record" }; + } + var result = await base.InsertAsync(stored, cancellationToken, firstLevelOnly); + if (owns) UnitOfWork.CommitChanges(); + return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } + + public override Task UpdateAsync(RmsAccessAudit entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + => throw new InvalidOperationException("RMS access audits are append-only; retention uses its explicit erasure inventory."); + public Task> GetForRecordAsync(int departmentId, string recordId, int take) { return QueryAsync( @@ -648,7 +855,7 @@ public Task> GetModifiedSinceAsync(int de parameters.Add("SinceId", sinceId); } - parameters.Add("Since", since.Value); + parameters.Add("Since", since.Value, System.Data.DbType.DateTime2); } return QueryAsync( @@ -734,7 +941,7 @@ public Task> GetByIdsAsync(int department sb.Append(" AND (") .Append($"p.{Col("AuthorUserId")} = {P}Viewer OR p.{Col("OwnerUserId")} = {P}Viewer OR p.{Col("ReviewerUserId")} = {P}Viewer") .Append($" OR ({participantCsv}) LIKE {P}ViewerCsvPattern") - .Append($" OR EXISTS (SELECT 1 FROM {Tbl("RmsRecordGroupScopes")} s WHERE s.{Col("DepartmentId")} = p.{Col("DepartmentId")} AND s.{Col("RecordId")} = p.{Col("RmsRecordSearchProjectionId")} AND {InList("DepartmentGroupId", "VisibleGroupIds", "s")})") + .Append($" OR EXISTS (SELECT 1 FROM {Tbl("RmsRecordGroupScopes")} s WHERE s.{Col("DepartmentId")} = p.{Col("DepartmentId")} AND s.{Col("RecordId")} = p.{Col("RmsRecordSearchProjectionId")} AND {InList("DepartmentGroupId", "VisibleGroupIds", "s")} AND {EffectiveGroupScope("s")})") .Append(")"); parameters.Add("Viewer", viewer); parameters.Add("ViewerCsvPattern", "%," + viewer + ",%"); @@ -777,33 +984,37 @@ public async Task> CountRecordsByGroupAsync(int department var rows = await QueryAsync( $"SELECT s.{Col("DepartmentGroupId")} AS {Col("DepartmentGroupId")}, COUNT(DISTINCT s.{Col("RecordId")}) AS {Col("RecordCount")} " + $"FROM {Tbl("RmsRecordGroupScopes")} s INNER JOIN {Tbl("RmsOperationalRecords")} r ON r.{Col("RmsOperationalRecordId")} = s.{Col("RecordId")} AND r.{Col("DepartmentId")} = s.{Col("DepartmentId")} " + - $"WHERE s.{Col("DepartmentId")} = {P}DepartmentId AND r.{Col("DeletedOn")} IS NULL GROUP BY s.{Col("DepartmentGroupId")}", + $"WHERE s.{Col("DepartmentId")} = {P}DepartmentId AND r.{Col("DeletedOn")} IS NULL AND r.{Col("PurgedOn")} IS NULL AND {EffectiveGroupScope("s")} GROUP BY s.{Col("DepartmentGroupId")}", new { DepartmentId = departmentId }); return (rows ?? Enumerable.Empty()).ToDictionary(x => x.DepartmentGroupId, x => x.RecordCount); } - public Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds) + public async Task> GetForRecordsAsync(int departmentId, IEnumerable recordIds) { - var ids = (recordIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList(); - if (ids.Count == 0) - return Task.FromResult>(new List()); - - var parameters = new DynamicParameters(); - parameters.Add("DepartmentId", departmentId); - parameters.Add("Ids", IsPostgres ? (object)ids.ToArray() : ids); - return QueryAsync( - $"SELECT * FROM {Tbl("RmsRecordGroupScopes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("RecordId", "Ids")}", - parameters); + var rows = new List(); + foreach (var ids in (recordIds ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().Chunk(1000)) + rows.AddRange(await QueryAsync($"SELECT s.* FROM {Tbl("RmsRecordGroupScopes")} s WHERE s.{Col("DepartmentId")} = {P}DepartmentId AND {InList("RecordId", "Ids", "s")} AND {EffectiveGroupScope("s")}", new { DepartmentId = departmentId, Ids = ids })); + return rows; } public Task> GetForRecordAsync(int departmentId, string recordId) { return QueryAsync( - $"SELECT * FROM {Tbl("RmsRecordGroupScopes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId", + $"SELECT s.* FROM {Tbl("RmsRecordGroupScopes")} s WHERE s.{Col("DepartmentId")} = {P}DepartmentId AND s.{Col("RecordId")} = {P}RecordId AND {EffectiveGroupScope("s")}", new { DepartmentId = departmentId, RecordId = recordId }); } + public async Task> GetEffectiveSharesAsync(int departmentId, IEnumerable groupIds) + { + var shares = new List(); + foreach (var ids in (groupIds ?? Enumerable.Empty()).Distinct().Chunk(1000)) + shares.AddRange(await QueryAsync( + $"SELECT {Cols("RmsRecordShareId", "DepartmentId", "RecordId", "DepartmentGroupId", "ExpiresOn", "RowVersion")} FROM {Tbl("RmsRecordShares")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {InList("DepartmentGroupId", "Ids")} AND {Col("RevokedOn")} IS NULL AND ({Col("ExpiresOn")} IS NULL OR {Col("ExpiresOn")} > {UtcNowSql})", + new { DepartmentId = departmentId, Ids = ids })); + return shares; + } + public async Task ReplaceForRecordAsync(int departmentId, string recordId, IEnumerable scopes, CancellationToken cancellationToken = default) { await ExecuteAsync( @@ -841,6 +1052,12 @@ public Task> GetForRecordAsync(int departmentId, str /// public class RmsEvidenceArtifactsRepository : RmsRepositoryBase, IRmsEvidenceArtifactsRepository { + public Task> GetHistoryAsync(int departmentId, string recordId, int skip, int take) => QueryAsync( + $@"SELECT {string.Join(",", new[] { "RmsEvidenceArtifactId", "DepartmentId", "RecordId", "RecordKind", "RevisionId", "Title", "CaptureReason", "Checksum", "SourceVersion", "CapturedOn", "SourceItemCount", "Classification", "SupersededOn" }.Select(Col))} + FROM {Tbl("RmsEvidenceArtifacts")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}RecordId AND {Col("DeletedOn")} IS NULL + ORDER BY {Col("CapturedOn")} DESC, {Col("RmsEvidenceArtifactId")} DESC {Paging()}", + new { DepartmentId = departmentId, RecordId = recordId, Skip = Math.Max(0, skip), Take = Math.Clamp(take, 1, 200) }); + public RmsEvidenceArtifactsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } @@ -927,6 +1144,15 @@ public Task CountOverdueAsync(int departmentId) new { DepartmentId = departmentId, Overdue = (int)RmsDueState.Overdue }); } + public Task CountVisibleOverdueAsync(int departmentId, List visibleGroupIds, string userId) + { + var operational = $"EXISTS (SELECT 1 FROM {Tbl("RmsOperationalRecords")} r WHERE r.{Col("DepartmentId")} = d.{Col("DepartmentId")} AND r.{Col("RmsOperationalRecordId")} = d.{Col("RecordId")} AND r.{Col("DeletedOn")} IS NULL AND r.{Col("PurgedOn")} IS NULL AND {VisibleRecord("r", "RmsOperationalRecordId", visibleGroupIds, true)})"; + var incident = $"EXISTS (SELECT 1 FROM {Tbl("RmsIncidentReports")} r WHERE r.{Col("DepartmentId")} = d.{Col("DepartmentId")} AND r.{Col("RmsIncidentReportId")} = d.{Col("RecordId")} AND r.{Col("DeletedOn")} IS NULL AND r.{Col("PurgedOn")} IS NULL AND {VisibleRecord("r", "RmsIncidentReportId", visibleGroupIds, false)})"; + var analysis = $"EXISTS (SELECT 1 FROM {Tbl("RmsIncidentAnalyses")} a JOIN {Tbl("RmsIncidentReports")} r ON r.{Col("DepartmentId")} = a.{Col("DepartmentId")} AND r.{Col("RmsIncidentReportId")} = a.{Col("IncidentReportId")} WHERE a.{Col("DepartmentId")} = d.{Col("DepartmentId")} AND a.{Col("RmsIncidentAnalysisId")} = d.{Col("RecordId")} AND a.{Col("DeletedOn")} IS NULL AND r.{Col("DeletedOn")} IS NULL AND r.{Col("PurgedOn")} IS NULL AND {VisibleRecord("r", "RmsIncidentReportId", visibleGroupIds, false)})"; + return ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsRecordDueStates")} d WHERE d.{Col("DepartmentId")} = {P}DepartmentId AND d.{Col("LastEmittedState")} = {P}Overdue AND ({operational} OR {incident} OR {analysis})", + new { DepartmentId = departmentId, Overdue = (int)RmsDueState.Overdue, VisibleGroupIds = InListValue(visibleGroupIds), Viewer = userId }); + } + public Task ClearForRecordAsync(int departmentId, string recordId, DateTime utcNow, CancellationToken cancellationToken = default) { return ExecuteAsync( @@ -939,6 +1165,8 @@ public Task ClearForRecordAsync(int departmentId, string recordId, DateTime /// Public-records requests — registry M0171, RMS-3. public class RmsDisclosureRequestsRepository : RmsRepositoryBase, IRmsDisclosureRequestsRepository { + public async Task TryBumpRowVersionAsync(int departmentId, string requestId, long expectedVersion, CancellationToken cancellationToken = default) => + await ExecuteAsync($"UPDATE {Tbl("RmsDisclosureRequests")} SET {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsDisclosureRequestId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("DeletedOn")} IS NULL AND {Col("ClosedOn")} IS NULL", new { DepartmentId = departmentId, Id = requestId, Version = expectedVersion }, cancellationToken) == 1; public RmsDisclosureRequestsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } @@ -987,10 +1215,13 @@ public Task CountOverdueAsync(int departmentId, DateTime utcNow) public Task GetMaxRequestNumberSequenceAsync(int departmentId, string numberPrefix) { + // The allocated prefix ends in '-', so a row that is only the prefix, or that carries a non-numeric + // suffix, has no sequence. PostgreSQL's pattern skips it; SQL Server needs the CASE plus TRY_CAST or + // it raises a conversion error rather than skipping the row. return ScalarAsync( IsPostgres ? $"SELECT COALESCE(MAX(CAST(SUBSTRING({Col("RequestNumber")} FROM '[0-9]+$') AS INTEGER)), 0) FROM {Tbl("RmsDisclosureRequests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RequestNumber")} LIKE {P}Prefix" - : $"SELECT ISNULL(MAX(CAST(RIGHT({Col("RequestNumber")}, CHARINDEX('-', REVERSE({Col("RequestNumber")})) - 1) AS INT)), 0) FROM {Tbl("RmsDisclosureRequests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RequestNumber")} LIKE {P}Prefix", + : $"SELECT ISNULL(MAX(CASE WHEN CHARINDEX('-', REVERSE({Col("RequestNumber")})) > 1 THEN TRY_CAST(RIGHT({Col("RequestNumber")}, CHARINDEX('-', REVERSE({Col("RequestNumber")})) - 1) AS INT) END), 0) FROM {Tbl("RmsDisclosureRequests")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RequestNumber")} LIKE {P}Prefix", new { DepartmentId = departmentId, Prefix = numberPrefix + "%" }); } } @@ -998,6 +1229,26 @@ public Task GetMaxRequestNumberSequenceAsync(int departmentId, string numbe /// Immutable produced sets — registry M0171, RMS-3. public class RmsDisclosureProductionsRepository : RmsRepositoryBase, IRmsDisclosureProductionsRepository { + public async Task TryReleaseAsync(int departmentId, string productionId, long expectedVersion, string userId, DateTime releasedOn, string deliveryMethod, string deliveryReference, CancellationToken cancellationToken = default) => + await ExecuteAsync($"UPDATE {Tbl("RmsDisclosureProductions")} SET {Col("ReleasedByUserId")} = {P}UserId, {Col("ReleasedOn")} = {P}Now, {Col("DeliveryMethod")} = {P}Method, {Col("DeliveryReference")} = {P}Reference, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsDisclosureProductionId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("ReleasedOn")} IS NULL", new { DepartmentId = departmentId, Id = productionId, Version = expectedVersion, UserId = userId, Now = releasedOn, Method = deliveryMethod, Reference = deliveryReference }, cancellationToken) == 1; + + public override async Task InsertAsync(RmsDisclosureProduction entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + var owns = UnitOfWork.Transaction == null; + UnitOfWork.CreateOrGetConnection(); + try + { + await LockRecordsDepartmentAsync(entity.DepartmentId, cancellationToken); + var manifest = Newtonsoft.Json.Linq.JArray.Parse(entity.ProducedSetJson); + foreach (var id in manifest.Select(t => (string)t["record_id"]).Distinct().OrderBy(id => id, StringComparer.Ordinal)) + await LockLiveContentParentAsync(entity.DepartmentId, id, cancellationToken); + entity.ProductionNumber = await GetMaxProductionNumberAsync(entity.DepartmentId, entity.DisclosureRequestId) + 1; + var result = await base.InsertAsync(entity, cancellationToken, true); + if (owns) UnitOfWork.CommitChanges(); + return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } public RmsDisclosureProductionsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } @@ -1026,6 +1277,34 @@ public Task GetMaxProductionNumberAsync(int departmentId, string requestId) /// Legal holds that suspend retention — registry M0170, RMS-3. public class RmsRecordLegalHoldsRepository : RmsRepositoryBase, IRmsRecordLegalHoldsRepository { + public async Task TryReleaseAsync(int departmentId, string holdId, long expectedVersion, string userId, string reason, DateTime releasedOn, CancellationToken cancellationToken = default) + { + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + return await ExecuteAsync($"UPDATE {Tbl("RmsRecordLegalHolds")} SET {Col("ReleasedByUserId")} = {P}UserId, {Col("ReleasedOn")} = {P}Now, {Col("ReleaseNotes")} = {P}Reason, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsRecordLegalHoldId")} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("ReleasedOn")} IS NULL", new { DepartmentId = departmentId, Id = holdId, Version = expectedVersion, UserId = userId, Reason = reason, Now = releasedOn }, cancellationToken) == 1; + } + public override Task InsertAsync(RmsRecordLegalHold entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + => WriteHoldAsync(entity, true, cancellationToken); + public override Task UpdateAsync(RmsRecordLegalHold entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + => WriteHoldAsync(entity, false, cancellationToken); + private async Task WriteHoldAsync(RmsRecordLegalHold entity, bool insert, CancellationToken cancellationToken) + { + var owns = UnitOfWork.Transaction == null; + UnitOfWork.CreateOrGetConnection(); + try + { + await LockRecordsDepartmentAsync(entity.DepartmentId, cancellationToken); + if (insert && !string.IsNullOrEmpty(entity.RecordId)) await LockLiveContentParentAsync(entity.DepartmentId, entity.RecordId, cancellationToken); + var result = insert ? await base.InsertAsync(entity, cancellationToken, true) : await base.UpdateAsync(entity, cancellationToken, true); + if (insert) + { + await PreserveCurrentHoldMembershipAsync(entity.DepartmentId, null, true, cancellationToken); + await PreserveCurrentHoldMembershipAsync(entity.DepartmentId, null, false, cancellationToken); + } + if (owns) UnitOfWork.CommitChanges(); + return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } public RmsRecordLegalHoldsRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs new file mode 100644 index 000000000..291da2fa4 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRetentionRepository.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// One purge inventory for every revision, draft and copied artifact. Source systems retain their own records. + public class RmsRetentionRepository : RmsRepositoryBase, IRmsRetentionRepository + { + public RmsRetentionRepository(IConnectionProvider connections, SqlConfiguration configuration, IUnitOfWork unitOfWork, IQueryFactory queries) + : base(connections, configuration, unitOfWork, queries) { } + + public static readonly string[] ContentTables = + { + "RmsOperationalRecordDetails", "RmsRecordParticipants", "RmsRecordUnitResponses", "RmsRecordAttachments", + "RmsExternalReferences", "RmsSourceFacts", "RmsUnitResponses", "RmsIncidentTypes", "RmsActionTactics", "RmsAids", + "RmsLocations", "RmsNarratives", "RmsValidationIssues", "RmsCasualtyRescues", "RmsExposures", "RmsIncidentModules", + "RmsIncidentResources", "RmsIncidentProperties", "RmsIncidentVehicles", "RmsEvidenceArtifacts" + }; + + public async Task> GetPendingSearchErasuresAsync(int take, RmsSearchErasureTarget after = null, CancellationToken cancellationToken = default) + { + string Pending(string table, string id, RmsRecordKind kind) => + $"SELECT {Col("DepartmentId")}, {(int)kind} AS {Col("RecordKind")}, {Col(id)} AS {Col("RecordId")}, {Col("PurgedOn")} FROM {Tbl(table)} WHERE {Col("PurgedOn")} IS NOT NULL AND {Col("SearchErasedOn")} IS NULL"; + var cursor = after == null ? "" : $"WHERE ({Col("DepartmentId")}>{P}AfterDepartment OR ({Col("DepartmentId")}={P}AfterDepartment AND ({Col("RecordKind")}>{P}AfterKind OR ({Col("RecordKind")}={P}AfterKind AND {Col("RecordId")}>{P}AfterId))))"; + var rows = (await QueryAsync( + $"SELECT * FROM ({Pending("RmsOperationalRecords", "RmsOperationalRecordId", RmsRecordKind.Operational)} UNION ALL {Pending("RmsIncidentReports", "RmsIncidentReportId", RmsRecordKind.IncidentReport)}) pending {cursor} ORDER BY {Col("DepartmentId")}, {Col("RecordKind")}, {Col("RecordId")} {Paging()}", + new { Skip = 0, Take = Math.Max(1, Math.Min(1000, take)), AfterDepartment = after?.DepartmentId, AfterKind = after?.RecordKind, AfterId = after?.RecordId }, cancellationToken)).ToList(); + foreach (var target in rows) + { + target.SourceIds.Add(target.RecordId); + if (target.RecordKind == (int)RmsRecordKind.IncidentReport) + target.SourceIds.AddRange(await QueryAsync($"SELECT {Col("RmsIncidentAnalysisId")} FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("IncidentReportId")}={P}RecordId", new { target.DepartmentId, target.RecordId }, cancellationToken)); + } + return rows; + } + + public async Task CompleteSearchErasureAsync(RmsSearchErasureTarget target, DateTime completedOn, CancellationToken cancellationToken = default) + { + if (target == null || target.RecordKind != (int)RmsRecordKind.Operational && target.RecordKind != (int)RmsRecordKind.IncidentReport) throw new ArgumentException("A purged parent report is required."); + var operational = target.RecordKind == (int)RmsRecordKind.Operational; + var table = operational ? "RmsOperationalRecords" : "RmsIncidentReports"; + var id = operational ? "RmsOperationalRecordId" : "RmsIncidentReportId"; + // PurgedOn is datetime2 on SQL Server. Binding it as datetime rounds the + // database value on replay and can make an otherwise identical acknowledgement fail. + var parameters = new DynamicParameters(new { target.DepartmentId, target.RecordId }); + parameters.Add("PurgedOn", target.PurgedOn, DbType.DateTime2); + parameters.Add("CompletedOn", completedOn, DbType.DateTime2); + return await ExecuteAsync($"UPDATE {Tbl(table)} SET {Col("SearchErasedOn")}={P}CompletedOn WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col(id)}={P}RecordId AND {Col("PurgedOn")}={P}PurgedOn AND {Col("SearchErasedOn")} IS NULL", + parameters, cancellationToken) == 1; + } + + public async Task PurgeAsync(int departmentId, string recordId, RmsRecordKind kind, long expectedVersion, DateTime now, CancellationToken cancellationToken = default) + { + if (UnitOfWork.Transaction != null) throw new InvalidOperationException("Retention requires its own transaction."); + UnitOfWork.CreateOrGetConnection(); + try + { + await LockRecordsDepartmentAsync(departmentId, cancellationToken); + var result = await PurgeLockedAsync(departmentId, recordId, kind, expectedVersion, now, cancellationToken); + UnitOfWork.CommitChanges(); + return result; + } + catch { UnitOfWork.DiscardChanges(); throw; } + } + + private async Task PurgeLockedAsync(int departmentId, string recordId, RmsRecordKind kind, long expectedVersion, DateTime now, CancellationToken cancellationToken) + { + var operational = kind == RmsRecordKind.Operational; + if (!operational && kind != RmsRecordKind.IncidentReport) throw new ArgumentException("Retention is evaluated on the parent report."); + var table = operational ? "RmsOperationalRecords" : "RmsIncidentReports"; + var idColumn = operational ? "RmsOperationalRecordId" : "RmsIncidentReportId"; + var key = new { DepartmentId = departmentId, Id = recordId }; + // The write lock also serializes draft/amendment/finalization writers that use the aggregate CAS. + var locked = await ExecuteAsync($"UPDATE {Tbl(table)} SET {Col("RowVersion")} = {Col("RowVersion")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col(idColumn)} = {P}Id AND {Col("RowVersion")} = {P}Version AND {Col("PurgedOn")} IS NULL", + new { DepartmentId = departmentId, Id = recordId, Version = expectedVersion }, cancellationToken); + if (locked != 1) return new RmsPurgeResult { Reason = "The record changed or was already purged." }; + var op = operational ? await QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl(table)} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col(idColumn)} = {P}Id", key, cancellationToken) : null; + var incident = !operational ? await QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl(table)} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col(idColumn)} = {P}Id", key, cancellationToken) : null; + var definition = op?.DefinitionKey ?? incident.DefinitionKey; + var finalized = op?.FinalizedOn ?? incident?.FinalizedOn; + var started = operational ? op.StartedOn ?? op.CreatedOn : incident.CallCreatedOn ?? incident.CreatedOn; + var state = (RmsRecordState)(op?.State ?? incident.State); + // Resumable upload bytes have a fixed 24-hour lifetime. Allow their expiration/cleanup before erasure. + if ((op?.ModifiedOn ?? incident.ModifiedOn) > now.AddHours(-25)) return new RmsPurgeResult { Reason = "Recent record activity is still within the upload cleanup window." }; + if (!finalized.HasValue || (op?.AmendsRevisionId ?? incident?.AmendsRevisionId) != null || + !new[] { RmsRecordState.Finalized, RmsRecordState.Amended, RmsRecordState.Accepted, RmsRecordState.Voided, RmsRecordState.Cancelled }.Contains(state)) + return new RmsPurgeResult { Reason = "The report is still open." }; + var setting = await QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("DepartmentSettings")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("SettingType")} = {(int)DepartmentSettingTypes.RecordsRetentionPolicy}", key, cancellationToken); + var policy = string.IsNullOrEmpty(setting?.Setting) ? new RecordsRetentionPolicy() : ObjectSerialization.Deserialize(setting.Setting); + if (policy == null) throw new InvalidOperationException("Retention policy is unreadable."); + var years = policy.ResolveYears(definition, finalized.Value); + if (years <= 0 || years > 9999 - finalized.Value.Year || finalized.Value.AddYears(years) > now) return new RmsPurgeResult { Reason = "Retention has not expired." }; + var analyses = operational ? new List() : (await QueryAsync($"SELECT * FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IncidentReportId")} = {P}Id", key, cancellationToken)).ToList(); + var ids = new[] { recordId }.Concat(analyses.Select(a => a.RmsIncidentAnalysisId)).ToArray(); + foreach (var analysis in analyses.OrderBy(a => a.RmsIncidentAnalysisId, StringComparer.Ordinal)) + { + await ExecuteAsync($"UPDATE {Tbl("RmsIncidentAnalyses")} SET {Col("RowVersion")} = {Col("RowVersion")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentAnalysisId")} = {P}Id", new { DepartmentId = departmentId, Id = analysis.RmsIncidentAnalysisId }, cancellationToken); + var current = await QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentAnalysisId")} = {P}Id", new { DepartmentId = departmentId, Id = analysis.RmsIncidentAnalysisId }, cancellationToken); + if (current.DeletedOn.HasValue) continue; + var childYears = policy.ResolveYears(definition, current.FinalizedOn ?? current.CreatedOn); + if (!current.FinalizedOn.HasValue || childYears <= 0 || childYears > 9999 - current.FinalizedOn.Value.Year || current.FinalizedOn.Value.AddYears(childYears) > now) + return new RmsPurgeResult { Held = true, Reason = "The analysis is open or has an unexpired retention obligation." }; + } + foreach (var permanentClass in new[] { "RmsCasualtyRescues", "RmsExposures" }) + if (await ScalarAsync($"SELECT COUNT(1) FROM {Tbl(permanentClass)} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", key, cancellationToken) > 0) + return new RmsPurgeResult { Held = true, Reason = "Casualty, rescue and exposure content retains permanently with its report." }; + var holds = (await QueryAsync($"SELECT * FROM {Tbl("RmsRecordLegalHolds")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("ReleasedOn")} IS NULL", key, cancellationToken)).ToList(); + if (holds.Any(h => h.Covers(recordId, definition, started) || analyses.Any(a => h.Covers(a.RmsIncidentAnalysisId, definition, started)))) + return new RmsPurgeResult { Held = true, Reason = "An active legal hold covers the report or its analysis." }; + var periodHolds = holds.Where(h => h.RecordId == null && (h.DefinitionKey == null || h.DefinitionKey == definition)).ToList(); + foreach (var id in ids) + if (await ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsRecordLegalHoldMembers")} m JOIN {Tbl("RmsRecordLegalHolds")} h ON h.{Col("DepartmentId")}=m.{Col("DepartmentId")} AND h.{Col("RmsRecordLegalHoldId")}=m.{Col("HoldId")} WHERE m.{Col("DepartmentId")}={P}DepartmentId AND m.{Col("RecordId")}={P}Id AND h.{Col("ReleasedOn")} IS NULL", new { DepartmentId = departmentId, Id = id }, cancellationToken) > 0) + return new RmsPurgeResult { Held = true, Reason = "The record previously matched an active preservation hold; date changes cannot release it." }; + if (periodHolds.Count > 0) + foreach (var id in ids) + { + var history = (await QueryAsync($"SELECT * FROM {Tbl("RmsRevisions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", new { DepartmentId = departmentId, Id = id }, cancellationToken)).ToList(); + if (history.Count == 0) return new RmsPurgeResult { Held = true, Reason = "Historical dates cannot be verified against an active preservation hold." }; + foreach (var revision in history) + { + try + { + var checksum = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(revision.SnapshotJson ?? ""))).ToLowerInvariant(); + if (checksum != revision.Checksum) return new RmsPurgeResult { Held = true, Reason = "Historical revision integrity cannot be verified against an active hold." }; + var snapshot = JObject.Parse(revision.SnapshotJson); var header = snapshot["Report"] as JObject ?? snapshot; + var date = new[] { header["StartedOn"], header["CallCreatedOn"], header["CreatedOn"] }.FirstOrDefault(t => t != null && t.Type != JTokenType.Null); + if (date == null || periodHolds.Any(h => h.Covers(id, definition, date.ToObject()))) return new RmsPurgeResult { Held = true, Reason = "An active legal hold covers a historical revision or its date is unknown." }; + } + catch (Exception ex) when (ex is JsonException || ex is FormatException || ex is InvalidCastException || ex is ArgumentException) + { return new RmsPurgeResult { Held = true, Reason = "Historical dates are unreadable; preservation cannot be released by retention." }; } + } + } + // A produced copy is an independent retained public-records artifact. Never claim complete erasure while it exists. + var productions = await QueryAsync($"SELECT {Cols("ProducedSetJson")} FROM {Tbl("RmsDisclosureProductions")} WHERE {Col("DepartmentId")} = {P}DepartmentId", key, cancellationToken); + foreach (var production in productions) + { + var manifest = JToken.Parse(production.ProducedSetJson ?? "[]"); + if (manifest.SelectTokens("$..RecordId").Concat(manifest.SelectTokens("$..recordId")).Concat(manifest.SelectTokens("$..record_id")).Any(t => ids.Contains(t.Value()))) + return new RmsPurgeResult { Held = true, Reason = "An immutable disclosure production retains a copy of this report." }; + } + var attachments = 0; + foreach (var id in ids) + { + var parameters = new { DepartmentId = departmentId, Id = id, Now = now }; + if (await ScalarAsync($"SELECT COUNT(1) FROM {Tbl("DomainEventOutbox")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("AggregateId")} = {P}Id AND ({Col("DispatchedOn")} IS NULL OR {Col("LeaseExpiresOn")} > {P}Now)", parameters, cancellationToken) > 0 + || await ScalarAsync($"SELECT COUNT(1) FROM {Tbl("WorkflowRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("AggregateId")} = {P}Id AND ({Col("CompletedOn")} IS NULL OR {Col("Status")} NOT IN ({(int)WorkflowRunStatus.Completed},{(int)WorkflowRunStatus.Failed},{(int)WorkflowRunStatus.Skipped}))", parameters, cancellationToken) > 0) + return new RmsPurgeResult { Held = true, Reason = "An event delivery or workflow execution is still unresolved." }; + var evidence = await QueryAsync($"SELECT {Cols("RetentionYears", "CapturedOn", "StorageReference")} FROM {Tbl("RmsEvidenceArtifacts")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", parameters, cancellationToken); + if (evidence.Any(e => e.RetentionYears.HasValue && (e.RetentionYears <= 0 || e.RetentionYears > 9999 - e.CapturedOn.Year || e.CapturedOn.AddYears(e.RetentionYears.Value) > now))) + return new RmsPurgeResult { Held = true, Reason = "Supporting evidence has a longer retention obligation." }; + if (evidence.Any(e => !string.IsNullOrEmpty(e.StorageReference)) || await ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsRecordAttachments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id AND {Col("StorageReference")} IS NOT NULL", parameters, cancellationToken) > 0) + throw new InvalidOperationException("External RMS storage requires a registered deletion provider before purge can complete."); + if (await ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsSubmissions")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id AND ({Col("LeaseExpiresOn")} > {P}Now OR {Col("RequiresReconciliation")} = {P}True OR {Col("CreatePendingReceipt")} = {P}True)", new { DepartmentId = departmentId, Id = id, Now = now, True = true }, cancellationToken) > 0) + return new RmsPurgeResult { Held = true, Reason = "A destination delivery is still unresolved." }; + attachments += await ScalarAsync($"SELECT COUNT(1) FROM {Tbl("RmsRecordAttachments")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", parameters, cancellationToken); + } + // All guards have passed. No content mutation happens before the last child has been checked. + foreach (var id in ids) + { + var parameters = new { DepartmentId = departmentId, Id = id, Now = now, Snapshot = "{}", True = true }; + foreach (var contentTable in ContentTables) + await ExecuteAsync($"DELETE FROM {Tbl(contentTable)} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", parameters, cancellationToken); + await ExecuteAsync($"DELETE FROM {Tbl("UdfFieldValues")} WHERE {Col("EntityType")}={(int)UdfEntityType.Record} AND {Col("EntityId")}={P}Id AND {Col("UdfDefinitionId")} IN (SELECT {Col("UdfDefinitionId")} FROM {Tbl("UdfDefinitions")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("EntityType")}={(int)UdfEntityType.Record})", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("RmsSignatures")} SET {Col("SignerNameSnapshot")} = NULL, {Col("SignerRoleSnapshot")} = NULL, {Col("StatementText")} = NULL, {Col("IpAddress")} = NULL WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("RmsRecordShares")} SET {Col("Reason")} = NULL WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("DomainEventOutbox")} SET {Col("PayloadJson")} = {P}Snapshot, {Col("LastError")} = NULL WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("AggregateId")} = {P}Id", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("WorkflowRunLogs")} SET {Col("RenderedOutput")} = NULL, {Col("ActionResult")} = NULL, {Col("ErrorMessage")} = NULL WHERE {Col("WorkflowRunId")} IN (SELECT {Col("WorkflowRunId")} FROM {Tbl("WorkflowRuns")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("AggregateId")} = {P}Id)", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("WorkflowRuns")} SET {Col("InputPayload")} = {P}Snapshot, {Col("ErrorMessage")} = NULL WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("AggregateId")} = {P}Id", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("RmsRevisions")} SET {Col("SnapshotJson")} = {P}Snapshot, {Col("ReasonText")} = NULL WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("RmsSubmissions")} SET {Col("PayloadJson")} = {P}Snapshot, {Col("ResponseJson")} = NULL, {Col("ErrorSummary")} = NULL, {Col("LeaseOwner")} = NULL, {Col("LeaseExpiresOn")} = NULL, {Col("CompletedOn")} = {P}Now, {Col("State")} = {(int)RmsSubmissionState.Superseded}, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("RmsSubmissionExchanges")} SET {Col("OutcomeJson")} = NULL WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("RmsAccessAudits")} SET {Col("DetailJson")} = NULL, {Col("Purpose")} = 'Pre-purge audit event', {Col("IpAddress")} = NULL WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RecordId")} = {P}Id", parameters, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("RmsRecordSearchProjections")} SET {Col("DisplaySummary")} = '[purged]', {Col("SearchText")} = NULL, {Col("DeletedOn")} = {P}Now, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("SourceId")} = {P}Id", parameters, cancellationToken); + } + if (operational) + await ExecuteAsync($"UPDATE {Tbl(table)} SET {Col("DisplaySummary")} = '[purged]', {Col("ReturnReasonText")} = NULL, {Col("VoidReasonText")} = NULL, {Col("ExternalId")} = NULL, {Col("PurgedOn")} = {P}Now, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col(idColumn)} = {P}Id", new { DepartmentId = departmentId, Id = recordId, Now = now }, cancellationToken); + else + { + var fields = new[] { "ReturnReasonText", "VoidReasonText", "RejectionSummary", "DispatchCenterId", "DeterminantCode", "DispatchIncidentCode", "Disposition", "PeoplePresent", "DisplacementCount", "AnimalsRescued", "SpecialModifiersCsv" }; + await ExecuteAsync($"UPDATE {Tbl(table)} SET {Col("DisplaySummary")} = '[purged]', {string.Join(", ", fields.Select(f => Col(f) + " = NULL"))}, {Col("PurgedOn")} = {P}Now, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col(idColumn)} = {P}Id", new { DepartmentId = departmentId, Id = recordId, Now = now }, cancellationToken); + await ExecuteAsync($"UPDATE {Tbl("RmsIncidentAnalyses")} SET {Col("GeneralCause")} = NULL, {Col("InvestigationTypesCsv")} = NULL, {Col("EstimatedLossTotal")} = NULL, {Col("EstimatedValueTotal")} = NULL, {Col("RejectionSummary")} = NULL, {Col("VoidReasonText")} = NULL, {Col("DeletedOn")} = {P}Now, {Col("ModifiedOn")} = {P}Now, {Col("RowVersion")} = {Col("RowVersion")} + 1 WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IncidentReportId")} = {P}Id", new { DepartmentId = departmentId, Id = recordId, Now = now }, cancellationToken); + } + await ExecuteAsync($"INSERT INTO {Tbl("RmsAccessAudits")} ({Cols("DepartmentId", "RecordId", "Action", "Purpose", "OriginClient", "Successful", "OccurredOn", "DetailJson")}) VALUES ({P}DepartmentId, {P}Id, {P}Action, {P}Purpose, {P}Origin, {P}Successful, {P}Now, {P}Detail)", + new { DepartmentId = departmentId, Id = recordId, Action = (int)RmsAccessAuditAction.Change, Purpose = "Retention purge", Origin = (int)RmsOriginClient.System, + Successful = true, Now = now, Detail = JsonConvert.SerializeObject(new { definition, years, finalizedOn = finalized, childRecordIds = ids, attachmentsPurged = attachments }) }, cancellationToken); + return new RmsPurgeResult { Purged = true, SearchErasurePending = true, AttachmentsPurged = attachments, Reason = "RMS database content removed; committed search-storage erasure is pending." }; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsSearchWriteFence.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsSearchWriteFence.cs new file mode 100644 index 000000000..80cadb9dc --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsSearchWriteFence.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// A preloaded projection may outlive a purge. Its Lucene write must finish before retention can take the department lock. + public sealed class RmsSearchWriteFence : RmsRepositoryBase, IRmsSearchWriteFence + { + public RmsSearchWriteFence(IConnectionProvider connections, SqlConfiguration configuration, IUnitOfWork unit, IQueryFactory queries) + : base(connections, configuration, unit, queries) { } + + public async Task WithLiveSourceAsync(RecordsSearchDocumentSource source, Func write, CancellationToken cancellationToken = default) + { + if (source?.Projection == null || write == null) throw new ArgumentException("A search source and index writer are required."); + if (UnitOfWork.Transaction != null) throw new InvalidOperationException("Index writes require their own short retention transaction."); + var requested = source.Projection; + if (requested.DepartmentId <= 0 || string.IsNullOrWhiteSpace(requested.SourceId)) throw new ArgumentException("The search source has no department or record identity."); + UnitOfWork.CreateOrGetConnection(); + try + { + await LockRecordsDepartmentAsync(requested.DepartmentId, cancellationToken); + var current = await QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("RmsRecordSearchProjections")} WHERE {Col("DepartmentId")}={P}DepartmentId AND {Col("SourceType")}={P}SourceType AND {Col("SourceId")}={P}SourceId", + new { requested.DepartmentId, requested.SourceType, requested.SourceId }, cancellationToken); + RecordsSearchDocumentSource authorized; + if (current == null || current.DeletedOn.HasValue) + { + // Preserve only the deletion key. A preloaded narrative or summary never enters this callback. + authorized = new RecordsSearchDocumentSource { Projection = new RmsRecordSearchProjection + { DepartmentId = requested.DepartmentId, SourceType = requested.SourceType, SourceId = requested.SourceId, DeletedOn = DateTime.UtcNow } }; + } + else + { + if (current.SourceType == (int)RmsSearchSourceType.Record) + await LockLiveContentParentAsync(current.DepartmentId, current.SourceId, cancellationToken); + if (current.RmsRecordSearchProjectionId != requested.RmsRecordSearchProjectionId || current.RowVersion != requested.RowVersion || current.ModifiedOn != requested.ModifiedOn) + throw new InvalidOperationException("The search source changed after it was loaded; retry from the committed checkpoint."); + authorized = new RecordsSearchDocumentSource { Projection = current, Narrative = source.Narrative, Generation = source.Generation }; + } + cancellationToken.ThrowIfCancellationRequested(); + var count = write(authorized); + UnitOfWork.CommitChanges(); + return count; + } + catch { UnitOfWork.DiscardChanges(); throw; } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsUdfDefinitionsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsUdfDefinitionsRepository.cs new file mode 100644 index 000000000..9c29030d4 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsUdfDefinitionsRepository.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class RmsUdfDefinitionsRepository : RmsRepositoryBase, IRmsUdfDefinitionsRepository + { + public RmsUdfDefinitionsRepository(IConnectionProvider connections, SqlConfiguration configuration, IUnitOfWork unit, IQueryFactory queries) : base(connections, configuration, unit, queries) { } + private string Scope => $"{Col("DepartmentId")}={P}Department AND {Col("EntityType")}={(int)UdfEntityType.Record} AND {Col("RecordDefinitionKey")}={P}Key AND {Col("RecordDefinitionVersion")}={P}Version"; + public Task GetActiveAsync(int departmentId, string key, int version) => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("UdfDefinitions")} WHERE {Scope} AND {Col("IsActive")}={P}Active ORDER BY {Col("Version")} DESC", new { Department=departmentId, Key=key, Version=version, Active=true }); + public Task GetScopedAsync(int departmentId, string definitionId, string key, int version) => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("UdfDefinitions")} WHERE {Scope} AND {Col("UdfDefinitionId")}={P}Id", new { Department=departmentId, Key=key, Version=version, Id=definitionId }); + public async Task DeactivateAsync(int departmentId, string key, int version, CancellationToken ct) => await ExecuteAsync($"UPDATE {Tbl("UdfDefinitions")} SET {Col("IsActive")}={P}Active WHERE {Scope}", new { Department=departmentId, Key=key, Version=version, Active=false }, ct); + public Task LockDepartmentAsync(int departmentId, CancellationToken ct) => LockRecordsDepartmentAsync(departmentId, ct); + public Task GuardRecordAsync(int departmentId, string recordId, CancellationToken ct) => LockLiveContentParentAsync(departmentId, recordId, ct); + public async Task DeleteRecordValuesAsync(int departmentId, string recordId, CancellationToken ct) => await ExecuteAsync($"DELETE FROM {Tbl("UdfFieldValues")} WHERE {Col("EntityType")}={(int)UdfEntityType.Record} AND {Col("EntityId")}={P}Id AND {Col("UdfDefinitionId")} IN (SELECT {Col("UdfDefinitionId")} FROM {Tbl("UdfDefinitions")} WHERE {Col("DepartmentId")}={P}Department AND {Col("EntityType")}={(int)UdfEntityType.Record})", new {Department=departmentId,Id=recordId},ct); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Transactions/UnitOfWork.cs b/Repositories/Resgrid.Repositories.DataRepository/Transactions/UnitOfWork.cs index 4fcaf3a39..cb90573c3 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Transactions/UnitOfWork.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Transactions/UnitOfWork.cs @@ -21,23 +21,24 @@ public UnitOfWork(IConnectionProvider connProvider) public DbConnection Connection { get; private set; } - public void CommitChanges() => Transaction?.Commit(); + public void CommitChanges() => Complete(true); public DbConnection CreateOrGetConnection() { _semaphore.Wait(); - if (Connection == null) + try { - Connection = _connectionProvider.Create(); - Connection.Open(); - - Transaction = Connection.BeginTransaction(); + if (Connection == null) + { + Connection = _connectionProvider.Create(); + Connection.Open(); + Transaction = Connection.BeginTransaction(); + } + return Connection; } - - _semaphore.Release(); - - return Connection; + catch { Reset(); throw; } + finally { _semaphore.Release(); } } public async Task CreateOrGetConnectionAsync(CancellationToken cancellationToken = default(CancellationToken)) @@ -56,19 +57,41 @@ public DbConnection CreateOrGetConnection() return Connection; } + catch { Reset(); throw; } finally { _semaphore.Release(); } } - public void DiscardChanges() => Transaction?.Rollback(); + public void DiscardChanges() => Complete(false); + + private void Complete(bool commit) + { + _semaphore.Wait(); + try + { + try { if (commit) Transaction?.Commit(); else Transaction?.Rollback(); } + finally { Reset(); } + } + finally { _semaphore.Release(); } + } + + private void Reset() + { + var transaction = Transaction; + var connection = Connection; + Transaction = null; + Connection = null; + try { transaction?.Dispose(); } + finally { connection?.Dispose(); } + } public void Dispose() { - Transaction?.Dispose(); - Connection?.Close(); - Connection?.Dispose(); + _semaphore.Wait(); + try { Reset(); } + finally { _semaphore.Release(); } } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunLogRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunLogRepository.cs index 71f97a6b2..08f02bfd4 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunLogRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunLogRepository.cs @@ -13,8 +13,34 @@ namespace Resgrid.Repositories.DataRepository { - public class WorkflowRunLogRepository : RepositoryBase, IWorkflowRunLogRepository + public class WorkflowRunLogRepository : RmsRepositoryBase, IWorkflowRunLogRepository { + public override Task InsertAsync(WorkflowRunLog entity, System.Threading.CancellationToken cancellationToken, bool firstLevelOnly = false) + => WriteLogAsync(entity, true, cancellationToken, firstLevelOnly); + public override Task UpdateAsync(WorkflowRunLog entity, System.Threading.CancellationToken cancellationToken, bool firstLevelOnly = false) + => WriteLogAsync(entity, false, cancellationToken, firstLevelOnly); + + private async Task WriteLogAsync(WorkflowRunLog entity, bool insert, System.Threading.CancellationToken cancellationToken, bool firstLevelOnly) + { + var owns = UnitOfWork.Transaction == null; + UnitOfWork.CreateOrGetConnection(); + try + { + var run = await QueryFirstOrDefaultAsync($"SELECT {Cols("DepartmentId", "AggregateId")} FROM {Tbl("WorkflowRuns")} WHERE {Col("WorkflowRunId")} = {P}RunId", new { RunId = entity.WorkflowRunId }, cancellationToken); + if (run == null) throw new InvalidOperationException("The workflow run does not exist."); + if (!string.IsNullOrEmpty(run.AggregateId)) + { + var key = new { DepartmentId = run.DepartmentId, Id = run.AggregateId }; + var rms = await ScalarAsync($"SELECT (SELECT COUNT(1) FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOperationalRecordId")} = {P}Id) + (SELECT COUNT(1) FROM {Tbl("RmsIncidentReports")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentReportId")} = {P}Id) + (SELECT COUNT(1) FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentAnalysisId")} = {P}Id)", key, cancellationToken); + if (rms > 0) await LockLiveContentParentAsync(run.DepartmentId, run.AggregateId, cancellationToken); + } + var result = insert ? await base.InsertAsync(entity, cancellationToken, firstLevelOnly) : await base.UpdateAsync(entity, cancellationToken, firstLevelOnly); + if (owns) UnitOfWork.CommitChanges(); + return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } + private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; diff --git a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs index 11d759a9b..09eb4e7db 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/WorkflowRunRepository.cs @@ -13,8 +13,28 @@ namespace Resgrid.Repositories.DataRepository { - public class WorkflowRunRepository : RepositoryBase, IWorkflowRunRepository + public class WorkflowRunRepository : RmsRepositoryBase, IWorkflowRunRepository { + public override Task InsertAsync(WorkflowRun entity, System.Threading.CancellationToken cancellationToken, bool firstLevelOnly = false) + => WriteRunAsync(entity, true, cancellationToken, firstLevelOnly); + public override Task UpdateAsync(WorkflowRun entity, System.Threading.CancellationToken cancellationToken, bool firstLevelOnly = false) + => WriteRunAsync(entity, false, cancellationToken, firstLevelOnly); + private async Task WriteRunAsync(WorkflowRun entity, bool insert, System.Threading.CancellationToken cancellationToken, bool firstLevelOnly) + { + if (string.IsNullOrEmpty(entity.AggregateId)) return insert ? await base.InsertAsync(entity, cancellationToken, firstLevelOnly) : await base.UpdateAsync(entity, cancellationToken, firstLevelOnly); + var owns = UnitOfWork.Transaction == null; + UnitOfWork.CreateOrGetConnection(); + try + { + var key = new { DepartmentId = entity.DepartmentId, Id = entity.AggregateId }; + var rms = await ScalarAsync($"SELECT (SELECT COUNT(1) FROM {Tbl("RmsOperationalRecords")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsOperationalRecordId")} = {P}Id) + (SELECT COUNT(1) FROM {Tbl("RmsIncidentReports")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentReportId")} = {P}Id) + (SELECT COUNT(1) FROM {Tbl("RmsIncidentAnalyses")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsIncidentAnalysisId")} = {P}Id)", key, cancellationToken); + if (rms > 0) await LockLiveContentParentAsync(entity.DepartmentId, entity.AggregateId, cancellationToken); + var result = insert ? await base.InsertAsync(entity, cancellationToken, firstLevelOnly) : await base.UpdateAsync(entity, cancellationToken, firstLevelOnly); + if (owns) UnitOfWork.CommitChanges(); + return result; + } + catch { if (owns) UnitOfWork.DiscardChanges(); throw; } + } private readonly IConnectionProvider _connectionProvider; private readonly SqlConfiguration _sqlConfiguration; private readonly IQueryFactory _queryFactory; diff --git a/Tests/Resgrid.Tests/Bootstrapper.cs b/Tests/Resgrid.Tests/Bootstrapper.cs index afb49908d..ff3bcbf27 100644 --- a/Tests/Resgrid.Tests/Bootstrapper.cs +++ b/Tests/Resgrid.Tests/Bootstrapper.cs @@ -32,6 +32,7 @@ public static void Initialize() builder.RegisterModule(new TestingDataModule()); builder.RegisterModule(new NoSqlDataModule()); builder.RegisterModule(new ServicesModule()); + builder.RegisterModule(new Resgrid.Providers.PdfProvider.PdfProviderModule()); builder.RegisterModule(new Resgrid.Search.SearchModule()); builder.RegisterModule(new Resgrid.Providers.Scanning.ScanningProviderModule()); builder.RegisterModule(new Resgrid.Providers.Neris.NerisProviderModule()); diff --git a/Tests/Resgrid.Tests/Providers/NerisApiClientTests.cs b/Tests/Resgrid.Tests/Providers/NerisApiClientTests.cs index e27fad1c5..aa33dceb2 100644 --- a/Tests/Resgrid.Tests/Providers/NerisApiClientTests.cs +++ b/Tests/Resgrid.Tests/Providers/NerisApiClientTests.cs @@ -8,6 +8,7 @@ using FluentAssertions; using NUnit.Framework; using Resgrid.Model; +using Resgrid.Config; using Resgrid.Providers.Neris; namespace Resgrid.Tests.Providers @@ -64,6 +65,7 @@ public async Task Create_sends_the_payload_verbatim_with_a_bearer_token_and_read create.Authorization.Should().Be("Bearer tok-1"); create.Body.Should().Be("{\"base\":{}}"); create.ContentType.Should().StartWith("application/json"); + _handler.Requests.Should().OnlyContain(r => r.UserAgent.Contains("Resgrid-RMS/"), "NERIS requires User-Agent on token and data requests"); } [Test] @@ -77,6 +79,21 @@ public async Task The_token_is_reused_until_it_expires() _handler.Requests.Should().HaveCount(3, "one token call, two validate calls"); } + [TestCase(202, false)] + [TestCase(204, false)] + [TestCase(201, false)] + [TestCase(202, true)] + [TestCase(204, true)] + [TestCase(201, true)] + public async Task A_successful_create_without_receipt_ID_requires_reconciliation(int status, bool analysis) + { + var path = analysis ? "/v1/incident_analysis/incident-1" : "/v1/incident/FD24027000"; + _handler.Reply("POST", path, (HttpStatusCode)status, "{\"incident_status\":{\"status\":\"REJECTED\"},\"incident_analysis_status\":{\"status\":\"REJECTED\"}}"); + var result = analysis ? await _client.CreateIncidentAnalysisAsync(_profile, _credential, "incident-1", "{}") + : await _client.CreateIncidentAsync(_profile, _credential, "{}"); + result.DeliveryUncertain.Should().BeTrue(); + } + [Test] public async Task A_422_becomes_a_rejection_with_field_paths_and_no_payload_echo() { @@ -160,6 +177,51 @@ public async Task A_missing_profile_or_credential_is_fatal_before_any_call() _handler.Requests.Should().BeEmpty(); } + [TestCase("APPROVED", NerisOutcomeKind.Accepted)] + [TestCase("REJECTED", NerisOutcomeKind.Rejected)] + [TestCase("PENDING_APPROVAL", NerisOutcomeKind.Pending)] + public async Task Analysis_status_uses_the_query_identifier_and_analysis_status_block(string state, NerisOutcomeKind expected) + { + const string id = "IA|FD24027000|incident:42|1729023498"; + var path = "/v1/incident_analysis/FD24027000?neris_id_ia=" + Uri.EscapeDataString(id); + _handler.Reply("GET", path, HttpStatusCode.OK, "{\"incident_analysis_status\":{\"status\":\"" + state + "\"},\"incident_status\":{\"status\":\"FAILED\"}}"); + var outcome = await _client.GetIncidentAnalysisStatusAsync(_profile, _credential, id); + outcome.Kind.Should().Be(expected); + outcome.ExternalId.Should().Be(id); + outcome.ExternalStatus.Should().Be(state); + _handler.Requests[1].Path.Should().Be(path); + } + + [Test] + public async Task Analysis_create_and_update_use_their_distinct_contract_routes() + { + _handler.Reply("POST", "/v1/incident_analysis/incident-1", HttpStatusCode.Created, "{\"neris_id\":\"analysis-1\",\"incident_analysis_status\":{\"status\":\"SUBMITTED\"}}"); + _handler.Reply("PUT", "/v1/incident_analysis/FD24027000/analysis-1", HttpStatusCode.NoContent, ""); + var created = await _client.CreateIncidentAnalysisAsync(_profile, _credential, "incident-1", "{}"); + created.Kind.Should().Be(NerisOutcomeKind.Created); + created.ExternalId.Should().Be("analysis-1"); + (await _client.UpdateIncidentAnalysisAsync(_profile, _credential, created.ExternalId, "{}")).Kind.Should().Be(NerisOutcomeKind.Updated); + _handler.Requests.Should().OnlyContain(r => r.UserAgent.Contains("Resgrid-RMS/")); + } + + [TestCase(null)] + [TestCase("https://api.neris.fsri.org/v1")] + [TestCase("https://API.NERIS.FSRI.ORG:443/test/")] + [TestCase("http://neris.test/v1")] + [TestCase("https://user:password@neris.test/v1")] + public async Task Sandbox_misconfiguration_fails_before_credentials_or_payload_leave_the_process(string endpoint) + { + var previous = NerisConfig.SandboxBaseUrl; + try + { + NerisConfig.SandboxBaseUrl = ""; + _profile.BaseUrlOverride = endpoint; + (await _client.CreateIncidentAsync(_profile, _credential, "{}")).Kind.Should().Be(NerisOutcomeKind.Fatal); + _handler.Requests.Should().BeEmpty(); + } + finally { NerisConfig.SandboxBaseUrl = previous; } + } + public sealed class RecordedRequest { public string Method { get; set; } @@ -167,6 +229,7 @@ public sealed class RecordedRequest public string Body { get; set; } public string Authorization { get; set; } public string ContentType { get; set; } + public string UserAgent { get; set; } } /// Replies keyed by "METHOD path"; the token endpoint has its own script. @@ -184,13 +247,14 @@ protected override async Task SendAsync(HttpRequestMessage Requests.Add(new RecordedRequest { Method = request.Method.Method, - Path = request.RequestUri.AbsolutePath, + Path = request.RequestUri.PathAndQuery, Body = body, Authorization = request.Headers.Authorization?.ToString(), - ContentType = request.Content?.Headers.ContentType?.ToString() + ContentType = request.Content?.Headers.ContentType?.ToString(), + UserAgent = request.Headers.UserAgent.ToString() }); - if (!_replies.TryGetValue($"{request.Method.Method} {request.RequestUri.AbsolutePath}", out var reply)) + if (!_replies.TryGetValue($"{request.Method.Method} {request.RequestUri.PathAndQuery}", out var reply)) return new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("{\"detail\":\"not scripted\"}", Encoding.UTF8, "application/json") }; return new HttpResponseMessage(reply.status) { Content = new StringContent(reply.body ?? string.Empty, Encoding.UTF8, "application/json") }; diff --git a/Tests/Resgrid.Tests/Providers/NerisContractCatalogTests.cs b/Tests/Resgrid.Tests/Providers/NerisContractCatalogTests.cs new file mode 100644 index 000000000..90c3e8020 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/NerisContractCatalogTests.cs @@ -0,0 +1,31 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Providers.Neris; + +namespace Resgrid.Tests.Providers +{ + [TestFixture] + public class NerisContractCatalogTests + { + [Test] + public void Analysis_accepts_the_pinned_parent_identifier_and_rejects_the_previous_invented_fields() + { + const string valid = "{\"base\":{\"neris_id_incident\":\"FD24027000|INC-123|1788264000\",\"incident_number\":\"INC-123\"}}"; + NerisContractCatalog.Instance.Validate("IncidentAnalysisPayload", valid, 1, "test").Should().BeEmpty(); + const string invalid = "{\"base\":{\"incident_neris_id\":\"private-wrong-value\",\"general_cause\":\"ACCIDENTAL\"}}"; + var issues = NerisContractCatalog.Instance.Validate("IncidentAnalysisPayload", invalid, 1, "test"); + issues.Should().Contain(i => i.RuleKey == "neris.schema.required"); + issues.Should().Contain(i => i.RuleKey == "neris.schema.additionalProperties"); + string.Join(" ", issues.Select(i => i.Message)).Should().NotContain("private-wrong-value"); + } + + [Test] + public void Section_validation_resolves_nested_references_and_required_fields() + { + var issues = NerisContractCatalog.Instance.Validate("FirePayload", "{}", 1, "test"); + issues.Should().Contain(i => i.RuleKey == "neris.schema.required"); + NerisContractCatalog.Instance.GetSchema("FirePayload")["required"].Should().NotBeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Providers/NerisMappingTests.cs b/Tests/Resgrid.Tests/Providers/NerisMappingTests.cs index 4e49f0b19..bd011d6b7 100644 --- a/Tests/Resgrid.Tests/Providers/NerisMappingTests.cs +++ b/Tests/Resgrid.Tests/Providers/NerisMappingTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -29,11 +29,12 @@ public static NerisIncidentSnapshot Snapshot() var t0 = new DateTime(2026, 9, 3, 14, 0, 0, DateTimeKind.Utc); return new NerisIncidentSnapshot { + CustomFields = new RecordUdfSection {DefinitionId="department-only-form",ExtensionVersion=4,Fields=new List {new RecordUdfField {Field=new UdfField {Name="dept_extension",Label="Department-only label",FieldDataType=0},Value="department-only-answer"}}}, Report = new RmsIncidentReport { RmsIncidentReportId = "rep-1", DepartmentId = 4, CallId = 77, ReportingEntityId = "FD24027000", DefinitionKey = RmsDefinitionKeys.NerisIncidentReport, IncidentNumber = "2026-000123", DispatchIncidentCode = "STRUCT FIRE", DispatchCenterId = "PSAP-1", Disposition = "Extinguished", - CallCreatedOn = t0, CallAnsweredOn = t0.AddSeconds(-20), CallArrivalOn = t0.AddMinutes(6), IncidentClearedOn = t0.AddMinutes(90), + CallCreatedOn = t0, CallAnsweredOn = t0.AddSeconds(-20), CallArrivalOn = t0.AddSeconds(-30), IncidentClearedOn = t0.AddMinutes(90), PeoplePresent = true, DisplacementCount = 2, AnimalsRescued = 1, SpecialModifiersCsv = "MCI", State = (int)RmsRecordState.Finalized }, Location = new RmsLocation { Number = "100", Street = "Main St", Municipality = "Springfield", County = "Sangamon", State = "IL", PostalCode = "62701", Country = "US", PlaceType = "RESIDENCE", CrossStreet1 = "1st Ave", Latitude = 39.7817m, Longitude = -89.6501m, AddressText = "100 Main St, Springfield IL" }, @@ -151,6 +152,7 @@ public void The_payload_never_carries_nulls_supplemental_sections_or_the_narrati json.Should().NotContain("null"); json.Should().NotContain("dept_only", "supplemental department questions never enter the submission payload"); + json.Should().NotContain("department-only-").And.NotContain("CustomFields").And.NotContain("dept_extension"); json.Should().NotContain("Dumpster fire extinguished", "the narrative body is not a NERIS field on this contract"); json.Should().Contain("\"outcome_narrative\":\"Fire out, no extension.\""); } diff --git a/Tests/Resgrid.Tests/Providers/NerisOfficerWorkflowTests.cs b/Tests/Resgrid.Tests/Providers/NerisOfficerWorkflowTests.cs new file mode 100644 index 000000000..9c8f8e389 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/NerisOfficerWorkflowTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Providers.Neris; +using Resgrid.Web.Areas.User.Models.Records; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Providers +{ + [TestFixture] + public class NerisOfficerWorkflowTests + { + private static NerisValidationService Validator() => new NerisValidationService(Mock.Of(), Mock.Of()); + private static RmsIncidentModule Module(RmsIncidentModuleKind kind, string json) => new RmsIncidentModule { ModuleKind = (int)kind, DetailJson = json }; + private static NerisIncidentSnapshot Scenario(string scenario) + { + var snapshot = NerisMappingTests.Snapshot(); + if (scenario == "outside") return snapshot; + snapshot.Casualties.Clear(); snapshot.Exposures.Clear(); snapshot.Modules.Clear(); snapshot.Types.Clear(); + if (scenario == "cooking") + { + snapshot.Types.Add(new RmsIncidentType { TypeCode = "FIRE||STRUCTURE_FIRE||CONFINED_COOKING_APPLIANCE_FIRE", IsPrimary = true }); + // Deliberately out of parent order: the mapper must preserve the separately completed location section. + snapshot.Modules.Add(Module(RmsIncidentModuleKind.StructureFireLocation, "{\"type\":\"STRUCTURE\",\"floor_of_origin\":1,\"arrival_condition\":\"FIRE_OUT_UPON_ARRIVAL\",\"damage_type\":\"MINOR_DAMAGE\",\"room_of_origin_type\":\"KITCHEN\",\"cause\":\"COOKING\"}")); + snapshot.Modules.Add(Module(RmsIncidentModuleKind.Fire, "{\"water_supply\":\"HYDRANT_GREATER_500\",\"investigation_needed\":\"NO_CAUSE_OBVIOUS\",\"investigation_types\":[]}")); + foreach (var kind in new[] { RmsIncidentModuleKind.SmokeAlarm, RmsIncidentModuleKind.FireAlarm, RmsIncidentModuleKind.OtherAlarm, RmsIncidentModuleKind.FireSuppression, RmsIncidentModuleKind.CookingFireSuppression }) + snapshot.Modules.Add(Module(kind, "{\"presence\":{\"type\":\"NOT_PRESENT\"}}")); + } + if (scenario == "medical") + { + snapshot.Types.Add(new RmsIncidentType { TypeCode = "MEDICAL||ILLNESS||BREATHING_PROBLEMS", IsPrimary = true }); + snapshot.Modules.Add(Module(RmsIncidentModuleKind.Medical, "{\"patient_care_evaluation\":\"PATIENT_EVALUATED_CARE_PROVIDED\",\"transport_disposition\":\"TRANSPORT_BY_EMS_UNIT\",\"patient_care_report_id\":\"LOCAL-PCR-1\"}")); + } + if (scenario == "hazmat") + { + snapshot.Types.Add(new RmsIncidentType { TypeCode = "HAZSIT||HAZARDOUS_MATERIALS||GAS_LEAK_ODOR", IsPrimary = true }); + snapshot.Modules.Add(Module(RmsIncidentModuleKind.Chemical, "{\"name\":\"Natural gas\",\"release_occurred\":true,\"dot_class\":\"GASES\"}")); + snapshot.Modules.Add(Module(RmsIncidentModuleKind.Hazsit, "{\"evacuated\":2,\"disposition\":\"RELEASED_TO_PRIVATE_AGENCY\"}")); + } + return snapshot; + } + + [TestCase("outside")] + [TestCase("cooking")] + [TestCase("medical")] + [TestCase("hazmat")] + public void Representative_complete_payloads_pass_the_full_pinned_contract_and_cross_field_rules(string scenario) + { + var snapshot = Scenario(scenario); + var issues = Validator().ValidateLocal(snapshot, NerisMappingTests.Profile()); + issues.Where(i => i.Severity == (int)RmsValidationSeverity.Error).Should().BeEmpty(string.Join("; ", issues.Select(i => i.FieldPath + ": " + i.Message))); + var payload = new NerisMappingService().BuildIncidentPayloadJson(snapshot, NerisMappingTests.Profile()); + payload.Should().NotContain("dept_only").And.NotContain("SupplementalJson"); + if (scenario == "outside") JObject.Parse(payload)["casualty_rescues"][0].Value("birth_month_year").Should().Be("04/1990"); + } + + [Test] + public void Missing_conditional_field_has_a_field_specific_error_and_cannot_pass_as_a_complete_section() + { + var snapshot = Scenario("cooking"); + snapshot.Modules.Single(m => m.ModuleKind == (int)RmsIncidentModuleKind.StructureFireLocation).DetailJson = "{\"type\":\"STRUCTURE\"}"; + Validator().ValidateLocal(snapshot, NerisMappingTests.Profile()).Should().Contain(i => i.FieldPath == "/fire_detail/location_detail/floor_of_origin" && i.RuleKey == "neris.schema.required"); + } + + [Test] + public void A_present_but_incomplete_alarm_and_a_wrong_incident_section_fail_before_submission() + { + var snapshot = Scenario("cooking"); + snapshot.Modules.Single(m => m.ModuleKind == (int)RmsIncidentModuleKind.SmokeAlarm).DetailJson = "{\"presence\":{}}"; + Validator().ValidateLocal(snapshot, NerisMappingTests.Profile()).Should().Contain(i => i.RuleKey.StartsWith("neris.schema.")); + snapshot = Scenario("medical"); snapshot.Modules.Add(Module(RmsIncidentModuleKind.Hazsit, "{\"evacuated\":0,\"disposition\":\"RELEASED_TO_PRIVATE_AGENCY\"}")); + Validator().ValidateLocal(snapshot, NerisMappingTests.Profile()).Should().Contain(i => i.FieldPath == "/hazsit_detail"); + } + + [Test] + public void Unit_arrival_cannot_be_used_as_dispatch_call_arrival() + { + var snapshot = Scenario("outside"); snapshot.Report.CallArrivalOn = snapshot.Units[0].OnSceneOn; + Validator().ValidateLocal(snapshot, NerisMappingTests.Profile()).Should().Contain(i => i.RuleKey == "neris.dispatch.sequence"); + } + + [Test] + public void Guided_exposure_retains_address_attributes_and_guided_rescue_retains_removal_details() + { + const string exposureJson = "{\"damage_type\":\"MINOR_DAMAGE\",\"location_detail\":{\"type\":\"EXTERNAL_EXPOSURE\",\"item_type\":\"STRUCTURE\"},\"location\":{\"number\":102,\"street\":\"Main St\",\"country\":\"US\"}}"; + var input = IncidentGuidedFormMapper.Exposure(new IncidentExposureRow { DetailJson = exposureJson }); + var exposure = Newtonsoft.Json.JsonConvert.DeserializeObject(Newtonsoft.Json.JsonConvert.SerializeObject(input)); + NerisMappingService.MapExposure(exposure)["location"].Value("number").Should().Be(102); + const string casualtyJson = "{\"type\":\"NONFF\",\"birth_month_year\":\"04/1990\",\"rescue\":{\"ffrescue_or_nonffrescue\":{\"type\":\"RESCUED_BY_FIREFIGHTER\",\"removal_or_nonremoval\":{\"type\":\"REMOVAL_FROM_STRUCTURE\",\"room_type\":\"KITCHEN\"}}}}"; + var casualtyInput = IncidentGuidedFormMapper.Casualty(new IncidentCasualtyRow { DetailJson = casualtyJson }, null); + var casualty = Newtonsoft.Json.JsonConvert.DeserializeObject(Newtonsoft.Json.JsonConvert.SerializeObject(casualtyInput)); + var body = NerisMappingService.MapCasualtyRescue(casualty); + body["rescue"]["ffrescue_or_nonffrescue"]["removal_or_nonremoval"].Value("room_type").Should().Be("KITCHEN"); + body.Value("birth_month_year").Should().Be("04/1990"); + NerisContractCatalog.Instance.Validate("CasualtyRescuePayload", body.ToString(), 4, "test").Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Providers/NerisSubmissionValidationTests.cs b/Tests/Resgrid.Tests/Providers/NerisSubmissionValidationTests.cs new file mode 100644 index 000000000..954299db6 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/NerisSubmissionValidationTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Providers.Neris; + +namespace Resgrid.Tests.Providers +{ + [TestFixture] + public class NerisSubmissionValidationTests + { + [TestCase("rank", "NONFF", "FF")] + [TestCase("years_of_service", "NONFF", "FF")] + [TestCase("rescue.mayday", "NONFF", "FF")] + [TestCase("casualty.injury_or_noninjury.ff_injury_details", "NONFF", "FF")] + [TestCase("rescue.presence_known", "FF", "NONFF")] + public void Casualty_conditions_have_exact_paths_and_do_not_drop_zero_or_empty_objects(string field, string wrongType, string allowedType) + { + var person = new JObject { ["type"] = wrongType }; + var keys = field.Split('.'); var parent = person; + foreach (var key in keys.Take(keys.Length - 1)) { var next = new JObject(); parent[key] = next; parent = next; } + parent[keys.Last()] = field == "years_of_service" ? new JValue(0) : field == "rank" ? new JValue("Captain") : new JObject(); + var payload = new JObject { ["casualty_rescues"] = new JArray(person) }; + var paths = new List(); + NerisPayloadRules.Validate(payload, false, (path, message) => paths.Add(path)); + paths.Should().ContainSingle().Which.Should().Be("/casualty_rescues/0/" + field.Replace('.', '/')); + person["type"] = allowedType; paths.Clear(); + NerisPayloadRules.Validate(payload, false, (path, message) => paths.Add(path)); + paths.Should().BeEmpty(); + } + + [Test] + public void Mapped_civilian_with_firefighter_fields_is_rejected_by_the_officer_validator() + { + var snapshot = NerisMappingTests.Snapshot(); + snapshot.Casualties[0].PersonType = RmsCasualtyPersonTypes.Civilian; + snapshot.Casualties[0].Rank = "Captain"; + snapshot.Casualties[0].YearsOfService = 0; + var issues = new NerisValidationService(Mock.Of(), Mock.Of()).ValidateLocal(snapshot, NerisMappingTests.Profile()); + issues.Where(i => i.RuleKey == "neris.contract.condition").Select(i => i.FieldPath).Should().Contain(new[] + { + "/casualty_rescues/0/rank", "/casualty_rescues/0/years_of_service", "/casualty_rescues/0/casualty/injury_or_noninjury/ff_injury_details" + }); + } + + [TestCase(false, "schema")] + [TestCase(false, "condition")] + [TestCase(false, "malformed")] + [TestCase(false, "foreign-profile")] + [TestCase(true, "schema")] + [TestCase(true, "condition")] + [TestCase(true, "malformed")] + [TestCase(true, "foreign-profile")] + public async Task Invalid_queued_payload_is_refused_before_credentials_or_any_HTTP(bool analysis, string attack) + { + var profile = NerisMappingTests.Profile(); + var payload = ValidPayload(analysis); + if (attack == "schema") payload["base"] = new JObject(); + if (attack == "condition") + { + if (analysis) payload.Remove("properties"); + else { payload["casualty_rescues"][0]["type"] = "NONFF"; payload["casualty_rescues"][0]["rank"] = "Captain"; } + } + var submission = new RmsSubmission { DepartmentId = profile.DepartmentId, RecordId = "r", PayloadJson = attack == "malformed" ? "{broken" : payload.ToString(Formatting.None) }; + if (attack == "foreign-profile") profile.DepartmentId++; + var original = submission.PayloadJson; + var client = new Mock(MockBehavior.Strict); + var profiles = new Mock(MockBehavior.Strict); + var service = new NerisSubmissionService(client.Object, profiles.Object); + var outcome = analysis ? await service.DeliverAnalysisAsync(profile, submission, "FD24027000|INC-123|1788264000", null) + : await service.DeliverAsync(profile, submission, null); + outcome.Kind.Should().Be(NerisOutcomeKind.Rejected); + outcome.LocalValidationFailure.Should().BeTrue(); + outcome.DeliveryUncertain.Should().BeFalse(); + outcome.ResponseJson.Should().BeNull("there was no external response"); + outcome.StatusCode.Should().BeNull(); + outcome.Errors.Should().NotBeEmpty(); + NerisValidationService.ToIssues(outcome, submission.DepartmentId, submission.RecordId).Should().OnlyContain(issue => issue.Source == (int)RmsValidationSource.Local); + submission.PayloadJson.Should().Be(original); + client.VerifyNoOtherCalls(); profiles.VerifyNoOtherCalls(); + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task Complete_queued_payload_is_sent_byte_for_byte_once_to_the_selected_operation(bool analysis, bool update) + { + var profile = NerisMappingTests.Profile(); var credential = new NerisCredential(); + var submission = new RmsSubmission { DepartmentId = profile.DepartmentId, RecordId = "r", PayloadJson = ValidPayload(analysis).ToString(Formatting.None) }; + var client = new Mock(MockBehavior.Strict); + var profiles = new Mock(MockBehavior.Strict); + profiles.Setup(p => p.GetCredentialAsync(profile)).ReturnsAsync(credential); + var accepted = new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Accepted, ExternalId = "receipt" }; + const string parent = "FD24027000|INC-123|1788264000"; + if (analysis && update) client.Setup(c => c.UpdateIncidentAnalysisAsync(profile, credential, "receipt", submission.PayloadJson, It.IsAny())).ReturnsAsync(accepted); + if (analysis && !update) client.Setup(c => c.CreateIncidentAnalysisAsync(profile, credential, parent, submission.PayloadJson, It.IsAny())).ReturnsAsync(accepted); + if (!analysis && update) client.Setup(c => c.UpdateIncidentAsync(profile, credential, "receipt", submission.PayloadJson, It.IsAny())).ReturnsAsync(accepted); + if (!analysis && !update) client.Setup(c => c.CreateIncidentAsync(profile, credential, submission.PayloadJson, It.IsAny())).ReturnsAsync(accepted); + var service = new NerisSubmissionService(client.Object, profiles.Object); + var outcome = analysis ? await service.DeliverAnalysisAsync(profile, submission, parent, update ? "receipt" : null) + : await service.DeliverAsync(profile, submission, update ? "receipt" : null); + outcome.Should().BeSameAs(accepted); + client.Invocations.Should().ContainSingle(); profiles.Invocations.Should().ContainSingle(); + } + + private static JObject ValidPayload(bool analysis) => analysis + ? JObject.Parse("{\"base\":{\"neris_id_incident\":\"FD24027000|INC-123|1788264000\",\"incident_number\":\"INC-123\"},\"properties\":[{\"parcel_id\":\"P-12\"}]}") + : JObject.Parse(new NerisMappingService().BuildIncidentPayloadJson(NerisMappingTests.Snapshot(), NerisMappingTests.Profile())); + } +} diff --git a/Tests/Resgrid.Tests/Providers/NerisValidationTests.cs b/Tests/Resgrid.Tests/Providers/NerisValidationTests.cs index 8554b3063..477ea71ae 100644 --- a/Tests/Resgrid.Tests/Providers/NerisValidationTests.cs +++ b/Tests/Resgrid.Tests/Providers/NerisValidationTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentAssertions; @@ -28,7 +28,7 @@ public void A_complete_report_has_no_errors() { var issues = Service().ValidateLocal(NerisMappingTests.Snapshot(), NerisMappingTests.Profile()); - issues.Where(i => i.Severity == (int)RmsValidationSeverity.Error).Should().BeEmpty(string.Join("; ", issues.Select(i => i.RuleKey))); + issues.Where(i => i.Severity == (int)RmsValidationSeverity.Error).Should().BeEmpty(string.Join("; ", issues.Select(i => i.FieldPath + ":" + i.RuleKey))); } [Test] diff --git a/Tests/Resgrid.Tests/Rms/DisclosureContentPolicyTests.cs b/Tests/Resgrid.Tests/Rms/DisclosureContentPolicyTests.cs new file mode 100644 index 000000000..92d17088e --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/DisclosureContentPolicyTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + [TestFixture] + public class DisclosureContentPolicyTests + { + [Test] + public void Redaction_changes_only_the_selected_field_and_leaves_original_revision_intact() + { + var original = JObject.Parse("{\"Modules\":[{\"DetailJson\":\"{\\\"patient/name\\\":\\\"private name\\\",\\\"count\\\":2}\"}],\"Narrative\":\"Approved narrative\"}"); + var content = DisclosureContentPolicy.Prepare(original); var log = new List(); + DisclosureContentPolicy.Apply(content, "incident", new[] { new RmsDisclosureFieldDecision { Path = "/Modules/0/DetailJson/patient~1name", Withhold = true, Authority = "Fixture authority 1", Basis = "Fixture reviewed reason" } }, log); + content.ToString().Should().NotContain("private name").And.Contain("Approved narrative"); + content["Modules"][0]["DetailJson"]["count"].Value().Should().Be(2); + original.ToString().Should().Contain("private name"); + log.Should().ContainSingle(e => e.Authority == "Fixture authority 1" && e.Field == "/Modules/0/DetailJson/patient~1name"); + } + [TestCase("/Modules/*/DetailJson")] + [TestCase("$..name")] + [TestCase("/Missing")] + [TestCase("/Modules/00")] + [TestCase("/Modules/~2")] + public void Forged_wildcard_or_stale_paths_fail_before_any_redaction(string invalid) + { + var content = JObject.Parse("{\"Narrative\":\"preserve\",\"Modules\":[{}]}"); var before = content.ToString(); var log = new List(); + Action redact = () => DisclosureContentPolicy.Apply(content, "r", new[] { new RmsDisclosureFieldDecision { Path = "/Narrative", Withhold = true, Authority = "A", Basis = "B" }, new RmsDisclosureFieldDecision { Path = invalid, Withhold = true, Authority = "A", Basis = "B" } }, log); + redact.Should().Throw(); content.ToString().Should().Be(before); log.Should().BeEmpty(); + } + [Test] + public void Authority_and_case_specific_reason_are_required() + { + Action redact = () => DisclosureContentPolicy.Apply(JObject.Parse("{\"x\":1}"), "r", new[] { new RmsDisclosureFieldDecision { Path = "/x", Withhold = true, Basis = "Restricted" } }, new List()); + redact.Should().Throw(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs b/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs index eb7cd8b70..113120491 100644 --- a/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs +++ b/Tests/Resgrid.Tests/Rms/FakeIncidentStore.cs @@ -29,6 +29,7 @@ public sealed class FakeIncidentStore public List Narratives { get; } = new List(); public List Issues { get; } = new List(); public List Submissions { get; } = new List(); + public List Exchanges { get; } = new List(); public List Signatures { get; } = new List(); public List Modules { get; } = new List(); public List Resources { get; } = new List(); @@ -48,6 +49,7 @@ public sealed class FakeIncidentStore public Mock NarrativesRepo { get; } = new Mock(); public Mock IssuesRepo { get; } = new Mock(); public Mock SubmissionsRepo { get; } = new Mock(); + public Mock ExchangesRepo { get; } = new Mock(); public Mock SignaturesRepo { get; } = new Mock(); public Mock ModulesRepo { get; } = new Mock(); public Mock ResourcesRepo { get; } = new Mock(); @@ -133,6 +135,9 @@ public FakeIncidentStore() .Where(x => x.DepartmentId == d && x.State == (int)RmsIncidentAnalysisState.Finalized && x.NerisAnalysisId == null && x.DeletedOn == null).Take(take).ToList()); AnalysesRepo.Setup(r => r.CountByStateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((int d, RmsIncidentAnalysisState state) => Analyses.Count(x => x.DepartmentId == d && x.State == (int)state && x.DeletedOn == null)); + AnalysesRepo.Setup(r => r.CountVisibleByStateAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, RmsIncidentAnalysisState state, List groups, string user) => Analyses.Count(x => x.DepartmentId == d && x.State == (int)state && x.DeletedOn == null + && MatchReports(d, new RmsIncidentReportQuery { VisibleGroupIds = groups, ViewerUserId = user }).Any(r => r.RmsIncidentReportId == x.IncidentReportId))); // Validation issues: a run replaces every issue of its source IssuesRepo.Setup(r => r.GetForRecordAsync(It.IsAny(), It.IsAny())) @@ -153,6 +158,62 @@ public FakeIncidentStore() }); // Submissions + SubmissionsRepo.Setup(r => r.TryConfirmNotCreatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long version, string destination, DateTime now, CancellationToken c) => + { + var row = Submissions.FirstOrDefault(s => s.DepartmentId == d && s.RmsSubmissionId == id && s.RowVersion == version && s.ExternalId == null + && (s.LeaseExpiresOn == null || s.LeaseExpiresOn <= now) && (s.DestinationIdentity == null || s.DestinationIdentity == destination) + && (s.RequiresReconciliation || s.CreatePendingReceipt || s.State == (int)RmsSubmissionState.Failed || s.State == (int)RmsSubmissionState.Rejected)); + if (row == null) return false; + row.DestinationIdentity = destination; row.RequiresReconciliation = false; row.CreatePendingReceipt = false; + row.State = (int)RmsSubmissionState.Rejected; row.NextAttemptOn = null; row.LeaseOwner = null; row.LeaseExpiresOn = null; + row.CompletedOn = now; row.ModifiedOn = now; row.RowVersion++; + return true; + }); + SubmissionsRepo.Setup(r => r.TryBindUnsentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long version, string destination, DateTime now, CancellationToken c) => + { + var row = Submissions.FirstOrDefault(s => s.DepartmentId == d && s.RmsSubmissionId == id && s.RowVersion == version + && (s.LeaseExpiresOn == null || s.LeaseExpiresOn <= now) && s.DestinationIdentity == null && s.SentOn == null && s.Attempts == 0 + && s.ExternalId == null && !s.RequiresReconciliation && !s.CreatePendingReceipt); + if (row == null) return false; + row.DestinationIdentity = destination; row.State = (int)RmsSubmissionState.Queued; row.NextAttemptOn = now; + row.CompletedOn = null; row.ModifiedOn = now; row.RowVersion++; + return true; + }); + SubmissionsRepo.Setup(r => r.TryReconcileReceiptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long version, string externalId, string destination, DateTime now, CancellationToken c) => + { + var row = Submissions.FirstOrDefault(s => s.DepartmentId == d && s.RmsSubmissionId == id && s.RowVersion == version + && (s.LeaseExpiresOn == null || s.LeaseExpiresOn <= now) && (s.RequiresReconciliation || s.CreatePendingReceipt)); + if (row == null) return false; + row.ExternalId = externalId; row.DestinationIdentity = destination; row.RequiresReconciliation = false; row.CreatePendingReceipt = false; + row.State = (int)RmsSubmissionState.AwaitingDestination; row.NextAttemptOn = now; row.LeaseOwner = null; row.LeaseExpiresOn = null; + row.CompletedOn = null; row.ModifiedOn = now; row.RowVersion++; + return true; + }); + ExchangesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RmsSubmissionExchange e, CancellationToken c, bool f) => { Exchanges.Add(e); return e; }); + ExchangesRepo.Setup(r => r.GetForSubmissionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => Exchanges.Where(e => e.DepartmentId == d && e.SubmissionId == id).ToList()); + AnalysesRepo.Setup(r => r.TryBumpRowVersionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long expected, CancellationToken c) => + { + var row = Analyses.FirstOrDefault(x => x.DepartmentId == d && x.RmsIncidentAnalysisId == id && x.RowVersion == expected); + if (row == null) return false; + row.RowVersion = expected + 1; + return true; + }); + SubmissionsRepo.Setup(r => r.TryFenceLeaseAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long version, string owner, DateTime now, CancellationToken c) => + { + var row = Submissions.FirstOrDefault(x => x.DepartmentId == d && x.RmsSubmissionId == id && x.RowVersion == version + && !string.IsNullOrEmpty(owner) && x.LeaseOwner == owner && x.LeaseExpiresOn > now + && (x.State == (int)RmsSubmissionState.Queued || x.State == (int)RmsSubmissionState.AwaitingDestination || x.State == (int)RmsSubmissionState.Failed)); + if (row == null) return false; + row.RowVersion++; + return true; + }); SubmissionsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RmsSubmission e, CancellationToken c, bool f) => { Submissions.Add(e); return e; }); SubmissionsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -181,7 +242,9 @@ public FakeIncidentStore() .ReturnsAsync((string owner, TimeSpan lease, int batch, DateTime now, CancellationToken c) => { var due = Submissions - .Where(x => (x.State == (int)RmsSubmissionState.Queued || x.State == (int)RmsSubmissionState.AwaitingDestination) + .Where(x => (x.State == (int)RmsSubmissionState.Queued || x.State == (int)RmsSubmissionState.AwaitingDestination || + (x.State == (int)RmsSubmissionState.Failed && x.RequiresReconciliation && Exchanges.Any(e => e.SubmissionId == x.RmsSubmissionId && e.Stage == "Response" + && !Exchanges.Any(a => a.ExchangeId == e.ExchangeId && a.Stage == "Applied")))) && (x.NextAttemptOn == null || x.NextAttemptOn <= now) && (x.LeaseExpiresOn == null || x.LeaseExpiresOn < now)) .OrderBy(x => x.QueuedOn).Take(batch).ToList(); @@ -189,6 +252,7 @@ public FakeIncidentStore() { row.LeaseOwner = owner; row.LeaseExpiresOn = now.Add(lease); + row.RowVersion++; } return due; }); @@ -205,6 +269,8 @@ public FakeIncidentStore() private IEnumerable MatchReports(int departmentId, RmsIncidentReportQuery query) { var rows = Reports.Where(x => x.DepartmentId == departmentId && x.DeletedOn == null); + if (query?.VisibleGroupIds != null) rows = rows.Where(r => r.AuthorUserId == query.ViewerUserId || r.OwnerUserId == query.ViewerUserId || r.ReviewerUserId == query.ViewerUserId + || Scopes.Any(s => s.DepartmentId == departmentId && s.RecordId == r.RmsIncidentReportId && query.VisibleGroupIds.Contains(s.DepartmentGroupId))); if (query?.States != null && query.States.Count > 0) rows = rows.Where(x => query.States.Contains(x.State)); if (query?.CallId != null) diff --git a/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs b/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs index ef195c3f4..e984c0ce1 100644 --- a/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs +++ b/Tests/Resgrid.Tests/Rms/FakeRmsStore.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -111,6 +111,11 @@ public FakeRmsStore() var list = states?.ToList(); return Records.Count(x => x.DepartmentId == d && x.DeletedOn == null && (list == null || list.Count == 0 || list.Contains(x.State))); }); + RecordsRepo.Setup(r => r.CountVisibleAsync(It.IsAny(), It.IsAny>(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, IEnumerable states, List groups, string user) => Records.Count(r => r.DepartmentId == d && r.DeletedOn == null && r.PurgedOn == null + && states.Contains(r.State) && (groups == null || r.AuthorUserId == user || r.OwnerUserId == user || r.ReviewerUserId == user || r.ApproverUserId == user + || Participants.Any(p => p.DepartmentId == d && p.RecordId == r.RmsOperationalRecordId && p.RevisionId == null && p.UserId == user) + || Scopes.Any(s => s.DepartmentId == d && s.RecordId == r.RmsOperationalRecordId && groups.Contains(s.DepartmentGroupId))))); RecordsRepo.Setup(r => r.GetOpenAsync(It.IsAny())) .ReturnsAsync((int d) => Records.Where(x => x.DepartmentId == d && (x.State == 1 || x.State == 2 || x.State == 3 || x.State == 4)).ToList()); RecordsRepo.Setup(r => r.GetFinalizedSinceAsync(It.IsAny(), It.IsAny())) @@ -147,6 +152,8 @@ public FakeRmsStore() .ReturnsAsync((RmsRecordAttachment e, CancellationToken c, bool f) => e); AttachmentsRepo.Setup(r => r.GetMetadataForRecordAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((int d, string id) => Attachments.Where(x => x.DepartmentId == d && x.RecordId == id && x.DeletedOn == null).ToList()); + AttachmentsRepo.Setup(r => r.GetHistoricalByIdForDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id) => Attachments.FirstOrDefault(x => x.DepartmentId == d && x.RmsRecordAttachmentId == id)); AttachmentsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((int d, string id) => Attachments.FirstOrDefault(x => x.DepartmentId == d && x.RmsRecordAttachmentId == id)); @@ -234,14 +241,24 @@ public FakeRmsStore() .ReturnsAsync(() => Cutovers.Where(x => x.State == (int)RmsDepartmentCutoverState.Active).ToList()); // RMS-3 retention candidates and the Pending-attachment rescan queue - RecordsRepo.Setup(r => r.GetRetentionCandidatesAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((int d, DateTime cutoff, int take) => Records - .Where(x => x.DepartmentId == d && x.DeletedOn == null && x.FinalizedOn.HasValue && x.FinalizedOn < cutoff) - .OrderBy(x => x.FinalizedOn).Take(take).ToList()); + RecordsRepo.Setup(r => r.GetRetentionCandidatesAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, DateTime cutoff, int take, string after) => Records + .Where(x => x.DepartmentId == d && x.DeletedOn == null && x.PurgedOn == null && x.FinalizedOn.HasValue && x.FinalizedOn < cutoff + && (after == null || string.CompareOrdinal(x.RmsOperationalRecordId, after) > 0)) + .OrderBy(x => x.RmsOperationalRecordId, StringComparer.Ordinal).Take(take).ToList()); AttachmentsRepo.Setup(r => r.GetPendingScanAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((int d, int take) => Attachments .Where(x => x.DepartmentId == d && x.DeletedOn == null && x.ScanState == (int)RmsAttachmentScanState.Pending) .OrderBy(x => x.UploadedOn).Take(take).ToList()); + AttachmentsRepo.Setup(r => r.ApplyScanResultAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long version, RmsAttachmentScanState state, DateTime now, CancellationToken c) => + { + var row = Attachments.SingleOrDefault(a => a.DepartmentId == d && a.RmsRecordAttachmentId == id && a.RowVersion == version && a.DeletedOn == null && a.ScanState == (int)RmsAttachmentScanState.Pending); + if (row == null) return false; + row.ScanState = (int)state; row.ModifiedOn = now; row.RowVersion++; + if (state == RmsAttachmentScanState.Rejected) { row.Data = null; row.StorageReference = null; row.DeletedOn = now; } + return true; + }); // RMS-3 due states (M0170): one row per (record, obligation) is what the emit-once guarantee rests on. DueStatesRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -256,6 +273,10 @@ public FakeRmsStore() .ReturnsAsync((int d, int take) => DueStates.Where(x => x.DepartmentId == d && x.LastEmittedState != (int)RmsDueState.Cleared).Take(take).ToList()); DueStatesRepo.Setup(r => r.CountOverdueAsync(It.IsAny())) .ReturnsAsync((int d) => DueStates.Count(x => x.DepartmentId == d && x.LastEmittedState == (int)RmsDueState.Overdue)); + DueStatesRepo.Setup(r => r.CountVisibleOverdueAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, List groups, string user) => DueStates.Count(x => x.DepartmentId == d && x.LastEmittedState == (int)RmsDueState.Overdue + && Records.Any(r => r.DepartmentId == d && r.RmsOperationalRecordId == x.RecordId && r.DeletedOn == null && r.PurgedOn == null + && (groups == null || r.AuthorUserId == user || r.OwnerUserId == user || Scopes.Any(s => s.DepartmentId == d && s.RecordId == r.RmsOperationalRecordId && groups.Contains(s.DepartmentGroupId)))))); DueStatesRepo.Setup(r => r.ClearForRecordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((int d, string id, DateTime now, CancellationToken c) => { @@ -265,6 +286,8 @@ public FakeRmsStore() }); // RMS-3 legal holds (M0170) + LegalHoldsRepo.Setup(r => r.TryReleaseAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long version, string user, string reason, DateTime now, CancellationToken ct) => { var row = LegalHolds.SingleOrDefault(h => h.DepartmentId == d && h.RmsRecordLegalHoldId == id && h.RowVersion == version && h.ReleasedOn == null); if (row == null) return false; row.ReleasedByUserId = user; row.ReleasedOn = now; row.ReleaseNotes = reason; row.RowVersion++; return true; }); LegalHoldsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RmsRecordLegalHold e, CancellationToken c, bool f) => { LegalHolds.Add(e); return e; }); LegalHoldsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -310,6 +333,8 @@ public FakeRmsStore() .ReturnsAsync((RmsDisclosureRequest e, CancellationToken c, bool f) => { DisclosureRequests.RemoveAll(x => x.RmsDisclosureRequestId == e.RmsDisclosureRequestId); DisclosureRequests.Add(e); return e; }); DisclosureRequestsRepo.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), It.IsAny())) .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) => { @@ -328,6 +353,8 @@ public FakeRmsStore() .Select(x => int.TryParse(x.RequestNumber.Substring(prefix.Length), out var n) ? n : 0) .DefaultIfEmpty(0).Max()); + DisclosureProductionsRepo.Setup(r => r.TryReleaseAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string id, long version, string user, DateTime now, string method, string reference, CancellationToken ct) => { var row = DisclosureProductions.SingleOrDefault(x => x.DepartmentId == d && x.RmsDisclosureProductionId == id && x.RowVersion == version && x.ReleasedOn == null); if (row == null) return false; var copy = Newtonsoft.Json.JsonConvert.DeserializeObject(Newtonsoft.Json.JsonConvert.SerializeObject(row)); copy.ReleasedByUserId = user; copy.ReleasedOn = now; copy.DeliveryMethod = method; copy.DeliveryReference = reference; copy.ModifiedOn = now; copy.RowVersion++; DisclosureProductions.Remove(row); DisclosureProductions.Add(copy); return true; }); DisclosureProductionsRepo.Setup(r => r.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RmsDisclosureProduction e, CancellationToken c, bool f) => { DisclosureProductions.Add(e); return e; }); DisclosureProductionsRepo.Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) diff --git a/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs index ce16b9ad9..c91a0376a 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentAnalysisServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -9,6 +9,7 @@ using NUnit.Framework; using Resgrid.Model; using Resgrid.Model.Providers; +using Resgrid.Model.Services; using Resgrid.Providers.Neris; using Resgrid.Services.Records; @@ -30,6 +31,7 @@ public class IncidentAnalysisServiceTests private bool _submissionEnabled; private IncidentAnalysisService _service; private RmsIncidentReport _report; + private Mock _authorization; [SetUp] public void SetUp() @@ -67,10 +69,19 @@ public void SetUp() _store.ModulesRepo.Object, _store.PropertiesRepo.Object, _store.VehiclesRepo.Object, _store.IssuesRepo.Object, _store.SubmissionsRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _store.UnitOfWork.Object, _neris.Object, - new NerisMappingService(), new NerisValidationService(Mock.Of(), _neris.Object)); + new NerisMappingService(), new NerisValidationService(Mock.Of(), _neris.Object), Authorized()); } - private static IncidentAnalysisDraftInput CompleteDraft() + private IRecordsAuthorizationService Authorized() + { + var auth = new Mock(); + auth.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); + auth.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + _authorization = auth; + return auth.Object; + } + + private static IncidentAnalysisDraftInput CompleteDraft() { return new IncidentAnalysisDraftInput { @@ -82,16 +93,16 @@ private static IncidentAnalysisDraftInput CompleteDraft() new IncidentPropertyInput { LocationUse = "RESIDENTIAL||DETATCHED_SINGLE_FAMILY_DWELLING", ConstructionType = "TYPE_VB", DamageType = "MAJOR_DAMAGE", - FireSpread = "BUILDING", EstimatedValue = 300000m, EstimatedLoss = 120000m, ContentsValue = 50000m, ContentsLoss = 20000m + FireSpread = "BUILDING", EstimatedValue = 300000m, EstimatedLoss = 120000m, ContentsValue = 50000m, ContentsLoss = 20000m, DetailJson = "{\"parcel_id\":\"test-parcel\",\"structures\":[{\"ignition_source\":true,\"location\":{\"number\":100,\"street\":\"Main St\",\"incorporated_municipality\":\"Springfield\",\"state\":\"IL\"}}]}" } }, Vehicles = new List { - new IncidentVehicleInput { VehicleKind = "AUTOMOBILE", Make = "FORD", Model = "F-150", ModelYear = 2019, DamageType = "DAMAGED_NOT_DRIVABLE", Vin = "1FTFW1E85KFA00000", LicensePlate = "ABC1234", LicenseState = "IL", EstimatedValue = 20000m, EstimatedLoss = 20000m } + new IncidentVehicleInput { VehicleKind = "AUTOMOBILE", Make = "FORD", Model = "F-150", ModelYear = 2019, BodyStyle = "PICKUP", DamageType = "DAMAGED_NOT_DRIVABLE", Vin = "1FTFW1E85KFA00000", LicensePlate = "ABC1234", LicenseState = "IL", EstimatedValue = 20000m, EstimatedLoss = 20000m } }, Modules = new List { - new IncidentModuleInput { Kind = RmsIncidentModuleKind.StructureFireOrigin, PrimaryCode = "KITCHEN", DetailJson = "{\"room_of_origin\":\"KITCHEN\"}" } + new IncidentModuleInput { Kind = RmsIncidentModuleKind.StructureFireOrigin, PrimaryCode = "KITCHEN", DetailJson = "{\"room_of_origin\":\"KITCHEN\",\"cause\":\"COOKING||OIL_GREASE\"}" } } }; } @@ -129,6 +140,7 @@ public async Task A_caller_without_the_restricted_grant_neither_writes_nor_erase saved.Vehicles.Single().Vin.Should().Be("1FTFW1E85KFA00000"); var input = CompleteDraft(); + input.Vehicles[0].VehicleId = saved.Vehicles.Single().RmsIncidentVehicleId; input.Vehicles[0].Vin = "SOMETHINGELSE"; input.Vehicles[0].LicensePlate = "ZZZ9999"; input.Vehicles[0].Model = "F-250"; @@ -153,10 +165,58 @@ public async Task Finalizing_before_the_incident_is_filed_succeeds_and_waits() _store.Submissions.Should().BeEmpty("the incident has no NERIS id yet, so there is nothing to file against"); } + [Test] + public async Task Reordering_vehicles_keeps_hidden_identifiers_with_their_original_rows() + { + var started = await _service.StartForReportAsync(Dept, "author", _report.RmsIncidentReportId); + var input = CompleteDraft(); + input.Vehicles.Add(new IncidentVehicleInput { VehicleKind = "AUTOMOBILE", Model = "Second", Vin = "SECOND-VIN", LicensePlate = "SECOND" }); + var saved = await _service.SaveDraftAsync(Dept, "author", started.Analysis.RmsIncidentAnalysisId, started.Analysis.RowVersion, input, true); + input.Vehicles[0].VehicleId = saved.Vehicles[0].RmsIncidentVehicleId; + input.Vehicles[1].VehicleId = saved.Vehicles[1].RmsIncidentVehicleId; + input.Vehicles.ForEach(v => { v.Vin = null; v.LicensePlate = null; }); + input.Vehicles.Reverse(); + var reordered = await _service.SaveDraftAsync(Dept, "author", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion, input, false); + reordered.Vehicles[0].Model.Should().Be("Second"); + reordered.Vehicles[0].Vin.Should().Be("SECOND-VIN"); + reordered.Vehicles[1].Vin.Should().Be("1FTFW1E85KFA00000"); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Hidden_vehicle_rows_cannot_be_erased_or_replaced_by_a_foreign_identifier(bool foreign) + { + var saved = await StartAndFillAsync(); + var input = CompleteDraft(); + if (foreign) input.Vehicles[0].VehicleId = "another-analysis-row"; else input.Vehicles.Clear(); + var act = () => _service.SaveDraftAsync(Dept, "author", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion, input, false); + if (foreign) await act.Should().ThrowAsync(); else await act.Should().ThrowAsync(); + _store.Vehicles.Single(v => v.RevisionId == null).Vin.Should().Be("1FTFW1E85KFA00000"); + } + + [Test] + public async Task A_stale_restricted_flag_cannot_override_the_live_permission() + { + var saved = await StartAndFillAsync(); + _authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + var input = CompleteDraft(); input.Vehicles[0].VehicleId = saved.Vehicles[0].RmsIncidentVehicleId; input.Vehicles[0].Vin = "FORGED"; + var result = await _service.SaveDraftAsync(Dept, "author", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion, input, true); + result.Vehicles[0].Vin.Should().Be("1FTFW1E85KFA00000"); + } + + [Test] + public async Task Purged_parent_cannot_be_retrieved_as_an_analysis_or_built_into_a_new_snapshot() + { + var saved = await StartAndFillAsync(); _report.PurgedOn = DateTime.UtcNow; + (await _service.GetAsync(Dept, saved.Analysis.RmsIncidentAnalysisId, true)).Should().BeNull(); + (await _service.GetForReportAsync(Dept, _report.RmsIncidentReportId, true)).Should().BeNull(); + (await _service.BuildSnapshotAsync(Dept, saved.Analysis.RmsIncidentAnalysisId)).Should().BeNull(); + } + [Test] public async Task Finalizing_after_the_incident_is_filed_queues_the_analysis_with_its_own_key() { - _report.NerisIncidentId = "FD24027000I2026000200"; + _report.NerisIncidentId = "FD24027000|2026-000200|1788436800"; var saved = await StartAndFillAsync(); var finalized = await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); @@ -172,25 +232,45 @@ public async Task Finalizing_after_the_incident_is_filed_queues_the_analysis_wit [Test] public async Task The_queued_payload_carries_the_incident_id_and_the_analysis_sections() { - _report.NerisIncidentId = "FD24027000I2026000200"; + _report.NerisIncidentId = "FD24027000|2026-000200|1788436800"; var saved = await StartAndFillAsync(); await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); var payload = JObject.Parse(_store.Submissions.Single().PayloadJson); - payload["base"].Value("incident_neris_id").Should().Be("FD24027000I2026000200"); - payload["base"].Value("general_cause").Should().Be("ACCIDENTAL"); + payload["base"].Value("neris_id_incident").Should().Be("FD24027000|2026-000200|1788436800"); + payload["base"].Value("incident_number").Should().Be("2026-000200"); + ((JObject)payload["base"]).Properties().Select(p => p.Name).Should().BeEquivalentTo("neris_id_incident", "incident_number", "narrative"); + payload["structure_fire_origin"].Value("general_cause").Should().Be("ACCIDENTAL"); + payload["base"].Value("narrative").Should().Contain("INVESTIGATED_BY_ARSON_FIRE_INVESTIGATOR"); payload["properties"].Should().HaveCount(1); payload["vehicles"].Should().HaveCount(1); payload["structure_fire_origin"].Should().NotBeNull("the module lands at the payload path its catalog descriptor names"); } + [Test] + public async Task Historical_analysis_and_later_queue_keep_signed_headers_when_drafts_change() + { + var saved = await StartAndFillAsync(); + var final = await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); + var revisionId = final.Analysis.CurrentRevisionId; + _store.Analyses.Single().GeneralCause = "UNAPPROVED"; _report.IncidentNumber = "UNAPPROVED"; + var snapshot = await _service.BuildSnapshotAsync(Dept, final.Analysis.RmsIncidentAnalysisId, revisionId); + snapshot.Analysis.GeneralCause.Should().Be("ACCIDENTAL"); snapshot.Report.IncidentNumber.Should().Be("2026-000200"); + _report.NerisIncidentId = "FD24027000|2026-000200|1788436800"; + (await _service.QueueAwaitingIncidentAsync(Dept)).Should().Be(1); + var payload = JObject.Parse(_store.Submissions.Single().PayloadJson); + payload["base"]["neris_id_incident"].Value().Should().Be(_report.NerisIncidentId); + payload["base"]["incident_number"].Value().Should().Be("2026-000200"); + payload["structure_fire_origin"]["general_cause"].Value().Should().Be("ACCIDENTAL"); + } + [Test] public async Task An_analysis_that_will_not_validate_never_touches_the_incident_report() { var saved = await StartAndFillAsync(); var input = CompleteDraft(); input.GeneralCause = "NOT_A_NERIS_CAUSE"; - var bad = await _service.SaveDraftAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion, input); + var bad = await _service.SaveDraftAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion, input, true); Func act = () => _service.FinalizeAsync(Dept, "investigator", bad.Analysis.RmsIncidentAnalysisId, bad.Analysis.RowVersion); @@ -207,7 +287,7 @@ public async Task Analyses_waiting_on_their_incident_are_queued_once_it_is_filed _store.Submissions.Should().BeEmpty(); // The incident's own submission landed; the analysis can now be filed against it. - _report.NerisIncidentId = "FD24027000I2026000200"; + _report.NerisIncidentId = "FD24027000|2026-000200|1788436800"; var queued = await _service.QueueAwaitingIncidentAsync(Dept); queued.Should().Be(1); @@ -221,7 +301,7 @@ public async Task Analyses_waiting_on_their_incident_are_queued_once_it_is_filed [Test] public async Task An_analysis_in_flight_cannot_be_voided_until_it_settles() { - _report.NerisIncidentId = "FD24027000I2026000200"; + _report.NerisIncidentId = "FD24027000|2026-000200|1788436800"; var saved = await StartAndFillAsync(); var finalized = await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); finalized.State.Should().Be(RmsIncidentAnalysisState.Submitted); @@ -234,7 +314,7 @@ public async Task An_analysis_in_flight_cannot_be_voided_until_it_settles() [Test] public async Task Voiding_a_rejected_analysis_supersedes_its_open_submission() { - _report.NerisIncidentId = "FD24027000I2026000200"; + _report.NerisIncidentId = "FD24027000|2026-000200|1788436800"; var saved = await StartAndFillAsync(); var finalized = await _service.FinalizeAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion); @@ -253,7 +333,7 @@ public async Task A_stale_row_version_is_refused() { var saved = await StartAndFillAsync(); - Func act = () => _service.SaveDraftAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion - 1, CompleteDraft()); + Func act = () => _service.SaveDraftAsync(Dept, "investigator", saved.Analysis.RmsIncidentAnalysisId, saved.Analysis.RowVersion - 1, CompleteDraft(), true); await act.Should().ThrowAsync(); } diff --git a/Tests/Resgrid.Tests/Rms/IncidentAttachmentTests.cs b/Tests/Resgrid.Tests/Rms/IncidentAttachmentTests.cs new file mode 100644 index 000000000..81d0c2566 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/IncidentAttachmentTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + [TestFixture] + public class IncidentAttachmentTests + { + private FakeIncidentStore _store; + private Mock _auth; + private Mock _scanner; + private IncidentAttachmentsService _service; + [SetUp] + public void Setup() + { + _store = new FakeIncidentStore(); _auth = new Mock(); _scanner = new Mock(); + _auth.Setup(a => a.CanUserViewRecordAsync("officer", "report", 1)).ReturnsAsync(true); _auth.Setup(a => a.HasPermissionAsync("officer", 1, It.IsAny())).ReturnsAsync(true); + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }); + _store.Reports.Add(new RmsIncidentReport { DepartmentId = 1, RmsIncidentReportId = "report", AuthorUserId = "officer", State = (int)RmsRecordState.Draft, RowVersion = 1 }); + _service = new IncidentAttachmentsService(_store.ReportsRepo.Object, _store.Shared.AttachmentsRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _auth.Object, _scanner.Object, _store.UnitOfWork.Object); + } + private Task Add() => _service.AddAsync(1, "officer", "report", 1, "scene.txt", "text/plain", Encoding.UTF8.GetBytes("scene evidence"), "Scene notes"); + + [Test] + public async Task Upload_download_and_revision_membership_preserve_bytes_and_checksums() + { + var metadata = await Add(); metadata.Data.Should().BeNull(); + var file = await _service.GetAsync(1, "officer", "report", metadata.RmsRecordAttachmentId); Encoding.UTF8.GetString(file.Data).Should().Be("scene evidence"); + _store.Reports[0].RowVersion.Should().Be(2); + var json = JsonConvert.SerializeObject(new { Attachments = new[] { metadata } }); + _store.Revisions.Add(new RmsRevision { DepartmentId = 1, RecordId = "report", RecordKind = 2, RmsRevisionId = "r1", SnapshotJson = json, Checksum = RecordSnapshotSerializer.Checksum(json) }); + (await _service.GetAsync(1, "officer", "report", metadata.RmsRecordAttachmentId, "r1")).Should().NotBeNull(); + await _service.RemoveAsync(1, "officer", "report", metadata.RmsRecordAttachmentId, 2); + (await _service.GetAsync(1, "officer", "report", metadata.RmsRecordAttachmentId)).Should().BeNull(); + (await _service.GetAsync(1, "officer", "report", metadata.RmsRecordAttachmentId, "r1")).Data.Should().Equal(Encoding.UTF8.GetBytes("scene evidence")); + _store.Revisions[0].SnapshotJson = "{\"Attachments\":[]}"; _store.Revisions[0].Checksum = RecordSnapshotSerializer.Checksum(_store.Revisions[0].SnapshotJson); + (await _service.GetAsync(1, "officer", "report", metadata.RmsRecordAttachmentId, "r1")).Should().BeNull(); + } + [Test] + public async Task Inflight_upload_cannot_attach_to_a_newly_finalized_or_purged_report() + { + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(() => { _store.Reports[0].State = (int)RmsRecordState.Finalized; _store.Reports[0].RowVersion++; return new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }; }); + Func write = () => Add(); await write.Should().ThrowAsync(); _store.Shared.Attachments.Should().BeEmpty(); + } + [Test] + public async Task Retained_view_and_create_permissions_do_not_allow_a_demoted_admin_to_change_another_officers_files() + { + var metadata = await Add(); + _store.Reports[0].AuthorUserId = "other"; + Func add = () => Add(); await add.Should().ThrowAsync(); + Func remove = () => _service.RemoveAsync(1, "officer", "report", metadata.RmsRecordAttachmentId, 2); await remove.Should().ThrowAsync(); + _store.Shared.Attachments.Single().DeletedOn.Should().BeNull(); + } + [Test] + public async Task Revoked_access_tampered_content_and_pending_scans_prevent_download() + { + var metadata = await Add(); var stored = _store.Shared.Attachments.Single(); + stored.ScanState = (int)RmsAttachmentScanState.Pending; + Func read = () => _service.GetAsync(1, "officer", "report", metadata.RmsRecordAttachmentId); await read.Should().ThrowAsync(); + stored.ScanState = (int)RmsAttachmentScanState.Clean; stored.Data = Encoding.UTF8.GetBytes("tampered"); await read.Should().ThrowAsync(); + _auth.Setup(a => a.CanUserViewRecordAsync("officer", "report", 1)).ReturnsAsync(false); await read.Should().ThrowAsync(); + } + + [Test] + public async Task Unclassified_or_restricted_files_require_live_restricted_permission_for_reads_and_removal() + { + var metadata = await Add(); _store.Shared.Attachments.Single().Classification = null; + _auth.Setup(a => a.HasPermissionAsync("officer", 1, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + Func read = () => _service.GetAsync(1, "officer", "report", metadata.RmsRecordAttachmentId); await read.Should().ThrowAsync(); + Func remove = () => _service.RemoveAsync(1, "officer", "report", metadata.RmsRecordAttachmentId, 2); await remove.Should().ThrowAsync(); + _store.Shared.Attachments.Single().DeletedOn.Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs new file mode 100644 index 000000000..f2097d9c2 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Providers.Neris; +using Resgrid.Services; +using Resgrid.Services.Records; +using Resgrid.Services.Records.Evidence; + +namespace Resgrid.Tests.Rms +{ + public partial class IncidentReportsServiceTests + { + /// Real lifecycle/validation/mapping/evidence/UDF/attachment/submission/document/disclosure services; + /// in-memory persistence, scanner, PDF provider and destination transport. This is not browser or sandbox acceptance. + [Test] + public async Task Officer_completes_submits_corrects_and_discloses_an_incident_with_frozen_custom_fields_evidence_and_attachment() + { + var (udf, definition) = await JourneyCustomFieldsAsync(); + _neris.Setup(n => n.GetDestinationIdentity(_profile)).Returns("journey-destination"); + var validator = new NerisValidationService(Mock.Of(), _neris.Object); + _validation.Setup(v => v.ValidateLocal(It.IsAny(), It.IsAny())) + .Returns((NerisIncidentSnapshot snapshot, RmsNerisProfile profile) => validator.ValidateLocal(snapshot, profile)); + var activations = new Mock(); + activations.Setup(a => a.GetActivationsByCallIdAsync(CallId)).ReturnsAsync(new[] { new RunCardActivation { + DepartmentId = Dept, CallId = CallId, RunCardActivationId = 14, RunCardId = 2, CreatedOn = LoggedOn, + ResultJson = "{\"decision\":\"Engine 5 selected\",\"caller\":\"Private caller identity\"}" } }); + var evidence = new RecordsEvidenceService(_store.Shared.EvidenceRepo.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, + _store.Shared.AuditsRepo.Object, _store.UnitOfWork.Object, new[] { new RunCardActivationEvidenceAdapter(activations.Object) }, + _authorization.Object, _calls.Object, Mock.Of()); + _service = BuildService(udf, evidence); + var started = await _service.StartFromCallAsync(Dept, "author", CallId); var id = started.Report.RmsIncidentReportId; + var sample = Resgrid.Tests.Providers.NerisMappingTests.Snapshot(); + var input = DraftFrom(new IncidentReportAggregate { Report = sample.Report, Location = sample.Location, Types = sample.Types, + Units = sample.Units.Take(1).ToList(), Aids = sample.Aids, Tactics = sample.Tactics, Narrative = sample.Narrative }); + input.Modules = sample.Modules.Select(m => new IncidentModuleInput { Kind = (RmsIncidentModuleKind)m.ModuleKind, PrimaryCode = m.PrimaryCode, + SecondaryCode = m.SecondaryCode, Quantity = m.Quantity, DetailJson = m.DetailJson }).ToList(); + input.Resources = JsonConvert.DeserializeObject>(JsonConvert.SerializeObject(sample.Resources)); + input.Casualties = JsonConvert.DeserializeObject>(JsonConvert.SerializeObject(sample.Casualties)); + input.Exposures = JsonConvert.DeserializeObject>(JsonConvert.SerializeObject(sample.Exposures)); + input.CustomFields = new RecordUdfInput { DefinitionId = definition.UdfDefinitionId, Values = new() { [definition.Fields.Single().UdfFieldId] = "23" } }; + var saved = await _service.SaveDraftAsync(Dept, "author", id, started.Report.RowVersion, input, true); + (await _service.ValidateAsync(Dept, id, false)).Where(i => i.Severity == (int)RmsValidationSeverity.Error).Should().BeEmpty(); + var scanner = new Mock(); scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }); + var files = new IncidentAttachmentsService(_store.ReportsRepo.Object, _store.Shared.AttachmentsRepo.Object, _store.Shared.RevisionsRepo.Object, + _store.Shared.AuditsRepo.Object, _authorization.Object, scanner.Object, _store.UnitOfWork.Object); + var file = await files.AddAsync(Dept, "author", id, (await _service.GetAsync(Dept, id)).Report.RowVersion, "scene.txt", "text/plain", + Encoding.UTF8.GetBytes("Officer's reviewed scene notes"), "Scene observations", classification: 0); + var captured = await evidence.CaptureAsync(new RecordEvidenceCaptureRequest { DepartmentId = Dept, CapturedByUserId = "author", RecordId = id, + RecordKind = RmsRecordKind.IncidentReport, Kind = RmsEvidenceKind.RunCardActivation, CallId = CallId, + ExpectedRowVersion = (await _service.GetAsync(Dept, id)).Report.RowVersion, CaptureReason = "Dispatch decision supporting this incident" }); + var final = await _service.FinalizeAsync(Dept, "author", id, (await _service.GetAsync(Dept, id)).Report.RowVersion, "1", "127.0.0.1", null, null); + var firstRevision = _store.Revisions.Single(); var originalJson = firstRevision.SnapshotJson; var originalChecksum = firstRevision.Checksum; + var firstSubmission = _store.Submissions.Single(); var originalPayload = firstSubmission.PayloadJson; + firstSubmission.PayloadJson.Should().NotContain("Department response score").And.NotContain("scene.txt").And.NotContain("Private caller identity"); + var firstSnapshot = await _service.BuildSnapshotAsync(Dept, id, firstRevision.RmsRevisionId); + firstSnapshot.CustomFields.Fields.Single().Value.Should().Be("23"); firstSnapshot.Evidence.Should().ContainSingle().Which.Checksum.Should().Be(captured.Checksum); + firstSnapshot.Attachments.Should().ContainSingle().Which.Checksum.Should().Be(file.Checksum); + _store.Signatures.Single().ArtifactChecksum.Should().Be(originalChecksum); + + var delivery = new Mock(); + delivery.Setup(d => d.DeliverAsync(_profile, It.IsAny(), null, It.IsAny())) + .ReturnsAsync(new NerisSubmissionOutcome { Kind = NerisOutcomeKind.Rejected, StatusCode = 422, ResponseJson = "{\"detail\":\"Review outcome narrative\"}" }); + var worker = new RecordsSubmissionService(_store.SubmissionsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, _store.Shared.ProjectionsRepo.Object, + _store.Shared.AuditsRepo.Object, _neris.Object, delivery.Object, new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object), + Mock.Of(), _store.UnitOfWork.Object, _store.Shared.CutoversRepo.Object, Mock.Of(), _store.ExchangesRepo.Object, _authorization.Object); + void Lease(RmsSubmission submission) { submission.LeaseOwner = "journey-worker"; submission.LeaseExpiresOn = DateTime.UtcNow.AddMinutes(5); submission.RowVersion++; } + Lease(firstSubmission); (await worker.ProcessAsync(firstSubmission)).State.Should().Be((int)RmsSubmissionState.Rejected); + var rejected = await _service.GetAsync(Dept, id); rejected.State.Should().Be(RmsRecordState.Rejected); + var correction = DraftFrom(rejected); correction.OutcomeNarrative = "Fire out; no extension after final inspection."; + correction.CustomFields = new RecordUdfInput { DefinitionId = definition.UdfDefinitionId, Values = new() { [definition.Fields.Single().UdfFieldId] = "24" } }; + var edited = await _service.SaveDraftAsync(Dept, "author", id, rejected.Report.RowVersion, correction, true); + await _service.CorrectAndResubmitAsync(Dept, "author", id, edited.Report.RowVersion, "1", "127.0.0.1", "destination-rejection", "Officer verified final inspection outcome"); + var secondRevision = _store.Revisions.Single(r => r.RevisionNumber == 2); + secondRevision.PriorRevisionId.Should().Be(firstRevision.RmsRevisionId); + firstRevision.SnapshotJson.Should().Be(originalJson); firstRevision.Checksum.Should().Be(originalChecksum); + _store.Submissions.Single(s => s.RmsSubmissionId == firstSubmission.RmsSubmissionId).PayloadJson.Should().Be(originalPayload); + var secondSubmission = _store.Submissions.Single(s => s.RevisionId == secondRevision.RmsRevisionId); + delivery.Setup(d => d.DeliverAsync(_profile, It.IsAny(), null, It.IsAny())).ReturnsAsync(new NerisSubmissionOutcome { + Kind = NerisOutcomeKind.Accepted, StatusCode = 201, ExternalId = "FD24027000I2026000123", ResponseJson = "{\"incident_status\":\"APPROVED\",\"neris_id\":\"FD24027000I2026000123\"}" }); + Lease(secondSubmission); (await worker.ProcessAsync(secondSubmission)).State.Should().Be((int)RmsSubmissionState.Accepted); + (await _service.GetAsync(Dept, id)).State.Should().Be(RmsRecordState.Accepted); + + var pdf = new Mock(); var rendered = new List(); + pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter")).Returns((string html, string paper) => { rendered.Add(html); return Encoding.ASCII.GetBytes("%PDF-journey-fixture"); }); + var branding = new Mock(); branding.Setup(b => b.GetBrandingAsync(Dept)).ReturnsAsync(new DepartmentBranding { DisplayName = "Journey Fire Department" }); + var documents = new RecordsDocumentService(_authorization.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, + _store.Shared.RevisionsRepo.Object, _service, branding.Object, Mock.Of(), pdf.Object, evidence, udf); + var original = await documents.GetAsync(Dept, "author", id, RmsRecordKind.IncidentReport, firstRevision.RmsRevisionId, true); + var corrected = await documents.GetAsync(Dept, "author", id, RmsRecordKind.IncidentReport, secondRevision.RmsRevisionId, true); + JObject.Parse(original.ContentJson)["CustomFields"]["Fields"][0]["Value"].Value().Should().Be("23"); + JObject.Parse(corrected.ContentJson)["CustomFields"]["Fields"][0]["Value"].Value().Should().Be("24"); + await documents.RenderPdfAsync(Dept, "author", corrected); + rendered.Last().Should().Contain("Department response score").And.Contain("Engine 5 selected").And.Contain("scene.txt").And.Contain("final inspection"); + var differences = await documents.DiffAsync(Dept, "author", original, corrected); + differences.Should().Contain(d => d.FieldLabel == "Department custom field: Department response score" && d.OldValue == "23" && d.NewValue == "24"); + differences.Should().NotContain(d => d.FieldKey.EndsWith("CreatedOn") || d.FieldKey.EndsWith("ModifiedOn") || d.FieldKey.EndsWith("RmsAidId")); + + var disclosures = new RecordsDisclosureService(_store.Shared.DisclosureRequestsRepo.Object, _store.Shared.DisclosureProductionsRepo.Object, + _store.Shared.RecordsRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _authorization.Object, _settings.Object, + _store.UnitOfWork.Object, _store.ReportsRepo.Object, documents, _store.Shared.AttachmentsRepo.Object, pdf.Object, _store.AnalysesRepo.Object, scanner.Object, udf); + var request = await disclosures.CreateRequestAsync(Dept, "custodian", new RmsDisclosureRequest { RequesterName = "Training requester", JurisdictionProfile = "Fixture jurisdiction", ReceivedOn = DateTime.UtcNow }); + await disclosures.SaveScopeAsync(Dept, "custodian", request.RmsDisclosureRequestId, "Incident and supporting file", new RmsRecordQuery { CallId = CallId, DefinitionKey = RmsDefinitionKeys.NerisIncidentReport }, RmsRedactionProfiles.Standard); + var review = await disclosures.GetReviewAsync(Dept, "custodian", request.RmsDisclosureRequestId); + review.Reviewed = true; review.Authority = "Fixture disclosure authority"; review.Basis = "Custodian reviewed report and attachment"; + var reviewed = review.Records.Should().ContainSingle().Which; + reviewed.Decisions.Add(new RmsDisclosureFieldDecision { Path = "/Evidence/0/ManifestJson/activations/0/result/caller", Withhold = true, Authority = "Fixture privacy rule", Basis = "Caller identity withheld" }); + reviewed.Attachments.Single().Reviewed = true; reviewed.Attachments.Single().Include = true; + var production = await disclosures.ProduceAsync(Dept, "custodian", request.RmsDisclosureRequestId, review: review); + production.ArtifactJson.Should().NotContain("Private caller identity").And.Contain("Engine 5 selected").And.Contain("Department response score"); + var frozenPacket = production.ArtifactJson; + await disclosures.ReleaseAsync(Dept, "custodian", production.RmsDisclosureProductionId, deliveryMethod: "Secure collection", deliveryReference: "Fixture delivery receipt 001"); + var download = await disclosures.DownloadAsync(Dept, "custodian", production.RmsDisclosureProductionId, "zip"); + using (var zip = new ZipArchive(new MemoryStream(download.Data))) + { + zip.GetEntry("packet.pdf").Should().NotBeNull(); + using var reader = new StreamReader(zip.GetEntry("attachments/0001-scene.txt").Open()); + (await reader.ReadToEndAsync()).Should().Be("Officer's reviewed scene notes"); + } + production.ArtifactJson.Should().Be(frozenPacket); firstRevision.SnapshotJson.Should().Be(originalJson); + var qa = Environment.GetEnvironmentVariable("RESGRID_RMS_DOCUMENT_QA_DIR"); + if (!string.IsNullOrWhiteSpace(qa)) + { + await System.IO.File.WriteAllTextAsync(Path.Combine(qa, "officer-journey-report.html"), rendered.First()); + await System.IO.File.WriteAllTextAsync(Path.Combine(qa, "officer-journey-disclosure.html"), rendered.Last()); + } + } + + private async Task<(RecordsUdfService service, UdfDefinition definition)> JourneyCustomFieldsAsync() + { + var definitions = new Mock(); var fields = new Mock(); var values = new Mock(); + var definitionRows = new List(); var fieldRows = new List(); var valueRows = new List(); + _authorization.Setup(a => a.IsDepartmentAdminAsync("author", Dept)).ReturnsAsync(true); + definitions.Setup(d => d.GetActiveAsync(Dept, RmsDefinitionKeys.NerisIncidentReport, 1)).ReturnsAsync(() => definitionRows.SingleOrDefault()); + definitions.Setup(d => d.GetScopedAsync(Dept, It.IsAny(), RmsDefinitionKeys.NerisIncidentReport, 1)).ReturnsAsync((int d, string id, string key, int version) => definitionRows.SingleOrDefault(r => r.UdfDefinitionId == id)); + definitions.Setup(d => d.InsertAsync(It.IsAny(), It.IsAny(), true)).ReturnsAsync((UdfDefinition d, CancellationToken c, bool force) => { definitionRows.Add(d); return d; }); + fields.Setup(f => f.InsertAsync(It.IsAny(), It.IsAny(), true)).ReturnsAsync((UdfField f, CancellationToken c, bool force) => { fieldRows.Add(f); return f; }); + fields.Setup(f => f.GetFieldsByDefinitionIdAsync(It.IsAny())).ReturnsAsync((string id) => fieldRows.Where(f => f.UdfDefinitionId == id)); + values.Setup(v => v.GetFieldValuesByEntityAsync(4, It.IsAny(), It.IsAny())).ReturnsAsync((int type, string id, string definition) => valueRows.Where(v => v.EntityId == id && v.UdfDefinitionId == definition)); + values.Setup(v => v.DeleteFieldValuesByEntityAndDefinitionAsync(4, It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int type, string id, string definition, CancellationToken c) => { valueRows.RemoveAll(v => v.EntityId == id && v.UdfDefinitionId == definition); return true; }); + values.Setup(v => v.InsertAsync(It.IsAny(), It.IsAny(), true)).ReturnsAsync((UdfFieldValue v, CancellationToken c, bool force) => { valueRows.Add(v); return v; }); + var service = new RecordsUdfService(definitions.Object, fields.Object, values.Object, _authorization.Object, _groups.Object, _store.UnitOfWork.Object, _adp.Object); + var definition = await service.PublishAsync(Dept, "author", RmsDefinitionKeys.NerisIncidentReport, 1, null, new List { + new() { Name = "response_score", Label = "Department response score", FieldDataType = (int)UdfFieldDataType.Number, IsEnabled = true, IsRequired = true, IsVisibleOnReports = true, RmsClassification = 0, Visibility = 0 } }); + return (service, definition); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs index c1c71a6cf..bbddecdee 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -21,7 +21,7 @@ namespace Resgrid.Tests.Rms /// attestation + submission chain, correction after rejection, and void superseding open submissions. /// [TestFixture] - public class IncidentReportsServiceTests + public partial class IncidentReportsServiceTests { private const int Dept = 42; private const int CallId = 77; @@ -43,6 +43,26 @@ public class IncidentReportsServiceTests private List _localIssues; private Call _call; private IncidentReportsService _service; + private Mock _authorization; + + [Test] + public async Task Starting_an_existing_report_rechecks_current_record_visibility() + { + var created = await _service.StartFromCallAsync(Dept, "author", CallId); + _authorization.Setup(a => a.CanUserViewRecordAsync("other-group", created.Report.RmsIncidentReportId, Dept)).ReturnsAsync(false); + Func start = () => _service.StartFromCallAsync(Dept, "other-group", CallId); + await start.Should().ThrowAsync(); + } + + [Test] + public async Task Denied_source_call_is_not_populated_or_persisted() + { + _authorization.Setup(a => a.CanReadSourceCallAsync("author", Dept, It.IsAny())).ReturnsAsync(false); + Func start = () => _service.StartFromCallAsync(Dept, "author", CallId); + await start.Should().ThrowAsync(); + _calls.Verify(c => c.PopulateCallData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _store.Reports.Should().BeEmpty(); + } [SetUp] public void SetUp() @@ -100,16 +120,25 @@ public void SetUp() _validation.Setup(v => v.ValidateLocal(It.IsAny(), It.IsAny())).Returns(() => _localIssues.ToList()); _aggregator = new Mock(); - var outbox = new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object); + _authorization = new Mock(); + _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); + _authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.CanReadSourceCallAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); + _service = BuildService(); + } - _service = new IncidentReportsService(_store.ReportsRepo.Object, _store.FactsRepo.Object, _store.UnitsRepo.Object, _store.TypesRepo.Object, + private IncidentReportsService BuildService(IRecordsUdfService udf = null, IRecordsEvidenceService evidence = null) + { + var outbox = new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object); + return new IncidentReportsService(_store.ReportsRepo.Object, _store.FactsRepo.Object, _store.UnitsRepo.Object, _store.TypesRepo.Object, _store.TacticsRepo.Object, _store.AidsRepo.Object, _store.LocationsRepo.Object, _store.NarrativesRepo.Object, _store.IssuesRepo.Object, _store.SubmissionsRepo.Object, _store.SignaturesRepo.Object, _store.ModulesRepo.Object, _store.ResourcesRepo.Object, _store.CasualtiesRepo.Object, _store.ExposuresRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _store.Shared.ScopesRepo.Object, _store.Shared.SharesRepo.Object, _store.Shared.ProjectionsRepo.Object, outbox, _settings.Object, _groups.Object, _profiles.Object, _roles.Object, _units.Object, _calls.Object, _adp.Object, _store.UnitOfWork.Object, - _neris.Object, new NerisMappingService(), _validation.Object); + _neris.Object, new NerisMappingService(), _validation.Object, _authorization.Object, _store.Shared.AttachmentsRepo.Object, _store.Shared.EvidenceRepo.Object, udf ?? Mock.Of(), evidence ?? Mock.Of()); } [Test] @@ -122,7 +151,8 @@ public async Task Start_from_call_prefills_dispatch_facts_with_provenance() aggregate.Report.DefinitionKey.Should().Be(RmsDefinitionKeys.NerisIncidentReport); aggregate.Report.IncidentNumber.Should().Be("2026-000123"); aggregate.Report.CallCreatedOn.Should().Be(LoggedOn); - aggregate.Report.CallArrivalOn.Should().Be(LoggedOn.AddMinutes(10), "first on-scene from the unit state log"); + aggregate.Report.CallArrivalOn.Should().BeNull("unit arrival is not call arrival at the PSAP"); + aggregate.Report.CallAnsweredOn.Should().BeNull("the Call has no observed PSAP answer time"); aggregate.Report.DraftReference.Should().StartWith("I-"); aggregate.Report.RecordNumber.Should().BeNull("numbers are allocated at finalize"); @@ -144,7 +174,7 @@ public async Task Start_from_call_prefills_dispatch_facts_with_provenance() var facts = aggregate.Facts; facts.Single(f => f.FactKey == NerisFactKeys.IncidentNumber).SourceKind.Should().Be((int)RmsSourceKind.Dispatch); - facts.Single(f => f.FactKey == NerisFactKeys.CallAnswered).SourceKind.Should().Be((int)RmsSourceKind.Derived, "Resgrid holds no PSAP answered time"); + facts.Should().NotContain(f => f.FactKey == NerisFactKeys.CallAnswered || f.FactKey == NerisFactKeys.CallArrival, "unknown PSAP timestamps must not be invented"); facts.Single(f => f.FactKey == NerisFactKeys.UnitTime(5, "on_scene")).SourceKind.Should().Be((int)RmsSourceKind.App); facts.Single(f => f.FactKey == NerisFactKeys.IncidentType).SourceKind.Should().Be((int)RmsSourceKind.Derived); facts.Should().ContainSingle(f => f.FactKey == IncidentReportsService.DispatchCommentFactPrefix + "0" && f.SourceValue == "Caller reports smoke from the roof"); @@ -194,7 +224,7 @@ public async Task The_promoted_primary_incident_type_is_persisted_not_only_retur new IncidentTypeInput { TypeCode = "RESCUE||SEARCH", IsPrimary = false } }; - var saved = await _service.SaveDraftAsync(Dept, "author", started.Report.RmsIncidentReportId, started.Report.RowVersion, input); + var saved = await _service.SaveDraftAsync(Dept, "author", started.Report.RmsIncidentReportId, started.Report.RowVersion, input, true); saved.Types.Count(t => t.IsPrimary).Should().Be(1); _store.Types.Where(t => t.RecordId == started.Report.RmsIncidentReportId && t.RevisionId == null) @@ -215,7 +245,7 @@ public async Task Save_draft_records_corrections_on_the_provenance_row() input.Narrative = "Fire confined to the attic."; input.Location.Street = "Main St"; - var saved = await _service.SaveDraftAsync(Dept, "author", started.Report.RmsIncidentReportId, started.Report.RowVersion, input); + var saved = await _service.SaveDraftAsync(Dept, "author", started.Report.RmsIncidentReportId, started.Report.RowVersion, input, true); saved.Report.IncidentNumber.Should().Be("2026-000124"); var number = saved.Facts.Single(f => f.FactKey == NerisFactKeys.IncidentNumber); @@ -356,7 +386,7 @@ public async Task Correct_and_resubmit_after_rejection_issues_a_new_revision_and var input = DraftFrom(await _service.GetAsync(Dept, reportId)); input.CallAnsweredOn = LoggedOn.AddSeconds(30); - var edited = await _service.SaveDraftAsync(Dept, "author", reportId, report.RowVersion, input); + var edited = await _service.SaveDraftAsync(Dept, "author", reportId, report.RowVersion, input, true); edited.State.Should().Be(RmsRecordState.Rejected, "a rejected report is editable in place"); var corrected = await _service.CorrectAndResubmitAsync(Dept, "author", reportId, edited.Report.RowVersion, null, null, "destination-rejection", "Answered time added"); @@ -448,6 +478,41 @@ public async Task Snapshot_carries_dispatch_comments_in_time_order() snapshot.Units.Should().ContainSingle(); } + [Test] + public async Task Revision_captures_conditional_sections_attachments_and_evidence_without_later_draft_changes() + { + _profile.AutoSubmitOnFinalize = false; + var started = await _service.StartFromCallAsync(Dept, "author", CallId); + var id = started.Report.RmsIncidentReportId; + _store.Modules.Add(new RmsIncidentModule { DepartmentId = Dept, RecordId = id, RmsIncidentModuleId = "fire", ModuleKind = (int)RmsIncidentModuleKind.Fire, DetailJson = "{\"water_supply\":\"HYDRANT\"}" }); + _store.Shared.Attachments.Add(new RmsRecordAttachment { DepartmentId = Dept, RecordId = id, RmsRecordAttachmentId = "photo", FileName = "scene.txt", Data = new byte[] { 1, 2, 3 }, Checksum = "fixture" }); + _store.Shared.EvidenceArtifacts.Add(new RmsEvidenceArtifact { DepartmentId = Dept, RecordId = id, RmsEvidenceArtifactId = "decision", ManifestJson = "{\"decision\":\"Engine 5\"}", Checksum = "fixture" }); + var final = await _service.FinalizeAsync(Dept, "author", id, started.Report.RowVersion, null, null, null, null); + var revision = _store.Revisions.Single(); var json = JObject.Parse(revision.SnapshotJson); + json["SnapshotVersion"].Value().Should().Be(2); + json["Modules"].Should().NotBeEmpty(); json["Evidence"].Should().NotBeEmpty(); + json["Attachments"][0]["Data"].Type.Should().Be(JTokenType.Null); + _store.Shared.EvidenceArtifacts.Single().RevisionId.Should().Be(revision.RmsRevisionId); + _store.Reports.Single().IncidentNumber = "unfinalized correction"; + _store.Modules.First(m => m.RevisionId == null).DetailJson = "{\"changed\":true}"; + _store.Shared.Attachments.Add(new RmsRecordAttachment { DepartmentId = Dept, RecordId = id, RmsRecordAttachmentId = "later", FileName = "later.txt", UploadedOn = DateTime.UtcNow.AddSeconds(2) }); + var frozen = await _service.BuildSnapshotAsync(Dept, id, revision.RmsRevisionId); + frozen.Report.IncidentNumber.Should().NotBe("unfinalized correction"); + frozen.Modules.Single().DetailJson.Should().Contain("HYDRANT"); + frozen.Attachments.Should().ContainSingle(a => a.RmsRecordAttachmentId == "photo"); + frozen.Evidence.Single().ManifestJson.Should().Contain("Engine 5"); + await _service.OpenAmendmentAsync(Dept, "author", id); + var restored = await _service.AbandonAmendmentAsync(Dept, "author", id); + restored.Report.IncidentNumber.Should().Be(frozen.Report.IncidentNumber); + restored.Modules.Single().DetailJson.Should().Be(frozen.Modules.Single().DetailJson); + restored.Attachments.Should().ContainSingle(a => a.RmsRecordAttachmentId == "photo"); + restored.Evidence.Should().ContainSingle(e => e.RmsEvidenceArtifactId == "decision"); + await _service.VoidAsync(Dept, "author", id, "duplicate", "Duplicate incident"); + var voidSnapshot = await _service.BuildSnapshotAsync(Dept, id, _store.Reports.Single().CurrentRevisionId); + voidSnapshot.Attachments.Should().ContainSingle(a => a.RmsRecordAttachmentId == "photo"); + voidSnapshot.Evidence.Should().ContainSingle(e => e.RmsEvidenceArtifactId == "decision"); + } + /// A draft input carrying the aggregate's current values, so a test can change one thing and save. private static IncidentReportDraftInput DraftFrom(IncidentReportAggregate a) { diff --git a/Tests/Resgrid.Tests/Rms/RecordAttachmentHygieneTests.cs b/Tests/Resgrid.Tests/Rms/RecordAttachmentHygieneTests.cs index 4c7a45313..b40b3d5e6 100644 --- a/Tests/Resgrid.Tests/Rms/RecordAttachmentHygieneTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordAttachmentHygieneTests.cs @@ -74,7 +74,10 @@ public void Active_content_svg_and_undecodable_images_are_refused() [Test] public void File_names_are_reduced_to_their_leaf() { + // Both separators, on both hosts: Linux treats a backslash as an ordinary filename character, so the + // Windows-shaped name is the one that used to survive whole on the containers this actually runs on. RecordAttachmentHygiene.Sanitize(@"C:\temp\..\report.pdf", "application/pdf", new byte[] { 1 }).FileName.Should().Be("report.pdf"); + RecordAttachmentHygiene.Sanitize("/var/tmp/../report.pdf", "application/pdf", new byte[] { 1 }).FileName.Should().Be("report.pdf"); } private static byte[] ImageWithMetadata(Action> unused, string format) diff --git a/Tests/Resgrid.Tests/Rms/RecordAttachmentUploadServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordAttachmentUploadServiceTests.cs index bf29b25a7..9d0e3d838 100644 --- a/Tests/Resgrid.Tests/Rms/RecordAttachmentUploadServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordAttachmentUploadServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -8,6 +8,7 @@ using Moq; using NUnit.Framework; using Resgrid.Model; +using Resgrid.Model.Repositories; using Resgrid.Model.Services; using Resgrid.Services.Records; @@ -58,8 +59,8 @@ public void SetUp() .ReturnsAsync(new RecordAggregate { Record = new RmsOperationalRecord { RmsOperationalRecordId = RecordId, DepartmentId = Dept, State = (int)RmsRecordState.Draft } }); _records.Setup(r => r.GetAsync(Dept, "final", It.IsAny())) .ReturnsAsync(new RecordAggregate { Record = new RmsOperationalRecord { RmsOperationalRecordId = "final", DepartmentId = Dept, State = (int)RmsRecordState.Finalized } }); - _records.Setup(r => r.AddAttachmentAsync(Dept, "author", RecordId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((int d, string u, string rec, string name, string type, byte[] data, string desc, CancellationToken c) => + _records.Setup(r => r.AddAttachmentAsync(Dept, "author", RecordId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, string u, string rec, string name, string type, byte[] data, string desc, CancellationToken c, int classification) => { _stored.Add((name, data)); return new RmsRecordAttachment { RmsRecordAttachmentId = "att-1", RecordId = rec, FileName = name, ContentType = type, ByteSize = data.Length, Checksum = RecordSnapshotSerializer.Checksum(data), ScanState = (int)RmsAttachmentScanState.Skipped }; @@ -172,7 +173,7 @@ public async Task Another_user_cannot_see_or_append_to_the_session() public async Task Hygiene_rejection_surfaces_as_rejected_and_the_session_stays_open_for_a_retry() { var file = Bytes(10); - _records.Setup(r => r.AddAttachmentAsync(Dept, "author", RecordId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + _records.Setup(r => r.AddAttachmentAsync(Dept, "author", RecordId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new RecordAttachmentRejectedException("Attachment 'a.svg' is not an allowed type.")); var session = await _service.BeginAsync(Dept, "author", RecordId, "a.svg", "image/svg+xml", file.Length, RecordAttachmentUploadService.Sha256Hex(file)); await _service.AppendAsync(Dept, "author", session.UploadId, 0, file); @@ -186,7 +187,7 @@ public async Task Hygiene_rejection_surfaces_as_rejected_and_the_session_stays_o [Test] public async Task Idempotency_service_replays_by_department_user_command_and_key() { - var idempotency = new RecordsApiIdempotencyService(_store); + var idempotency = new RecordsApiIdempotencyService(_store, Mock.Of()); (await idempotency.TryGetRecordIdAsync(Dept, "u1", "k1", "Finalize")).Should().BeNull(); await idempotency.RememberAsync(Dept, "u1", "k1", "Finalize", "rec-9"); @@ -200,6 +201,25 @@ public async Task Idempotency_service_replays_by_department_user_command_and_key (await idempotency.TryGetRecordIdAsync(Dept, "u1", "k1", "Void")).Should().BeNull("keys are scoped to the command"); } + [Test] + public async Task Legacy_command_receipts_keep_request_identity_and_unbound_entries_cannot_be_treated_as_unused_keys() + { + var repository = new Mock(MockBehavior.Strict); + repository.Setup(r => r.GetAsync(It.IsAny(), It.IsAny())).ReturnsAsync((RecordCommandReceipt)null); + var idempotency = new RecordsApiIdempotencyService(_store, repository.Object); + await _store.SetAsync(RecordsApiIdempotencyService.Key(Dept, "officer", "key", "Finalize"), Newtonsoft.Json.JsonConvert.SerializeObject(new RecordCommandReceipt { RecordId = "record", RequestChecksum = "checksum" }), TimeSpan.FromHours(1)); + var receipt = await idempotency.TryGetCommandAsync(Dept, "officer", "key", "Finalize"); + receipt.RecordId.Should().Be("record"); receipt.RequestChecksum.Should().Be("checksum"); + (await idempotency.TryGetCommandAsync(Dept, "another-officer", "key", "Finalize")).Should().BeNull(); + (await idempotency.TryGetCommandAsync(Dept + 1, "officer", "key", "Finalize")).Should().BeNull(); + (await idempotency.TryGetCommandAsync(Dept, "officer", "key", "Cancel")).Should().BeNull(); + await idempotency.RememberAsync(Dept, "officer", "legacy", "Finalize", "old-record"); + var legacy = await idempotency.TryGetCommandAsync(Dept, "officer", "legacy", "Finalize"); + legacy.Should().NotBeNull(); legacy.RecordId.Should().Be("old-record"); legacy.RequestChecksum.Should().BeNull(); + (await idempotency.TryReserveCommandAsync(Dept, "officer", "key", "Finalize", "record", "checksum")).Should().BeFalse(); + (await idempotency.TryReserveCommandAsync(Dept, "officer", "legacy", "Finalize", "old-record", "checksum")).Should().BeFalse(); + } + [Test] public void Conflict_resolver_names_only_the_paths_the_stale_copy_would_change() { diff --git a/Tests/Resgrid.Tests/Rms/RecordEvidenceAdapterTests.cs b/Tests/Resgrid.Tests/Rms/RecordEvidenceAdapterTests.cs new file mode 100644 index 000000000..0523082a9 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordEvidenceAdapterTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using Resgrid.Services.Records.Evidence; + +namespace Resgrid.Tests.Rms +{ + [TestFixture] + public class RecordEvidenceAdapterTests + { + private static RecordEvidenceCaptureRequest Request() => new() { DepartmentId = 9, RecordId = "report", CapturedByUserId = "officer", CallId = 501, CaptureReason = "Officer selected supporting evidence", CoverageStart = new DateTime(2026,9,1,0,0,0,DateTimeKind.Utc), CoverageEnd = new DateTime(2026,9,1,1,0,0,DateTimeKind.Utc) }; + [Test] + public async Task Tracking_requires_unit_tenant_and_location_permission_and_freezes_only_fixes_in_the_window() + { + var source = new Mock(); var units = new Mock(); var auth = new Mock(); + units.Setup(u => u.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = 9 }); + var fix = new UnitLocation { UnitLocationId = 10, UnitId = 5, Timestamp = Request().CoverageStart.Value, Latitude = 38, Longitude = -119 }; + source.Setup(s => s.GetLastUnitLocationByUnitIdTimestampAsync(5, It.IsAny())).ReturnsAsync(() => fix); + var adapter = new TrackingFixEvidenceAdapter(source.Object, units.Object, new Lazy(() => auth.Object)); var request = Request(); request.UnitIds = new() { 5 }; + Func denied = () => adapter.CaptureAsync(request); await denied.Should().ThrowAsync(); + source.Verify(s => s.GetLastUnitLocationByUnitIdTimestampAsync(5, It.IsAny()), Times.Never); + auth.Setup(a => a.CanUserViewUnitLocationAsync("officer", 5, 9)).ReturnsAsync(true); + var capture = await adapter.CaptureAsync(request); capture.SourceItemCount.Should().Be(1); + var frozen = RecordsEvidenceService.Serialize(capture.Manifest); fix.Latitude = 0; + RecordsEvidenceService.Serialize(capture.Manifest).Should().Be(frozen).And.Contain("38"); + units.Setup(u => u.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { UnitId = 5, DepartmentId = 99 }); await denied.Should().ThrowAsync(); + } + [Test] + public async Task Chat_requires_channel_permission_and_rejects_deleted_moderated_foreign_and_revoked_messages() + { + var messages = new Mock(); var channels = new Mock(); var permission = new Mock(); + var channel = new ChatChannel { ChatChannelId = "channel", DepartmentId = 9, CallId = 501 }; + channels.Setup(c => c.GetByCallIdAsync(501)).ReturnsAsync(new[] { channel }); + var message = new ChatMessage { ChatMessageId = "message", ChatChannelId = "channel", DepartmentId = 9, Body = "Crew clear", SenderUserId = "member", MessageSeq = 42, SentOn = Request().CoverageStart.Value }; + messages.Setup(m => m.GetByIdAsync("message")).ReturnsAsync(() => message); + var adapter = new ChatPromotionEvidenceAdapter(messages.Object, channels.Object, new Lazy(() => permission.Object)); var request = Request(); request.SourceIds = new() { "message" }; + (await adapter.CaptureAsync(request)).Available.Should().BeFalse(); messages.Verify(m => m.GetByIdAsync("message"), Times.Never); + permission.Setup(p => p.CanAccessChannelAsync(channel, "officer", null)).ReturnsAsync(true); + var capture = await adapter.CaptureAsync(request); var frozen = RecordsEvidenceService.Serialize(capture.Manifest); capture.Classification.Should().Be(RmsEvidenceClassification.Restricted); + message.Body = "Changed later"; RecordsEvidenceService.Serialize(capture.Manifest).Should().Be(frozen).And.Contain("Crew clear"); + foreach (var mutate in new Action[] { () => message.DeletedOn = DateTime.UtcNow, () => { message.DeletedOn = null; message.IsModerated = true; }, () => { message.IsModerated = false; message.DepartmentId = 10; } }) + { mutate(); Func denied = () => adapter.CaptureAsync(request); await denied.Should().ThrowAsync(); } + message.DepartmentId = 9; + permission.SetupSequence(p => p.CanAccessChannelAsync(channel, "officer", null)).ReturnsAsync(true).ReturnsAsync(false); + Func revoked = () => adapter.CaptureAsync(request); await revoked.Should().ThrowAsync(); + } + [Test] + public async Task Certification_snapshot_requires_person_visibility_and_excludes_certificate_numbers_and_files() + { + var source = new Mock(); var auth = new Mock(); + var certificate = new PersonnelCertification { PersonnelCertificationId = 13, DepartmentId = 9, UserId = "member", Name = "Firefighter II", Number = "SECRET-NUMBER", Filename = "SECRET-FILE", Data = new byte[] { 1, 2 }, RecievedOn = new DateTime(2025,1,1), ExpiresOn = new DateTime(2027,1,1) }; + source.Setup(s => s.GetCertificationsByUserIdAsync("member")).ReturnsAsync(new List { certificate }); + var adapter = new CertificationSnapshotEvidenceAdapter(source.Object, Mock.Of(), new Lazy(() => auth.Object)); var request = Request(); request.UserIds = new() { "member" }; + Func denied = () => adapter.CaptureAsync(request); await denied.Should().ThrowAsync(); + source.Verify(s => s.GetCertificationsByUserIdAsync("member"), Times.Never); + auth.Setup(a => a.CanUserViewPersonAsync("officer", "member", 9)).ReturnsAsync(true); + var capture = await adapter.CaptureAsync(request); var frozen = RecordsEvidenceService.Serialize(capture.Manifest); + frozen.Should().Contain("Firefighter II").And.Contain("source_id").And.NotContain("SECRET").And.NotContain("AQI="); + certificate.Name = "Edited later"; RecordsEvidenceService.Serialize(capture.Manifest).Should().Be(frozen); + } + [Test] + public async Task Run_card_capture_rejects_other_department_and_call_rows_and_freezes_the_recorded_decision() + { + var source = new Mock(); + var decision = new RunCardActivation { RunCardActivationId = 7, DepartmentId = 9, CallId = 501, RunCardId = 3, ResultJson = "{\"selected\":[\"Engine 5\"],\"shortfall\":0}", CreatedOn = Request().CoverageStart.Value }; + source.Setup(s => s.GetActivationsByCallIdAsync(501)).ReturnsAsync(new[] { decision, new RunCardActivation { DepartmentId = 10, CallId = 501, ResultJson = "{\"secret\":true}" }, new RunCardActivation { DepartmentId = 9, CallId = 999, ResultJson = "{\"secret\":true}" } }); + var adapter = new RunCardActivationEvidenceAdapter(source.Object); var capture = await adapter.CaptureAsync(Request()); var frozen = RecordsEvidenceService.Serialize(capture.Manifest); + capture.SourceItemCount.Should().Be(1); frozen.Should().Contain("Engine 5").And.NotContain("secret"); + decision.ResultJson = "{}"; RecordsEvidenceService.Serialize(capture.Manifest).Should().Be(frozen); + } + + [Test] + public async Task Oversized_source_selections_are_rejected_instead_of_silently_producing_partial_evidence() + { + var request = Request(); request.UnitIds = Enumerable.Range(1, 21).ToList(); request.SourceIds = Enumerable.Range(1, 501).Select(i => i.ToString()).ToList(); request.UserIds = request.SourceIds; + var locations = new Mock(); var messages = new Mock(); var certifications = new Mock(); + var tracking = new TrackingFixEvidenceAdapter(locations.Object, Mock.Of(), new Lazy(() => Mock.Of())); + var chat = new ChatPromotionEvidenceAdapter(messages.Object, Mock.Of(), new Lazy(() => Mock.Of())); + var certificate = new CertificationSnapshotEvidenceAdapter(certifications.Object, Mock.Of(), new Lazy(() => Mock.Of())); + foreach (var adapter in new IRecordEvidenceAdapter[] { tracking, chat, certificate }) + { + Func capture = () => adapter.CaptureAsync(request); await capture.Should().ThrowAsync(); + } + locations.VerifyNoOtherCalls(); messages.VerifyNoOtherCalls(); certifications.VerifyNoOtherCalls(); + var activations = new Mock(); + activations.Setup(a => a.GetActivationsByCallIdAsync(501)).ReturnsAsync(Enumerable.Range(1, 501).Select(i => new RunCardActivation { DepartmentId = 9, CallId = 501, RunCardActivationId = i })); + Func runCards = () => new RunCardActivationEvidenceAdapter(activations.Object).CaptureAsync(Request()); await runCards.Should().ThrowAsync(); + var usage = new Mock(); var auth = new Mock(); + auth.Setup(a => a.CanUseSourceInventoryAsync("officer", 9, null)).ReturnsAsync(true); + usage.Setup(u => u.GetUsageForRecordAsync(9, "report")).ReturnsAsync(Enumerable.Range(1, 501).Select(i => new RmsInventoryUsage()).ToList()); + Func inventory = () => new InventoryUsageEvidenceAdapter(usage.Object, auth.Object).CaptureAsync(Request()); await inventory.Should().ThrowAsync(); + } + + [TestCase("{broken")] + [TestCase("{\"decision\":")] + public async Task Malformed_recorded_run_card_decisions_cannot_be_replaced_with_null_evidence(string json) + { + var source = new Mock(); + source.Setup(a => a.GetActivationsByCallIdAsync(501)).ReturnsAsync(new[] { new RunCardActivation { DepartmentId = 9, CallId = 501, ResultJson = json } }); + Func capture = () => new RunCardActivationEvidenceAdapter(source.Object).CaptureAsync(Request()); + await capture.Should().ThrowAsync().WithMessage("*unreadable*"); + } + + [Test] + public async Task Certifications_issued_after_the_incident_are_invalid_even_when_not_expired() + { + var source = new Mock(); var auth = new Mock(); + auth.Setup(a => a.CanUserViewPersonAsync("officer", "member", 9)).ReturnsAsync(true); + var request = Request(); request.UserIds = new() { "member" }; + source.Setup(s => s.GetCertificationsByUserIdAsync("member")).ReturnsAsync(new List + { + new() { PersonnelCertificationId = 1, DepartmentId = 9, UserId = "member", RecievedOn = request.CoverageEnd.Value.AddDays(1) }, + new() { PersonnelCertificationId = 2, DepartmentId = 9, UserId = "member", RecievedOn = request.CoverageStart.Value.AddYears(-1), ExpiresOn = request.CoverageEnd.Value.AddDays(-1) }, + new() { PersonnelCertificationId = 3, DepartmentId = 9, UserId = "member", RecievedOn = request.CoverageEnd, ExpiresOn = request.CoverageEnd } + }); + var capture = await new CertificationSnapshotEvidenceAdapter(source.Object, Mock.Of(), new Lazy(() => auth.Object)).CaptureAsync(request); + var items = JObject.Parse(RecordsEvidenceService.Serialize(capture.Manifest))["people"][0]["certifications"]; + items.Select(i => (bool)i["valid_at_incident"]).Should().Equal(false, false, true); + } + + [Test] + public async Task Chat_channel_moved_to_another_incident_during_capture_is_rejected() + { + var messages = new Mock(); var channels = new Mock(); var permission = new Mock(); + var channel = new ChatChannel { ChatChannelId = "channel", DepartmentId = 9, CallId = 501 }; + channels.Setup(c => c.GetByCallIdAsync(501)).ReturnsAsync(new[] { channel }); + permission.Setup(p => p.CanAccessChannelAsync(channel, "officer", null)).ReturnsAsync(true); + messages.Setup(m => m.GetByIdAsync("message")).Callback(() => channel.CallId = 999).ReturnsAsync(new ChatMessage { ChatMessageId = "message", ChatChannelId = "channel", DepartmentId = 9, Body = "Crew clear" }); + var request = Request(); request.SourceIds = new() { "message" }; + Func capture = () => new ChatPromotionEvidenceAdapter(messages.Object, channels.Object, new Lazy(() => permission.Object)).CaptureAsync(request); + await capture.Should().ThrowAsync(); + } + + [TestCase(-1)] + [TestCase(25)] + public async Task Tracking_rejects_reversed_or_overlong_windows_before_reading_locations(int hours) + { + var locations = new Mock(); var request = Request(); request.UnitIds = new() { 5 }; + request.CoverageEnd = request.CoverageStart.Value.AddHours(hours); + var adapter = new TrackingFixEvidenceAdapter(locations.Object, Mock.Of(), new Lazy(() => Mock.Of())); + Func capture = () => adapter.CaptureAsync(request); + await capture.Should().ThrowAsync(); locations.VerifyNoOtherCalls(); + } + + [Test] + public async Task Tracking_windows_and_certification_people_and_times_have_distinct_bounded_identities() + { + var request = Request(); request.UnitIds = new() { 5 }; + var units = new Mock(); units.Setup(u => u.GetUnitByIdAsync(5)).ReturnsAsync(new Unit { DepartmentId = 9, UnitId = 5 }); + var auth = new Mock(); auth.Setup(a => a.CanUserViewUnitLocationAsync("officer", 5, 9)).ReturnsAsync(true); + var locations = new Mock(); locations.Setup(l => l.GetLastUnitLocationByUnitIdTimestampAsync(5, It.IsAny())) + .ReturnsAsync((int unit, DateTime at) => new UnitLocation { UnitId = unit, UnitLocationId = 1, Timestamp = at }); + var tracking = new TrackingFixEvidenceAdapter(locations.Object, units.Object, new Lazy(() => auth.Object)); + var first = await tracking.CaptureAsync(request); request.CoverageEnd = request.CoverageEnd.Value.AddHours(1); + (await tracking.CaptureAsync(request)).SourceEntityId.Should().NotBe(first.SourceEntityId); + var certificates = new Mock(); + certificates.Setup(c => c.GetCertificationsByUserIdAsync(It.IsAny())).ReturnsAsync((string id) => new List { new() { DepartmentId = 9, UserId = id } }); + auth.Setup(a => a.CanUserViewPersonAsync("officer", It.IsAny(), 9)).ReturnsAsync(true); + var adapter = new CertificationSnapshotEvidenceAdapter(certificates.Object, Mock.Of(), new Lazy(() => auth.Object)); + request.UserIds = new() { "one" }; var one = await adapter.CaptureAsync(request); + request.UserIds = new() { "two" }; var two = await adapter.CaptureAsync(request); + request.UserIds = new() { "one" }; request.CoverageEnd = request.CoverageEnd.Value.AddDays(1); var later = await adapter.CaptureAsync(request); + new[] { one.SourceEntityId, two.SourceEntityId, later.SourceEntityId }.Distinct().Should().HaveCount(3); + one.SourceEntityId.Length.Should().BeLessThan(200); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordEvidenceApiControllerTests.cs b/Tests/Resgrid.Tests/Rms/RecordEvidenceApiControllerTests.cs new file mode 100644 index 000000000..b3cf0a5c6 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordEvidenceApiControllerTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Services.Records; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Records; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Rms +{ + [TestFixture, NonParallelizable] + public class RecordEvidenceApiControllerTests + { + private const int Department = 42; + private const string Officer = "officer"; + private Mock _evidence; + private Mock _authorization; + private Mock _idempotency; + private RecordEvidenceController _controller; + private DefaultHttpContext _http; + private Activity _activity; + + [SetUp] + public void Setup() + { + _evidence = new Mock(); + _authorization = new Mock(); + _authorization.Setup(a => a.CanUserViewRecordAsync(Officer, It.IsAny(), Department)).ReturnsAsync(true); + _authorization.Setup(a => a.HasPermissionAsync(Officer, Department, It.IsAny())).ReturnsAsync(true); + _idempotency = new Mock(); + var cutover = new Mock(); + cutover.Setup(c => c.GetModuleStateAsync(Department, It.IsAny())).ReturnsAsync(new RecordsModuleState + { DepartmentId = Department, FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active, LegacyWritesBlocked = true }); + _http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, Officer), new Claim(ClaimTypes.PrimaryGroupSid, Department.ToString()), + new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.View), + new Claim(ResgridClaimTypes.Resources.Record, ResgridClaimTypes.Actions.Create), + new Claim(ResgridClaimTypes.Resources.RecordRestricted, ResgridClaimTypes.Actions.View) + }, "test")) }; + _http.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; + _activity = new Activity(nameof(RecordEvidenceApiControllerTests)).Start(); + _controller = new RecordEvidenceController(_evidence.Object, cutover.Object, _authorization.Object, + Mock.Of(), _idempotency.Object) { ControllerContext = new ControllerContext { HttpContext = _http } }; + } + + [TearDown] public void Cleanup() => _activity?.Stop(); + + private static CaptureRecordEvidenceInput Input() => new() { RecordId = "report", ExpectedRowVersion = 7, + Kind = (int)RmsEvidenceKind.ChatPromotion, SourceIds = new() { "message" }, CaptureReason = "Officer selected supporting evidence", OriginClient = (int)RmsOriginClient.Api, IdempotencyKey = "capture-1" }; + private static RmsEvidenceArtifact Artifact(CaptureRecordEvidenceInput input) => new() + { + RmsEvidenceArtifactId = "artifact", DepartmentId = Department, RecordId = input.RecordId, + CapturedByUserId = Officer, Classification = (int)RmsEvidenceClassification.Restricted, + Title = "Sensitive channel", CaptureReason = "Sensitive reason", SourceEntityId = "sensitive-channel", + ManifestJson = "{\"body\":\"Sensitive message\"}", Checksum = "sensitive-hash", + CaptureRequestChecksum = RecordsEvidenceService.ComputeRequestChecksum(RecordEvidenceApiMapper.ToCaptureRequest(input, Department, Officer, RmsOriginClient.Api)) + }; + private void Replay(RmsEvidenceArtifact artifact) + { + _idempotency.Setup(i => i.TryGetRecordIdAsync(Department, Officer, "capture-1", "Capture")).ReturnsAsync("artifact"); + _evidence.Setup(e => e.GetAsync(Department, "artifact")).ReturnsAsync(artifact); + } + + [Test] + public async Task Capture_requires_a_parent_version_before_reading_or_mutating_evidence() + { + var input = Input(); input.ExpectedRowVersion = null; + var result = await _controller.Capture(input, default); + result.Result.Should().BeOfType().Which.StatusCode.Should().Be(428); + _evidence.VerifyNoOtherCalls(); _idempotency.VerifyNoOtherCalls(); + } + + [Test] + public async Task Capture_accepts_IfMatch_and_passes_the_server_actor_and_department() + { + var input = Input(); var artifact = Artifact(input); input.ExpectedRowVersion = null; _http.Request.Headers.IfMatch = "\"7\""; + _evidence.Setup(e => e.CaptureAsync(It.IsAny(), true, It.IsAny())).ReturnsAsync(artifact); + (await _controller.Capture(input, default)).Result.Should().BeOfType().Which.StatusCode.Should().Be(201); + _evidence.Verify(e => e.CaptureAsync(It.Is(r => r.DepartmentId == Department && r.CapturedByUserId == Officer && r.ExpectedRowVersion == 7), true, It.IsAny()), Times.Once); + } + + [TestCase("record")] + [TestCase("actor")] + [TestCase("reason")] + [TestCase("selection")] + [TestCase("version")] + [TestCase("legacy")] + public async Task Replay_rejects_a_different_capture_or_an_unbound_legacy_artifact(string change) + { + var input = Input(); var artifact = Artifact(input); Replay(artifact); + switch (change) + { + case "record": artifact.RecordId = "another-report"; break; + case "actor": artifact.CapturedByUserId = "another-officer"; break; + case "reason": input.CaptureReason = "Changed reason"; break; + case "selection": input.SourceIds.Add("another-message"); break; + case "version": input.ExpectedRowVersion++; break; + case "legacy": artifact.CaptureRequestChecksum = null; break; + } + (await _controller.Capture(input, default)).Result.Should().BeOfType(); + _evidence.Verify(e => e.CaptureAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Replay_rechecks_access_to_the_original_artifact_and_never_returns_it_after_revocation() + { + var input = Input(); Replay(Artifact(input)); + _authorization.SetupSequence(a => a.CanUserViewRecordAsync(Officer, "report", Department)).ReturnsAsync(true).ReturnsAsync(false); + (await _controller.Capture(input, default)).Result.Should().BeOfType(); + } + + [Test] + public async Task Exact_replay_returns_the_original_capture_without_a_second_write() + { + var input = Input(); Replay(Artifact(input)); + var result = (await _controller.Capture(input, default)).Result.Should().BeOfType().Subject; + result.Value.Should().BeOfType().Which.Data.ManifestJson.Should().Contain("Sensitive message"); + _evidence.Verify(e => e.CaptureAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Stale_restricted_claims_do_not_expose_manifest_or_provenance_through_read_list_replay_or_verify() + { + var input = Input(); var artifact = Artifact(input); Replay(artifact); + _authorization.Setup(a => a.HasPermissionAsync(Officer, Department, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + _evidence.Setup(e => e.GetForRecordAsync(Department, "report", null, false)).ReturnsAsync(new List { artifact }); + var read = (await _controller.GetArtifact("artifact")).Result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject.Data; + var replay = (await _controller.Capture(input, default)).Result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject.Data; + var list = (await _controller.GetEvidence("report", null)).Result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject.Data[0]; + foreach (var data in new[] { read, replay, list }) + { + data.ManifestWithheld.Should().BeTrue(); data.Title.Should().Be("Restricted evidence"); + data.ManifestJson.Should().BeNull(); data.SourceEntityId.Should().BeNull(); data.CapturedByUserId.Should().BeNull(); + data.Checksum.Should().BeNull(); data.CaptureReason.Should().BeNull(); + } + (await _controller.Verify("artifact")).Result.Should().BeOfType(); + } + + [Test] + public async Task Verify_and_manifest_reads_recheck_record_access_after_the_last_awaited_permission_or_source_read() + { + Replay(Artifact(Input())); var allowed = true; + _authorization.Setup(a => a.CanUserViewRecordAsync(Officer, "report", Department)).ReturnsAsync(() => allowed); + _evidence.Setup(e => e.VerifyAsync(Department, "artifact")).Callback(() => allowed = false).ReturnsAsync(true); + (await _controller.Verify("artifact")).Result.Should().BeOfType(); + allowed = true; + _authorization.Setup(a => a.HasPermissionAsync(Officer, Department, PermissionTypes.ViewRestrictedRecords)).Callback(() => allowed = false).ReturnsAsync(true); + (await _controller.GetArtifact("artifact")).Result.Should().BeOfType(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordEvidenceSelectionServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordEvidenceSelectionServiceTests.cs new file mode 100644 index 000000000..f9314a591 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordEvidenceSelectionServiceTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + [TestFixture] + public class RecordEvidenceSelectionServiceTests + { + private Mock _auth; + private Mock _sourceAuth; + private Mock _chat; + private Mock _channels; + private Mock _messages; + private Mock _units; + private Mock _departments; + private RmsOperationalRecord _record; + private ChatChannel _channel; + private RecordEvidenceSelectionService _service; + + [SetUp] + public void Setup() + { + _record = new RmsOperationalRecord { DepartmentId = 9, RmsOperationalRecordId = "record", AuthorUserId = "officer", State = (int)RmsRecordState.Draft, RowVersion = 7, CallId = 501 }; + var records = new Mock(); records.Setup(r => r.GetByIdForDepartmentAsync(9, "record")).ReturnsAsync(() => _record); + var incidents = new Mock(); incidents.Setup(r => r.GetByIdForDepartmentAsync(9, "incident")).ReturnsAsync(new RmsIncidentReport { + DepartmentId = 9, RmsIncidentReportId = "incident", AuthorUserId = "officer", State = (int)RmsRecordState.Draft, RowVersion = 8, CallId = 501 }); + _auth = new(); _auth.Setup(a => a.CanUserViewRecordAsync("officer", It.IsAny(), 9)).ReturnsAsync(true); + _auth.Setup(a => a.HasPermissionAsync("officer", 9, It.IsAny())).ReturnsAsync(true); + _auth.Setup(a => a.CanReadSourceCallAsync("officer", 9, It.IsAny())).ReturnsAsync(true); + _sourceAuth = new(); _chat = new(); _channels = new(); _messages = new(); _units = new(); _departments = new(); + var cutover = new Mock(); cutover.Setup(c => c.GetModuleStateAsync(9, false)).ReturnsAsync(new RecordsModuleState { + FlagEnabled = true, Activated = true, CutoverState = RmsDepartmentCutoverState.Active }); + var evidence = new Mock(); evidence.Setup(e => e.GetSourceStatesAsync(9)).ReturnsAsync(Enum.GetValues() + .Select(k => new RecordEvidenceSourceState { Kind = k, Available = k != RmsEvidenceKind.ReadinessPacket, Reason = "Unavailable dependency" }).ToList()); + _channel = new ChatChannel { DepartmentId = 9, CallId = 501, ChatChannelId = "channel", Name = "Incident" }; + _channels.Setup(c => c.GetByCallIdAsync(501)).ReturnsAsync(new[] { _channel }); + _chat.Setup(c => c.CanAccessChannelAsync(_channel, "officer", null)).ReturnsAsync(true); + _service = new RecordEvidenceSelectionService(records.Object, incidents.Object, _auth.Object, cutover.Object, evidence.Object, + Mock.Of(), _units.Object, _departments.Object, new Lazy(() => _sourceAuth.Object), + _channels.Object, _messages.Object, new Lazy(() => _chat.Object)); + } + private Task Select(RmsEvidenceKind source, string channel = null) => + _service.GetAsync(9, "officer", "record", RmsRecordKind.Operational, source, channel); + + [TestCase("record", RmsRecordKind.Operational)] + [TestCase("incident", RmsRecordKind.IncidentReport)] + public async Task Both_officer_record_kinds_offer_only_source_authorized_personnel(string id, RmsRecordKind kind) + { + _departments.Setup(d => d.GetAllPersonnelNamesForDepartmentAsync(9)).ReturnsAsync(new List { + new() { UserId = "visible", FirstName = "Visible", LastName = "Member" }, new() { UserId = "hidden", FirstName = "Sensitive", LastName = "Person" } }); + _sourceAuth.Setup(a => a.CanUserViewPersonAsync("officer", "VISIBLE", 9)).ReturnsAsync(true); + var selection = await _service.GetAsync(9, "officer", id, kind, RmsEvidenceKind.CertificationSnapshot); + selection.Choices.Should().ContainSingle().Which.Label.Should().Be("Visible Member"); + selection.Context.RecordKind.Should().Be(kind); selection.Context.CanCapture.Should().BeTrue(); + } + + [Test] + public async Task Unit_choices_hide_foreign_and_denied_units_and_recheck_earlier_permissions() + { + _units.Setup(u => u.GetUnitsForDepartmentAsync(9)).ReturnsAsync(new List { + new() { DepartmentId = 9, UnitId = 1, Name = "Engine 1" }, new() { DepartmentId = 9, UnitId = 2, Name = "Hidden" }, new() { DepartmentId = 10, UnitId = 3, Name = "Foreign" } }); + _sourceAuth.Setup(a => a.CanUserViewUnitLocationAsync("officer", 1, 9)).ReturnsAsync(true); + (await Select(RmsEvidenceKind.TrackingFix)).Choices.Should().ContainSingle().Which.Id.Should().Be("1"); + _sourceAuth.SetupSequence(a => a.CanUserViewUnitLocationAsync("officer", 1, 9)).ReturnsAsync(true).ReturnsAsync(false); + Func revoked = () => Select(RmsEvidenceKind.TrackingFix); await revoked.Should().ThrowAsync(); + } + + [Test] + public async Task Chat_pages_include_replies_and_advance_past_deleted_moderated_and_foreign_rows_without_exposing_them() + { + var rows = Enumerable.Range(1, 101).Select(i => new ChatMessage { ChatMessageId = i.ToString(), DepartmentId = 9, ChatChannelId = "channel", + MessageSeq = i, Body = "Message " + i, ThreadRootMessageId = "parent" }).ToList(); + rows[0].DeletedOn = DateTime.UtcNow; rows[1].IsModerated = true; rows[2].DepartmentId = 10; rows[3].ChatChannelId = "other"; + _messages.Setup(m => m.GetAfterSeqAsync("channel", 0, 101)).ReturnsAsync(rows); + var selection = await Select(RmsEvidenceKind.ChatPromotion, "channel"); + selection.NextSequence.Should().Be(100); selection.Choices.Should().HaveCount(96); + selection.Choices.Select(c => c.Id).Should().NotContain(new[] { "1", "2", "3", "4", "101" }); + selection.Choices.Last().Body.Should().Be("Message 100"); + } + + [TestCase("record")] + [TestCase("call")] + [TestCase("restricted")] + [TestCase("owner")] + [TestCase("finalized")] + public async Task Selection_is_denied_before_reading_message_bodies_when_a_required_grant_or_edit_state_is_missing(string boundary) + { + if (boundary == "record") _auth.Setup(a => a.CanUserViewRecordAsync("officer", "record", 9)).ReturnsAsync(false); + if (boundary == "call") _auth.Setup(a => a.CanReadSourceCallAsync("officer", 9, It.IsAny())).ReturnsAsync(false); + if (boundary == "restricted") _auth.Setup(a => a.HasPermissionAsync("officer", 9, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + if (boundary == "owner") _record.AuthorUserId = "another-officer"; + if (boundary == "finalized") _record.State = (int)RmsRecordState.Finalized; + Func denied = () => Select(RmsEvidenceKind.ChatPromotion, "channel"); await denied.Should().ThrowAsync(); + _messages.VerifyNoOtherCalls(); + } + + [TestCase("channel")] + [TestCase("rebind")] + [TestCase("restricted")] + [TestCase("record")] + public async Task Access_loss_during_the_source_read_cannot_return_earlier_message_bodies(string boundary) + { + _messages.Setup(m => m.GetAfterSeqAsync("channel", 0, 101)).Callback(() => { + if (boundary == "channel") _chat.Setup(c => c.CanAccessChannelAsync(_channel, "officer", null)).ReturnsAsync(false); + if (boundary == "rebind") _channel.CallId = 999; + if (boundary == "restricted") _auth.Setup(a => a.HasPermissionAsync("officer", 9, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + if (boundary == "record") _auth.Setup(a => a.CanUserViewRecordAsync("officer", "record", 9)).ReturnsAsync(false); + }).ReturnsAsync(new[] { new ChatMessage { DepartmentId = 9, ChatChannelId = "channel", Body = "Sensitive" } }); + Func denied = () => Select(RmsEvidenceKind.ChatPromotion, "channel"); await denied.Should().ThrowAsync(); + } + + [Test] + public async Task A_forged_channel_and_a_concurrently_changed_parent_never_return_a_capture_form() + { + Func forged = () => Select(RmsEvidenceKind.ChatPromotion, "another-channel"); await forged.Should().ThrowAsync(); + _messages.VerifyNoOtherCalls(); + _messages.Setup(m => m.GetAfterSeqAsync("channel", 0, 101)).Callback(() => _record.RowVersion++).ReturnsAsync(Array.Empty()); + Func stale = () => Select(RmsEvidenceKind.ChatPromotion, "channel"); await stale.Should().ThrowAsync(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordEvidenceWebControllerTests.cs b/Tests/Resgrid.Tests/Rms/RecordEvidenceWebControllerTests.cs new file mode 100644 index 000000000..cab8fe748 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordEvidenceWebControllerTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services.Records; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Records; + +namespace Resgrid.Tests.Rms +{ + [TestFixture, NonParallelizable] + public class RecordEvidenceWebControllerTests + { + private Mock _selection; + private Mock _evidence; + private Mock _records; + private RecordEvidenceContext _context; + private RecordEvidenceController _controller; + [SetUp] + public void Setup() + { + _selection = new(); _evidence = new(); _records = new(); + _context = new RecordEvidenceContext { RecordId = "record", RecordKind = RmsRecordKind.Operational, RowVersion = 7, CallId = 501, CanCapture = true, CanViewRestricted = true, CanExport = true }; + _selection.Setup(s => s.GetContextAsync(9, "officer", "record", RmsRecordKind.Operational)).ReturnsAsync(() => _context); + var http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { + new Claim(ClaimTypes.PrimarySid, "officer"), new Claim(ClaimTypes.PrimaryGroupSid, "9") }, "test")) }; + http.Connection.RemoteIpAddress = IPAddress.Loopback; + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = http }; + _controller = new RecordEvidenceController(_selection.Object, _evidence.Object, _records.Object) { + ControllerContext = new ControllerContext { HttpContext = http }, TempData = new TempDataDictionary(http, Mock.Of()) }; + } + [TearDown] public void Cleanup() => Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = null; + private static RecordEvidenceForm Input() => new() { RecordId = "record", RecordKind = RmsRecordKind.Operational, + RowVersion = 7, SourceKind = RmsEvidenceKind.ChatPromotion, SourceIds = new() { "one", "two" }, CaptureReason = "Selected incident messages" }; + private static RmsEvidenceArtifact Artifact() => new() { DepartmentId = 9, RecordId = "record", RecordKind = (int)RmsRecordKind.Operational, + RmsEvidenceArtifactId = "artifact", RevisionId = "signed", Classification = (int)RmsEvidenceClassification.Restricted, + Title = "Sensitive title", CaptureReason = "Sensitive reason", SourceVersion = "secret-source-version", ManifestJson = "{\"body\":\"Selected message\"}", Checksum = RecordSnapshotSerializer.Checksum("{\"body\":\"Selected message\"}") }; + + [Test] + public async Task Missing_and_stale_form_versions_cannot_capture_or_silently_adopt_the_current_draft() + { + var input = Input(); input.RowVersion = null; + (await _controller.Capture(input, default)).Should().BeOfType().Which.StatusCode.Should().Be(428); + input.RowVersion = 6; (await _controller.Capture(input, default)).Should().BeOfType(); + _controller.TempData["EvidenceMessage"].ToString().Should().Contain("draft changed"); + _evidence.VerifyNoOtherCalls(); + } + + [TestCase(RmsEvidenceKind.CertificationSnapshot)] + [TestCase(RmsEvidenceKind.ChatPromotion)] + [TestCase(RmsEvidenceKind.TrackingFix)] + public async Task No_selection_cannot_fall_back_to_all_record_participants(RmsEvidenceKind kind) + { + var input = Input(); input.SourceKind = kind; input.SourceIds.Clear(); + (await _controller.Capture(input, default)).Should().BeOfType(); _evidence.VerifyNoOtherCalls(); + } + + [Test] + public async Task Capture_uses_posted_selection_and_version_with_server_actor_tenant_call_and_utc() + { + var input = Input(); input.StartUtc = new DateTime(2026, 9, 1, 8, 0, 0); input.EndUtc = input.StartUtc.Value.AddHours(1); + (await _controller.Capture(input, default)).Should().BeOfType(); + _evidence.Verify(e => e.CaptureAsync(It.Is(r => r.DepartmentId == 9 && r.CapturedByUserId == "officer" && r.CallId == 501 + && r.ExpectedRowVersion == 7 && r.SourceIds.Count == 2 && r.CoverageStart.Value.Kind == DateTimeKind.Utc && r.OriginClient == RmsOriginClient.Web), true, It.IsAny()), Times.Once); + } + + [Test] + public async Task Signed_evidence_history_uses_all_revision_query_and_restricts_the_entire_metadata_entry_after_audit() + { + _evidence.Setup(e => e.GetHistoryAsync(9, "record", 0, 51)).ReturnsAsync(new List { Artifact() }); + _records.Setup(r => r.RecordAccessAsync(9, "officer", "record", null, RmsAccessAuditAction.Read, It.IsAny(), It.IsAny(), RmsOriginClient.Web)) + .Callback(() => _context.CanViewRestricted = false).Returns(Task.CompletedTask); + var result = (ViewResult)await _controller.Index("record", RmsRecordKind.Operational); + var entry = ((RecordEvidenceView)result.Model).Artifacts.Should().ContainSingle().Which; + entry.Withheld.Should().BeTrue(); entry.Id.Should().BeNull(); entry.Title.Should().BeNull(); entry.Reason.Should().BeNull(); entry.Checksum.Should().BeNull(); entry.RevisionId.Should().BeNull(); entry.SourceVersion.Should().BeNull(); + } + + [TestCase("restricted")] + [TestCase("export")] + [TestCase("record")] + [TestCase("purged")] + [TestCase("checksum")] + [TestCase("tenant")] + public async Task Manifest_rechecks_grants_parent_and_integrity_after_audit_before_returning_bytes(string boundary) + { + var artifact = Artifact(); _evidence.Setup(e => e.GetAsync(9, "artifact")).ReturnsAsync(() => artifact); + _records.Setup(r => r.RecordAccessAsync(9, "officer", "record", "signed", RmsAccessAuditAction.Export, It.IsAny(), It.IsAny(), RmsOriginClient.Web)) + .Callback(() => { + if (boundary == "restricted") _context.CanViewRestricted = false; + if (boundary == "export") _context.CanExport = false; + if (boundary == "record") _selection.Setup(s => s.GetContextAsync(9, "officer", "record", RmsRecordKind.Operational)).ThrowsAsync(new UnauthorizedAccessException()); + if (boundary == "purged") artifact = null; + if (boundary == "checksum") artifact.ManifestJson = "{}"; + if (boundary == "tenant") artifact.DepartmentId = 10; + }).Returns(Task.CompletedTask); + var result = await _controller.Manifest("record", RmsRecordKind.Operational, "artifact"); + if (boundary == "checksum") result.Should().BeOfType().Which.StatusCode.Should().Be(409); + else result.Should().BeOfType(); + } + + [Test] + public async Task A_verified_signed_manifest_can_be_retrieved_and_is_not_cached() + { + var artifact = Artifact(); _evidence.Setup(e => e.GetAsync(9, "artifact")).ReturnsAsync(artifact); + var file = (FileContentResult)await _controller.Manifest("record", RmsRecordKind.Operational, "artifact"); + System.Text.Encoding.UTF8.GetString(file.FileContents).Should().Be(artifact.ManifestJson); + _controller.Response.Headers.CacheControl.ToString().Should().Be("no-store"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordNarrativeFormatterTests.cs b/Tests/Resgrid.Tests/Rms/RecordNarrativeFormatterTests.cs new file mode 100644 index 000000000..e5c2def81 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordNarrativeFormatterTests.cs @@ -0,0 +1,30 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Framework; + +namespace Resgrid.Tests.Rms +{ + [TestFixture] + public class RecordNarrativeFormatterTests + { + [TestCase("

Safe text

")] + [TestCase("

Safe text

")] + public void Rich_text_keeps_basic_formatting_and_drops_active_content_attributes_and_external_resources(string input) + { + var html = RecordNarrativeFormatter.ForStorage(input); + html.Should().Contain("text").And.NotContain("onclick").And.NotContain("img").And.NotContain("script").And.NotContain("secret").And.NotContain("evil").And.NotContain("iframe"); + RecordNarrativeFormatter.Render(html).Should().Be(html); + } + [TestCase("


")] + [TestCase("

 

")] + [TestCase("")] + public void Empty_rich_text_does_not_satisfy_required_narrative(string input) => RecordNarrativeFormatter.HasText(input).Should().BeFalse(); + [Test] + public void Existing_plain_text_and_comparisons_keep_their_stored_value() + { + const string text = "Crew: 2 < 3 & 5 > 4\nAll safe."; + RecordNarrativeFormatter.ForStorage(text).Should().Be(text); + RecordNarrativeFormatter.Render(text).Should().Contain("<").And.Contain("&"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsAuthorizationTests.cs b/Tests/Resgrid.Tests/Rms/RecordsAuthorizationTests.cs new file mode 100644 index 000000000..317860af9 --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsAuthorizationTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Records; + +namespace Resgrid.Tests.Rms +{ + [TestFixture] + public class RecordsAuthorizationTests + { + private Mock _departments; + private Mock _permissions; + private Mock _groups; + private Mock _roles; + private Mock _settings; + private Mock _scopes; + private DepartmentMember _member; + private RecordsAuthorizationService _service; + + [SetUp] + public void SetUp() + { + _member = new DepartmentMember { DepartmentId = 9, UserId = "author" }; + _departments = new Mock(); + _departments.Setup(d => d.GetDepartmentMemberAsync("author", 9, true)).ReturnsAsync(() => _member); + _departments.Setup(d => d.GetDepartmentByIdAsync(9, It.IsAny())).ReturnsAsync(new Department { DepartmentId = 9, ManagingUserId = "admin" }); + _permissions = new Mock(); + _groups = new Mock(); + _roles = new Mock(); + _settings = new Mock(); + _scopes = new Mock(); + var records = new Mock(); + records.Setup(r => r.GetByIdForDepartmentAsync(9, "r1")).ReturnsAsync(new RmsOperationalRecord { RmsOperationalRecordId = "r1", DepartmentId = 9, AuthorUserId = "author" }); + _service = new RecordsAuthorizationService(_permissions.Object, _departments.Object, _groups.Object, _roles.Object, + _settings.Object, records.Object, _scopes.Object, Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), new Lazy(() => Mock.Of())); + } + + [Test] + public async Task Removed_or_disabled_authors_do_not_keep_the_author_visibility_exception() + { + (await _service.CanUserViewRecordAsync("author", "r1", 9)).Should().BeTrue(); + _member.IsDisabled = true; + (await _service.CanUserViewRecordAsync("author", "r1", 9)).Should().BeFalse(); + _member.IsDisabled = false; + _member.IsDeleted = true; + (await _service.CanUserViewRecordAsync("author", "r1", 9)).Should().BeFalse(); + _member = null; + (await _service.GetVisibleGroupIdsAsync("author", 9)).Should().BeEmpty(); + } + + [Test] + public async Task Missing_permission_rows_use_the_RMS_default_and_grant_revocation_is_immediate() + { + (await _service.HasPermissionAsync("author", 9, PermissionTypes.CreateRecord)).Should().BeTrue(); + (await _service.HasPermissionAsync("author", 9, PermissionTypes.ViewRestrictedRecords)).Should().BeFalse(); + _member.IsAdmin = true; + (await _service.HasPermissionAsync("author", 9, PermissionTypes.ViewRestrictedRecords)).Should().BeTrue(); + _member.IsAdmin = false; + (await _service.HasPermissionAsync("author", 9, PermissionTypes.ViewRestrictedRecords)).Should().BeFalse(); + (await _service.HasPermissionAsync(null, 9, PermissionTypes.CreateRecord)).Should().BeFalse(); + } + + [Test] + public async Task Author_exception_and_cache_scope_require_active_membership() + { + _member.IsDisabled = true; + (await _service.CanUserViewRecordAsync("author", "r1", 9)).Should().BeFalse(); + (await _service.GetReadScopeStampAsync("author", 9)).Should().BeNull(); + } + + [Test] + public async Task Scope_stamp_tracks_admin_roles_and_permission_policy_even_in_department_wide_mode() + { + var initial = await _service.GetReadScopeStampAsync("author", 9); + initial.Should().NotBeNullOrWhiteSpace(); + _member.IsAdmin = true; + (await _service.GetReadScopeStampAsync("author", 9)).Should().NotBe(initial); + _member.IsAdmin = false; + _roles.Setup(r => r.GetRolesForUserAsync("author", 9)).ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = 7 } }); + (await _service.GetReadScopeStampAsync("author", 9)).Should().NotBe(initial); + _roles.Setup(r => r.GetRolesForUserAsync("author", 9)).ReturnsAsync(new List()); + _permissions.Setup(p => p.GetAllPermissionsForDepartmentAsync(9)).ReturnsAsync(new List + { + new Permission { PermissionType = (int)PermissionTypes.ViewRestrictedRecords, Action = (int)PermissionActions.DepartmentAdminsOnly } + }); + (await _service.GetReadScopeStampAsync("author", 9)).Should().NotBe(initial); + } + + [Test] + public async Task An_expired_or_revoked_share_changes_cache_scope_without_modifying_a_report() + { + _settings.Setup(s => s.GetRecordsGroupVisibilityModeAsync(9, It.IsAny())).ReturnsAsync(RecordsGroupVisibilityMode.GroupScoped); + _permissions.Setup(p => p.GetPermissionByDepartmentTypeAsync(9, PermissionTypes.ViewGroupRecords)) + .ReturnsAsync(new Permission { PermissionType = (int)PermissionTypes.ViewGroupRecords, Action = (int)PermissionActions.Everyone, LockToGroup = true }); + _groups.Setup(g => g.GetGroupForUserAsync("author", 9)).ReturnsAsync(new DepartmentGroup { DepartmentId = 9, DepartmentGroupId = 27 }); + var shares = new List { new RmsRecordShare { RmsRecordShareId = "share", DepartmentId = 9, RecordId = "r2", DepartmentGroupId = 27, RowVersion = 1, ExpiresOn = DateTime.UtcNow.AddHours(1) } }; + _scopes.Setup(s => s.GetEffectiveSharesAsync(9, It.IsAny>())).ReturnsAsync(() => shares); + var initial = await _service.GetReadScopeStampAsync("author", 9); + initial.Should().NotBeNullOrWhiteSpace(); + shares.Clear(); + (await _service.GetReadScopeStampAsync("author", 9)).Should().NotBe(initial); + _scopes.Setup(s => s.GetEffectiveSharesAsync(9, It.IsAny>())).ThrowsAsync(new InvalidOperationException("share lookup unavailable")); + (await _service.GetReadScopeStampAsync("author", 9)).Should().BeNull(); + } + + [Test] + public async Task Scope_stamp_is_order_independent_and_refuses_unavailable_membership_sources() + { + _roles.Setup(r => r.GetRolesForUserAsync("author", 9)).ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = 2 }, new PersonnelRole { PersonnelRoleId = 1 } }); + var initial = await _service.GetReadScopeStampAsync("author", 9); + _roles.Setup(r => r.GetRolesForUserAsync("author", 9)).ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = 1 }, new PersonnelRole { PersonnelRoleId = 2 } }); + (await _service.GetReadScopeStampAsync("author", 9)).Should().Be(initial); + _roles.Setup(r => r.GetRolesForUserAsync("author", 9)).ThrowsAsync(new InvalidOperationException("unavailable")); + (await _service.GetReadScopeStampAsync("author", 9)).Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/RecordsCutoverServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsCutoverServiceTests.cs index d081a1cac..992bb5de9 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsCutoverServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsCutoverServiceTests.cs @@ -140,7 +140,7 @@ public async Task Preview_reports_legacy_counts_permission_table_and_blocks_when } [Test] - public async Task Preview_blocks_while_protected_data_is_mid_migration_and_passes_when_enabled() + public async Task Preview_blocks_protected_data_until_RMS_protection_is_supported() { FlagOn(); _adp.Setup(a => a.GetPolicyByDepartmentIdAsync(Dept, It.IsAny())).ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = Dept, State = (int)DepartmentDataProtectionState.Encrypting }); @@ -149,6 +149,33 @@ public async Task Preview_blocks_while_protected_data_is_mid_migration_and_passe _adp.Setup(a => a.GetPolicyByDepartmentIdAsync(Dept, It.IsAny())).ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = Dept, State = (int)DepartmentDataProtectionState.Enabled }); var preview = await _service.GetActivationPreviewAsync(Dept); preview.ProtectedDataPreflight.Should().Be("Enabled"); + preview.CanActivate.Should().BeFalse(); + _adp.Verify(a => a.GetPolicyByDepartmentIdAsync(Dept, true), Times.Exactly(2)); + } + + [Test] + public async Task Unavailable_protection_policy_blocks_activation_without_writing_cutover_or_permissions() + { + FlagOn(); + _adp.Setup(a => a.GetPolicyByDepartmentIdAsync(Dept, true)).ThrowsAsync(new InvalidOperationException("policy unavailable")); + var preview = await _service.GetActivationPreviewAsync(Dept); + preview.ProtectedDataPreflight.Should().Be("Unknown"); + preview.CanActivate.Should().BeFalse(); + var result = await _service.ActivateAsync(Dept, "admin", "go live", true); + result.Success.Should().BeFalse(); + _store.Cutovers.Should().BeEmpty(); + _store.CutoverEvents.Should().BeEmpty(); + _rows.Should().BeEmpty(); + _store.Commits.Should().Be(0); + } + + [Test] + public async Task A_disabled_protection_policy_allows_activation() + { + FlagOn(); + _adp.Setup(a => a.GetPolicyByDepartmentIdAsync(Dept, true)).ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = Dept, State = (int)DepartmentDataProtectionState.Disabled }); + var preview = await _service.GetActivationPreviewAsync(Dept); + preview.ProtectedDataPreflight.Should().Be("NotApplicable"); preview.CanActivate.Should().BeTrue(); } diff --git a/Tests/Resgrid.Tests/Rms/RecordsDashboardServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDashboardServiceTests.cs index bda57214f..9f9081e77 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsDashboardServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsDashboardServiceTests.cs @@ -30,6 +30,7 @@ public class RecordsDashboardServiceTests private List _crosswalks; private List _callTypes; private RecordsDashboardService _service; + private Mock _authorization; [SetUp] public void SetUp() @@ -51,10 +52,14 @@ public void SetUp() _calls = new Mock(); _calls.Setup(c => c.GetCallTypesForDepartmentAsync(Dept)).ReturnsAsync(() => _callTypes); + _authorization = new Mock(); + _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.GetVisibleGroupIdsAsync(It.IsAny(), Dept)).ReturnsAsync((List)null); + _authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, PermissionTypes.ManageRecordDisclosures)).ReturnsAsync(true); _service = new RecordsDashboardService(_store.RecordsRepo.Object, _incidents.ReportsRepo.Object, _incidents.AnalysesRepo.Object, _store.DueStatesRepo.Object, _store.DisclosureRequestsRepo.Object, - _neris.Object, _calls.Object); + _neris.Object, _calls.Object, _authorization.Object); } private void SeedRecord(RmsRecordState state) @@ -138,6 +143,8 @@ public async Task The_queues_count_both_aggregates() [Test] public async Task Overdue_comes_from_the_persisted_due_states() { + SeedRecord(RmsRecordState.Draft); + _store.Records.Single().RmsOperationalRecordId = "r1"; _store.DueStates.Add(new RmsRecordDueState { RmsRecordDueStateId = Guid.NewGuid().ToString(), DepartmentId = Dept, RecordId = "r1", Obligation = (int)RmsRecordObligation.Review, LastEmittedState = (int)RmsDueState.Overdue }); _store.DueStates.Add(new RmsRecordDueState { RmsRecordDueStateId = Guid.NewGuid().ToString(), DepartmentId = Dept, RecordId = "r2", Obligation = (int)RmsRecordObligation.Review, LastEmittedState = (int)RmsDueState.NotDue }); @@ -163,7 +170,7 @@ public async Task The_statutory_clock_counts_only_open_requests() [Test] public async Task A_broken_count_degrades_to_a_warning() { - _store.DueStatesRepo.Setup(r => r.CountOverdueAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("table missing")); + _store.DueStatesRepo.Setup(r => r.CountVisibleOverdueAsync(It.IsAny(), It.IsAny>(), It.IsAny())).ThrowsAsync(new InvalidOperationException("table missing")); SeedRecord(RmsRecordState.Draft); var dashboard = await _service.GetAsync(Dept, "chief"); @@ -194,6 +201,58 @@ public async Task Crosswalk_coverage_separates_mapped_unmapped_and_stale() coverage.Items.Single(i => i.LocalCode == "Service Call").Mapped.Should().BeFalse(); } + [Test] + public async Task A_member_sees_only_their_queues_and_no_unauthorized_disclosure_counts() + { + SeedRecord(RmsRecordState.Draft); + SeedRecord(RmsRecordState.Draft); + _store.Records[0].OwnerUserId = "officer"; + SeedReport(RmsRecordState.Rejected); + SeedReport(RmsRecordState.Rejected); + _incidents.Reports[0].OwnerUserId = "officer"; + _incidents.Analyses.Add(new RmsIncidentAnalysis { DepartmentId = Dept, IncidentReportId = _incidents.Reports[0].RmsIncidentReportId, State = (int)RmsIncidentAnalysisState.Finalized }); + _incidents.Analyses.Add(new RmsIncidentAnalysis { DepartmentId = Dept, IncidentReportId = _incidents.Reports[1].RmsIncidentReportId, State = (int)RmsIncidentAnalysisState.Finalized }); + foreach (var record in _store.Records) _store.DueStates.Add(new RmsRecordDueState { DepartmentId = Dept, RecordId = record.RmsOperationalRecordId, LastEmittedState = (int)RmsDueState.Overdue }); + SeedDisclosure(RmsDisclosureState.Received, DateTime.UtcNow.AddDays(-1)); + _authorization.Setup(a => a.GetVisibleGroupIdsAsync("officer", Dept)).ReturnsAsync(new List()); + _authorization.Setup(a => a.HasPermissionAsync("officer", Dept, PermissionTypes.ManageRecordDisclosures)).ReturnsAsync(false); + var dashboard = await _service.GetAsync(Dept, "officer"); + dashboard.OperationalDrafts.Should().Be(1); + dashboard.IncidentRejected.Should().Be(1); + dashboard.AnalysesAwaitingFiling.Should().Be(1); + dashboard.Overdue.Should().Be(1); + dashboard.DisclosuresOpen.Should().Be(0); + dashboard.DisclosuresOverdue.Should().Be(0); + _store.DisclosureRequestsRepo.Invocations.Should().BeEmpty(); + } + + [Test] + public async Task Scope_revoked_during_counts_cannot_return_the_former_department_totals() + { + SeedRecord(RmsRecordState.Draft); + _authorization.SetupSequence(a => a.GetVisibleGroupIdsAsync("officer", Dept)).ReturnsAsync((List)null).ReturnsAsync(new List()); + Func read = () => _service.GetAsync(Dept, "officer"); + await read.Should().ThrowAsync(); + } + + [Test] + public async Task Disclosure_permission_revoked_during_counts_removes_only_the_protected_category() + { + SeedRecord(RmsRecordState.Draft); SeedDisclosure(RmsDisclosureState.Received, DateTime.UtcNow.AddDays(-1)); + _authorization.SetupSequence(a => a.HasPermissionAsync("officer", Dept, PermissionTypes.ManageRecordDisclosures)).ReturnsAsync(true).ReturnsAsync(false); + var dashboard = await _service.GetAsync(Dept, "officer"); + dashboard.OperationalDrafts.Should().Be(1); dashboard.DisclosuresOpen.Should().Be(0); dashboard.DisclosuresOverdue.Should().Be(0); + } + + [Test] + public async Task A_removed_member_cannot_obtain_dashboard_counts() + { + _authorization.Setup(a => a.IsActiveMemberAsync("former-chief", Dept)).ReturnsAsync(false); + Func read = () => _service.GetAsync(Dept, "former-chief"); + await read.Should().ThrowAsync(); + _store.RecordsRepo.Invocations.Should().BeEmpty(); + } + [Test] public async Task Crosswalk_coverage_degrades_when_the_sources_cannot_be_read() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs index dc33ce205..959bc8df3 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -9,6 +9,7 @@ using Newtonsoft.Json.Linq; using NUnit.Framework; using Resgrid.Model; +using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; using Resgrid.Services.Records; @@ -26,20 +27,28 @@ public class RecordsDisclosureServiceTests private const int Dept = 21; private FakeRmsStore _store; + private FakeIncidentStore _incidentStore; + private Mock _pdf; + private Mock _scanner; private Mock _authorization; private Mock _settings; private RecordsDisclosureService _service; private RmsOperationalRecord _finalized; private RmsRevision _revision; + private bool _udfAdmin; [SetUp] public void SetUp() { - _store = new FakeRmsStore(); + _incidentStore = new FakeIncidentStore(); _store = _incidentStore.Shared; _authorization = new Mock(); + _authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); _authorization.Setup(a => a.GetVisibleGroupIdsAsync(It.IsAny(), Dept)).ReturnsAsync((List)null); _authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); + _udfAdmin = true; + _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync(true); + _authorization.Setup(a => a.IsDepartmentAdminAsync(It.IsAny(), Dept)).ReturnsAsync(() => _udfAdmin); _settings = new Mock(); _settings.Setup(s => s.GetRecordsDisclosureConfigAsync(Dept, It.IsAny())) @@ -48,9 +57,300 @@ public void SetUp() _finalized = SeedRecord(RmsRecordState.Finalized); _revision = SeedRevision(_finalized); + _scanner = new Mock(); + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }); + _pdf = new Mock(); + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter")).Returns(System.Text.Encoding.ASCII.GetBytes("%PDF-fixture")); + var incidents = new Mock(); + incidents.Setup(s => s.BuildSnapshotAsync(Dept, It.IsAny(), It.IsAny())).ReturnsAsync((int d, string id, string revision) => JsonConvert.DeserializeObject(_store.Revisions.Single(r => r.RmsRevisionId == revision).SnapshotJson)); + var udf = new RecordsUdfService(Mock.Of(), Mock.Of(), Mock.Of(), _authorization.Object, Mock.Of(), _store.UnitOfWork.Object, Mock.Of()); + var documents = new RecordsDocumentService(_authorization.Object, _store.RecordsRepo.Object, _incidentStore.ReportsRepo.Object, _incidentStore.AnalysesRepo.Object, _store.RevisionsRepo.Object, + incidents.Object, Mock.Of(), Mock.Of(), _pdf.Object, Mock.Of(), udf); _service = new RecordsDisclosureService(_store.DisclosureRequestsRepo.Object, _store.DisclosureProductionsRepo.Object, _store.RecordsRepo.Object, _store.RevisionsRepo.Object, _store.AuditsRepo.Object, - _authorization.Object, _settings.Object, _store.UnitOfWork.Object); + _authorization.Object, _settings.Object, _store.UnitOfWork.Object, _incidentStore.ReportsRepo.Object, documents, _store.AttachmentsRepo.Object, _pdf.Object, _incidentStore.AnalysesRepo.Object, _scanner.Object, udf); + } + + private async Task ReviewedProduceAsync(int departmentId, string userId, string requestId) + { + var review = await _service.GetReviewAsync(departmentId, userId, requestId); + review.Reviewed = true; review.Authority = "Test jurisdiction rule 4"; review.Basis = "Fixture custodian review"; review.UnresolvedScopeHandling = "Separate review tracked by fixture"; + return await _service.ProduceAsync(departmentId, userId, requestId, review: review); + } + + private static void Approve(RmsDisclosureReview review) + { review.Reviewed = true; review.Authority = "Fixture rule 4"; review.Basis = "Reviewed for this test request"; } + private void AddAdminCustomField() + { + var snapshot = JsonConvert.DeserializeObject(_revision.SnapshotJson); + snapshot.CustomFields = new RecordUdfSection { DefinitionId = "captured-form", ExtensionVersion = 1, Fields = new() + { new() { Field = new() { UdfFieldId = "admin-only", Label = "Admin review label", RmsClassification = 0, Visibility = 2, IsEnabled = true }, Value = "Admin review value" } } }; + _revision.SnapshotJson = RecordSnapshotSerializer.Serialize(snapshot); _revision.Checksum = RecordSnapshotSerializer.Checksum(_revision.SnapshotJson); + } + [Test] + public async Task Production_list_drops_earlier_packets_when_record_access_is_revoked_during_later_packet_loading() + { + var request = await OpenRequestAsync(); + var first = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + var second = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + _store.DisclosureProductionsRepo.Setup(p => p.GetForRequestAsync(Dept, request.RmsDisclosureRequestId)).ReturnsAsync(new[] { first, second }); + var allowed = true; + _authorization.Setup(a => a.CanUserViewRecordAsync("clerk", It.IsAny(), Dept)).ReturnsAsync(() => allowed); + _store.DisclosureProductionsRepo.Setup(p => p.GetByIdForDepartmentAsync(Dept, second.RmsDisclosureProductionId)) + .Callback(() => allowed = false).ReturnsAsync(second); + (await _service.GetProductionsAsync(Dept, "clerk", request.RmsDisclosureRequestId)).Should().BeEmpty(); + first.ArtifactJson.Should().NotBeNullOrEmpty("revocation must not destroy a previously produced immutable packet"); + } + + [Test] + public async Task Production_read_rechecks_custodian_permission_after_record_authorization() + { + var request = await OpenRequestAsync(); var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + var allowed = true; + _authorization.Setup(a => a.HasPermissionAsync("clerk", Dept, PermissionTypes.ManageRecordDisclosures)).ReturnsAsync(() => allowed); + _authorization.Setup(a => a.CanUserViewRecordAsync("clerk", It.IsAny(), Dept)).Callback(() => allowed = false).ReturnsAsync(true); + (await _service.GetAuthorizedProductionAsync(Dept, "clerk", production.RmsDisclosureProductionId)).Should().BeNull(); + } + + [Test] + public async Task Standard_packet_keeps_custom_field_role_requirement_after_custodian_role_revocation() + { + AddAdminCustomField(); var request = await OpenRequestAsync(); var original = _revision.SnapshotJson; + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + production.ArtifactJson.Should().Contain("Admin review value"); + JObject.Parse(production.ArtifactJson)["udf_visibility_required"].Value().Should().Be(2); + (await _service.GetAuthorizedProductionAsync(Dept, "clerk", production.RmsDisclosureProductionId)).Should().NotBeNull(); + _udfAdmin = false; + (await _service.GetAuthorizedProductionAsync(Dept, "clerk", production.RmsDisclosureProductionId)).Should().BeNull(); + foreach (var format in new[] { "pdf", "json", "zip" }) + { + Func download = () => _service.DownloadAsync(Dept, "clerk", production.RmsDisclosureProductionId, format); + await download.Should().ThrowAsync(); + } + Func release = () => _service.ReleaseAsync(Dept, "clerk", production.RmsDisclosureProductionId, deliveryMethod: "Counter", deliveryReference: "Denied test"); + await release.Should().ThrowAsync(); _revision.SnapshotJson.Should().Be(original); + } + [Test] + public async Task Review_fails_closed_when_admin_role_is_revoked_after_the_document_was_projected() + { + AddAdminCustomField(); var request = await OpenRequestAsync(); var calls = 0; + _authorization.Setup(a => a.IsDepartmentAdminAsync("clerk", Dept)).ReturnsAsync(() => ++calls <= 2); + Func review = () => _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); + await review.Should().ThrowAsync(); _store.DisclosureProductions.Should().BeEmpty(); + } + [Test] + public async Task Redacted_attachment_replacement_is_scanned_traced_and_does_not_release_original_bytes_or_metadata() + { + SeedIncidentWithFile(); var original = _store.Attachments.Single().Data.ToArray(); var request = await OpenRequestAsync(); + await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Incident", new RmsRecordQuery { DefinitionKey = RmsDefinitionKeys.NerisIncidentReport }, RmsRedactionProfiles.Standard); + var review = await _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); Approve(review); var file = review.Records.Single().Attachments.Single(); + file.Reviewed = true; file.Include = true; var bytes = System.Text.Encoding.UTF8.GetBytes("Public scene information only"); + file.Derivative = new RmsDisclosureAttachmentDerivative { FileName = "released.txt", ContentType = "text/plain", Data = bytes, Checksum = RecordSnapshotSerializer.Checksum(bytes) }; + Func produce = () => _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); + await produce.Should().ThrowAsync(); file.Authority = "Fixture privacy provision"; file.Basis = "Personal contact information removed"; + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Skipped }); + await produce.Should().ThrowAsync(); _store.DisclosureProductions.Should().BeEmpty(); + _scanner.Setup(s => s.ScanAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(new RecordAttachmentScanResult { State = RmsAttachmentScanState.Clean }); + var packet = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); + packet.ArtifactJson.Should().Contain(file.Checksum).And.Contain(file.Derivative.Checksum).And.Contain("Attachment replacement").And.NotContain("scene.txt").And.NotContain(Convert.ToBase64String(original)); + _store.Attachments.Single().Data.Should().Equal(original); + using var zip = new System.IO.Compression.ZipArchive(new System.IO.MemoryStream((await _service.DownloadAsync(Dept, "clerk", packet.RmsDisclosureProductionId, "zip")).Data)); + using var reader = new System.IO.StreamReader(zip.GetEntry("attachments/0001-released.txt").Open()); (await reader.ReadToEndAsync()).Should().Be("Public scene information only"); + } + [Test] + public async Task A_stale_release_cannot_replace_another_officers_partial_delivery() + { + SeedRecord(RmsRecordState.Draft); var request = await OpenRequestAsync(); + await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "All", new RmsRecordQuery(), RmsRedactionProfiles.Standard); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + var stale = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(production)); + await _service.ReleaseAsync(Dept, "first", production.RmsDisclosureProductionId, deliveryMethod: "Collection", deliveryReference: "First receipt"); + var released = _store.DisclosureProductions.Single(); var version = released.RowVersion; + // Second officer already read the unreleased production, then reads the newly advanced, still-open request. + _store.DisclosureProductionsRepo.SetupSequence(r => r.GetByIdForDepartmentAsync(Dept, production.RmsDisclosureProductionId)).ReturnsAsync(stale).ReturnsAsync(released); + Func second = () => _service.ReleaseAsync(Dept, "second", production.RmsDisclosureProductionId, deliveryMethod: "Collection", deliveryReference: "Second receipt"); + await second.Should().ThrowAsync(); + _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 Request_reads_use_live_permissions_and_never_mutate_stored_requester_identity() + { + var request = await OpenRequestAsync(); + _authorization.Setup(a => a.HasPermissionAsync("clerk", Dept, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + (await _service.GetAsync(Dept, "clerk", request.RmsDisclosureRequestId)).RequesterName.Should().BeNull(); + (await _service.QueryAsync(Dept, "clerk", null)).Single().RequesterName.Should().BeNull(); + _store.DisclosureRequests.Single().RequesterName.Should().NotBeNull(); + _authorization.Setup(a => a.HasPermissionAsync("clerk", Dept, PermissionTypes.ManageRecordDisclosures)).ReturnsAsync(false); + Func get = () => _service.GetAsync(Dept, "clerk", request.RmsDisclosureRequestId); await get.Should().ThrowAsync(); + Func query = () => _service.QueryAsync(Dept, "clerk", null); await query.Should().ThrowAsync(); + } + [TestCase(PermissionTypes.ViewRestrictedRecords)] + [TestCase(PermissionTypes.ManageRecordDisclosures)] + public async Task Review_does_not_return_earlier_content_after_revocation_during_a_later_record(PermissionTypes permission) + { + SeedIncidentWithFile(); var request = await OpenRequestAsync(RmsRedactionProfiles.FullDisclosure); + await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "All", new RmsRecordQuery(), RmsRedactionProfiles.FullDisclosure); + // Scope checks use QueryAsync. This lookup occurs only when the later incident document is loaded. + _incidentStore.ReportsRepo.Setup(r => r.GetByIdForDepartmentAsync(Dept, "incident")).ReturnsAsync(() => + { _authorization.Setup(a => a.HasPermissionAsync("clerk", Dept, permission)).ReturnsAsync(false); return _incidentStore.Reports.Single(); }); + Func review = () => _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); await review.Should().ThrowAsync(); + } + [Test] + public async Task Incident_scope_includes_its_analysis_and_binds_all_emitted_attachment_metadata() + { + var report = SeedIncidentWithFile(); + var source = _store.Revisions.Single(r => r.RecordId == "incident"); var snapshot = JObject.Parse(source.SnapshotJson); + snapshot["Attachments"][0]["Description"] = "Sensitive scene description"; snapshot["Attachments"][0]["UploadedByUserId"] = "photographer"; + source.SnapshotJson = snapshot.ToString(Formatting.None); source.Checksum = RecordSnapshotSerializer.Checksum(source.SnapshotJson); + var analysis = new RmsIncidentAnalysis { DepartmentId = Dept, RmsIncidentAnalysisId = "analysis", IncidentReportId = report.RmsIncidentReportId, CurrentRevisionId = "analysis-r1" }; + _incidentStore.Analyses.Add(analysis); var json = JsonConvert.SerializeObject(new { Analysis = analysis, Report = report, Fire = new { GeneralCause = "Cooking" } }); + _store.Revisions.Add(new RmsRevision { DepartmentId = Dept, RecordId = "analysis", RecordKind = (int)RmsRecordKind.IncidentAnalysis, RmsRevisionId = "analysis-r1", RevisionNumber = 1, SnapshotJson = json, Checksum = RecordSnapshotSerializer.Checksum(json) }); + var request = await OpenRequestAsync(); await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Incident and analysis", new RmsRecordQuery { DefinitionKey = RmsDefinitionKeys.NerisIncidentReport }, RmsRedactionProfiles.Standard); + var review = await _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); Approve(review); + review.Records.Should().HaveCount(2); review.Records.Should().Contain(r => r.RecordKind == RmsRecordKind.IncidentAnalysis && r.Fields.Any(f => f.Value == "Cooking")); + var file = review.Records.Single(r => r.RecordKind == RmsRecordKind.IncidentReport).Attachments.Single(); file.Metadata.Should().Contain(f => f.Value == "Sensitive scene description").And.Contain(f => f.Value == "photographer"); file.Include = true; file.Reviewed = true; + _store.Attachments.Single().FileName = "changed-live-name.txt"; + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); + production.RecordCount.Should().Be(2); production.ArtifactJson.Should().Contain("Cooking").And.NotContain("changed-live-name"); + (await _service.GetAuthorizedProductionAsync(Dept, "clerk", production.RmsDisclosureProductionId)).Should().NotBeNull(); + _authorization.Setup(a => a.CanUserViewRecordAsync("clerk", "incident", Dept)).ReturnsAsync(false); + (await _service.GetAuthorizedProductionAsync(Dept, "clerk", production.RmsDisclosureProductionId)).Should().BeNull(); + } + [Test] + public async Task A_partial_scope_release_records_delivery_but_keeps_the_request_clock_open() + { + SeedRecord(RmsRecordState.Draft, "Unfinished responsive report"); var request = await OpenRequestAsync(); + await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "All responsive reports", new RmsRecordQuery(), RmsRedactionProfiles.Standard); + var review = await _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); Approve(review); review.UnresolvedScopeHandling = "Custodian reviewing unfinished report separately"; + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); + Func missingReceipt = () => _service.ReleaseAsync(Dept, "clerk", production.RmsDisclosureProductionId); await missingReceipt.Should().ThrowAsync(); + await _service.ReleaseAsync(Dept, "clerk", production.RmsDisclosureProductionId, deliveryMethod: "Counter collection", deliveryReference: "Fixture receipt 7"); + var open = await _service.GetAsync(Dept, "clerk", request.RmsDisclosureRequestId); open.ClosedOn.Should().BeNull(); open.State.Should().Be((int)RmsDisclosureState.InReview); + _store.Audits.Should().Contain(a => a.Purpose == "Disclosure released" && a.DetailJson.Contains("Fixture receipt 7")); + } + [Test] + public async Task Concurrent_request_change_prevents_production_from_overwriting_scope_or_status() + { + var request = await OpenRequestAsync(); var review = await _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); Approve(review); + _store.DisclosureRequestsRepo.Setup(r => r.TryBumpRowVersionAsync(Dept, request.RmsDisclosureRequestId, It.IsAny(), It.IsAny())).ReturnsAsync(false); + Func produce = () => _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); await produce.Should().ThrowAsync(); + _store.DisclosureProductions.Should().BeEmpty(); + } + private RmsIncidentReport SeedIncidentWithFile() + { + var report = new RmsIncidentReport { DepartmentId = Dept, RmsIncidentReportId = "incident", DefinitionKey = RmsDefinitionKeys.NerisIncidentReport, State = (int)RmsRecordState.Accepted, CallId = 77, RecordNumber = "INC-2026-77", CurrentRevisionId = "incident-r1" }; + var file = new RmsRecordAttachment { DepartmentId = Dept, RecordId = "incident", RmsRecordAttachmentId = "scene", Classification = 0, FileName = "scene.txt", Data = System.Text.Encoding.UTF8.GetBytes("Reviewed scene attachment"), ScanState = (int)RmsAttachmentScanState.Clean }; + file.Checksum = RecordSnapshotSerializer.Checksum(file.Data); file.ByteSize = file.Data.Length; _store.Attachments.Add(file); + var snapshot = new NerisIncidentSnapshot { Report = report, Narrative = new RmsNarrative { Narrative = "Crew completed incident operations" }, Attachments = new List { new RmsRecordAttachment { RmsRecordAttachmentId = "scene", Classification = 0, FileName = file.FileName, Checksum = file.Checksum } }, + Evidence = new List { new RmsEvidenceArtifact { Classification = 0, ManifestJson = "{\"decision\":\"Engine 2 selected\",\"caller\":\"private caller\"}", Checksum = "fixture" } } }; + var json = JsonConvert.SerializeObject(snapshot); _incidentStore.Reports.Add(report); + _store.Revisions.Add(new RmsRevision { DepartmentId = Dept, RecordId = "incident", RecordKind = (int)RmsRecordKind.IncidentReport, RmsRevisionId = "incident-r1", RevisionNumber = 1, SnapshotJson = json, Checksum = RecordSnapshotSerializer.Checksum(json) }); return report; + } + + [Test] + public async Task Production_requires_an_explicit_review_and_rejects_a_stale_review() + { + var request = await OpenRequestAsync(); + Func missing = () => _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); await missing.Should().ThrowAsync(); + var review = await _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); Approve(review); + SeedRevision(_finalized); + Func stale = () => _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); await stale.Should().ThrowAsync(); + _store.DisclosureProductions.Should().BeEmpty(); + } + [Test] + public async Task Both_record_kinds_field_redactions_evidence_files_and_frozen_packet_survive_later_changes() + { + SeedIncidentWithFile(); var request = await OpenRequestAsync(); + await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Operational and incident", new RmsRecordQuery(), RmsRedactionProfiles.Standard); + var review = await _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); review.Records.Should().HaveCount(2); Approve(review); + var incident = review.Records.Single(r => r.RecordKind == RmsRecordKind.IncidentReport); + incident.Decisions.Add(new RmsDisclosureFieldDecision { Path = "/Evidence/0/ManifestJson/caller", Withhold = true, Authority = "Fixture privacy rule", Basis = "Caller identity excluded from this request" }); + incident.Attachments.Single().Reviewed = true; incident.Attachments.Single().Include = true; + var original = _store.Revisions.Single(r => r.RmsRevisionId == "incident-r1").SnapshotJson; + string rendered = null; + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter")).Returns((string html, string paper) => { rendered = html; return System.Text.Encoding.ASCII.GetBytes("%PDF-fixture"); }); + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); + production.ArtifactJson.Should().NotContain("private caller").And.Contain("Engine 2 selected").And.Contain("WITHHELD"); + rendered.Should().Contain("Contents").And.Contain("Fixture privacy rule").And.NotContain("private caller"); + _store.Revisions.Single(r => r.RmsRevisionId == "incident-r1").SnapshotJson.Should().Be(original); + _store.Attachments.Single().Data = new byte[] { 9 }; // The release keeps the reviewed bytes, not a live file link. + var download = await _service.DownloadAsync(Dept, "clerk", production.RmsDisclosureProductionId, "zip"); + using var zip = new System.IO.Compression.ZipArchive(new System.IO.MemoryStream(download.Data)); + zip.GetEntry("packet.pdf").Should().NotBeNull(); + using var reader = new System.IO.StreamReader(zip.GetEntry("attachments/0001-scene.txt").Open()); (await reader.ReadToEndAsync()).Should().Be("Reviewed scene attachment"); + var qa = Environment.GetEnvironmentVariable("RESGRID_RMS_DOCUMENT_QA_DIR"); if (!string.IsNullOrWhiteSpace(qa)) await System.IO.File.WriteAllTextAsync(System.IO.Path.Combine(qa, "disclosure-packet.html"), rendered); + } + [Test] + public async Task Every_file_needs_a_decision_and_a_withheld_file_cannot_remain_in_the_packet() + { + SeedIncidentWithFile(); var request = await OpenRequestAsync(); await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Incident", new RmsRecordQuery { DefinitionKey = RmsDefinitionKeys.NerisIncidentReport }, RmsRedactionProfiles.Standard); + var review = await _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); Approve(review); + Func produce = () => _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); + await produce.Should().ThrowAsync(); + var file = review.Records.Single().Attachments.Single(); file.Reviewed = true; + await produce.Should().ThrowAsync(); + file.Authority = "Fixture rule"; file.Basis = "Entire file withheld"; + var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); + ((JArray)JObject.Parse(production.ArtifactJson)["attachments"]).Should().BeEmpty(); + production.ArtifactJson.Should().NotContain("scene.txt").And.NotContain(Convert.ToBase64String(_store.Attachments.Single().Data)); + } + [Test] + public async Task Permission_revoked_while_PDF_is_rendered_prevents_storing_or_releasing_a_packet() + { + var request = await OpenRequestAsync(); var review = await _service.GetReviewAsync(Dept, "clerk", request.RmsDisclosureRequestId); Approve(review); + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter")).Returns(() => { _authorization.Setup(a => a.HasPermissionAsync("clerk", Dept, PermissionTypes.ManageRecordDisclosures)).ReturnsAsync(false); return System.Text.Encoding.ASCII.GetBytes("%PDF-fixture"); }); + Func produce = () => _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId, review: review); await produce.Should().ThrowAsync(); _store.DisclosureProductions.Should().BeEmpty(); + } + [Test] + public async Task Call_and_station_scope_filters_are_applied_to_the_saved_revision() + { + var request = await OpenRequestAsync(); var saved = RecordSnapshotSerializer.Deserialize(_revision.SnapshotJson); saved.CallId = 77; saved.StationGroupId = 5; saved.StartedOn = new DateTime(2024, 2, 3); + _revision.SnapshotJson = RecordSnapshotSerializer.Serialize(saved); _revision.Checksum = RecordSnapshotSerializer.Checksum(_revision.SnapshotJson); + _finalized.CallId = 99; _finalized.StationGroupId = 9; _finalized.StartedOn = DateTime.UtcNow; + await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Original call", new RmsRecordQuery { CallId = 77, StationGroupId = 5, Year = 2024 }, RmsRedactionProfiles.Standard); + (await _service.PreviewScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId)).Items.Should().ContainSingle(); + await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Other call", new RmsRecordQuery { CallId = 99 }, RmsRedactionProfiles.Standard); + (await _service.PreviewScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId)).Items.Should().BeEmpty(); + } + + [Test] + public async Task Full_disclosure_cannot_grant_restricted_permission_and_source_bytes_are_unchanged() + { + var request = await OpenRequestAsync(RmsRedactionProfiles.FullDisclosure); + var before = _revision.SnapshotJson; + _authorization.Setup(a => a.HasPermissionAsync("clerk", Dept, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + JObject.Parse(production.ArtifactJson)["documents"][0]["content"]["Details"]["CaseNumber"].Should().BeNull(); + production.WithheldFieldsJson.Should().Contain("Details.CaseNumber"); + _revision.SnapshotJson.Should().Be(before); + (await _service.GetAuthorizedProductionAsync(Dept, "clerk", production.RmsDisclosureProductionId)).Should().NotBeNull(); + } + + [Test] + public async Task Losing_restricted_access_prevents_download_and_release_of_an_existing_full_packet() + { + var request = await OpenRequestAsync(RmsRedactionProfiles.FullDisclosure); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + var bytes = production.ArtifactJson; + _authorization.Setup(a => a.HasPermissionAsync("clerk", Dept, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + (await _service.GetAuthorizedProductionAsync(Dept, "clerk", production.RmsDisclosureProductionId)).Should().BeNull(); + (await _service.GetProductionsAsync(Dept, "clerk", request.RmsDisclosureRequestId)).Should().BeEmpty(); + Func release = () => _service.ReleaseAsync(Dept, "clerk", production.RmsDisclosureProductionId); + await release.Should().ThrowAsync(); + production.ReleasedOn.Should().BeNull(); + production.ArtifactJson.Should().Be(bytes); + } + + [Test] + public async Task Cross_group_or_malformed_production_manifests_are_not_retrievable() + { + var request = await OpenRequestAsync(); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + _authorization.Setup(a => a.CanUserViewRecordAsync("other-group", _finalized.RmsOperationalRecordId, Dept)).ReturnsAsync(false); + (await _service.GetAuthorizedProductionAsync(Dept, "other-group", production.RmsDisclosureProductionId)).Should().BeNull(); + production.ProducedSetJson = "[]"; + (await _service.GetAuthorizedProductionAsync(Dept, "clerk", production.RmsDisclosureProductionId)).Should().BeNull(); } private RmsOperationalRecord SeedRecord(RmsRecordState state, string summary = "Structure fire response") @@ -169,7 +469,7 @@ public async Task The_scope_preview_runs_through_the_same_authorization_path_as_ var preview = await _service.PreviewScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId); - preview.MatchedCount.Should().Be(1); + preview.MatchedCount.Should().Be(0, "inaccessible candidates are counted separately and their scope facts are not exposed"); preview.WithheldWholeRecordCount.Should().Be(1, "a disclosure officer sees no more of the department than their queue shows them"); preview.Items.Should().BeEmpty(); } @@ -187,7 +487,7 @@ await _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Ev preview.Items.Should().HaveCount(2); var draft = preview.Items.Single(i => i.Summary == "Half-written report"); draft.Producible.Should().BeFalse(); - draft.NotProducibleReason.Should().Contain("not finalized"); + draft.NotProducibleReason.Should().Contain("saved revision"); } [Test] @@ -195,19 +495,19 @@ public async Task A_production_redacts_restricted_fields_and_logs_what_it_withhe { var request = await OpenRequestAsync(); - var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); production.RecordCount.Should().Be(1); production.WithheldFieldCount.Should().BeGreaterThan(0); var artifact = JObject.Parse(production.ArtifactJson); - var details = artifact["documents"][0]["details"]; + var details = artifact["documents"][0]["content"]["Details"]; details["Narrative"].Value().Should().Contain("knocked the fire down"); details["ContactName"].Value().Should().Be("Jane Public", "an unrestricted field is released"); details["CaseNumber"].Should().BeNull("a restricted-class field is not released under the standard profile"); var withheld = JArray.Parse(production.WithheldFieldsJson); - withheld.Should().Contain(w => w["Field"].Value() == "CaseNumber", + withheld.Should().Contain(w => w["Field"].Value() == "Details.CaseNumber", "a requester is entitled to know something was withheld even when they cannot have it"); } @@ -216,11 +516,11 @@ public async Task The_no_identifiers_profile_withholds_participant_identity() { var request = await OpenRequestAsync(RmsRedactionProfiles.NoPersonalIdentifiers); - var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); var artifact = JObject.Parse(production.ArtifactJson); - ((JArray)artifact["documents"][0]["participants"]).Should().BeEmpty(); - JArray.Parse(production.WithheldFieldsJson).Should().Contain(w => w["Section"].Value() == "Participants"); + artifact["documents"][0]["content"]["Participants"].Should().BeNull(); + JArray.Parse(production.WithheldFieldsJson).Should().Contain(w => w["Field"].Value() == "Participants"); } [Test] @@ -231,7 +531,7 @@ public async Task A_production_never_mutates_the_source_revision() var beforeChecksum = _revision.Checksum; var beforeRowVersion = _finalized.RowVersion; - await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); _revision.SnapshotJson.Should().Be(before); _revision.Checksum.Should().Be(beforeChecksum); @@ -242,7 +542,7 @@ public async Task A_production_never_mutates_the_source_revision() public async Task The_produced_set_freezes_the_revision_and_its_checksum() { var request = await OpenRequestAsync(); - var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); var produced = JArray.Parse(production.ProducedSetJson); produced.Should().ContainSingle(); @@ -262,7 +562,7 @@ public async Task The_produced_set_freezes_the_revision_and_its_checksum() public async Task A_production_is_checksummed_and_verifiable() { var request = await OpenRequestAsync(); - var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); (await _service.VerifyProductionAsync(Dept, production.RmsDisclosureProductionId)).Should().BeTrue(); @@ -274,9 +574,9 @@ public async Task A_production_is_checksummed_and_verifiable() public async Task Releasing_closes_the_statutory_clock_and_audits_the_handover() { var request = await OpenRequestAsync(); - var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); - var released = await _service.ReleaseAsync(Dept, "chief", production.RmsDisclosureProductionId); + var released = await _service.ReleaseAsync(Dept, "chief", production.RmsDisclosureProductionId, deliveryMethod: "Custodian handover", deliveryReference: "Fixture receipt 4"); released.ReleasedOn.Should().NotBeNull(); released.ReleasedByUserId.Should().Be("chief"); @@ -289,10 +589,10 @@ public async Task Releasing_closes_the_statutory_clock_and_audits_the_handover() public async Task Releasing_twice_is_refused() { var request = await OpenRequestAsync(); - var production = await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); - await _service.ReleaseAsync(Dept, "chief", production.RmsDisclosureProductionId); + var production = await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + await _service.ReleaseAsync(Dept, "chief", production.RmsDisclosureProductionId, deliveryMethod: "Custodian handover", deliveryReference: "Fixture receipt 4"); - Func act = () => _service.ReleaseAsync(Dept, "chief", production.RmsDisclosureProductionId); + Func act = () => _service.ReleaseAsync(Dept, "chief", production.RmsDisclosureProductionId, deliveryMethod: "Custodian handover", deliveryReference: "Fixture receipt 4"); await act.Should().ThrowAsync(); } @@ -301,7 +601,7 @@ public async Task Releasing_twice_is_refused() public async Task The_scope_cannot_change_once_something_has_been_produced() { var request = await OpenRequestAsync(); - await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); Func act = () => _service.SaveScopeAsync(Dept, "clerk", request.RmsDisclosureRequestId, "Actually, everything", new RmsRecordQuery(), RmsRedactionProfiles.FullDisclosure); @@ -335,7 +635,7 @@ public async Task Denying_records_the_exemption_relied_on() public async Task Every_produced_record_is_audited_against_the_record_itself() { var request = await OpenRequestAsync(); - await _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + await ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); // "What did we hand out about this record" has to be answerable from the record, not only the request. _store.Audits.Should().Contain(a => a.RecordId == _finalized.RmsOperationalRecordId && a.Purpose.StartsWith("Disclosure production")); @@ -347,7 +647,7 @@ public async Task A_scope_that_resolves_to_nothing_producible_is_refused() _finalized.State = (int)RmsRecordState.Draft; var request = await OpenRequestAsync(); - Func act = () => _service.ProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); + Func act = () => ReviewedProduceAsync(Dept, "clerk", request.RmsDisclosureRequestId); await act.Should().ThrowAsync(); } diff --git a/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs new file mode 100644 index 000000000..c3b64e36b --- /dev/null +++ b/Tests/Resgrid.Tests/Rms/RecordsDocumentTests.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using File = System.IO.File; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Providers.PdfProvider; +using Resgrid.Services.Records; +using Resgrid.Web.Areas.User.Controllers; + +namespace Resgrid.Tests.Rms +{ + [TestFixture] + public class RecordsDocumentTests + { + private FakeIncidentStore _store; + private Mock _auth; + private Mock _incidents; + private Mock _pdf; + private RecordsDocumentService _service; + private NerisIncidentSnapshot _snapshot; + private Mock _groups; + [SetUp] + public void Setup() + { + _store = new FakeIncidentStore(); _auth = new Mock(); _incidents = new Mock(); _pdf = new Mock(); + _auth.Setup(a => a.CanUserViewRecordAsync("officer", "report", 1)).ReturnsAsync(true); + _auth.Setup(a => a.HasPermissionAsync("officer", 1, It.IsAny())).ReturnsAsync(true); + _auth.Setup(a => a.IsActiveMemberAsync("officer", 1)).ReturnsAsync(true); + _auth.Setup(a => a.IsDepartmentAdminAsync("officer", 1)).ReturnsAsync(true); + _groups = new Mock(); + var report = new RmsIncidentReport { DepartmentId = 1, RmsIncidentReportId = "report", CurrentRevisionId = "r1", RecordNumber = "INC-2026-0023", IncidentNumber = "2026-00023", RowVersion = 2, State = (int)RmsRecordState.Finalized }; + _snapshot = new NerisIncidentSnapshot { Report = report, Narrative = new RmsNarrative { Narrative = "Officer verified all occupants accounted for. " }, + Casualties = new List { new RmsCasualtyRescue { PersonnelUserId = "restricted-person", BirthMonthYear = "1990-04", DetailJson = "{\"private\":\"restricted-body\"}" } }, + Evidence = new List { new RmsEvidenceArtifact { Title = "Dispatch decision", Classification = 0, ManifestJson = "{\"decision\":\"Engine 2 selected\"}", Checksum = "fixture" }, new RmsEvidenceArtifact { Title = "Restricted source title", Classification = 1, ManifestJson = "restricted-evidence" } }, + Attachments = new List { new RmsRecordAttachment { RmsRecordAttachmentId = "photo", Classification = 0, FileName = "scene.jpg", Checksum = "fixture-attachment", ByteSize = 12 } } }; + _store.Reports.Add(JsonConvert.DeserializeObject(JsonConvert.SerializeObject(report))); + var json = JsonConvert.SerializeObject(_snapshot); + _store.Revisions.Add(new RmsRevision { DepartmentId = 1, RecordId = "report", RecordKind = 2, RmsRevisionId = "r1", RevisionNumber = 1, SnapshotJson = json, Checksum = RecordSnapshotSerializer.Checksum(json), ActorUserId = "Officer Jones", CreatedOn = new DateTime(2026, 9, 4, 12, 0, 0, DateTimeKind.Utc), AttestationStatementVersion = "1" }); + _incidents.Setup(s => s.BuildSnapshotAsync(1, "report", "r1")).ReturnsAsync(() => JsonConvert.DeserializeObject(json)); + var brand = new Mock(); brand.Setup(b => b.GetBrandingAsync(1)).ReturnsAsync(new DepartmentBranding { DisplayName = "Example Fire Department", AddressText = "100 Example Street", PhoneNumber = "555-0100", Website = "example.invalid" }); + var layouts = new Mock(); layouts.Setup(l => l.GetDepartmentDefaultAsync(1)).ReturnsAsync(new RmsRecordPrintLayout { Version = 3, Scope = 1, Config = new RecordsPrintLayoutConfig { LetterheadLine1 = "Fire Prevention and Emergency Response", FooterText = "Departmental record copy", WatermarkLabel = "TRAINING FIXTURE" } }); + var udf = new RecordsUdfService(Mock.Of(), Mock.Of(), Mock.Of(), _auth.Object, _groups.Object, Mock.Of(), Mock.Of()); + _service = new RecordsDocumentService(_auth.Object, _store.Shared.RecordsRepo.Object, _store.ReportsRepo.Object, _store.AnalysesRepo.Object, _store.Shared.RevisionsRepo.Object, _incidents.Object, brand.Object, layouts.Object, _pdf.Object, Mock.Of(), udf); + } + + private void CaptureCustomFields() + { + _snapshot.CustomFields = new RecordUdfSection { DefinitionId = "extension-v1", ExtensionVersion = 1, Fields = new() + { + new() { Field = new() { UdfFieldId = "public", Label = "Department score", RmsClassification = 0, Visibility = 0, IsEnabled = true }, Value = "23" }, + new() { Field = new() { UdfFieldId = "admin", Label = "Confidential admin label", RmsClassification = 0, Visibility = 2, IsEnabled = true }, Value = "admin-only-value" }, + new() { Field = new() { UdfFieldId = "restricted", Label = "Restricted custom label", RmsClassification = 1, Visibility = 0, IsEnabled = true }, Value = "restricted-custom-value" } + } }; + var json = JsonConvert.SerializeObject(_snapshot); _store.Revisions[0].SnapshotJson = json; _store.Revisions[0].Checksum = RecordSnapshotSerializer.Checksum(json); + _incidents.Setup(s => s.BuildSnapshotAsync(1, "report", "r1")).ReturnsAsync(() => JsonConvert.DeserializeObject(json)); + } + + [Test] + public async Task Address_state_is_printed_and_compared_while_header_lifecycle_state_and_storage_ids_are_omitted() + { + _snapshot.Location = new RmsLocation { Street = "Border Road", State = "CA", Country = "US", SourceKind = (int)RmsSourceKind.Dispatch }; + _snapshot.Report.UdfDefinitionId = "storage-form-identity"; + var json = JsonConvert.SerializeObject(_snapshot); _store.Revisions[0].SnapshotJson = json; _store.Revisions[0].Checksum = RecordSnapshotSerializer.Checksum(json); + _incidents.Setup(s => s.BuildSnapshotAsync(1, "report", "r1")).ReturnsAsync(() => JsonConvert.DeserializeObject(json)); + var changed = JsonConvert.DeserializeObject(json); changed.Location.State = "NV"; changed.Report.State = (int)RmsRecordState.Accepted; + var nextJson = JsonConvert.SerializeObject(changed); + _store.Revisions.Add(new RmsRevision { DepartmentId = 1, RecordId = "report", RecordKind = (int)RmsRecordKind.IncidentReport, RmsRevisionId = "r2", RevisionNumber = 2, SnapshotJson = nextJson, Checksum = RecordSnapshotSerializer.Checksum(nextJson) }); + _incidents.Setup(s => s.BuildSnapshotAsync(1, "report", "r2")).ReturnsAsync(() => JsonConvert.DeserializeObject(nextJson)); + var original = await _service.GetAsync(1, "officer", "report", RmsRecordKind.IncidentReport, "r1"); + var next = await _service.GetAsync(1, "officer", "report", RmsRecordKind.IncidentReport, "r2"); + var html = await _service.RenderHtmlAsync(1, "officer", original); + html.Should().Contain(">CA<").And.Contain(">Dispatch<").And.NotContain("storage-form-identity"); + var differences = await _service.DiffAsync(1, "officer", original, next); + differences.Should().Contain(d => d.FieldKey == "Location.State" && d.OldValue == "CA" && d.NewValue == "NV"); + differences.Should().NotContain(d => d.FieldKey == "Report.State"); + } + + [TestCase("record")] + [TestCase("export")] + [TestCase("restricted")] + public async Task Permission_revoked_during_PDF_generation_prevents_returning_the_rendered_bytes(string permission) + { + CaptureCustomFields(); var document = await _service.GetAsync(1, "officer", "report", RmsRecordKind.IncidentReport); var original = _store.Revisions[0].SnapshotJson; + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter")).Callback(() => + { + if (permission == "record") _auth.Setup(a => a.CanUserViewRecordAsync("officer", "report", 1)).ReturnsAsync(false); + else _auth.Setup(a => a.HasPermissionAsync("officer", 1, permission == "export" ? PermissionTypes.ExportRecords : PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + }).Returns(new byte[] { 1, 2, 3 }); + Func print = () => _service.RenderPdfAsync(1, "officer", document); + await print.Should().ThrowAsync(); _store.Revisions[0].SnapshotJson.Should().Be(original); + _pdf.Verify(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter"), Times.Once); + } + + [Test] + public async Task Comparison_PDF_includes_captured_custom_field_labels_values_and_provenance_with_safe_HTML() + { + CaptureCustomFields(); + var changed = JsonConvert.DeserializeObject(_store.Revisions[0].SnapshotJson); changed.CustomFields.Fields[0].Value = ""; + var json = JsonConvert.SerializeObject(changed); + _store.Revisions.Add(new RmsRevision { DepartmentId = 1, RecordId = "report", RecordKind = (int)RmsRecordKind.IncidentReport, RmsRevisionId = "r2", RevisionNumber = 2, SnapshotJson = json, Checksum = RecordSnapshotSerializer.Checksum(json) }); + _incidents.Setup(s => s.BuildSnapshotAsync(1, "report", "r2")).ReturnsAsync(() => JsonConvert.DeserializeObject(json)); + _auth.Setup(a => a.IsDepartmentAdminAsync("officer", 1)).ReturnsAsync(false); + _auth.Setup(a => a.HasPermissionAsync("officer", 1, PermissionTypes.ViewRestrictedRecords)).ReturnsAsync(false); + string html = null; + _pdf.Setup(p => p.ConvertHtmlToPdf(It.IsAny(), "Letter")).Callback((text, size) => html = text).Returns(new byte[] { 1, 2, 3 }); + (await _service.RenderDiffPdfAsync(1, "officer", "report", RmsRecordKind.IncidentReport, "r1", "r2")).Should().Equal(1, 2, 3); + html.Should().Contain("Example Fire Department").And.Contain("revision 1 to 2").And.Contain("Department score").And.Contain("23").And.Contain("<script>unsafe</script>").And.NotContain(" \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml index 5064e62b3..36af4b783 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml @@ -1,4 +1,4 @@ -@using Resgrid.Model +@using Resgrid.Model @using Resgrid.Model.Helpers @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Records.IncidentReportDetailView @@ -31,6 +31,7 @@ @if (Model.CanEdit && Model.IsEditable) { @commonLocalizer["Edit"] + Inventory usage
@Html.AntiForgeryToken()
} @if (Model.CanSubmit && Model.CanQueueSubmission) @@ -45,15 +46,37 @@ {
@Html.AntiForgeryToken()
} - @if (Model.CanExport) + @if (Model.CanExport && r.CurrentRevisionId != null) { - @localizer["Print"] + @localizer["Print"] + JSON + CSV }
+
+
@localizer["Attachments"]
+ @foreach (var attachment in a.Attachments.Where(x => !x.IsProtected || Model.CanViewRestricted)) + { +

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

+ @if (Model.CanEdit && Model.IsEditable) + {
@Html.AntiForgeryToken()
} + } + @if (Model.CanEdit && Model.IsEditable) + { +
+ @Html.AntiForgeryToken() + + + + +
+ } +
+
@if (!string.IsNullOrEmpty(Model.Message)) @@ -100,6 +123,7 @@
@localizer["Owner"]
@Name(r.OwnerUserId)
@localizer["Station"]
@(r.StationGroupId.HasValue && Model.GroupNames.TryGetValue(r.StationGroupId.Value, out var g) ? g : "-")
+
@@ -433,21 +457,9 @@ @if (Model.CanEdit && Model.IsEditable) { -
- @Html.AntiForgeryToken() - - - - -
+ Select and capture evidence } + Evidence history
@@ -610,6 +622,10 @@
@localizer["QueuedOn"]
@When(s.QueuedOn)
@localizer["SentOn"]
@When(s.SentOn)
@localizer["CompletedOn"]
@When(s.CompletedOn)
+ @if (Model.CanSubmit && Model.CanViewRestricted) + { +
Exchange history
View responses and recovery actions
+ } @if (!string.IsNullOrWhiteSpace(s.ErrorSummary)) {
@localizer["ErrorSummary"]
@s.ErrorSummary
@@ -619,7 +635,27 @@
@localizer["Payload"]
JSON @s.PayloadChecksum?.Substring(0, Math.Min(12, s.PayloadChecksum.Length))
} -
+ @if (Model.CanSubmit && (s.RequiresReconciliation || s.CreatePendingReceipt || s.DestinationIdentity == null)) + { + var canBind = s.DestinationIdentity == null && s.SentOn == null && s.Attempts == 0 && s.ExternalId == null && !s.RequiresReconciliation && !s.CreatePendingReceipt; +
+ @Html.AntiForgeryToken() + + + @if (canBind) + { +

This submission has never been sent. Review the department's current NERIS entity and destination before binding it for delivery.

+ } + else + { +

This delivery may already exist in NERIS. Locate its filing and enter its identifier to verify it against this report.

+ + } + + +
+ } +
} }
@@ -639,7 +675,10 @@ @foreach (var rev in a.Revisions.OrderByDescending(x => x.RevisionNumber)) { - @rev.RevisionNumber@(((RmsRevisionTransition)rev.Transition).ToString()) @(string.IsNullOrWhiteSpace(rev.ReasonCode) ? string.Empty : "(" + rev.ReasonCode + ")")@Name(rev.ActorUserId)@When(rev.CreatedOn) + @rev.RevisionNumber + @if (rev.PriorRevisionId != null) { Compare } + @if (Model.CanExport) { PDF } + @(((RmsRevisionTransition)rev.Transition).ToString()) @(string.IsNullOrWhiteSpace(rev.ReasonCode) ? string.Empty : "(" + rev.ReasonCode + ")")@Name(rev.ActorUserId)@When(rev.CreatedOn) } diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml index 84f36537c..6f01fcc02 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml @@ -1,4 +1,4 @@ -@using Resgrid.Model +@using Resgrid.Model @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView @inject IStringLocalizer localizer @@ -33,11 +33,12 @@ -
+ @Html.AntiForgeryToken() @Html.HiddenFor(m => m.ReportId) @Html.HiddenFor(m => m.RowVersion) @Html.HiddenFor(m => m.CallId) + @await Html.PartialAsync("~/Areas/User/Views/Records/_CustomFields.cshtml", Model.CustomFieldForm)
@@ -61,7 +62,7 @@
    @foreach (var i in Model.Issues.OrderBy(i => i.Severity)) { -
  • @(((RmsValidationSeverity)i.Severity).ToString()) @i.FieldPath @i.Message
  • +
  • @(((RmsValidationSeverity)i.Severity).ToString()) @i.FieldPath @i.Message
  • }
@@ -201,26 +202,30 @@ + @{ var unitRow = 0; } @for (var i = 0; i < Model.AvailableUnits.Count; i++) { if (!int.TryParse(Model.AvailableUnits[i].Value, out var unitId)) { continue; } + // The posted index has to be gapless: model binding stops at the first missing + // Units[n], so a skipped source row would silently drop every unit after it. + var index = unitRow++; var existing = Model.Units.FirstOrDefault(u => u.UnitId == unitId); - + - - - - - - - - + + + + + + + + }
@localizer["Unit"]@localizer["UnitNerisId"]@localizer["Staffing"]@localizer["Dispatched"]@localizer["Enroute"]@localizer["OnScene"]@localizer["Staged"]@localizer["CanceledEnroute"]@localizer["Cleared"]@localizer["ResponseMode"]
@Model.AvailableUnits[i].Text @badge(NerisFactKeys.UnitTime(unitId, "dispatch")) @badge(NerisFactKeys.UnitTime(unitId, "on_scene")) @badge(NerisFactKeys.UnitTime(unitId, "on_scene")) - @foreach (var m in Model.ResponseModes) { @@ -331,53 +336,24 @@ { @localizer["Suggested"] } - @sectionView.PayloadPath +

@sectionView.Reason

@foreach (var i in indices) { var row = Model.Modules[i]; -
-
- - -
-
- @if (sectionView.PrimaryCodes.Count > 0) - { - - } - else - { - - } -
-
- @if (sectionView.SecondaryCodes.Count > 0) - { - - } -
-
-
-
-
-
-
-
-
+
+
+ + + + + + + + +
+
} } @@ -394,7 +370,23 @@ { var row = i < Model.Casualties.Count ? Model.Casualties[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentCasualtyRow(); var isRescue = row.Kind == (int)RmsCasualtyRescueKind.Rescue; -
+ @if (Model.CanEditRestricted) + { +
+ + + + + + +
+ } + else + { + +
@@ -511,6 +503,8 @@
} + + } }
@@ -521,57 +515,12 @@ @for (var i = 0; i < Model.Exposures.Count + Resgrid.Web.Areas.User.Models.Records.IncidentReportEditView.ExposureRows; i++) { var row = i < Model.Exposures.Count ? Model.Exposures[i] : new Resgrid.Web.Areas.User.Models.Records.IncidentExposureRow(); -
-
-
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- @foreach (var c in Model.DisplacementCauseCodes) - { - - } -
-
+
+ + + + +
} @@ -615,8 +564,8 @@
@commonLocalizer["Cancel"] - - + +
@@ -633,4 +582,8 @@ + + + + diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDocuments/Diff.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDocuments/Diff.cshtml new file mode 100644 index 000000000..310239875 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDocuments/Diff.cshtml @@ -0,0 +1,11 @@ +@model List +@{ ViewBag.Title = "Compare report revisions"; } +
+

Revision @ViewData["From"] to revision @ViewData["To"]

+

Print comparison as PDF

+ @if ((bool?)ViewData["Withheld"] == true) {

Restricted values are withheld under your current permissions.

} + @if (Model.Count == 0) {

No visible content changed.

} + + @foreach (var change in Model) { } +
FieldBeforeAfter
@(change.FieldLabel ?? change.FieldKey)@change.OldValue@change.NewValue
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Index.cshtml new file mode 100644 index 000000000..1092c7fea --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Index.cshtml @@ -0,0 +1,40 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordEvidenceView +@{ + ViewBag.Title = "Resgrid | Supporting evidence"; + var context = Model.Context; + var recordController = context.RecordKind == RmsRecordKind.IncidentReport ? "IncidentReports" : "Records"; +} +
+

Supporting evidence — @context.RecordNumber

+

Return to report

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

Captured manifests preserve the selected source facts and their checksum. Draft captures become part of the signed revision. Corrections preserve earlier evidence in the history.

+ @if (context.CanCapture) + { +

Select and capture evidence + Record inventory usage

+ } + @if (Model.Artifacts.Count == 0) {

No evidence on this page.

} + + @foreach (var artifact in Model.Artifacts) + { +
+ @if (artifact.Withheld) + { +

Restricted evidence withheld.

+ } + else + { +

@artifact.Title

+

@artifact.Reason

+

@artifact.Items source item(s) · captured @artifact.CapturedOn.ToString("yyyy-MM-dd HH:mm:ss") UTC · @(artifact.Superseded ? "Superseded" : "Current") · @(artifact.RevisionId == null ? "Draft" : "Signed revision: " + artifact.RevisionId)

+

Source version: @artifact.SourceVersion
SHA-256: @artifact.Checksum

+ @if (context.CanExport) { Download verified manifest } + } +
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml new file mode 100644 index 000000000..c58f644c9 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordEvidence/Select.cshtml @@ -0,0 +1,83 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordEvidenceSelectionView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | Select supporting evidence"; + var selection = Model.Selection; + var context = selection.Context; + var source = selection.Sources.FirstOrDefault(s => s.Kind == selection.SourceKind); +} +
+

Select supporting evidence — @context.RecordNumber

+

Evidence history

+
+ + + + + +
+
+ @if (source?.Available != true) + { +
@source?.Reason No evidence will be recorded for an unavailable source.
+ } + else + { + @if (selection.SourceKind == RmsEvidenceKind.ChatPromotion) + { +
+ + +
+

Select the messages to preserve. Thread replies are included. Each page is a separate capture; capture this page before moving to the next. Message bodies are displayed as text.

+ } +
+ @Html.AntiForgeryToken() + + @if (selection.SourceKind == RmsEvidenceKind.TrackingFix) + { +

Choose at most 20 units and a window of at most 24 hours. Up to 24 fixes per unit are sampled across that window. Capture additional windows separately.

+
+
+ } + @if (selection.SourceKind == RmsEvidenceKind.CertificationSnapshot) + { +

Choose personnel whose qualification status belongs in this report. Certificate numbers and document files remain in Certifications.

+
+ } + @if (selection.SourceKind == RmsEvidenceKind.RunCardActivation) {

Capture the dispatch decisions recorded by Run Cards for this report’s Call. A missing recorded decision cannot be reconstructed by RMS.

} + @if (selection.SourceKind == RmsEvidenceKind.InventoryUsage) {

Refresh the evidence for all recorded inventory consumption. This action does not consume stock again.

} + @if (selection.SourceKind == RmsEvidenceKind.TrackingFix || selection.SourceKind == RmsEvidenceKind.CertificationSnapshot || selection.SourceKind == RmsEvidenceKind.ChatPromotion) + { +
@(selection.SourceKind == RmsEvidenceKind.TrackingFix ? "Units" : selection.SourceKind == RmsEvidenceKind.CertificationSnapshot ? "Personnel" : "Messages") + @if (selection.Choices.Count == 0) {

No accessible items on this page.

} + @foreach (var choice in selection.Choices) + { + var field = selection.SourceKind == RmsEvidenceKind.TrackingFix ? "Input.UnitIds" : selection.SourceKind == RmsEvidenceKind.CertificationSnapshot ? "Input.UserIds" : "Input.SourceIds"; +
+ } +
+ } +
+ +
+ @if (selection.NextSequence.HasValue) + { +

Next message page (uncaptured selections will be cleared)

+ } + } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordLegalHolds/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordLegalHolds/Index.cshtml new file mode 100644 index 000000000..6daa5cc86 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordLegalHolds/Index.cshtml @@ -0,0 +1,35 @@ +@using Resgrid.Model +@model List +@{ ViewBag.Title = "Records preservation holds"; } +

Records preservation holds

+

Use a preservation hold for litigation, investigation or a records request. A record hold also protects its analysis and evidence. Releasing a disclosure does not release its preservation hold.

+@if (TempData["HoldMessage"] != null) {

@TempData["HoldMessage"]

} +@if (TempData["HoldError"] != null) {

@TempData["HoldError"]

} +

Place a hold

+
+@Html.AntiForgeryToken() + + +

For a definition/date scope, leave both dates blank to preserve every date. These times are UTC.

+ + + + + +

+
+

Hold history

+@foreach (var hold in Model) +{ +

@hold.ReferenceNumber · @(hold.IsActive ? "Active" : "Released")

+

@hold.Reason · Record: @(hold.RecordId ?? "All matching") · Definition: @(hold.DefinitionKey ?? "All")

+

Period: @(hold.PeriodStart?.ToString("u") ?? "Any start") to @(hold.PeriodEnd?.ToString("u") ?? "Any end")

+

@hold.Notes

Placed @hold.PlacedOn.ToString("u") by @hold.PlacedByUserId

+ @if (hold.IsActive) { +
@Html.AntiForgeryToken() + + +
+ } else {

Released @hold.ReleasedOn?.ToString("u") by @hold.ReleasedByUserId

@hold.ReleaseNotes

} +
+} diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordSubmissions/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordSubmissions/Details.cshtml new file mode 100644 index 000000000..ec0e9bc5e --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordSubmissions/Details.cshtml @@ -0,0 +1,61 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordSubmissionView +@{ + ViewBag.Title = "Submission history and recovery"; + var submission = Model.Submission; + var canBind = submission.DestinationIdentity == null && submission.SentOn == null && submission.Attempts == 0 && submission.ExternalId == null && !submission.RequiresReconciliation && !submission.CreatePendingReceipt; + var ambiguous = submission.RequiresReconciliation || submission.CreatePendingReceipt; + var recoverable = ambiguous || submission.State == (int)RmsSubmissionState.Rejected || submission.State == (int)RmsSubmissionState.Failed; + var reportController = submission.Destination == RmsSubmissionDestinations.NerisIncidentAnalysis ? "IncidentAnalysis" : "IncidentReports"; +} +
+

Submission history and recovery

+

Return to report

+ @if (Model.Message != null) {

@Model.Message

} + @if (Model.Error != null) { } +
+
State
@((RmsSubmissionState)submission.State)
+
Destination
@submission.Destination · @submission.DestinationVersion
+
Filing identifier
@(submission.ExternalId ?? "No identifier recorded")
+
Attempts
@submission.Attempts / @submission.MaxAttempts
+
Latest issue
@submission.ErrorSummary
+
+

Delivery and response history

+

Times are UTC. Earlier responses remain available after rejection, retry and correction.

+ @if (Model.Exchanges.Count == 0) {

No exchange history was recorded for this legacy submission. Its last stored response appears below.

} + @foreach (var exchange in Model.Exchanges.OrderBy(e => e.OccurredOn).ThenBy(e => e.RmsSubmissionExchangeId)) + { +
+ @exchange.OccurredOn.ToString("u") — @exchange.Operation / @exchange.Stage · attempt @exchange.AttemptNumber +

Exchange @exchange.ExchangeId

+ @if (exchange.OutcomeJson != null) {
@exchange.OutcomeJson

Response checksum: @exchange.OutcomeChecksum

} +
+ } +
Exact queued payload
@submission.PayloadJson

Payload checksum: @submission.PayloadChecksum

+ @if (submission.ResponseJson != null) {
Last stored destination response
@submission.ResponseJson

Response checksum: @submission.ResponseChecksum

} + @if (canBind || ambiguous) + { +

@(canBind ? "Bind an unsent submission" : "Verify an existing destination filing")

+

@(canBind ? "Review the department's current NERIS entity and destination. Binding this unsent submission queues it for delivery." : "Locate the filing in the original destination. Its identifier is checked against the queued report before polling resumes.")

+
+ @Html.AntiForgeryToken() + + @if (!canBind) { } + + +
+ } + @if (Model.IsAdministrator && submission.ExternalId == null && recoverable) + { +

Record externally verified absence of a filing

+

Use only after the original destination or its support team confirms that this attempted filing was not created. An empty search result alone is insufficient. This records your verification and clears the recovery block; it sends nothing and does not schedule a retry.

+
+ @Html.AntiForgeryToken() + + + +

+ +
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml index ede6e5dc3..543a36375 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml @@ -25,9 +25,11 @@
+ Supporting evidence @if (Model.CanEdit && isEditable) { @commonLocalizer["Edit"] + Inventory usage } @if (Model.CanAmend && RmsLifecycle.CanTransition((RmsLifecyclePreset)record.LifecyclePreset, Model.State, RmsRecordState.Amended) && record.AmendsRevisionId == null) { @@ -140,7 +142,7 @@
@localizer["OtherPersonnel"]
@details.OtherPersonnel
} -

@details.Narrative

+
@Html.Raw(Resgrid.Framework.RecordNarrativeFormatter.Render(details.Narrative))
@@ -222,6 +224,7 @@
@Html.AntiForgeryToken() +
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Edit.cshtml index c11a66dd8..f0131d0ce 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Edit.cshtml @@ -24,12 +24,14 @@ -
+ + @Html.AntiForgeryToken() @Html.HiddenFor(m => m.RecordId) @Html.HiddenFor(m => m.RowVersion) @Html.HiddenFor(m => m.DefinitionKey) @Html.HiddenFor(m => m.IsAmendment) + @await Html.PartialAsync("~/Areas/User/Views/Records/_CustomFields.cshtml", Model.CustomFieldForm)
@@ -90,6 +92,10 @@
+ @if (t == RmsOperationalRecordType.Run && !Model.CallId.HasValue && ClaimsAuthorizationHelper.CanCreateCall()) + { +
@if (Model.IsNew) { Save the draft to create and link a historical Call. } else { Create Call for Run }
+ }
@@ -137,7 +143,7 @@
} - @if (isCoroner) + @if (isCoroner && Model.CanViewRestricted) {
@localizer["RestrictedSection"]
@@ -152,6 +158,9 @@
+ } + @if (isCoroner) + {
@@ -187,20 +196,31 @@
-
+
- @if (!isUnitActivity) - {
@localizer["Participants"]
- + + @for (var i = 0; i < Model.ParticipantRows.Count; i++) + { + var participant = Model.ParticipantRows[i]; var name = Model.Personnel.FirstOrDefault(p => p.Value == participant.UserId)?.Text ?? participant.UserId; + + + } +
@localizer["Participants"]@localizer["Unit"]Role
+ + +
- }
@localizer["Units"]
@@ -244,6 +264,7 @@
@localizer["Attachments"]
+
@@ -266,6 +287,7 @@ }
+

@(Model.IsNew ? "Save the draft to enable automatic saving." : "Changes are saved automatically. Attachments require Save Draft.")

@if (Model.CanFinalize) { @@ -280,3 +302,7 @@
+@section Scripts { + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/NewRunCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/NewRunCall.cshtml new file mode 100644 index 000000000..05e80ac9a --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Records/NewRunCall.cshtml @@ -0,0 +1,16 @@ +@model Resgrid.Web.Areas.User.Models.Records.RecordNewCallView +@{ ViewBag.Title = "Resgrid | Create Call for Run"; } +

Create Call for Run

+

Record a past incident and link it to this Run. The Call is created as closed and does not dispatch responders.

+ @if (!string.IsNullOrEmpty(Model.ErrorMessage)) {
@Model.ErrorMessage
} +
+ @Html.AntiForgeryToken() + +
+
+
+
+ + Return to Run +
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Print.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Print.cshtml index 2b52ddf44..614ef5e59 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Print.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Print.cshtml @@ -104,7 +104,7 @@
@localizer["OtherPersonnel"]@Text(details.OtherPersonnel)
-

@details.Narrative

+
@Html.Raw(Resgrid.Framework.RecordNarrativeFormatter.Render(details.Narrative))
@if (isCoroner) diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/_CustomFields.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/_CustomFields.cshtml new file mode 100644 index 000000000..858f4871b --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Records/_CustomFields.cshtml @@ -0,0 +1,37 @@ +@using Resgrid.Model +@using Newtonsoft.Json +@model RecordUdfSection +@inject Resgrid.Model.Services.IUdfRenderingService UdfRenderer +@if (Model != null) +{ + var definition = new UdfDefinition { UdfDefinitionId = Model.DefinitionId, EntityType = (int)UdfEntityType.Record, Version = Model.ExtensionVersion }; + var fields = Model.Fields.Select(e => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(e.Field))).ToList(); + var values = Model.Fields.Select(e => new UdfFieldValue { UdfFieldId = e.Field.UdfFieldId, Value = e.Value }).ToList(); + // Drafts can be incomplete. The service applies the pinned requiredness at finalization. + foreach (var field in fields) { if (field.IsRequired) field.Label += " (required before finalization)"; field.IsRequired = false; } +

Department custom fields

+

Form version @Model.ExtensionVersion

+ + @Html.Raw(UdfRenderer.GenerateHtmlFormFields(definition, fields.Where(f => !f.IsReadOnly).ToList(), values)) + @Html.Raw(UdfRenderer.GenerateReadOnlyHtml(definition, fields.Where(f => f.IsReadOnly).ToList(), values)) +
+ +} diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml new file mode 100644 index 000000000..95f10a9ea --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsInventory/Edit.cshtml @@ -0,0 +1,25 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Records.RecordInventoryView +@{ ViewBag.Title = "Resgrid | Record inventory usage"; var controller = Model.Kind == RmsRecordKind.IncidentReport ? "IncidentReports" : "Records"; } +

Inventory usage

+

Enter supplies consumed on this report. Saving creates an inventory ledger adjustment and captures supporting evidence. Correct stock adjustments in Inventory; record corrections through an RMS amendment.

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

Recorded usage

+ + @foreach (var usage in Model.Usage) { } +
ItemQuantityNoteLedger entryRecorded at (UTC)
@usage.ItemName@usage.Quantity @usage.UnitOfMeasure@usage.Note@usage.InventoryId@usage.CapturedOn.ToString("u")
+
+ @Html.AntiForgeryToken() +
+
+
+
+
+ + Return to report +
+
+ @Html.AntiForgeryToken() + +
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml index 5e4cfd6a8..b2efa0fa4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -1,4 +1,5 @@ @inject Resgrid.Model.Services.IFeatureToggleService featureToggleService +@inject Resgrid.Model.Services.IRecordsAuthorizationService recordsAuthorization @{ // Chat.System flag gates every chat surface (chat, assistant, moderation). When it is off // the nav items are hidden entirely; the API 404s the endpoints regardless. @@ -79,6 +80,10 @@
  • @commonLocalizer["RecordsModule"]
  • + @if (await recordsAuthorization.HasPermissionAsync(ClaimsAuthorizationHelper.GetUserId(), ClaimsAuthorizationHelper.GetDepartmentId(), Resgrid.Model.PermissionTypes.ManageRecordLegalHold)) + { +
  • Records preservation holds
  • + } } else if (SettingsHelper.IsLogsEnabled()) { diff --git a/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/Edit.cshtml index d9f8f3098..f2be780a2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/Edit.cshtml @@ -3,6 +3,7 @@ @{ ViewData["Title"] = "Resgrid | " + string.Format(localizer["EditDefinitionHeader"].Value, Model.EntityTypeName); Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; + ViewData["RmsExtension"] = Model.EntityType == Resgrid.Model.UdfEntityType.Record; var dataTypes = Enum.GetValues(typeof(Resgrid.Model.UdfFieldDataType)) .Cast() @@ -49,6 +50,13 @@ + + + @if (Model.EntityType == Resgrid.Model.UdfEntityType.Record) + { +

    Record type: @Model.RecordDefinitionKey · definition @Model.RecordDefinitionVersion. Publishing creates a new form version for new reports. Existing reports keep their captured form.

    +

    Department custom fields appear on the departmental record. They are excluded from NERIS submissions and other standardized national exports.

    + }
    diff --git a/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/Index.cshtml index 718c4aeb0..5aa06d60a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/Index.cshtml @@ -17,6 +17,13 @@
    +

    Record custom fields

    +

    Choose the record type whose form you want to extend.

    + @foreach (var key in Resgrid.Model.RmsDefinitionKeys.LockedTypes.Keys.Concat(new[] { Resgrid.Model.RmsDefinitionKeys.NerisIncidentReport })) + { + @key + } +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/_UdfFieldRow.cshtml b/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/_UdfFieldRow.cshtml index 6ec4b7269..2a3575d86 100644 --- a/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/_UdfFieldRow.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/UserDefinedFields/_UdfFieldRow.cshtml @@ -47,6 +47,15 @@
    + @if (ViewData["RmsExtension"] as bool? == true) + { +
    + +
    + }
    diff --git a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index 3d680575b..3083d7c1b 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -139,6 +139,8 @@ public static bool CanCreateCall() return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Call, ResgridClaimTypes.Actions.Create); } + public static bool CanViewCalls() => GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Call, ResgridClaimTypes.Actions.View); + public static bool CanCreateTraining() { return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Training, ResgridClaimTypes.Actions.Create); diff --git a/Web/Resgrid.Web/Helpers/IncidentGuidedFormMapper.cs b/Web/Resgrid.Web/Helpers/IncidentGuidedFormMapper.cs new file mode 100644 index 000000000..51d840349 --- /dev/null +++ b/Web/Resgrid.Web/Helpers/IncidentGuidedFormMapper.cs @@ -0,0 +1,89 @@ +using System; +using System.Linq; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Web.Areas.User.Models.Records; + +namespace Resgrid.Web.Helpers +{ + /// Maps the guided property form into the existing typed department fields plus its full body. + public static class IncidentGuidedFormMapper + { + public static IncidentCasualtyRescueInput Casualty(IncidentCasualtyRow row, DateTime? occurredOn) + { + try + { + var body = JObject.Parse(string.IsNullOrWhiteSpace(row.DetailJson) ? "{}" : row.DetailJson); + var injury = body["casualty"]?["injury_or_noninjury"]; + var details = injury?["ff_injury_details"]; + var rescue = body["rescue"]; + var ff = rescue?["ffrescue_or_nonffrescue"]; + var removal = ff?["removal_or_nonremoval"]; + var birth = (string)body["birth_month_year"]; + if (DateTime.TryParseExact(birth, "MM/yyyy", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var month)) birth = month.ToString("yyyy-MM"); + return new IncidentCasualtyRescueInput + { + CasualtyId = row.CasualtyId, Kind = rescue != null && injury == null ? RmsCasualtyRescueKind.Rescue : RmsCasualtyRescueKind.Casualty, + PersonType = (string)body["type"], PersonnelUserId = row.PersonnelUserId, + Rank = (string)body["rank"], YearsOfService = (decimal?)body["years_of_service"], BirthMonthYear = birth, + Gender = (string)body["gender"], Race = (string)body["race"], WasInjured = injury == null ? null : (string)injury["type"] != "UNINJURED", + WasFatal = (string)injury?["type"] == "INJURED_FATAL", CasualtyCause = (string)injury?["cause"], + JobClassification = (string)details?["job_classification"], DutyType = (string)details?["duty_type"], + CasualtyAction = (string)details?["action_type"], CasualtyTimeline = (string)details?["incident_stage"], + Ppe = (details?["ppe_items"] as JArray)?.Values().ToList(), InjuryDetailJson = details?.ToString(Newtonsoft.Json.Formatting.None), + RescueType = (string)ff?["type"], RescueActions = (ff?["actions"] as JArray)?.Values().ToList(), + RescueImpediments = (ff?["impediments"] as JArray)?.Values().ToList(), RescueMode = (string)removal?["type"], + RescuePath = (string)removal?["rescue_path_type"], RescueElevation = (string)removal?["elevation_type"], + PresenceKnown = (string)rescue?["presence_known"]?["presence_known_type"], OccurredOn = occurredOn, + DetailJson = body.ToString(Newtonsoft.Json.Formatting.None) + }; + } + catch (Exception ex) when (ex is Newtonsoft.Json.JsonException || ex is FormatException || ex is InvalidCastException || ex is OverflowException) + { throw new ArgumentException("The casualty or rescue fields could not be read. Reload this report before saving."); } + } + + public static IncidentExposureInput Exposure(IncidentExposureRow row) + { + try + { + var body = JObject.Parse(string.IsNullOrWhiteSpace(row.DetailJson) ? "{}" : row.DetailJson); + var location = body["location"]; + var coordinates = body["point"]?["geometry"]?["coordinates"] as JArray; + return new IncidentExposureInput + { + LocationKind = (string)body["location_detail"]?["type"], ItemType = (string)body["location_detail"]?["item_type"], + DamageType = (string)body["damage_type"], LocationUse = (string)body["location_use"]?["use_type"], + PeoplePresent = (bool?)body["people_present"], DisplacementCount = (int?)body["displacement_count"], + DisplacementCauses = (body["displacement_causes"] as JArray)?.Values().ToList(), + AddressText = (string)location?["additional_info"], Street = (string)location?["street"], Municipality = (string)location?["incorporated_municipality"], + State = (string)location?["state"], PostalCode = (string)location?["postal_code"], + Longitude = coordinates?.Count == 2 ? (decimal?)coordinates[0] : null, Latitude = coordinates?.Count == 2 ? (decimal?)coordinates[1] : null, + EstimatedValue = row.EstimatedValue, EstimatedLoss = row.EstimatedLoss, DetailJson = body.ToString(Newtonsoft.Json.Formatting.None) + }; + } + catch (Exception ex) when (ex is Newtonsoft.Json.JsonException || ex is FormatException || ex is InvalidCastException || ex is OverflowException) + { throw new ArgumentException("The exposure fields could not be read. Reload this report before saving."); } + } + + public static IncidentPropertyInput Property(IncidentPropertyRow row) + { + JObject body; + try { body = JObject.Parse(string.IsNullOrWhiteSpace(row.DetailJson) ? "{}" : row.DetailJson); } + catch (Newtonsoft.Json.JsonException) { throw new ArgumentException("The property fields could not be read. Reload this report before saving."); } + var structures = (body["structures"] as JArray)?.OfType().ToList() ?? new System.Collections.Generic.List(); + var first = structures.FirstOrDefault(); + decimal? FirstValue(string field) => (decimal?)first?[field]; + return new IncidentPropertyInput + { + LocationUse = (string)first?["location_use"]?["use_type"], Vacancy = (string)first?["location_use"]?["vacancy_cause"], + ConstructionType = (string)first?["construction_type"], Foundation = (string)first?["foundation"], + ExteriorFinish = (string)first?["exterior_finish"], RoofMaterial = (string)first?["roof_material"], + YearBuilt = (int?)first?["year_built"], DamageType = (string)first?["damage_assessment"], + StoriesAboveGrade = row.StoriesAboveGrade, StoriesBelowGrade = row.StoriesBelowGrade, FireSpread = row.FireSpread, + EstimatedValue = FirstValue("estimated_property_value"), EstimatedLoss = FirstValue("estimated_property_loss_value"), + ContentsValue = FirstValue("estimated_contents_value"), ContentsLoss = FirstValue("estimated_contents_loss_value"), + DetailJson = body.ToString(Newtonsoft.Json.Formatting.None) + }; + } + } +} diff --git a/Web/Resgrid.Web/wwwroot/css/neris-guided-form.css b/Web/Resgrid.Web/wwwroot/css/neris-guided-form.css new file mode 100644 index 000000000..7a44510ea --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/css/neris-guided-form.css @@ -0,0 +1,6 @@ +.neris-guided-section { padding: 12px; margin: 12px 0; border: 1px solid #d5dce1; border-radius: 4px; } +.neris-field { margin: 12px 0; } +.neris-field > label { display: block; margin-bottom: 4px; } +.neris-nested { padding-left: 16px; border-left: 2px solid #d5dce1; } +.neris-array-entry { padding: 8px 0; border-bottom: 1px solid #e5e5e5; } +.neris-guided-section [hidden] { display: none !important; } diff --git a/Web/Resgrid.Web/wwwroot/js/neris-guided-form.js b/Web/Resgrid.Web/wwwroot/js/neris-guided-form.js new file mode 100644 index 000000000..32d15a7d9 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/neris-guided-form.js @@ -0,0 +1,290 @@ +/* Guided authoring from the embedded, pinned NERIS contract. No report content is inserted as HTML. */ +(function (root) { + "use strict"; + let sequence = 0; + const own = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key); + const copy = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); + const label = value => ({ FF: "Firefighter", NONFF: "Nonfirefighter" }[value]) || String(value || "Entry").replace(/Payload$|Value$/g, "").replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/\|\|/g, " / ").replace(/_/g, " ").toLowerCase().replace(/(^|\s)[a-z]/g, c => c.toUpperCase()); + function resolve(schema, schemas, depth = 0) { + if (depth > 24 || !schema || typeof schema !== "object") throw new Error("Unsupported reporting field."); + if (!schema.$ref) return schema; + const name = schema.$ref.replace("#/components/schemas/", ""); + if (name === schema.$ref || !own(schemas, name)) throw new Error("Missing reporting field definition."); + const result = Object.assign({}, resolve(schemas[name], schemas, depth + 1), schema); + delete result.$ref; + return result; + } + function createEditor(host, schema, initial, schemas, options = {}) { + const doc = host.ownerDocument; + const moveField = new WeakMap(); + function structureChanged() { + host.dispatchEvent(new doc.defaultView.CustomEvent("neris-structure-change", { bubbles: true })); + } + function el(tag, text, parent, cls) { + const node = doc.createElement(tag); + if (text !== undefined) node.textContent = text; + if (cls) node.className = cls; + if (parent) parent.appendChild(node); + return node; + } + function field(parent, spec, value, title, path, depth, optional) { + if (depth > 24) throw new Error("This section exceeds the supported nesting depth."); + spec = resolve(spec, schemas); + let variants = spec.oneOf || spec.anyOf; + if (variants) { + variants = variants.map(v => resolve(v, schemas)).filter(v => v.type !== "null"); + if (variants.length === 1) { + const merged = Object.assign({}, spec, variants[0]); + delete merged.anyOf; delete merged.oneOf; + return field(parent, merged, value, title, path, depth + 1, optional); + } + } + const name = spec["x-ui-label"] || spec.title || title; + const wrap = el("div", undefined, parent, "neris-field"); + wrap.dataset.nerisPath = path; + wrap.dataset.nerisFullPath = (options.root || "") + path; + moveField.set(wrap, nextPath => { + path = nextPath; + wrap.dataset.nerisPath = path; + wrap.dataset.nerisFullPath = (options.root || "") + path; + }); + const id = "neris-field-" + (++sequence); + let head = el("label", name + (optional ? "" : " *"), wrap); + head.htmlFor = id; + const hint = spec["x-ui-hint"]; + if (hint) { const help = el("p", hint, wrap, "help-block"); help.id = id + "-hint"; } + if (variants && variants.length > 1) { + const select = el("select", undefined, wrap, "form-control"); select.id = id; + el("option", "Choose a type", select).value = ""; + const discriminator = spec.discriminator && spec.discriminator.propertyName || "type"; + variants.forEach((v, i) => { + const t = resolve((v.properties || {})[discriminator] || {}, schemas); + const text = own(t, "const") ? label(t.const) : t.enum ? t.enum.map(label).join(" / ") : label(v.title || "Type " + (i + 1)); + el("option", text, select).value = String(i); + }); + let chosen = variants.findIndex(v => { + const t = resolve((v.properties || {})[discriminator] || {}, schemas); + return value && (own(t, "const") ? value[discriminator] === t.const : t.enum && t.enum.includes(value[discriminator])); + }); + const area = el("div", undefined, wrap, "neris-nested"); + let current = null; + const drafts = new Map(); + if (chosen >= 0) drafts.set(chosen, copy(value)); + function choose() { + if (current && chosen >= 0) drafts.set(chosen, current.get()); + chosen = select.value === "" ? -1 : Number(select.value); + area.replaceChildren(); + current = chosen < 0 ? null : field(area, variants[chosen], drafts.get(chosen), title, path, depth + 1, false); + } + select.value = chosen < 0 ? "" : String(chosen); + choose(); select.addEventListener("change", () => { choose(); structureChanged(); }); + return { get: () => current ? current.get() : undefined }; + } + if (own(spec, "const")) { + const control = el("input", undefined, wrap, "form-control"); control.id = id; control.value = label(spec.const); control.readOnly = true; + return { get: () => spec.const }; + } + const complex = spec.type === "object" || spec.properties || spec.type === "array"; + if (complex && optional) { + head.remove(); + const toggleLabel = el("label", undefined, wrap, "checkbox-inline"); + const toggle = el("input", undefined, toggleLabel); toggle.type = "checkbox"; toggle.id = id; + toggleLabel.appendChild(doc.createTextNode(" Include " + name)); + toggle.checked = value !== undefined && value !== null; + const area = el("div", undefined, wrap, "neris-nested"); + let child = null; + function activate() { + area.hidden = !toggle.checked; + if (toggle.checked && !child) child = field(area, spec, value, title, path, depth + 1, false); + } + toggle.addEventListener("change", () => { activate(); structureChanged(); }); activate(); + return { get: () => toggle.checked && child ? child.get() : undefined }; + } + if (spec.type === "object" || spec.properties) { + head.removeAttribute("for"); + const original = value && typeof value === "object" && !Array.isArray(value) ? copy(value) : {}; + const fields = new Map(); + const required = spec.required || []; + Object.entries(spec.properties || {}).forEach(([key, child]) => { + if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("Invalid reporting field."); + const childPath = path + "/" + key; + if ((options.exclude || []).includes(childPath)) return; + fields.set(key, field(wrap, child, original[key], label(key), childPath, depth + 1, !required.includes(key))); + }); + const unknown = Object.keys(original).filter(k => !own(spec.properties, k)); + if (unknown.length) el("p", "Previously saved fields outside this form are preserved. Run validation before finalizing.", wrap, "text-warning"); + return { get: () => { + const result = copy(original); + fields.forEach((child, key) => { const v = child.get(); if (v === undefined) delete result[key]; else result[key] = v; }); + return result; + }}; + } + if (spec.type === "array") { + head.removeAttribute("for"); + const itemSpec = resolve(spec.items || {}, schemas); + if (itemSpec.enum) { + const select = el("select", undefined, wrap, "form-control"); select.multiple = true; select.id = id; + select.setAttribute("aria-label", name); select.size = Math.min(8, itemSpec.enum.length); + const known = new Set(itemSpec.enum); + const values = Array.isArray(value) ? value : []; + [...itemSpec.enum, ...values.filter(v => !known.has(v))].forEach(v => { + const option = el("option", label(v), select); option.value = JSON.stringify(v); option.selected = values.includes(v); + }); + el("small", "Select all that apply. Use Ctrl or Command to select multiple options.", wrap, "help-block"); + return { get: () => Array.from(select.selectedOptions).map(o => JSON.parse(o.value)) }; + } + const list = el("div", undefined, wrap, "neris-nested"); + const rows = []; + const add = el("button", "Add " + label(title), wrap, "btn btn-default btn-sm"); add.type = "button"; + function addRow(v) { + if (rows.length >= (spec.maxItems || 1000)) return; + const area = el("div", undefined, list, "neris-array-entry"); + const child = field(area, spec.items || {}, v, /coordinates$/.test(path) ? (rows.length === 0 ? "Longitude" : "Latitude") : "Entry", path + "/" + rows.length, depth + 1, false); + const remove = el("button", "Remove entry", area, "btn btn-default btn-sm"); remove.type = "button"; + const row = { area, child }; rows.push(row); + remove.addEventListener("click", () => { + rows.splice(rows.indexOf(row), 1); area.remove(); add.disabled = false; + rows.forEach((remaining, index) => { + const previous = remaining.area.firstElementChild.dataset.nerisPath; + const next = path + "/" + index; + remaining.area.querySelectorAll("[data-neris-path]").forEach(node => { + const currentPath = node.dataset.nerisPath; + if (currentPath === previous || currentPath.startsWith(previous + "/")) + moveField.get(node)(next + currentPath.slice(previous.length)); + }); + }); + structureChanged(); + }); + add.disabled = rows.length >= (spec.maxItems || 1000); + } + (Array.isArray(value) ? value : []).forEach(addRow); + add.addEventListener("click", () => { addRow(undefined); structureChanged(); }); + return { get: () => rows.map(r => r.child.get()).filter(v => v !== undefined) }; + } + let control; + if (spec.enum || spec.type === "boolean") { + control = el("select", undefined, wrap, "form-control"); + el("option", "Choose a value", control).value = ""; + const choices = spec.enum || [true, false]; + if (value !== undefined && value !== null && !choices.includes(value)) el("option", "Previously recorded: " + label(value), control).value = JSON.stringify(value); + choices.forEach(v => { el("option", typeof v === "boolean" ? (v ? "Yes" : "No") : label(v), control).value = JSON.stringify(v); }); + control.value = value === undefined || value === null ? "" : JSON.stringify(value); + } else { + const multiline = spec.type === "string" && ((spec.maxLength || 0) > 512 || /narrative|description|comment/.test(path)); + control = el(multiline ? "textarea" : "input", undefined, wrap, "form-control"); + if (multiline) control.rows = 3; + else control.type = spec.type === "integer" || spec.type === "number" ? "number" : spec.format === "date" ? "date" : "text"; + if (spec.type === "integer" || spec.type === "number") { + control.step = spec.type === "integer" ? "1" : "any"; + if (spec.minimum !== undefined) control.min = spec.minimum; + if (spec.maximum !== undefined) control.max = spec.maximum; + } + if (spec.maxLength) control.maxLength = spec.maxLength; + if (spec.format === "date-time") { control.placeholder = "YYYY-MM-DDTHH:MM:SSZ"; el("small", "Enter an ISO timestamp including its time zone (Z for UTC).", wrap, "help-block"); } + control.value = value === undefined || value === null ? "" : String(value); + } + control.id = id; + if (hint) control.setAttribute("aria-describedby", id + "-hint"); + return { get: () => { + if (control.value === "") return undefined; + if (spec.enum || spec.type === "boolean") return JSON.parse(control.value); + if (spec.type === "integer" || spec.type === "number") { + const number = Number(control.value); + if (!Number.isFinite(number) || (spec.type === "integer" && !Number.isInteger(number))) throw new Error("Enter a valid number for " + name + "."); + return number; + } + return control.value; + }}; + } + const editor = field(host, schema, initial, options.title || "Section details", "", 0, false); + // These conditions are prose rules in the pinned contract, outside its JSON Schema keywords. + if (resolve(schema, schemas).title === "CasualtyRescuePayload") { + const conditions = [ + { path: "/rank", personType: "FF" }, + { path: "/years_of_service", personType: "FF" }, + { path: "/rescue/mayday", personType: "FF" }, + { path: "/casualty/injury_or_noninjury/ff_injury_details", personType: "FF" }, + { path: "/rescue/presence_known", personType: "NONFF" } + ]; + const help = el("p", "Choose whether the person is a firefighter. Fields that do not apply are excluded when saving; switching back during this edit restores their draft values.", host, "help-block"); + help.setAttribute("role", "note"); + const typeField = Array.from(host.querySelectorAll("[data-neris-path]")).find(node => node.dataset.nerisPath === "/type"); + const typeControl = typeField && typeField.querySelector("select"); + function personType() { return typeControl && typeControl.value ? JSON.parse(typeControl.value) : null; } + function showApplicable() { + const type = personType(); + for (const rule of conditions) for (const node of host.querySelectorAll("[data-neris-path]")) + if (node.dataset.nerisPath === rule.path) node.hidden = type !== rule.personType; + } + host.addEventListener("change", showApplicable); + host.addEventListener("neris-structure-change", showApplicable); + showApplicable(); + return { get: () => { + const result = editor.get(); + for (const rule of conditions) if (result.type !== rule.personType) { + const keys = rule.path.slice(1).split("/"); + const key = keys.pop(); + const parent = keys.reduce((value, part) => value && value[part], result); + if (parent) delete parent[key]; + } + return result; + }}; + } + return editor; + } + async function initialize(form) { + const inputs = Array.from(form.querySelectorAll("input[data-neris-schema]")); + if (!inputs.length) return; + let ready = false; + const editors = []; + const status = form.ownerDocument.createElement("p"); status.setAttribute("role", "alert"); status.className = "alert alert-info"; + status.textContent = "Loading report fields…"; form.prepend(status); + form.addEventListener("submit", event => { + try { + if (!ready) throw new Error("Report fields could not be loaded. Reload this page before saving."); + // Read every field first. A failure must never partially replace hidden stored values. + const values = editors.map(e => JSON.stringify(e.editor.get() || {})); + editors.forEach((e, i) => { e.input.value = values[i]; }); + } catch (error) { event.preventDefault(); status.hidden = false; status.textContent = error.message; status.className = "alert alert-danger"; status.focus(); } + }); + try { + const response = await fetch(form.dataset.nerisSchemaUrl, { credentials: "same-origin", headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error("Reporting fields are unavailable. Reload the page before saving."); + const schemas = await response.json(); + for (const input of inputs) { + if (!own(schemas, input.dataset.nerisSchema)) throw new Error("A reporting section could not be loaded."); + const host = form.ownerDocument.createElement("div"); host.className = "neris-guided-section"; input.after(host); + const initial = JSON.parse(input.value || "{}"); + const editor = createEditor(host, schemas[input.dataset.nerisSchema], initial, schemas, { exclude: (input.dataset.nerisExclude || "").split(","), title: "Section details", root: input.dataset.nerisRoot || "" }); + editors.push({ input, editor }); + const includeName = input.name.replace(/DetailJson$/, "Included"); + const include = form.elements.namedItem(includeName); + if (include && include.type === "checkbox") { const show = () => { host.hidden = !include.checked; }; include.addEventListener("change", show); show(); } + } + form.querySelectorAll("[data-neris-issue]").forEach(issue => { + let path = issue.dataset.nerisIssue; + if (!path.startsWith("/")) path = "/" + path.replace(/\[(\d+)\]/g, "/$1").replace(/\./g, "/"); + const fields = Array.from(form.querySelectorAll("[data-neris-full-path]")); + const field = fields.find(f => f.dataset.nerisFullPath === path); + if (!field) return; + field.classList.add("has-error"); + const control = field.querySelector("input,select,textarea"); + if (!control) return; + control.setAttribute("aria-invalid", "true"); + const jump = form.ownerDocument.createElement("button"); jump.type = "button"; jump.className = "btn btn-link btn-xs"; + jump.textContent = "Go to field"; + jump.addEventListener("click", () => { field.scrollIntoView({ block: "center" }); control.focus(); }); + field.closest(".neris-guided-section").addEventListener("neris-structure-change", () => { + field.classList.remove("has-error"); control.removeAttribute("aria-invalid"); + jump.disabled = true; jump.textContent = "Section changed — save and validate again"; + }, { once: true }); + issue.appendChild(jump); + }); + ready = true; status.hidden = true; form.querySelectorAll("[data-neris-save]").forEach(button => { button.disabled = false; }); + } catch (error) { status.textContent = error.message; status.className = "alert alert-danger"; } + } + const api = { createEditor, resolve, initialize }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else { root.NerisGuidedForm = api; document.querySelectorAll("form[data-neris-schema-url]").forEach(initialize); } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/Web/Resgrid.Web/wwwroot/js/record-authoring.js b/Web/Resgrid.Web/wwwroot/js/record-authoring.js new file mode 100644 index 000000000..94323bbcd --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/record-authoring.js @@ -0,0 +1,59 @@ +(function () { + 'use strict'; + var form = document.getElementById('record-edit-form'); + if (!form) return; + var text = form.querySelector('[name="Details.Narrative"]'); + var host = document.getElementById('record-narrative-editor'); + var template = document.getElementById('record-narrative-initial'); + if (text && host && window.Quill) { + host.hidden = false; + var editor = new Quill(host, { theme: 'snow', formats: ['bold', 'italic', 'underline', 'header', 'list', 'blockquote'], modules: { toolbar: [['bold', 'italic', 'underline'], [{ header: [1, 2, 3, false] }], [{ list: 'ordered' }, { list: 'bullet' }], ['blockquote', 'clean']] } }); + if (template) editor.setContents(editor.clipboard.convert(template.innerHTML)); + else editor.setText(text.value); + text.hidden = true; + function collect() { text.value = editor.getText().trim() ? editor.root.innerHTML : ''; } + editor.on('text-change', function (_, __, source) { collect(); if (source === 'user') form.dispatchEvent(new Event('input', { bubbles: true })); }); + form.addEventListener('rms:collect', collect); form.addEventListener('submit', collect); + } + var status = document.getElementById('record-autosave-status'); + var version = form.querySelector('[name="RowVersion"]'); + var url = form.dataset.autosaveUrl; + var generation = 0, savedGeneration = 0, timer, pending = null, blocked = false, manual = false, leaving = false; + function say(message) { if (status) status.textContent = message; } + function schedule() { clearTimeout(timer); if (url && !blocked && !manual) timer = setTimeout(save, 2000); } + form.addEventListener('input', function (event) { if (event.target.type === 'file') return; generation++; say('Unsaved changes'); schedule(); }); + form.addEventListener('change', function (event) { if (event.target.type === 'file') { say('Selected attachments will upload when you choose Save Draft.'); return; } generation++; schedule(); }); + async function save() { + if (pending || blocked || manual || generation === savedGeneration) return pending; + form.dispatchEvent(new Event('rms:collect')); + var sentGeneration = generation, data = new FormData(form); + Array.from(data.keys()).forEach(function (key) { if (data.get(key) instanceof File) data.delete(key); }); + data.delete('FinalizeAfterSave'); data.delete('Attested'); + say('Saving draft…'); + pending = (async function () { + try { + var response = await fetch(url, { method: 'POST', body: data, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); + if (response.redirected || response.status === 401 || response.status === 403 || response.status === 404) { blocked = true; say('Automatic saving stopped. Your session or access changed. Keep your text and reload before continuing.'); return; } + var result = await response.json(); + if (!response.ok) { blocked = response.status === 409; say(result.error || 'Draft could not be saved. Correct the form and try again.'); return; } + if (!Number.isSafeInteger(result.rowVersion) || result.rowVersion <= Number(version.value)) throw new Error('Invalid version response'); + version.value = String(result.rowVersion); savedGeneration = sentGeneration; + say(generation === savedGeneration ? 'Draft saved. Attachments require Save Draft.' : 'Unsaved changes'); + } catch (_) { blocked = true; say('Save could not be confirmed. Your text remains here. Reload to check the saved draft before continuing.'); } + finally { pending = null; if (!manual && generation !== savedGeneration && !blocked && savedGeneration === sentGeneration) schedule(); } + })(); + return pending; + } + form.addEventListener('submit', function (event) { + if (leaving) return; + if (blocked) { event.preventDefault(); say('Saving stopped. Reload the draft to resolve the conflict or confirm the previous save before continuing.'); return; } + clearTimeout(timer); manual = true; + if (pending) { + event.preventDefault(); var submitter = event.submitter; + pending.then(function () { if (!blocked) { leaving = true; form.requestSubmit(submitter); } else manual = false; }); + } else leaving = true; + }); + window.addEventListener('beforeunload', function (event) { + if (!leaving && (generation !== savedGeneration || pending || Array.from(form.querySelectorAll('input[type=file]')).some(function (input) { return input.files.length; }))) { event.preventDefault(); event.returnValue = ''; } + }); +})(); diff --git a/Web/Resgrid.Web/wwwroot/js/record-participants.js b/Web/Resgrid.Web/wwwroot/js/record-participants.js new file mode 100644 index 000000000..64e4173a8 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/record-participants.js @@ -0,0 +1,16 @@ +(function () { + 'use strict'; + const rows = document.getElementById('record-participant-rows'); + const template = document.getElementById('record-participant-template'); + const button = document.getElementById('add-record-participant'); + if (!rows || !template || !button) return; + button.addEventListener('click', function () { + const index = rows.children.length; + const fragment = template.content.cloneNode(true); + fragment.querySelectorAll('[name]').forEach(function (element) { + element.name = element.name.replace('__index__', String(index)); + }); + rows.appendChild(fragment); + rows.lastElementChild.querySelector('select').focus(); + }); +}()); diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index 2cae57460..37437a227 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -492,7 +492,7 @@ await Client.ScheduleAsync("Domain Event Outbox Dispatch", stoppingToken); // Worker ID 44 (Identifier Allocation Registry section 3.3, the Unified Search allocation absorbed by RMS-1): records search index maintenance. This - // process holds the single Lucene writer; the sweep is a no-op while SearchConfig.Enabled is off. + // process holds the single Lucene writer; retention erasure still runs while search indexing is disabled. _logger.Log(LogLevel.Information, "Scheduling Records Search Index"); await Client.ScheduleAsync("Records Search Index", new Commands.RecordsSearchIndexCommand(44), diff --git a/Workers/Resgrid.Workers.Framework/Logic/DomainEventOutboxDispatchLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/DomainEventOutboxDispatchLogic.cs index 349068dd4..8f6839cf8 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/DomainEventOutboxDispatchLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/DomainEventOutboxDispatchLogic.cs @@ -24,7 +24,8 @@ public async Task> Process(CancellationToken cancellationTok { try { - var outboxService = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var outboxService = scope.Resolve(); var leaseOwner = "worker40:" + Environment.MachineName; var total = 0; diff --git a/Workers/Resgrid.Workers.Framework/Logic/RecordsSearchIndexLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/RecordsSearchIndexLogic.cs index d0e9d4ff8..311c80ab9 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/RecordsSearchIndexLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/RecordsSearchIndexLogic.cs @@ -11,7 +11,7 @@ namespace Resgrid.Workers.Framework.Logic /// /// Worker command 44: the records search index maintenance sweep (RMS plan section 5.10). The worker process /// is the only holder of the index writer; this logic just drives IRecordsSearchIndexMaintenanceService and - /// is a no-op while SearchConfig.Enabled is off. + /// stops indexing while SearchConfig.Enabled is off but still completes durable retention erasures. /// public sealed class RecordsSearchIndexLogic { @@ -19,16 +19,14 @@ public async Task> Process(CancellationToken cancellationTok { try { - if (!SearchConfig.Enabled) - return new Tuple(true, "Search host disabled; nothing to do."); - - var maintenance = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var maintenance = scope.Resolve(); var result = await maintenance.SweepAsync(cancellationToken); if (result.Errors > 0) Logging.LogError($"Records search index sweep finished with {result.Errors} error(s): {result.Message}"); - return new Tuple(true, result.Message); + return new Tuple(result.Errors == 0, result.Message); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { diff --git a/Workers/Resgrid.Workers.Framework/Logic/RmsDueStateEvaluationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/RmsDueStateEvaluationLogic.cs index 0e65b5f68..fe22e9a97 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/RmsDueStateEvaluationLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/RmsDueStateEvaluationLogic.cs @@ -18,7 +18,8 @@ public async Task> Process(CancellationToken cancellationTok { try { - var service = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var service = scope.Resolve(); var result = await service.SweepAsync(cancellationToken); if (result.Errors > 0) diff --git a/Workers/Resgrid.Workers.Framework/Logic/RmsRetentionAndPurgeLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/RmsRetentionAndPurgeLogic.cs index 601ec43df..0c5093cba 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/RmsRetentionAndPurgeLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/RmsRetentionAndPurgeLogic.cs @@ -18,7 +18,8 @@ public async Task> Process(CancellationToken cancellationTok { try { - var service = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var service = scope.Resolve(); var result = await service.SweepAsync(cancellationToken); if (result.Errors > 0) diff --git a/Workers/Resgrid.Workers.Framework/Logic/RmsSubmissionLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/RmsSubmissionLogic.cs index 2750c358f..891cd411b 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/RmsSubmissionLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/RmsSubmissionLogic.cs @@ -22,7 +22,8 @@ public async Task> Process(CancellationToken cancellationTok if (!NerisConfig.Enabled) return new Tuple(true, "NERIS submission disabled; nothing to do."); - var service = Bootstrapper.GetKernel().Resolve(); + using var scope = Bootstrapper.GetKernel().BeginLifetimeScope(); + var service = scope.Resolve(); var result = await service.SweepAsync(cancellationToken); if (result.Errors > 0)