diff --git a/Core/Resgrid.Config/SystemBehaviorConfig.cs b/Core/Resgrid.Config/SystemBehaviorConfig.cs index c03dfca01..cca308e30 100644 --- a/Core/Resgrid.Config/SystemBehaviorConfig.cs +++ b/Core/Resgrid.Config/SystemBehaviorConfig.cs @@ -30,6 +30,21 @@ public static class SystemBehaviorConfig /// public static string ResgridEventingBaseUrl = "https://resgridevents.local"; + /// + /// Maximum length (characters) of an outbound SMS body before it is truncated. Default 459 = three concatenated + /// GSM-7 SMS segments (153 chars each) - enough to convey a useful summary while keeping per-message cost low; + /// the full content is always available in the Resgrid app. (Hard carrier limit is 1600 - Twilio error 21617.) + /// + public static int SmsMaxLength = 459; + + /// + /// Comma-separated host allow-list for URLs in outbound SMS. Carriers (A2P 10DLC) filter messages containing + /// public/unbranded links, so any URL whose host is not on this list is removed before the SMS is sent. + /// Includes Resgrid's own domains, the common mapping/location services departments share, and bit.ly (Resgrid's + /// default link shortener). Deployments using a self-hosted shortener (Polr/Kutt) should add their short domain. + /// + public static string SmsAllowedUrlDomains = "resgrid.com,resgrid.net,resgrid.io,google.com,maps.app.goo.gl,goo.gl,apple.com,bing.com,openstreetmap.org,osm.org,what3words.com,w3w.co,bit.ly"; + /// /// Resgrid internal Billing API Url. Do not set for Open-Source install. /// diff --git a/Core/Resgrid.Framework/PhoneRegionHelper.cs b/Core/Resgrid.Framework/PhoneRegionHelper.cs new file mode 100644 index 000000000..d41149bc8 --- /dev/null +++ b/Core/Resgrid.Framework/PhoneRegionHelper.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; + +namespace Resgrid.Framework +{ + /// + /// Maps a country display name (as used in Resgrid.Model.Countries.CountryNames) to its ISO-3166 alpha-2 + /// region code, which the phone-number validator uses as the default region when parsing a national-format number + /// (e.g. a South-African "082446..." -> region "za" -> "+2782446..."). Unmapped countries return null, in which + /// case the validator falls back to its own default — callers should treat null as "no region hint". + /// Extend the map as new markets are onboarded. + /// + public static class PhoneRegionHelper + { + private static readonly Dictionary NameToIso = new Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + { "United States", "us" }, + { "Canada", "ca" }, + { "United Kingdom", "gb" }, + { "Australia", "au" }, + { "New Zealand", "nz" }, + { "Ireland", "ie" }, + { "South Africa", "za" }, + { "Namibia", "na" }, + { "Botswana", "bw" }, + { "Zimbabwe", "zw" }, + { "Kenya", "ke" }, + { "Nigeria", "ng" }, + { "Ghana", "gh" }, + { "India", "in" }, + { "Germany", "de" }, + { "France", "fr" }, + { "Spain", "es" }, + { "Italy", "it" }, + { "Netherlands", "nl" }, + { "Belgium", "be" }, + { "Switzerland", "ch" }, + { "Austria", "at" }, + { "Sweden", "se" }, + { "Norway", "no" }, + { "Denmark", "dk" }, + { "Finland", "fi" }, + { "Portugal", "pt" }, + { "Poland", "pl" }, + { "Mexico", "mx" }, + { "Brazil", "br" }, + { "Argentina", "ar" }, + { "Chile", "cl" }, + { "Japan", "jp" }, + { "China", "cn" }, + { "Singapore", "sg" }, + { "Philippines", "ph" }, + { "Malaysia", "my" }, + { "Indonesia", "id" }, + { "United Arab Emirates", "ae" }, + { "Saudi Arabia", "sa" }, + { "Israel", "il" }, + }; + + /// Returns the ISO-3166 alpha-2 region code for a country name, or null when not mapped/blank. + public static string ToIso(string countryName) + { + if (string.IsNullOrWhiteSpace(countryName)) + return null; + + return NameToIso.TryGetValue(countryName.Trim(), out var iso) ? iso : null; + } + } +} diff --git a/Core/Resgrid.Framework/SmsContentHelper.cs b/Core/Resgrid.Framework/SmsContentHelper.cs new file mode 100644 index 000000000..53d0df886 --- /dev/null +++ b/Core/Resgrid.Framework/SmsContentHelper.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Resgrid.Framework +{ + /// + /// Prepares outbound SMS content for deliverability and cost. US carriers (A2P 10DLC) filter messages that + /// contain public/unbranded links (bit.ly, tinyurl, unknown domains), so links whose host is not on the supplied + /// allow-list are removed. Common non-GSM-7 characters are normalized so a stray smart-quote/em-dash doesn't flip + /// the whole message into costlier UCS-2 encoding (67 vs 153 chars per segment), and the body is capped so we + /// never pay for an excessively long message (or get rejected for length - Twilio error 21617 at 1600 chars). + /// Pure (no config dependency): callers pass the allow-list + max length. + /// + public static class SmsContentHelper + { + private const string TruncationSuffix = "... (open Resgrid for the full message)"; + + // Only scheme-qualified (http/https) or www-prefixed links are treated as URLs. Matching bare "domain.tld" + // would mangle ordinary text like "U.S.", "9-1-1", or "400 Olive St." that appears in real messages. + private static readonly Regex UrlRegex = new Regex( + @"(?:https?://|www\.)\S+", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex MultiSpaceRegex = new Regex(@"[ \t]{2,}", RegexOptions.Compiled); + + /// Strip-disallowed-URLs, normalize, then truncate. Returns a carrier-safe, cost-bounded body. + public static string PrepareForSms(string message, int maxLength, IEnumerable allowedUrlDomains) + { + if (string.IsNullOrEmpty(message)) + return message; + + message = StripDisallowedUrls(message, allowedUrlDomains); + message = NormalizeForGsm(message); + message = Truncate(message, maxLength); + return message; + } + + /// Removes any URL whose host is not on the allow-list (host or a subdomain of an allowed domain). + public static string StripDisallowedUrls(string message, IEnumerable allowedUrlDomains) + { + if (string.IsNullOrEmpty(message)) + return message; + + var allowed = (allowedUrlDomains ?? Enumerable.Empty()) + .Where(d => !string.IsNullOrWhiteSpace(d)) + .Select(d => d.Trim().TrimStart('.').ToLowerInvariant()) + .ToList(); + + var stripped = false; + var result = UrlRegex.Replace(message, match => + { + if (IsAllowedUrl(match.Value, allowed)) + return match.Value; + + stripped = true; + return string.Empty; + }); + + if (!stripped) + return message; + + // Collapse whitespace / dangling separators left where a link was removed. + result = MultiSpaceRegex.Replace(result, " "); + result = result.Replace(" .", ".").Replace(" ,", ",").Replace("( )", "").Replace("()", ""); + return result.Trim(); + } + + private static bool IsAllowedUrl(string token, List allowedDomains) + { + var host = ExtractHost(token); + if (string.IsNullOrEmpty(host)) + return false; + + return allowedDomains.Any(d => + host.Equals(d, StringComparison.OrdinalIgnoreCase) || + host.EndsWith("." + d, StringComparison.OrdinalIgnoreCase)); + } + + private static string ExtractHost(string token) + { + var s = token.Trim(); + + var scheme = s.IndexOf("://", StringComparison.Ordinal); + if (scheme >= 0) + s = s.Substring(scheme + 3); + + // Drop anything from the first path/query/fragment separator onward. + var sep = s.IndexOfAny(new[] { '/', '?', '#', '\\' }); + if (sep >= 0) + s = s.Substring(0, sep); + + var at = s.IndexOf('@'); // strip userinfo + if (at >= 0) s = s.Substring(at + 1); + + var colon = s.IndexOf(':'); // strip port + if (colon >= 0) s = s.Substring(0, colon); + + return s.Trim().Trim('.').ToLowerInvariant(); + } + + /// Replaces the most common copy-paste non-GSM-7 characters so the body stays single-byte (cheaper). + public static string NormalizeForGsm(string message) + { + if (string.IsNullOrEmpty(message)) + return message; + + return message + .Replace('‘', '\'').Replace('’', '\'') // curly single quotes / smart apostrophe + .Replace('“', '"').Replace('”', '"') // curly double quotes + .Replace('–', '-').Replace('—', '-') // en dash / em dash + .Replace("…", "...") // horizontal ellipsis + .Replace(' ', ' '); // non-breaking space + } + + /// Truncates to with a clear suffix when over the limit. + public static string Truncate(string message, int maxLength) + { + if (maxLength <= 0 || string.IsNullOrEmpty(message) || message.Length <= maxLength) + return message; + + if (maxLength <= TruncationSuffix.Length) + return message.Substring(0, maxLength); + + return message.Substring(0, maxLength - TruncationSuffix.Length) + TruncationSuffix; + } + } +} diff --git a/Core/Resgrid.Model/Address.cs b/Core/Resgrid.Model/Address.cs index 2d93b15c9..332360fb8 100644 --- a/Core/Resgrid.Model/Address.cs +++ b/Core/Resgrid.Model/Address.cs @@ -17,27 +17,27 @@ public class Address : IEntity public int AddressId { get; set; } [Required] - [MaxLength(200)] + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] [Display(Name = "Street Address")] [ProtoMember(2)] public string Address1 { get; set; } [Required] - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] [ProtoMember(3)] public string City { get; set; } [Required] - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] [ProtoMember(4)] public string State { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] [ProtoMember(5)] public string PostalCode { get; set; } [Required] - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] [ProtoMember(6)] public string Country { get; set; } diff --git a/Core/Resgrid.Model/CheckInRecord.cs b/Core/Resgrid.Model/CheckInRecord.cs index ccea2fdb3..56f8f9ba2 100644 --- a/Core/Resgrid.Model/CheckInRecord.cs +++ b/Core/Resgrid.Model/CheckInRecord.cs @@ -27,6 +27,12 @@ public class CheckInRecord : IEntity public string Note { get; set; } + /// + /// Client-supplied idempotency key (the offline outbox event id). When set, a replayed check-in carrying the + /// same key returns the original record instead of inserting a duplicate. See offline-first-architecture.md. + /// + public string IdempotencyKey { get; set; } + [NotMapped] public string TableName => "CheckInRecords"; diff --git a/Core/Resgrid.Model/IChangeTracked.cs b/Core/Resgrid.Model/IChangeTracked.cs new file mode 100644 index 000000000..a809a0e61 --- /dev/null +++ b/Core/Resgrid.Model/IChangeTracked.cs @@ -0,0 +1,14 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// Entities that carry a change cursor. It is stamped on every insert and update + /// and drives two offline-first concerns: the delta-sync "changed since" query (pull) and last-write-wins + /// conflict resolution on reconnect. See docs/architecture/offline-first-architecture.md. + /// + public interface IChangeTracked + { + DateTime? ModifiedOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs index 5b705a742..485dd235f 100644 --- a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs +++ b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs @@ -9,7 +9,7 @@ namespace Resgrid.Model /// A live lane / span-of-control node on the command board (Division, Group, Branch, Staging, ...). /// Initially seeded from a CommandDefinitionRole then per-incident editable. /// - public class CommandStructureNode : IEntity + public class CommandStructureNode : IEntity, IChangeTracked { public string CommandStructureNodeId { get; set; } @@ -36,6 +36,12 @@ public class CommandStructureNode : IEntity /// The CommandDefinitionRole this node was seeded from, if any. public int? SourceRoleId { get; set; } + /// Soft-delete tombstone so a lane removed offline propagates on delta sync (null = live). + public DateTime? DeletedOn { get; set; } + + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "CommandStructureNodes"; @@ -61,7 +67,7 @@ public object IdValue /// Assigns a resource to a command structure node. Polymorphic: the resource may be an own-department /// unit/person, a linked (mutual-aid) department unit/person, or an incident ad-hoc unit/person. /// - public class ResourceAssignment : IEntity + public class ResourceAssignment : IEntity, IChangeTracked { public string ResourceAssignmentId { get; set; } @@ -85,6 +91,9 @@ public class ResourceAssignment : IEntity public DateTime? ReleasedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "ResourceAssignments"; diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs b/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs index 816f0703b..5895ff7aa 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs @@ -9,7 +9,7 @@ namespace Resgrid.Model /// An incident-scoped, ad-hoc unit created on the fly for resources not in Resgrid (e.g. a mutual-aid /// crew from a non-Resgrid agency, or a unit formed from on-scene personnel). Not a real department Unit. /// - public class IncidentAdHocUnit : IEntity + public class IncidentAdHocUnit : IEntity, IChangeTracked { public string IncidentAdHocUnitId { get; set; } @@ -34,6 +34,9 @@ public class IncidentAdHocUnit : IEntity public DateTime? ReleasedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentAdHocUnits"; @@ -59,7 +62,7 @@ public object IdValue /// An incident-scoped, ad-hoc person created on the fly for resources not in Resgrid. May ride an ad-hoc /// (or real) unit for accountability via + . /// - public class IncidentAdHocPersonnel : IEntity + public class IncidentAdHocPersonnel : IEntity, IChangeTracked { public string IncidentAdHocPersonnelId { get; set; } @@ -88,6 +91,9 @@ public class IncidentAdHocPersonnel : IEntity public DateTime? ReleasedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentAdHocPersonnel"; diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs index 698815472..c9fa8c0df 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs @@ -9,7 +9,7 @@ namespace Resgrid.Model /// A live incident-command instance established on a specific Call. Seeded (optionally) from a /// CommandDefinition template and then freely editable by the Commander for the life of the incident. /// - public class IncidentCommand : IEntity + public class IncidentCommand : IEntity, IChangeTracked { public string IncidentCommandId { get; set; } @@ -40,6 +40,9 @@ public class IncidentCommand : IEntity public DateTime? ClosedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentCommands"; diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs new file mode 100644 index 000000000..fbc33ef8b --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Delta payload for offline sync: every change-tracked incident-command row whose + /// (or, for the append-only timeline, OccurredOn) is newer than the client's last sync cursor, scoped to a department. + /// Soft-deleted / closed / released rows ARE included (with their state columns set) so the client can remove or + /// update them locally. The client stores and passes it back as the next `since`. + /// Ad-hoc resources are not change-tracked and are pulled separately (full refetch). See + /// docs/architecture/offline-first-architecture.md. + /// + public class IncidentCommandChanges + { + /// Server clock (Unix epoch ms) captured at the start of the read; the client's next-sync cursor. + public long ServerTimestampMs { get; set; } + + public List Commands { get; set; } = new List(); + + public List Nodes { get; set; } = new List(); + + public List Assignments { get; set; } = new List(); + + public List Objectives { get; set; } = new List(); + + public List Timers { get; set; } = new List(); + + public List Annotations { get; set; } = new List(); + + public List Roles { get; set; } = new List(); + + public List AdHocUnits { get; set; } = new List(); + + public List AdHocPersonnel { get; set; } = new List(); + + public List TimelineEntries { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs index c12583610..37c6d83c1 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs @@ -149,7 +149,7 @@ public static IncidentCapabilities GetCapabilities(IncidentRoleType role) /// Assigns a Resgrid user to a functional incident-command role for a specific incident (Call). Incident-scoped, /// not a department-wide claim. Optionally scoped to a structure node for supervisors. /// - public class IncidentRoleAssignment : IEntity + public class IncidentRoleAssignment : IEntity, IChangeTracked { public string IncidentRoleAssignmentId { get; set; } @@ -174,6 +174,9 @@ public class IncidentRoleAssignment : IEntity public DateTime? RemovedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentRoleAssignments"; diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs b/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs index deeada03f..6c70f361c 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs @@ -6,7 +6,7 @@ namespace Resgrid.Model { /// A tactical objective / benchmark for an incident (e.g. "Primary search complete"). - public class TacticalObjective : IEntity + public class TacticalObjective : IEntity, IChangeTracked { public string TacticalObjectiveId { get; set; } @@ -32,6 +32,9 @@ public class TacticalObjective : IEntity public int SortOrder { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "TacticalObjectives"; @@ -57,7 +60,7 @@ public object IdValue /// A scene / benchmark / role timer for an incident. Personnel accountability (PAR) is handled by the /// Checkin feature, not by these timers. /// - public class IncidentTimer : IEntity + public class IncidentTimer : IEntity, IChangeTracked { public string IncidentTimerId { get; set; } @@ -89,6 +92,9 @@ public class IncidentTimer : IEntity public DateTime? AcknowledgedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentTimers"; @@ -111,7 +117,7 @@ public object IdValue } /// A real-time map annotation (markup) on the tactical map, synced across devices. - public class IncidentMapAnnotation : IEntity + public class IncidentMapAnnotation : IEntity, IChangeTracked { public string IncidentMapAnnotationId { get; set; } @@ -138,6 +144,9 @@ public class IncidentMapAnnotation : IEntity public DateTime? DeletedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentMapAnnotations"; diff --git a/Core/Resgrid.Model/Services/IIncidentCommandService.cs b/Core/Resgrid.Model/Services/IIncidentCommandService.cs index eef68d477..5f899093a 100644 --- a/Core/Resgrid.Model/Services/IIncidentCommandService.cs +++ b/Core/Resgrid.Model/Services/IIncidentCommandService.cs @@ -19,6 +19,13 @@ public interface IIncidentCommandService Task GetCommandBoardAsync(int departmentId, int callId); Task> GetAccountabilityForCallAsync(int departmentId, int callId); + /// + /// Offline-first delta pull: returns every change-tracked incident-command row (and append-only timeline entry) + /// for the department whose ModifiedOn/OccurredOn is newer than . Includes + /// soft-deleted/closed/released rows so a reconnecting client can reconcile removals. See the offline-first doc. + /// + Task GetChangesSinceAsync(int departmentId, System.DateTime sinceUtc); + /// /// Sweeps personnel accountability (PAR) for the call and raises CriticalParDetectedEvent once per /// member each time they transition into the Critical (overdue) state. Idempotent via a timeline marker — diff --git a/Core/Resgrid.Model/Services/IIncidentResourcesService.cs b/Core/Resgrid.Model/Services/IIncidentResourcesService.cs index 2d3a6d2f0..b99bfffc0 100644 --- a/Core/Resgrid.Model/Services/IIncidentResourcesService.cs +++ b/Core/Resgrid.Model/Services/IIncidentResourcesService.cs @@ -26,5 +26,12 @@ public interface IIncidentResourcesService /// Forms a new ad-hoc unit and attaches the given ad-hoc personnel to it as its roster. Task FormUnitFromPersonnelAsync(IncidentAdHocUnit unit, List adHocPersonnelIds, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Offline-first delta pull for ad-hoc resources: returns the department's ad-hoc units and personnel whose + /// ModifiedOn is newer than (released rows included so the client reconciles them). + /// Aggregated into the unified /Sync/Changes payload by SyncController. See offline-first-architecture.md. + /// + Task<(List Units, List Personnel)> GetAdHocChangesSinceAsync(int departmentId, System.DateTime sinceUtc); } } diff --git a/Core/Resgrid.Services/CheckInTimerService.cs b/Core/Resgrid.Services/CheckInTimerService.cs index 96d33c0d4..203bb5a1c 100644 --- a/Core/Resgrid.Services/CheckInTimerService.cs +++ b/Core/Resgrid.Services/CheckInTimerService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data.Common; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -320,8 +321,38 @@ public async Task> ResolveAllTimersForCallAsync(Call public async Task PerformCheckInAsync(CheckInRecord record, CancellationToken cancellationToken = default) { + // Offline idempotency: when the client supplies its outbox event id, a replayed check-in returns the + // original record instead of inserting a duplicate. Dedup is in-memory over the call's (small) check-in + // set, so only replayed events — which carry a key — pay the extra read; live UI check-ins skip it. + if (!string.IsNullOrWhiteSpace(record.IdempotencyKey)) + { + var existing = await _recordRepository.GetByCallIdAsync(record.CallId); + var duplicate = existing?.FirstOrDefault(r => r.IdempotencyKey == record.IdempotencyKey); + if (duplicate != null) + return duplicate; + } + record.Timestamp = DateTime.UtcNow; - var saved = await _recordRepository.SaveOrUpdateAsync(record, cancellationToken); + + CheckInRecord saved; + try + { + saved = await _recordRepository.SaveOrUpdateAsync(record, cancellationToken); + } + catch (DbException) when (!string.IsNullOrWhiteSpace(record.IdempotencyKey)) + { + // The in-memory pre-check above is check-then-insert and races under concurrent replays; the filtered + // unique index (UX_CheckInRecords_Department_IdempotencyKey on SQL Server / ux_..._idempotencykey on + // PostgreSQL) is the real guard. On that race, adopt the winner — same idempotent result as the + // pre-check — rather than 500ing (which would just trigger another retry). Scope is DbException only + // (covers PG 23505 + SQL Server 2601/2627, both derive from it); any non-DB failure, or a DbException + // with no matching winner, propagates below instead of being masked as a replay. + var existing = await _recordRepository.GetByCallIdAsync(record.CallId); + var winner = existing?.FirstOrDefault(r => r.IdempotencyKey == record.IdempotencyKey); + if (winner != null) + return winner; + throw; + } // Real-time board refresh is best-effort: the check-in is already persisted, so a CQRS/Redis // publish failure must not fail the check-in — that would 500 the caller and a retry would diff --git a/Core/Resgrid.Services/ContactVerificationService.cs b/Core/Resgrid.Services/ContactVerificationService.cs index 0c8a0b122..c4848f80c 100644 --- a/Core/Resgrid.Services/ContactVerificationService.cs +++ b/Core/Resgrid.Services/ContactVerificationService.cs @@ -22,6 +22,7 @@ public sealed class ContactVerificationService : IContactVerificationService private readonly ISystemAuditsService _systemAuditsService; private readonly IEncryptionService _encryptionService; private readonly IOutboundVoiceProvider _outboundVoiceProvider; + private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; public ContactVerificationService( IUserProfileService userProfileService, @@ -30,7 +31,8 @@ public ContactVerificationService( ISmsService smsService, ISystemAuditsService systemAuditsService, IEncryptionService encryptionService, - IOutboundVoiceProvider outboundVoiceProvider) + IOutboundVoiceProvider outboundVoiceProvider, + IPhoneNumberProcesserProvider phoneNumberProcesser) { _userProfileService = userProfileService; _usersService = usersService; @@ -39,6 +41,7 @@ public ContactVerificationService( _systemAuditsService = systemAuditsService; _encryptionService = encryptionService; _outboundVoiceProvider = outboundVoiceProvider; + _phoneNumberProcesser = phoneNumberProcesser; } public async Task SendEmailVerificationCodeAsync(string userId, int departmentId, CancellationToken cancellationToken = default) @@ -80,6 +83,16 @@ public async Task SendMobileVerificationCodeAsync(string userId, int depar if (profile == null || string.IsNullOrWhiteSpace(profile.MobileNumber)) return false; + // Normalize to E.164 and validate before sending so an invalid/local-format number (e.g. a bare + // "082446..." with no country code) is rejected here instead of throwing a Twilio "Invalid 'To'" error. + var mobileResult = _phoneNumberProcesser.Process(profile.GetPhoneNumber()); + if (mobileResult == null || !mobileResult.IsValid || string.IsNullOrWhiteSpace(mobileResult.InternationalNumber)) + { + Logging.LogInfo($"Mobile verification SMS skipped for user {userId}: phone number is not a valid sendable number (needs international format, e.g. +)."); + await WriteAuditAsync(userId, departmentId, ContactVerificationType.MobileNumber, false, "Send-InvalidNumber", null, cancellationToken); + return false; + } + if (!IsWithinHourlySendLimit(profile.MobileVerificationCodeExpiry, profile.MobileVerificationAttempts)) return false; @@ -90,7 +103,7 @@ public async Task SendMobileVerificationCodeAsync(string userId, int depar await _userProfileService.SaveProfileAsync(departmentId, profile, cancellationToken); - bool sent = await _smsService.SendSmsVerificationCodeAsync(profile.GetPhoneNumber(), code, departmentNumber); + bool sent = await _smsService.SendSmsVerificationCodeAsync(mobileResult.InternationalNumber, code, departmentNumber); await WriteAuditAsync(userId, departmentId, ContactVerificationType.MobileNumber, sent, "Send", null, cancellationToken); @@ -103,6 +116,15 @@ public async Task SendHomeVerificationCodeAsync(string userId, int departm if (profile == null || string.IsNullOrWhiteSpace(profile.HomeNumber)) return false; + // Validate/normalize before placing the Twilio voice call so an invalid number doesn't throw "Invalid 'To'". + var homeResult = _phoneNumberProcesser.Process(profile.GetHomePhoneNumber()); + if (homeResult == null || !homeResult.IsValid || string.IsNullOrWhiteSpace(homeResult.InternationalNumber)) + { + Logging.LogInfo($"Home verification call skipped for user {userId}: phone number is not a valid sendable number."); + await WriteAuditAsync(userId, departmentId, ContactVerificationType.HomeNumber, false, "SendVoice-InvalidNumber", null, cancellationToken); + return false; + } + if (!IsWithinHourlySendLimit(profile.HomeVerificationCodeExpiry, profile.HomeVerificationAttempts)) return false; @@ -117,7 +139,7 @@ public async Task SendHomeVerificationCodeAsync(string userId, int departm // landlines that cannot receive text messages. The call speaks the digits // of the verification code, repeating multiple times so the user can note them. bool sent = await _outboundVoiceProvider.SendVoiceVerificationCallAsync( - profile.GetHomePhoneNumber(), userId, (int)ContactVerificationType.HomeNumber); + homeResult.InternationalNumber, userId, (int)ContactVerificationType.HomeNumber); await WriteAuditAsync(userId, departmentId, ContactVerificationType.HomeNumber, sent, "SendVoice", null, cancellationToken); diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 01749caa1..5261d0148 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -98,7 +98,9 @@ public IncidentCommandService( try { - command = await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken); + // Explicit insert: the GUID is pre-set, and SaveOrUpdateAsync would treat a non-empty IdType-1 id + // as an UPDATE (zero rows) instead of an insert. + command = await _incidentCommandRepository.InsertAsync(Touch(command), cancellationToken); } catch (Exception) { @@ -141,7 +143,7 @@ public IncidentCommandService( SourceRoleId = role.CommandDefinitionRoleId }; - await _commandStructureNodeRepository.SaveOrUpdateAsync(node, cancellationToken); + await _commandStructureNodeRepository.InsertAsync(Touch(node), cancellationToken); } } } @@ -259,23 +261,20 @@ private async Task EnableAccountabilityIfConfiguredAsync(int departmentId, int c return null; assignment.CallId = command.CallId; - if (string.IsNullOrWhiteSpace(assignment.IncidentRoleAssignmentId)) - { - assignment.IncidentRoleAssignmentId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _incidentRoleAssignmentRepository.GetByIdAsync(assignment.IncidentRoleAssignmentId); - if (existing == null || existing.DepartmentId != assignment.DepartmentId) - return null; - } - assignment.AssignedByUserId = userId; if (assignment.AssignedOn == default(DateTime)) assignment.AssignedOn = DateTime.UtcNow; - assignment = await _incidentRoleAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + var (saved, _, rejected) = await UpsertOwnedAsync(_incidentRoleAssignmentRepository, assignment, assignment.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + incoming.AssignedOn = stored.AssignedOn; + incoming.AssignedByUserId = stored.AssignedByUserId; + incoming.RemovedOn = stored.RemovedOn; + }, cancellationToken); + if (rejected) + return null; + assignment = saved; await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleAssigned, $"Role {(IncidentRoleType)assignment.RoleType} assigned", userId, cancellationToken); @@ -290,7 +289,7 @@ private async Task EnableAccountabilityIfConfiguredAsync(int departmentId, int c return false; assignment.RemovedOn = DateTime.UtcNow; - await _incidentRoleAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + await _incidentRoleAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleRemoved, $"Role {(IncidentRoleType)assignment.RoleType} removed", userId, cancellationToken); return true; @@ -352,6 +351,56 @@ public async Task GetCommandBoardAsync(int departmentId, i return board; } + public async Task GetChangesSinceAsync(int departmentId, DateTime sinceUtc) + { + // Capture the cursor before reading so a row committed during the read is not missed next time (it may be + // returned again on the next sync — harmless, the client upserts idempotently). + var changes = new IncidentCommandChanges { ServerTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + + // On an initial full sync (since=0 → DateTime.MinValue) return EVERY row — including any with a null + // ModifiedOn (e.g. rows created before the change-tracking column existed) — so the first pull is complete; + // incremental syncs keep the strict "changed since the cursor" filter. Method-group conversion is + // contravariance-aware, so this Func binds to each entity-typed Where(). Soft-deleted/ + // closed/released rows are intentionally surfaced (with their state columns) so the client reconciles them. + var fullSync = sinceUtc == DateTime.MinValue; + bool Changed(IChangeTracked e) => fullSync || (e.ModifiedOn.HasValue && e.ModifiedOn.Value > sinceUtc); + + var commands = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId); + if (commands != null) + changes.Commands = commands.Where(Changed).ToList(); + + var nodes = await _commandStructureNodeRepository.GetAllByDepartmentIdAsync(departmentId); + if (nodes != null) + changes.Nodes = nodes.Where(Changed).ToList(); + + var assignments = await _resourceAssignmentRepository.GetAllByDepartmentIdAsync(departmentId); + if (assignments != null) + changes.Assignments = assignments.Where(Changed).ToList(); + + var objectives = await _tacticalObjectiveRepository.GetAllByDepartmentIdAsync(departmentId); + if (objectives != null) + changes.Objectives = objectives.Where(Changed).ToList(); + + var timers = await _incidentTimerRepository.GetAllByDepartmentIdAsync(departmentId); + if (timers != null) + changes.Timers = timers.Where(Changed).ToList(); + + var annotations = await _incidentMapAnnotationRepository.GetAllByDepartmentIdAsync(departmentId); + if (annotations != null) + changes.Annotations = annotations.Where(Changed).ToList(); + + var roles = await _incidentRoleAssignmentRepository.GetAllByDepartmentIdAsync(departmentId); + if (roles != null) + changes.Roles = roles.Where(Changed).ToList(); + + // The timeline is append-only (no ModifiedOn); its natural cursor is OccurredOn. + var timeline = await _commandLogEntryRepository.GetAllByDepartmentIdAsync(departmentId); + if (timeline != null) + changes.TimelineEntries = timeline.Where(x => x.OccurredOn > sinceUtc).ToList(); + + return changes; + } + public async Task CloseCommandAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var command = await _incidentCommandRepository.GetByIdAsync(incidentCommandId); @@ -360,7 +409,7 @@ public async Task GetCommandBoardAsync(int departmentId, i command.Status = (int)IncidentCommandStatus.Closed; command.ClosedOn = DateTime.UtcNow; - command = await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken); + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandClosed, "Command closed", userId, cancellationToken); @@ -378,7 +427,7 @@ public async Task GetCommandBoardAsync(int departmentId, i return null; command.CurrentCommanderUserId = toUserId; - await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken); + await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); var transfer = new CommandTransfer { @@ -391,7 +440,7 @@ public async Task GetCommandBoardAsync(int departmentId, i TransferredOn = DateTime.UtcNow, Notes = notes }; - transfer = await _commandTransferRepository.SaveOrUpdateAsync(transfer, cancellationToken); + transfer = await _commandTransferRepository.InsertAsync(transfer, cancellationToken); await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandTransferred, "Command transferred", fromUserId, cancellationToken); @@ -406,7 +455,7 @@ public async Task GetCommandBoardAsync(int departmentId, i return null; command.IncidentActionPlan = actionPlan; - command = await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken); + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.Note, "Incident action plan updated", userId, cancellationToken); return command; @@ -426,20 +475,11 @@ public async Task GetCommandBoardAsync(int departmentId, i return null; node.CallId = command.CallId; - var isNew = string.IsNullOrWhiteSpace(node.CommandStructureNodeId); - if (isNew) - { - node.CommandStructureNodeId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department (no foreign-row takeover). - var existing = await _commandStructureNodeRepository.GetByIdAsync(node.CommandStructureNodeId); - if (existing == null || existing.DepartmentId != node.DepartmentId) - return null; - } - - node = await _commandStructureNodeRepository.SaveOrUpdateAsync(node, cancellationToken); + var (saved, isNew, rejected) = await UpsertOwnedAsync(_commandStructureNodeRepository, node, node.DepartmentId, + e => e.DepartmentId, (stored, incoming) => incoming.DeletedOn = stored.DeletedOn, cancellationToken); + if (rejected) + return null; + node = saved; await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, isNew ? CommandLogEntryType.NodeAdded : CommandLogEntryType.NodeUpdated, @@ -453,10 +493,13 @@ await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, if (node == null || node.DepartmentId != departmentId) return false; - var result = await _commandStructureNodeRepository.DeleteAsync(node, cancellationToken); + // Soft-delete (tombstone) rather than hard-delete so the removal propagates to offline clients on the + // next delta sync; ModifiedOn is stamped so the change is picked up by the "changed since" query. + node.DeletedOn = DateTime.UtcNow; + await _commandStructureNodeRepository.SaveOrUpdateAsync(Touch(node), cancellationToken); await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, CommandLogEntryType.NodeRemoved, $"Lane '{node.Name}' removed", userId, cancellationToken); - return result; + return true; } public async Task> GetNodesForCallAsync(int departmentId, int callId) @@ -465,7 +508,7 @@ public async Task> GetNodesForCallAsync(int departmen if (items == null) return new List(); - return items.Where(x => x.CallId == callId).OrderBy(x => x.SortOrder).ToList(); + return items.Where(x => x.CallId == callId && x.DeletedOn == null).OrderBy(x => x.SortOrder).ToList(); } #endregion Structure (lanes) @@ -481,23 +524,20 @@ public async Task> GetNodesForCallAsync(int departmen return null; assignment.CallId = command.CallId; - if (string.IsNullOrWhiteSpace(assignment.ResourceAssignmentId)) - { - assignment.ResourceAssignmentId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _resourceAssignmentRepository.GetByIdAsync(assignment.ResourceAssignmentId); - if (existing == null || existing.DepartmentId != assignment.DepartmentId) - return null; - } - if (assignment.AssignedOn == default(DateTime)) assignment.AssignedOn = DateTime.UtcNow; - assignment.AssignedByUserId = userId; - assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + + var (saved, _, rejected) = await UpsertOwnedAsync(_resourceAssignmentRepository, assignment, assignment.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + incoming.AssignedOn = stored.AssignedOn; + incoming.AssignedByUserId = stored.AssignedByUserId; + incoming.ReleasedOn = stored.ReleasedOn; + }, cancellationToken); + if (rejected) + return null; + assignment = saved; await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceAssigned, "Resource assigned", userId, cancellationToken); @@ -518,7 +558,7 @@ public async Task> GetNodesForCallAsync(int departmen return null; assignment.CommandStructureNodeId = targetNodeId; - assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceMoved, "Resource moved", userId, cancellationToken); return assignment; @@ -531,7 +571,7 @@ public async Task> GetNodesForCallAsync(int departmen return false; assignment.ReleasedOn = DateTime.UtcNow; - await _resourceAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + await _resourceAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceReleased, "Resource released", userId, cancellationToken); @@ -561,20 +601,17 @@ public async Task> GetAssignmentsForCallAsync(int depar return null; objective.CallId = command.CallId; - var isNew = string.IsNullOrWhiteSpace(objective.TacticalObjectiveId); - if (isNew) - { - objective.TacticalObjectiveId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _tacticalObjectiveRepository.GetByIdAsync(objective.TacticalObjectiveId); - if (existing == null || existing.DepartmentId != objective.DepartmentId) - return null; - } - - objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(objective, cancellationToken); + var (saved, isNew, rejected) = await UpsertOwnedAsync(_tacticalObjectiveRepository, objective, objective.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + // Completion is owned by CompleteObjectiveAsync; a Save (edit/replay) must not reset it. + incoming.Status = stored.Status; + incoming.CompletedByUserId = stored.CompletedByUserId; + incoming.CompletedOn = stored.CompletedOn; + }, cancellationToken); + if (rejected) + return null; + objective = saved; if (isNew) await WriteLogAsync(objective.IncidentCommandId, objective.DepartmentId, objective.CallId, CommandLogEntryType.ObjectiveAdded, $"Objective '{objective.Name}' added", userId, cancellationToken); @@ -591,7 +628,7 @@ public async Task> GetAssignmentsForCallAsync(int depar objective.Status = (int)TacticalObjectiveStatus.Complete; objective.CompletedByUserId = userId; objective.CompletedOn = DateTime.UtcNow; - objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(objective, cancellationToken); + objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(Touch(objective), cancellationToken); await WriteLogAsync(objective.IncidentCommandId, objective.DepartmentId, objective.CallId, CommandLogEntryType.ObjectiveCompleted, $"Objective '{objective.Name}' completed", userId, cancellationToken); @@ -621,24 +658,23 @@ public async Task> GetObjectivesForCallAsync(int departm return null; timer.CallId = command.CallId; - if (string.IsNullOrWhiteSpace(timer.IncidentTimerId)) - { - timer.IncidentTimerId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _incidentTimerRepository.GetByIdAsync(timer.IncidentTimerId); - if (existing == null || existing.DepartmentId != timer.DepartmentId) - return null; - } - timer.StartedOn = DateTime.UtcNow; timer.Status = (int)IncidentTimerStatus.Running; if (timer.IntervalSeconds > 0) timer.NextDueOn = timer.StartedOn.AddSeconds(timer.IntervalSeconds); - timer = await _incidentTimerRepository.SaveOrUpdateAsync(timer, cancellationToken); + var (saved, _, rejected) = await UpsertOwnedAsync(_incidentTimerRepository, timer, timer.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + // Existing id => a replayed start; keep the original run state rather than restarting the timer. + incoming.StartedOn = stored.StartedOn; + incoming.Status = stored.Status; + incoming.NextDueOn = stored.NextDueOn; + incoming.AcknowledgedOn = stored.AcknowledgedOn; + }, cancellationToken); + if (rejected) + return null; + timer = saved; await WriteLogAsync(timer.IncidentCommandId, timer.DepartmentId, timer.CallId, CommandLogEntryType.TimerStarted, $"Timer '{timer.Name}' started", userId, cancellationToken); return timer; @@ -655,7 +691,7 @@ public async Task> GetObjectivesForCallAsync(int departm if (timer.IntervalSeconds > 0) timer.NextDueOn = timer.AcknowledgedOn.Value.AddSeconds(timer.IntervalSeconds); - timer = await _incidentTimerRepository.SaveOrUpdateAsync(timer, cancellationToken); + timer = await _incidentTimerRepository.SaveOrUpdateAsync(Touch(timer), cancellationToken); await WriteLogAsync(timer.IncidentCommandId, timer.DepartmentId, timer.CallId, CommandLogEntryType.TimerAcknowledged, $"Timer '{timer.Name}' acknowledged", userId, cancellationToken); return timer; @@ -683,22 +719,21 @@ public async Task> GetActiveTimersForCallAsync(int departmen return null; annotation.CallId = command.CallId; - var isNew = string.IsNullOrWhiteSpace(annotation.IncidentMapAnnotationId); - if (isNew) - { - annotation.IncidentMapAnnotationId = Guid.NewGuid().ToString(); + if (annotation.CreatedOn == default(DateTime)) annotation.CreatedOn = DateTime.UtcNow; + if (string.IsNullOrWhiteSpace(annotation.CreatedByUserId)) annotation.CreatedByUserId = userId; - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _incidentMapAnnotationRepository.GetByIdAsync(annotation.IncidentMapAnnotationId); - if (existing == null || existing.DepartmentId != annotation.DepartmentId) - return null; - } - annotation = await _incidentMapAnnotationRepository.SaveOrUpdateAsync(annotation, cancellationToken); + var (saved, isNew, rejected) = await UpsertOwnedAsync(_incidentMapAnnotationRepository, annotation, annotation.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + incoming.CreatedOn = stored.CreatedOn; + incoming.CreatedByUserId = stored.CreatedByUserId; + incoming.DeletedOn = stored.DeletedOn; + }, cancellationToken); + if (rejected) + return null; + annotation = saved; if (isNew) await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationAdded, "Map annotation added", userId, cancellationToken); @@ -713,7 +748,7 @@ public async Task> GetActiveTimersForCallAsync(int departmen return false; annotation.DeletedOn = DateTime.UtcNow; - await _incidentMapAnnotationRepository.SaveOrUpdateAsync(annotation, cancellationToken); + await _incidentMapAnnotationRepository.SaveOrUpdateAsync(Touch(annotation), cancellationToken); await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationRemoved, "Map annotation removed", userId, cancellationToken); return true; @@ -750,6 +785,55 @@ public async Task> GetTimelineForCallAsync(int departmentI #region Private helpers + /// + /// Stamps the offline-sync change cursor on an entity. Called on every insert and update so the delta + /// endpoint can surface the row as "changed since" and reconnect conflict resolution can compare write + /// times (last-write-wins). See docs/architecture/offline-first-architecture.md. + /// + private static T Touch(T entity) where T : IChangeTracked + { + entity.ModifiedOn = DateTime.UtcNow; + return entity; + } + + /// + /// Idempotent upsert for an owned child entity. Create-vs-update is resolved by the entity id's EXISTENCE + /// (not merely whether an id was supplied), which is what makes offline replay safe: + /// • no id, or a client-supplied id that does not exist yet -> INSERT with that (or a generated) GUID, so + /// an offline-created row replays without duplicating; + /// • id already present -> it must belong to (else rejected) and is + /// UPDATED, with copying server-owned fields off the stored row so a + /// replayed create payload cannot clobber them. + /// Returns rejected=true only for a foreign-department row. A plain SaveOrUpdateAsync cannot do the create + /// here: for string-GUID (IdType 1) entities it only inserts when the id is blank and otherwise issues a + /// blind UPDATE, so a client-supplied PK would silently update zero rows. See offline-first-architecture.md. + /// + private static async Task<(T entity, bool isNew, bool rejected)> UpsertOwnedAsync( + IRepository repository, T entity, int departmentId, Func departmentOf, + Action preserve, CancellationToken cancellationToken) where T : class, IEntity, IChangeTracked + { + var id = entity.IdValue?.ToString(); + + T stored = null; + if (!string.IsNullOrWhiteSpace(id)) + { + stored = await repository.GetByIdAsync(id); + if (stored != null && departmentOf(stored) != departmentId) + return (null, false, true); + } + + if (stored == null) + { + if (string.IsNullOrWhiteSpace(id)) + entity.IdValue = Guid.NewGuid().ToString(); + + return (await repository.InsertAsync(Touch(entity), cancellationToken), true, false); + } + + preserve?.Invoke(stored, entity); + return (await repository.SaveOrUpdateAsync(Touch(entity), cancellationToken), false, false); + } + /// /// Loads the parent incident command and returns it only if it belongs to the given department (else null). /// Gates create/update of child entities AND supplies the authoritative CallId to stamp onto them — a caller @@ -778,7 +862,7 @@ private async Task WriteLogAsync(string incidentCommandId, int OccurredOn = DateTime.UtcNow }; - var saved = await _commandLogEntryRepository.SaveOrUpdateAsync(entry, cancellationToken); + var saved = await _commandLogEntryRepository.InsertAsync(entry, cancellationToken); // Real-time: every command mutation flows through here, so push one board-changed signal. await _coreEventService.IncidentCommandUpdatedAsync(departmentId, callId); diff --git a/Core/Resgrid.Services/IncidentResourcesService.cs b/Core/Resgrid.Services/IncidentResourcesService.cs index ab7f0f352..1490c45dd 100644 --- a/Core/Resgrid.Services/IncidentResourcesService.cs +++ b/Core/Resgrid.Services/IncidentResourcesService.cs @@ -44,14 +44,25 @@ public IncidentResourcesService( if (command == null) return null; - if (string.IsNullOrWhiteSpace(unit.IncidentAdHocUnitId)) + // Idempotent create: the client may generate the GUID PK offline. If a row with that id already exists + // for this department the create was already applied (replay) — return it without duplicating. Otherwise + // INSERT explicitly; SaveOrUpdateAsync would treat the pre-set GUID as a 0-row UPDATE, not an insert. + if (!string.IsNullOrWhiteSpace(unit.IncidentAdHocUnitId)) + { + var stored = await _adHocUnitRepository.GetByIdAsync(unit.IncidentAdHocUnitId); + if (stored != null) + return stored.DepartmentId == unit.DepartmentId ? stored : null; + } + else + { unit.IncidentAdHocUnitId = Guid.NewGuid().ToString(); + } unit.CreatedByUserId = userId; if (unit.CreatedOn == default(DateTime)) unit.CreatedOn = DateTime.UtcNow; - unit = await _adHocUnitRepository.SaveOrUpdateAsync(unit, cancellationToken); + unit = await _adHocUnitRepository.InsertAsync(Touch(unit), cancellationToken); await LogAsync(unit.DepartmentId, unit.CallId, $"Ad-hoc unit '{unit.Name}' created", userId, cancellationToken); @@ -80,7 +91,7 @@ public async Task> GetAdHocUnitsForCallAsync(int departm return false; unit.ReleasedOn = DateTime.UtcNow; - await _adHocUnitRepository.SaveOrUpdateAsync(unit, cancellationToken); + await _adHocUnitRepository.SaveOrUpdateAsync(Touch(unit), cancellationToken); await LogAsync(unit.DepartmentId, unit.CallId, $"Ad-hoc unit '{unit.Name}' released", userId, cancellationToken); return true; @@ -97,14 +108,24 @@ public async Task> GetAdHocUnitsForCallAsync(int departm if (command == null) return null; - if (string.IsNullOrWhiteSpace(personnel.IncidentAdHocPersonnelId)) + // Idempotent create (see CreateAdHocUnitAsync): replay of an existing id returns the stored row; a new id + // is inserted explicitly (a pre-set GUID + SaveOrUpdateAsync would be a 0-row UPDATE, not an insert). + if (!string.IsNullOrWhiteSpace(personnel.IncidentAdHocPersonnelId)) + { + var stored = await _adHocPersonnelRepository.GetByIdAsync(personnel.IncidentAdHocPersonnelId); + if (stored != null) + return stored.DepartmentId == personnel.DepartmentId ? stored : null; + } + else + { personnel.IncidentAdHocPersonnelId = Guid.NewGuid().ToString(); + } personnel.CreatedByUserId = userId; if (personnel.CreatedOn == default(DateTime)) personnel.CreatedOn = DateTime.UtcNow; - personnel = await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken); + personnel = await _adHocPersonnelRepository.InsertAsync(Touch(personnel), cancellationToken); await LogAsync(personnel.DepartmentId, personnel.CallId, $"Ad-hoc personnel '{personnel.Name}' created", userId, cancellationToken); @@ -128,7 +149,7 @@ public async Task> GetAdHocPersonnelForCallAsync(in return false; personnel.ReleasedOn = DateTime.UtcNow; - await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken); + await _adHocPersonnelRepository.SaveOrUpdateAsync(Touch(personnel), cancellationToken); await LogAsync(personnel.DepartmentId, personnel.CallId, $"Ad-hoc personnel '{personnel.Name}' released", userId, cancellationToken); return true; @@ -146,7 +167,7 @@ public async Task> GetAdHocPersonnelForCallAsync(in personnel.RidingResourceKind = ridingResourceKind; personnel.RidingResourceId = ridingResourceId; - personnel = await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken); + personnel = await _adHocPersonnelRepository.SaveOrUpdateAsync(Touch(personnel), cancellationToken); await LogAsync(personnel.DepartmentId, personnel.CallId, $"'{personnel.Name}' added to unit roster", userId, cancellationToken); return personnel; @@ -170,7 +191,7 @@ public async Task> GetAdHocPersonnelForCallAsync(in personnel.RidingResourceKind = (int)ResourceAssignmentKind.AdHocUnit; personnel.RidingResourceId = createdUnit.IncidentAdHocUnitId; - await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken); + await _adHocPersonnelRepository.SaveOrUpdateAsync(Touch(personnel), cancellationToken); } } @@ -180,8 +201,33 @@ public async Task> GetAdHocPersonnelForCallAsync(in #endregion Roster building + #region Offline sync + + public async Task<(List Units, List Personnel)> GetAdHocChangesSinceAsync(int departmentId, DateTime sinceUtc) + { + // Full sync (since=0 → DateTime.MinValue) returns every row incl. null-ModifiedOn; incremental filters strictly. + var fullSync = sinceUtc == DateTime.MinValue; + bool Changed(IChangeTracked e) => fullSync || (e.ModifiedOn.HasValue && e.ModifiedOn.Value > sinceUtc); + + var units = await _adHocUnitRepository.GetAllByDepartmentIdAsync(departmentId); + var personnel = await _adHocPersonnelRepository.GetAllByDepartmentIdAsync(departmentId); + + return ( + units?.Where(Changed).ToList() ?? new List(), + personnel?.Where(Changed).ToList() ?? new List()); + } + + #endregion Offline sync + #region Private helpers + /// Stamps the offline-sync change cursor (ModifiedOn) on every insert/update. See offline-first-architecture.md. + private static T Touch(T entity) where T : IChangeTracked + { + entity.ModifiedOn = DateTime.UtcNow; + return entity; + } + private async Task LogAsync(int departmentId, int callId, string description, string userId, CancellationToken cancellationToken) { var command = await _incidentCommandService.GetActiveCommandForCallAsync(departmentId, callId); diff --git a/Core/Resgrid.Services/IncidentVoiceService.cs b/Core/Resgrid.Services/IncidentVoiceService.cs index 4d6106a80..85d501c5a 100644 --- a/Core/Resgrid.Services/IncidentVoiceService.cs +++ b/Core/Resgrid.Services/IncidentVoiceService.cs @@ -126,7 +126,8 @@ private async Task WriteLogAsync(int departmentId, int callId, CommandLogEntryTy OccurredOn = DateTime.UtcNow }; - await _commandLogEntryRepository.SaveOrUpdateAsync(entry, cancellationToken); + // Append-only insert: a pre-set GUID would make SaveOrUpdateAsync issue a 0-row UPDATE instead of inserting. + await _commandLogEntryRepository.InsertAsync(entry, cancellationToken); // Real-time: channel open/close is a board change. await _coreEventService.IncidentCommandUpdatedAsync(departmentId, callId); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs new file mode 100644 index 000000000..e6e23382a --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs @@ -0,0 +1,58 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Offline-first sync foundation: adds a ModifiedOn change cursor to the mutable incident-command tables + /// (delta "changed since" + last-write-wins) and a DeletedOn soft-delete tombstone to CommandStructureNodes + /// so a lane removed offline propagates on delta sync. Append-only tables (CommandLogEntries / CommandTransfers) + /// already carry a natural creation timestamp and are intentionally excluded. + /// See docs/architecture/offline-first-architecture.md. + /// + [Migration(81)] + public class M0081_AddIncidentCommandChangeTracking : Migration + { + private static readonly string[] ChangeTrackedTables = + { + "IncidentCommands", + "CommandStructureNodes", + "ResourceAssignments", + "TacticalObjectives", + "IncidentTimers", + "IncidentMapAnnotations", + "IncidentRoleAssignments" + }; + + public override void Up() + { + foreach (var table in ChangeTrackedTables) + { + if (Schema.Table(table).Exists() && !Schema.Table(table).Column("ModifiedOn").Exists()) + { + Alter.Table(table).AddColumn("ModifiedOn").AsDateTime2().Nullable(); + } + } + + if (Schema.Table("CommandStructureNodes").Exists() && !Schema.Table("CommandStructureNodes").Column("DeletedOn").Exists()) + { + Alter.Table("CommandStructureNodes").AddColumn("DeletedOn").AsDateTime2().Nullable(); + } + } + + public override void Down() + { + foreach (var table in ChangeTrackedTables) + { + if (Schema.Table(table).Exists() && Schema.Table(table).Column("ModifiedOn").Exists()) + { + Delete.Column("ModifiedOn").FromTable(table); + } + } + + if (Schema.Table("CommandStructureNodes").Exists() && Schema.Table("CommandStructureNodes").Column("DeletedOn").Exists()) + { + Delete.Column("DeletedOn").FromTable("CommandStructureNodes"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs new file mode 100644 index 000000000..509a93d0d --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs @@ -0,0 +1,34 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Offline-first action idempotency: a client-supplied key on check-in records so a replayed offline check-in + /// dedups instead of creating a duplicate row. See docs/architecture/offline-first-architecture.md. + /// + [Migration(82)] + public class M0082_AddCheckInRecordIdempotencyKey : Migration + { + public override void Up() + { + if (Schema.Table("CheckInRecords").Exists() && !Schema.Table("CheckInRecords").Column("IdempotencyKey").Exists()) + { + Alter.Table("CheckInRecords").AddColumn("IdempotencyKey").AsString(128).Nullable(); + + // At most one check-in per (department, idempotency key). Filtered so the many NULL-key (live UI) + // check-ins don't collide. Backstops the check-then-insert race in + // CheckInTimerService.PerformCheckInAsync (which adopts the winner on violation). + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_CheckInRecords_Department_IdempotencyKey ON CheckInRecords (DepartmentId, IdempotencyKey) WHERE IdempotencyKey IS NOT NULL;"); + } + } + + public override void Down() + { + if (Schema.Table("CheckInRecords").Exists() && Schema.Table("CheckInRecords").Column("IdempotencyKey").Exists()) + { + Execute.Sql("DROP INDEX IF EXISTS UX_CheckInRecords_Department_IdempotencyKey ON CheckInRecords;"); + Delete.Column("IdempotencyKey").FromTable("CheckInRecords"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs new file mode 100644 index 000000000..70a8fc16d --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Offline-first: adds a ModifiedOn change cursor to the ad-hoc incident resource tables so they participate in + /// the /Sync/Changes delta pull (previously they were full-refetched). See docs/architecture/offline-first-architecture.md. + /// + [Migration(83)] + public class M0083_AddAdHocResourceChangeTracking : Migration + { + private static readonly string[] Tables = { "IncidentAdHocUnits", "IncidentAdHocPersonnel" }; + + public override void Up() + { + foreach (var table in Tables) + { + if (Schema.Table(table).Exists() && !Schema.Table(table).Column("ModifiedOn").Exists()) + { + Alter.Table(table).AddColumn("ModifiedOn").AsDateTime2().Nullable(); + } + } + } + + public override void Down() + { + foreach (var table in Tables) + { + if (Schema.Table(table).Exists() && Schema.Table(table).Column("ModifiedOn").Exists()) + { + Delete.Column("ModifiedOn").FromTable(table); + } + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0084_WidenAutofillDataColumn.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0084_WidenAutofillDataColumn.cs new file mode 100644 index 000000000..e24e8b0f5 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0084_WidenAutofillDataColumn.cs @@ -0,0 +1,27 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Widens Autofills.Data (the call-note / autofill template body) from the M0011 default nvarchar(255) to + /// nvarchar(max). Users saving a call-note template longer than 255 chars via POST /User/Templates/EditCallNote + /// hit SQL error 8152 "String or binary data would be truncated" in RepositoryBase.UpdateAsync. + /// + [Migration(84)] + public class M0084_WidenAutofillDataColumn : Migration + { + public override void Up() + { + if (Schema.Table("Autofills").Exists() && Schema.Table("Autofills").Column("Data").Exists()) + { + Alter.Table("Autofills").AlterColumn("Data").AsString(Int32.MaxValue).Nullable(); + } + } + + public override void Down() + { + // No-op: narrowing back to nvarchar(255) could truncate existing data. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0085_WidenAddressColumns.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0085_WidenAddressColumns.cs new file mode 100644 index 000000000..fc58b9763 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0085_WidenAddressColumns.cs @@ -0,0 +1,36 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Widens the Addresses columns. Address1 (street address) was nvarchar(200); long addresses saved via Contacts + /// edit (and Stations/Groups/Home) hit SQL error 8152 "String or binary data would be truncated" in + /// RepositoryBase.UpdateAsync (AddressService.SaveAddressAsync). Street address is free-text and goes to max; the + /// shorter fields are padded generously. + /// + [Migration(85)] + public class M0085_WidenAddressColumns : Migration + { + public override void Up() + { + if (Schema.Table("Addresses").Exists()) + { + // Keep these columns Nullable: the Addresses table predates the migration system and they were never + // enforced NOT NULL, so legacy rows may hold NULLs. Enforcing NOT NULL here would fail the migration on + // those rows. ([Required] on the Address model already prevents new null saves at the app layer.) This + // migration's job is only to widen the columns to stop the 8152 "would be truncated" errors. + Alter.Table("Addresses").AlterColumn("Address1").AsString(Int32.MaxValue).Nullable(); + Alter.Table("Addresses").AlterColumn("City").AsString(200).Nullable(); + Alter.Table("Addresses").AlterColumn("State").AsString(100).Nullable(); + Alter.Table("Addresses").AlterColumn("PostalCode").AsString(100).Nullable(); + Alter.Table("Addresses").AlterColumn("Country").AsString(100).Nullable(); + } + } + + public override void Down() + { + // No-op: narrowing back could truncate existing data. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs new file mode 100644 index 000000000..390bf19a3 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs @@ -0,0 +1,59 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Offline-first sync foundation (PostgreSQL): adds a ModifiedOn change cursor to the mutable incident-command + /// tables (delta "changed since" + last-write-wins) and a DeletedOn soft-delete tombstone to commandstructurenodes. + /// Append-only tables already carry a natural creation timestamp and are intentionally excluded. + /// See docs/architecture/offline-first-architecture.md. + /// + [Migration(81)] + public class M0081_AddIncidentCommandChangeTrackingPg : Migration + { + private static readonly string[] ChangeTrackedTables = + { + "IncidentCommands", + "CommandStructureNodes", + "ResourceAssignments", + "TacticalObjectives", + "IncidentTimers", + "IncidentMapAnnotations", + "IncidentRoleAssignments" + }; + + public override void Up() + { + foreach (var table in ChangeTrackedTables) + { + var t = table.ToLower(); + if (Schema.Table(t).Exists() && !Schema.Table(t).Column("ModifiedOn".ToLower()).Exists()) + { + Alter.Table(t).AddColumn("ModifiedOn".ToLower()).AsDateTime2().Nullable(); + } + } + + if (Schema.Table("CommandStructureNodes".ToLower()).Exists() && !Schema.Table("CommandStructureNodes".ToLower()).Column("DeletedOn".ToLower()).Exists()) + { + Alter.Table("CommandStructureNodes".ToLower()).AddColumn("DeletedOn".ToLower()).AsDateTime2().Nullable(); + } + } + + public override void Down() + { + foreach (var table in ChangeTrackedTables) + { + var t = table.ToLower(); + if (Schema.Table(t).Exists() && Schema.Table(t).Column("ModifiedOn".ToLower()).Exists()) + { + Delete.Column("ModifiedOn".ToLower()).FromTable(t); + } + } + + if (Schema.Table("CommandStructureNodes".ToLower()).Exists() && Schema.Table("CommandStructureNodes".ToLower()).Column("DeletedOn".ToLower()).Exists()) + { + Delete.Column("DeletedOn".ToLower()).FromTable("CommandStructureNodes".ToLower()); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs new file mode 100644 index 000000000..0eaaee0d2 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs @@ -0,0 +1,33 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Offline-first action idempotency (PostgreSQL): a client-supplied key on check-in records so a replayed offline + /// check-in dedups instead of creating a duplicate row. See docs/architecture/offline-first-architecture.md. + /// + [Migration(82)] + public class M0082_AddCheckInRecordIdempotencyKeyPg : Migration + { + public override void Up() + { + if (Schema.Table("CheckInRecords".ToLower()).Exists() && !Schema.Table("CheckInRecords".ToLower()).Column("IdempotencyKey".ToLower()).Exists()) + { + Alter.Table("CheckInRecords".ToLower()).AddColumn("IdempotencyKey".ToLower()).AsCustom("citext").Nullable(); + + // At most one check-in per (department, idempotency key). Partial so the many NULL-key (live UI) + // check-ins don't collide. Backstops the check-then-insert race in PerformCheckInAsync. + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_checkinrecords_department_idempotencykey ON checkinrecords (departmentid, idempotencykey) WHERE idempotencykey IS NOT NULL;"); + } + } + + public override void Down() + { + if (Schema.Table("CheckInRecords".ToLower()).Exists() && Schema.Table("CheckInRecords".ToLower()).Column("IdempotencyKey".ToLower()).Exists()) + { + Execute.Sql("DROP INDEX IF EXISTS ux_checkinrecords_department_idempotencykey;"); + Delete.Column("IdempotencyKey".ToLower()).FromTable("CheckInRecords".ToLower()); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs new file mode 100644 index 000000000..d63c63d00 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs @@ -0,0 +1,38 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Offline-first (PostgreSQL): adds a ModifiedOn change cursor to the ad-hoc incident resource tables so they + /// participate in the /Sync/Changes delta pull. See docs/architecture/offline-first-architecture.md. + /// + [Migration(83)] + public class M0083_AddAdHocResourceChangeTrackingPg : Migration + { + private static readonly string[] Tables = { "IncidentAdHocUnits", "IncidentAdHocPersonnel" }; + + public override void Up() + { + foreach (var table in Tables) + { + var t = table.ToLower(); + if (Schema.Table(t).Exists() && !Schema.Table(t).Column("ModifiedOn".ToLower()).Exists()) + { + Alter.Table(t).AddColumn("ModifiedOn".ToLower()).AsDateTime2().Nullable(); + } + } + } + + public override void Down() + { + foreach (var table in Tables) + { + var t = table.ToLower(); + if (Schema.Table(t).Exists() && Schema.Table(t).Column("ModifiedOn".ToLower()).Exists()) + { + Delete.Column("ModifiedOn".ToLower()).FromTable(t); + } + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0084_WidenAutofillDataColumnPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0084_WidenAutofillDataColumnPg.cs new file mode 100644 index 000000000..3d6294098 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0084_WidenAutofillDataColumnPg.cs @@ -0,0 +1,25 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of the Autofills.Data widening: ensure the call-note / autofill template body is unbounded + /// (text) so long templates don't truncate. See M0084_WidenAutofillDataColumn (SQL Server). + /// + [Migration(84)] + public class M0084_WidenAutofillDataColumnPg : Migration + { + public override void Up() + { + if (Schema.Table("autofills").Exists() && Schema.Table("autofills").Column("data").Exists()) + { + Alter.Table("autofills").AlterColumn("data").AsCustom("text").Nullable(); + } + } + + public override void Down() + { + // No-op: narrowing could truncate existing data. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0085_WidenAddressColumnsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0085_WidenAddressColumnsPg.cs new file mode 100644 index 000000000..1191331b7 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0085_WidenAddressColumnsPg.cs @@ -0,0 +1,27 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// PostgreSQL twin of the Addresses widening. PG string columns are typically citext/unbounded (so the SQL-Server + /// 8152 truncation does not occur here), but ensure the free-text street address is unbounded for parity. + /// See M0085_WidenAddressColumns (SQL Server). + /// + [Migration(85)] + public class M0085_WidenAddressColumnsPg : Migration + { + public override void Up() + { + if (Schema.Table("addresses").Exists() && Schema.Table("addresses").Column("address1").Exists()) + { + // Keep Nullable (see M0085_WidenAddressColumns): legacy rows may hold NULLs, so NOT NULL would fail. + Alter.Table("addresses").AlterColumn("address1").AsCustom("text").Nullable(); + } + } + + public override void Down() + { + // No-op: narrowing could truncate existing data. + } + } +} diff --git a/Providers/Resgrid.Providers.Number/TextMessageProvider.cs b/Providers/Resgrid.Providers.Number/TextMessageProvider.cs index 5d490e0c7..3bed5a3f5 100644 --- a/Providers/Resgrid.Providers.Number/TextMessageProvider.cs +++ b/Providers/Resgrid.Providers.Number/TextMessageProvider.cs @@ -28,6 +28,11 @@ public class TextMessageProvider : ITextMessageProvider public async Task SendTextMessage(string number, string message, string departmentNumber, MobileCarriers carrier, int departmentId, bool forceGateway = false, bool isCall = false) { + // Single chokepoint for every outbound SMS path below (Twilio + SignalWire fallback): strip non-allow-listed + // URLs (carrier deliverability) and cap length (cost + avoid Twilio error 21617 at 1600 chars) before sending. + message = SmsContentHelper.PrepareForSms(message, Config.SystemBehaviorConfig.SmsMaxLength, + (Config.SystemBehaviorConfig.SmsAllowedUrlDomains ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries)); + var wasTwillioSuccessful = await SendTextMessageViaTwillio(number, message, departmentNumber); if (!wasTwillioSuccessful && isCall) @@ -154,6 +159,13 @@ public async Task SendTextMessageViaTwillio(string number, string message, else return false; } + catch (Twilio.Exceptions.ApiException ex) when (ex.Code == 21211 || ex.Code == 21214 || ex.Code == 21217 || ex.Code == 21614) + { + // Invalid / unreachable 'To' number - bad input, not a system fault. Log quietly so these don't flood + // Sentry as fatals; the caller still gets false and can surface a validation message to the user. + Framework.Logging.LogInfo($"Twilio rejected an invalid 'To' number (code {ex.Code}): {ex.Message}"); + return false; + } catch (Exception ex) { Framework.Logging.LogException(ex); diff --git a/Providers/Resgrid.Providers.Workflow/Executors/TwilioSmsExecutor.cs b/Providers/Resgrid.Providers.Workflow/Executors/TwilioSmsExecutor.cs index 42320c867..3a54c928b 100644 --- a/Providers/Resgrid.Providers.Workflow/Executors/TwilioSmsExecutor.cs +++ b/Providers/Resgrid.Providers.Workflow/Executors/TwilioSmsExecutor.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; using Resgrid.Config; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Providers; using Twilio.Clients; @@ -55,11 +56,15 @@ public async Task ExecuteAsync(WorkflowActionContext conte var twilioClient = new TwilioRestClient(cred.AccountSid, cred.AuthToken); + // Strip non-allow-listed URLs (carrier deliverability) and cap length (cost + avoid Twilio error 21617). + var body = SmsContentHelper.PrepareForSms(context.RenderedContent, SystemBehaviorConfig.SmsMaxLength, + (SystemBehaviorConfig.SmsAllowedUrlDomains ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries)); + string lastSid = null; foreach (var recipient in recipients) { var message = await MessageResource.CreateAsync( - body: context.RenderedContent, + body: body, from: new Twilio.Types.PhoneNumber(cred.FromNumber), to: new Twilio.Types.PhoneNumber(recipient), client: twilioClient); diff --git a/Repositories/Resgrid.Repositories.DataRepository/HealthRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/HealthRepository.cs index 1df6a6592..e13f782ab 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/HealthRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/HealthRepository.cs @@ -1,30 +1,44 @@ -using System; -using System.Data; -using Microsoft.Data.SqlClient; +using System; using System.Linq; using System.Threading.Tasks; using Dapper; +using Resgrid.Framework; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; namespace Resgrid.Repositories.DataRepository { public class HealthRepository : IHealthRepository { + private readonly IConnectionProvider _connectionProvider; + + public HealthRepository(IConnectionProvider connectionProvider) + { + _connectionProvider = connectionProvider; + } + public async Task GetDatabaseCurrentTime() { - var query = $@"SELECT GETDATE()"; + // Use the configured connection provider (SQL Server OR PostgreSQL) and a portable query. + // CURRENT_TIMESTAMP is valid on both engines; the old hardcoded SqlConnection + GETDATE() always threw + // on PostgreSQL-backed datacenters, so the health check reported DatabaseOnline = false there. + const string query = "SELECT CURRENT_TIMESTAMP"; try { - using (IDbConnection db = new SqlConnection(Config.DataConfig.ConnectionString)) + using (var db = _connectionProvider.Create()) { - var results = await db.QueryAsync(query); + var results = await db.QueryAsync(query); + var timestamp = results.FirstOrDefault(); - return results.FirstOrDefault(); + return timestamp == default(DateTime) ? null : timestamp.ToString("o"); } } - catch (Exception) + catch (Exception ex) { + // Log so a failing health check (DatabaseOnline = false) is diagnosable instead of silently swallowed; + // keep returning null so the caller still reports the DB as offline rather than throwing. + Logging.LogException(ex, "HealthRepository.GetDatabaseCurrentTime database connectivity check failed"); return null; } } diff --git a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs index 22b9f7bee..8187474be 100644 --- a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs @@ -270,6 +270,70 @@ public async Task PerformCheckInAsync_SavesRecordWithTimestamp() _recordRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + [Test] + public async Task PerformCheckInAsync_WithIdempotencyKey_ReturnsExistingRecord_WithoutDuplicateInsert() + { + // A check-in with this key was already recorded for the call (the client's outbox is replaying it). + var existing = new CheckInRecord { CheckInRecordId = "existing-1", DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-1", Timestamp = DateTime.UtcNow.AddMinutes(-1) }; + _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List { existing }); + + var replay = new CheckInRecord { DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-1" }; + var result = await _service.PerformCheckInAsync(replay); + + result.Should().BeSameAs(existing); + _recordRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task PerformCheckInAsync_WithNewIdempotencyKey_Inserts() + { + _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List()); // key not seen yet + _recordRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CheckInRecord r, CancellationToken ct, bool b) => { r.CheckInRecordId = "new-id"; return r; }); + + var record = new CheckInRecord { DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-2" }; + var result = await _service.PerformCheckInAsync(record); + + result.CheckInRecordId.Should().Be("new-id"); + _recordRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task PerformCheckInAsync_OnConcurrentReplayUniqueViolation_AdoptsTheWinningRecord() + { + var winner = new CheckInRecord { CheckInRecordId = "winner-1", DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-1" }; + // Race: our pre-check sees nothing, a concurrent replay commits first, so our insert hits the unique + // index; the post-violation re-query then finds the winner and we adopt it instead of 500ing. + _recordRepo.SetupSequence(x => x.GetByCallIdAsync(1)) + .ReturnsAsync(new List()) + .ReturnsAsync(new List { winner }); + _recordRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new FakeDbException("23505: duplicate key value violates unique constraint")); + + var result = await _service.PerformCheckInAsync(new CheckInRecord { DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-1" }); + + result.Should().BeSameAs(winner); + } + + [Test] + public async Task PerformCheckInAsync_NonDatabaseError_Propagates_NotMaskedAsReplay() + { + // A non-DbException must NOT be swallowed as an idempotent replay even when a key is present. + _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List()); + _recordRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + + Func act = async () => await _service.PerformCheckInAsync(new CheckInRecord { DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-1" }); + + await act.Should().ThrowAsync(); + } + + // Minimal concrete DbException (the framework type is abstract) to simulate a provider unique-constraint violation. + private sealed class FakeDbException : System.Data.Common.DbException + { + public FakeDbException(string message) : base(message) { } + } + [Test] public async Task GetLastCheckInAsync_ReturnsUserCheckIn_WhenNoUnitId() { diff --git a/Tests/Resgrid.Tests/Services/ContactVerificationServiceTests.cs b/Tests/Resgrid.Tests/Services/ContactVerificationServiceTests.cs index f813461b8..8ea104bf5 100644 --- a/Tests/Resgrid.Tests/Services/ContactVerificationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ContactVerificationServiceTests.cs @@ -23,6 +23,7 @@ public class with_the_contact_verification_service : TestBase protected Mock _systemAuditsServiceMock; protected Mock _encryptionServiceMock; protected Mock _outboundVoiceProviderMock; + protected Mock _phoneNumberProcesserMock; protected IContactVerificationService _contactVerificationService; protected with_the_contact_verification_service() @@ -52,6 +53,13 @@ protected with_the_contact_verification_service() .Setup(v => v.SendVoiceVerificationCallAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); + // Phone processor: by default treat numbers as valid and echo them back as the "international" form so + // the send paths proceed; individual tests can override to simulate an invalid/unsendable number. + _phoneNumberProcesserMock = new Mock(); + _phoneNumberProcesserMock + .Setup(p => p.Process(It.IsAny(), It.IsAny())) + .Returns((num, cc) => new PhoneNumberResult { IsValid = !string.IsNullOrWhiteSpace(num), InternationalNumber = num, LocalNumber = num }); + _contactVerificationService = new ContactVerificationService( _userProfileServiceMock.Object, _usersServiceMock.Object, @@ -59,7 +67,8 @@ protected with_the_contact_verification_service() _smsServiceMock.Object, _systemAuditsServiceMock.Object, _encryptionServiceMock.Object, - _outboundVoiceProviderMock.Object); + _outboundVoiceProviderMock.Object, + _phoneNumberProcesserMock.Object); } protected static UserProfile BuildProfile(string userId = "user1", diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs index b859a0e9d..26ec7c489 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -61,8 +61,8 @@ public void SetUp() _eventAggregator = new Mock(); _coreEventService = new Mock(); - // The marker write echoes back the entry so WriteLogAsync resolves a non-null result. - _logRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + // Timeline entries are append-only inserts; echo back the entry so WriteLogAsync resolves a non-null result. + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); _service = new IncidentCommandService(_commandRepo.Object, _nodeRepo.Object, _assignmentRepo.Object, @@ -126,7 +126,7 @@ public async Task EvaluateCriticalParAsync_RaisesEventAndWritesMarker_OnFirstTra result.Should().ContainSingle().Which.Should().Be("user1"); _eventAggregator.Verify(x => x.SendMessage(It.Is( e => e.UserId == "user1" && e.CallId == CallId && e.DepartmentId == Dept)), Times.Once); - _logRepo.Verify(x => x.SaveOrUpdateAsync( + _logRepo.Verify(x => x.InsertAsync( It.Is(e => e.EntryType == (int)CommandLogEntryType.ParCritical && e.UserId == "user1"), It.IsAny(), It.IsAny()), Times.Once); } @@ -150,7 +150,7 @@ public async Task EvaluateCriticalParAsync_Deduped_WhenMarkerAlreadyExistsForEpi result.Should().BeEmpty(); _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); - _logRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _logRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Test] @@ -254,7 +254,7 @@ public async Task SaveNodeAsync_StampsCallId_FromParentCommand_NotCallerSupplied { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active }); - _nodeRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + _nodeRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); var node = new CommandStructureNode { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = 999, Name = "Staging" }; @@ -330,5 +330,174 @@ public async Task MoveResourceAsync_ReturnsNull_WhenTargetNodeMissing() result.Should().BeNull(); } + + // Offline-first change tracking: every mutation stamps ModifiedOn (the delta "changed since" cursor) and a + // lane removal is a soft-delete tombstone (DeletedOn), so removals propagate to offline clients on delta sync. + + [Test] + public async Task SaveNodeAsync_StampsModifiedOn_OnSave() + { + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + _nodeRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); + + var node = new CommandStructureNode { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Name = "Staging" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().NotBeNull(); + saved.ModifiedOn.Should().NotBeNull(); + } + + // Idempotent creates (offline replay): the client generates the GUID PK offline. A brand-new client id must + // INSERT (not be rejected as a missing-row update); a replayed id must UPDATE the same row (no duplicate); + // an id owned by another department must be rejected. + + [Test] + public async Task SaveNodeAsync_WithClientSuppliedId_NotYetPersisted_InsertsWithThatId() + { + const string clientId = "client-guid-1"; + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + _nodeRepo.Setup(x => x.GetByIdAsync(clientId)).ReturnsAsync((CommandStructureNode)null); // not persisted yet + _nodeRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); + + var node = new CommandStructureNode { CommandStructureNodeId = clientId, IncidentCommandId = "ic1", DepartmentId = Dept, Name = "Staging" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().NotBeNull(); + saved.CommandStructureNodeId.Should().Be(clientId); // honored the client GUID, did not generate a new one + _nodeRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _nodeRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task SaveNodeAsync_WithClientSuppliedId_AlreadyPersisted_Updates_WithoutDuplicateInsert() + { + const string clientId = "client-guid-1"; + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + // Replay: the row already exists for this department. + _nodeRepo.Setup(x => x.GetByIdAsync(clientId)).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = clientId, IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Name = "Staging" + }); + _nodeRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); + + var node = new CommandStructureNode { CommandStructureNodeId = clientId, IncidentCommandId = "ic1", DepartmentId = Dept, Name = "Staging (edited)" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().NotBeNull(); + _nodeRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _nodeRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task SaveNodeAsync_WithClientSuppliedId_OwnedByAnotherDepartment_ReturnsNull() + { + const string clientId = "client-guid-1"; + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + // A row with that id exists but belongs to another department — reject, never take it over. + _nodeRepo.Setup(x => x.GetByIdAsync(clientId)).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = clientId, DepartmentId = 99, CallId = CallId + }); + + var node = new CommandStructureNode { CommandStructureNodeId = clientId, IncidentCommandId = "ic1", DepartmentId = Dept, Name = "Staging" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().BeNull(); + _nodeRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _nodeRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task DeleteNodeAsync_SoftDeletes_SetsDeletedOnAndModifiedOn_AndPersistsInsteadOfHardDeleting() + { + CommandStructureNode persisted = null; + _nodeRepo.Setup(x => x.GetByIdAsync("node-1")).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = "node-1", DepartmentId = Dept, CallId = CallId, IncidentCommandId = "ic1", Name = "Staging" + }); + // A hard delete would never call SaveOrUpdate; capturing it here proves the removal is a soft-delete. + _nodeRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((CommandStructureNode n, CancellationToken ct, bool b) => persisted = n) + .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); + + var result = await _service.DeleteNodeAsync(Dept, "node-1", "user1"); + + result.Should().BeTrue(); + persisted.Should().NotBeNull(); + persisted.DeletedOn.Should().NotBeNull(); + persisted.ModifiedOn.Should().NotBeNull(); + } + + [Test] + public async Task GetNodesForCallAsync_ExcludesSoftDeletedNodes() + { + _nodeRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new CommandStructureNode { CommandStructureNodeId = "n1", DepartmentId = Dept, CallId = CallId, Name = "Live", SortOrder = 1 }, + new CommandStructureNode { CommandStructureNodeId = "n2", DepartmentId = Dept, CallId = CallId, Name = "Removed", SortOrder = 2, DeletedOn = DateTime.UtcNow } + }); + + var nodes = await _service.GetNodesForCallAsync(Dept, CallId); + + nodes.Should().ContainSingle().Which.CommandStructureNodeId.Should().Be("n1"); + } + + // Delta pull (offline reconnect): returns only rows changed after the cursor — INCLUDING soft-deleted rows so + // the client can remove them locally; rows with no ModifiedOn or an older ModifiedOn are excluded. + + [Test] + public async Task GetChangesSinceAsync_ReturnsOnlyRowsChangedAfterCursor_IncludingSoftDeleted() + { + var since = DateTime.UtcNow.AddMinutes(-10); + + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentCommand { IncidentCommandId = "c-new", DepartmentId = Dept, CallId = CallId, ModifiedOn = DateTime.UtcNow }, + new IncidentCommand { IncidentCommandId = "c-old", DepartmentId = Dept, CallId = CallId, ModifiedOn = since.AddMinutes(-5) }, + new IncidentCommand { IncidentCommandId = "c-null", DepartmentId = Dept, CallId = CallId, ModifiedOn = null } + }); + _nodeRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + // A lane soft-deleted after the cursor must be INCLUDED (with DeletedOn) so the client removes it. + new CommandStructureNode { CommandStructureNodeId = "n-del", DepartmentId = Dept, CallId = CallId, DeletedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow } + }); + + var changes = await _service.GetChangesSinceAsync(Dept, since); + + changes.ServerTimestampMs.Should().BeGreaterThan(0); + changes.Commands.Should().ContainSingle().Which.IncidentCommandId.Should().Be("c-new"); + changes.Nodes.Should().ContainSingle().Which.DeletedOn.Should().NotBeNull(); + } + + [Test] + public async Task GetChangesSinceAsync_FullSync_ReturnsAllRowsIncludingNullModifiedOn() + { + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentCommand { IncidentCommandId = "c-tracked", DepartmentId = Dept, CallId = CallId, ModifiedOn = DateTime.UtcNow }, + new IncidentCommand { IncidentCommandId = "c-legacy", DepartmentId = Dept, CallId = CallId, ModifiedOn = null } // never stamped + }); + + // since=0 maps to DateTime.MinValue in the controller — the full pull must include the legacy null row. + var changes = await _service.GetChangesSinceAsync(Dept, DateTime.MinValue); + + changes.Commands.Should().HaveCount(2); + changes.Commands.Should().Contain(c => c.IncidentCommandId == "c-legacy"); + } } } diff --git a/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs new file mode 100644 index 000000000..374d17685 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Threading; +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; + +namespace Resgrid.Tests.Services +{ + /// + /// Covers idempotent ad-hoc resource creation: the create must actually INSERT (a pre-set GUID + SaveOrUpdateAsync + /// would be a silent 0-row UPDATE), honor a client-supplied GUID, treat a replayed id as a no-op (return the stored + /// row, no duplicate), and reject an id owned by another department. + /// + [TestFixture] + public class IncidentResourcesServiceTests + { + private const int Dept = 10; + private const int CallId = 7; + + private Mock _unitRepo; + private Mock _personnelRepo; + private Mock _commandService; + private Mock _eventAggregator; + private IncidentResourcesService _service; + + [SetUp] + public void SetUp() + { + _unitRepo = new Mock(); + _personnelRepo = new Mock(); + _commandService = new Mock(); + _eventAggregator = new Mock(); + + _service = new IncidentResourcesService(_unitRepo.Object, _personnelRepo.Object, _commandService.Object, _eventAggregator.Object); + } + + private void ArrangeActiveCommand() + { + _commandService.Setup(x => x.GetActiveCommandForCallAsync(Dept, CallId)).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + } + + [Test] + public async Task CreateAdHocUnitAsync_ReturnsNull_WhenNoActiveCommandForCall() + { + _commandService.Setup(x => x.GetActiveCommandForCallAsync(Dept, CallId)).ReturnsAsync((IncidentCommand)null); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().BeNull(); + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateAdHocUnitAsync_NoId_GeneratesIdAndInserts() + { + ArrangeActiveCommand(); + _unitRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentAdHocUnit u, CancellationToken ct, bool b) => u); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().NotBeNull(); + result.IncidentAdHocUnitId.Should().NotBeNullOrEmpty(); + result.CreatedOn.Should().NotBe(default(DateTime)); + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _unitRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateAdHocUnitAsync_ClientSuppliedId_NotPersisted_InsertsWithThatId() + { + ArrangeActiveCommand(); + _unitRepo.Setup(x => x.GetByIdAsync("client-1")).ReturnsAsync((IncidentAdHocUnit)null); + _unitRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentAdHocUnit u, CancellationToken ct, bool b) => u); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { IncidentAdHocUnitId = "client-1", DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().NotBeNull(); + result.IncidentAdHocUnitId.Should().Be("client-1"); // honored the client GUID + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task CreateAdHocUnitAsync_ReplayOfExistingId_ReturnsStored_WithoutDuplicate() + { + ArrangeActiveCommand(); + _unitRepo.Setup(x => x.GetByIdAsync("client-1")).ReturnsAsync(new IncidentAdHocUnit + { + IncidentAdHocUnitId = "client-1", DepartmentId = Dept, CallId = CallId, Name = "Engine 1" + }); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { IncidentAdHocUnitId = "client-1", DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().NotBeNull(); + result.IncidentAdHocUnitId.Should().Be("client-1"); + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _unitRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateAdHocUnitAsync_ClientSuppliedId_OwnedByAnotherDepartment_ReturnsNull() + { + ArrangeActiveCommand(); + _unitRepo.Setup(x => x.GetByIdAsync("client-1")).ReturnsAsync(new IncidentAdHocUnit + { + IncidentAdHocUnitId = "client-1", DepartmentId = 99, CallId = CallId, Name = "Engine 1" + }); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { IncidentAdHocUnitId = "client-1", DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().BeNull(); + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateAdHocPersonnelAsync_NoId_GeneratesIdAndInserts() + { + ArrangeActiveCommand(); + _personnelRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentAdHocPersonnel p, CancellationToken ct, bool b) => p); + + var result = await _service.CreateAdHocPersonnelAsync(new IncidentAdHocPersonnel { DepartmentId = Dept, CallId = CallId, Name = "J. Doe" }, "user1"); + + result.Should().NotBeNull(); + result.IncidentAdHocPersonnelId.Should().NotBeNullOrEmpty(); + _personnelRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _personnelRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // Change tracking: ad-hoc resources now carry ModifiedOn so they ride the /Sync/Changes delta. + + [Test] + public async Task CreateAdHocUnitAsync_StampsModifiedOn() + { + ArrangeActiveCommand(); + IncidentAdHocUnit inserted = null; + _unitRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((IncidentAdHocUnit u, CancellationToken ct, bool b) => inserted = u) + .ReturnsAsync((IncidentAdHocUnit u, CancellationToken ct, bool b) => u); + + await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + inserted.Should().NotBeNull(); + inserted.ModifiedOn.Should().NotBeNull(); + } + + [Test] + public async Task GetAdHocChangesSinceAsync_ReturnsOnlyRowsChangedAfterCursor() + { + var since = DateTime.UtcNow.AddMinutes(-10); + _unitRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentAdHocUnit { IncidentAdHocUnitId = "u-new", DepartmentId = Dept, CallId = CallId, ModifiedOn = DateTime.UtcNow }, + new IncidentAdHocUnit { IncidentAdHocUnitId = "u-old", DepartmentId = Dept, CallId = CallId, ModifiedOn = since.AddMinutes(-5) }, + new IncidentAdHocUnit { IncidentAdHocUnitId = "u-null", DepartmentId = Dept, CallId = CallId, ModifiedOn = null } + }); + _personnelRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + // A released person changed after the cursor must be included so the client reconciles the removal. + new IncidentAdHocPersonnel { IncidentAdHocPersonnelId = "p-rel", DepartmentId = Dept, CallId = CallId, ReleasedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow } + }); + + var (units, personnel) = await _service.GetAdHocChangesSinceAsync(Dept, since); + + units.Should().ContainSingle().Which.IncidentAdHocUnitId.Should().Be("u-new"); + personnel.Should().ContainSingle().Which.IncidentAdHocPersonnelId.Should().Be("p-rel"); + } + + [Test] + public async Task GetAdHocChangesSinceAsync_FullSync_IncludesNullModifiedOnRows() + { + _unitRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentAdHocUnit { IncidentAdHocUnitId = "u-legacy", DepartmentId = Dept, CallId = CallId, ModifiedOn = null } + }); + _personnelRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + + var (units, _) = await _service.GetAdHocChangesSinceAsync(Dept, DateTime.MinValue); + + units.Should().ContainSingle().Which.IncidentAdHocUnitId.Should().Be("u-legacy"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs index 46756504a..e5a45c691 100644 --- a/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs @@ -46,7 +46,7 @@ public async Task CloseIncidentChannelsForCallAsync_WritesChannelClosedLog_EvenW Status = (int)IncidentCommandStatus.Closed, EstablishedOn = DateTime.UtcNow.AddHours(-1) } }); - logRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); var service = new IncidentVoiceService(voiceService.Object, departmentsService.Object, logRepo.Object, @@ -55,8 +55,8 @@ public async Task CloseIncidentChannelsForCallAsync_WritesChannelClosedLog_EvenW var result = await service.CloseIncidentChannelsForCallAsync(10, 7, "user1"); result.Should().BeTrue(); - // The channel-closed entry is logged against the (now Closed) command, not silently dropped. - logRepo.Verify(x => x.SaveOrUpdateAsync( + // The channel-closed entry is logged (inserted) against the (now Closed) command, not silently dropped. + logRepo.Verify(x => x.InsertAsync( It.Is(e => e.EntryType == (int)CommandLogEntryType.ChannelClosed && e.IncidentCommandId == "ic1"), It.IsAny(), It.IsAny()), Times.Once); coreEventService.Verify(x => x.IncidentCommandUpdatedAsync(10, 7), Times.Once); diff --git a/Tests/Resgrid.Tests/Services/PhoneRegionHelperTests.cs b/Tests/Resgrid.Tests/Services/PhoneRegionHelperTests.cs new file mode 100644 index 000000000..6a7b306eb --- /dev/null +++ b/Tests/Resgrid.Tests/Services/PhoneRegionHelperTests.cs @@ -0,0 +1,32 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Framework; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class PhoneRegionHelperTests + { + [Test] + public void ToIso_MapsKnownCountries() + { + PhoneRegionHelper.ToIso("South Africa").Should().Be("za"); + PhoneRegionHelper.ToIso("United States").Should().Be("us"); + PhoneRegionHelper.ToIso("United Kingdom").Should().Be("gb"); + } + + [Test] + public void ToIso_IsCaseAndWhitespaceInsensitive() + { + PhoneRegionHelper.ToIso(" south africa ").Should().Be("za"); + } + + [Test] + public void ToIso_ReturnsNullForUnknownOrBlank() + { + PhoneRegionHelper.ToIso("Atlantis").Should().BeNull(); + PhoneRegionHelper.ToIso("").Should().BeNull(); + PhoneRegionHelper.ToIso(null).Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/SmsContentHelperTests.cs b/Tests/Resgrid.Tests/Services/SmsContentHelperTests.cs new file mode 100644 index 000000000..e03ee2ec3 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/SmsContentHelperTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Framework; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class SmsContentHelperTests + { + private static readonly string[] Allowed = { "resgrid.com", "google.com", "maps.app.goo.gl", "bit.ly", "what3words.com" }; + + [Test] + public void StripDisallowedUrls_KeepsAllowListedLinks() + { + var input = "Map https://maps.app.goo.gl/abc123 also https://www.google.com/maps/place/x audio https://bit.ly/xyz"; + + var result = SmsContentHelper.StripDisallowedUrls(input, Allowed); + + result.Should().Contain("https://maps.app.goo.gl/abc123"); + result.Should().Contain("https://www.google.com/maps/place/x"); + result.Should().Contain("https://bit.ly/xyz"); + } + + [Test] + public void StripDisallowedUrls_RemovesUnknownAndShortenerLinks() + { + var input = "See https://tinyurl.com/abc and http://evil.example.com/phish now"; + + var result = SmsContentHelper.StripDisallowedUrls(input, Allowed); + + result.Should().NotContain("tinyurl.com"); + result.Should().NotContain("evil.example.com"); + result.Should().Contain("See"); + result.Should().Contain("now"); + } + + [Test] + public void StripDisallowedUrls_DoesNotMangleAbbreviationsOrAddresses() + { + // No scheme/www tokens -> nothing should be treated as a URL. + var input = "Meet at 400 Olive St, Dallas, TX 75201, U.S. Call 9-1-1 for emergencies."; + + SmsContentHelper.StripDisallowedUrls(input, Allowed).Should().Be(input); + } + + [Test] + public void StripDisallowedUrls_AllowsSubdomainsOfAllowedDomain() + { + SmsContentHelper.StripDisallowedUrls("Loc https://maps.google.com/q", Allowed).Should().Contain("maps.google.com"); + } + + [Test] + public void Truncate_CapsLongBodyWithSuffix() + { + var result = SmsContentHelper.Truncate(new string('a', 1000), 459); + + result.Length.Should().Be(459); + result.Should().EndWith("the full message)"); + } + + [Test] + public void Truncate_LeavesShortBodyUnchanged() + { + SmsContentHelper.Truncate("short body", 459).Should().Be("short body"); + } + + [Test] + public void NormalizeForGsm_ReplacesSmartPunctuation() + { + var input = "It’s a “test” – really…"; + + SmsContentHelper.NormalizeForGsm(input).Should().Be("It's a \"test\" - really..."); + } + + [Test] + public void PrepareForSms_StripsDisallowedUrlThenTruncates() + { + var input = "Go to https://tinyurl.com/spam " + new string('b', 1000); + + var result = SmsContentHelper.PrepareForSms(input, 459, Allowed); + + result.Should().NotContain("tinyurl.com"); + (result.Length <= 459).Should().BeTrue(); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs index 8b9fded21..87615db43 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs @@ -391,7 +391,8 @@ public async Task> PerformCheckIn([FromBody] UnitId = input.UnitId, Latitude = input.Latitude, Longitude = input.Longitude, - Note = input.Note + Note = input.Note, + IdempotencyKey = input.IdempotencyKey }; var saved = await _checkInTimerService.PerformCheckInAsync(record, cancellationToken); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs new file mode 100644 index 000000000..00121e58c --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Sync; +using System; +using System.Threading.Tasks; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Offline-first delta sync. A reconnecting client calls Changes with its last cursor to pull everything that + /// changed while it was offline (created / updated / removed incident-command rows), reconciles its local store, + /// then replays its outbox. See docs/architecture/offline-first-architecture.md. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class SyncController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly IIncidentCommandService _incidentCommandService; + private readonly IIncidentResourcesService _incidentResourcesService; + + public SyncController(IIncidentCommandService incidentCommandService, IIncidentResourcesService incidentResourcesService) + { + _incidentCommandService = incidentCommandService; + _incidentResourcesService = incidentResourcesService; + } + #endregion Members and Constructors + + /// + /// Returns incident-command rows changed since (Unix epoch milliseconds; 0 or omitted + /// = full pull), scoped to the caller's department. Soft-deleted / closed / released rows are included so the + /// client can remove them locally. Persist the returned Data.ServerTimestampMs and pass it as the next + /// . + /// + [HttpGet("Changes")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> Changes(long since = 0) + { + var sinceUtc = since <= 0 + ? DateTime.MinValue + : DateTimeOffset.FromUnixTimeMilliseconds(since).UtcDateTime; + + var changes = await _incidentCommandService.GetChangesSinceAsync(DepartmentId, sinceUtc); + + // Ad-hoc resources live in IncidentResourcesService; aggregate them into the unified delta payload. + var adHoc = await _incidentResourcesService.GetAdHocChangesSinceAsync(DepartmentId, sinceUtc); + changes.AdHocUnits = adHoc.Units; + changes.AdHocPersonnel = adHoc.Personnel; + + var result = new SyncChangesResult { Data = changes }; + result.PageSize = changes.Commands.Count + changes.Nodes.Count + changes.Assignments.Count + + changes.Objectives.Count + changes.Timers.Count + changes.Annotations.Count + + changes.Roles.Count + changes.AdHocUnits.Count + changes.AdHocPersonnel.Count + changes.TimelineEntries.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs index 8583faef1..7e29c20d9 100644 --- a/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs @@ -145,6 +145,9 @@ public class PerformCheckInInput public string Longitude { get; set; } public int? UnitId { get; set; } public string Note { get; set; } + + /// Optional offline idempotency key (the client's outbox event id); a replayed check-in dedups on it. + public string IdempotencyKey { get; set; } } public class PerformCheckInResult : StandardApiResponseV4Base diff --git a/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs b/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs new file mode 100644 index 000000000..4d4b85a6e --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs @@ -0,0 +1,13 @@ +using Resgrid.Web.Services.Models.v4; + +namespace Resgrid.Web.Services.Models.v4.Sync +{ + /// + /// Delta sync payload for offline clients: incident-command rows changed since the client's cursor. The client + /// stores Data.ServerTimestampMs and passes it back as the next `since`. + /// + public class SyncChangesResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentCommandChanges Data { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 4cb874c91..2695276af 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1922,6 +1922,21 @@ + + + Offline-first delta sync. A reconnecting client calls Changes with its last cursor to pull everything that + changed while it was offline (created / updated / removed incident-command rows), reconciles its local store, + then replays its outbox. See docs/architecture/offline-first-architecture.md. + + + + + Returns incident-command rows changed since (Unix epoch milliseconds; 0 or omitted + = full pull), scoped to the caller's department. Soft-deleted / closed / released rows are included so the + client can remove them locally. Persist the returned Data.ServerTimestampMs and pass it as the next + . + + Templates in the system. Templates can be call Templates, Autofills (i.e. Call Notes) @@ -6303,6 +6318,9 @@ User Defined Field values for this contact + + Optional offline idempotency key (the client's outbox event id); a replayed check-in dedups on it. + Response wrapper for . @@ -9614,6 +9632,12 @@ Default constructor + + + Delta sync payload for offline clients: incident-command rows changed since the client's cursor. The client + stores Data.ServerTimestampMs and passes it back as the next `since`. + + Multiple call note template result diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index f6bd2855e..92492615d 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -38,11 +38,12 @@ public class ContactsController : SecureBaseController private readonly IUdfRenderingService _udfRenderingService; private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IRouteService _routeService; + private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; public ContactsController(IContactsService contactsService, IDepartmentsService departmentsService, IUserProfileService userProfileService, IAddressService addressService, IEventAggregator eventAggregator, ICallsService callsService, IAuthorizationService authorizationService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, - IDepartmentGroupsService departmentGroupsService, IRouteService routeService) + IDepartmentGroupsService departmentGroupsService, IRouteService routeService, IPhoneNumberProcesserProvider phoneNumberProcesser) { _contactsService = contactsService; _departmentsService = departmentsService; @@ -55,6 +56,7 @@ public ContactsController(IContactsService contactsService, IDepartmentsService _udfRenderingService = udfRenderingService; _departmentGroupsService = departmentGroupsService; _routeService = routeService; + _phoneNumberProcesser = phoneNumberProcesser; } #endregion Private Members and Constructors @@ -263,6 +265,13 @@ public async Task Add(AddContactView model, CancellationToken can ModelState.AddModelError("ExitGpsLongitude", "Exit Longitude value seems invalid, MUST be decimal format."); } + // Server-side phone backstop: validate + normalize each number to E.164 so saved values are + // Twilio-sendable (mirrors EditUserProfile). Region comes from the contact's physical country. + model.Contact.CellPhoneNumber = PhoneValidationHelper.ValidateAndNormalize(_phoneNumberProcesser, ModelState, "Contact.CellPhoneNumber", "cell phone number", model.Contact.CellPhoneNumber, model.PhysicalCountry); + model.Contact.HomePhoneNumber = PhoneValidationHelper.ValidateAndNormalize(_phoneNumberProcesser, ModelState, "Contact.HomePhoneNumber", "home phone number", model.Contact.HomePhoneNumber, model.PhysicalCountry); + model.Contact.FaxPhoneNumber = PhoneValidationHelper.ValidateAndNormalize(_phoneNumberProcesser, ModelState, "Contact.FaxPhoneNumber", "fax number", model.Contact.FaxPhoneNumber, model.PhysicalCountry); + model.Contact.OfficePhoneNumber = PhoneValidationHelper.ValidateAndNormalize(_phoneNumberProcesser, ModelState, "Contact.OfficePhoneNumber", "office phone number", model.Contact.OfficePhoneNumber, model.PhysicalCountry); + Address physicalAddress = new Address(); Address mailingAddress = new Address(); @@ -523,6 +532,13 @@ public async Task Edit(EditContactView model, CancellationToken c ModelState.AddModelError("ExitGpsLongitude", "Exit Longitude value seems invalid, MUST be decimal format."); } + // Server-side phone backstop: validate + normalize each number to E.164 so saved values are + // Twilio-sendable (mirrors EditUserProfile). Region comes from the contact's physical country. + model.Contact.CellPhoneNumber = PhoneValidationHelper.ValidateAndNormalize(_phoneNumberProcesser, ModelState, "Contact.CellPhoneNumber", "cell phone number", model.Contact.CellPhoneNumber, model.PhysicalCountry); + model.Contact.HomePhoneNumber = PhoneValidationHelper.ValidateAndNormalize(_phoneNumberProcesser, ModelState, "Contact.HomePhoneNumber", "home phone number", model.Contact.HomePhoneNumber, model.PhysicalCountry); + model.Contact.FaxPhoneNumber = PhoneValidationHelper.ValidateAndNormalize(_phoneNumberProcesser, ModelState, "Contact.FaxPhoneNumber", "fax number", model.Contact.FaxPhoneNumber, model.PhysicalCountry); + model.Contact.OfficePhoneNumber = PhoneValidationHelper.ValidateAndNormalize(_phoneNumberProcesser, ModelState, "Contact.OfficePhoneNumber", "office phone number", model.Contact.OfficePhoneNumber, model.PhysicalCountry); + if (ModelState.IsValid) { var contact = await _contactsService.GetContactByIdAsync(model.Contact.ContactId); @@ -591,11 +607,11 @@ public async Task Edit(EditContactView model, CancellationToken c if (contact.MailingAddressId.HasValue) mailingAddress = await _addressService.GetAddressByIdAsync(contact.MailingAddressId.Value); - mailingAddress.Address1 = model.PhysicalAddress1; - mailingAddress.City = model.PhysicalCity; - mailingAddress.Country = model.PhysicalCountry; - mailingAddress.PostalCode = model.PhysicalPostalCode; - mailingAddress.State = model.PhysicalState; + mailingAddress.Address1 = model.MailingAddress1; + mailingAddress.City = model.MailingCity; + mailingAddress.Country = model.MailingCountry; + mailingAddress.PostalCode = model.MailingPostalCode; + mailingAddress.State = model.MailingState; mailingAddress = await _addressService.SaveAddressAsync(mailingAddress, cancellationToken); contact.MailingAddressId = mailingAddress.AddressId; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 576d0c283..2c6812ce0 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -70,6 +70,7 @@ public class HomeController : SecureBaseController private readonly IStringLocalizer _secLocalizer; private readonly IGdprDataExportService _gdprDataExportService; private readonly ISystemAuditsService _systemAuditsService; + private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; public HomeController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IUserStateService userStateService, IDepartmentGroupsService departmentGroupsService, Resgrid.Model.Services.IAuthorizationService authorizationService, @@ -79,7 +80,7 @@ public HomeController(IDepartmentsService departmentsService, IUsersService user IStringLocalizerFactory factory, ISubscriptionsService subscriptionsService, IContactVerificationService contactVerificationService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, IDepartmentSsoService departmentSsoService, IStringLocalizer secLocalizer, IGdprDataExportService gdprDataExportService, - ISystemAuditsService systemAuditsService) + ISystemAuditsService systemAuditsService, IPhoneNumberProcesserProvider phoneNumberProcesser) { _departmentsService = departmentsService; _usersService = usersService; @@ -108,6 +109,7 @@ public HomeController(IDepartmentsService departmentsService, IUsersService user _secLocalizer = secLocalizer; _gdprDataExportService = gdprDataExportService; _systemAuditsService = systemAuditsService; + _phoneNumberProcesser = phoneNumberProcesser; _localizer = factory.Create("Home.Dashboard", new AssemblyName(typeof(SupportedLocales).GetTypeInfo().Assembly.FullName).Name); } @@ -563,6 +565,26 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo ModelState.AddModelError("Profile.MobileNumber", "You have selected you want SMS/Text notifications but have not supplied a mobile number."); } + // Validate phone numbers to a sendable E.164 form (Twilio rejects non-E.164 'To' numbers). Use the user's + // country as the region hint so a national-format number (e.g. "082446...") can be recognized & normalized. + var phoneRegion = PhoneRegionHelper.ToIso(model.PhysicalCountry) ?? PhoneRegionHelper.ToIso(model.MailingCountry); + PhoneNumberResult mobileResult = null; + PhoneNumberResult homeResult = null; + + if (!String.IsNullOrWhiteSpace(model.Profile.MobileNumber)) + { + mobileResult = _phoneNumberProcesser.Process(model.Profile.MobileNumber, phoneRegion); + if (mobileResult == null || !mobileResult.IsValid) + ModelState.AddModelError("Profile.MobileNumber", "This mobile number doesn't look valid for sending texts. Enter it in full international format, starting with your country code (for example +27 82 446 1783)."); + } + + if (!String.IsNullOrWhiteSpace(model.Profile.HomeNumber)) + { + homeResult = _phoneNumberProcesser.Process(model.Profile.HomeNumber, phoneRegion); + if (homeResult == null || !homeResult.IsValid) + ModelState.AddModelError("Profile.HomeNumber", "This home/phone number doesn't look valid for calls. Enter it in full international format, starting with your country code."); + } + // They specified a street address for physical if (!String.IsNullOrWhiteSpace(model.PhysicalAddress1)) { @@ -685,7 +707,9 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo savedProfile.MobileCarrier = (int)model.Carrier; savedProfile.FirstName = model.FirstName; savedProfile.LastName = model.LastName; - savedProfile.MobileNumber = model.Profile.MobileNumber; + savedProfile.MobileNumber = (mobileResult != null && mobileResult.IsValid && !string.IsNullOrWhiteSpace(mobileResult.InternationalNumber)) + ? mobileResult.InternationalNumber + : model.Profile.MobileNumber; savedProfile.SendEmail = model.Profile.SendEmail; savedProfile.SendPush = model.Profile.SendPush; savedProfile.SendSms = model.Profile.SendSms; @@ -696,7 +720,9 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo savedProfile.SendNotificationPush = model.Profile.SendNotificationPush; savedProfile.SendNotificationSms = model.Profile.SendNotificationSms; savedProfile.DoNotRecieveNewsletters = model.Profile.DoNotRecieveNewsletters; - savedProfile.HomeNumber = model.Profile.HomeNumber; + savedProfile.HomeNumber = (homeResult != null && homeResult.IsValid && !string.IsNullOrWhiteSpace(homeResult.InternationalNumber)) + ? homeResult.InternationalNumber + : model.Profile.HomeNumber; savedProfile.IdentificationNumber = model.Profile.IdentificationNumber; savedProfile.TimeZone = model.Profile.TimeZone; savedProfile.Language = model.Profile.Language; @@ -1083,6 +1109,38 @@ public async Task ConfirmContactVerificationCode([FromBody] Confi return Json(new { success = confirmed }); } + + /// + /// AJAX: validates a phone number the user is entering on the profile page and returns its canonical E.164 + /// form. Read-only (no state change), so it intentionally skips antiforgery. The profile form calls this on + /// blur to warn about — and offer a one-click fix for — numbers Twilio would reject. + /// + [HttpPost] + [Authorize(Policy = ResgridResources.Department_View)] + public IActionResult ValidatePhoneNumber([FromBody] ValidatePhoneNumberRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Number)) + return Json(new { isValid = false, formatted = (string)null, message = "Please enter a phone number." }); + + var iso = PhoneRegionHelper.ToIso(request.Country); + var result = _phoneNumberProcesser.Process(request.Number, iso); + + if (result != null && result.IsValid && !string.IsNullOrWhiteSpace(result.InternationalNumber)) + return Json(new { isValid = true, formatted = result.InternationalNumber, message = (string)null }); + + return Json(new + { + isValid = false, + formatted = (result != null && !string.IsNullOrWhiteSpace(result.InternationalNumber)) ? result.InternationalNumber : null, + message = "This number doesn't look valid for sending. Enter it in full international format, starting with your country code (for example +27 82 446 1783)." + }); + } + + public sealed class ValidatePhoneNumberRequest + { + public string Number { get; set; } + public string Country { get; set; } + } #endregion Contact Verification (AJAX) #region GDPR Data Export diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 74a9ca34b..9f77f6697 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -60,6 +60,7 @@ public class PersonnelController : SecureBaseController private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly IUdfRenderingService _udfRenderingService; private readonly IStringLocalizer _localizer; + private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; public PersonnelController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IEmailService emailService, IUserProfileService userProfileService, IDeleteService deleteService, Model.Services.IAuthorizationService authorizationService, @@ -67,7 +68,7 @@ public PersonnelController(IDepartmentsService departmentsService, IUsersService IEventAggregator eventAggregator, IEmailMarketingProvider emailMarketingProvider, ICertificationService certificationService, ICustomStateService customStateService, IGeoService geoService, UserManager userManager, IDepartmentSettingsService departmentSettingsService, ICallsService callsService, IGeoLocationProvider geoLocationProvider, IMappingService mappingService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, - IStringLocalizer localizer) + IStringLocalizer localizer, IPhoneNumberProcesserProvider phoneNumberProcesser) { _departmentsService = departmentsService; _usersService = usersService; @@ -93,6 +94,7 @@ public PersonnelController(IDepartmentsService departmentsService, IUsersService _userDefinedFieldsService = userDefinedFieldsService; _udfRenderingService = udfRenderingService; _localizer = localizer; + _phoneNumberProcesser = phoneNumberProcesser; } #endregion Private Members and Constructors @@ -513,6 +515,10 @@ public async Task AddPerson(AddPersonModel model, IFormCollection ModelState.AddModelError("Profile.MobileNumber", "You have selected you want SMS/Text notifications but have not supplied a mobile number."); } + // Server-side phone backstop: validate + normalize to E.164 so the saved number is Twilio-sendable + // (mirrors EditUserProfile). AddPerson has no country field, so the validator uses its default region. + model.Profile.MobileNumber = PhoneValidationHelper.ValidateAndNormalize(_phoneNumberProcesser, ModelState, "Profile.MobileNumber", "mobile number", model.Profile.MobileNumber, null); + var deletedUserId = await _departmentsService.GetUserIdForDeletedUserInDepartmentAsync(DepartmentId, model.Email); if (deletedUserId != null) { diff --git a/Web/Resgrid.Web/Areas/User/Models/Connect/ProfileView.cs b/Web/Resgrid.Web/Areas/User/Models/Connect/ProfileView.cs index acda0eb04..c37464241 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Connect/ProfileView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Connect/ProfileView.cs @@ -15,18 +15,19 @@ public class ProfileView public string ApiUrl { get; set; } + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string Address1 { get; set; } - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string City { get; set; } - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string State { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string PostalCode { get; set; } - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string Country { get; set; } } } \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Models/Contacts/AddContactView.cs b/Web/Resgrid.Web/Areas/User/Models/Contacts/AddContactView.cs index 92c677fe3..ef56b292e 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Contacts/AddContactView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Contacts/AddContactView.cs @@ -1,4 +1,4 @@ -using Resgrid.Model; +using Resgrid.Model; using System.ComponentModel.DataAnnotations; namespace Resgrid.Web.Areas.User.Models.Contacts @@ -18,42 +18,44 @@ public class AddContactView public string ExitGpsLatitude { get; set; } public string ExitGpsLongitude { get; set; } - [MaxLength(100)] + // Limits mirror the (widened) Addresses columns so client + server validation give feedback before save; + // every cap is <= its DB column width, so a validated value can never truncate. See M0085. + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string PhysicalAddress1 { get; set; } - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address line 2 cannot exceed 500 characters.")] public string PhysicalAddress2 { get; set; } - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string PhysicalCity { get; set; } - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string PhysicalState { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string PhysicalPostalCode { get; set; } - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string PhysicalCountry { get; set; } public bool MailingAddressSameAsPhysical { get; set; } - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string MailingAddress1 { get; set; } - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address line 2 cannot exceed 500 characters.")] public string MailingAddress2 { get; set; } - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string MailingCity { get; set; } - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string MailingState { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string MailingPostalCode { get; set; } - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string MailingCountry { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Contacts/EditContactView.cs b/Web/Resgrid.Web/Areas/User/Models/Contacts/EditContactView.cs index 5957b4e56..c9263536c 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Contacts/EditContactView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Contacts/EditContactView.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Resgrid.Model; namespace Resgrid.Web.Areas.User.Models.Contacts; @@ -21,41 +21,43 @@ public class EditContactView public string ExitGpsLatitude { get; set; } public string ExitGpsLongitude { get; set; } - [MaxLength(100)] + // Limits mirror the (widened) Addresses columns so client + server validation give feedback before save; + // every cap is <= its DB column width, so a validated value can never truncate. See M0085. + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string PhysicalAddress1 { get; set; } - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address line 2 cannot exceed 500 characters.")] public string PhysicalAddress2 { get; set; } - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string PhysicalCity { get; set; } - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string PhysicalState { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string PhysicalPostalCode { get; set; } - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string PhysicalCountry { get; set; } public bool MailingAddressSameAsPhysical { get; set; } - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string MailingAddress1 { get; set; } - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address line 2 cannot exceed 500 characters.")] public string MailingAddress2 { get; set; } - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string MailingCity { get; set; } - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string MailingState { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string MailingPostalCode { get; set; } - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string MailingCountry { get; set; } } diff --git a/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs b/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs index eb6f09505..0d87a7195 100644 --- a/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.Rendering; using Resgrid.Model; using Resgrid.Model.Identity; @@ -18,10 +19,19 @@ public class DepartmentSettingsModel : BaseUserModel public string MapZoomLevel { get; set; } public string RefreshTime { get; set; } + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string MapCenterPointAddressAddress1 { get; set; } + + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string MapCenterPointAddressCity { get; set; } + + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string MapCenterPointAddressState { get; set; } + + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string MapCenterPointAddressPostalCode { get; set; } + + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string MapCenterPointAddressCountry { get; set; } public string MapCenterGpsCoordinatesLatitude { get; set; } public string MapCenterGpsCoordinatesLongitude { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs b/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs index 81a2be1e9..643ee976b 100644 --- a/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs @@ -71,42 +71,42 @@ public class EditProfileModel: BaseUserModel public int MinPasswordLength { get; set; } = 8; - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string PhysicalAddress1 { get; set; } - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address line 2 cannot exceed 500 characters.")] public string PhysicalAddress2 { get; set; } - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string PhysicalCity { get; set; } - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string PhysicalState { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string PhysicalPostalCode { get; set; } - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string PhysicalCountry { get; set; } public bool MailingAddressSameAsPhysical { get; set; } - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string MailingAddress1 { get; set; } - [MaxLength(100)] + [StringLength(500, ErrorMessage = "Street address line 2 cannot exceed 500 characters.")] public string MailingAddress2 { get; set; } - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string MailingCity { get; set; } - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string MailingState { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string MailingPostalCode { get; set; } - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string MailingCountry { get; set; } public bool EnableSms { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Groups/EditGroupView.cs b/Web/Resgrid.Web/Areas/User/Models/Groups/EditGroupView.cs index 6a1e9d194..677a4f931 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Groups/EditGroupView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Groups/EditGroupView.cs @@ -14,18 +14,19 @@ public class EditGroupView : BaseUserModel public List Users { get; set; } public SelectList StationGroups { get; set; } + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string Address1 { get; set; } - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string City { get; set; } - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string State { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string PostalCode { get; set; } - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string Country { get; set; } public string InternalDispatchEmail { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Groups/NewGroupView.cs b/Web/Resgrid.Web/Areas/User/Models/Groups/NewGroupView.cs index e548f6399..2a2c66249 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Groups/NewGroupView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Groups/NewGroupView.cs @@ -21,18 +21,19 @@ public NewGroupView() NewGroup.Type = 1; } + [StringLength(500, ErrorMessage = "Street address cannot exceed 500 characters.")] public string Address1 { get; set; } - [MaxLength(100)] + [StringLength(150, ErrorMessage = "City cannot exceed 150 characters.")] public string City { get; set; } - [MaxLength(50)] + [StringLength(100, ErrorMessage = "State/Province cannot exceed 100 characters.")] public string State { get; set; } - [MaxLength(50)] + [StringLength(32, ErrorMessage = "Postal code cannot exceed 32 characters.")] public string PostalCode { get; set; } - [MaxLength(100)] + [StringLength(100, ErrorMessage = "Country cannot exceed 100 characters.")] public string Country { get; set; } public string Latitude { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Connect/Profile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Connect/Profile.cshtml index d9e62e37b..4046db14c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Connect/Profile.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Connect/Profile.cshtml @@ -163,7 +163,7 @@
- @Html.TextBoxFor(m => m.Address1, new { @class = "form-control" }) + @Html.TextBoxFor(m => m.Address1, new { @class = "form-control" }) @Html.ValidationMessageFor(m => m.Address1, "", new { @class = "text-danger" })
@@ -175,7 +175,7 @@
- @Html.TextBoxFor(m => m.City, new { @class = "form-control" }) + @Html.TextBoxFor(m => m.City, new { @class = "form-control" }) @Html.ValidationMessageFor(m => m.City, "", new { @class = "text-danger" })
@@ -187,7 +187,7 @@
- @Html.TextBoxFor(m => m.State, new { @class = "form-control" }) + @Html.TextBoxFor(m => m.State, new { @class = "form-control" }) @Html.ValidationMessageFor(m => m.State, "", new { @class = "text-danger" })
@@ -199,7 +199,7 @@
- @Html.TextBoxFor(m => m.PostalCode, new { @class = "form-control" }) + @Html.TextBoxFor(m => m.PostalCode, new { @class = "form-control" }) @Html.ValidationMessageFor(m => m.PostalCode, "", new { @class = "text-danger" })
@@ -397,6 +397,7 @@ } @section Scripts { + + diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Edit.cshtml index 39c0ce6a0..db3b99a9b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Edit.cshtml @@ -106,19 +106,19 @@
-
+
-
+
-
+
-
+

@@ -229,19 +229,19 @@

-
+
-
+
-
+
-
+
@@ -257,19 +257,19 @@
-
+
-
+
-
+
-
+
@@ -307,6 +307,9 @@ @section Scripts { + + + diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml index 653ab2a12..2b2216f58 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml @@ -82,9 +82,9 @@ if (categoryContacts != null && categoryContacts.Any()) { - @Html.Raw("