diff --git a/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs b/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs index 5cdcbbac..63ff9e6d 100644 --- a/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs +++ b/Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs @@ -80,8 +80,6 @@ public async Task GenerateCall(CallEmail email, string managingUser, List< } StringBuilder title = new StringBuilder(); - title.Append("Email Call "); - var priorityName = GetCallPriorityName(c.Priority, activePriorities); if (!String.IsNullOrEmpty(priorityName)) diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs index e75aa082..cb8804b9 100644 --- a/Core/Resgrid.Services/PushService.cs +++ b/Core/Resgrid.Services/PushService.cs @@ -48,8 +48,10 @@ public async Task Register(PushUri pushUri) // IC app registrations target the IC-specific Novu subscriber, keeping its inbox/push separate from the Responder app. var isICApp = string.Equals(pushUri.Source, "IC", StringComparison.OrdinalIgnoreCase); - if (isICApp) - await EnsureICUserSubscriber(pushUri, code); + // The credential write below lands on nothing unless the subscriber already exists. The + // Responder path used to lean on the security-rights endpoint creating it on every app + // launch; registration is the only place that actually needs it, so both apps ensure here. + await EnsureUserSubscriber(pushUri, code, isICApp); bool registered; @@ -87,13 +89,23 @@ public async Task UnRegister(PushUri pushUri) return true; } - private async Task EnsureICUserSubscriber(PushUri pushUri, string code) + /// + /// Best-effort by design, unlike the unit path's hard stop: an already-present subscriber is the + /// common case (Novu answers it with a conflict the provider reports as success), and a failed + /// create surfaces immediately after as a logged credential-write rejection. + /// + private async Task EnsureUserSubscriber(PushUri pushUri, string code, bool isICApp) { try { var profile = await _userProfileService.GetProfileByUserIdAsync(pushUri.UserId); - await _novuProvider.CreateICUserSubscriber(pushUri.UserId, code, pushUri.DepartmentId, - profile?.MembershipEmail, profile?.FirstName, profile?.LastName); + + if (isICApp) + await _novuProvider.CreateICUserSubscriber(pushUri.UserId, code, pushUri.DepartmentId, + profile?.MembershipEmail, profile?.FirstName, profile?.LastName); + else + await _novuProvider.CreateUserSubscriber(pushUri.UserId, code, pushUri.DepartmentId, + profile?.MembershipEmail, profile?.FirstName, profile?.LastName); } catch (Exception ex) { diff --git a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs index 94219b02..4c03e3f0 100644 --- a/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs +++ b/Providers/Resgrid.Providers.Email/PostmarkTemplateProvider.cs @@ -416,7 +416,7 @@ public async Task SendPaymentReciept(string departmentName, string name, s newEmail.HtmlBody = content; newEmail.Sender = FROM_EMAIL; newEmail.From = FROM_EMAIL; - newEmail.Subject = $"Resgrid Password Reset"; + newEmail.Subject = "Resgrid Receipt"; newEmail.To.Add(email); return await _emailSender.Send(newEmail); diff --git a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs index 9f56c846..799afa0f 100644 --- a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs +++ b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs @@ -86,6 +86,14 @@ private static string DescribeErrorBody(string body) } } + var errors = parsed.SelectToken("errors"); + if (errors != null) + { + var described = DescribeValidationErrors(errors); + if (!string.IsNullOrWhiteSpace(described)) + parts.Add($"errors={described}"); + } + return string.Join(" ", parts); } catch (JsonException) @@ -94,6 +102,75 @@ private static string DescribeErrorBody(string body) } } + /// + /// Both validation-error shapes Novu emits are handled: class-validator entries + /// ({ property, constraints }) and zod issues ({ path, message }), plus the v2 keyed form + /// ({ field: { messages: [...] } }). Only field names and rule messages are read out -- + /// the rejected values themselves are never pulled into the log. + /// + private static string DescribeValidationErrors(JToken errors) + { + var items = new List(); + + if (errors.Type == JTokenType.Object) + { + foreach (var prop in ((JObject)errors).Properties()) + { + var messages = ExtractErrorMessages(prop.Value); + items.Add(messages.Count > 0 ? $"{prop.Name}: {string.Join("; ", messages)}" : prop.Name); + } + } + else if (errors.Type == JTokenType.Array) + { + foreach (var err in errors.Children()) + { + if (err.Type != JTokenType.Object) + continue; + + var label = err.SelectToken("property")?.ToString(); + if (label == null) + { + var path = err.SelectToken("path"); + if (path != null) + label = path.Type == JTokenType.Array + ? string.Join(".", path.Children().Select(p => p.ToString())) + : path.ToString(); + } + + var messages = new List(); + var message = err.SelectToken("message"); + if (message != null && message.Type == JTokenType.String) + messages.Add(message.ToString()); + + if (err.SelectToken("constraints") is JObject constraints) + messages.AddRange(constraints.Properties().Select(p => p.Value.ToString())); + + if (label == null && messages.Count == 0) + continue; + + items.Add(messages.Count > 0 ? $"{label ?? "?"}: {string.Join("; ", messages)}" : label); + } + } + + return string.Join(" | ", items); + } + + private static List ExtractErrorMessages(JToken value) + { + var result = new List(); + + if (value.Type == JTokenType.String) + result.Add(value.ToString()); + else if (value.Type == JTokenType.Array) + result.AddRange(value.Children().Where(x => x.Type == JTokenType.String).Select(x => x.ToString())); + else if (value.Type == JTokenType.Object && value.SelectToken("messages") is JToken messages && messages.Type == JTokenType.Array) + result.AddRange(messages.Children().Where(x => x.Type == JTokenType.String).Select(x => x.ToString())); + + return result; + } + + private static string NullIfWhiteSpace(string value) => string.IsNullOrWhiteSpace(value) ? null : value; + private async Task CreateSubscriber(string id, int departmentId, string email, string firstName, string lastName, List data) { try @@ -104,16 +181,16 @@ private async Task CreateSubscriber(string id, int departmentId, string em httpClient.DefaultRequestHeaders.Add("idempotency-key", Guid.NewGuid().ToString()); httpClient.DefaultRequestHeaders.Add("Authorization", $"ApiKey {ChatConfig.NovuSecretKey}"); + // The v2 endpoint validates optional fields (@IsEmail, @IsTimeZone, @IsLocale) whenever + // they are present -- @IsOptional only skips null/undefined, so an empty string is a 422, + // not an omission. Empty values must therefore be null (dropped by NullValueHandling.Ignore) + // and the always-empty phone/avatar/timezone/locale keys are not sent at all. var payload = new { subscriberId = id, - firstName = firstName, - lastName = lastName, - email = email, - phone = "", - avatar = "", - timezone = "", - locale = "", + firstName = NullIfWhiteSpace(firstName), + lastName = NullIfWhiteSpace(lastName), + email = NullIfWhiteSpace(email), data = new Dictionary() }; diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs index b2d5b266..0bdd64fb 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs @@ -53,6 +53,9 @@ DELETE FROM [dbo].[LogAttachments] WHERE LogId IN (SELECT LogId FROM [dbo].[Logs DELETE FROM [dbo].[LogUnits] WHERE LogId IN (SELECT LogId FROM [dbo].[Logs] WHERE DepartmentId = @DepartmentId) DELETE FROM [dbo].[LogUsers] WHERE LogId IN (SELECT LogId FROM [dbo].[Logs] WHERE DepartmentId = @DepartmentId) + -- PushUris has no DepartmentId column; delete by membership while DepartmentMembers rows still exist + DELETE FROM [dbo].[PushUris] WHERE UserId IN (SELECT UserId FROM [dbo].[DepartmentMembers] WHERE DepartmentId = @DepartmentId) + OPEN db_cursor FETCH NEXT FROM db_cursor INTO @UserId @@ -183,8 +186,8 @@ DELETE FROM [dbo].[Pois] WHERE PoiTypeId IN (SELECT PoiTypeId FROM [dbo].[POITyp DELETE FROM [dbo].[POITypes] WHERE DepartmentId = @DepartmentId -- Resource orders (ResourceOrders row deleted further down) - DELETE FROM [dbo].[ResourceOrderFillUnits] WHERE ResourceOrderFillId IN (SELECT ResourceOrderFillId FROM [dbo].[ResourceOrderFills] WHERE DepartmentId = @DepartmentId OR ResourceOrderId IN (SELECT ResourceOrderId FROM [dbo].[ResourceOrders] WHERE DepartmentId = @DepartmentId)) - DELETE FROM [dbo].[ResourceOrderFills] WHERE DepartmentId = @DepartmentId OR ResourceOrderId IN (SELECT ResourceOrderId FROM [dbo].[ResourceOrders] WHERE DepartmentId = @DepartmentId) + DELETE FROM [dbo].[ResourceOrderFillUnits] WHERE ResourceOrderFillId IN (SELECT ResourceOrderFillId FROM [dbo].[ResourceOrderFills] WHERE DepartmentId = @DepartmentId OR ResourceOrderItemId IN (SELECT ResourceOrderItemId FROM [dbo].[ResourceOrderItems] WHERE ResourceOrderId IN (SELECT ResourceOrderId FROM [dbo].[ResourceOrders] WHERE DepartmentId = @DepartmentId))) + DELETE FROM [dbo].[ResourceOrderFills] WHERE DepartmentId = @DepartmentId OR ResourceOrderItemId IN (SELECT ResourceOrderItemId FROM [dbo].[ResourceOrderItems] WHERE ResourceOrderId IN (SELECT ResourceOrderId FROM [dbo].[ResourceOrders] WHERE DepartmentId = @DepartmentId)) DELETE FROM [dbo].[ResourceOrderItems] WHERE ResourceOrderId IN (SELECT ResourceOrderId FROM [dbo].[ResourceOrders] WHERE DepartmentId = @DepartmentId) DELETE FROM [dbo].[ResourceOrderSettings] WHERE DepartmentId = @DepartmentId @@ -240,7 +243,6 @@ DELETE FROM [dbo].[UdfFields] WHERE UdfDefinitionId IN (SELECT UdfDefinitionId F DELETE FROM [dbo].[UserStates] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[PersonnelCertifications] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[PersonnelRoleUsers] WHERE DepartmentId = @DepartmentId - DELETE FROM [dbo].[PushUris] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[Invites] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[Payments] WHERE DepartmentId = @DepartmentId diff --git a/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs b/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs index 5817b716..8727c2e7 100644 --- a/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs +++ b/Tests/Resgrid.Tests/Services/CallEmailFactoryTests.cs @@ -609,7 +609,7 @@ public async Task should_import_every_documented_value() call.MapPage.Should().Be("12B"); call.NatureOfCall.Should().Be("55 Y/O Male, Chest Pain"); call.Notes.Should().Be("Caller is on scene"); - call.Name.Should().Be("Email Call Emergency MEDICAL 2020-1234 "); + call.Name.Should().Be("Emergency MEDICAL 2020-1234 "); call.Dispatches.Count.Should().Be(_dispatchUsers.Count); } @@ -643,7 +643,7 @@ public async Task should_use_the_department_default_when_the_priority_is_empty() call.Should().NotBeNull(); call.Priority.Should().Be((int)CallPriority.Medium); - call.Name.Should().Be("Email Call Medium MEDICAL 2020-1234 "); + call.Name.Should().Be("Medium MEDICAL 2020-1234 "); } [Test] @@ -656,7 +656,7 @@ public async Task should_match_a_custom_call_priority_by_name() call.Should().NotBeNull(); call.Priority.Should().Be(502); - call.Name.Should().Be("Email Call Structure Fire MEDICAL 2020-1234 "); + call.Name.Should().Be("Structure Fire MEDICAL 2020-1234 "); } [Test] diff --git a/Tests/Resgrid.Tests/Services/PushServiceUserRegistrationTests.cs b/Tests/Resgrid.Tests/Services/PushServiceUserRegistrationTests.cs new file mode 100644 index 00000000..a9d59bf4 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/PushServiceUserRegistrationTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class PushServiceUserRegistrationTests + { + private const string UserId = "user-1"; + private const int DepartmentId = 7; + private const string Code = "DEPT"; + private const string DeviceId = "device-token"; + private const string Email = "user@example.com"; + private const string FirstName = "First"; + private const string LastName = "Last"; + + private Mock _novuProvider; + private Mock _userProfileService; + private PushService _pushService; + + [SetUp] + public void SetUp() + { + _novuProvider = new Mock(); + _userProfileService = new Mock(); + _userProfileService.Setup(x => x.GetProfileByUserIdAsync(UserId, It.IsAny())).ReturnsAsync(new UserProfile + { + UserId = UserId, + FirstName = FirstName, + LastName = LastName, + MembershipEmail = Email + }); + + _pushService = new PushService( + Mock.Of(), + Mock.Of(), + _userProfileService.Object, + Mock.Of(), + _novuProvider.Object, + Mock.Of(), + Mock.Of()); + } + + [Test] + public async Task Register_responder_should_create_subscriber_before_credential_write() + { + _novuProvider.Setup(x => x.UpdateUserSubscriberFcm(UserId, Code, DeviceId)).ReturnsAsync(true); + + var result = await _pushService.Register(CreatePushUri(source: null)); + + result.Should().BeTrue(); + _novuProvider.Verify(x => x.CreateUserSubscriber(UserId, Code, DepartmentId, Email, FirstName, LastName), Times.Once); + _novuProvider.Verify(x => x.CreateICUserSubscriber(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _novuProvider.Verify(x => x.UpdateUserSubscriberFcm(UserId, Code, DeviceId), Times.Once); + } + + [Test] + public async Task Register_ic_should_create_ic_subscriber_before_credential_write() + { + _novuProvider.Setup(x => x.UpdateICUserSubscriberFcm(UserId, Code, DeviceId)).ReturnsAsync(true); + + var result = await _pushService.Register(CreatePushUri(source: "IC")); + + result.Should().BeTrue(); + _novuProvider.Verify(x => x.CreateICUserSubscriber(UserId, Code, DepartmentId, Email, FirstName, LastName), Times.Once); + _novuProvider.Verify(x => x.CreateUserSubscriber(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _novuProvider.Verify(x => x.UpdateICUserSubscriberFcm(UserId, Code, DeviceId), Times.Once); + } + + [Test] + public async Task Register_should_still_write_credentials_when_subscriber_create_fails() + { + _novuProvider.Setup(x => x.CreateUserSubscriber(UserId, Code, DepartmentId, Email, FirstName, LastName)) + .ReturnsAsync(false); + _novuProvider.Setup(x => x.UpdateUserSubscriberFcm(UserId, Code, DeviceId)).ReturnsAsync(true); + + var result = await _pushService.Register(CreatePushUri(source: null)); + + result.Should().BeTrue(); + _novuProvider.Verify(x => x.UpdateUserSubscriberFcm(UserId, Code, DeviceId), Times.Once); + } + + [Test] + public async Task Register_should_still_write_credentials_when_subscriber_create_throws() + { + _novuProvider.Setup(x => x.CreateUserSubscriber(UserId, Code, DepartmentId, Email, FirstName, LastName)) + .ThrowsAsync(new InvalidOperationException("Novu unavailable")); + _novuProvider.Setup(x => x.UpdateUserSubscriberFcm(UserId, Code, DeviceId)).ReturnsAsync(true); + + var result = await _pushService.Register(CreatePushUri(source: null)); + + result.Should().BeTrue(); + _novuProvider.Verify(x => x.UpdateUserSubscriberFcm(UserId, Code, DeviceId), Times.Once); + } + + private static PushUri CreatePushUri(string source) + { + return new PushUri + { + UserId = UserId, + DepartmentId = DepartmentId, + PlatformType = (int)Platforms.Android, + PushLocation = Code, + DeviceId = DeviceId, + Source = source + }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SecurityController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SecurityController.cs index e18851bf..fcab4ff2 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/SecurityController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/SecurityController.cs @@ -6,7 +6,6 @@ using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Security; using Resgrid.Model; -using Resgrid.Model.Providers; namespace Resgrid.Web.Services.Controllers.v4 { @@ -24,21 +23,18 @@ public class SecurityController : V4AuthenticatedApiControllerbase private readonly IPermissionsService _permissionsService; private readonly IPersonnelRolesService _personnelRolesService; private readonly IUserProfileService _userProfileService; - private readonly INovuProvider _novuProvider; /// /// Operations to perform against the security sub-system /// public SecurityController(IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, - IPermissionsService permissionsService, IPersonnelRolesService personnelRolesService, IUserProfileService userProfileService, - INovuProvider novuProvider) + IPermissionsService permissionsService, IPersonnelRolesService personnelRolesService, IUserProfileService userProfileService) { _departmentsService = departmentsService; _departmentGroupsService = departmentGroupsService; _permissionsService = permissionsService; _personnelRolesService = personnelRolesService; _userProfileService = userProfileService; - _novuProvider = novuProvider; } #endregion Members and Constructors @@ -110,8 +106,6 @@ public async Task> GetCurrentUsersRights() result.Data.CanLoginToDispatchApp = _permissionsService.IsUserAllowed(dispatchAppLoginPermission, result.Data.IsAdmin, isGroupAdmin, roles); result.Data.CanLoginToCommandApp = _permissionsService.IsUserAllowed(commandAppLoginPermission, result.Data.IsAdmin, isGroupAdmin, roles); - var novuSuccess = await _novuProvider.CreateUserSubscriber(UserId, department.Code, DepartmentId, profile.MembershipEmail, profile.FirstName, profile.LastName); - result.PageSize = 1; result.Status = ResponseHelper.Success; ResponseHelper.PopulateV4ResponseData(result); diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 9a2e1dd0..3acf4eb5 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -2560,7 +2560,7 @@ Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department - + Operations to perform against the security sub-system