+
diff --git a/Providers/Resgrid.Providers.Email/Template/Call.html b/Providers/Resgrid.Providers.Email/Template/Call.html
index ed65e0abe..fd37cbec1 100644
--- a/Providers/Resgrid.Providers.Email/Template/Call.html
+++ b/Providers/Resgrid.Providers.Email/Template/Call.html
@@ -497,9 +497,9 @@ {{subject}}
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Email/Template/Cancelled.html b/Providers/Resgrid.Providers.Email/Template/Cancelled.html
index ff7b8b9c5..0e5fabaff 100644
--- a/Providers/Resgrid.Providers.Email/Template/Cancelled.html
+++ b/Providers/Resgrid.Providers.Email/Template/Cancelled.html
@@ -471,9 +471,9 @@ Your Resgrid subscription has been canceled
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Email/Template/ChargeFailed.html b/Providers/Resgrid.Providers.Email/Template/ChargeFailed.html
index 4d896a34e..e9db57761 100644
--- a/Providers/Resgrid.Providers.Email/Template/ChargeFailed.html
+++ b/Providers/Resgrid.Providers.Email/Template/ChargeFailed.html
@@ -467,9 +467,9 @@ We were unable to charge your card for your {{plan_name}} plan.
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html b/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html
new file mode 100644
index 000000000..93a501cf1
--- /dev/null
+++ b/Providers/Resgrid.Providers.Email/Template/CommunicationTest.html
@@ -0,0 +1,487 @@
+
+
+
+
+
+ Resgrid Communication Test
+
+
+
+
+
+
+
+
+
+
+ |
+
+ Resgrid
+
+ |
+
+
+
+
+
+
+
+
+ {{greeting}}
+ {{intro}}
+ {{disclaimer}}
+
+
+
+
+
+ | {{department_label}} {{department_name}} |
+
+
+ | {{test_label}} {{test_name}} |
+
+
+ |
+
+
+ {{action}}
+
+
+
+ {{signoff}}
+ {{team_name}}
+
+
+
+
+ |
+ {{trouble_text}}
+ {{confirm_url}}
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ |
+
+
+ |
+
+
+
+
diff --git a/Providers/Resgrid.Providers.Email/Template/DeleteDepartment.html b/Providers/Resgrid.Providers.Email/Template/DeleteDepartment.html
index 27a4aaeba..0ab99f6a5 100644
--- a/Providers/Resgrid.Providers.Email/Template/DeleteDepartment.html
+++ b/Providers/Resgrid.Providers.Email/Template/DeleteDepartment.html
@@ -457,9 +457,9 @@ Hi {{name}},
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Email/Template/DepartmentLinkCreated.html b/Providers/Resgrid.Providers.Email/Template/DepartmentLinkCreated.html
index e7a4c76b1..264735326 100644
--- a/Providers/Resgrid.Providers.Email/Template/DepartmentLinkCreated.html
+++ b/Providers/Resgrid.Providers.Email/Template/DepartmentLinkCreated.html
@@ -468,9 +468,9 @@ Hi {{name}},
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Email/Template/Invitation.html b/Providers/Resgrid.Providers.Email/Template/Invitation.html
index 661ea7543..1286750b8 100644
--- a/Providers/Resgrid.Providers.Email/Template/Invitation.html
+++ b/Providers/Resgrid.Providers.Email/Template/Invitation.html
@@ -458,9 +458,9 @@ Hello,
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Email/Template/Message.html b/Providers/Resgrid.Providers.Email/Template/Message.html
index 1ae07ccf2..8767c22fd 100644
--- a/Providers/Resgrid.Providers.Email/Template/Message.html
+++ b/Providers/Resgrid.Providers.Email/Template/Message.html
@@ -450,6 +450,13 @@ {{title}}
By {{sender_name}} at {{timestamp}}
View the message
+
+ Resgrid, LLC
+ 3079 Harrison Ave
+ Suite 110
+ South Lake Tahoe, CA 96150
+ USA
+
|
diff --git a/Providers/Resgrid.Providers.Email/Template/PasswordReset.html b/Providers/Resgrid.Providers.Email/Template/PasswordReset.html
index f64e66006..d373b487e 100644
--- a/Providers/Resgrid.Providers.Email/Template/PasswordReset.html
+++ b/Providers/Resgrid.Providers.Email/Template/PasswordReset.html
@@ -473,9 +473,9 @@ Hi {{name}},
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Email/Template/Receipt.html b/Providers/Resgrid.Providers.Email/Template/Receipt.html
index 775e3b8dc..5163df8b2 100644
--- a/Providers/Resgrid.Providers.Email/Template/Receipt.html
+++ b/Providers/Resgrid.Providers.Email/Template/Receipt.html
@@ -517,9 +517,9 @@ {{date}}
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Email/Template/ReportDelivery.html b/Providers/Resgrid.Providers.Email/Template/ReportDelivery.html
index 6544fcfe7..99cac84bf 100644
--- a/Providers/Resgrid.Providers.Email/Template/ReportDelivery.html
+++ b/Providers/Resgrid.Providers.Email/Template/ReportDelivery.html
@@ -428,6 +428,13 @@ {{title}}
Sent on: {{timestamp}}
View your Scheduled Report Deliveries
+
+ Resgrid, LLC
+ 3079 Harrison Ave
+ Suite 110
+ South Lake Tahoe, CA 96150
+ USA
+
|
diff --git a/Providers/Resgrid.Providers.Email/Template/TroubleAlert.html b/Providers/Resgrid.Providers.Email/Template/TroubleAlert.html
index 9cc9e8a69..5c66daef5 100644
--- a/Providers/Resgrid.Providers.Email/Template/TroubleAlert.html
+++ b/Providers/Resgrid.Providers.Email/Template/TroubleAlert.html
@@ -442,9 +442,9 @@ TROUBLE ALERT
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Email/Template/Welcome.html b/Providers/Resgrid.Providers.Email/Template/Welcome.html
index 284ae6ba2..6e1007045 100644
--- a/Providers/Resgrid.Providers.Email/Template/Welcome.html
+++ b/Providers/Resgrid.Providers.Email/Template/Welcome.html
@@ -488,9 +488,9 @@ Welcome, {{name}}!
© Resgrid, LLC. All rights reserved.
Resgrid, LLC
-
1802 North Carson Street
-
Suite 157
-
Carson City, NV 89701
+
3079 Harrison Ave
+
Suite 110
+
South Lake Tahoe, CA 96150
USA
diff --git a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs
index 6c2c6fe7a..58b7210ea 100644
--- a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs
+++ b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs
@@ -9,12 +9,90 @@
using Resgrid.Providers.Bus.Models;
using SharpCompress.Common;
using System.Text;
+using System.Text.RegularExpressions;
namespace Resgrid.Providers.Messaging
{
public class NovuProvider : INovuProvider
{
+ private const int MaxLoggedErrorBodyLength = 500;
+
+ ///
+ /// Anything long and opaque enough to be a credential rather than prose. Device tokens are the
+ /// reason this exists: a rejected credential write echoes the token back inside its validation
+ /// message, and FCM/APNS tokens are well over this length.
+ ///
+ private static readonly Regex OpaqueValuePattern = new Regex(@"[A-Za-z0-9_\-:\.]{20,}", RegexOptions.Compiled);
+
+ private static readonly Regex WhitespaceRunPattern = new Regex(@"\s+", RegexOptions.Compiled);
+
+ ///
+ /// Novu's error bodies are provider-controlled and unbounded, and the calls that produce them
+ /// carry device tokens and notification wording -- a validation failure echoes the rejected
+ /// payload straight back. Rather than trusting the body, pull the few diagnostic fields worth
+ /// having, redact anything token-shaped inside them and cap the result. A body that isn't the
+ /// shape we expect is reported by size alone; its content never reaches the log.
+ ///
+ private static string DescribeErrorBody(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ return "";
+
+ var summary = ExtractDiagnosticFields(body);
+ if (string.IsNullOrWhiteSpace(summary))
+ return $"";
+
+ summary = WhitespaceRunPattern.Replace(summary, " ").Trim();
+ summary = OpaqueValuePattern.Replace(summary, "");
+
+ return summary.Length > MaxLoggedErrorBodyLength
+ ? summary.Substring(0, MaxLoggedErrorBodyLength) + "..."
+ : summary;
+ }
+
+ ///
+ /// Whitelist, not blacklist: only these keys are ever read out of the body, so a field we have
+ /// not vetted cannot reach the log by being added on Novu's side.
+ ///
+ private static string? ExtractDiagnosticFields(string body)
+ {
+ try
+ {
+ var parsed = JToken.Parse(body);
+ var parts = new List();
+
+ foreach (var name in new[] { "statusCode", "error", "message" })
+ {
+ var value = parsed.SelectToken(name);
+ if (value == null)
+ continue;
+
+ // class-validator returns message as an array of strings.
+ if (value.Type == JTokenType.Array)
+ {
+ var items = value.Children()
+ .Where(x => x.Type != JTokenType.Object && x.Type != JTokenType.Array)
+ .Select(x => x.ToString());
+
+ var joined = string.Join("; ", items);
+ if (!string.IsNullOrWhiteSpace(joined))
+ parts.Add($"{name}={joined}");
+ }
+ else if (value.Type != JTokenType.Object)
+ {
+ parts.Add($"{name}={value}");
+ }
+ }
+
+ return string.Join(" ", parts);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
private async Task CreateSubscriber(string id, int departmentId, string email, string firstName, string lastName, List data)
{
try
@@ -130,7 +208,17 @@ private async Task UpdateSubscriberFcm(string id, string token, string fcm
request.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
HttpResponseMessage response = await client.SendAsync(request);
- return response.IsSuccessStatusCode;
+ // An unknown integrationIdentifier, an inactive integration or a malformed token all come
+ // back as a 4xx here. Swallowing that left the subscriber with no push channel and no clue.
+ if (!response.IsSuccessStatusCode)
+ {
+ var error = await response.Content.ReadAsStringAsync();
+ Logging.LogError($"Novu FCM credential write failed ({(int)response.StatusCode} {response.StatusCode}) subscriber '{id}' integration '{fcmId}': {DescribeErrorBody(error)}");
+
+ return false;
+ }
+
+ return true;
}
}
catch (Exception e)
@@ -184,13 +272,22 @@ private async Task UpdateSubscriberApns(string id, string token, string ap
if (string.IsNullOrWhiteSpace(jsonContent))
{
+ Logging.LogWarning($"Novu APNS credential write skipped for subscriber '{id}': neither an apns nor an fcm integration identifier was supplied.");
return false;
}
request.Content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
HttpResponseMessage response = await client.SendAsync(request);
- return response.IsSuccessStatusCode;
+ if (!response.IsSuccessStatusCode)
+ {
+ var error = await response.Content.ReadAsStringAsync();
+ Logging.LogError($"Novu APNS credential write failed ({(int)response.StatusCode} {response.StatusCode}) subscriber '{id}' integration '{apnsId ?? fcmId}': {DescribeErrorBody(error)}");
+
+ return false;
+ }
+
+ return true;
}
}
catch (Exception e)
@@ -351,7 +448,18 @@ private async Task SendNotification(string title, string body, string reci
var result = await httpClient.PostAsync("v1/events/trigger", content);
- return result.IsSuccessStatusCode;
+ // A rejected trigger (unknown workflow identifier, unknown subscriber, bad payload) is a
+ // 4xx with a body explaining why. Returning the bare bool made every one of those silent,
+ // so a workflow that was never created in Novu looked exactly like a delivered push.
+ if (!result.IsSuccessStatusCode)
+ {
+ var error = await result.Content.ReadAsStringAsync();
+ Logging.LogError($"Novu trigger failed ({(int)result.StatusCode} {result.StatusCode}) workflow '{workflowIdentifier}' subscriber '{recipientId}' event '{eventCode}': {DescribeErrorBody(error)}");
+
+ return false;
+ }
+
+ return true;
}
}
catch (Exception e)
diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
index 550dd61ca..d624196a6 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/ChatRepositories.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Common;
@@ -252,9 +252,14 @@ public async Task> GetByIdsAsync(IEnumerable ch
if (ids.Count == 0)
return new List();
+ // Dapper only rewrites an IN-list into individual bind variables on providers that
+ // lack array support. Npgsql has it, so Dapper binds the list as one array parameter
+ // and leaves the SQL untouched -- "IN @Ids" arrives at the server as "IN $1" and
+ // fails to parse. Postgres consumes the array directly with = ANY(); SQL Server
+ // still needs the IN form Dapper expands. Every list parameter below follows this.
var notation = _sqlConfiguration.ParameterNotation;
var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
- ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannels WHERE chatchannelid IN {notation}Ids"
+ ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannels WHERE chatchannelid = ANY({notation}Ids)"
: $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatChannels] WHERE [ChatChannelId] IN {notation}Ids";
var select = new Func>>(connection =>
@@ -910,7 +915,7 @@ public async Task> GetActiveByChannelIdsAsync(IEn
var notation = _sqlConfiguration.ParameterNotation;
var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
- ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannelmembers WHERE chatchannelid IN {notation}Ids AND removedon IS NULL"
+ ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatchannelmembers WHERE chatchannelid = ANY({notation}Ids) AND removedon IS NULL"
: $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatChannelMembers] WHERE [ChatChannelId] IN {notation}Ids AND [RemovedOn] IS NULL";
var select = new Func>>(connection =>
@@ -1352,7 +1357,7 @@ public async Task> SearchAsync(int departmentId, IEnume
{
var fromClause = from.HasValue ? $" AND senton >= {notation}From" : string.Empty;
var toClause = to.HasValue ? $" AND senton <= {notation}To" : string.Empty;
- sql = $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessages WHERE departmentid = {notation}DepartmentId AND chatchannelid IN {notation}Ids AND deletedon IS NULL AND body ILIKE {notation}Query{fromClause}{toClause} ORDER BY senton DESC LIMIT {notation}PageSize OFFSET {notation}Offset";
+ sql = $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessages WHERE departmentid = {notation}DepartmentId AND chatchannelid = ANY({notation}Ids) AND deletedon IS NULL AND body ILIKE {notation}Query{fromClause}{toClause} ORDER BY senton DESC LIMIT {notation}PageSize OFFSET {notation}Offset";
}
else
{
@@ -1472,13 +1477,13 @@ public async Task DeleteMessagesByIdsAsync(List chatMessageIds, Can
{
statements = new[]
{
- $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageedits WHERE chatmessageid IN @Ids",
- $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessagereactions WHERE chatmessageid IN @Ids",
- $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessagementions WHERE chatmessageid IN @Ids",
- $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageacks WHERE chatmessageid IN @Ids",
- $"DELETE FROM {_sqlConfiguration.SchemaName}.chatattachments WHERE chatmessageid IN @Ids",
- $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageflags WHERE chatmessageid IN @Ids",
- $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessages WHERE chatmessageid IN @Ids"
+ $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageedits WHERE chatmessageid = ANY(@Ids)",
+ $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessagereactions WHERE chatmessageid = ANY(@Ids)",
+ $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessagementions WHERE chatmessageid = ANY(@Ids)",
+ $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageacks WHERE chatmessageid = ANY(@Ids)",
+ $"DELETE FROM {_sqlConfiguration.SchemaName}.chatattachments WHERE chatmessageid = ANY(@Ids)",
+ $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessageflags WHERE chatmessageid = ANY(@Ids)",
+ $"DELETE FROM {_sqlConfiguration.SchemaName}.chatmessages WHERE chatmessageid = ANY(@Ids)"
};
}
else
@@ -1739,7 +1744,7 @@ public async Task> GetChatExportEditsByMessageIdsAs
var notation = _sqlConfiguration.ParameterNotation;
var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
- ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessageedits WHERE chatmessageid IN {notation}Ids"
+ ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessageedits WHERE chatmessageid = ANY({notation}Ids)"
: $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatMessageEdits] WHERE [ChatMessageId] IN {notation}Ids";
var select = new Func>>(connection =>
@@ -1786,7 +1791,7 @@ public async Task> GetMetadataByMessageIdsAsync(IEnu
var notation = _sqlConfiguration.ParameterNotation;
var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
- ? $"SELECT chatattachmentid, chatmessageid, chatchannelid, departmentid, filename, contenttype, size, sha256, uploadedbyuserid, uploadedon FROM {_sqlConfiguration.SchemaName}.chatattachments WHERE chatmessageid IN {notation}Ids"
+ ? $"SELECT chatattachmentid, chatmessageid, chatchannelid, departmentid, filename, contenttype, size, sha256, uploadedbyuserid, uploadedon FROM {_sqlConfiguration.SchemaName}.chatattachments WHERE chatmessageid = ANY({notation}Ids)"
: $"SELECT [ChatAttachmentId], [ChatMessageId], [ChatChannelId], [DepartmentId], [FileName], [ContentType], [Size], [Sha256], [UploadedByUserId], [UploadedOn] FROM {_sqlConfiguration.SchemaName}.[ChatAttachments] WHERE [ChatMessageId] IN {notation}Ids";
var select = new Func>>(connection =>
@@ -1833,7 +1838,7 @@ public async Task> GetByMessageIdsAsync(IEnumer
var notation = _sqlConfiguration.ParameterNotation;
var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
- ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessagereactions WHERE chatmessageid IN {notation}Ids"
+ ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatmessagereactions WHERE chatmessageid = ANY({notation}Ids)"
: $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatMessageReactions] WHERE [ChatMessageId] IN {notation}Ids";
var select = new Func>>(connection =>
diff --git a/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs
index dcec8880f..2166b6d80 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/ModerationRepositories.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
@@ -84,7 +84,7 @@ public async Task> GetByItemsAndReporterAsync(int
r.completedbyuserid, r.completedon, r.adminnote
FROM {_sqlConfiguration.SchemaName}.moderationrequests r
WHERE r.departmentid = {notation}DepartmentId AND r.itemtype = {notation}ItemType
- AND r.itemid IN {notation}ItemIds
+ AND r.itemid = ANY({notation}ItemIds)
AND EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports p
WHERE p.moderationrequestid = r.moderationrequestid AND p.reportedbyuserid = {notation}ReporterUserId)"
: $@"SELECT r.[ModerationRequestId], r.[DepartmentId], r.[ItemType], r.[ItemId], r.[CallId],
@@ -181,7 +181,7 @@ public async Task> SearchAsync(int departmentId,
? $" AND rv.reportedbyuserid = {notation}ReportedByUserId"
: $" AND rv.[ReportedByUserId] = {notation}ReportedByUserId";
filters.Add(postgres
- ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rv WHERE rv.moderationrequestid = r.moderationrequestid{requestedReporter} AND (rv.reportedbyuserid = {notation}ReporterUserId OR rv.reportergroupid IN {notation}VisibleGroupIds))"
+ ? $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.moderationreports rv WHERE rv.moderationrequestid = r.moderationrequestid{requestedReporter} AND (rv.reportedbyuserid = {notation}ReporterUserId OR rv.reportergroupid = ANY({notation}VisibleGroupIds)))"
: $"EXISTS (SELECT 1 FROM {_sqlConfiguration.SchemaName}.[ModerationReports] rv WHERE rv.[ModerationRequestId] = r.[ModerationRequestId]{requestedReporter} AND (rv.[ReportedByUserId] = {notation}ReporterUserId OR rv.[ReporterGroupId] IN {notation}VisibleGroupIds))");
}
else
@@ -292,7 +292,7 @@ public async Task> GetByRequestIdsAsync(IEnumerabl
var notation = _sqlConfiguration.ParameterNotation;
var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
- ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.moderationreports WHERE moderationrequestid IN {notation}Ids ORDER BY moderationrequestid, reportedon"
+ ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.moderationreports WHERE moderationrequestid = ANY({notation}Ids) ORDER BY moderationrequestid, reportedon"
: $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ModerationReports] WHERE [ModerationRequestId] IN {notation}Ids ORDER BY [ModerationRequestId], [ReportedOn]";
var select = new Func>>(connection =>
@@ -424,7 +424,7 @@ public async Task> GetByRequestIdsAsync(IEnumerabl
performedbyuserid, performedon, note, previousstatus, newstatus, actorrole, ipaddress,
useragent, traceid, servername, detailsjson, evidencetext, evidencemetadatajson
FROM {_sqlConfiguration.SchemaName}.moderationactions
-WHERE moderationrequestid IN {notation}Ids ORDER BY moderationrequestid, performedon"
+WHERE moderationrequestid = ANY({notation}Ids) ORDER BY moderationrequestid, performedon"
: $@"SELECT [ModerationActionId], [ModerationRequestId], [DepartmentId], [ActionType],
[PerformedByUserId], [PerformedOn], [Note], [PreviousStatus], [NewStatus], [ActorRole], [IpAddress],
[UserAgent], [TraceId], [ServerName], [DetailsJson], [EvidenceText], [EvidenceMetadataJson]
diff --git a/Repositories/Resgrid.Repositories.DataRepository/UdfFieldValueRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UdfFieldValueRepository.cs
index 9ebd8b859..b2c820fe6 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/UdfFieldValueRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/UdfFieldValueRepository.cs
@@ -1,4 +1,5 @@
using Dapper;
+using Resgrid.Config;
using Resgrid.Framework;
using Resgrid.Model;
using Resgrid.Model.Repositories;
@@ -86,9 +87,13 @@ public async Task> GetFieldValuesByEntitiesAsync(int
var schema = _sqlConfiguration.SchemaName;
var table = _sqlConfiguration.UdfFieldValuesTableName;
- // Build an inline SQL statement that leverages Dapper's native IN-list expansion.
- // The @EntityIds parameter is expanded by Dapper into the correct number of bind variables.
- var sql = $"SELECT * FROM {schema}.{table} WHERE EntityType = @EntityType AND EntityId IN @EntityIds AND UdfDefinitionId = @UdfDefinitionId";
+ // Dapper only expands an IN-list into individual bind variables on providers without
+ // array support. Against Npgsql it binds the list as a single array parameter and
+ // leaves the SQL alone, so "IN @EntityIds" reaches the server as "IN $1" and fails
+ // to parse. Postgres takes the array directly via = ANY().
+ var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
+ ? $"SELECT * FROM {schema}.{table} WHERE EntityType = @EntityType AND EntityId = ANY(@EntityIds) AND UdfDefinitionId = @UdfDefinitionId"
+ : $"SELECT * FROM {schema}.{table} WHERE EntityType = @EntityType AND EntityId IN @EntityIds AND UdfDefinitionId = @UdfDefinitionId";
return await x.QueryAsync(sql: sql,
param: new { EntityType = entityType, EntityIds = idList, UdfDefinitionId = definitionId },
diff --git a/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs b/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs
new file mode 100644
index 000000000..4cb145dab
--- /dev/null
+++ b/Tests/Resgrid.Tests/Providers/CommunicationTestEmailTemplateTests.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Providers.EmailProvider;
+
+namespace Resgrid.Tests.Providers
+{
+ ///
+ /// A communication test proves a department can reach its people, and the proof is the recipient
+ /// clicking through. An email that arrives as a wall of plain text with a bare URL is a worse
+ /// proof than one that looks like every other Resgrid email and carries a button, so these pin
+ /// that the test email goes out through the shared HTML template with the confirm link wired to
+ /// a real anchor.
+ ///
+ [TestFixture]
+ public class CommunicationTestEmailTemplateTests
+ {
+ /// Every placeholder the template is allowed to contain, mirroring the model the provider builds.
+ private static readonly string[] ExpectedPlaceholders =
+ {
+ "preheader", "greeting", "intro", "disclaimer", "department_label", "department_name",
+ "test_label", "test_name", "action", "button_text", "confirm_url", "trouble_text",
+ "signoff", "team_name"
+ };
+
+ private static CommunicationTestEmailContent SampleContent()
+ {
+ // Distinct sentinels rather than realistic copy: a field the template forgets to place
+ // renders as nothing, and a sentinel makes that omission visible.
+ return new CommunicationTestEmailContent
+ {
+ Subject = "SUBJECT-SENTINEL",
+ Preheader = "PREHEADER-SENTINEL",
+ Greeting = "GREETING-SENTINEL",
+ Intro = "INTRO-SENTINEL",
+ Disclaimer = "DISCLAIMER-SENTINEL",
+ Action = "ACTION-SENTINEL",
+ ButtonText = "BUTTON-SENTINEL",
+ ConfirmUrl = "https://confirm/link",
+ TroubleText = "TROUBLE-SENTINEL",
+ Signoff = "SIGNOFF-SENTINEL",
+ TeamName = "TEAM-SENTINEL",
+ DepartmentLabel = "DEPARTMENTLABEL-SENTINEL",
+ DepartmentName = "DEPARTMENTNAME-SENTINEL",
+ TestLabel = "TESTLABEL-SENTINEL",
+ TestName = "TESTNAME-SENTINEL"
+ };
+ }
+
+ private static string ReadTemplate()
+ {
+ var assembly = typeof(PostmarkTemplateProvider).Assembly;
+ using (var resource = assembly.GetManifestResourceStream(assembly.GetName().Name + ".Template.CommunicationTest.html"))
+ {
+ resource.Should().NotBeNull("the communication test template should be embedded in the email provider assembly");
+
+ using (var reader = new StreamReader(resource))
+ return reader.ReadToEnd();
+ }
+ }
+
+ /// Sends through the provider with a fake sender and hands back the email it would have transmitted.
+ private static async Task Render(CommunicationTestEmailContent content)
+ {
+ Email sent = null;
+
+ var senderMock = new Mock();
+ senderMock
+ .Setup(x => x.Send(It.IsAny()))
+ .Callback(x => sent = x)
+ .ReturnsAsync(true);
+
+ var provider = new PostmarkTemplateProvider(senderMock.Object);
+
+ var result = await provider.SendCommunicationTestMail("member@example.com", content);
+
+ result.Should().BeTrue("the template should render and hand off to the sender");
+ sent.Should().NotBeNull();
+
+ return sent;
+ }
+
+ [Test]
+ public async Task a_null_content_should_be_a_failed_send_not_a_throw()
+ {
+ // The template model is assembled before the provider's try block, so a null content would
+ // escape the catch that records every other failure as a false.
+ var senderMock = new Mock();
+ var provider = new PostmarkTemplateProvider(senderMock.Object);
+
+ var result = await provider.SendCommunicationTestMail("member@example.com", null);
+
+ result.Should().BeFalse();
+ senderMock.Verify(x => x.Send(It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task the_email_should_be_sent_as_html_using_the_shared_resgrid_template()
+ {
+ var sent = await Render(SampleContent());
+
+ sent.To.Should().Contain("member@example.com");
+ sent.Subject.Should().Be("SUBJECT-SENTINEL");
+
+ // The masthead and footer are what make it read as a Resgrid email rather than a raw note.
+ sent.HtmlBody.Should().Contain("class=\"email-masthead_name\"");
+ sent.HtmlBody.Should().Contain("Resgrid, LLC. All rights reserved.");
+ }
+
+ [Test]
+ public async Task the_confirm_url_should_be_a_clickable_button_and_a_pasteable_fallback()
+ {
+ var sent = await Render(SampleContent());
+
+ sent.HtmlBody.Should().Contain("()
+ .Select(x => x.Groups[2].Value)
+ .Distinct()
+ .ToList();
+
+ placeholders.Should().BeEquivalentTo((IEnumerable)ExpectedPlaceholders);
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs
index 871c32d99..14eae5670 100644
--- a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs
@@ -34,6 +34,7 @@ public class with_the_chat_channel_service : TestBase
protected Mock _eventAggregatorMock;
protected Mock _cacheProviderMock;
protected Mock _unitOfWorkMock;
+ protected Mock _incidentCommandServiceMock;
protected with_the_chat_channel_service()
{
@@ -62,6 +63,7 @@ private void BuildService()
_eventAggregatorMock = new Mock();
_cacheProviderMock = new Mock();
_unitOfWorkMock = new Mock();
+ _incidentCommandServiceMock = new Mock();
// Inserts/updates echo back the entity they were handed (repository contract).
_chatChannelRepositoryMock.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()))
@@ -98,7 +100,8 @@ private void BuildService()
_callsServiceMock.Object,
_eventAggregatorMock.Object,
_cacheProviderMock.Object,
- _unitOfWorkMock.Object);
+ _unitOfWorkMock.Object,
+ _incidentCommandServiceMock.Object);
}
}
diff --git a/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs
new file mode 100644
index 000000000..da963940e
--- /dev/null
+++ b/Tests/Resgrid.Tests/Services/ChatCommanderLineTests.cs
@@ -0,0 +1,417 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Framework.Testing;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Model.Services;
+using Resgrid.Services;
+
+namespace Resgrid.Tests.Services
+{
+ namespace ChatCommanderLineTests
+ {
+ ///
+ /// The IncidentCommanderLine channel is addressed to the command ROLE rather than to a person, so
+ /// the behaviour worth pinning is what happens as command changes hands: the conversation and its
+ /// history stay on the incident, the incoming commander picks them up, and the outgoing one loses
+ /// them — all without touching a membership row.
+ ///
+ public class with_the_commander_line : TestBase
+ {
+ protected const int DepartmentId = 1;
+ protected const int CallId = 42;
+ protected const string CommandId = "command-1";
+
+ protected IChatChannelService _chatChannelService;
+ protected IChatPermissionService _chatPermissionService;
+
+ protected Mock _channelRepositoryMock;
+ protected Mock _memberRepositoryMock;
+ protected Mock _incidentCommandServiceMock;
+ protected Mock _unitsServiceMock;
+ protected Mock _userProfileServiceMock;
+ protected Mock _callsServiceMock;
+ protected Mock _dispatchAccessServiceMock;
+ protected Mock _authorizationServiceMock;
+
+ protected with_the_commander_line()
+ {
+ BuildServices();
+ }
+
+ protected override void Before_all_tests()
+ {
+ BuildServices();
+ }
+
+ private void BuildServices()
+ {
+ _channelRepositoryMock = new Mock();
+ _memberRepositoryMock = new Mock();
+ _incidentCommandServiceMock = new Mock();
+ _unitsServiceMock = new Mock();
+ _userProfileServiceMock = new Mock();
+ _callsServiceMock = new Mock();
+ _dispatchAccessServiceMock = new Mock();
+ _authorizationServiceMock = new Mock();
+
+ var cacheProviderMock = new Mock();
+ cacheProviderMock.Setup(x => x.GetStringAsync(It.IsAny())).ReturnsAsync((string)null);
+ cacheProviderMock.Setup(x => x.SetStringAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+
+ _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false);
+ _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false);
+ _dispatchAccessServiceMock.Setup(x => x.GetDispatchUserIdsAsync(It.IsAny())).ReturnsAsync(new List());
+
+ // The atomic channel+members insert echoes the channel back, and the member rows stay
+ // inspectable through the callback argument.
+ _channelRepositoryMock
+ .Setup(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()))
+ .ReturnsAsync((ChatChannel c, IEnumerable m, CancellationToken t) => c);
+
+ _callsServiceMock.Setup(x => x.GetCallByIdAsync(CallId, It.IsAny())).ReturnsAsync(new Call
+ {
+ CallId = CallId,
+ DepartmentId = DepartmentId,
+ Name = "Structure Fire"
+ });
+
+ _chatPermissionService = new ChatPermissionService(
+ _memberRepositoryMock.Object,
+ Mock.Of(),
+ _authorizationServiceMock.Object,
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ _unitsServiceMock.Object,
+ _callsServiceMock.Object,
+ _incidentCommandServiceMock.Object,
+ _dispatchAccessServiceMock.Object,
+ cacheProviderMock.Object);
+
+ _chatChannelService = new ChatChannelService(
+ _channelRepositoryMock.Object,
+ _memberRepositoryMock.Object,
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ _unitsServiceMock.Object,
+ _userProfileServiceMock.Object,
+ _callsServiceMock.Object,
+ Mock.Of(),
+ cacheProviderMock.Object,
+ Mock.Of(),
+ _incidentCommandServiceMock.Object);
+ }
+
+ protected void GivenCommanderIs(string userId)
+ {
+ _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(DepartmentId, CallId)).ReturnsAsync(new IncidentCommand
+ {
+ IncidentCommandId = CommandId,
+ DepartmentId = DepartmentId,
+ CallId = CallId,
+ Name = "Structure Fire",
+ CurrentCommanderUserId = userId,
+ EstablishedByUserId = TestData.Users.TestUser3Id,
+ Status = (int)IncidentCommandStatus.Active
+ });
+ }
+
+ protected void GivenClosedCommandWithCommander(string userId)
+ {
+ _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(DepartmentId, CallId)).ReturnsAsync(new IncidentCommand
+ {
+ IncidentCommandId = CommandId,
+ DepartmentId = DepartmentId,
+ CallId = CallId,
+ Name = "Structure Fire",
+ CurrentCommanderUserId = userId,
+ EstablishedByUserId = TestData.Users.TestUser3Id,
+ Status = (int)IncidentCommandStatus.Closed,
+ ClosedOn = DateTime.UtcNow
+ });
+ }
+
+ protected void GivenNoCommand()
+ {
+ _incidentCommandServiceMock.Setup(x => x.GetCommandForCallAsync(DepartmentId, CallId)).ReturnsAsync((IncidentCommand)null);
+ }
+
+ protected ChatChannel BuildCommanderLine()
+ => new ChatChannel
+ {
+ ChatChannelId = "commander-line-1",
+ DepartmentId = DepartmentId,
+ ChannelType = (int)ChatChannelType.IncidentCommanderLine,
+ CallId = CallId,
+ IncidentCommandId = CommandId,
+ DmKey = $"iccommander:{CallId}|u:{TestData.Users.TestUser1Id.ToLowerInvariant()}"
+ };
+ }
+
+ [TestFixture]
+ public class when_provisioning_a_commander_line : with_the_commander_line
+ {
+ [Test]
+ public async Task no_established_command_should_not_provision_a_line()
+ {
+ GivenNoCommand();
+
+ var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null);
+
+ result.Should().BeNull("there is no command role to address yet");
+ _channelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task a_command_with_no_current_commander_should_not_provision_a_line()
+ {
+ GivenCommanderIs(null);
+
+ var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null);
+
+ result.Should().BeNull("the seat is empty even though a command record exists");
+ }
+
+ [Test]
+ public async Task a_closed_command_should_not_provision_a_line()
+ {
+ // Closing a command does not clear CurrentCommanderUserId, so the "is the seat filled"
+ // check passes on a command nobody is running any more.
+ GivenClosedCommandWithCommander(TestData.Users.TestUser2Id);
+ _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync((ChatChannel)null);
+
+ var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null);
+
+ result.Should().BeNull("the incident is over even though the record still names a commander");
+ _channelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task a_closed_command_should_not_reopen_an_existing_line()
+ {
+ // The reuse path rebinds and unarchives, so a closed command reaching it would lift the
+ // archive freeze that closing the command put on the line.
+ GivenClosedCommandWithCommander(TestData.Users.TestUser2Id);
+
+ var existing = BuildCommanderLine();
+ existing.IsArchived = true;
+ _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync(existing);
+
+ var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null);
+
+ result.Should().BeNull();
+ existing.IsArchived.Should().BeTrue("the freeze the close applied has to survive");
+ _channelRepositoryMock.Verify(x => x.RebindToIncidentCommandAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task a_new_line_should_be_anchored_to_the_call_not_the_commander()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync((ChatChannel)null);
+
+ var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null);
+
+ result.Should().NotBeNull();
+ result.ChannelType.Should().Be((int)ChatChannelType.IncidentCommanderLine);
+ result.CallId.Should().Be(CallId);
+
+ // The key carries the call and the requester and NOT the commander — that is precisely what
+ // lets command change hands without forking the conversation.
+ result.DmKey.Should().Be($"iccommander:{CallId}|u:{TestData.Users.TestUser1Id.ToLowerInvariant()}");
+ result.DmKey.Should().NotContain(TestData.Users.TestUser2Id.ToLowerInvariant());
+ }
+
+ [Test]
+ public async Task only_the_requester_should_get_a_member_row()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync((ChatChannel)null);
+
+ List captured = null;
+ _channelRepositoryMock
+ .Setup(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()))
+ .ReturnsAsync((ChatChannel c, IEnumerable m, CancellationToken t) =>
+ {
+ captured = new List(m);
+ return c;
+ });
+
+ await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null);
+
+ captured.Should().HaveCount(1, "the commander side is implicit so the row cannot go stale on transfer");
+ captured[0].UserId.Should().Be(TestData.Users.TestUser1Id);
+ }
+
+ [Test]
+ public async Task a_unit_requester_should_get_a_unit_keyed_line()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = DepartmentId, Name = "Engine 6" });
+ _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, It.IsAny())).ReturnsAsync((ChatChannel)null);
+
+ var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, 7);
+
+ result.Should().NotBeNull();
+ result.DmKey.Should().Be($"iccommander:{CallId}|unit:7");
+ }
+
+ [Test]
+ public async Task a_unit_from_another_department_should_be_rejected()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ _unitsServiceMock.Setup(x => x.GetUnitByIdAsync(7)).ReturnsAsync(new Unit { UnitId = 7, DepartmentId = 99, Name = "Engine 6" });
+
+ var act = async () => await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, 7);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [Test]
+ public async Task an_existing_line_should_be_reused_rather_than_duplicated()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ var existing = BuildCommanderLine();
+ existing.Name = "Structure Fire Incident Commander";
+ _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, existing.DmKey)).ReturnsAsync(existing);
+
+ var result = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null);
+
+ result.Should().BeSameAs(existing);
+ _channelRepositoryMock.Verify(x => x.CreateDirectMessageChannelAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task the_same_line_should_be_reused_after_command_changes_hands()
+ {
+ var existing = BuildCommanderLine();
+ _channelRepositoryMock.Setup(x => x.GetByDmKeyAsync(DepartmentId, existing.DmKey)).ReturnsAsync(existing);
+
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ var before = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null);
+
+ GivenCommanderIs(TestData.Users.TestUser3Id);
+ var after = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, TestData.Users.TestUser1Id, null);
+
+ after.ChatChannelId.Should().Be(before.ChatChannelId, "the history has to follow the incident, not the outgoing commander");
+ }
+ }
+
+ [TestFixture]
+ public class when_resolving_commander_line_access : with_the_commander_line
+ {
+ [Test]
+ public async Task the_current_commander_should_have_access_without_a_member_row()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null);
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser2Id, null);
+
+ result.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task an_outgoing_commander_should_lose_access_on_the_next_check()
+ {
+ _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null);
+
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ var whileInCommand = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser2Id, null);
+
+ GivenCommanderIs(TestData.Users.TestUser3Id);
+ var afterHandover = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser2Id, null);
+
+ whileInCommand.Should().BeTrue();
+ afterHandover.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task the_requester_should_keep_access_across_a_handover()
+ {
+ GivenCommanderIs(TestData.Users.TestUser3Id);
+ _memberRepositoryMock
+ .Setup(x => x.GetUserMemberAsync("commander-line-1", TestData.Users.TestUser1Id))
+ .ReturnsAsync(new ChatChannelMember
+ {
+ ChatChannelId = "commander-line-1",
+ UserId = TestData.Users.TestUser1Id,
+ ParticipantType = (int)ChatParticipantType.User
+ });
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser1Id, null);
+
+ result.Should().BeTrue();
+ }
+
+ [Test]
+ public async Task an_uninvolved_user_should_be_denied()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null);
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser1Id, null);
+
+ result.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task a_dispatcher_should_not_get_in_on_dispatch_standing_alone()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null);
+ _dispatchAccessServiceMock.Setup(x => x.CanUseDispatchAsync(DepartmentId, TestData.Users.TestUser1Id)).ReturnsAsync(true);
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser1Id, null);
+
+ result.Should().BeFalse("this is a private line, not incident-wide dispatch traffic");
+ }
+
+ [Test]
+ public async Task a_department_admin_should_not_get_in_on_admin_standing_alone()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ _memberRepositoryMock.Setup(x => x.GetUserMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChatChannelMember)null);
+ _authorizationServiceMock.Setup(x => x.CanUserModifyDepartmentAsync(TestData.Users.TestUser1Id, DepartmentId)).ReturnsAsync(true);
+
+ var result = await _chatPermissionService.CanAccessChannelAsync(BuildCommanderLine(), TestData.Users.TestUser1Id, null);
+
+ result.Should().BeFalse();
+ }
+
+ [Test]
+ public async Task the_audience_should_be_the_requester_plus_the_current_commander_only()
+ {
+ GivenCommanderIs(TestData.Users.TestUser2Id);
+ _memberRepositoryMock.Setup(x => x.GetByChannelIdAsync("commander-line-1")).ReturnsAsync(new List
+ {
+ new ChatChannelMember
+ {
+ ChatChannelId = "commander-line-1",
+ UserId = TestData.Users.TestUser1Id,
+ ParticipantType = (int)ChatParticipantType.User
+ }
+ });
+
+ var audience = await _chatPermissionService.ResolveChannelAudienceUserIdsAsync(BuildCommanderLine());
+
+ audience.Should().BeEquivalentTo(new[] { TestData.Users.TestUser1Id, TestData.Users.TestUser2Id });
+
+ // EstablishedByUserId is TestUser3 — deliberately excluded. Only the seat, not the wider
+ // command staff, which is what separates this from the IncidentCommand channel.
+ audience.Should().NotContain(TestData.Users.TestUser3Id);
+ }
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs
index 99dc33f8b..e656d9c6e 100644
--- a/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs
+++ b/Tests/Resgrid.Tests/Services/ChatIncidentBackfillTests.cs
@@ -68,7 +68,8 @@ private ChatChannelService BuildService()
_callsService.Object,
Mock.Of(),
_cacheProvider.Object,
- Mock.Of());
+ Mock.Of(),
+ Mock.Of());
private static IncidentCommand BuildCommand(IncidentCommandStatus status = IncidentCommandStatus.Active)
=> new IncidentCommand
diff --git a/Tests/Resgrid.Tests/Services/QueueServiceTests.cs b/Tests/Resgrid.Tests/Services/QueueServiceTests.cs
index a2f62d847..2a2babc41 100644
--- a/Tests/Resgrid.Tests/Services/QueueServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/QueueServiceTests.cs
@@ -1,4 +1,6 @@
-using System;
+using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Moq;
@@ -42,5 +44,69 @@ await act.Should().ThrowAsync()
.WithMessage("Failed to enqueue call broadcast for processing.");
outboundQueueProvider.Verify(provider => provider.EnqueueCall(queueItem), Times.Once);
}
+
+ [Test]
+ public async Task EnqueueCallBroadcastAsync_StripsProfileImagesBeforePublishing()
+ {
+ // Arrange: avatar blobs on every profile are what pushed the serialized broadcast
+ // past RabbitMQ's 16MB frame limit and killed the dispatch outright.
+ var queueItem = new CallQueueItem
+ {
+ Call = new Call { Address = "123 Main Street" },
+ Profiles = new List
+ {
+ new UserProfile { UserId = "user-1", Image = new byte[] { 1, 2, 3 } },
+ new UserProfile { UserId = "user-2", Image = new byte[] { 4, 5, 6 } },
+ null
+ }
+ };
+ byte[][] imagesAsPublished = null;
+ var outboundQueueProvider = new Mock();
+ outboundQueueProvider
+ .Setup(provider => provider.EnqueueCall(queueItem))
+ .Callback(cqi =>
+ imagesAsPublished = cqi.Profiles.Where(x => x != null).Select(x => x.Image).ToArray())
+ .ReturnsAsync(true);
+ var service = new QueueService(
+ new Mock().Object,
+ outboundQueueProvider.Object,
+ new Mock().Object,
+ new Mock().Object,
+ new Mock().Object);
+
+ // Act
+ var result = await service.EnqueueCallBroadcastAsync(queueItem);
+
+ // Assert
+ result.Should().BeTrue();
+ imagesAsPublished.Should().OnlyContain(image => image == null);
+ }
+
+ [Test]
+ public async Task EnqueueCallBroadcastAsync_WithNullProfiles_DoesNotThrow()
+ {
+ // Arrange
+ var queueItem = new CallQueueItem
+ {
+ Call = new Call { Address = "123 Main Street" },
+ Profiles = null
+ };
+ var outboundQueueProvider = new Mock();
+ outboundQueueProvider
+ .Setup(provider => provider.EnqueueCall(queueItem))
+ .ReturnsAsync(true);
+ var service = new QueueService(
+ new Mock().Object,
+ outboundQueueProvider.Object,
+ new Mock().Object,
+ new Mock().Object,
+ new Mock().Object);
+
+ // Act
+ var result = await service.EnqueueCallBroadcastAsync(queueItem);
+
+ // Assert
+ result.Should().BeTrue();
+ }
}
}
diff --git a/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs b/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs
new file mode 100644
index 000000000..e519a0909
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Services/ChatControllerCommanderLineTests.cs
@@ -0,0 +1,164 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+using Resgrid.Web.Services.Controllers.v4;
+using Resgrid.Web.Services.Models.v4.Chat;
+using Resgrid.Web.ServicesCore.Helpers;
+
+namespace Resgrid.Tests.Web.Services
+{
+ ///
+ /// A commander line opened as a unit carries a single member row, and it is the unit's — the caller
+ /// has no user row on the channel. The read pointer the response reports has to come from that row,
+ /// or the client is told the whole conversation is unread every time it opens the line.
+ ///
+ [TestFixture]
+ [NonParallelizable]
+ public class ChatControllerCommanderLineTests
+ {
+ private const int DepartmentId = 10;
+ private const string UserId = "requester-user";
+ private const int UnitId = 7;
+ private const int CallId = 42;
+ private const string ChannelId = "commander-line-1";
+
+ private Mock _chatChannelService;
+ private Mock _chatPermissionService;
+ private ChatController _controller;
+ private Activity _activity;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _chatChannelService = new Mock();
+ _chatPermissionService = new Mock();
+
+ var featureToggleService = new Mock();
+ featureToggleService
+ .Setup(x => x.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId, It.IsAny(), It.IsAny>()))
+ .ReturnsAsync(true);
+
+ _chatPermissionService.Setup(x => x.CanSendAsUnitAsync(UserId, UnitId, DepartmentId)).ReturnsAsync(true);
+
+ _chatChannelService
+ .Setup(x => x.EnsureIncidentCommanderLineAsync(DepartmentId, CallId, UserId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new ChatChannel
+ {
+ ChatChannelId = ChannelId,
+ DepartmentId = DepartmentId,
+ ChannelType = (int)ChatChannelType.IncidentCommanderLine,
+ CallId = CallId,
+ LastMessageSeq = 12
+ });
+
+ // The unit's row is the only one on the channel; a lookup by user id finds nothing.
+ _chatChannelService
+ .Setup(x => x.GetUserMembershipAsync(ChannelId, UserId))
+ .ReturnsAsync((ChatChannelMember)null);
+
+ _chatChannelService
+ .Setup(x => x.GetUnitMembershipAsync(ChannelId, UnitId))
+ .ReturnsAsync(new ChatChannelMember
+ {
+ ChatChannelMemberId = "member-1",
+ ChatChannelId = ChannelId,
+ DepartmentId = DepartmentId,
+ ParticipantType = (int)ChatParticipantType.Unit,
+ UnitId = UnitId,
+ LastReadSeq = 9,
+ NotificationPreference = 2
+ });
+
+ var httpContext = new DefaultHttpContext
+ {
+ User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim(ClaimTypes.PrimarySid, UserId),
+ new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString())
+ }, "test"))
+ };
+ ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
+ _activity = new Activity("ChatControllerCommanderLineTests").Start();
+
+ _controller = new ChatController(
+ _chatChannelService.Object,
+ _chatPermissionService.Object,
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ featureToggleService.Object,
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of())
+ {
+ ControllerContext = new ControllerContext { HttpContext = httpContext }
+ };
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ ClaimsAuthorizationHelper._httpContextAccessor = null;
+ _activity?.Stop();
+ }
+
+ [Test]
+ public async Task CreateIncidentCommanderLine_AsUnit_ReportsTheUnitsReadState()
+ {
+ var response = await _controller.CreateIncidentCommanderLine(
+ new CreateIncidentCommanderLineInput { CallId = CallId, AsUnitId = UnitId }, CancellationToken.None);
+
+ response.Value.Should().NotBeNull();
+ response.Value.Data.MyLastReadSeq.Should().Be(9);
+ response.Value.Data.UnreadCount.Should().Be(3, "12 sent, 9 read");
+ response.Value.Data.NotificationPreference.Should().Be(2);
+
+ _chatChannelService.Verify(x => x.GetUnitMembershipAsync(ChannelId, UnitId), Times.Once);
+ _chatChannelService.Verify(x => x.GetUserMembershipAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task CreateIncidentCommanderLine_AsUser_StillUsesTheUserRow()
+ {
+ _chatChannelService
+ .Setup(x => x.GetUserMembershipAsync(ChannelId, UserId))
+ .ReturnsAsync(new ChatChannelMember
+ {
+ ChatChannelMemberId = "member-2",
+ ChatChannelId = ChannelId,
+ DepartmentId = DepartmentId,
+ ParticipantType = (int)ChatParticipantType.User,
+ UserId = UserId,
+ LastReadSeq = 5,
+ NotificationPreference = 1
+ });
+
+ var response = await _controller.CreateIncidentCommanderLine(
+ new CreateIncidentCommanderLineInput { CallId = CallId }, CancellationToken.None);
+
+ response.Value.Should().NotBeNull();
+ response.Value.Data.MyLastReadSeq.Should().Be(5);
+ response.Value.Data.UnreadCount.Should().Be(7, "12 sent, 5 read");
+ response.Value.Data.NotificationPreference.Should().Be(1);
+
+ _chatChannelService.Verify(x => x.GetUnitMembershipAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+ }
+}
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs
index 0d55f3000..5fe6eecc6 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs
@@ -270,6 +270,72 @@ public async Task> CreateDirectMessage([F
return result;
}
+ ///
+ /// Finds or creates the caller's private line to the incident's current Incident Commander.
+ /// Addressed to the command role rather than to a person, so the conversation and its history
+ /// follow command transfers. Returns Failure when the call has no established command with a
+ /// current commander — clients keep the "Message the IC" action disabled until one exists.
+ ///
+ /// The call to reach command on, and optionally the unit to speak as
+ /// ChatChannelCreatedResult with the existing or newly created commander line
+ [HttpPost("CreateIncidentCommanderLine")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> CreateIncidentCommanderLine([FromBody] CreateIncidentCommanderLineInput input, CancellationToken cancellationToken)
+ {
+ if (!await ChatEnabledAsync())
+ return NotFound();
+
+ if (!ModelState.IsValid)
+ return BadRequest();
+
+ if (input == null || input.CallId <= 0)
+ return BadRequest();
+
+ // Speaking as a unit has to be earned, not asserted — otherwise a caller could open (and post
+ // into) a commander line in another unit's name.
+ if (input.AsUnitId.HasValue && !await _chatPermissionService.CanSendAsUnitAsync(UserId, input.AsUnitId.Value, DepartmentId))
+ return StatusCode(StatusCodes.Status403Forbidden);
+
+ var result = new ChatChannelCreatedResult();
+ ChatChannel channel;
+
+ try
+ {
+ channel = await _chatChannelService.EnsureIncidentCommanderLineAsync(DepartmentId, input.CallId, UserId, input.AsUnitId, cancellationToken);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return StatusCode(StatusCodes.Status403Forbidden);
+ }
+
+ if (channel != null)
+ {
+ // A line opened as a unit gets one member row, and it is the unit's -- there is no user row
+ // to find. Looking the caller up by user id returns null, which silently reports the whole
+ // channel as unread with default notification settings every time the line is opened.
+ var member = input.AsUnitId.HasValue
+ ? await _chatChannelService.GetUnitMembershipAsync(channel.ChatChannelId, input.AsUnitId.Value)
+ : await _chatChannelService.GetUserMembershipAsync(channel.ChatChannelId, UserId);
+
+ result.Data = ConvertChannelResultData(channel, member);
+ result.PageSize = 1;
+ result.Status = ResponseHelper.Created;
+ }
+ else
+ {
+ // No command established yet (or none with a current commander). Not an error: the incident
+ // simply has nobody to address.
+ result.PageSize = 0;
+ result.Status = ResponseHelper.Failure;
+ }
+
+ ResponseHelper.PopulateV4ResponseData(result);
+ return result;
+ }
+
///
/// Creates an ad-hoc group channel with an explicit member list.
///
diff --git a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs
index 421ea5a8a..fc3402986 100644
--- a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs
+++ b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs
@@ -349,7 +349,7 @@ public class ChatChannelResultData
public string ChatChannelId { get; set; }
///
- /// Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch)
+ /// Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch, 12 = IncidentCommanderLine)
///
public int ChannelType { get; set; }
@@ -1023,6 +1023,22 @@ public class CreateDirectMessageInput
public int? TargetUnitId { get; set; }
}
+///
+/// Input to open the caller's private line to an incident's current commander ("Message the IC")
+///
+public class CreateIncidentCommanderLineInput
+{
+ ///
+ /// The call whose current Incident Commander should be messaged
+ ///
+ public int CallId { get; set; }
+
+ ///
+ /// Open the line as this unit rather than as the calling user (the caller must crew the unit)
+ ///
+ public int? AsUnitId { get; set; }
+}
+
///
/// Input to create an ad-hoc group channel
///
diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
index d58d201bf..60fbefcce 100644
--- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
+++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
@@ -533,6 +533,16 @@
Target user or unit for the direct message
ChatChannelCreatedResult with the existing or newly created channel
+
+
+ Finds or creates the caller's private line to the incident's current Incident Commander.
+ Addressed to the command role rather than to a person, so the conversation and its history
+ follow command transfers. Returns Failure when the call has no established command with a
+ current commander — clients keep the "Message the IC" action disabled until one exists.
+
+ The call to reach command on, and optionally the unit to speak as
+ ChatChannelCreatedResult with the existing or newly created commander line
+
Creates an ad-hoc group channel with an explicit member list.
@@ -7518,7 +7528,7 @@
- Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch)
+ Channel type (0 = DirectMessage, 1 = AdHocGroup, 2 = DepartmentDefault, 3 = GroupDefault, 4 = CustomLocked, 5 = Incident, 6 = IncidentLane, 7 = IncidentCommand, 8 = Chatbot, 9 = IncidentLeads, 10 = IncidentDispatch, 11 = UnitDispatch, 12 = IncidentCommanderLine)
@@ -8171,6 +8181,21 @@
Target unit for the DM (mutually exclusive with TargetUserId)