Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Core/Resgrid.Config/SystemBehaviorConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ public static class SystemBehaviorConfig
/// </summary>
public static string ResgridEventingBaseUrl = "https://resgridevents.local";

/// <summary>
/// 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.)
/// </summary>
public static int SmsMaxLength = 459;

/// <summary>
/// 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.
/// </summary>
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";

/// <summary>
/// Resgrid internal Billing API Url. Do not set for Open-Source install.
/// </summary>
Expand Down
68 changes: 68 additions & 0 deletions Core/Resgrid.Framework/PhoneRegionHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Collections.Generic;

namespace Resgrid.Framework
{
/// <summary>
/// Maps a country display name (as used in <c>Resgrid.Model.Countries.CountryNames</c>) 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.
/// </summary>
public static class PhoneRegionHelper
{
private static readonly Dictionary<string, string> NameToIso = new Dictionary<string, string>(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" },
};

/// <summary>Returns the ISO-3166 alpha-2 region code for a country name, or null when not mapped/blank.</summary>
public static string ToIso(string countryName)
{
if (string.IsNullOrWhiteSpace(countryName))
return null;

return NameToIso.TryGetValue(countryName.Trim(), out var iso) ? iso : null;
}
}
}
129 changes: 129 additions & 0 deletions Core/Resgrid.Framework/SmsContentHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace Resgrid.Framework
{
/// <summary>
/// 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.
/// </summary>
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);

/// <summary>Strip-disallowed-URLs, normalize, then truncate. Returns a carrier-safe, cost-bounded body.</summary>
public static string PrepareForSms(string message, int maxLength, IEnumerable<string> allowedUrlDomains)
{
if (string.IsNullOrEmpty(message))
return message;

message = StripDisallowedUrls(message, allowedUrlDomains);
message = NormalizeForGsm(message);
message = Truncate(message, maxLength);
return message;
}

/// <summary>Removes any URL whose host is not on the allow-list (host or a subdomain of an allowed domain).</summary>
public static string StripDisallowedUrls(string message, IEnumerable<string> allowedUrlDomains)
{
if (string.IsNullOrEmpty(message))
return message;

var allowed = (allowedUrlDomains ?? Enumerable.Empty<string>())
.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<string> 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();
}

/// <summary>Replaces the most common copy-paste non-GSM-7 characters so the body stays single-byte (cheaper).</summary>
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
}

/// <summary>Truncates to <paramref name="maxLength"/> with a clear suffix when over the limit.</summary>
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;
}
}
}
10 changes: 5 additions & 5 deletions Core/Resgrid.Model/Address.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
6 changes: 6 additions & 0 deletions Core/Resgrid.Model/CheckInRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public class CheckInRecord : IEntity

public string Note { get; set; }

/// <summary>
/// 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.
/// </summary>
public string IdempotencyKey { get; set; }

[NotMapped]
public string TableName => "CheckInRecords";

Expand Down
14 changes: 14 additions & 0 deletions Core/Resgrid.Model/IChangeTracked.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;

namespace Resgrid.Model
{
/// <summary>
/// Entities that carry a <see cref="ModifiedOn"/> 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 <c>docs/architecture/offline-first-architecture.md</c>.
/// </summary>
public interface IChangeTracked
{
DateTime? ModifiedOn { get; set; }
}
}
13 changes: 11 additions & 2 deletions Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <c>CommandDefinitionRole</c> then per-incident editable.
/// </summary>
public class CommandStructureNode : IEntity
public class CommandStructureNode : IEntity, IChangeTracked
{
public string CommandStructureNodeId { get; set; }

Expand All @@ -36,6 +36,12 @@ public class CommandStructureNode : IEntity
/// <summary>The CommandDefinitionRole this node was seeded from, if any.</summary>
public int? SourceRoleId { get; set; }

/// <summary>Soft-delete tombstone so a lane removed offline propagates on delta sync (null = live).</summary>
public DateTime? DeletedOn { get; set; }

/// <summary>Change cursor for offline delta sync + last-write-wins; stamped on every write.</summary>
public DateTime? ModifiedOn { get; set; }

[NotMapped]
public string TableName => "CommandStructureNodes";

Expand All @@ -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.
/// </summary>
public class ResourceAssignment : IEntity
public class ResourceAssignment : IEntity, IChangeTracked
{
public string ResourceAssignmentId { get; set; }

Expand All @@ -85,6 +91,9 @@ public class ResourceAssignment : IEntity

public DateTime? ReleasedOn { get; set; }

/// <summary>Change cursor for offline delta sync + last-write-wins; stamped on every write.</summary>
public DateTime? ModifiedOn { get; set; }

[NotMapped]
public string TableName => "ResourceAssignments";

Expand Down
10 changes: 8 additions & 2 deletions Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public class IncidentAdHocUnit : IEntity
public class IncidentAdHocUnit : IEntity, IChangeTracked
{
public string IncidentAdHocUnitId { get; set; }

Expand All @@ -34,6 +34,9 @@ public class IncidentAdHocUnit : IEntity

public DateTime? ReleasedOn { get; set; }

/// <summary>Change cursor for offline delta sync + last-write-wins; stamped on every write.</summary>
public DateTime? ModifiedOn { get; set; }

[NotMapped]
public string TableName => "IncidentAdHocUnits";

Expand All @@ -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 <see cref="RidingResourceKind"/> + <see cref="RidingResourceId"/>.
/// </summary>
public class IncidentAdHocPersonnel : IEntity
public class IncidentAdHocPersonnel : IEntity, IChangeTracked
{
public string IncidentAdHocPersonnelId { get; set; }

Expand Down Expand Up @@ -88,6 +91,9 @@ public class IncidentAdHocPersonnel : IEntity

public DateTime? ReleasedOn { get; set; }

/// <summary>Change cursor for offline delta sync + last-write-wins; stamped on every write.</summary>
public DateTime? ModifiedOn { get; set; }

[NotMapped]
public string TableName => "IncidentAdHocPersonnel";

Expand Down
5 changes: 4 additions & 1 deletion Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Resgrid.Model
/// A live incident-command instance established on a specific <c>Call</c>. Seeded (optionally) from a
/// <c>CommandDefinition</c> template and then freely editable by the Commander for the life of the incident.
/// </summary>
public class IncidentCommand : IEntity
public class IncidentCommand : IEntity, IChangeTracked
{
public string IncidentCommandId { get; set; }

Expand Down Expand Up @@ -40,6 +40,9 @@ public class IncidentCommand : IEntity

public DateTime? ClosedOn { get; set; }

/// <summary>Change cursor for offline delta sync + last-write-wins; stamped on every write.</summary>
public DateTime? ModifiedOn { get; set; }

[NotMapped]
public string TableName => "IncidentCommands";

Expand Down
Loading
Loading