diff --git a/AdminSettings.cs b/AdminSettings.cs new file mode 100644 index 0000000..49bdd6d --- /dev/null +++ b/AdminSettings.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Countersoft.Gemini.Infrastructure.Apps; + +namespace EmailNotificationEngine +{ + +} diff --git a/BaseNotification.cs b/BaseNotification.cs new file mode 100644 index 0000000..5619850 --- /dev/null +++ b/BaseNotification.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Web; +using Countersoft.Gemini.Infrastructure.Managers; + +namespace EmailNotificationEngine +{ + public abstract class BaseNotification : INotificationAlert + { + protected IssueManager IssueManager { get; } + protected readonly NotificationCache Cache; + private StreamWriter _streamWriter; + + protected abstract void ProcessNotifications(); + + public void Send() + { + try + { + if (CreateFileLog) + { + using (_streamWriter = OpenFile()) + { + ProcessNotifications(); + LogDebugMessage($"Finished processing {this.GetType().Name}"); + CloseFile(); + } + } + else + { + ProcessNotifications(); + } + + } + catch (Exception ex) + { + LogDebugMessage(ex.ToString()); + } + finally + { + if (CreateFileLog) + { + CloseFile(); + } + } + } + + public List Log { get; set; } = new List(); + public bool CreateFileLog { get; set; } + + protected void LogDebugMessage(string message, int level = 1) + { + Log.Add(message); + if (level == 1) + { + MessageRaised(new NotificationMessageEventArgs(message, level)); + } + if (CreateFileLog) + { + if (_streamWriter == null) + { + OpenFile(); + } + var padding = string.Empty; + for (int i = 2; i < level; i++) + { + padding += " "; + } + _streamWriter.WriteLine(padding + message); + } + } + + protected void LogException(Exception ex) + { + Log.Add(ex.ToString()); + MessageRaised(new NotificationMessageEventArgs(ex.ToString())); + } + + protected BaseNotification(NotificationCache cache, IssueManager issueManager) + { + IssueManager = issueManager; + Cache = cache; + } + + protected StreamWriter OpenFile() + { + var dir = Path.Combine(HttpRuntime.AppDomainAppPath, "app_data", "EmalProcessor"); + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + var path = Path.Combine(dir,"EmailProcessorLog_"+ this.GetType().Name +".txt"); + if (File.Exists(path)) + { + File.Copy(path, path.Replace(".txt", DateTime.Now.Ticks + ".txt")); + File.Delete(path); + } + _streamWriter = File.CreateText(path); + _streamWriter.AutoFlush = true; + return _streamWriter; + } + + + protected void CloseFile() + { + try + { + _streamWriter.WriteLine("Flusing file stream buffer"); + _streamWriter.Flush(); + _streamWriter.Close(); + } + catch (Exception ex) + { + DebugMessageRaised(new NotificationMessageEventArgs("There was an error closing the log file " + ex.ToString())); + } + } + + + + public event EventHandler OnMessageRaised; + public event EventHandler OnDebugMessageRaised; + + protected virtual void MessageRaised(NotificationMessageEventArgs e) + { + OnMessageRaised?.Invoke(this, e); + } + + protected virtual void DebugMessageRaised(NotificationMessageEventArgs e) + { + OnDebugMessageRaised?.Invoke(this, e); + } + } +} \ No newline at end of file diff --git a/EmailNotificationEngine.cs b/EmailNotificationEngine.cs index 156347f..823f947 100644 --- a/EmailNotificationEngine.cs +++ b/EmailNotificationEngine.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using Countersoft.Foundation.Commons.Extensions; using Countersoft.Gemini.Commons; @@ -19,685 +21,55 @@ using Countersoft.Gemini.Extensibility.Apps; using Countersoft.Gemini; using Countersoft.Gemini.Commons.Entity.Security; +using EmailNotificationEngine; namespace EmailAlerts { [AppType(AppTypeEnum.Timer), AppGuid("840D6D86-C74A-43D2-8D3A-3CF9985DB1D4"), AppName("Email Notification Engine"), - AppDescription("Sends email notifications to users")] + AppDescription("Sends email notifications to users"), + AppRequiresConfigScreen(true)] public class EmailNotificationEngine : TimerJob { - private List _templates; - - private List _types; - - private List _permissionSets; - - private List _organizations; - - private IssueManager _issueManager; public override bool Run(IssueManager issueManager) { if (!issueManager.UserContext.Config.EmailAlertsEnabled) return true; + issueManager.UserContext.User.Entity = new User(issueManager.UserContext.User.Entity); issueManager.UserContext.User.Entity.Language = "en-US"; - _issueManager = issueManager; - - _templates = GeminiApp.Container.Resolve().FindWhere(c => c.AlertType != AlertTemplateType.Breeze).ToList(); - - _types = new MetaManager(issueManager).TypeGetAll(); - - _permissionSets = new PermissionSetManager(issueManager).GetAll(); - - _organizations = new OrganizationManager(issueManager).GetAll(); - - ProcessAppNavCardAlerts(); - - ProcessWatcherAlerts(); + /*_issueManager = issueManager; + */ - return true; - } - - private List GetCardSubscribers(NavigationCard card, NavigationCardsManager cardManager, UserManager userManager, UserDto owner) - { - if (!owner.Entity.Active) return new List(); - - Dictionary subscribers = new Dictionary(); - - subscribers.Add(owner.Entity.Id, owner); - - foreach (var user in card.CardData.Subscription.Users) - { - var userDto = userManager.Get(user); - - if(user != owner.Entity.Id && userDto != null && userDto.Entity.Active) subscribers.Add(user, userDto); - } - - var groupUsers = cardManager.GetUsersFromGroups(card, card.CardData.Subscription.Groups); - - foreach (var user in groupUsers) - { - if (!subscribers.ContainsKey(user.Entity.Id)) subscribers.Add(user.Entity.Id, user); - } - - return new List(subscribers.Values); - } - - private void ProcessAppNavCardAlerts() - { - var navigationCardsManager = new NavigationCardsManager(_issueManager); - - List cards = navigationCardsManager.GetPendingAlertCards(); - - LogDebugMessage("Email templates loaded: " + _templates.Count); - - LogDebugMessage("Pending card alerts: " + cards.Count); - - // ? We need to store user id and issue id for every email we send out -- avoids dupes? - List issuesEmailedToUsers = new List(50); - - List individualIssuesEmailedToUsers = new List(50); - - AlertsTemplateHelper alerts = new AlertsTemplateHelper(_templates, GetUrl(_issueManager)); - - UserManager userManager = new UserManager(_issueManager); - - bool refreshCache = false; - - var allOptOuts = navigationCardsManager.GetOptOuts(); - - foreach (NavigationCard card in cards) - { - List individualIssues = new List(); - - // Safety checks - if (!card.UserId.HasValue) continue; - - if (card.CardData.Alerts.Count == 0) continue; - - UserDto recepient = userManager.Get(card.UserId.Value); - - // Safety check - if (!recepient.Entity.EmailMe || recepient.Entity.Email.IsEmpty()) continue; - - DateTime lastChecked = card.CardData.AlertsLastSent.HasValue ? card.CardData.AlertsLastSent.Value : card.Created; - - DateTime lastCheckedLocal = lastChecked.ToLocal(_issueManager.UserContext.User.TimeZone); - - AlertTypeAppNavAlertsTemplateModel model = new AlertTypeAppNavAlertsTemplateModel(); - - model.TheRecipient = recepient; - - model.Version = GeminiVersion.Version; - - model.GeminiUrl = alerts.GeminiUrl; - - List issuesToAlert = new List(card.CardData.Alerts); - - foreach (int issueId in issuesToAlert) - { - IssueDto issue = null; - try - { - issue = _issueManager.Get(issueId); - } - catch (Exception ex) - { - LogException(ex); - } - - // Safety check - if (issue == null || issue.Entity.IsNew) continue; - - // Dupe check - string dupeKey = string.Format("{0}-{1}-{2}", recepient.Entity.Id, issueId, card.Id); - - if (issuesEmailedToUsers.Contains(dupeKey)) continue; - - var permissionManager = new PermissionsManager(recepient, _types, _permissionSets, _organizations, _issueManager.UserContext.Config.HelpDeskModeGroup, false); - - if (!permissionManager.CanSeeItem(issue.Project, issue)) continue; - - foreach (var comment in issue.Comments) - { - if (!permissionManager.CanSeeComment(issue, comment)) - { - issue.Comments.RemoveAll(c => !permissionManager.CanSeeComment(issue, c)); - break; - } - } - - // Remove the reported by first entry! - if(issue.History.Count > 0 && issue.History[issue.History.Count-1].Entity.AttributeChanged == ItemAttributeVisibility.ReportedBy) issue.History.RemoveAt(issue.History.Count -1); - issue.ChangeLog = _issueManager.GetChangeLog(issue, _issueManager.UserContext.User, recepient, lastCheckedLocal); - - // Populate model for email template - if (card.CardData.Subscription.IndividualAlert) individualIssues.Add(issue); - - if (card.CardData.Subscription.Created && issue.Created.ToUtc(_issueManager.UserContext.User.TimeZone) >= lastChecked) - { - model.TheItemsCreated.Add(issue); - } - else - { - List allChanges = issue.History.FindAll(h => h.Entity.Created.ToUtc(_issueManager.UserContext.User.TimeZone) >= lastChecked); - - List commentChanges = allChanges.FindAll(a => !a.Entity.IsCustom && a.Entity.AttributeChanged == ItemAttributeVisibility.AssociatedComments); - - List nonCommentChanges = allChanges.FindAll(a => a.Entity.IsCustom || a.Entity.AttributeChanged != ItemAttributeVisibility.AssociatedComments); - - // Add comments and updates - if (card.CardData.Subscription.Updated && nonCommentChanges.Count > 0 || card.CardData.Subscription.Commented && commentChanges.Count > 0 && issue.Comments.Count > 0) - { - model.TheItemsUpdated.Add(issue); - } - - if (card.CardData.Subscription.Commented && commentChanges.Count > 0 && issue.Comments.Count > 0) - { - model.TheItemsCommented.Add(issue); - } - } - - // Record the fact that we have processed this issue for this recepient (to prevent dupes) - issuesEmailedToUsers.Add(dupeKey); - } - - model.CardTitle = string.Format("{0} {1}", card.Key, card.Title); - - model.CardKey = card.Key; - - model.CardDescription = card.Title; - - model.CardComment = card.CardData.Comment; - - model.CardUrl = string.Concat(model.GeminiUrl, "workspace/", card.Id, '/', card.Url); - - // Safety check! - if (model.ChangeCount > 0) - { - List subscribers = GetCardSubscribers(card, navigationCardsManager, userManager, recepient); - - //if (!subscribers.Contains(recepient) && subscribers.Find(u => u.Entity.Id == recepient.Entity.Id) == null) subscribers.Insert(0, recepient); - if (card.CardData.Subscription.IndividualAlert) - { - foreach (var user in subscribers) - { - if (allOptOuts.Any(s => s.UserId == user.Entity.Id && s.CardId == card.Id && s.OptOutType == OptOutEmails.OptOutTypes.Alert)) continue; - - foreach (var issue in individualIssues) - { - string individualDupeKey = string.Format("{0}-{1}", user.Entity.Id, issue.Entity.Id); - - if (individualIssuesEmailedToUsers.Contains(individualDupeKey)) continue; - - if (user != recepient) - { - var permissionManager = new PermissionsManager(user, _types, _permissionSets, _organizations, _issueManager.UserContext.Config.HelpDeskModeGroup, false); - - if (!permissionManager.CanSeeItem(issue.Project, issue)) continue; - - issue.ChangeLog = _issueManager.GetChangeLog(issue, _issueManager.UserContext.User, user, lastCheckedLocal); - } - - var indModel = new AlertTypeIndividualTemplateModel(); - - indModel.GeminiUrl = model.GeminiUrl; - - indModel.LinkViewItem = NavigationHelper.GetIssueUrl(_issueManager.UserContext, issue.Entity.ProjectId, issue.EscapedProjectCode, issue.Entity.Id); - - indModel.TheItem = issue; - - indModel.TheRecipient = user; - - indModel.Version = GeminiVersion.Version; - - indModel.IsNewItem = model.TheItemsCreated.Contains(issue); - - indModel.CardKey = model.CardKey; - - indModel.CardDescription = model.CardDescription; - - indModel.CardComment = model.CardComment; - - indModel.CardUrl = model.CardUrl; - - if (!indModel.IsNewItem && issue.ChangeLog.Count == 0) continue; - - var template = alerts.FindTemplateForProject(indModel.IsNewItem ? AlertTemplateType.Created : AlertTemplateType.Updated, issue.Entity.ProjectId); - - string html = alerts.GenerateHtml(template, indModel); - - if (GeminiApp.GeminiLicense.IsFree) html = alerts.AddSignature(html); - - // Send email - string log; - - string subject = template.Options.Subject.HasValue() ? alerts.GenerateHtml(template, indModel, true) : string.Format("[{0}] {1} {2} ({3})", issue.IssueKey, issue.Type, model.TheItemsCreated.Contains(issue) ? "Created" : "Updated", issue.Title, issue.IsClosed ? "Closed" : string.Empty); - - EmailHelper.Send(_issueManager.UserContext.Config, subject, html, user.Entity.Email, user.Fullname, true, out log); - - individualIssuesEmailedToUsers.Add(individualDupeKey); - } - } - } - else - { - var cloneCreated = new List(model.TheItemsCreated); - - var cloneUpdated = new List(model.TheItemsUpdated); - - var cloneCommented = new List(model.TheItemsCommented); - - // Find email template to use (for this project or fall back to default template) - AlertTemplate template = alerts.FindTemplateForProject(AlertTemplateType.AppNavAlerts, 0); - - foreach (var user in subscribers) - { - if (allOptOuts.Any(s => s.UserId == user.Entity.Id && s.CardId == card.Id && s.OptOutType == OptOutEmails.OptOutTypes.Alert)) continue; - - model.TheItemsCreated = new List(cloneCreated); - - model.TheItemsUpdated = new List(cloneUpdated); - - model.TheItemsCommented = new List(cloneCommented); - - if (user != recepient) - { - var permissionManager = new PermissionsManager(user, _types, _permissionSets, _organizations, _issueManager.UserContext.Config.HelpDeskModeGroup, false); - - model.TheItemsCreated.RemoveAll(i => !permissionManager.CanSeeItem(i.Project, i)); - - model.TheItemsUpdated.RemoveAll(i => !permissionManager.CanSeeItem(i.Project, i)); - - model.TheItemsCommented.RemoveAll(i => !permissionManager.CanSeeItem(i.Project, i)); - - foreach (var issue in model.TheItemsCreated.Concat(model.TheItemsUpdated).Concat(model.TheItemsCommented)) - { - issue.ChangeLog = _issueManager.GetChangeLog(issue, _issueManager.UserContext.User, user, lastCheckedLocal); - } - } - - //model.TheItemsCreated.RemoveAll(i => i.ChangeLog.Count == 0); - model.TheItemsUpdated.RemoveAll(i => i.ChangeLog.Count == 0); - - model.TheItemsCommented.RemoveAll(i => i.ChangeLog.Count == 0); + NotificationCache cache = new NotificationCache(issueManager, GetUrl(issueManager)); - if (model.ChangeCount == 0) continue; + IList alerts = AlertFactory.GetAlerters(cache, issueManager); - // Generate email template - string html = alerts.GenerateHtml(template, model); - - if (GeminiApp.GeminiLicense.IsFree) html = alerts.AddSignature(html); - - string subject = template.Options.Subject.HasValue() ? alerts.GenerateHtml(template, model, true) : string.Format("{0} {1}", card.Key, card.Title); - - // Send email - string log; - - EmailHelper.Send(_issueManager.UserContext.Config, subject, html, user.Entity.Email, user.Fullname, true, out log); - } - } - } - - // Remove the alert notifications and update the database - lock (card.CardData.Alerts) - { - card.CardData.Alerts.RemoveAll(a => issuesToAlert.Contains(a)); - } - - card.CardData.AlertsLastSent = DateTime.UtcNow; - - refreshCache = true; - - navigationCardsManager.Update(card, false, false); - } - - if (refreshCache) + foreach (var alert in alerts) { - navigationCardsManager.Cache.NavigationCards.Invalidate(); - var webNodes = GeminiApp.Container.Resolve(); - webNodes.AddDataOnAllNodesButMe(new WebNodeData() { NodeGuid = GeminiApp.GUID, Key = "cache", Value = navigationCardsManager.Cache.NavigationCards.CacheKey }); + LogDebugMessage("Running Process :" + alert.GetType().Name); + alert.OnMessageRaised += OnMessageRaised; + alert.CreateFileLog = true; + alert.Send(); + LogDebugMessage(alert.Log.Aggregate((s1, s2) => s1 += ", \n\r" + s2)); } - } - - private void ProcessWatcherAlerts() - { - SchedulerSettings settings = _issueManager.UserContext.Config.SchedulerSettings.HasValue() ? _issueManager.UserContext.Config.SchedulerSettings.FromJson() : new SchedulerSettings(); - - DateTime lastChecked = settings.LastCheckedWatchers.HasValue ? settings.LastCheckedWatchers.Value : DateTime.UtcNow; - IssuesFilter filter = new IssuesFilter(); + //ProcessAppNavCardAlerts(); - filter.RevisedAfter = lastChecked.ToString(); - - filter.IncludeClosed = true; - - LogDebugMessage("Last checked for watched item alerts: " + lastChecked); - - settings.LastCheckedWatchers = DateTime.UtcNow; - - List issues = _issueManager.GetFiltered(filter); - - LogDebugMessage("Item that have changed: " + issues.Count); + //ProcessWatcherAlerts(); - if (issues.Count > 0) ProcessWatchers(issues, lastChecked); - - IConfiguration configuration = GeminiApp.Container.Resolve(); - - GeminiConfiguration config = configuration.Get(); - - config.SchedulerSettings = settings.ToJson(); - - ConfigurationItem item = new ConfigurationItem(); - - item.SettingId = GeminiConfigurationOption.SchedulerSettings.ToString(); - - item.SettingValue = config.SchedulerSettings; - - configuration.Update(item); - - GeminiApp.RefreshConfig(config); + return true; } - private bool IsUserOnlyChange(List history, int userId) + private void OnMessageRaised(object sender, NotificationMessageEventArgs notificationMessageEventArgs) { - return history.Find(h => h.Entity.UserId.GetValueOrDefault() != userId) == null; + LogDebugMessage(notificationMessageEventArgs.Message); } - private void ProcessWatchers(List issues, DateTime lastChecked) - { - var lastCheckedLocal = lastChecked.ToLocal(_issueManager.UserContext.User.TimeZone); - - Dictionary targets = new Dictionary(); - Dictionary emailTargets = new Dictionary(); - var userManager = new UserManager(_issueManager); - List projectsMissingFollowerTemplate = new List(); - int emailWatchers = -3; - - LogDebugMessage(string.Concat("Processing follower - ", issues.Count, " items found")); - // Build array of users that are watching issues - foreach (var issue in issues) - { - //Safety check - if (issue.Watchers.Count == 0) continue; - if (issue.Revised == issue.Created) continue; - if (issue.Revised.ToUtc(_issueManager.UserContext.User.TimeZone) <= lastChecked) continue; - - var history = _issueManager.GetHistory(issue); - - issue.History = new List(history); - - history.RemoveAll(h => h.Entity.Created <= lastCheckedLocal); - - foreach (var watcher in issue.Watchers) - { - if (watcher.Entity.UserId != null) - { - if (targets.ContainsKey(watcher.Entity.UserId.Value)) - { - WatcherData data = targets[watcher.Entity.UserId.Value]; - - var permissionManager = new PermissionsManager(data.User, _types, _permissionSets, _organizations, _issueManager.UserContext.Config.HelpDeskModeGroup, false); - - if (!permissionManager.CanSeeItem(issue.Project, issue)) continue; - - if (!data.User.Entity.EmailMeMyChanges && IsUserOnlyChange(history, data.User.Entity.Id)) continue; - - data.IssueId.Add(issue.Entity.Id); - } - else - { - WatcherData data = new WatcherData(); - - data.User = userManager.Get(watcher.Entity.UserId.Value); - - if (data.User.Entity.Active) - { - var permissionManager = new PermissionsManager(data.User, _types, _permissionSets, _organizations, _issueManager.UserContext.Config.HelpDeskModeGroup, false); - - if (!permissionManager.CanSeeItem(issue.Project, issue)) continue; - - if (!data.User.Entity.EmailMeMyChanges && IsUserOnlyChange(history, data.User.Entity.Id)) continue; - - data.IssueId.Add(issue.Entity.Id); - - targets.Add(watcher.Entity.UserId.Value, data); - } - } - } - else - { - if (emailTargets.ContainsKey(watcher.Entity.Email.ToLower())) - { - WatcherData data = emailTargets[watcher.Entity.Email.ToLower()]; - data = targets[data.User.Entity.Id]; - data.IssueId.Add(issue.Entity.Id); - } - else - { - WatcherData data = new WatcherData(); - data.User = new UserDto(new User()); - data.User.Entity.Id = emailWatchers--; - data.User.Entity.Email = watcher.Entity.Email; - data.User.Entity.EmailMe = true; - data.User.Entity.EmailMeMyChanges = true; - data.User.Entity.ProjectGroups.Add(new ProjectGroupMembership() { ProjectGroupId = Constants.GlobalGroupEveryone, UserId = data.User.Entity.Id }); - UserSettings settings = new UserSettings(); - settings.IndividualFollowerAlerts = true; - data.User.Entity.Settings = settings.ToJson(); - var group = new ProjectGroup() { Id = Constants.GlobalGroupEveryone, Members = new List() }; - group.Members2.Add(new ProjectGroupMembership() { UserId = data.User.Entity.Id, ProjectGroupId = Constants.GlobalGroupEveryone }); - data.User.ProjectGroups.Add(group); - data.IssueId.Add(issue.Entity.Id); - emailTargets.Add(watcher.Entity.Email.ToLower(), data); - targets.Add(data.User.Entity.Id, data); - } - } - } - } - - AlertsTemplateHelper alerts = new AlertsTemplateHelper(_templates, GetUrl(_issueManager)); - - // Now loop through users sending them watcher summary email - Dictionary> originalComments = new Dictionary>(); - List processedProjects; - - foreach (var target in targets) - { - processedProjects = new List(); - - if (originalComments.Count > 0) - { - foreach (var kv in originalComments) - { - IssueDto issue = issues.Find(i => i.Entity.Id == kv.Key); - - // Safety check - if (issue == null || issue.Entity.IsNew) continue; - - issue.Comments = kv.Value; - } - - originalComments = new Dictionary>(); - } - - var recipient = target.Value; - - // Safety check - if (!recipient.User.Entity.EmailMe || recipient.User.Entity.Email.IsEmpty()) continue; - - AlertTypeWatchersTemplateModel model = new AlertTypeWatchersTemplateModel(); - - model.TheRecipient = recipient.User; - - model.Version = GeminiVersion.Version; - - model.GeminiUrl = alerts.GeminiUrl; - - foreach (int issueId in recipient.IssueId) - { - IssueDto issue = issues.Find(i => i.Entity.Id == issueId); - - // Safety check - if (issue == null || issue.Entity.IsNew) continue; - - issue.ChangeLog = _issueManager.GetChangeLog(issue, _issueManager.UserContext.User, recipient.User, lastCheckedLocal); - - var permissionManager = new PermissionsManager(recipient.User, _types, _permissionSets, _organizations, _issueManager.UserContext.Config.HelpDeskModeGroup, false); - - foreach (var comment in issue.Comments) - { - if (!permissionManager.CanSeeComment(issue, comment)) - { - originalComments.Add(issueId, issue.Comments); - - List comments = new List(issue.Comments); - - comments.RemoveAll(c => !permissionManager.CanSeeComment(issue, c)); - - issue.Comments = comments; - - break; - } - } - - if (issue.ChangeLog.Count == 0) continue; - - if (recipient.User.GetSettings().IndividualFollowerAlerts) - { - var template = alerts.FindTemplateForProject(AlertTemplateType.Updated, issue.Entity.ProjectId); - - if (template == null) - { - LogDebugMessage("No update notification template found"); - continue; - } - - var indModel = new AlertTypeIndividualTemplateModel(); - - indModel.GeminiUrl = model.GeminiUrl; - - indModel.LinkViewItem = NavigationHelper.GetIssueUrl(_issueManager.UserContext, issue.Entity.ProjectId, issue.EscapedProjectCode, issue.Entity.Id); - - indModel.TheItem = issue; - - indModel.TheRecipient = recipient.User; - - indModel.Version = GeminiVersion.Version; - - indModel.IsNewItem = false; - - string html = alerts.GenerateHtml(template, indModel); - - if (GeminiApp.GeminiLicense.IsFree) html = alerts.AddSignature(html); - - string log; - - string subject = template.Options.Subject.HasValue() ? alerts.GenerateHtml(template, indModel, true) : string.Format("[{0}] {1} {2} ({3})", issue.IssueKey, issue.Type, "Updated", issue.Title, issue.IsClosed ? "Closed" : string.Empty); - LogDebugMessage(string.Concat("Processing follower - Send item ", issue.IssueKey, " to ", recipient.User.Entity.Email)); - EmailHelper.Send(_issueManager.UserContext.Config, subject, html, recipient.User.Entity.Email, recipient.User.Fullname, true, out log); - } - else - { - model.TheItemsUpdated.Add(issue); - } - - } - - if (recipient.User.GetSettings().IndividualFollowerAlerts) continue; - - // Safety check! - if (model.ChangeCount > 0) - { - var watcherAlertTemplates = alerts.Templates.FindAll(s => s.AlertType == AlertTemplateType.Watchers); - - if (watcherAlertTemplates.Count == 0) - { - LogDebugMessage("No follower notification template found"); - continue; - } - - if (!watcherAlertTemplates.Any(p => p.GetAssociatedProjectValue().IsEmpty())) - { - List allItemProjects = model.TheItemsUpdated.Select(s => s.Project).ToList(); - allItemProjects = allItemProjects.Where(s => !watcherAlertTemplates.Any(a => a.GetAssociatedProjects().Contains(s.Id))).ToList(); - - if (projectsMissingFollowerTemplate.Count > 0) - { - allItemProjects = allItemProjects.Where(s => !projectsMissingFollowerTemplate.Contains(s.Id)).ToList(); - } - - if (allItemProjects.Count > 0) - { - LogDebugMessage(string.Concat("No follower notification template found for project ", string.Join(", ", allItemProjects.Select(s => s.Name).Distinct()))); - projectsMissingFollowerTemplate.AddRange(allItemProjects.Select(s => s.Id).Distinct()); - } - } - - watcherAlertTemplates.Sort((x, y) => y.GetAssociatedProjectValue().CompareTo(x.GetAssociatedProjectValue())); - - foreach(var watcherTemplate in watcherAlertTemplates) - { - var allTemplateProjects = watcherTemplate.GetAssociatedProjects(); - - var issuesTemplate = allTemplateProjects.Count == 0 ? model.TheItemsUpdated : model.TheItemsUpdated.FindAll(s => allTemplateProjects.Contains(s.Entity.ProjectId)); - - if (issuesTemplate.Count == 0) continue; - - var projectIds = issuesTemplate.Select(s => s.Entity.ProjectId).Distinct(); - - if (processedProjects.Count > 0) - { - projectIds = projectIds.Where(s => !processedProjects.Contains(s)); - issuesTemplate = issuesTemplate.Where(s => !processedProjects.Contains(s.Entity.ProjectId)).ToList(); - } - - if (processedProjects.Contains(0) || projectIds.Count() == 0 || issuesTemplate.Count == 0) continue; - - AlertTypeWatchersTemplateModel projectTemplateModel = new AlertTypeWatchersTemplateModel(); - - projectTemplateModel.TheItemsUpdated.AddRange(issuesTemplate); - projectTemplateModel.TheRecipient = model.TheRecipient; - projectTemplateModel.Version = model.Version; - projectTemplateModel.GeminiUrl = model.GeminiUrl; - - AlertTemplate template = alerts.FindTemplateForProject(AlertTemplateType.Watchers, issuesTemplate.First().Entity.ProjectId); - - if (template.Id == 0) - { - - continue; - } - - // Generate email template - string html = alerts.GenerateHtml(template, projectTemplateModel); - - if (GeminiApp.GeminiLicense.IsFree) html = alerts.AddSignature(html); - - string subject = template.Options.Subject.HasValue() ? alerts.GenerateHtml(template, projectTemplateModel, true) : string.Format("{0} {1}", projectTemplateModel.ChangeCount, "Gemini Updates"); - - // Send email - string log; - LogDebugMessage(string.Concat("Processing follower - Send items ", issuesTemplate.Select(i=> i.IssueKey).ToDelimited(", "), " to ", recipient.User.Entity.Email)); - EmailHelper.Send(_issueManager.UserContext.Config, subject, html, recipient.User.Entity.Email, recipient.User.Entity.Fullname, true, out log); - - if (allTemplateProjects.Count == 0) - { - processedProjects.Add(0); - } - else - { - processedProjects.AddRange(projectIds); - } - } - } - } - } public override void Shutdown() { @@ -717,6 +89,7 @@ public override TimerJobSchedule GetInterval(IGlobalConfigurationWidgetStore dat return data.Value; } + } public class WatcherData diff --git a/EmailNotificationEngine.csproj b/EmailNotificationEngine.csproj index 630b7e4..d31b55b 100644 --- a/EmailNotificationEngine.csproj +++ b/EmailNotificationEngine.csproj @@ -93,7 +93,13 @@ + + + + + + @@ -107,7 +113,7 @@ copy $(TargetDir)$(TargetName).dll $(ProjectDir)$(TargetName).dll del $(ProjectDir)$(TargetName).pdb -rem copy $(TargetDir)$(TargetName).pdb $(ProjectDir)$(TargetName).pdb +copy $(TargetDir)$(TargetName).pdb $(ProjectDir)$(TargetName).pdb del $(ProjectDir)$(TargetName).zip $(ProjectDir)libs\7za.exe a $(TargetDir)$(TargetName).zip $(ProjectDir)*.dll $(ProjectDir)*.pdb $(ProjectDir)*.manifest diff --git a/FollowerNotification.cs b/FollowerNotification.cs new file mode 100644 index 0000000..cc52d3b --- /dev/null +++ b/FollowerNotification.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Linq.Expressions; +using System.Web; +using Countersoft.Foundation.Commons.Extensions; +using Countersoft.Gemini; +using Countersoft.Gemini.Commons; +using Countersoft.Gemini.Commons.Dto; +using Countersoft.Gemini.Commons.Entity; +using Countersoft.Gemini.Commons.Entity.Security; +using Countersoft.Gemini.Commons.Permissions; +using Countersoft.Gemini.Commons.System; +using Countersoft.Gemini.Contracts; +using Countersoft.Gemini.Infrastructure.Helpers; +using Countersoft.Gemini.Infrastructure.Managers; +using Countersoft.Gemini.Mailer; +using EmailAlerts; +using Microsoft.Practices.Unity; + +namespace EmailNotificationEngine +{ + public class FollowerNotification : BaseNotification, INotificationAlert + { + public FollowerNotification(NotificationCache cache, IssueManager issueManager) : base(cache, issueManager) + { + } + + protected override void ProcessNotifications() + { + ProcessWatcherAlerts(); + } + + + private void ProcessWatcherAlerts() + { + SchedulerSettings settings = IssueManager.UserContext.Config.SchedulerSettings.HasValue() + ? IssueManager.UserContext.Config.SchedulerSettings.FromJson() + : new SchedulerSettings(); + + DateTime lastChecked = settings.LastCheckedWatchers ?? DateTime.UtcNow; + + IssuesFilter filter = new IssuesFilter + { + RevisedAfter = lastChecked.ToString(), + IncludeClosed = true + }; + + settings.LastCheckedWatchers = DateTime.UtcNow; + + LogDebugMessage("Last checked for watched item alerts: " + lastChecked + " next check will check from " + settings.LastCheckedWatchers + " (the time now)"); + + //NOTE - this filter only works on the date, so loads all changed today. - It skips then in processing later, but why not remove them, save the effort.? + List changedIssues = IssueManager.GetFiltered(filter); + //issue.Revised.ToUtc(IssueManager.UserContext.User.TimeZone) <= lastChecked + var changedAfterLastRun = changedIssues.Count(d => d.Revised.ToUtc(IssueManager.UserContext.User.TimeZone) >= lastChecked); + + LogDebugMessage($"Item that have changed: {changedIssues.Count} (today), {changedAfterLastRun} since the last run"); + + if (changedIssues.Count > 0) + { + try + { + ProcessWatchers(changedIssues, lastChecked); + } + catch(Exception ex) + { + LogDebugMessage($"There was an error while Processing Followers - {ex.ToString()}"); + CloseFile(); + throw; + } + } + + IConfiguration configuration = GeminiApp.Container.Resolve(); + + GeminiConfiguration config = configuration.Get(); + + config.SchedulerSettings = settings.ToJson(); + + ConfigurationItem item = new ConfigurationItem + { + SettingId = GeminiConfigurationOption.SchedulerSettings.ToString(), + SettingValue = config.SchedulerSettings + }; + + configuration.Update(item); + LogDebugMessage($"Updating scheduler settings, new datetime {settings.LastCheckedWatchers.GetValueOrDefault()}"); + + GeminiApp.RefreshConfig(config); + } + + + private void ProcessWatchers(List changedIssues, DateTime lastChecked) + { + + var lastCheckedLocal = lastChecked.ToLocal(IssueManager.UserContext.User.TimeZone); + + Dictionary targets = new Dictionary(); + Dictionary emailTargets = new Dictionary(); + var userManager = new UserManager(IssueManager); + List projectsMissingFollowerTemplate = new List(); + int emailWatchers = -3; + + LogDebugMessage($"Processing follower: {changedIssues.Count} items found (" + + $"{changedIssues.Select(i => i.Entity.Id.ToString()).Aggregate((s, s1) => s += ", " + s1)})"); + + //List debugInfo = new List(); + // Build array of users that are watching issues + foreach (var issue in changedIssues) + { + LogDebugMessage("",2); + LogDebugMessage($"Processing {IssueDetail(issue)}", 2); + + //Safety check + if (issue.Watchers.Count == 0) + { + LogDebugMessage(IssueDetail(issue) + " did not have any watchers, Skipping", 3); + continue; + } + if (issue.Revised == issue.Created) + { + LogDebugMessage(IssueDetail(issue) + " has just been created, skipping",3); + continue; + } + if (issue.Revised.ToUtc(IssueManager.UserContext.User.TimeZone) <= lastChecked) + { + LogDebugMessage(IssueDetail(issue) + $" was revised {issue.Revised.ToUtc(IssueManager.UserContext.User.TimeZone)} before last checked {lastChecked}, skipping",3); + continue; + } + + var history = IssueManager.GetHistory(issue); + + issue.History = new List(history); + + history.RemoveAll(h => h.Entity.Created <= lastCheckedLocal); + + LogDebugMessage($"{issue.Entity.Id} has {history.Count} change(s) to be notified to {issue.Watchers.Count} watchers:", 3); + LogDebugMessage(issue.Watchers.Select(s => s.Fullname ?? s.Username ?? s.Entity.Id.ToString()).Aggregate((s, s1) => s += ", " + s1), 4); + + foreach (var watcher in issue.Watchers) + { + LogDebugMessage($"Processing watcher: {UserDetail(watcher)}", 3); + if (watcher.Entity.UserId != null) + { + if (targets.ContainsKey(watcher.Entity.UserId.Value)) + { + WatcherData data = targets[watcher.Entity.UserId.Value]; + + var permissionManager = new PermissionsManager(data.User, Cache.Types, Cache.PermissionSets, Cache.Organizations, IssueManager.UserContext.Config.HelpDeskModeGroup, false); + //var permissionManager = new PermissionsManager(data.User, _types, _permissionSets, _organizations, IssueManager.UserContext.Config.HelpDeskModeGroup, false); + + if (!permissionManager.CanSeeItem(issue.Project, issue)) + { + LogDebugMessage($"watcher does not have permission to view item {IssueDetail(issue)}", 4); + continue; + } + + if (!data.User.Entity.EmailMeMyChanges && IsUserOnlyChange(history, data.User.Entity.Id)) + { + LogDebugMessage($"Watcher has opted not to receive their changes, and this issue was changed solely by them", 4); + continue; + } + + data.IssueId.Add(issue.Entity.Id); + LogDebugMessage($"User's notifications include these entities: " + data.IssueId.Cast().Aggregate((s1,s2)=> s1 + ", " + s2)); + } + else + { + WatcherData data = new WatcherData(); + + data.User = userManager.Get(watcher.Entity.UserId.Value); + + if (data.User.Entity.Active) + { + var permissionManager = new PermissionsManager(data.User, Cache.Types, + Cache.PermissionSets, Cache.Organizations, IssueManager.UserContext.Config.HelpDeskModeGroup, false); + //var permissionManager = new PermissionsManager(data.User, _types, _permissionSets, _organizations, _issueManager.UserContext.Config.HelpDeskModeGroup, false); + + if (!permissionManager.CanSeeItem(issue.Project, issue)) + { + LogDebugMessage($"watcher does not have permission to view item {IssueDetail(issue)}",4); + continue; + } + + if (!data.User.Entity.EmailMeMyChanges && IsUserOnlyChange(history, data.User.Entity.Id)) + { + LogDebugMessage($"Watcher has opted not to receive their changes, and this issue was changed solely by them",4); + continue; + } + + data.IssueId.Add(issue.Entity.Id); + + targets.Add(watcher.Entity.UserId.Value, data); + } + else + { + LogDebugMessage($"User {data.User.Fullname} is not active and therefore will not be sent a notification",4); + } + } + } + else + { + LogDebugMessage($"Email **Subscription** user {watcher.Entity.Email.ToLower()}",4); + if (emailTargets.ContainsKey(watcher.Entity.Email.ToLower())) + { + WatcherData data = emailTargets[watcher.Entity.Email.ToLower()]; + data = targets[data.User.Entity.Id]; + data.IssueId.Add(issue.Entity.Id); + } + else + { + WatcherData data = new WatcherData(); + data.User = new UserDto(new User()) + { + Entity = + { + Id = emailWatchers--, + Email = watcher.Entity.Email, + EmailMe = true, + EmailMeMyChanges = true + } + }; + data.User.Entity.ProjectGroups.Add(new ProjectGroupMembership { ProjectGroupId = Constants.GlobalGroupEveryone, UserId = data.User.Entity.Id }); + UserSettings settings = new UserSettings { IndividualFollowerAlerts = true }; + data.User.Entity.Settings = settings.ToJson(); + var group = new ProjectGroup { Id = Constants.GlobalGroupEveryone, Members = new List() }; + group.Members2.Add(new ProjectGroupMembership { UserId = data.User.Entity.Id, ProjectGroupId = Constants.GlobalGroupEveryone }); + data.User.ProjectGroups.Add(group); + data.IssueId.Add(issue.Entity.Id); + emailTargets.Add(watcher.Entity.Email.ToLower(), data); + targets.Add(data.User.Entity.Id, data); + } + } + } + } + + LogDebugMessage("", 2); + LogDebugMessage($"-------------------------------------------------------------------------------",2); + LogDebugMessage($"------- STEP2 - Now process these change notifications to {targets.Count} people ---------",2); + LogDebugMessage($"-------------------------------------------------------------------------------",2); + + AlertsTemplateHelper alerts = new AlertsTemplateHelper(Cache.Templates, Cache.BaseUrl); + //AlertsTemplateHelper alerts = new AlertsTemplateHelper(_templates, GetUrl(_issueManager)); + + // Now loop through users sending them watcher summary email + Dictionary> originalComments = new Dictionary>(); + List processedProjects; + + foreach (var target in targets) //Users + { + LogDebugMessage($"Processing User {target.Key} {target.Value?.User.Entity?.Username}",2); + processedProjects = new List(); + + if (originalComments.Count > 0) + { + foreach (var kv in originalComments) + { + IssueDto issue = changedIssues.Find(i => i.Entity.Id == kv.Key); + + // Safety check + if (issue == null || issue.Entity.IsNew) continue; + + issue.Comments = kv.Value; + } + + originalComments = new Dictionary>(); + } + + var recipient = target.Value; + + // Safety check + if (!recipient.User.Entity.EmailMe || recipient.User.Entity.Email.IsEmpty()) + { + LogDebugMessage($"{recipient.User.Fullname ?? recipient.User.Entity.Email ?? recipient.User.Entity.Id.ToString()} does not want emails, or has no email address set, skipping.",3); + continue; + } + + AlertTypeWatchersTemplateModel model = new AlertTypeWatchersTemplateModel(); + + model.TheRecipient = recipient.User; + + model.Version = GeminiVersion.Version; + + model.GeminiUrl = alerts.GeminiUrl; + + foreach (int issueId in recipient.IssueId) + { + IssueDto issue = changedIssues.Find(i => i.Entity.Id == issueId); + + // Safety check + if (issue == null || issue.Entity.IsNew) + { + LogDebugMessage($"Issue {issueId} could not be found or is marked as new, skipping",3); + continue; + } + + issue.ChangeLog = IssueManager.GetChangeLog(issue, IssueManager.UserContext.User, recipient.User, lastCheckedLocal); + + //var permissionManager = new PermissionsManager(recipient.User, _types, _permissionSets, _organizations, _issueManager.UserContext.Config.HelpDeskModeGroup, false); + var permissionManager = new PermissionsManager(recipient.User, Cache.Types, Cache.PermissionSets, Cache.Organizations, IssueManager.UserContext.Config.HelpDeskModeGroup, false); + + foreach (var comment in issue.Comments) + { + if (!permissionManager.CanSeeComment(issue, comment)) + { + originalComments.Add(issueId, issue.Comments); + + List comments = new List(issue.Comments); + + comments.RemoveAll(c => !permissionManager.CanSeeComment(issue, c)); + + issue.Comments = comments; + + break; //TODO this break is before all the comments added to original comments, so original comments are not complete. + } + } + + if (issue.ChangeLog.Count == 0) + { + LogDebugMessage($"{IssueDetail(issue)} does not have any changes from the ChangeLog, skipping",3); + continue; + } + + if (recipient.User.GetSettings().IndividualFollowerAlerts) + { + LogDebugMessage($"{UserDetail(recipient.User)} wants individual email alerts", 3); + var template = alerts.FindTemplateForProject(AlertTemplateType.Updated, issue.Entity.ProjectId); + + if (template == null) + { + LogDebugMessage("No update notification template found, skipping",3); + continue; + } + + var indModel = new AlertTypeIndividualTemplateModel + { + GeminiUrl = model.GeminiUrl, + LinkViewItem = NavigationHelper.GetIssueUrl(IssueManager.UserContext, issue.Entity.ProjectId, issue.EscapedProjectCode, issue.Entity.Id), + TheItem = issue, + TheRecipient = recipient.User, + Version = GeminiVersion.Version, + IsNewItem = false + }; + + string html = alerts.GenerateHtml(template, indModel); + + if (GeminiApp.GeminiLicense.IsFree) + { + html = alerts.AddSignature(html); + } + + string log; + + string subject = template.Options.Subject.HasValue() ? alerts.GenerateHtml(template, indModel, true) : string.Format("[{0}] {1} {2} ({3})", issue.IssueKey, issue.Type, "Updated", issue.Title, issue.IsClosed ? "Closed" : string.Empty); + LogDebugMessage($"Processing follower - Send item {issue.IssueKey} to {recipient.User.Entity.Email}",3); + //LogDebugMessage(string.Concat("Processing follower - Send item ", issue.IssueKey, " to ", recipient.User.Entity.Email)); + EmailHelper.Send(IssueManager.UserContext.Config, subject, html, recipient.User.Entity.Email, recipient.User.Fullname, true, out log); + LogDebugMessage($"Sending email to {recipient.User.Entity.Email} ({recipient.User.Entity.Fullname}) - {subject}"); + } + else + { + LogDebugMessage($"{UserDetail(recipient.User)} wants batched emails, added issue {issue.IssueKey} to updated item list", 3); + model.TheItemsUpdated.Add(issue); + } + + } + + if (recipient.User.GetSettings().IndividualFollowerAlerts) + { + LogDebugMessage($"Individual Alerts for this user has finished, moving to next user",2); + continue; + } + + // Safety check! + if (model.ChangeCount > 0) + { + var watcherAlertTemplates = alerts.Templates.FindAll(s => s.AlertType == AlertTemplateType.Watchers); + + if (watcherAlertTemplates.Count == 0) + { + LogDebugMessage("No follower notification template found",1); + continue; + } + + if (!watcherAlertTemplates.Any(p => p.GetAssociatedProjectValue().IsEmpty())) + { + List allItemProjects = model.TheItemsUpdated + .Select(item => item.Project) + .Where(project => !watcherAlertTemplates.Any(template => template.GetAssociatedProjects().Contains(project.Id))) + .ToList(); + + if (projectsMissingFollowerTemplate.Count > 0) + { + allItemProjects = allItemProjects.Where(s => !projectsMissingFollowerTemplate.Contains(s.Id)).ToList(); + } + + if (allItemProjects.Count > 0) + { + LogDebugMessage(string.Concat("No follower notification template found for project ", string.Join(", ", allItemProjects.Select(s => s.Name).Distinct()))); + projectsMissingFollowerTemplate.AddRange(allItemProjects.Select(s => s.Id).Distinct()); + } + } + + watcherAlertTemplates.Sort((x, y) => y.GetAssociatedProjectValue().CompareTo(x.GetAssociatedProjectValue())); + + foreach (var watcherTemplate in watcherAlertTemplates) + { + var allTemplateProjects = watcherTemplate.GetAssociatedProjects(); + + var issueForTemplate = allTemplateProjects.Count == 0 ? model.TheItemsUpdated : model.TheItemsUpdated.FindAll(s => allTemplateProjects.Contains(s.Entity.ProjectId)); + + if (issueForTemplate.Count == 0) + { + LogDebugMessage($"There are no issues for the selected watcher template - {watcherTemplate.Label}",2); + continue; + } + + + var projectIds = issueForTemplate.Select(s => s.Entity.ProjectId).Distinct(); + + if (processedProjects.Count > 0) + { + projectIds = projectIds.Where(s => !processedProjects.Contains(s)); + issueForTemplate = issueForTemplate.Where(s => !processedProjects.Contains(s.Entity.ProjectId)).ToList(); + } + + if (processedProjects.Contains(0) || projectIds.Count() == 0 || issueForTemplate.Count == 0) + { + LogDebugMessage($" Processed the all projects, or no issues for the current template {processedProjects.Contains(0)}|{projectIds.Count()}|{issueForTemplate.Count}", 2); + continue; + } + + AlertTypeWatchersTemplateModel projectTemplateModel = new AlertTypeWatchersTemplateModel(); + + projectTemplateModel.TheItemsUpdated.AddRange(issueForTemplate); + projectTemplateModel.TheRecipient = model.TheRecipient; + projectTemplateModel.Version = model.Version; + projectTemplateModel.GeminiUrl = model.GeminiUrl; + + AlertTemplate template = alerts.FindTemplateForProject(AlertTemplateType.Watchers, issueForTemplate.First().Entity.ProjectId); + + if (template.Id == 0) + { + LogDebugMessage($"Template id was zero",2); + continue; + } + + // Generate email template + string html = alerts.GenerateHtml(template, projectTemplateModel); + + if (GeminiApp.GeminiLicense.IsFree) html = alerts.AddSignature(html); + + string subject = template.Options.Subject.HasValue() ? alerts.GenerateHtml(template, projectTemplateModel, true) : string.Format("{0} {1}", projectTemplateModel.ChangeCount, "Gemini Updates"); + + // Send email + string log; + LogDebugMessage(string.Concat("Processing follower - Send items ", issueForTemplate.Select(i => i.IssueKey).ToDelimited(", "), " to ", recipient.User.Entity.Email)); + EmailHelper.Send(IssueManager.UserContext.Config, subject, html, recipient.User.Entity.Email, recipient.User.Entity.Fullname, true, out log); + + LogDebugMessage($"Sending email to {recipient.User.Entity.Email} {recipient.User.Entity.Fullname} - {subject}",3); + + if (allTemplateProjects.Count == 0) + { + processedProjects.Add(0); + } + else + { + processedProjects.AddRange(projectIds); + } + } + } + } + } + + private string IssueDetail(IssueDto issue) + { + return $"Item {issue.Title} ({issue.ProjectCode}:{issue.Entity.Id})"; + } + + private string UserDetail(UserDto user) + { + if (user.Fullname.HasValue()) + { + return $"User {user.Fullname} "; + } + if (user.Entity.Username.HasValue()) + { + return $"User {user.Entity.Username} "; + } + if (user.Entity.Email.HasValue()) + { + return $"User {user.Entity.Email} "; + } + if (user.Entity != null) + { + return $"User {user.Entity.Id}"; + } + return "unknown user"; + } + + private string UserDetail(IssueWatcherDto user) + { + return UserDetail(new UserDto + { + Entity = new User { Email = user.Email, Username = user.Username, Id = user.Entity.Id }, + Fullname = user.Fullname + }); + } + + private bool IsUserOnlyChange(List history, int userId) + { + return history.Find(h => h.Entity.UserId.GetValueOrDefault() != userId) == null; + } + } +} \ No newline at end of file diff --git a/INotificationAlert.cs b/INotificationAlert.cs new file mode 100644 index 0000000..eb34ff4 --- /dev/null +++ b/INotificationAlert.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Countersoft.Gemini.Infrastructure.Managers; + +namespace EmailNotificationEngine +{ + public interface INotificationAlert + { + void Send(); + List Log { get; set; } + bool CreateFileLog { get; set; } + event EventHandler OnMessageRaised; + } + + public class NotificationMessageEventArgs : EventArgs + { + public NotificationMessageEventArgs(string message, int level=1) + { + Message = message; + Level = level; + } + public string Message { get; } + public int Level { get; } + } + + public static class AlertFactory + { + public static IList GetAlerters(NotificationCache cache, IssueManager issueManager) + { + return new List + { + new WorkspaceNotification(cache, issueManager), + new FollowerNotification(cache, issueManager) + }; + } + } + + public enum ENotificationType + { + Workspace = 1, + Follower = 2 + } + +} diff --git a/NotificationCache.cs b/NotificationCache.cs new file mode 100644 index 0000000..6bff8e9 --- /dev/null +++ b/NotificationCache.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; +using Countersoft.Gemini; +using Countersoft.Gemini.Commons; +using Countersoft.Gemini.Commons.Dto; +using Countersoft.Gemini.Commons.Entity; +using Countersoft.Gemini.Commons.Entity.Security; +using Countersoft.Gemini.Contracts.Business; +using Countersoft.Gemini.Infrastructure.Managers; +using Microsoft.Practices.Unity; + +namespace EmailNotificationEngine +{ + public class NotificationCache + { + public List Templates { get; } + public List Types { get; } + public List PermissionSets { get; } + public List Organizations { get; } + + private IssueManager _issueManager; + public string BaseUrl { get; } + + public NotificationCache(IssueManager issueManager, string baseUrl) + { + _issueManager = issueManager; + BaseUrl = baseUrl; + + Templates = GeminiApp.Container.Resolve().FindWhere(c => c.AlertType != AlertTemplateType.Breeze).ToList(); + + Types = new MetaManager(issueManager).TypeGetAll(); + + PermissionSets = new PermissionSetManager(issueManager).GetAll(); + + Organizations = new OrganizationManager(issueManager).GetAll(); + } + } +} \ No newline at end of file diff --git a/WorkspaceNotification.cs b/WorkspaceNotification.cs new file mode 100644 index 0000000..99fad30 --- /dev/null +++ b/WorkspaceNotification.cs @@ -0,0 +1,431 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Countersoft.Foundation.Commons.Extensions; +using Countersoft.Gemini; +using Countersoft.Gemini.Commons; +using Countersoft.Gemini.Commons.Dto; +using Countersoft.Gemini.Commons.Entity; +using Countersoft.Gemini.Commons.Permissions; +using Countersoft.Gemini.Contracts.Business; +using Countersoft.Gemini.Infrastructure.Helpers; +using Countersoft.Gemini.Infrastructure.Managers; +using Countersoft.Gemini.Mailer; +using Countersoft.Gemini.Models; +using Microsoft.Practices.Unity; + +namespace EmailNotificationEngine +{ + public class WorkspaceNotification: BaseNotification, INotificationAlert + { + + public WorkspaceNotification(NotificationCache cache, IssueManager issueManager) + : base(cache, issueManager) + { + + } + + protected override void ProcessNotifications() + { + ProcessAppNavCardAlerts(); + } + + private void ProcessAppNavCardAlerts() + { + var navigationCardsManager = new NavigationCardsManager(IssueManager); + + List cards = navigationCardsManager.GetPendingAlertCards(); + + //LogDebugMessage("Email templates loaded: " + _templates.Count); + LogDebugMessage("Email templates loaded: " + Cache.Templates.Count); + + LogDebugMessage("Pending card alerts: " + cards.Count); + + // ? We need to store user id and issue id for every email we send out -- avoids dupes? + List issuesEmailedToUsers = new List(50); + + List individualIssuesEmailedToUsers = new List(50); + + //AlertsTemplateHelper alerts = new AlertsTemplateHelper(_templates, GetUrl(_issueManager)); + AlertsTemplateHelper alerts = new AlertsTemplateHelper(Cache.Templates, Cache.BaseUrl); + + UserManager userManager = new UserManager(IssueManager); + + bool refreshCache = false; + + var allOptOuts = navigationCardsManager.GetOptOuts(); + + foreach (NavigationCard card in cards) + { + List individualIssues = new List(); + + // Safety checks + if (!card.UserId.HasValue) + { + LogDebugMessage($"Card {card.Id} is not assigned to a user!",2); + continue; + } + + if (card.CardData.Alerts.Count == 0) + { + LogDebugMessage($"Card {card.Id} is does not have any alerts!",2); + continue; + } + UserDto recepient = userManager.Get(card.UserId.Value); + LogDebugMessage($"Processing Card {card.Key} ({card.Id}) for user {recepient.Entity.Username}, last sent {card.CardData.AlertsLastSent}",2); + + // Safety check + if (!recepient.Entity.EmailMe || recepient.Entity.Email.IsEmpty()) + { + LogDebugMessage($"User does not want emails, or email address not supplied",2); + continue; + } + + DateTime lastChecked = card.CardData.AlertsLastSent.HasValue ? card.CardData.AlertsLastSent.Value : card.Created; + + DateTime lastCheckedLocal = lastChecked.ToLocal(IssueManager.UserContext.User.TimeZone); + + AlertTypeAppNavAlertsTemplateModel model = new AlertTypeAppNavAlertsTemplateModel(); + + model.TheRecipient = recepient; + + model.Version = GeminiVersion.Version; + + model.GeminiUrl = alerts.GeminiUrl; + + List issuesToAlert = new List(card.CardData.Alerts); + LogDebugMessage($"Card has {issuesToAlert.Count} issues to alert", 2); + + foreach (int issueId in issuesToAlert) + { + IssueDto issue = null; + try + { + issue = IssueManager.Get(issueId); + } + catch (Exception ex) + { + LogException(ex); + } + + // Safety check + if (issue == null || issue.Entity.IsNew) + { + LogDebugMessage($"Item is null or is new - {issue?.Entity.Title}",2); + continue; + } + + // Dupe check + string dupeKey = string.Format("{0}-{1}-{2}", recepient.Entity.Id, issueId, card.Id); + + if (issuesEmailedToUsers.Contains(dupeKey)) + { + LogDebugMessage($"Already sent email to this user for this issue and card {dupeKey}"); + continue; + } + + //var permissionManager = new PermissionsManager(recepient, _types, _permissionSets, _organizations, _issueManager.UserContext.Config.HelpDeskModeGroup, false); + var permissionManager = new PermissionsManager(recepient, Cache.Types, Cache.PermissionSets, Cache.Organizations, IssueManager.UserContext.Config.HelpDeskModeGroup, false); + + if (!permissionManager.CanSeeItem(issue.Project, issue)) continue; + + + //TODO this is nonsense code... no need to loop if removing all items... + + try + { + foreach (var comment in issue.Comments) + { + if (!permissionManager.CanSeeComment(issue, comment)) + { + issue.Comments.RemoveAll(c => !permissionManager.CanSeeComment(issue, c)); + break; + } + } + } + catch (Exception ex) + { + LogDebugMessage($"There was an error with comments and permissions for item {issue.Title}"); + } + + // Remove the reported by first entry! + if (issue.History.Count > 0 && issue.History[issue.History.Count - 1].Entity.AttributeChanged == ItemAttributeVisibility.ReportedBy) issue.History.RemoveAt(issue.History.Count - 1); + issue.ChangeLog = IssueManager.GetChangeLog(issue, IssueManager.UserContext.User, recepient, lastCheckedLocal); + + // Populate model for email template + if (card.CardData.Subscription.IndividualAlert) individualIssues.Add(issue); + + if (card.CardData.Subscription.Created && issue.Created.ToUtc(IssueManager.UserContext.User.TimeZone) >= lastChecked) + { + LogDebugMessage($"Issue is new and created subscription is requested",2); + model.TheItemsCreated.Add(issue); + } + else + { + List allChanges = issue.History.FindAll(h => h.Entity.Created.ToUtc(IssueManager.UserContext.User.TimeZone) >= lastChecked); + + List commentChanges = allChanges.FindAll(a => !a.Entity.IsCustom && a.Entity.AttributeChanged == ItemAttributeVisibility.AssociatedComments); + + List nonCommentChanges = allChanges.FindAll(a => a.Entity.IsCustom || a.Entity.AttributeChanged != ItemAttributeVisibility.AssociatedComments); + + // Add comments and updates + if (card.CardData.Subscription.Updated && nonCommentChanges.Count > 0 || card.CardData.Subscription.Commented && commentChanges.Count > 0 && issue.Comments.Count > 0) + { + LogDebugMessage($"Issue is updated and updated subscription was requested", 2); + model.TheItemsUpdated.Add(issue); + } + + if (card.CardData.Subscription.Commented && commentChanges.Count > 0 && issue.Comments.Count > 0) + { + LogDebugMessage($"Issue has been commented on and comment subscription was requested", 2); + model.TheItemsCommented.Add(issue); + } + } + + // Record the fact that we have processed this issue for this recepient (to prevent dupes) + issuesEmailedToUsers.Add(dupeKey); + } + + model.CardTitle = string.Format("{0} {1}", card.Key, card.Title); + + model.CardKey = card.Key; + + model.CardDescription = card.Title; + + model.CardComment = card.CardData.Comment; + + model.CardUrl = string.Concat(model.GeminiUrl, "workspace/", card.Id, '/', card.Url); + + // Safety check! + if (model.ChangeCount > 0) + { + List subscribers = + GetCardSubscribers(card, navigationCardsManager, userManager, recepient); + + //if (!subscribers.Contains(recepient) && subscribers.Find(u => u.Entity.Id == recepient.Entity.Id) == null) subscribers.Insert(0, recepient); + if (card.CardData.Subscription.IndividualAlert) + { + foreach (var user in subscribers) + { + if (allOptOuts.Any(s => + s.UserId == user.Entity.Id && s.CardId == card.Id && + s.OptOutType == OptOutEmails.OptOutTypes.Alert)) + { + LogDebugMessage($"User {user.Entity.Fullname} has opted out of this alert (card:{card.Id})",2); + continue; + } + + foreach (var issue in individualIssues) + { + string individualDupeKey = string.Format("{0}-{1}", user.Entity.Id, issue.Entity.Id); + + if (individualIssuesEmailedToUsers.Contains(individualDupeKey)) + { + LogDebugMessage($"The user has already received this combination - key:{individualDupeKey}",2); + continue; + } + + if (user != recepient) + { + //var permissionManager = new PermissionsManager(user, _types, _permissionSets, _organizations, IssueManager.UserContext.Config.HelpDeskModeGroup, false); + var permissionManager = new PermissionsManager(user, Cache.Types, + Cache.PermissionSets, Cache.Organizations, + IssueManager.UserContext.Config.HelpDeskModeGroup, false); + + if (!permissionManager.CanSeeItem(issue.Project, issue)) + { + LogDebugMessage($"The user {user.Entity.Fullname} does not have permission to view this item {issue.IssueKey}"); + continue; + } + + issue.ChangeLog = IssueManager.GetChangeLog(issue, IssueManager.UserContext.User, + user, lastCheckedLocal); + } + + var indModel = new AlertTypeIndividualTemplateModel(); + + indModel.GeminiUrl = model.GeminiUrl; + + indModel.LinkViewItem = NavigationHelper.GetIssueUrl(IssueManager.UserContext, + issue.Entity.ProjectId, issue.EscapedProjectCode, issue.Entity.Id); + + indModel.TheItem = issue; + + indModel.TheRecipient = user; + + indModel.Version = GeminiVersion.Version; + + indModel.IsNewItem = model.TheItemsCreated.Contains(issue); + + indModel.CardKey = model.CardKey; + + indModel.CardDescription = model.CardDescription; + + indModel.CardComment = model.CardComment; + + indModel.CardUrl = model.CardUrl; + + if (!indModel.IsNewItem && issue.ChangeLog.Count == 0) continue; + + var template = alerts.FindTemplateForProject( + indModel.IsNewItem ? AlertTemplateType.Created : AlertTemplateType.Updated, + issue.Entity.ProjectId); + + string html = alerts.GenerateHtml(template, indModel); + + if (GeminiApp.GeminiLicense.IsFree) html = alerts.AddSignature(html); + + // Send email + string log; + + string subject = template.Options.Subject.HasValue() + ? alerts.GenerateHtml(template, indModel, true) + : string.Format("[{0}] {1} {2} ({3})", issue.IssueKey, issue.Type, + model.TheItemsCreated.Contains(issue) ? "Created" : "Updated", issue.Title, + issue.IsClosed ? "Closed" : string.Empty); + + LogDebugMessage($"Sending email to {user.Entity.Email} subject: {subject}"); + EmailHelper.Send(IssueManager.UserContext.Config, subject, html, user.Entity.Email, + user.Fullname, true, out log); + + individualIssuesEmailedToUsers.Add(individualDupeKey); + } + } + } + else + { + LogDebugMessage($"Batched emails requested"); + + var cloneCreated = new List(model.TheItemsCreated); + + var cloneUpdated = new List(model.TheItemsUpdated); + + var cloneCommented = new List(model.TheItemsCommented); + + // Find email template to use (for this project or fall back to default template) + AlertTemplate template = alerts.FindTemplateForProject(AlertTemplateType.AppNavAlerts, 0); + + foreach (var user in subscribers) + { + if (allOptOuts.Any(s => + s.UserId == user.Entity.Id && s.CardId == card.Id && + s.OptOutType == OptOutEmails.OptOutTypes.Alert)) + { + LogDebugMessage($"User {user.Entity.Fullname} has opted out of this alert (card:{card.Id})", 2); + continue; + } + + model.TheItemsCreated = new List(cloneCreated); + + model.TheItemsUpdated = new List(cloneUpdated); + + model.TheItemsCommented = new List(cloneCommented); + + if (user != recepient) + { + //var permissionManager = new PermissionsManager(user, _types, _permissionSets, _organizations, IssueManager.UserContext.Config.HelpDeskModeGroup, false); + var permissionManager = new PermissionsManager(user, Cache.Types, Cache.PermissionSets, + Cache.Organizations, IssueManager.UserContext.Config.HelpDeskModeGroup, false); + + model.TheItemsCreated.RemoveAll(i => !permissionManager.CanSeeItem(i.Project, i)); + + model.TheItemsUpdated.RemoveAll(i => !permissionManager.CanSeeItem(i.Project, i)); + + model.TheItemsCommented.RemoveAll(i => !permissionManager.CanSeeItem(i.Project, i)); + + foreach (var issue in model.TheItemsCreated.Concat(model.TheItemsUpdated) + .Concat(model.TheItemsCommented)) + { + issue.ChangeLog = IssueManager.GetChangeLog(issue, IssueManager.UserContext.User, + user, lastCheckedLocal); + } + } + + //model.TheItemsCreated.RemoveAll(i => i.ChangeLog.Count == 0); + model.TheItemsUpdated.RemoveAll(i => i.ChangeLog.Count == 0); + + model.TheItemsCommented.RemoveAll(i => i.ChangeLog.Count == 0); + + if (model.ChangeCount == 0) + { + LogDebugMessage($"There were no changes visible for this user",2); + continue; + } + + // Generate email template + string html = alerts.GenerateHtml(template, model); + + if (GeminiApp.GeminiLicense.IsFree) html = alerts.AddSignature(html); + + string subject = template.Options.Subject.HasValue() + ? alerts.GenerateHtml(template, model, true) + : string.Format("{0} {1}", card.Key, card.Title); + + // Send email + string log; + + LogDebugMessage($"Sending email to {user.Entity.Email} subject: {subject}"); + EmailHelper.Send(IssueManager.UserContext.Config, subject, html, user.Entity.Email, + user.Fullname, true, out log); + } + } + } + else + { + LogDebugMessage("No changes to show for this model",2); + } + + // Remove the alert notifications and update the database + lock (card.CardData.Alerts) + { + card.CardData.Alerts.RemoveAll(a => issuesToAlert.Contains(a)); + } + + card.CardData.AlertsLastSent = DateTime.UtcNow; + LogDebugMessage($"Updating the cards's last updated date to {card.CardData.AlertsLastSent}",2); + + refreshCache = true; + + navigationCardsManager.Update(card, false, false); + } + + if (refreshCache) + { + LogDebugMessage($"Refreshing the navigation card cache"); + navigationCardsManager.Cache.NavigationCards.Invalidate(); + var webNodes = GeminiApp.Container.Resolve(); + webNodes.AddDataOnAllNodesButMe(new WebNodeData() { NodeGuid = GeminiApp.GUID, Key = "cache", Value = navigationCardsManager.Cache.NavigationCards.CacheKey }); + } + } + + + private List GetCardSubscribers(NavigationCard card, NavigationCardsManager cardManager, UserManager userManager, UserDto owner) + { + if (!owner.Entity.Active) return new List(); + + Dictionary subscribers = new Dictionary(); + + subscribers.Add(owner.Entity.Id, owner); + + foreach (var user in card.CardData.Subscription.Users) + { + var userDto = userManager.Get(user); + + if (user != owner.Entity.Id && userDto != null && userDto.Entity.Active) subscribers.Add(user, userDto); + } + + var groupUsers = cardManager.GetUsersFromGroups(card, card.CardData.Subscription.Groups); + + foreach (var user in groupUsers) + { + if (!subscribers.ContainsKey(user.Entity.Id)) subscribers.Add(user.Entity.Id, user); + } + + return new List(subscribers.Values); + } + + + + } +}