diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx index 23920828f..3230aacd5 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx @@ -68,6 +68,8 @@ مستخدم عادي البريد الإلكتروني خيارات الإشعارات + تمكين أصوات التطبيق الحديثة + استخدم أصوات الإشعارات الحديثة بدلاً من الأصوات القديمة. إذا قام قسمك بتمكين الأصوات الحديثة، فسيتم استخدامها بغض النظر عن هذا الإعداد. خيارات الإشعارات الأخرى إشعار فوري العنوان الفعلي @@ -125,4 +127,4 @@ رمز PIN من 4 أرقام - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx index 0e659186e..f3daf7c67 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx @@ -254,6 +254,12 @@ Notification Options + + Enable Modern Application Sounds + + + Use modern notification sounds instead of the legacy sounds. If your department enables modern sounds, they are used regardless of this setting. + Other Notification Options @@ -383,4 +389,4 @@ 4-stellige PIN - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.en.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.en.resx index f80087aa2..bc3df66f8 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.en.resx @@ -306,6 +306,12 @@ Notification Options + + Enable Modern Application Sounds + + + Use modern notification sounds instead of the legacy sounds. If your department enables modern sounds, they are used regardless of this setting. + Other Notification Options @@ -438,4 +444,4 @@ 4-digit PIN - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx index bdcf59b1d..3c4923b9b 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx @@ -300,6 +300,12 @@ Opciones de notificación + + Activar sonidos modernos de la aplicación + + + Usa sonidos de notificación modernos en lugar de los sonidos clásicos. Si tu departamento activa los sonidos modernos, se utilizarán independientemente de esta opción. + Otras opciones de notificación @@ -429,4 +435,4 @@ PIN de 4 dígitos - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx index 769d01aae..ce77a177d 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx @@ -254,6 +254,12 @@ Notification Options + + Enable Modern Application Sounds + + + Use modern notification sounds instead of the legacy sounds. If your department enables modern sounds, they are used regardless of this setting. + Other Notification Options @@ -383,4 +389,4 @@ PIN à 4 chiffres - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx index 001a097f2..8cae8ccb2 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx @@ -254,6 +254,12 @@ Notification Options + + Enable Modern Application Sounds + + + Use modern notification sounds instead of the legacy sounds. If your department enables modern sounds, they are used regardless of this setting. + Other Notification Options @@ -383,4 +389,4 @@ PIN a 4 cifre - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx index 7d41f6cca..1ac1d80e1 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx @@ -254,6 +254,12 @@ Notification Options + + Enable Modern Application Sounds + + + Use modern notification sounds instead of the legacy sounds. If your department enables modern sounds, they are used regardless of this setting. + Other Notification Options @@ -383,4 +389,4 @@ 4-cyfrowy PIN - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx index 58d055450..5eef10d52 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx @@ -254,6 +254,12 @@ Notification Options + + Enable Modern Application Sounds + + + Use modern notification sounds instead of the legacy sounds. If your department enables modern sounds, they are used regardless of this setting. + Other Notification Options @@ -383,4 +389,4 @@ 4-siffrig PIN - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx index d6c59aef9..620be50f6 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx @@ -254,6 +254,12 @@ Notification Options + + Enable Modern Application Sounds + + + Use modern notification sounds instead of the legacy sounds. If your department enables modern sounds, they are used regardless of this setting. + Other Notification Options @@ -383,4 +389,4 @@ 4-значний PIN - \ No newline at end of file + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.ar.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.ar.resx index 9005dae7c..f75c8cebd 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.ar.resx @@ -228,4 +228,13 @@ عرض الرسالة + + الرد على دعوة التقويم + + + سأحضر + + + لن أحضر + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.de.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.de.resx index baf5e3d6e..197951d6c 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.de.resx @@ -179,4 +179,13 @@ View Message + + Kalender-RSVP + + + Ich nehme teil + + + Ich nehme nicht teil + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.en.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.en.resx index be25667c1..05b75451a 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.en.resx @@ -228,4 +228,13 @@ View Message - \ No newline at end of file + + Calendar RSVP + + + Attending + + + Not attending + + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.es.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.es.resx index 742323724..b2881c102 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.es.resx @@ -228,4 +228,13 @@ Ver mensaje - \ No newline at end of file + + Confirmación del calendario + + + Asistiré + + + No asistiré + + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.fr.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.fr.resx index 55706e468..1238afbc5 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.fr.resx @@ -179,4 +179,13 @@ View Message + + Réponse au calendrier + + + Présent + + + Absent + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.it.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.it.resx index 3c77d3336..20611ec2e 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.it.resx @@ -179,4 +179,13 @@ View Message + + RSVP calendario + + + Parteciperò + + + Non parteciperò + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.pl.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.pl.resx index 66e99d323..e6f0e8aab 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.pl.resx @@ -179,4 +179,13 @@ View Message + + Potwierdzenie kalendarza + + + Będę uczestniczyć + + + Nie będę uczestniczyć + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.sv.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.sv.resx index 8ccc8b9bb..441d317d1 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.sv.resx @@ -179,4 +179,13 @@ View Message + + Kalender-O.S.A. + + + Deltar + + + Deltar inte + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.uk.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.uk.resx index 9e18610eb..657a74611 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.uk.resx @@ -179,4 +179,13 @@ View Message + + Відповідь на подію + + + Відвідаю + + + Не відвідаю + diff --git a/Core/Resgrid.Model/ModernApplicationSoundSettings.cs b/Core/Resgrid.Model/ModernApplicationSoundSettings.cs new file mode 100644 index 000000000..e19e6e136 --- /dev/null +++ b/Core/Resgrid.Model/ModernApplicationSoundSettings.cs @@ -0,0 +1,14 @@ +namespace Resgrid.Model +{ + /// + /// Resolves the effective modern application sound setting shared by notification delivery + /// and API clients. + /// + public static class ModernApplicationSoundSettings + { + public static bool IsEnabled(bool departmentEnabled, bool userEnabled) + { + return departmentEnabled || userEnabled; + } + } +} diff --git a/Core/Resgrid.Model/Services/ICalendarService.cs b/Core/Resgrid.Model/Services/ICalendarService.cs index 140379153..8f03ac18a 100644 --- a/Core/Resgrid.Model/Services/ICalendarService.cs +++ b/Core/Resgrid.Model/Services/ICalendarService.cs @@ -64,6 +64,10 @@ Task DeleteCalendarItemAndRecurrences(int calendarItemId, Task SignupForEvent(int calendarEventItemId, string userId, string note, int attendeeType, CancellationToken cancellationToken = default(CancellationToken)); + Task SignupForEventAndUpdateMessageRecipientAsync(int calendarEventItemId, string userId, + string response, int attendeeType, MessageRecipient messageRecipient, + CancellationToken cancellationToken = default(CancellationToken)); + Task GetCalendarItemAttendeeByUserAsync(int calendarEventItemId, string userId); Task> GetCalendarItemsToNotifyAsync(DateTime timestamp); diff --git a/Core/Resgrid.Model/UserProfile.cs b/Core/Resgrid.Model/UserProfile.cs index 0866b3aaf..c3f100026 100644 --- a/Core/Resgrid.Model/UserProfile.cs +++ b/Core/Resgrid.Model/UserProfile.cs @@ -198,6 +198,13 @@ public class UserProfile: IEntity [ProtoMember(52)] public bool SecurityPinEnabled { get; set; } + /// + /// The user opted in to modern application notification sounds. The department-level + /// EnableModernNotifications setting overrides this to on for every user. + /// + [ProtoMember(53)] + public bool EnableModernApplicationSounds { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Services/CalendarService.cs b/Core/Resgrid.Services/CalendarService.cs index ae9569602..e87e3e26c 100644 --- a/Core/Resgrid.Services/CalendarService.cs +++ b/Core/Resgrid.Services/CalendarService.cs @@ -2,6 +2,7 @@ using Resgrid.Model; using Resgrid.Model.Helpers; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; using System; using System.Collections.Generic; @@ -26,11 +27,14 @@ public class CalendarService : ICalendarService private readonly IEncryptionService _encryptionService; private readonly ICalendarItemCheckInRepository _calendarItemCheckInRepository; private readonly ITextResponsePromptService _textResponsePromptService; + private readonly IMessageRecipientRepository _messageRecipientRepository; + private readonly IUnitOfWork _unitOfWork; public CalendarService(ICalendarItemsRepository calendarItemRepository, ICalendarItemTypeRepository calendarItemTypeRepository, ICalendarItemAttendeeRepository calendarItemAttendeeRepository, IDepartmentsService departmentsService, ICommunicationService communicationService, IUserProfileService userProfileService, IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, IEncryptionService encryptionService, ICalendarItemCheckInRepository calendarItemCheckInRepository, + IMessageRecipientRepository messageRecipientRepository, IUnitOfWork unitOfWork, ITextResponsePromptService textResponsePromptService = null) { _calendarItemRepository = calendarItemRepository; @@ -43,6 +47,8 @@ public CalendarService(ICalendarItemsRepository calendarItemRepository, ICalenda _departmentSettingsService = departmentSettingsService; _encryptionService = encryptionService; _calendarItemCheckInRepository = calendarItemCheckInRepository; + _messageRecipientRepository = messageRecipientRepository; + _unitOfWork = unitOfWork; _textResponsePromptService = textResponsePromptService; } @@ -479,6 +485,33 @@ public async Task> GetCalendarItemsForDepartmentInRangeAsync( return await _calendarItemAttendeeRepository.SaveOrUpdateAsync(attendee, cancellationToken); } + public async Task SignupForEventAndUpdateMessageRecipientAsync(int calendarEventItemId, + string userId, string response, int attendeeType, MessageRecipient messageRecipient, + CancellationToken cancellationToken = default(CancellationToken)) + { + if (messageRecipient == null) + throw new ArgumentNullException(nameof(messageRecipient)); + + _unitOfWork.CreateOrGetConnection(); + + try + { + var attendee = await SignupForEvent(calendarEventItemId, userId, response, attendeeType, cancellationToken); + + messageRecipient.Response = response; + messageRecipient.ReadOn = DateTime.UtcNow; + await _messageRecipientRepository.SaveOrUpdateAsync(messageRecipient, cancellationToken); + + _unitOfWork.CommitChanges(); + return attendee; + } + catch + { + _unitOfWork.DiscardChanges(); + throw; + } + } + public async Task GetCalendarItemAttendeeByUserAsync(int calendarEventItemId, string userId) { return await _calendarItemAttendeeRepository.GetCalendarItemAttendeeByUserAsync(calendarEventItemId, userId); diff --git a/Core/Resgrid.Services/MessageService.cs b/Core/Resgrid.Services/MessageService.cs index 870bd39da..c24b08056 100644 --- a/Core/Resgrid.Services/MessageService.cs +++ b/Core/Resgrid.Services/MessageService.cs @@ -50,14 +50,20 @@ public async Task GetMessageByIdAsync(int messageId) public async Task> GetInboxMessagesByUserIdAsync(string userId) { var list = await _messageRepository.GetInboxMessagesByUserIdAsync(userId); - return list.OrderByDescending(x => x.SentOn).ToList(); + return (list ?? Enumerable.Empty()) + .Where(IsActiveInboxMessage) + .OrderByDescending(x => x.SentOn) + .ToList(); } public async Task> GetUnreadInboxMessagesByUserIdAsync(string userId) { var messages = await _messageRepository.GetInboxMessagesByUserIdAsync(userId); - return messages.Where(m => !m.HasUserRead(userId)).OrderByDescending(x => x.SentOn).ToList(); + return (messages ?? Enumerable.Empty()) + .Where(m => IsActiveInboxMessage(m) && !m.HasUserRead(userId)) + .OrderByDescending(x => x.SentOn) + .ToList(); } public async Task> GetSentMessagesByUserIdAsync(string userId) @@ -75,6 +81,13 @@ public async Task GetUnreadMessagesCountByUserIdAsync(string userId) return await _messageRepository.GetUnreadMessageCountAsync(userId); } + private static bool IsActiveInboxMessage(Message message) + { + return message != null + && !message.IsDeleted + && (!message.ExpireOn.HasValue || message.ExpireOn.Value > DateTime.UtcNow); + } + public async Task MarkMessageAsDeletedAsync(int messageId, CancellationToken cancellationToken = default(CancellationToken)) { var message = await GetMessageByIdAsync(messageId); diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs index 82e17296c..021fff284 100644 --- a/Core/Resgrid.Services/PushService.cs +++ b/Core/Resgrid.Services/PushService.cs @@ -98,7 +98,7 @@ public async Task PushMessage(StandardPushMessage message, string userId, if (profile != null && profile.SendMessagePush) { - string soundType = await GetSoundTypeAsync(message.DepartmentId, PushSoundTypes.Message, PushSoundTypes.ModernMessage); + string soundType = await GetSoundTypeAsync(message.DepartmentId, profile, PushSoundTypes.Message, PushSoundTypes.ModernMessage); try { @@ -133,7 +133,7 @@ public async Task PushNotification(StandardPushMessage message, string use if (profile != null && profile.SendNotificationPush) { - string soundType = await GetSoundTypeAsync(message.DepartmentId, PushSoundTypes.Notifiation, PushSoundTypes.ModernNotification); + string soundType = await GetSoundTypeAsync(message.DepartmentId, profile, PushSoundTypes.Notifiation, PushSoundTypes.ModernNotification); try { @@ -166,7 +166,7 @@ public async Task PushChat(StandardPushMessage message, string userId, Use if (profile != null && profile.SendMessagePush) { - string soundType = await GetSoundTypeAsync(message.DepartmentId, PushSoundTypes.Message, PushSoundTypes.ModernChat); + string soundType = await GetSoundTypeAsync(message.DepartmentId, profile, PushSoundTypes.Message, PushSoundTypes.ModernChat); await _notificationProvider.SendAllNotifications(message.Title, message.SubTitle, userId, message.Id, soundType, true, 1, "#000000"); } @@ -190,7 +190,7 @@ public async Task PushCall(StandardPushCall call, string userId, UserProfi if (priority != null) color = priority.Color; - string soundType = await GetCallSoundTypeAsync(call, priority); + string soundType = await GetCallSoundTypeAsync(call, priority, profile); // Legacy Push Notifications (Azure) try @@ -241,34 +241,28 @@ public async Task PushCallUnit(StandardPushCall call, int unitId, Departme return true; } - private async Task GetSoundTypeAsync(int? departmentId, PushSoundTypes legacyType, PushSoundTypes modernType) + private async Task GetSoundTypeAsync(int? departmentId, UserProfile profile, PushSoundTypes legacyType, PushSoundTypes modernType) { - if (departmentId.HasValue) - { - try - { - bool useModern = await _departmentSettingsService.GetModernNotificationsEnabledAsync(departmentId.Value); - if (useModern) - return ((int)modernType).ToString(); - } - catch (Exception ex) - { - Framework.Logging.LogException(ex); - } - } + bool useModern = await GetModernApplicationSoundsEnabledAsync(departmentId, profile); + + return ((int)(useModern ? modernType : legacyType)).ToString(); + } - return ((int)legacyType).ToString(); + private async Task GetCallSoundTypeAsync(StandardPushCall call, DepartmentCallPriority priority, UserProfile profile = null) + { + bool useModern = await GetModernApplicationSoundsEnabledAsync(call.DepartmentId, profile); + return ConvertCallPriorityToSound((int)call.Priority, priority, useModern); } - private async Task GetCallSoundTypeAsync(StandardPushCall call, DepartmentCallPriority priority) + private async Task GetModernApplicationSoundsEnabledAsync(int? departmentId, UserProfile profile) { - bool useModern = false; + bool departmentEnabled = false; - if (call.DepartmentId.HasValue) + if (departmentId.HasValue) { try { - useModern = await _departmentSettingsService.GetModernNotificationsEnabledAsync(call.DepartmentId.Value); + departmentEnabled = await _departmentSettingsService.GetModernNotificationsEnabledAsync(departmentId.Value); } catch (Exception ex) { @@ -276,7 +270,9 @@ private async Task GetCallSoundTypeAsync(StandardPushCall call, Departme } } - return ConvertCallPriorityToSound((int)call.Priority, priority, useModern); + return ModernApplicationSoundSettings.IsEnabled( + departmentEnabled, + profile?.EnableModernApplicationSounds == true); } private string ConvertCallPriorityToSound(int priority, DepartmentCallPriority callPriority, bool useModern) diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0100_AddModernApplicationSoundsUserSetting.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0100_AddModernApplicationSoundsUserSetting.cs new file mode 100644 index 000000000..8147e0f0a --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0100_AddModernApplicationSoundsUserSetting.cs @@ -0,0 +1,19 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(100)] + public class M0100_AddModernApplicationSoundsUserSetting : Migration + { + public override void Up() + { + Alter.Table("UserProfiles") + .AddColumn("EnableModernApplicationSounds").AsBoolean().NotNullable().WithDefaultValue(false); + } + + public override void Down() + { + Delete.Column("EnableModernApplicationSounds").FromTable("UserProfiles"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0100_AddModernApplicationSoundsUserSettingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0100_AddModernApplicationSoundsUserSettingPg.cs new file mode 100644 index 000000000..55e2e0237 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0100_AddModernApplicationSoundsUserSettingPg.cs @@ -0,0 +1,19 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(100)] + public class M0100_AddModernApplicationSoundsUserSettingPg : Migration + { + public override void Up() + { + Alter.Table("UserProfiles".ToLower()) + .AddColumn("EnableModernApplicationSounds".ToLower()).AsBoolean().NotNullable().WithDefaultValue(false); + } + + public override void Down() + { + Delete.Column("EnableModernApplicationSounds".ToLower()).FromTable("UserProfiles".ToLower()); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/MessageRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/MessageRepository.cs index bb4a7f93e..c84364470 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/MessageRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/MessageRepository.cs @@ -39,6 +39,7 @@ public async Task GetUnreadMessageCountAsync(string userId) { var dynamicParameters = new DynamicParametersExtension(); dynamicParameters.Add("UserId", userId); + dynamicParameters.Add("CurrentDate", DateTime.UtcNow); var query = _queryFactory.GetQuery(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Messages/SelectUnreadMessageCountQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Messages/SelectUnreadMessageCountQuery.cs index 8d2d315ac..e0f6c686e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Messages/SelectUnreadMessageCountQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Messages/SelectUnreadMessageCountQuery.cs @@ -20,10 +20,12 @@ public string GetQuery() string.Empty, _sqlConfiguration.ParameterNotation, new string[] { - "%USERID%" + "%USERID%", + "%CURRENTDATE%" }, - new string[] { + new string[] { "UserId", + "CurrentDate" }, new string[] { "%MESSAGESTABLE%", diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 85c6eb09c..1d2b52acf 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -358,7 +358,8 @@ DELETE FROM %SCHEMA%.%TABLENAME% SelectUnreadMessageCountQuery = @" SELECT COUNT(*) FROM %SCHEMA%.%MESSAGESTABLE% m LEFT OUTER JOIN %SCHEMA%.%MESSAGERECIPIENTSTABLE% mr ON m.MessageId = mr.MessageId - WHERE mr.UserId = %USERID% AND mr.IsDeleted = false AND m.IsDeleted = false"; + WHERE mr.UserId = %USERID% AND mr.IsDeleted = false AND mr.ReadOn IS NULL + AND m.IsDeleted = false AND (m.ExpireOn IS NULL OR m.ExpireOn > %CURRENTDATE%)"; SelectMessageRecpByMessageUsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE MessageId = %MESSAGEID% AND UserId = %USERID%"; SelectMessagesByUserQuery = @" diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index 4942e478a..a31717c5a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -356,7 +356,8 @@ DELETE FROM %SCHEMA%.%TABLENAME% SelectUnreadMessageCountQuery = @" SELECT COUNT(*) FROM %SCHEMA%.%MESSAGESTABLE% m LEFT OUTER JOIN %SCHEMA%.%MESSAGERECIPIENTSTABLE% mr ON m.MessageId = mr.MessageId - WHERE mr.[UserId] = %USERID% AND mr.[IsDeleted] = 0 AND m.[IsDeleted] = 0"; + WHERE mr.[UserId] = %USERID% AND mr.[IsDeleted] = 0 AND mr.[ReadOn] IS NULL + AND m.[IsDeleted] = 0 AND (m.[ExpireOn] IS NULL OR m.[ExpireOn] > %CURRENTDATE%)"; SelectMessageRecpByMessageUsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [MessageId] = %MESSAGEID% AND [UserId] = %USERID%"; SelectMessagesByUserQuery = @" diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs index 53d52db95..38ff9ee71 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs @@ -9,6 +9,7 @@ using Resgrid.Model; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; using Resgrid.Services; @@ -27,6 +28,8 @@ public class CalendarServiceCheckInTests private Mock _departmentSettingsService; private Mock _encryptionService; private Mock _checkInRepo; + private Mock _messageRecipientRepo; + private Mock _unitOfWork; private CalendarService _service; [SetUp] @@ -42,6 +45,8 @@ public void SetUp() _departmentSettingsService = new Mock(); _encryptionService = new Mock(); _checkInRepo = new Mock(); + _messageRecipientRepo = new Mock(); + _unitOfWork = new Mock(); _service = new CalendarService( _calendarItemRepo.Object, @@ -53,7 +58,9 @@ public void SetUp() _departmentGroupsService.Object, _departmentSettingsService.Object, _encryptionService.Object, - _checkInRepo.Object); + _checkInRepo.Object, + _messageRecipientRepo.Object, + _unitOfWork.Object); } #region Service Logic Tests diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceRsvpTransactionTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceRsvpTransactionTests.cs new file mode 100644 index 000000000..79f315d9a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CalendarServiceRsvpTransactionTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class CalendarServiceRsvpTransactionTests + { + private Mock _attendeeRepository; + private Mock _messageRecipientRepository; + private Mock _unitOfWork; + private CalendarService _service; + + [SetUp] + public void SetUp() + { + _attendeeRepository = new Mock(); + _messageRecipientRepository = new Mock(); + _unitOfWork = new Mock(); + _service = new CalendarService( + null, + null, + _attendeeRepository.Object, + null, + null, + null, + null, + null, + null, + null, + _messageRecipientRepository.Object, + _unitOfWork.Object); + } + + [Test] + public async Task SignupForEventAndUpdateMessageRecipientAsync_CommitsBothWrites() + { + var recipient = new MessageRecipient { MessageRecipientId = 10, MessageId = 20, UserId = "user-1" }; + _attendeeRepository.Setup(x => x.GetCalendarItemAttendeeByUserAsync(30, "user-1")) + .ReturnsAsync((CalendarItemAttendee)null); + _attendeeRepository.Setup(x => x.SaveOrUpdateAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItemAttendee attendee, CancellationToken cancellationToken, bool firstLevelOnly) => attendee); + _messageRecipientRepository.Setup(x => x.SaveOrUpdateAsync( + recipient, It.IsAny(), It.IsAny())) + .ReturnsAsync(recipient); + + var result = await _service.SignupForEventAndUpdateMessageRecipientAsync( + 30, "user-1", "Yes", (int)CalendarItemAttendeeTypes.RSVP, recipient); + + result.CalendarItemId.Should().Be(30); + result.UserId.Should().Be("user-1"); + recipient.Response.Should().Be("Yes"); + recipient.ReadOn.Should().NotBeNull(); + _unitOfWork.Verify(x => x.CreateOrGetConnection(), Times.Once); + _unitOfWork.Verify(x => x.CommitChanges(), Times.Once); + _unitOfWork.Verify(x => x.DiscardChanges(), Times.Never); + } + + [Test] + public async Task SignupForEventAndUpdateMessageRecipientAsync_RollsBackWhenRecipientWriteFails() + { + var recipient = new MessageRecipient { MessageRecipientId = 10, MessageId = 20, UserId = "user-1" }; + _attendeeRepository.Setup(x => x.GetCalendarItemAttendeeByUserAsync(30, "user-1")) + .ReturnsAsync((CalendarItemAttendee)null); + _attendeeRepository.Setup(x => x.SaveOrUpdateAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CalendarItemAttendee attendee, CancellationToken cancellationToken, bool firstLevelOnly) => attendee); + _messageRecipientRepository.Setup(x => x.SaveOrUpdateAsync( + recipient, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Recipient write failed.")); + + Func act = () => _service.SignupForEventAndUpdateMessageRecipientAsync( + 30, "user-1", "Yes", (int)CalendarItemAttendeeTypes.RSVP, recipient); + + await act.Should().ThrowAsync(); + _unitOfWork.Verify(x => x.CommitChanges(), Times.Never); + _unitOfWork.Verify(x => x.DiscardChanges(), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs index 426f9567b..9517759c6 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs @@ -8,6 +8,7 @@ using Resgrid.Model; using Resgrid.Model.Helpers; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; using Resgrid.Services; @@ -74,6 +75,7 @@ protected with_the_calendar_service() _calendarItemAttendeeRepositoryMock.Object, _departmentsServiceMock.Object, _communicationServiceMock.Object, _userProfileServiceMock.Object, _departmentGroupsServiceMock.Object, _departmentSettingsServiceMock.Object, _encryptionServiceMock.Object, new Mock().Object, + new Mock().Object, new Mock().Object, _textResponsePromptServiceMock.Object); } } diff --git a/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs b/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs new file mode 100644 index 000000000..6b3982c69 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Messages; +using Resgrid.Model.Repositories; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries.Messages; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; +using Resgrid.Services; +using Resgrid.Web.Services.Controllers.v4; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class MessageServiceInboxTests + { + [Test] + public async Task InboxAndUnreadCountExcludeExpiredMessages() + { + var repository = new Mock(); + var messages = new List + { + CreateMessage(1, null), + CreateMessage(2, DateTime.UtcNow.AddMinutes(10)), + CreateMessage(3, DateTime.UtcNow.AddMinutes(-10)) + }; + repository.Setup(x => x.GetInboxMessagesByUserIdAsync("user-1")) + .ReturnsAsync(messages); + repository.Setup(x => x.GetUnreadMessageCountAsync("user-1")) + .ReturnsAsync(2); + var service = new MessageService(repository.Object, null, null, null, null, null); + + var inbox = await service.GetInboxMessagesByUserIdAsync("user-1"); + var unreadCount = await service.GetUnreadMessagesCountByUserIdAsync("user-1"); + + inbox.Should().HaveCount(2); + inbox.Should().OnlyContain(x => x.MessageId != 3); + unreadCount.Should().Be(2); + repository.Verify(x => x.GetInboxMessagesByUserIdAsync("user-1"), Times.Once); + repository.Verify(x => x.GetUnreadMessageCountAsync("user-1"), Times.Once); + } + + [Test] + public void ApiMappingUsesSystemSenderAndExposesCalendarRsvpMetadataForCurrentUser() + { + var message = CreateMessage(12, DateTime.UtcNow.AddMinutes(10)); + message.SystemGenerated = true; + message.SendingUserId = "user-1"; + message.Type = (int)MessageTypes.CalendarRsvp; + message.MessageRecipients.First().Note = TextResponsePromptMetadata.ForCalendarRsvp(8521); + message.MessageRecipients.First().Response = "Yes"; + + var result = MessagesController.ConvertMessageResultData(message, null, "user-1", null); + + result.IsSystem.Should().BeTrue(); + result.SendingName.Should().Be("System"); + result.CalendarItemId.Should().Be("8521"); + result.Responded.Should().BeTrue(); + result.ResponseType.Should().Be("Yes"); + } + + [Test] + public void UnreadCountQueriesApplyUnreadAndActiveMessagePredicates() + { + var configurations = new SqlConfiguration[] + { + new SqlServerConfiguration(), + new PostgreSqlConfiguration() + }; + + foreach (var configuration in configurations) + { + var query = new SelectUnreadMessageCountQuery(configuration).GetQuery(); + + query.Should().Contain("ReadOn"); + query.Should().Contain("ExpireOn"); + query.Should().Contain("@CurrentDate"); + } + } + + private static Message CreateMessage(int id, DateTime? expireOn) + { + return new Message + { + MessageId = id, + Subject = "Message", + Body = "Body", + SentOn = DateTime.UtcNow, + ExpireOn = expireOn, + MessageRecipients = new List + { + new MessageRecipient + { + MessageRecipientId = id, + MessageId = id, + UserId = "user-1" + } + } + }; + } + } +} diff --git a/Tests/Resgrid.Tests/Services/PushServiceModernApplicationSoundTests.cs b/Tests/Resgrid.Tests/Services/PushServiceModernApplicationSoundTests.cs new file mode 100644 index 000000000..512f75c68 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/PushServiceModernApplicationSoundTests.cs @@ -0,0 +1,191 @@ +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Messages; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class PushServiceModernApplicationSoundTests + { + private const int DepartmentId = 17; + private const string UserId = "modern-sound-user"; + + private Mock _notificationProvider; + private Mock _novuProvider; + private Mock _departmentSettingsService; + private PushService _pushService; + private bool _departmentWasAlreadyBypassed; + + [SetUp] + public void SetUp() + { + _departmentWasAlreadyBypassed = SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(DepartmentId); + SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Add(DepartmentId); + + _notificationProvider = new Mock(); + _novuProvider = new Mock(); + _departmentSettingsService = new Mock(); + + _notificationProvider + .Setup(x => x.SendAllNotifications( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + _novuProvider + .Setup(x => x.SendUserDispatch( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + + _pushService = new PushService( + new Mock().Object, + _notificationProvider.Object, + new Mock().Object, + new Mock().Object, + _novuProvider.Object, + _departmentSettingsService.Object); + } + + [TearDown] + public void TearDown() + { + if (!_departmentWasAlreadyBypassed) + SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Remove(DepartmentId); + } + + [TestCase(true, false, PushSoundTypes.ModernMessage)] + [TestCase(false, true, PushSoundTypes.ModernMessage)] + [TestCase(true, true, PushSoundTypes.ModernMessage)] + [TestCase(false, false, PushSoundTypes.Message)] + public async Task PushMessage_uses_effective_department_or_user_sound_setting( + bool departmentEnabled, + bool userEnabled, + PushSoundTypes expectedSound) + { + _departmentSettingsService + .Setup(x => x.GetModernNotificationsEnabledAsync(DepartmentId, false)) + .ReturnsAsync(departmentEnabled); + + var profile = new UserProfile + { + UserId = UserId, + SendMessagePush = true, + EnableModernApplicationSounds = userEnabled + }; + var message = new StandardPushMessage + { + DepartmentId = DepartmentId, + MessageId = 42, + Title = "Message", + SubTitle = "Body" + }; + + await _pushService.PushMessage(message, UserId, profile); + + _notificationProvider.Verify(x => x.SendAllNotifications( + message.Title, + message.SubTitle, + UserId, + "M42", + ((int)expectedSound).ToString(), + true, + 1, + "#000000"), Times.Once); + } + + [Test] + public async Task PushNotification_uses_legacy_sound_when_department_and_user_settings_are_disabled() + { + _departmentSettingsService + .Setup(x => x.GetModernNotificationsEnabledAsync(DepartmentId, false)) + .ReturnsAsync(false); + + var profile = new UserProfile + { + UserId = UserId, + SendNotificationPush = true, + EnableModernApplicationSounds = false + }; + var message = new StandardPushMessage + { + DepartmentId = DepartmentId, + MessageId = 43, + Title = "Notification", + SubTitle = "Body" + }; + + await _pushService.PushNotification(message, UserId, profile); + + _notificationProvider.Verify(x => x.SendAllNotifications( + message.Title, + message.SubTitle, + UserId, + "N43", + ((int)PushSoundTypes.Notifiation).ToString(), + true, + 1, + "#000000"), Times.Once); + } + + [TestCase(false, false, PushSoundTypes.CallEmergency)] + [TestCase(false, true, PushSoundTypes.ModernCallEmergency)] + [TestCase(true, false, PushSoundTypes.ModernCallEmergency)] + public async Task PushCall_uses_effective_department_or_user_sound_setting( + bool departmentEnabled, + bool userEnabled, + PushSoundTypes expectedSound) + { + _departmentSettingsService + .Setup(x => x.GetModernNotificationsEnabledAsync(DepartmentId, false)) + .ReturnsAsync(departmentEnabled); + + var profile = new UserProfile + { + UserId = UserId, + SendPush = true, + EnableModernApplicationSounds = userEnabled + }; + var call = new StandardPushCall + { + DepartmentId = DepartmentId, + DepartmentCode = "TEST", + CallId = 99, + Priority = (int)CallPriority.Emergency, + Title = "Dispatch", + SubTitle = "Address" + }; + + await _pushService.PushCall(call, UserId, profile); + + _notificationProvider.Verify(x => x.SendAllNotifications( + call.SubTitle, + call.Title, + UserId, + "C99", + ((int)expectedSound).ToString(), + true, + 0, + null), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/CheckInTimersControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/CheckInTimersControllerTests.cs new file mode 100644 index 000000000..3930a7622 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/CheckInTimersControllerTests.cs @@ -0,0 +1,143 @@ +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.Services; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Models.v4.CheckInTimers; +using Resgrid.Web.ServicesCore.Helpers; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class CheckInTimersControllerTests + { + private const int DepartmentId = 10; + private const int CallId = 101; + private const string CommanderUserId = "ic-user"; + private const string TargetUserId = "target-user"; + + private Mock _checkInTimerService; + private Mock _callsService; + private Mock _incidentCommandService; + private CheckInTimersController _controller; + private Activity _activity; + + [SetUp] + public void SetUp() + { + _checkInTimerService = new Mock(); + _callsService = new Mock(); + _incidentCommandService = new Mock(); + + var call = new Call + { + CallId = CallId, + DepartmentId = DepartmentId, + CheckInTimersEnabled = true, + State = (int)CallStates.Active + }; + _callsService.Setup(service => service.GetCallByIdAsync(CallId, It.IsAny())).ReturnsAsync(call); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, CommanderUserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + _activity = new Activity("CheckInTimersControllerTests").Start(); + + _controller = new CheckInTimersController( + _checkInTimerService.Object, + _callsService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + _incidentCommandService.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [TearDown] + public void TearDown() + { + ClaimsAuthorizationHelper._httpContextAccessor = null; + _activity?.Stop(); + } + + [Test] + public async Task PerformCheckIn_RecordsDispatchedTargetUser_WhenCommanderManagesAccountability() + { + _incidentCommandService + .Setup(service => service.GetCapabilitiesForUserAsync(DepartmentId, CallId, CommanderUserId)) + .ReturnsAsync(IncidentCapabilities.ManageAccountability); + _checkInTimerService + .Setup(service => service.GetCallPersonnelCheckInStatusesAsync(It.IsAny())) + .ReturnsAsync(new List { new PersonnelCallCheckInStatus { UserId = TargetUserId } }); + + CheckInRecord savedRecord = null; + _checkInTimerService + .Setup(service => service.PerformCheckInAsync(It.IsAny(), It.IsAny())) + .Callback((record, _) => savedRecord = record) + .ReturnsAsync(new CheckInRecord { CheckInRecordId = "record-1" }); + + var response = await _controller.PerformCheckIn(new PerformCheckInInput + { + CallId = CallId, + CheckInType = (int)CheckInTimerTargetType.Personnel, + UserId = TargetUserId + }, CancellationToken.None); + + response.Result.Should().BeOfType(); + savedRecord.Should().NotBeNull(); + savedRecord.UserId.Should().Be(TargetUserId); + } + + [Test] + public async Task PerformCheckIn_ReturnsForbidden_WhenCallerCannotManageAccountability() + { + _incidentCommandService + .Setup(service => service.GetCapabilitiesForUserAsync(DepartmentId, CallId, CommanderUserId)) + .ReturnsAsync(IncidentCapabilities.ViewBoard); + + var response = await _controller.PerformCheckIn(new PerformCheckInInput + { + CallId = CallId, + CheckInType = (int)CheckInTimerTargetType.Personnel, + UserId = TargetUserId + }, CancellationToken.None); + + response.Result.Should().BeOfType(); + _checkInTimerService.Verify( + service => service.PerformCheckInAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task PerformCheckIn_RejectsTargetUser_ForNonPersonnelTimer() + { + var response = await _controller.PerformCheckIn(new PerformCheckInInput + { + CallId = CallId, + CheckInType = (int)CheckInTimerTargetType.IC, + UserId = TargetUserId + }, CancellationToken.None); + + response.Result.Should().BeOfType(); + _incidentCommandService.Verify( + service => service.GetCapabilitiesForUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs index 87615db43..6056f6aa0 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs @@ -27,19 +27,22 @@ public class CheckInTimersController : V4AuthenticatedApiControllerbase private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IDepartmentsService _departmentsService; private readonly IUserProfileService _userProfileService; + private readonly IIncidentCommandService _incidentCommandService; public CheckInTimersController( ICheckInTimerService checkInTimerService, ICallsService callsService, IDepartmentSettingsService departmentSettingsService, IDepartmentsService departmentsService, - IUserProfileService userProfileService) + IUserProfileService userProfileService, + IIncidentCommandService incidentCommandService) { _checkInTimerService = checkInTimerService; _callsService = callsService; _departmentSettingsService = departmentSettingsService; _departmentsService = departmentsService; _userProfileService = userProfileService; + _incidentCommandService = incidentCommandService; } #region Timer Configuration @@ -382,12 +385,29 @@ public async Task> PerformCheckIn([FromBody] if (!System.Enum.IsDefined(typeof(CheckInTimerTargetType), input.CheckInType)) return BadRequest("Invalid check-in type."); + var checkInUserId = UserId; + if (!string.IsNullOrWhiteSpace(input.UserId) && !string.Equals(input.UserId, UserId, System.StringComparison.OrdinalIgnoreCase)) + { + if (input.CheckInType != (int)CheckInTimerTargetType.Personnel) + return BadRequest("A target user can only be supplied for a personnel check-in."); + + var capabilities = await _incidentCommandService.GetCapabilitiesForUserAsync(DepartmentId, input.CallId, UserId); + if ((capabilities & IncidentCapabilities.ManageAccountability) != IncidentCapabilities.ManageAccountability) + return Forbid(); + + var personnelStatuses = await _checkInTimerService.GetCallPersonnelCheckInStatusesAsync(call); + if (personnelStatuses == null || !personnelStatuses.Any(status => string.Equals(status.UserId, input.UserId, System.StringComparison.OrdinalIgnoreCase))) + return BadRequest("The target user is not dispatched on this call or no personnel timer is active."); + + checkInUserId = input.UserId; + } + var record = new CheckInRecord { DepartmentId = DepartmentId, CallId = input.CallId, CheckInType = input.CheckInType, - UserId = UserId, + UserId = checkInUserId, UnitId = input.UnitId, Latitude = input.Latitude, Longitude = input.Longitude, diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs index b5cf6039d..438c840ea 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ConfigController.cs @@ -6,6 +6,7 @@ using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Configs; using Resgrid.Config; +using Resgrid.Model; using Microsoft.Diagnostics.Tracing.Parsers.Kernel; using Resgrid.Web.ServicesCore.Helpers; @@ -21,10 +22,12 @@ public class ConfigController : ControllerBase { #region Members and Constructors private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IUserProfileService _userProfileService; - public ConfigController(IDepartmentSettingsService departmentSettingsService) + public ConfigController(IDepartmentSettingsService departmentSettingsService, IUserProfileService userProfileService) { _departmentSettingsService = departmentSettingsService; + _userProfileService = userProfileService; } #endregion Members and Constructors @@ -111,6 +114,34 @@ private async Task BuildConfigResultAsync(string key, int depar result.Data.AnalyticsApiKey = ""; result.Data.AnalyticsHost = ""; + bool departmentModernApplicationSoundsEnabled = false; + + if (departmentId > 0) + { + try + { + departmentModernApplicationSoundsEnabled = await _departmentSettingsService.GetModernNotificationsEnabledAsync(departmentId); + } + catch (System.Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, + $"{nameof(BuildConfigResultAsync)}: {nameof(IDepartmentSettingsService.GetModernNotificationsEnabledAsync)} failed for departmentId {departmentId}."); + } + } + + bool userModernApplicationSoundsEnabled = false; + var userId = GetCurrentUserId(); + + if (!string.IsNullOrWhiteSpace(userId)) + { + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + userModernApplicationSoundsEnabled = profile?.EnableModernApplicationSounds == true; + } + + result.Data.EnableModernApplicationSounds = ModernApplicationSoundSettings.IsEnabled( + departmentModernApplicationSoundsEnabled, + userModernApplicationSoundsEnabled); + result.PageSize = 1; result.Status = ResponseHelper.Success; ResponseHelper.PopulateV4ResponseData(result); @@ -127,5 +158,15 @@ private static int GetCurrentDepartmentId() return 0; } + + private static string GetCurrentUserId() + { + var principal = ClaimsAuthorizationHelper.GetClaimsPrincipal(); + + if (principal?.Identity != null && principal.Identity.IsAuthenticated) + return ClaimsAuthorizationHelper.GetUserId(); + + return string.Empty; + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs index e9c2e7f5a..eceb44a32 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs @@ -13,6 +13,7 @@ using Resgrid.Framework; using Resgrid.Model.Helpers; using Resgrid.Model.Providers; +using Resgrid.Model.Messages; using Resgrid.Web.Services.Models.v4.Messages; using IAuthorizationService = Resgrid.Model.Services.IAuthorizationService; @@ -27,6 +28,9 @@ namespace Resgrid.Web.Services.Controllers.v4 public class MessagesController : V4AuthenticatedApiControllerbase { #region Members and Constructors + private const int CalendarRsvpResponseTypeAttending = 1; + private const int CalendarRsvpResponseTypeNotAttending = 3; + private readonly ICallsService _callsService; private readonly IDepartmentsService _departmentsService; private readonly IUserProfileService _userProfileService; @@ -37,6 +41,7 @@ public class MessagesController : V4AuthenticatedApiControllerbase private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IPersonnelRolesService _personnelRolesService; private readonly IUnitsService _unitsService; + private readonly ICalendarService _calendarService; public MessagesController( ICallsService callsService, @@ -48,7 +53,8 @@ public MessagesController( IUsersService usersService, IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, - IUnitsService unitsService) + IUnitsService unitsService, + ICalendarService calendarService) { _callsService = callsService; _departmentsService = departmentsService; @@ -60,6 +66,7 @@ public MessagesController( _departmentGroupsService = departmentGroupsService; _personnelRolesService = personnelRolesService; _unitsService = unitsService; + _calendarService = calendarService; } #endregion Members and Constructors @@ -418,11 +425,13 @@ public async Task> SendMessage([FromBody] NewMes } /// - /// Deletes a message from the system + /// Records a user's response to a message. Calendar RSVP responses also update event attendance. /// /// Returns OK status code if successful [HttpPut("RespondToMessage")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] [Authorize(Policy = ResgridResources.Messages_View)] public async Task> RespondToMessage([FromBody] RespondToMessageInput responseInput, @@ -433,15 +442,49 @@ public async Task> RespondToMessage([FromBo if (message != null) { + if (message.ExpireOn.HasValue && message.ExpireOn.Value <= DateTime.UtcNow) + return NotFound(); - var response = message.MessageRecipients.FirstOrDefault(x => x.UserId == UserId); + var response = message.MessageRecipients?.FirstOrDefault(x => x.UserId == UserId && !x.IsDeleted); if (response == null) return NotFound(); - response.Response = responseInput.Type.ToString(); + if (message.Type == (int)MessageTypes.CalendarRsvp) + { + if ((responseInput.Type != CalendarRsvpResponseTypeAttending + && responseInput.Type != CalendarRsvpResponseTypeNotAttending) + || !TextResponsePromptMetadata.TryGetCalendarItemId(response.Note, out var calendarItemId)) + return BadRequest(); + + var calendarItem = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); + if (calendarItem == null) + return NotFound(); + + if (calendarItem.DepartmentId != DepartmentId) + return Unauthorized(); + + if (calendarItem.SignupType != (int)CalendarItemSignupTypes.RSVP + || !await _authorizationService.CanUserCheckInToCalendarEventAsync(UserId, calendarItemId)) + return Unauthorized(); + + var attending = responseInput.Type == CalendarRsvpResponseTypeAttending; + var normalizedResponse = attending ? "Yes" : "No"; + var attendeeType = attending + ? (int)CalendarItemAttendeeTypes.RSVP + : (int)CalendarItemAttendeeTypes.NotAttending; + + await _calendarService.SignupForEvent(calendarItemId, UserId, normalizedResponse, + attendeeType, cancellationToken); + response.Response = normalizedResponse; + } + else + { + response.Response = responseInput.Type.ToString(); + response.Note = responseInput.Note; + } + response.ReadOn = DateTime.UtcNow; - response.Note = responseInput.Note; var respondResult = await _messageService.SaveMessageRecipientAsync(response, cancellationToken); @@ -516,7 +559,12 @@ public static MessageResultData ConvertMessageResultData(Message savedMessage, D message.Type = savedMessage.Type; message.ExpiredOn = savedMessage.ExpireOn; - if (!String.IsNullOrWhiteSpace(savedMessage.SendingUserId)) + if (savedMessage.SystemGenerated) + { + message.SendingUserId = savedMessage.SendingUserId; + message.SendingName = "System"; + } + else if (!String.IsNullOrWhiteSpace(savedMessage.SendingUserId)) { message.SendingUserId = savedMessage.SendingUserId; @@ -528,7 +576,7 @@ public static MessageResultData ConvertMessageResultData(Message savedMessage, D } } - bool outboxMessage = savedMessage.SendingUserId == currentUserId; + bool outboxMessage = savedMessage.SendingUserId == currentUserId && !savedMessage.SystemGenerated; if (!outboxMessage) { @@ -544,6 +592,10 @@ public static MessageResultData ConvertMessageResultData(Message savedMessage, D message.Note = respose.Note; message.RespondedOn = respose.ReadOn; + + if (savedMessage.Type == (int)MessageTypes.CalendarRsvp + && TextResponsePromptMetadata.TryGetCalendarItemId(respose.Note, out var calendarItemId)) + message.CalendarItemId = calendarItemId.ToString(); } else { diff --git a/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs index 7e29c20d9..3751fdc8a 100644 --- a/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs @@ -141,6 +141,9 @@ public class PerformCheckInInput [Range(0, int.MaxValue)] public int CheckInType { get; set; } + /// Optional personnel user id when an incident commander checks in on that person's behalf. + public string UserId { get; set; } + public string Latitude { get; set; } public string Longitude { get; set; } public int? UnitId { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs b/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs index 31dcc49a8..4a83360ca 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Configs/GetConfigResult.cs @@ -140,5 +140,11 @@ public class GetConfigResultData /// Analytics Host /// public string AnalyticsHost { get; set; } + + /// + /// Whether the current user should use modern application notification sounds. This is + /// enabled when either the department-wide setting or the user's profile setting is enabled. + /// + public bool EnableModernApplicationSounds { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Messages/GetMessagesResult.cs b/Web/Resgrid.Web.Services/Models/v4/Messages/GetMessagesResult.cs index 60aa47cb5..31bdbae2d 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Messages/GetMessagesResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Messages/GetMessagesResult.cs @@ -63,12 +63,12 @@ public class MessageResultData public DateTime SentOnUtc { get; set; } /// - /// Message Type (0 = Normal, 1 = Callback, 2 = Poll) + /// Message Type (0 = Normal, 1 = Callback, 2 = Poll, 3 = Weather Alert, 4 = Calendar RSVP) /// public int Type { get; set; } /// - /// When does the message expire (used for Polls and Callbacks) + /// When the message expires /// public DateTime? ExpiredOn { get; set; } @@ -97,6 +97,11 @@ public class MessageResultData /// public bool IsSystem { get; set; } + /// + /// Calendar item associated with a calendar RSVP message + /// + public string CalendarItemId { get; set; } + /// /// Recipients of this message /// diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 656883ffe..8819ca800 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1433,7 +1433,7 @@ - Deletes a message from the system + Records a user's response to a message. Calendar RSVP responses also update event attendance. Returns OK status code if successful @@ -6481,6 +6481,9 @@ User Defined Field values for this contact + + Optional personnel user id when an incident commander checks in on that person's behalf. + Optional offline idempotency key (the client's outbox event id); a replayed check-in dedups on it. @@ -6908,6 +6911,12 @@ Analytics Host + + + Whether the current user should use modern application notification sounds. This is + enabled when either the department-wide setting or the user's profile setting is enabled. + + Gets Configuration Information for the Resgrid System @@ -7912,12 +7921,12 @@ - Message Type (0 = Normal, 1 = Callback, 2 = Poll) + Message Type (0 = Normal, 1 = Callback, 2 = Poll, 3 = Weather Alert, 4 = Calendar RSVP) - When does the message expire (used for Polls and Callbacks) + When the message expires @@ -7945,6 +7954,11 @@ Was this message system generated (i.e. email import) + + + Calendar item associated with a calendar RSVP message + + Recipients of this message diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 157debd35..7e3c5f8fd 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -743,6 +743,7 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo savedProfile.SendNotificationEmail = model.Profile.SendNotificationEmail; savedProfile.SendNotificationPush = model.Profile.SendNotificationPush; savedProfile.SendNotificationSms = model.Profile.SendNotificationSms; + savedProfile.EnableModernApplicationSounds = model.Profile.EnableModernApplicationSounds; savedProfile.DoNotRecieveNewsletters = model.Profile.DoNotRecieveNewsletters; savedProfile.HomeNumber = (homeResult != null && homeResult.IsValid && !string.IsNullOrWhiteSpace(homeResult.InternationalNumber)) ? homeResult.InternationalNumber diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs index 9f66e5bfe..651f04f21 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs @@ -15,6 +15,8 @@ using Resgrid.Web.Helpers; using Microsoft.AspNetCore.Authorization; using System.Threading; +using Resgrid.Framework; +using Resgrid.Model.Messages; namespace Resgrid.Web.Areas.User.Controllers { @@ -30,10 +32,11 @@ public class MessagesController : SecureBaseController private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IPersonnelRolesService _personnelRolesService; private readonly IShiftsService _shiftsService; + private readonly ICalendarService _calendarService; public MessagesController(IMessageService messageService, IDepartmentsService departmentsService, IUsersService usersService, ICommunicationService communicationService, Model.Services.IAuthorizationService authorizationService, IDepartmentGroupsService departmentGroupsService, - IPersonnelRolesService personnelRolesService, IShiftsService shiftsService) + IPersonnelRolesService personnelRolesService, IShiftsService shiftsService, ICalendarService calendarService) { _messageService = messageService; _departmentsService = departmentsService; @@ -43,6 +46,7 @@ public MessagesController(IMessageService messageService, IDepartmentsService de _departmentGroupsService = departmentGroupsService; _personnelRolesService = personnelRolesService; _shiftsService = shiftsService; + _calendarService = calendarService; } #endregion Private Members and Constructors @@ -245,13 +249,80 @@ public async Task ViewMessage(int messageId, CancellationToken ca model.User = _usersService.GetUserById(UserId); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); model.Message = await _messageService.GetMessageByIdAsync(messageId); + if (model.Message == null || (model.Message.ExpireOn.HasValue && model.Message.ExpireOn.Value <= DateTime.UtcNow)) + return RedirectToAction("Inbox"); + model.UnreadMessages = await _messageService.GetUnreadMessagesCountByUserIdAsync(UserId); model.UserGroupsAndRoles = await _usersService.GetUserGroupAndRolesByDepartmentIdAsync(DepartmentId, true, true, true); + + if (model.Message.Type == (int)MessageTypes.CalendarRsvp) + { + var recipient = model.Message.MessageRecipients?.FirstOrDefault(x => x.UserId == UserId && !x.IsDeleted); + if (recipient != null + && TextResponsePromptMetadata.TryGetCalendarItemId(recipient.Note, out var calendarItemId)) + { + var calendarItem = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); + if (calendarItem != null && calendarItem.DepartmentId == DepartmentId + && calendarItem.SignupType == (int)CalendarItemSignupTypes.RSVP) + { + model.CanRespondToCalendarRsvp = + await _authorizationService.CanUserCheckInToCalendarEventAsync(UserId, calendarItemId); + var attendee = await _calendarService.GetCalendarItemAttendeeByUserAsync(calendarItemId, UserId); + model.CalendarRsvpAttendeeType = attendee?.AttendeeType; + } + } + } + await _messageService.ReadMessageRecipientAsync(messageId, UserId, cancellationToken); return View(model); } + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Messages_View)] + public async Task CalendarRsvp(int messageId, bool attending, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserViewMessageAsync(UserId, messageId)) + return Unauthorized(); + + var message = await _messageService.GetMessageByIdAsync(messageId); + if (message == null || message.Type != (int)MessageTypes.CalendarRsvp + || (message.ExpireOn.HasValue && message.ExpireOn.Value <= DateTime.UtcNow)) + return RedirectToAction("Inbox"); + + var recipient = message.MessageRecipients?.FirstOrDefault(x => x.UserId == UserId && !x.IsDeleted); + if (recipient == null + || !TextResponsePromptMetadata.TryGetCalendarItemId(recipient.Note, out var calendarItemId)) + return Unauthorized(); + + var calendarItem = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); + if (calendarItem == null || calendarItem.DepartmentId != DepartmentId + || calendarItem.SignupType != (int)CalendarItemSignupTypes.RSVP + || !await _authorizationService.CanUserCheckInToCalendarEventAsync(UserId, calendarItemId)) + return Unauthorized(); + + var normalizedResponse = attending ? "Yes" : "No"; + var attendeeType = attending + ? (int)CalendarItemAttendeeTypes.RSVP + : (int)CalendarItemAttendeeTypes.NotAttending; + + try + { + await _calendarService.SignupForEventAndUpdateMessageRecipientAsync(calendarItemId, UserId, + normalizedResponse, attendeeType, recipient, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, + $"CalendarRsvp response transaction failed. messageId={messageId}, calendarItemId={calendarItemId}, UserId={UserId}."); + return StatusCode(StatusCodes.Status500InternalServerError, + "Unable to update your attendance. Please try again."); + } + + return RedirectToAction("ViewMessage", new { messageId }); + } + [HttpGet] [Authorize(Policy = ResgridResources.Messages_View)] public async Task MessageResponse(int recipientId, int response, string note, CancellationToken cancellationToken) diff --git a/Web/Resgrid.Web/Areas/User/Models/Messages/ViewMessageView.cs b/Web/Resgrid.Web/Areas/User/Models/Messages/ViewMessageView.cs index 82589a6e3..f3c9995ba 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Messages/ViewMessageView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Messages/ViewMessageView.cs @@ -11,5 +11,7 @@ public class ViewMessageView: BaseUserModel public Message Message { get; set; } public int UnreadMessages { get; set; } public List UserGroupsAndRoles { get; set; } + public bool CanRespondToCalendarRsvp { get; set; } + public int? CalendarRsvpAttendeeType { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml index 9ba539e7a..ab6a9b403 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml @@ -722,6 +722,21 @@ +
+ +
+
+
+ + +
+
+ @localizer["ModernApplicationSoundsHelp"] +
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Messages/ViewMessage.cshtml b/Web/Resgrid.Web/Areas/User/Views/Messages/ViewMessage.cshtml index 40cfb9d91..1595e564a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Messages/ViewMessage.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Messages/ViewMessage.cshtml @@ -43,9 +43,13 @@
@Model.Message.SentOn.TimeConverterToString(Model.Department) - @if (!String.IsNullOrWhiteSpace(Model.Message.SendingUserId)) + @if (Model.Message.SystemGenerated) { - From: @(await UserHelper.GetFullNameForUser(Model.Message.SendingUserId)) + @localizer["FromLabel"]: @commonLocalizer["System"] + } + else if (!String.IsNullOrWhiteSpace(Model.Message.SendingUserId)) + { + @localizer["FromLabel"]: @(await UserHelper.GetFullNameForUser(Model.Message.SendingUserId)) } else { @@ -84,6 +88,35 @@ @Html.Raw(Model.Message.Body)
+ @if (Model.Message.Type == (int)MessageTypes.CalendarRsvp && Model.CanRespondToCalendarRsvp) + { +
+
+ +
+
+
+ @Html.AntiForgeryToken() + + + +
+
+ @Html.AntiForgeryToken() + + + +
+
+
+
+
+ } + @if (Model.Message.Type == (int)MessageTypes.Poll || Model.Message.Type == (int)MessageTypes.Callback) { var recipient = Model.Message.MessageRecipients.FirstOrDefault(x => x.UserId == ClaimsAuthorizationHelper.GetUserId());