diff --git a/src/Server/Coderr.Server.Api/Core/Users/Commands/DeleteBrowserSubscription.cs b/src/Server/Coderr.Server.Api/Core/Users/Commands/DeleteBrowserSubscription.cs new file mode 100644 index 00000000..bed2f178 --- /dev/null +++ b/src/Server/Coderr.Server.Api/Core/Users/Commands/DeleteBrowserSubscription.cs @@ -0,0 +1,9 @@ +namespace Coderr.Server.Api.Core.Users.Commands +{ + [Message] + public class DeleteBrowserSubscription + { + public int UserId { get; set; } + public string Endpoint { get; set; } + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.Api/Core/Users/Commands/StoreBrowserSubscription.cs b/src/Server/Coderr.Server.Api/Core/Users/Commands/StoreBrowserSubscription.cs new file mode 100644 index 00000000..9270f201 --- /dev/null +++ b/src/Server/Coderr.Server.Api/Core/Users/Commands/StoreBrowserSubscription.cs @@ -0,0 +1,17 @@ +namespace Coderr.Server.Api.Core.Users.Commands +{ + /// + /// https://tools.ietf.org/html/draft-ietf-webpush-encryption-08 + /// + [Message] + public class StoreBrowserSubscription + { + public int UserId { get; set; } + public string Endpoint { get; set; } + + public string PublicKey { get; set; } + public string AuthenticationSecret { get; set; } + + public int? ExpirationTime { get; set; } + } +} diff --git a/src/Server/Coderr.Server.Api/Core/Users/Commands/UpdateNotifications.cs b/src/Server/Coderr.Server.Api/Core/Users/Commands/UpdateNotifications.cs index 628dfbc9..b79dadf9 100644 --- a/src/Server/Coderr.Server.Api/Core/Users/Commands/UpdateNotifications.cs +++ b/src/Server/Coderr.Server.Api/Core/Users/Commands/UpdateNotifications.cs @@ -16,11 +16,6 @@ public class UpdateNotifications /// public NotificationState NotifyOnNewIncidents { get; set; } - /// - /// How to notify when a new report is created (receive an exception) - /// - public NotificationState NotifyOnNewReport { get; set; } - /// /// How to notify user when a peak is detected /// @@ -36,6 +31,7 @@ public class UpdateNotifications /// public NotificationState NotifyOnUserFeedback { get; set; } + /// /// User that configured its settings. /// diff --git a/src/Server/Coderr.Server.Api/Core/Users/NotificationSettings.cs b/src/Server/Coderr.Server.Api/Core/Users/NotificationSettings.cs index b32a5ce7..25e74f2b 100644 --- a/src/Server/Coderr.Server.Api/Core/Users/NotificationSettings.cs +++ b/src/Server/Coderr.Server.Api/Core/Users/NotificationSettings.cs @@ -12,11 +12,6 @@ public class NotificationSettings /// public NotificationState NotifyOnNewIncidents { get; set; } - /// - /// How to notify when a new report is created (receive an exception) - /// - public NotificationState NotifyOnNewReport { get; set; } - /// /// How to notify user when a peak is detected /// diff --git a/src/Server/Coderr.Server.Api/Core/Users/NotificationState.cs b/src/Server/Coderr.Server.Api/Core/Users/NotificationState.cs index acdb558d..191af889 100644 --- a/src/Server/Coderr.Server.Api/Core/Users/NotificationState.cs +++ b/src/Server/Coderr.Server.Api/Core/Users/NotificationState.cs @@ -8,21 +8,26 @@ public enum NotificationState /// /// Use global setting /// - UseGlobalSetting, + UseGlobalSetting = 1, /// /// Do not notify /// - Disabled, + Disabled = 2, /// /// By cellphone (text message) /// - Cellphone, + Cellphone = 3, /// /// By email /// - Email + Email = 4, + + /// + /// Use browser/desktop notifications. + /// + BrowserNotification = 5 } } \ No newline at end of file diff --git a/src/Server/Coderr.Server.App/Core/Notifications/Commands/DeleteBrowserSubscriptionHandler.cs b/src/Server/Coderr.Server.App/Core/Notifications/Commands/DeleteBrowserSubscriptionHandler.cs new file mode 100644 index 00000000..315ccce6 --- /dev/null +++ b/src/Server/Coderr.Server.App/Core/Notifications/Commands/DeleteBrowserSubscriptionHandler.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Coderr.Server.Api.Core.Users.Commands; +using DotNetCqs; + +namespace Coderr.Server.App.Core.Notifications.Commands +{ + internal class DeleteBrowserSubscriptionHandler : IMessageHandler + { + private readonly INotificationsRepository _repository; + + public DeleteBrowserSubscriptionHandler(INotificationsRepository repository) + { + _repository = repository; + } + + public async Task HandleAsync(IMessageContext context, DeleteBrowserSubscription message) + { + await _repository.DeleteBrowserSubscription(message.UserId, message.Endpoint); + } + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.App/Core/Notifications/Commands/StoreBrowserSubscriptionHandler.cs b/src/Server/Coderr.Server.App/Core/Notifications/Commands/StoreBrowserSubscriptionHandler.cs new file mode 100644 index 00000000..4ba9eb01 --- /dev/null +++ b/src/Server/Coderr.Server.App/Core/Notifications/Commands/StoreBrowserSubscriptionHandler.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; +using Coderr.Server.Abstractions.Security; +using Coderr.Server.Api.Core.Users.Commands; +using Coderr.Server.Domain.Modules.UserNotifications; +using DotNetCqs; + +namespace Coderr.Server.App.Core.Notifications.Commands +{ + internal class StoreBrowserSubscriptionHandler : IMessageHandler + { + private readonly INotificationsRepository _notificationsRepository; + + public StoreBrowserSubscriptionHandler(INotificationsRepository notificationsRepository) + { + _notificationsRepository = notificationsRepository; + } + + public async Task HandleAsync(IMessageContext context, StoreBrowserSubscription message) + { + var subscription = new BrowserSubscription + { + AccountId = context.Principal.GetAccountId(), + AuthenticationSecret = message.AuthenticationSecret, + Endpoint = message.Endpoint, + PublicKey = message.PublicKey, + ExpiresAtUtc = message.ExpirationTime == null + ? (DateTime?) null + : DateTime.UtcNow.AddMilliseconds(message.ExpirationTime.Value) + }; + await _notificationsRepository.Save(subscription); + } + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.App/Core/Notifications/INotificationsRepository.cs b/src/Server/Coderr.Server.App/Core/Notifications/INotificationsRepository.cs index 7aa09664..2a1783cb 100644 --- a/src/Server/Coderr.Server.App/Core/Notifications/INotificationsRepository.cs +++ b/src/Server/Coderr.Server.App/Core/Notifications/INotificationsRepository.cs @@ -2,12 +2,13 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; +using Coderr.Server.Api.Core.Users.Commands; using Coderr.Server.Domain.Modules.UserNotifications; namespace Coderr.Server.App.Core.Notifications { /// - /// Repository for notification settings + /// Repository for managing notification settings /// public interface INotificationsRepository { @@ -45,5 +46,8 @@ public interface INotificationsRepository /// task /// notificationSettings Task UpdateAsync(UserNotificationSettings notificationSettings); + + Task Save(BrowserSubscription message); + Task DeleteBrowserSubscription(int accountId, string endpoint); } } \ No newline at end of file diff --git a/src/Server/Coderr.Server.App/Core/Reports/Config/ReportConfig.cs b/src/Server/Coderr.Server.App/Core/Reports/Config/ReportConfig.cs index bff3d217..e08fac04 100644 --- a/src/Server/Coderr.Server.App/Core/Reports/Config/ReportConfig.cs +++ b/src/Server/Coderr.Server.App/Core/Reports/Config/ReportConfig.cs @@ -1,7 +1,5 @@ using System.Collections.Generic; using Coderr.Server.Abstractions.Config; -using Coderr.Server.Infrastructure.Configuration; -//using Coderr.Server.ReportAnalyzer.Abstractions.ErrorReports; namespace Coderr.Server.App.Core.Reports.Config { diff --git a/src/Server/Coderr.Server.App/Core/Reports/Jobs/DeleteReportsBelowReportLimit.cs b/src/Server/Coderr.Server.App/Core/Reports/Jobs/DeleteReportsBelowReportLimit.cs index 140d6182..9634ba9f 100644 --- a/src/Server/Coderr.Server.App/Core/Reports/Jobs/DeleteReportsBelowReportLimit.cs +++ b/src/Server/Coderr.Server.App/Core/Reports/Jobs/DeleteReportsBelowReportLimit.cs @@ -1,6 +1,4 @@ -using System; using System.Data; -using System.Diagnostics; using Coderr.Server.Abstractions.Boot; using Coderr.Server.Abstractions.Config; using Coderr.Server.App.Core.Reports.Config; diff --git a/src/Server/Coderr.Server.App/Core/Users/WebApi/GetUserSettingsHandler.cs b/src/Server/Coderr.Server.App/Core/Users/WebApi/GetUserSettingsHandler.cs index 1c08e9b8..38beed89 100644 --- a/src/Server/Coderr.Server.App/Core/Users/WebApi/GetUserSettingsHandler.cs +++ b/src/Server/Coderr.Server.App/Core/Users/WebApi/GetUserSettingsHandler.cs @@ -38,7 +38,6 @@ public async Task HandleAsync(IMessageContext context, Ge NotifyOnReOpenedIncident = settings.ReopenedIncident.ConvertEnum(), NotifyOnUserFeedback = settings.UserFeedback.ConvertEnum(), NotifyOnPeaks = settings.ApplicationSpike.ConvertEnum(), - NotifyOnNewReport = settings.NewReport.ConvertEnum(), NotifyOnNewIncidents = settings.NewIncident.ConvertEnum() } }; diff --git a/src/Server/Coderr.Server.App/Core/Users/WebApi/UpdateNotificationsHandler.cs b/src/Server/Coderr.Server.App/Core/Users/WebApi/UpdateNotificationsHandler.cs index 15118ed8..e7ad28fd 100644 --- a/src/Server/Coderr.Server.App/Core/Users/WebApi/UpdateNotificationsHandler.cs +++ b/src/Server/Coderr.Server.App/Core/Users/WebApi/UpdateNotificationsHandler.cs @@ -45,7 +45,6 @@ public async Task HandleAsync(IMessageContext context, UpdateNotifications comma settings.ApplicationSpike = command.NotifyOnPeaks.ConvertEnum(); settings.NewIncident = command.NotifyOnNewIncidents.ConvertEnum(); - settings.NewReport = command.NotifyOnNewReport.ConvertEnum(); settings.ReopenedIncident = command.NotifyOnReOpenedIncident.ConvertEnum(); settings.UserFeedback = command.NotifyOnUserFeedback.ConvertEnum(); await _notificationsRepository.UpdateAsync(settings); diff --git a/src/Server/Coderr.Server.Domain/Modules/UserNotifications/BrowserSubscription.cs b/src/Server/Coderr.Server.Domain/Modules/UserNotifications/BrowserSubscription.cs new file mode 100644 index 00000000..edc4ff62 --- /dev/null +++ b/src/Server/Coderr.Server.Domain/Modules/UserNotifications/BrowserSubscription.cs @@ -0,0 +1,26 @@ +using System; + +namespace Coderr.Server.Domain.Modules.UserNotifications +{ + public class BrowserSubscription + { + public int AccountId { get; set; } + + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + + /// + /// + public string AuthenticationSecret { get; set; } + + public string Endpoint { get; set; } + + public DateTime? ExpiresAtUtc { get; set; } + + public int Id { get; set; } + + /// + /// https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/getKey + /// + public string PublicKey { get; set; } + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.Domain/Modules/UserNotifications/IUserNotificationsRepository.cs b/src/Server/Coderr.Server.Domain/Modules/UserNotifications/IUserNotificationsRepository.cs index e52caca8..07396778 100644 --- a/src/Server/Coderr.Server.Domain/Modules/UserNotifications/IUserNotificationsRepository.cs +++ b/src/Server/Coderr.Server.Domain/Modules/UserNotifications/IUserNotificationsRepository.cs @@ -16,5 +16,9 @@ public interface IUserNotificationsRepository [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures")] [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate")] Task> GetAllAsync(int applicationId); + + Task> GetSubscriptions(int accountId); + Task Delete(BrowserSubscription subscription); + } } diff --git a/src/Server/Coderr.Server.Domain/Modules/UserNotifications/NotificationState.cs b/src/Server/Coderr.Server.Domain/Modules/UserNotifications/NotificationState.cs index bdc3eff7..f1f7d431 100644 --- a/src/Server/Coderr.Server.Domain/Modules/UserNotifications/NotificationState.cs +++ b/src/Server/Coderr.Server.Domain/Modules/UserNotifications/NotificationState.cs @@ -8,21 +8,31 @@ public enum NotificationState /// /// Use global setting /// - UseGlobalSetting, + UseGlobalSetting = 1, /// /// Do not notify /// - Disabled, + Disabled = 2, /// /// By cellphone (text message) /// - Cellphone, + Cellphone = 3, /// /// By email /// - Email + Email = 4, + + /// + /// Send a browser notification to the user + /// + /// + /// + /// Requires that the user have approved it (by a javascript request). + /// + /// + BrowserNotification = 5 } } \ No newline at end of file diff --git a/src/Server/Coderr.Server.Domain/Modules/UserNotifications/UserNotificationSettings.cs b/src/Server/Coderr.Server.Domain/Modules/UserNotifications/UserNotificationSettings.cs index a1a1e903..b511683a 100644 --- a/src/Server/Coderr.Server.Domain/Modules/UserNotifications/UserNotificationSettings.cs +++ b/src/Server/Coderr.Server.Domain/Modules/UserNotifications/UserNotificationSettings.cs @@ -18,6 +18,13 @@ public UserNotificationSettings(int accountId, int applicationId) if (accountId <= 0) throw new ArgumentOutOfRangeException("accountId"); AccountId = accountId; ApplicationId = applicationId; + if (applicationId != 0) + return; + + ApplicationSpike = NotificationState.Disabled; + NewIncident = NotificationState.Disabled; + ReopenedIncident = NotificationState.Disabled; + UserFeedback = NotificationState.Disabled; } /// @@ -47,11 +54,6 @@ protected UserNotificationSettings() /// public NotificationState NewIncident { get; set; } - /// - /// Notify each time a new exception is received (no matter if it's unique or not) - /// - public NotificationState NewReport { get; set; } - /// /// Notify when we received a report for an incident that has been closed /// diff --git a/src/Server/Coderr.Server.ReportAnalyzer.Abstractions/Feedback/FeedbackAttachedToIncident.cs b/src/Server/Coderr.Server.ReportAnalyzer.Abstractions/Feedback/FeedbackAttachedToIncident.cs index aac160e7..061d998e 100644 --- a/src/Server/Coderr.Server.ReportAnalyzer.Abstractions/Feedback/FeedbackAttachedToIncident.cs +++ b/src/Server/Coderr.Server.ReportAnalyzer.Abstractions/Feedback/FeedbackAttachedToIncident.cs @@ -5,6 +5,16 @@ /// public class FeedbackAttachedToIncident { + /// + /// Application that the incident belongs to. + /// + public int ApplicationId { get; set; } + + /// + /// Name of the application that the feedback is for. + /// + public string ApplicationName { get; set; } + /// /// Incident that the feedback was attached to. /// diff --git a/src/Server/Coderr.Server.ReportAnalyzer/Feedback/Handlers/AttachFeedbackToIncident.cs b/src/Server/Coderr.Server.ReportAnalyzer/Feedback/Handlers/AttachFeedbackToIncident.cs index d4a7e152..a1bed354 100644 --- a/src/Server/Coderr.Server.ReportAnalyzer/Feedback/Handlers/AttachFeedbackToIncident.cs +++ b/src/Server/Coderr.Server.ReportAnalyzer/Feedback/Handlers/AttachFeedbackToIncident.cs @@ -25,9 +25,10 @@ public async Task HandleAsync(IMessageContext context, ReportAddedToIncident e) return; feedback.AssignToReport(e.Report.Id, e.Incident.Id, e.Incident.ApplicationId); - var evt = new FeedbackAttachedToIncident { + ApplicationId = e.Incident.ApplicationId, + ApplicationName = e.Incident.ApplicationName, IncidentId = e.Incident.Id, Message = feedback.Description, UserEmailAddress = feedback.EmailAddress diff --git a/src/Server/Coderr.Server.ReportAnalyzer/Inbound/SaveReportHandler.cs b/src/Server/Coderr.Server.ReportAnalyzer/Inbound/SaveReportHandler.cs index e0509ae9..bb837c94 100644 --- a/src/Server/Coderr.Server.ReportAnalyzer/Inbound/SaveReportHandler.cs +++ b/src/Server/Coderr.Server.ReportAnalyzer/Inbound/SaveReportHandler.cs @@ -182,8 +182,9 @@ private async Task GetAppAsync(string appKey) { using (var cmd = _unitOfWork.CreateDbCommand()) { - cmd.CommandText = "SELECT Id, SharedSecret FROM Applications WHERE AppKey = @key"; + cmd.CommandText = "SELECT Id, SharedSecret FROM Applications WHERE AppKey = @key OR AppKey = @key2"; cmd.AddParameter("key", appKey); + cmd.AddParameter("key2", appKey.Replace("-", "")); using (var reader = await cmd.ExecuteReaderAsync()) { if (!await reader.ReadAsync()) diff --git a/src/Server/Coderr.Server.ReportAnalyzer/ReportSpikes/Handlers/CheckForReportPeak.cs b/src/Server/Coderr.Server.ReportAnalyzer/ReportSpikes/Handlers/CheckForReportPeak.cs index db725d55..63a8b0ee 100644 --- a/src/Server/Coderr.Server.ReportAnalyzer/ReportSpikes/Handlers/CheckForReportPeak.cs +++ b/src/Server/Coderr.Server.ReportAnalyzer/ReportSpikes/Handlers/CheckForReportPeak.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Coderr.Client; using Coderr.Server.Abstractions.Boot; using Coderr.Server.Abstractions.Config; using Coderr.Server.Api.Core.Messaging; @@ -10,9 +11,12 @@ using Coderr.Server.Domain.Modules.UserNotifications; using Coderr.Server.Infrastructure.Configuration; using Coderr.Server.ReportAnalyzer.Abstractions.Incidents; +using Coderr.Server.ReportAnalyzer.UserNotifications; +using Coderr.Server.ReportAnalyzer.UserNotifications.Dtos; using Coderr.Server.ReportAnalyzer.UserNotifications.Handlers; using DotNetCqs; using log4net; +using Newtonsoft.Json; namespace Coderr.Server.ReportAnalyzer.ReportSpikes.Handlers { @@ -23,7 +27,7 @@ public class CheckForReportPeak : IMessageHandler private readonly IUserNotificationsRepository _repository; private readonly IReportSpikeRepository _spikeRepository; private readonly BaseConfiguration _baseConfiguration; - private ILog _log = LogManager.GetLogger(typeof(CheckForReportPeak)); + private readonly INotificationService _notificationService; /// /// Creates a new instance of . @@ -31,10 +35,11 @@ public class CheckForReportPeak : IMessageHandler /// To check if spikes should be analyzed /// store/fetch information of current spikes. /// - public CheckForReportPeak(IUserNotificationsRepository repository, IReportSpikeRepository spikeRepository, IConfiguration baseConfiguration) + public CheckForReportPeak(IUserNotificationsRepository repository, IReportSpikeRepository spikeRepository, IConfiguration baseConfiguration, INotificationService notificationService) { _repository = repository; _spikeRepository = spikeRepository; + _notificationService = notificationService; _baseConfiguration = baseConfiguration.Value; } @@ -49,23 +54,18 @@ public async Task HandleAsync(IMessageContext context, ReportAddedToIncident e) { if (e == null) throw new ArgumentNullException("e"); - _log.Info("ReportId: " + e.Report.Id); - - var url = _baseConfiguration.BaseUrl; var notificationSettings = (await _repository.GetAllAsync(e.Report.ApplicationId)).ToList(); if (notificationSettings.All(x => x.ApplicationSpike == NotificationState.Disabled)) return; - var todaysCount = await CalculateSpike(e); - if (todaysCount == null) + var countToday = await CalculateSpike(e); + if (countToday == null) return; var spike = await _spikeRepository.GetSpikeAsync(e.Incident.ApplicationId); - if (spike != null) - spike.IncreaseReportCount(); + spike?.IncreaseReportCount(); var existed = spike != null; - var messages = new List(); foreach (var setting in notificationSettings) { if (setting.ApplicationSpike == NotificationState.Disabled) @@ -78,29 +78,72 @@ public async Task HandleAsync(IMessageContext context, ReportAddedToIncident e) spike = new ErrorReportSpike(e.Incident.ApplicationId, 1); spike.AddNotifiedAccount(setting.AccountId); - var msg = new EmailMessage(setting.AccountId.ToString()) - { - Subject = $"Spike detected for {e.Incident.ApplicationName} ({todaysCount} reports)", - HtmlBody = - $"We've detected a spike in incoming reports for application {e.Incident.ApplicationName}\r\n" + - "\r\n" + - $"We've received {todaysCount.SpikeCount} reports so far. Day average is {todaysCount.DayAverage}\r\n" + - "\r\n" + "No further spike emails will be sent today for this application." - }; - messages.Add(msg); + if (setting.ApplicationSpike == NotificationState.Email) + { + var msg = BuildEmail(e, setting, countToday); + var cmd = new SendEmail(msg); + await context.SendAsync(cmd); + } + else + { + var notification = BuildBrowserNotification(e, countToday, setting); + try + { + await _notificationService.SendBrowserNotification(setting.AccountId, notification); + } + catch (Exception ex) + { + Err.Report(ex, new {setting, notification}); + } + } } if (existed) await _spikeRepository.UpdateSpikeAsync(spike); else await _spikeRepository.CreateSpikeAsync(spike); + } + + private Notification BuildBrowserNotification(ReportAddedToIncident e, NewSpike countToday, + UserNotificationSettings setting) + { + var notification = + new Notification( + $"Coderr have received {countToday.SpikeCount} reports so far. Day average is {countToday.DayAverage}.") + { + Title = $"Spike detected for {e.Incident.ApplicationName}", + Actions = new List + { + new NotificationAction + { + Title = "View", + Action = "discoverApplication" + } + }, + Data = new + { + applicationId = e.Incident.ApplicationId, + accountId = setting.AccountId, + discoverApplicationUrlk = $"{_baseConfiguration.BaseUrl}/discover/{e.Incident.ApplicationId}" + }, + Timestamp = e.Report.CreatedAtUtc + }; + return notification; + } - foreach (var message in messages) + private EmailMessage BuildEmail(ReportAddedToIncident e, UserNotificationSettings setting, NewSpike countToday) + { + var msg = new EmailMessage(setting.AccountId.ToString()) { - var sendEmail = new SendEmail(message); - await context.SendAsync(sendEmail); - } + Subject = $"Spike detected for {e.Incident.ApplicationName} ({countToday} reports)", + HtmlBody = + $"We've detected a spike in incoming reports for application {e.Incident.ApplicationName}\r\n" + + "\r\n" + + $"We've received {countToday.SpikeCount} reports so far. Day average is {countToday.DayAverage}\r\n" + + "\r\n" + "No further spike emails will be sent today for this application." + }; + return msg; } /// @@ -110,14 +153,14 @@ public async Task HandleAsync(IMessageContext context, ReportAddedToIncident e) /// -1 if no spike is detected; otherwise the spike count protected async Task CalculateSpike(ReportAddedToIncident applicationEvent) { - if (applicationEvent == null) throw new ArgumentNullException("applicationEvent"); + if (applicationEvent == null) throw new ArgumentNullException(nameof(applicationEvent)); var average = await _spikeRepository.GetAverageReportCountAsync(applicationEvent.Incident.ApplicationId); if (average == 0) return null; var todaysCount = await _spikeRepository.GetTodaysCountAsync(applicationEvent.Incident.ApplicationId); - var threshold = average > 20 ? average * 1.2 : average * 2; + var threshold = average > 100 ? average * 1.2 : average * 2; if (todaysCount < threshold) return null; diff --git a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/BrowserNotificationConfig.cs b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/BrowserNotificationConfig.cs new file mode 100644 index 00000000..27fdef02 --- /dev/null +++ b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/BrowserNotificationConfig.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using Coderr.Server.Abstractions.Config; + +namespace Coderr.Server.ReportAnalyzer.UserNotifications +{ + /// + /// Configuration settings for browser notifications. + /// + public class BrowserNotificationConfig : IConfigurationSection//, IReportConfig + { + /// + /// Creates a new instance of + /// + public BrowserNotificationConfig() + { + } + + public string PublicKey { get; set; } + + public string PrivateKey { get; set; } + + /// + /// Number of days to store reports. + /// + /// + /// All reports older than this amount of days will be deleted. + /// + public int RetentionDays { get; set; } + + string IConfigurationSection.SectionName => "BrowserNotificationConfig"; + + IDictionary IConfigurationSection.ToDictionary() + { + return this.ToConfigDictionary(); + } + + void IConfigurationSection.Load(IDictionary settings) + { + this.AssignProperties(settings); + } + } +} diff --git a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Dtos/Notification.cs b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Dtos/Notification.cs new file mode 100644 index 00000000..5a7c7093 --- /dev/null +++ b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Dtos/Notification.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Coderr.Server.ReportAnalyzer.UserNotifications.Dtos +{ + /// + /// Notification API Standard + /// + public class Notification + { + public Notification() + { + } + + public Notification(string text) + { + Body = text; + } + + [JsonProperty("actions")] + public List Actions { get; set; } = + new List(); + + [JsonProperty("data", TypeNameHandling = TypeNameHandling.None)] + public object Data { get; set; } + + [JsonProperty("badge")] public string Badge { get; set; } + + [JsonProperty("body")] public string Body { get; set; } + + [JsonProperty("icon")] public string Icon { get; set; } + + [JsonProperty("image")] public string Image { get; set; } + + [JsonProperty("lang")] public string Lang { get; set; } = "en"; + + [JsonProperty("requireInteraction")] public bool RequireInteraction { get; set; } + + [JsonProperty("tag")] public string Tag { get; set; } + + [JsonProperty("timestamp")] public DateTime Timestamp { get; set; } = DateTime.Now; + + [JsonProperty("title")] public string Title { get; set; } = "Push Demo"; + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Dtos/NotificationAction.cs b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Dtos/NotificationAction.cs new file mode 100644 index 00000000..549462e4 --- /dev/null +++ b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Dtos/NotificationAction.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace Coderr.Server.ReportAnalyzer.UserNotifications.Dtos +{ + /// + /// Notification API Standard + /// + public class NotificationAction + { + + [JsonProperty("action")] + public string Action { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Handlers/CheckForNotificationsToSend.cs b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Handlers/CheckForNotificationsToSend.cs index 50d03c10..fe737ae5 100644 --- a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Handlers/CheckForNotificationsToSend.cs +++ b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Handlers/CheckForNotificationsToSend.cs @@ -6,8 +6,9 @@ using Coderr.Server.ReportAnalyzer.Abstractions.Incidents; using Coderr.Server.ReportAnalyzer.UserNotifications.Handlers.Tasks; using DotNetCqs; -using Coderr.Server.Abstractions.Boot; using Coderr.Server.Abstractions.Config; +using Coderr.Server.Api.Core.Incidents.Queries; +using Coderr.Server.ReportAnalyzer.UserNotifications.Dtos; using log4net; namespace Coderr.Server.ReportAnalyzer.UserNotifications.Handlers @@ -20,7 +21,8 @@ public class CheckForNotificationsToSend : IMessageHandler /// Creates a new instance of . @@ -28,10 +30,11 @@ public class CheckForNotificationsToSend : IMessageHandlerTo load notification configuration /// To load user info public CheckForNotificationsToSend(IUserNotificationsRepository notificationsRepository, - IUserRepository userRepository, IConfiguration configuration) + IUserRepository userRepository, IConfiguration configuration, INotificationService notificationService) { _notificationsRepository = notificationsRepository; _userRepository = userRepository; + _notificationService = notificationService; _configuration = configuration.Value; } @@ -44,21 +47,17 @@ public CheckForNotificationsToSend(IUserNotificationsRepository notificationsRep /// public async Task HandleAsync(IMessageContext context, ReportAddedToIncident e) { - if (e == null) throw new ArgumentNullException("e"); + if (e == null) throw new ArgumentNullException(nameof(e)); _log.Info("ReportId: " + e.Report.Id); var settings = await _notificationsRepository.GetAllAsync(e.Incident.ApplicationId); foreach (var setting in settings) { - if (setting.NewIncident != NotificationState.Disabled && e.Incident.ReportCount == 1) + if (setting.NewIncident != NotificationState.Disabled && e.IsNewIncident == true) { await CreateNotification(context, e, setting.AccountId, setting.NewIncident); } - else if (setting.NewReport != NotificationState.Disabled) - { - await CreateNotification(context, e, setting.AccountId, setting.NewReport); - } else if (setting.ReopenedIncident != NotificationState.Disabled && e.IsReOpened) { await CreateNotification(context, e, setting.AccountId, setting.ReopenedIncident); @@ -69,7 +68,32 @@ public async Task HandleAsync(IMessageContext context, ReportAddedToIncident e) private async Task CreateNotification(IMessageContext context, ReportAddedToIncident e, int accountId, NotificationState state) { - if (state == NotificationState.Email) + if (state == NotificationState.BrowserNotification) + { + var notification = new Notification($"Application: {e.Incident.ApplicationName}\r\n{e.Incident.Name}"); + notification.Actions.Add(new NotificationAction() + { + Title = "Assign to me", + Action = "AssignToMe" + }); + notification.Actions.Add(new NotificationAction() + { + Title = "View", + Action = "View" + }); + notification.Icon = "/favicon-32x32.png"; + notification.Timestamp = e.Report.CreatedAtUtc; + notification.Title = e.IsNewIncident == true + ? "New incident" + : "Re-opened incident"; + notification.Data = new + { + applicationId = e.Incident.ApplicationId, + incidentId = e.Incident.Id + }; + await _notificationService.SendBrowserNotification(accountId, notification); + } + else if (state == NotificationState.Email) { var email = new SendIncidentEmail(_configuration); await email.SendAsync(context, accountId.ToString(), e.Incident, e.Report); diff --git a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Handlers/Tasks/SendIncidentEmail.cs b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Handlers/Tasks/SendIncidentEmail.cs index de7fa094..f7bc6a74 100644 --- a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Handlers/Tasks/SendIncidentEmail.cs +++ b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/Handlers/Tasks/SendIncidentEmail.cs @@ -41,11 +41,8 @@ public async Task SendAsync(IMessageContext context, string idOrEmailAddress, In : incident.Name; var pos = shortName.IndexOfAny(new[] {'\r', '\n'}); - if (pos != -1) - { - shortName = shortName.Substring(0, pos) + "[...]"; - } - + if (pos != -1) shortName = shortName.Substring(0, pos) + "[...]"; + var baseUrl = _baseConfiguration.BaseUrl.ToString().TrimEnd('/'); var incidentUrl = diff --git a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/INotificationService.cs b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/INotificationService.cs new file mode 100644 index 00000000..41dfb4df --- /dev/null +++ b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/INotificationService.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using Coderr.Server.ReportAnalyzer.UserNotifications.Dtos; + +namespace Coderr.Server.ReportAnalyzer.UserNotifications +{ + /// + /// Used to send notifications to users. + /// + public interface INotificationService + { + /// + /// Send a browser notification (requires that the user first have approved notifications through javascript). + /// + /// Account to send to + /// Notification details + /// + Task SendBrowserNotification(int accountId, Notification notification); + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/IWebPushClient.cs b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/IWebPushClient.cs new file mode 100644 index 00000000..549fa496 --- /dev/null +++ b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/IWebPushClient.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Coderr.Server.Domain.Modules.UserNotifications; +using Coderr.Server.ReportAnalyzer.UserNotifications.Dtos; + +namespace Coderr.Server.ReportAnalyzer.UserNotifications +{ + /// + /// Abstraction for the web push implementation. + /// + public interface IWebPushClient + { + /// + /// Send the notification to the push service of the browser. + /// + /// User subscription + /// Information to send. + /// + Task SendNotification(BrowserSubscription subscription, Notification notification); + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/InvalidSubscriptionException.cs b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/InvalidSubscriptionException.cs new file mode 100644 index 00000000..f85a6529 --- /dev/null +++ b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/InvalidSubscriptionException.cs @@ -0,0 +1,21 @@ +using System; + +namespace Coderr.Server.ReportAnalyzer.UserNotifications +{ + /// + /// Browser subscription is not valid (remove it). + /// + /// + /// + /// TODO: Notify the user that it was removed. + /// + /// + public class InvalidSubscriptionException : Exception + { + /// + public InvalidSubscriptionException(string errorMessage, Exception inner) + : base(errorMessage, inner) + { + } + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/NotificationService.cs b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/NotificationService.cs new file mode 100644 index 00000000..40489fdd --- /dev/null +++ b/src/Server/Coderr.Server.ReportAnalyzer/UserNotifications/NotificationService.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using Coderr.Server.Abstractions.Boot; +using Coderr.Server.Domain.Modules.UserNotifications; +using Coderr.Server.ReportAnalyzer.UserNotifications.Dtos; +using log4net; + +namespace Coderr.Server.ReportAnalyzer.UserNotifications +{ + /// + /// Implementation of . + /// + [ContainerService] + public class NotificationService : INotificationService + { + private readonly IWebPushClient _client; + private readonly ILog _logger = LogManager.GetLogger(typeof(NotificationService)); + private readonly IUserNotificationsRepository _repository; + + public NotificationService(IUserNotificationsRepository repository, IWebPushClient client) + { + _repository = repository; + _client = client; + } + + public async Task SendBrowserNotification(int accountId, Notification notification) + { + if (notification == null) throw new ArgumentNullException(nameof(notification)); + if (accountId <= 0) throw new ArgumentOutOfRangeException(nameof(accountId)); + + var subscriptions = await _repository.GetSubscriptions(accountId); + foreach (var subscription in subscriptions) + { + try + { + await _client.SendNotification(subscription, notification); + } + catch (InvalidSubscriptionException e) + { + _logger.Error("Failed to send notification to " + subscription.AccountId, e); + await _repository.Delete(subscription); + } + catch (Exception e) + { + _logger.Error("Failed to send notification to " + subscription.AccountId, e); + } + } + } + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.SqlServer/Coderr.Server.SqlServer.csproj b/src/Server/Coderr.Server.SqlServer/Coderr.Server.SqlServer.csproj index f265a341..a3f0f788 100644 --- a/src/Server/Coderr.Server.SqlServer/Coderr.Server.SqlServer.csproj +++ b/src/Server/Coderr.Server.SqlServer/Coderr.Server.SqlServer.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Server/Coderr.Server.SqlServer/Core/Accounts/QueryHandlers/GetAccountEmailByIdHandler.cs b/src/Server/Coderr.Server.SqlServer/Core/Accounts/QueryHandlers/GetAccountEmailByIdHandler.cs index 78b3d386..7b379eb9 100644 --- a/src/Server/Coderr.Server.SqlServer/Core/Accounts/QueryHandlers/GetAccountEmailByIdHandler.cs +++ b/src/Server/Coderr.Server.SqlServer/Core/Accounts/QueryHandlers/GetAccountEmailByIdHandler.cs @@ -1,12 +1,13 @@ using System; using System.Threading.Tasks; +using Coderr.Server.Abstractions.Boot; using Coderr.Server.Api.Core.Accounts.Queries; using Coderr.Server.Domain.Core.Account; using DotNetCqs; -using Coderr.Server.ReportAnalyzer.Abstractions; namespace Coderr.Server.SqlServer.Core.Accounts.QueryHandlers { + [ContainerService] public class GetAccountEmailByIdHandler : IQueryHandler { private readonly IAccountRepository _accountRepository; diff --git a/src/Server/Coderr.Server.SqlServer/Core/Feedback/CheckForFeedbackNotificationsToSend.cs b/src/Server/Coderr.Server.SqlServer/Core/Feedback/CheckForFeedbackNotificationsToSend.cs index e3eeb2d8..66a47b79 100644 --- a/src/Server/Coderr.Server.SqlServer/Core/Feedback/CheckForFeedbackNotificationsToSend.cs +++ b/src/Server/Coderr.Server.SqlServer/Core/Feedback/CheckForFeedbackNotificationsToSend.cs @@ -1,4 +1,7 @@ -using System.Threading.Tasks; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Coderr.Client; using Coderr.Server.Abstractions.Config; using Coderr.Server.Api.Core.Accounts.Queries; using Coderr.Server.Api.Core.Messaging; @@ -7,6 +10,8 @@ using Coderr.Server.Domain.Modules.UserNotifications; using Coderr.Server.Infrastructure.Configuration; using Coderr.Server.ReportAnalyzer.Abstractions.Feedback; +using Coderr.Server.ReportAnalyzer.UserNotifications; +using Coderr.Server.ReportAnalyzer.UserNotifications.Dtos; using Coderr.Server.ReportAnalyzer.UserNotifications.Handlers; using DotNetCqs; @@ -20,18 +25,20 @@ public class CheckForFeedbackNotificationsToSend : IMessageHandler { private readonly IUserNotificationsRepository _notificationsRepository; - private ConfigurationStore _configStore; - private IIncidentRepository _incidentRepository; + private readonly string _baseUrl; + private readonly IIncidentRepository _incidentRepository; + private readonly INotificationService _notificationService; /// /// Creates a new instance of . /// /// To load notification configuration - public CheckForFeedbackNotificationsToSend(IUserNotificationsRepository notificationsRepository, ConfigurationStore configStore, IIncidentRepository incidentRepository) + public CheckForFeedbackNotificationsToSend(IUserNotificationsRepository notificationsRepository, IConfiguration baseConfig, IIncidentRepository incidentRepository, INotificationService notificationService) { _notificationsRepository = notificationsRepository; - _configStore = configStore; + _baseUrl = baseConfig.Value.BaseUrl.ToString().Trim('/'); _incidentRepository = incidentRepository; + _notificationService = notificationService; } /// @@ -45,34 +52,76 @@ public async Task HandleAsync(IMessageContext context, FeedbackAttachedToInciden continue; var notificationEmail = await context.QueryAsync(new GetAccountEmailById(setting.AccountId)); - var config = _configStore.Load(); var shortName = incident.Description.Length > 40 ? incident.Description.Substring(0, 40) + "..." : incident.Description; if (string.IsNullOrEmpty(e.UserEmailAddress)) - e.UserEmailAddress = "unknown"; + e.UserEmailAddress = "Unknown"; - var incidentUrl = string.Format("{0}/discover/{1}/incident/{2}", - config.BaseUrl.ToString().TrimEnd('/'), - incident.ApplicationId, - incident.Id); + var incidentUrl = $"{_baseUrl}/discover/{incident.ApplicationId}/incident/{incident.Id}"; - //TODO: Add more information - var msg = new EmailMessage(notificationEmail); - msg.Subject = "New feedback: " + shortName; - msg.TextBody = string.Format(@"Incident: {0} -Feedback: {0}/feedback -From: {1} + if (setting.UserFeedback == NotificationState.Email) + { + await SendEmail(context, e, notificationEmail, shortName, incidentUrl); + } + else if (setting.UserFeedback == NotificationState.BrowserNotification) + { + var msg = $@"Incident: {shortName} +From: {e.UserEmailAddress} +Application: {e.ApplicationName} +{e.Message}"; + var notification = new Notification(msg) + { + Title = "New feedback", + Data = new + { + viewFeedbackUrl = $"{incidentUrl}/feedback", + incidentId = e.IncidentId, + applicationId = incident.ApplicationId + }, + Timestamp = DateTime.UtcNow, + Actions = new List + { + new NotificationAction + { + Title = "View", + Action = "viewFeedback" + } + } + }; + try + { + await _notificationService.SendBrowserNotification(setting.AccountId, notification); + } + catch (Exception ex) + { + Err.Report(ex, new { notification, setting }); + } -{2} -", incidentUrl, e.UserEmailAddress, e.Message); + } - - var emailCmd = new SendEmail(msg); - await context.SendAsync(emailCmd); } } + + private static async Task SendEmail(IMessageContext context, FeedbackAttachedToIncident e, string notificationEmail, + string shortName, string incidentUrl) + { + //TODO: Add more information + var msg = new EmailMessage(notificationEmail) + { + Subject = "New feedback: " + shortName, + TextBody = $@"Incident: {incidentUrl} +Feedback: {incidentUrl}/feedback +From: {e.UserEmailAddress} + +{e.Message}" + }; + + + var emailCmd = new SendEmail(msg); + await context.SendAsync(emailCmd); + } } } \ No newline at end of file diff --git a/src/Server/Coderr.Server.SqlServer/Core/Feedback/SubmitFeedbackHandler.cs b/src/Server/Coderr.Server.SqlServer/Core/Feedback/SubmitFeedbackHandler.cs index 66fee2b9..97f58a08 100644 --- a/src/Server/Coderr.Server.SqlServer/Core/Feedback/SubmitFeedbackHandler.cs +++ b/src/Server/Coderr.Server.SqlServer/Core/Feedback/SubmitFeedbackHandler.cs @@ -2,6 +2,7 @@ using System.Data.Common; using System.Threading.Tasks; using Coderr.Server.Api.Core.Feedback.Commands; +using Coderr.Server.Domain.Core.Applications; using Coderr.Server.Domain.Core.ErrorReports; using Coderr.Server.ReportAnalyzer.Abstractions.Feedback; using DotNetCqs; @@ -12,14 +13,17 @@ namespace Coderr.Server.SqlServer.Core.Feedback { public class SubmitFeedbackHandler : IMessageHandler { + private readonly IApplicationRepository _applicationRepository; private readonly ILog _logger = LogManager.GetLogger(typeof(SubmitFeedbackHandler)); private readonly IReportsRepository _reportsRepository; private readonly IAdoNetUnitOfWork _unitOfWork; - public SubmitFeedbackHandler(IAdoNetUnitOfWork unitOfWork, IReportsRepository reportsRepository) + public SubmitFeedbackHandler(IAdoNetUnitOfWork unitOfWork, IReportsRepository reportsRepository, + IApplicationRepository applicationRepository) { _unitOfWork = unitOfWork; _reportsRepository = reportsRepository; + _applicationRepository = applicationRepository; } public async Task HandleAsync(IMessageContext context, SubmitFeedback command) @@ -49,10 +53,7 @@ public async Task HandleAsync(IMessageContext context, SubmitFeedback command) else { report2 = await _reportsRepository.FindByErrorIdAsync(command.ErrorId); - if (report2 == null) - { - _logger.Warn("Failed to find report by error id: " + command.ErrorId); - } + if (report2 == null) _logger.Warn("Failed to find report by error id: " + command.ErrorId); } // storing it without connections as the report might not have been uploaded yet. @@ -65,9 +66,10 @@ public async Task HandleAsync(IMessageContext context, SubmitFeedback command) { using (var cmd = _unitOfWork.CreateCommand()) { - cmd.CommandText = "INSERT INTO IncidentFeedback (ErrorReportId, RemoteAddress, Description, EmailAddress, CreatedAtUtc, Conversation, ConversationLength) " - + - "VALUES (@ErrorReportId, @RemoteAddress, @Description, @EmailAddress, @CreatedAtUtc, '', 0)"; + cmd.CommandText = + "INSERT INTO IncidentFeedback (ErrorReportId, RemoteAddress, Description, EmailAddress, CreatedAtUtc, Conversation, ConversationLength) " + + + "VALUES (@ErrorReportId, @RemoteAddress, @Description, @EmailAddress, @CreatedAtUtc, '', 0)"; cmd.AddParameter("ErrorReportId", command.ErrorId); cmd.AddParameter("RemoteAddress", command.RemoteAddress); cmd.AddParameter("Description", command.Feedback); @@ -75,6 +77,7 @@ public async Task HandleAsync(IMessageContext context, SubmitFeedback command) cmd.AddParameter("CreatedAtUtc", DateTime.UtcNow); cmd.ExecuteNonQuery(); } + _logger.Info("** STORING FEEDBACK"); } catch (Exception exception) @@ -87,11 +90,12 @@ public async Task HandleAsync(IMessageContext context, SubmitFeedback command) return; } - using (var cmd = (DbCommand) _unitOfWork.CreateCommand()) + using (var cmd = (DbCommand)_unitOfWork.CreateCommand()) { - cmd.CommandText = "INSERT INTO IncidentFeedback (ErrorReportId, ApplicationId, ReportId, IncidentId, RemoteAddress, Description, EmailAddress, CreatedAtUtc, Conversation, ConversationLength) " - + - "VALUES (@ErrorReportId, @ApplicationId, @ReportId, @IncidentId, @RemoteAddress, @Description, @EmailAddress, @CreatedAtUtc, @Conversation, 0)"; + cmd.CommandText = + "INSERT INTO IncidentFeedback (ErrorReportId, ApplicationId, ReportId, IncidentId, RemoteAddress, Description, EmailAddress, CreatedAtUtc, Conversation, ConversationLength) " + + + "VALUES (@ErrorReportId, @ApplicationId, @ReportId, @IncidentId, @RemoteAddress, @Description, @EmailAddress, @CreatedAtUtc, @Conversation, 0)"; cmd.AddParameter("ErrorReportId", command.ErrorId); cmd.AddParameter("ApplicationId", report2.ApplicationId); cmd.AddParameter("ReportId", reportId); @@ -102,15 +106,17 @@ public async Task HandleAsync(IMessageContext context, SubmitFeedback command) cmd.AddParameter("Conversation", ""); cmd.AddParameter("CreatedAtUtc", DateTime.UtcNow); + var app = await _applicationRepository.GetByIdAsync(report2.ApplicationId); var evt = new FeedbackAttachedToIncident { + ApplicationId = report2.ApplicationId, + ApplicationName = app.Name, Message = command.Feedback, UserEmailAddress = command.Email, IncidentId = report2.IncidentId }; await context.SendAsync(evt); - _logger.Info("** STORING FEEDBACK"); await cmd.ExecuteNonQueryAsync(); } } diff --git a/src/Server/Coderr.Server.SqlServer/Core/Notifications/BrowserSubscriptionMapper.cs b/src/Server/Coderr.Server.SqlServer/Core/Notifications/BrowserSubscriptionMapper.cs new file mode 100644 index 00000000..ee0fc686 --- /dev/null +++ b/src/Server/Coderr.Server.SqlServer/Core/Notifications/BrowserSubscriptionMapper.cs @@ -0,0 +1,14 @@ +using Coderr.Server.Domain.Modules.UserNotifications; +using Griffin.Data.Mapper; + +namespace Coderr.Server.SqlServer.Core.Notifications +{ + internal class BrowserSubscriptionMapper : CrudEntityMapper + { + public BrowserSubscriptionMapper() : base("NotificationsBrowser") + { + Property(x => x.Id) + .PrimaryKey(true); + } + } +} \ No newline at end of file diff --git a/src/Server/Coderr.Server.SqlServer/Core/Notifications/NotificationRepository.cs b/src/Server/Coderr.Server.SqlServer/Core/Notifications/NotificationRepository.cs index 00948b82..587ccc4e 100644 --- a/src/Server/Coderr.Server.SqlServer/Core/Notifications/NotificationRepository.cs +++ b/src/Server/Coderr.Server.SqlServer/Core/Notifications/NotificationRepository.cs @@ -5,7 +5,6 @@ using Coderr.Server.Abstractions.Boot; using Coderr.Server.App.Core.Notifications; using Coderr.Server.Domain.Modules.UserNotifications; -using Coderr.Server.ReportAnalyzer.Abstractions; using Griffin.Data; using Griffin.Data.Mapper; @@ -26,11 +25,7 @@ public async Task TryGetAsync(int accountId, int appli if (applicationId == 0) applicationId = -1; return await _unitOfWork.FirstOrDefaultAsync( - new - { - AccountId = accountId, - ApplicationId = applicationId - }); + new {AccountId = accountId, ApplicationId = applicationId}); } public async Task UpdateAsync(UserNotificationSettings notificationSettings) @@ -40,27 +35,48 @@ public async Task UpdateAsync(UserNotificationSettings notificationSettings) await _unitOfWork.UpdateAsync(notificationSettings); } - public async Task CreateAsync(UserNotificationSettings notificationSettings) + public async Task> GetSubscriptions(int accountId) { - if (notificationSettings.ApplicationId == 0) - notificationSettings.ApplicationId = -1; - await _unitOfWork.InsertAsync(notificationSettings); + return await _unitOfWork.ToListAsync( + "SELECT * FROM NotificationsBrowser WHERE AccountId = @id", + new {id = accountId}); } - public async Task ExistsAsync(int accountId, int applicationId) + public async Task Delete(BrowserSubscription subscription) { - if (applicationId == 0) - applicationId = -1; - using (var cmd = (DbCommand) _unitOfWork.CreateCommand()) + await _unitOfWork.DeleteAsync(subscription); + } + + public async Task Save(BrowserSubscription message) + { + var existing = + await _unitOfWork.FirstOrDefaultAsync(new {message.Endpoint, message.AccountId}); + + if (existing != null) { - cmd.CommandText = - "SELECT TOP 1 AccountId FROM UserNotificationSettings WHERE AccountId = @id AND ApplicationId = @appId"; - cmd.AddParameter("id", accountId); - cmd.AddParameter("appId", applicationId); - return await cmd.ExecuteScalarAsync() != null; + existing.PublicKey = message.PublicKey; + existing.AuthenticationSecret = message.AuthenticationSecret; + existing.ExpiresAtUtc = message.ExpiresAtUtc; + await _unitOfWork.UpdateAsync(existing); + } + else + { + await _unitOfWork.InsertAsync(message); } } + public async Task DeleteBrowserSubscription(int accountId, string endpoint) + { + await _unitOfWork.DeleteAsync(new {AccountId = accountId, Endpoint = endpoint}); + } + + public async Task CreateAsync(UserNotificationSettings notificationSettings) + { + if (notificationSettings.ApplicationId == 0) + notificationSettings.ApplicationId = -1; + await _unitOfWork.InsertAsync(notificationSettings); + } + public async Task> GetAllAsync(int applicationId) { var sql = @@ -73,12 +89,49 @@ FROM UserNotificationSettings var dict = new Dictionary(); foreach (var setting in settings) { - if (dict.ContainsKey(setting.AccountId)) + if (!dict.TryGetValue(setting.AccountId, out var appSettings)) + { + dict[setting.AccountId] = setting; continue; - dict[setting.AccountId] = setting; + } + + // We got the most specific settings thanks to the ASC sort. + // now check if the app settings have "use global" + + if (appSettings.ApplicationSpike == NotificationState.UseGlobalSetting) + appSettings.ApplicationSpike = setting.ApplicationSpike; + + if (appSettings.NewIncident == NotificationState.UseGlobalSetting) + appSettings.NewIncident = setting.NewIncident; + + if (appSettings.ReopenedIncident == NotificationState.UseGlobalSetting) + appSettings.ReopenedIncident = setting.ReopenedIncident; + + if (appSettings.UserFeedback == NotificationState.UseGlobalSetting) + appSettings.UserFeedback = setting.UserFeedback; + + if (appSettings.WeeklySummary == NotificationState.UseGlobalSetting) + appSettings.WeeklySummary = setting.WeeklySummary; + + if (appSettings.ReopenedIncident == NotificationState.UseGlobalSetting) + appSettings.ReopenedIncident = setting.ReopenedIncident; } return dict.Values.ToList(); } + + public async Task ExistsAsync(int accountId, int applicationId) + { + if (applicationId == 0) + applicationId = -1; + using (var cmd = (DbCommand) _unitOfWork.CreateCommand()) + { + cmd.CommandText = + "SELECT TOP 1 AccountId FROM UserNotificationSettings WHERE AccountId = @id AND ApplicationId = @appId"; + cmd.AddParameter("id", accountId); + cmd.AddParameter("appId", applicationId); + return await cmd.ExecuteScalarAsync() != null; + } + } } } \ No newline at end of file diff --git a/src/Server/Coderr.Server.SqlServer/Core/Notifications/UserNotificationSettingsMap.cs b/src/Server/Coderr.Server.SqlServer/Core/Notifications/UserNotificationSettingsMap.cs index c1302ab1..181efe93 100644 --- a/src/Server/Coderr.Server.SqlServer/Core/Notifications/UserNotificationSettingsMap.cs +++ b/src/Server/Coderr.Server.SqlServer/Core/Notifications/UserNotificationSettingsMap.cs @@ -17,9 +17,6 @@ public UserNotificationSettingsMap() Property(x => x.ReopenedIncident) .ToColumnValue(StringToEnum) .ToPropertyValue(EnumToString); - Property(x => x.NewReport) - .ToColumnValue(StringToEnum) - .ToPropertyValue(EnumToString); Property(x => x.UserFeedback) .ToColumnValue(StringToEnum) .ToPropertyValue(EnumToString); diff --git a/src/Server/Coderr.Server.SqlServer/Modules/ReportSpikes/ReportSpikesRepository.cs b/src/Server/Coderr.Server.SqlServer/Modules/ReportSpikes/ReportSpikesRepository.cs index 65c60b3f..423e75fc 100644 --- a/src/Server/Coderr.Server.SqlServer/Modules/ReportSpikes/ReportSpikesRepository.cs +++ b/src/Server/Coderr.Server.SqlServer/Modules/ReportSpikes/ReportSpikesRepository.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Coderr.Server.Abstractions.Boot; using Coderr.Server.Domain.Modules.ReportSpikes; -using Coderr.Server.ReportAnalyzer.Abstractions; using Griffin.Data; using Griffin.Data.Mapper; @@ -27,12 +26,12 @@ public virtual async Task GetAverageReportCountAsync(int applicationId) using (var cmd = (DbCommand) _unitOfWork.CreateCommand()) { cmd.CommandText = @"SELECT - [Day] = DATENAME(WEEKDAY, createdatutc), - Totals = cast (COUNT(*) as int) - FROM errorreports - WHERE applicationid=@appId - GROUP BY - DATENAME(WEEKDAY, createdatutc)"; + [Day] = DATENAME(WEEKDAY, ReceivedAtUtc), + Totals = cast (COUNT(IncidentReports.Id) as int) + FROM IncidentReports + JOIN Incidents ON (Incidents.Id = IncidentReports.IncidentId) + WHERE applicationid = @appid + GROUP BY DATENAME(WEEKDAY, ReceivedAtUtc)"; cmd.AddParameter("appId", applicationId); var numbers = new List(); using (var reader = await cmd.ExecuteReaderAsync()) diff --git a/src/Server/Coderr.Server.SqlServer/Schema/Coderr.v22.sql b/src/Server/Coderr.Server.SqlServer/Schema/Coderr.v22.sql new file mode 100644 index 00000000..88b3d1fa --- /dev/null +++ b/src/Server/Coderr.Server.SqlServer/Schema/Coderr.v22.sql @@ -0,0 +1,11 @@ + +create table NotificationsBrowser +( + Id int not null identity primary key, + AccountId int not null constraint FK_NotificationBrowser_AccountId foreign key references Accounts(Id), + Endpoint varchar(255) not null, + PublicKey varchar(150) not null, + AuthenticationSecret varchar(150) not null, + CreatedAtUtc datetime not null, + ExpiresAtUtc datetime null +); diff --git a/src/Server/Coderr.Server.SqlServer/Web/Feedback/Queries/GetOverviewFeedbackHandler.cs b/src/Server/Coderr.Server.SqlServer/Web/Feedback/Queries/GetOverviewFeedbackHandler.cs index 805cb5bc..6becafaa 100644 --- a/src/Server/Coderr.Server.SqlServer/Web/Feedback/Queries/GetOverviewFeedbackHandler.cs +++ b/src/Server/Coderr.Server.SqlServer/Web/Feedback/Queries/GetOverviewFeedbackHandler.cs @@ -1,24 +1,20 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Coderr.Server.Abstractions.Boot; using Coderr.Server.Abstractions.Security; using Coderr.Server.Api.Web.Feedback.Queries; -using Coderr.Server.Infrastructure.Security; using DotNetCqs; -using Coderr.Server.ReportAnalyzer.Abstractions; using Griffin.Data; using Griffin.Data.Mapper; namespace Coderr.Server.SqlServer.Web.Feedback.Queries { - [ContainerService] - public class GetOverviewFeedbackHandler : + public class GetFeedbackForDashboardPageHandler : IQueryHandler { private readonly IAdoNetUnitOfWork _unitOfWork; - public GetOverviewFeedbackHandler(IAdoNetUnitOfWork unitOfWork) + public GetFeedbackForDashboardPageHandler(IAdoNetUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; } diff --git a/src/Server/Coderr.Server.Web/Boot/Cqs/CqsObjectMapper.cs b/src/Server/Coderr.Server.Web/Boot/Cqs/CqsObjectMapper.cs index a28b0115..7ab2a612 100644 --- a/src/Server/Coderr.Server.Web/Boot/Cqs/CqsObjectMapper.cs +++ b/src/Server/Coderr.Server.Web/Boot/Cqs/CqsObjectMapper.cs @@ -29,7 +29,11 @@ public class CqsObjectMapper ContractResolver = new IncludeNonPublicMembersContractResolver(), NullValueHandling = NullValueHandling.Ignore, ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor, - Converters = new List { new StringEnumConverter() } + + // Typescript requires numbers for easier enum handling + // otherwise we have to redefine all enums so that the keys are strings. + + //Converters = new List { new StringEnumConverter() } }; public bool IsEmpty => _cqsTypes.Count == 0; @@ -50,6 +54,16 @@ public object Deserialize(string dotNetTypeOrCqsName, string json) : JsonConvert.DeserializeObject(json, type, _jsonSerializerSettings); } + /// + /// We just have this method to make sure that the serialization is exactly the same in both directions. + /// + /// + /// + public string Serialize(object value) + { + return JsonConvert.SerializeObject(value, _jsonSerializerSettings); + } + /// /// Determines whether the type implements the command handler interface /// diff --git a/src/Server/Coderr.Server.Web/ClientApp/boot.ts b/src/Server/Coderr.Server.Web/ClientApp/boot.ts index 62c60dcb..d6dc6667 100644 --- a/src/Server/Coderr.Server.Web/ClientApp/boot.ts +++ b/src/Server/Coderr.Server.Web/ClientApp/boot.ts @@ -74,14 +74,24 @@ const routes = [ path: "/discover/", component: require("./components/discover/discover.vue.html").default, children: [ + { + name: "assignIncident", + path: "assign/incident/:incidentId/", + component: require("./components/discover/incidents/assign.vue.html").default + }, { name: "findIncidents", path: "incidents/:applicationId?", component: require("./components/discover/incidents/search.vue.html").default }, + { + name: "discoverFeedback", + path: "feedback/:applicationId?", + component: require("./components/discover/feedback/feedback.vue.html").default + }, { name: "discoverIncident", - path: "incidents/:applicationId/incident/:incidentId", + path: "incidents/:applicationId/incident/:incidentId/", component: require("./components/discover/incidents/incident.vue.html").default }, { @@ -198,6 +208,17 @@ const routes = [ } ] }, + { + path: "/manage/account/", + component: require("./components/manage/account/manage.vue.html").default, + children: [ + { + name: "manageAccount", + path: "", + component: require("./components/manage/account/home/home.vue.html").default + } + ] + }, { path: "/manage/", component: require("./components/manage/system/manage.vue.html").default, @@ -206,7 +227,6 @@ const routes = [ name: "manageHome", path: "", component: require("./components/manage/system/home/home.vue.html").default, - }, { name: "createApp", @@ -304,7 +324,7 @@ AppRoot.Instance.loadCurrentUser() //}); ourVue.$router.afterEach((to, from) => { hooks.afterRoute(to.path, from.path); - }) + }); ourVue.user$ = user; if (v["Cypress"]) { diff --git a/src/Server/Coderr.Server.Web/ClientApp/components/discover/feedback/feedback.css b/src/Server/Coderr.Server.Web/ClientApp/components/discover/feedback/feedback.css new file mode 100644 index 00000000..9ec54698 --- /dev/null +++ b/src/Server/Coderr.Server.Web/ClientApp/components/discover/feedback/feedback.css @@ -0,0 +1,3 @@ +.card-header a { + color: #59c1d5; +} diff --git a/src/Server/Coderr.Server.Web/ClientApp/components/discover/feedback/feedback.ts b/src/Server/Coderr.Server.Web/ClientApp/components/discover/feedback/feedback.ts new file mode 100644 index 00000000..c157eb8c --- /dev/null +++ b/src/Server/Coderr.Server.Web/ClientApp/components/discover/feedback/feedback.ts @@ -0,0 +1,80 @@ +import { PubSubService, MessageContext } from "@/services/PubSub"; +import { AppRoot } from '@/services/AppRoot'; +import * as MenuApi from "@/services/menu/MenuApi"; +import * as feedback from "@/dto/Web/Feedback"; +import { Component, Vue } from "vue-property-decorator"; + +interface IFeedback { + description: string; + email: string; + writtenAtUtc: Date; + applicationName: string, + applicationId: number; + incidentId?: number; + incidentName?: string; +} + +@Component +export default class DiscoverFeedbackComponent extends Vue { + + applicationId: number = 0; + feedbackList: IFeedback[] = []; + destroyed$ = false; + + created() { + PubSubService.Instance.subscribe(MenuApi.MessagingTopics.ApplicationChanged, this.onApplicationChangedInNavMenu); + + this.applicationId = parseInt(this.$route.params.applicationId, 10); + this.fetchFeedback(); + } + + destroyed() { + PubSubService.Instance.unsubscribe(MenuApi.MessagingTopics.ApplicationChanged, this.onApplicationChangedInNavMenu); + this.destroyed$ = true; + } + + private onApplicationChangedInNavMenu(ctx: MessageContext) { + if (this.$route.name !== 'discoverFeedback') { + return; + } + + this.feedbackList = []; + const body = ctx.message.body; + this.applicationId = body.applicationId; + this.fetchFeedback(); + } + + private fetchFeedback() { + console.log(this.applicationId); + if (this.applicationId > 0) { + const q = new feedback.GetFeedbackForApplicationPage(); + q.ApplicationId = this.applicationId; + AppRoot.Instance.apiClient.query(q) + .then(result => this.processFeedback(result)); + } else { + const q = new feedback.GetFeedbackForDashboardPage(); + AppRoot.Instance.apiClient.query(q) + .then(result => this.processFeedback(result)); + } + } + private processFeedback(result: feedback.GetFeedbackForDashboardPageResult) { + result.Items.forEach(x => { + if (!x.Message) { + return; + } + + var any = x; + this.feedbackList.push({ + applicationName: x.ApplicationName, + applicationId: x.ApplicationId, + incidentId: any.IncidentId, + incidentName: any.IncidentName, + description: x.Message, + email: x.EmailAddress, + writtenAtUtc: x.WrittenAtUtc + }); + }); + } + + +} diff --git a/src/Server/Coderr.Server.Web/ClientApp/components/discover/feedback/feedback.vue.html b/src/Server/Coderr.Server.Web/ClientApp/components/discover/feedback/feedback.vue.html new file mode 100644 index 00000000..1c14b0af --- /dev/null +++ b/src/Server/Coderr.Server.Web/ClientApp/components/discover/feedback/feedback.vue.html @@ -0,0 +1,55 @@ + + + + + \ No newline at end of file diff --git a/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/assign.ts b/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/assign.ts new file mode 100644 index 00000000..325889a3 --- /dev/null +++ b/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/assign.ts @@ -0,0 +1,15 @@ +import { AppRoot } from '../../../services/AppRoot'; +import { Component, Vue } from "vue-property-decorator"; + + +@Component +export default class AssignIncidentComponent extends Vue { + created() { + const incidentId = parseInt(this.$route.params.incidentId, 10); + AppRoot.Instance.incidentService.assignToMe(incidentId); + setTimeout(() => { + this.$router.push({ name: 'analyzeIncident', params: { 'incidentId': incidentId.toString() } }); + }, + 1000); + } +} diff --git a/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/assign.vue.html b/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/assign.vue.html new file mode 100644 index 00000000..aa0ee32a --- /dev/null +++ b/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/assign.vue.html @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/search.ts b/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/search.ts index 5c2e5dfa..5086d404 100644 --- a/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/search.ts +++ b/src/Server/Coderr.Server.Web/ClientApp/components/discover/incidents/search.ts @@ -7,8 +7,7 @@ import { ApplicationService, AppEvents, ApplicationCreated } from "../../../serv import { ApiClient } from '../../../services/ApiClient'; import { AppRoot } from "../../../services/AppRoot"; import { IncidentService } from "../../../services/incidents/IncidentService"; -import Vue from "vue"; -import { Component } from "vue-property-decorator"; +import { Component, Vue } from "vue-property-decorator"; declare var $: any; interface Incident { diff --git a/src/Server/Coderr.Server.Web/ClientApp/components/discover/menu.vue.html b/src/Server/Coderr.Server.Web/ClientApp/components/discover/menu.vue.html index a27fb8ac..f7b164c4 100644 --- a/src/Server/Coderr.Server.Web/ClientApp/components/discover/menu.vue.html +++ b/src/Server/Coderr.Server.Web/ClientApp/components/discover/menu.vue.html @@ -8,6 +8,9 @@ + diff --git a/src/Server/Coderr.Server.Web/ClientApp/components/home/navmenu/navmenu.vue.html b/src/Server/Coderr.Server.Web/ClientApp/components/home/navmenu/navmenu.vue.html index 2e28f842..28ee3a00 100644 --- a/src/Server/Coderr.Server.Web/ClientApp/components/home/navmenu/navmenu.vue.html +++ b/src/Server/Coderr.Server.Web/ClientApp/components/home/navmenu/navmenu.vue.html @@ -42,7 +42,7 @@ Deployment insights --> - + +