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
8 changes: 4 additions & 4 deletions Core/Resgrid.Config/ChatConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ public static class ChatConfig
public static string NovuApplicationId = "";
public static string NovuSecretKey = "";

public static string NovuUnitFcmProviderId = "";
public static string NovuUnitApnsProviderId = "";
public static string NovuResponderFcmProviderId = "";
public static string NovuResponderApnsProviderId = "";
public static string NovuUnitFcmProviderId = "firebase-cloud-messaging-7Z5wHFPpQ";
public static string NovuUnitApnsProviderId = "unit-apns";
public static string NovuResponderFcmProviderId = "respond-firebase-cloud-messaging";
public static string NovuResponderApnsProviderId = "respond-apns";
public static string NovuDispatchUnitWorkflowId = "unit-dispatch";
public static string NovuDispatchUserWorkflowId = "user-dispatch";
public static string NovuMessageUserWorkflowId = "user-message";
Expand Down
38 changes: 37 additions & 1 deletion Core/Resgrid.Config/InfoConfig.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace Resgrid.Config
using System.Collections.Generic;

namespace Resgrid.Config
{
public static class InfoConfig
{
Expand All @@ -21,5 +23,39 @@ public static class InfoConfig
public static string RelayAppKey = "RelayAppKey";

public static string EmailProcessorKey = "EmailProcessorKey";

public static List<ResgridSystemLocation> Locations = new List<ResgridSystemLocation>()
{
new ResgridSystemLocation()
{
Name = "US-West",
DisplayName = "Resgrid North America (Global)",
LocationInfo =
"This is the Resgrid system hosted in the Western United States (private datacenter). This system services most Resgrid customers.",
IsDefault = true,
ApiUrl = "https://api.resgrid.com",
AllowsFreeAccounts = true
},
new ResgridSystemLocation()
{
Name = "EU-Central",
DisplayName = "Resgrid Europe",
LocationInfo =
"This is the Resgrid system hosted in Central Europe (on OVH). This system services Resgrid customers in the European Union to help with data compliance requirements.",
IsDefault = false,
ApiUrl = "https://api.eu.resgrid.com",
AllowsFreeAccounts = false
}
};
Comment on lines +27 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Expose Locations as read-only to avoid global mutation; initialize immutably.

The config list is public and mutable, which is risky. Prefer IReadOnlyList and immutable initialization.

-		public static List<ResgridSystemLocation> Locations = new List<ResgridSystemLocation>()
-		{
-			new ResgridSystemLocation()
-			{
-				Name = "US-West",
-				DisplayName = "Resgrid North America (Global)",
-				LocationInfo =
-					"This is the Resgrid system hosted in the Western United States (private datacenter). This system services most Resgrid customers.",
-				IsDefault = true,
-				ApiUrl = "https://api.resgrid.com",
-				AllowsFreeAccounts = true
-			},
-			new ResgridSystemLocation()
-			{
-				Name = "EU-Central",
-				DisplayName = "Resgrid Europe",
-				LocationInfo =
-					"This is the Resgrid system hosted in Central Europe (on OVH). This system services Resgrid customers in the European Union to help with data compliance requirements.",
-				IsDefault = false,
-				ApiUrl = "https://api.eu.resgrid.com",
-				AllowsFreeAccounts = false
-			}
-		};
+		public static IReadOnlyList<ResgridSystemLocation> Locations { get; } = new[]
+		{
+			new ResgridSystemLocation(
+				Name: "US-West",
+				DisplayName: "Resgrid North America (Global)",
+				LocationInfo: "This is the Resgrid system hosted in the Western United States (private datacenter). This system services most Resgrid customers.",
+				IsDefault: true,
+				ApiUrl: "https://api.resgrid.com",
+				AllowsFreeAccounts: true
+			),
+			new ResgridSystemLocation(
+				Name: "EU-Central",
+				DisplayName: "Resgrid Europe",
+				LocationInfo: "This is the Resgrid system hosted in Central Europe (on OVH). This system services Resgrid customers in the European Union to help with data compliance requirements.",
+				IsDefault: false,
+				ApiUrl: "https://api.eu.resgrid.com",
+				AllowsFreeAccounts: false
+			)
+		};

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In Core/Resgrid.Config/InfoConfig.cs around lines 27 to 49, the public mutable
List<ResgridSystemLocation> Locations allows global mutation; change the API to
expose a read-only collection and initialize it immutably. Replace the public
mutable field with a private static readonly List<ResgridSystemLocation> backing
list populated with the two locations and expose it via a public static
IReadOnlyList<ResgridSystemLocation> (or ReadOnlyCollection/ImmutableList)
property that returns the immutable/read-only view so callers cannot add/remove
items.

}

public class ResgridSystemLocation
{
public string Name { get; set; }
public string DisplayName { get; set; }
public string LocationInfo { get; set; }
public bool IsDefault { get; set; }
public string ApiUrl { get; set; }
public bool AllowsFreeAccounts { get; set; }
Comment on lines +52 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use a record with init-only semantics for config state.

Records with positional parameters make the configuration immutable and concise, matching the guideline to separate state from behavior.

-	public class ResgridSystemLocation
-	{
-		public string Name { get; set; }
-		public string DisplayName { get; set; }
-		public string LocationInfo { get; set; }
-		public bool IsDefault { get; set; }
-		public string ApiUrl { get; set; }
-		public bool AllowsFreeAccounts { get; set; }
-	}
+	public sealed record ResgridSystemLocation(
+		string Name,
+		string DisplayName,
+		string LocationInfo,
+		bool IsDefault,
+		string ApiUrl,
+		bool AllowsFreeAccounts
+	);

If other code uses object initializers, convert those call sites accordingly. Also consider adding a helper to fetch the default location:

public static ResgridSystemLocation DefaultLocation => Locations.First(l => l.IsDefault);
🤖 Prompt for AI Agents
In Core/Resgrid.Config/InfoConfig.cs around lines 52 to 59, the mutable class
ResgridSystemLocation should be converted to an immutable record with init-only
semantics (preferably a positional record) so configuration state is represented
immutably; replace the class declaration with a record declaration, update
properties to be init-only (or use positional parameters), and adjust any call
sites that use object initializers to use the new positional/with syntax;
optionally add a static helper property (e.g., DefaultLocation) that returns
Locations.First(l => l.IsDefault) to fetch the configured default location.

}
}
5 changes: 5 additions & 0 deletions Core/Resgrid.Config/SystemBehaviorConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ public static class SystemBehaviorConfig
/// </summary>
public static string BlogUrl = "https://blog.resgrid.com";

/// <summary>
/// Sets the name of the location this Resgrid system is running in
/// </summary>
public static string LocationName = "US-West";

public static string GetEnvPrefix()
{
switch (Environment)
Expand Down
4 changes: 2 additions & 2 deletions Core/Resgrid.Model/Services/ICommunicationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public interface ICommunicationService
/// <param name="profile">The profile.</param>
/// <returns>Task&lt;System.Boolean&gt;.</returns>
Task<bool> SendMessageAsync(Message message, string sendersName, string departmentNumber, int departmentId,
UserProfile profile = null);
UserProfile profile = null, Department department = null);

/// <summary>
/// Sends the call asynchronous.
Expand Down Expand Up @@ -110,6 +110,6 @@ Task<bool> SendTextMessageAsync(string userId, string title, string message, int
/// <param name="profile">The profile.</param>
/// <returns>Task&lt;System.Boolean&gt;.</returns>
Task<bool> SendCalendarAsync(string userId, int departmentId, string message, string departmentNumber,
string title = "Notification", UserProfile profile = null);
string title = "Notification", UserProfile profile = null, Department department = null);
}
}
6 changes: 3 additions & 3 deletions Core/Resgrid.Services/CalendarService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ public async Task<bool> NotifyNewCalendarItemAsync(CalendarItem calendarItem)
// Notify the entire department
foreach (var profile in profiles)
{
await _communicationService.SendCalendarAsync(profile.Key, calendarItem.DepartmentId, message, departmentNumber, title, profile.Value);
await _communicationService.SendCalendarAsync(profile.Key, calendarItem.DepartmentId, message, departmentNumber, title, profile.Value, department);
}
}
else
Expand All @@ -487,9 +487,9 @@ public async Task<bool> NotifyNewCalendarItemAsync(CalendarItem calendarItem)
foreach (var member in group.Members)
{
if (profiles.ContainsKey(member.UserId))
await _communicationService.SendNotificationAsync(member.UserId, calendarItem.DepartmentId, message, departmentNumber, department, title, profiles[member.UserId]);
await _communicationService.SendCalendarAsync(member.UserId, calendarItem.DepartmentId, message, departmentNumber, title, profiles[member.UserId], department);
else
await _communicationService.SendNotificationAsync(member.UserId, calendarItem.DepartmentId, message, departmentNumber, department, title, null);
await _communicationService.SendCalendarAsync(member.UserId, calendarItem.DepartmentId, message, departmentNumber, title, null, department);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,48 +49,84 @@ public async Task<Call> GenerateCall(CallEmail email, string managingUser, List<
if (String.IsNullOrEmpty(email.Subject))
return null;

string[] sections = email.TextBody.Split(new[] {" ALPHA 512 "}, StringSplitOptions.None);
string[] sectionOneParts = sections[0].Split(new[] {" "}, StringSplitOptions.None);

Call c = new Call();
c.Notes = email.TextBody;
c.Name = sections[1].Trim();
c.LoggedOn = DateTime.UtcNow;
c.Priority = priority;
c.ReportingUserId = managingUser;
c.Dispatches = new Collection<CallDispatch>();
c.CallSource = (int)CallSources.EmailImport;
c.SourceIdentifier = email.MessageId;
c.NatureOfCall = sections[1].Trim();
c.IncidentNumber = sectionOneParts[0].Trim();
c.ExternalIdentifier = sectionOneParts[0].Trim();

if (users != null && users.Any())
try
{
foreach (var u in users)
string[] sections = email.TextBody.Split(new[] { " ALPHA 512 " }, StringSplitOptions.None);
string[] sectionOneParts = sections[0].Split(new[] { " " }, StringSplitOptions.None);

Call c = new Call();
c.Notes = email.TextBody;
c.Name = sections[1].Trim();
c.LoggedOn = DateTime.UtcNow;
c.Priority = priority;
c.ReportingUserId = managingUser;
c.Dispatches = new Collection<CallDispatch>();
c.CallSource = (int)CallSources.EmailImport;
c.SourceIdentifier = email.MessageId;
c.NatureOfCall = sections[1].Trim();
c.IncidentNumber = sectionOneParts[0].Trim();
c.ExternalIdentifier = sectionOneParts[0].Trim();

if (users != null && users.Any())
{
CallDispatch cd = new CallDispatch();
cd.UserId = u.UserId;
foreach (var u in users)
{
CallDispatch cd = new CallDispatch();
cd.UserId = u.UserId;

c.Dispatches.Add(cd);
c.Dispatches.Add(cd);
}
}
}

// Search for an active call
if (activeCalls != null && activeCalls.Any())
// Search for an active call
if (activeCalls != null && activeCalls.Any())
{
var activeCall = activeCalls.FirstOrDefault(x => x.IncidentNumber == c.IncidentNumber);

if (activeCall != null)
{
activeCall.Notes = c.Notes;
activeCall.LastDispatchedOn = DateTime.UtcNow;

return activeCall;
}
}

return c;
}
catch (Exception ex)
{
var activeCall = activeCalls.FirstOrDefault(x => x.IncidentNumber == c.IncidentNumber);
Call c = new Call();
c.Name = email.Subject;
c.NatureOfCall = $"ERROR PROCESSING DISPATCH EMAIL, Unprocessed email body: {email.TextBody}";

if (activeCall != null)
if (users != null && users.Any())
{
activeCall.Notes = c.Notes;
activeCall.LastDispatchedOn = DateTime.UtcNow;
foreach (var u in users)
{
CallDispatch cd = new CallDispatch();
cd.UserId = u.UserId;

return activeCall;
c.Dispatches.Add(cd);
}
}
Comment on lines +99 to 112
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix NullReference and complete fallback Call construction in catch block

c.Dispatches isn't initialized before Add, causing a NullReferenceException inside catch. Also key fields (Notes, LoggedOn, Priority, ReportingUserId, CallSource, SourceIdentifier) aren't set, and active-call lookup uses a null IncidentNumber.

Apply this diff:

-			catch (Exception ex)
+			catch (Exception ex)
 			{
-				Call c = new Call();
-				c.Name = email.Subject;
-				c.NatureOfCall = $"ERROR PROCESSING DISPATCH EMAIL, Unprocessed email body: {email.TextBody}";
+				Call c = new Call();
+				c.Name = email.Subject ?? "Email-to-Call Error";
+				c.Notes = email.TextBody;
+				c.NatureOfCall = $"ERROR PROCESSING DISPATCH EMAIL, Unprocessed email body: {email.TextBody}";
+				c.LoggedOn = DateTime.UtcNow;
+				c.Priority = priority;
+				c.ReportingUserId = managingUser;
+				c.Dispatches = new Collection<CallDispatch>();
+				c.CallSource = (int)CallSources.EmailImport;
+				c.SourceIdentifier = email.MessageId;
 
 				if (users != null && users.Any())
 				{
 					foreach (var u in users)
 					{
 						CallDispatch cd = new CallDispatch();
 						cd.UserId = u.UserId;
 
 						c.Dispatches.Add(cd);
 					}
 				}
 
-				// Search for an active call
-				if (activeCalls != null && activeCalls.Any())
+				// Search for an active call only if we have an incident number
+				if (!string.IsNullOrWhiteSpace(c.IncidentNumber) && activeCalls != null && activeCalls.Any())
 				{
 					var activeCall = activeCalls.FirstOrDefault(x => x.IncidentNumber == c.IncidentNumber);
 
 					if (activeCall != null)
 					{
 						activeCall.Notes = c.Notes;
 						activeCall.LastDispatchedOn = DateTime.UtcNow;
 
 						return activeCall;
 					}
 				}
 
 				return c;
 			}

Also applies to: 114-129

🤖 Prompt for AI Agents
In Core/Resgrid.Services/CallEmailTemplates/OttawaKingstonTorontoTemplate.cs
around lines 99-112 (and similarly for 114-129), the fallback Call created in
the catch block leaves Call.Dispatches null and omits key properties causing
NREs and incorrect active-call lookup. Initialize c.Dispatches to an empty
collection before adding CallDispatch items; set the missing properties (c.Notes
= email.TextBody or appropriate message, c.LoggedOn = DateTime.UtcNow,
c.Priority = default priority, c.ReportingUserId = a valid reporter id or null
handling, c.CallSource = CallSource.Email (or appropriate enum),
c.SourceIdentifier = email.MessageId or a unique identifier) and ensure
IncidentNumber is set to a non-null value (e.g., generate or use
email.MessageId) before doing the active-call lookup; also keep the users
null/empty check and only add dispatches when users exist.

}

return c;
// Search for an active call
if (activeCalls != null && activeCalls.Any())
{
var activeCall = activeCalls.FirstOrDefault(x => x.IncidentNumber == c.IncidentNumber);

if (activeCall != null)
{
activeCall.Notes = c.Notes;
activeCall.LastDispatchedOn = DateTime.UtcNow;

return activeCall;
}
}

return c;
}
}
}
}
8 changes: 6 additions & 2 deletions Core/Resgrid.Services/CommunicationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public CommunicationService(ISmsService smsService, IEmailService emailService,
_userStateService = userStateService;
}

public async Task<bool> SendMessageAsync(Message message, string sendersName, string departmentNumber, int departmentId, UserProfile profile = null)
public async Task<bool> SendMessageAsync(Message message, string sendersName, string departmentNumber, int departmentId, UserProfile profile = null, Department department = null)
{
if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId))
return false;
Expand Down Expand Up @@ -79,6 +79,7 @@ public async Task<bool> SendMessageAsync(Message message, string sendersName, st
{
var spm = new StandardPushMessage();
spm.MessageId = message.MessageId;
spm.DepartmentCode = department?.Code;

if (message.SystemGenerated)
spm.SubTitle = "Msg from System";
Expand Down Expand Up @@ -124,6 +125,7 @@ public async Task<bool> SendCallAsync(Call call, CallDispatch dispatch, string d
spc.Priority = call.Priority;
spc.ActiveCallCount = 1;
spc.DepartmentId = departmentId;
spc.DepartmentCode = call.Department?.Code;

if (call.CallPriority != null && !String.IsNullOrWhiteSpace(call.CallPriority.Color))
{
Expand Down Expand Up @@ -335,7 +337,7 @@ public async Task<bool> SendNotificationAsync(string userId, int departmentId, s
return true;
}

public async Task<bool> SendCalendarAsync(string userId, int departmentId, string message, string departmentNumber, string title = "Notification", UserProfile profile = null)
public async Task<bool> SendCalendarAsync(string userId, int departmentId, string message, string departmentNumber, string title = "Notification", UserProfile profile = null, Department department = null)
{
if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId))
return false;
Expand Down Expand Up @@ -364,6 +366,7 @@ public async Task<bool> SendCalendarAsync(string userId, int departmentId, strin
var spm = new StandardPushMessage();
spm.Title = "Calendar";
spm.SubTitle = $"{title} {message}";
spm.DepartmentCode = null;

try
{
Expand Down Expand Up @@ -467,6 +470,7 @@ public async Task<bool> SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven
spc.Priority = (int)CallPriority.Emergency;
spc.ActiveCallCount = 1;
spc.DepartmentId = departmentId;
spc.DepartmentCode = call.Department?.Code;

string subTitle = String.Empty;
if (!String.IsNullOrWhiteSpace(unitAddress))
Expand Down
6 changes: 4 additions & 2 deletions Core/Resgrid.Services/PushService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ public async Task<bool> PushMessage(StandardPushMessage message, string userId,

try
{
await _novuProvider.SendUserMessage(message.Title, message.SubTitle, userId, message.DepartmentCode, string.Format("M{0}", message.MessageId), null);
if (!string.IsNullOrWhiteSpace(message.DepartmentCode))
await _novuProvider.SendUserMessage(message.Title, message.SubTitle, userId, message.DepartmentCode, string.Format("M{0}", message.MessageId), null);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -143,7 +144,8 @@ public async Task<bool> PushNotification(StandardPushMessage message, string use
}
try
{
await _novuProvider.SendUserMessage(message.Title, message.SubTitle, userId, message.DepartmentCode, string.Format("N{0}", message.MessageId), null);
if (!string.IsNullOrWhiteSpace(message.DepartmentCode))
await _novuProvider.SendUserNotification(message.Title, message.SubTitle, userId, message.DepartmentCode, string.Format("N{0}", message.MessageId), null);
}
catch (Exception ex)
{
Expand Down
1 change: 1 addition & 0 deletions Providers/Resgrid.Providers.Messaging/NovuProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ private async Task<bool> SendNotification(string title, string body, string reci
{
subject = title,
body = body,
id = eventCode
},
overrides = new
{
Expand Down
17 changes: 17 additions & 0 deletions Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ public ConfigController()
}
#endregion Members and Constructors

/// <summary>
/// Gets the system config
/// </summary>
/// <returns></returns>
[HttpGet("GetSystemConfig")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<GetSystemConfigResult>> GetSystemConfig()
{
var result = new GetSystemConfigResult();

result.PageSize = 1;
result.Status = ResponseHelper.Success;
ResponseHelper.PopulateV4ResponseData(result);

return result;
}

/// <summary>
/// Gets the config values for a key
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using Resgrid.Config;

namespace Resgrid.Web.Services.Models.v4.Configs
{
/// <summary>
/// Gets Configuration Information for the Resgrid System
/// </summary>
public class GetSystemConfigResult : StandardApiResponseV4Base
{
/// <summary>
/// Response Data
/// </summary>
public GetSystemConfigResultData Data { get; set; }

/// <summary>
/// Default constructor
/// </summary>
public GetSystemConfigResult()
{
Data = new GetSystemConfigResultData();
}
}

/// <summary>
/// Information about the Resgrid System
/// </summary>
public class GetSystemConfigResultData
{
/// <summary>
/// Resgrid Datacenter Locations
/// </summary>
public List<ResgridSystemLocation> Locations { get; set; }

public GetSystemConfigResultData()
{
Locations = InfoConfig.Locations;
}
Comment on lines +35 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Don’t return a reference to global mutable state (deep-copy Locations).

Assigning InfoConfig.Locations directly exposes and couples response instances to a shared static list. Any downstream mutation (even accidental) would alter global config for all requests. Deep-copy the entries before returning.

 using System.Collections.Generic;
+using System.Linq;
 using Resgrid.Config;
@@
   public GetSystemConfigResultData()
   {
-    Locations = InfoConfig.Locations;
+    Locations = InfoConfig.Locations
+      .Select(l => new ResgridSystemLocation
+      {
+        Name = l.Name,
+        DisplayName = l.DisplayName,
+        LocationInfo = l.LocationInfo,
+        IsDefault = l.IsDefault,
+        ApiUrl = l.ApiUrl,
+        AllowsFreeAccounts = l.AllowsFreeAccounts
+      })
+      .ToList();
   }

Also applies to: 1-1

🤖 Prompt for AI Agents
In Web/Resgrid.Web.Services/Models/v4/Configs/GetSystemConfigResult.cs around
lines 35-38, the constructor assigns Locations = InfoConfig.Locations which
exposes a reference to global mutable state; replace this with a deep-copy of
the list and its entries (e.g. null-check InfoConfig.Locations, create a new
list and map/clone each Location item into a new DTO/instance copying all
relevant properties) so the returned Locations cannot mutate the shared static
list.

}
}
Loading
Loading