using System; using System.Linq; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using AyaNova.Models; using AyaNova.Util; namespace AyaNova.Biz { /// /// Notification processor /// turn notifyEvent records into notifications for in app and deliver smtp /// /// internal static class CoreJobNotify { private static bool NotifyIsRunning = false; private static ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger("CoreJobNotify"); private static DateTime lastRun = DateTime.MinValue; // private static TimeSpan DELETE_AFTER_AGE = new TimeSpan(90, 0, 0, 0); // private static TimeSpan RUN_EVERY_INTERVAL = new TimeSpan(0, 2, 0);//once every 2 minutes minimum private static DateTime lastNotifyHealthCheckSentLocal = DateTime.MinValue; private static TimeSpan TS_24_HOURS = new TimeSpan(24, 0, 0);//used to ensure daily ops happen no more than that #if (DEBUG) private static TimeSpan DELETE_AFTER_AGE = new TimeSpan(0, 12, 0, 0); private static TimeSpan RUN_EVERY_INTERVAL = new TimeSpan(0, 0, 20);//no more frequently than once every 20 seconds #else private static TimeSpan DELETE_AFTER_AGE = new TimeSpan(90, 0, 0, 0); private static TimeSpan RUN_EVERY_INTERVAL = new TimeSpan(0, 2, 0);//no more frequently than once every 2 minutes #endif //temporary list to hold translations as required during delivery private static Dictionary> _NotifyEventTypeTransCache = new Dictionary>(); private static Dictionary> _AyaTypeTypeTransCache = new Dictionary>(); private static Dictionary _UserTranslationIdCache = new Dictionary(); private static Dictionary _ServerTheWordTranslations = new Dictionary(); //////////////////////////////////////////////////////////////////////////////////////////////// // DoSweep // public static async Task DoWorkAsync() { log.LogTrace("Checking if Notify should run"); if (NotifyIsRunning) { log.LogTrace("Notify is running already exiting this cycle"); return; } //This will get triggered roughly every minute, but we don't want to deliver that frequently if (DateTime.UtcNow - lastRun < RUN_EVERY_INTERVAL) { log.LogTrace($"Notify ran less than {RUN_EVERY_INTERVAL}, exiting this cycle"); return; } try { NotifyIsRunning = true; log.LogTrace("Notify set to RUNNING state and starting now"); //NotifyHealthCheck processing //Note this deliberately uses LOCAL time in effort to deliver the health check first thing in the morning for workers //However if server is on UTC already then that's what is used, there is no adjustment DateTime dtNowLocal = DateTime.Now; if (dtNowLocal - lastNotifyHealthCheckSentLocal > TS_24_HOURS) { //are we in the 7th to 9th hour? if (dtNowLocal.Hour > 6 && dtNowLocal.Hour < 10) { log.LogTrace("Notify health check submitted to subscribers"); await NotifyEventHelper.AddGeneralNotifyEvent(NotifyEventType.NotifyHealthCheck, "OK", ""); lastNotifyHealthCheckSentLocal = dtNowLocal; } } using (AyContext ct = AyaNova.Util.ServiceProviderProvider.DBContext) { //select all jobs with no deliver date or deliver date no longer in future //IMPLEMENTATION NOTES: This code // finds the events in NotifyEvent // check subscription to determine if the age related ones are deliverable now // check subscriptions for each delivery type, i.e. there might be both smtp and inapp separately for the same event so it needs to know that and deliver accordingly // remove the notify event after it's processed //Older notes: //### PLAN if it's an smtp delivery that fails and it's to someone who can be delivered in app then it should send an inapp notification of //delivery failure and still delete the smtp delivery //If it's not possible to notify the person via in app of the failed smtp then perhaps it notifies OPS personnel and biz admin personnel //NEW NOTIFICATION SUBSCRIPTION EVENT TYPE: //OPERATIONS_PROBLEMS - backup, notifications, out of memory, what have you, anyone can subscribe to it regardless of rights //this is just to let people know there is a problem //todo: create message here if not already set? //todo: Link to open report in here //CLIENT EXPECTS: open report links to have a query string [CLIENTAPPURL]/viewreport?oid=[objectid]&rid=[reportid] //All items have an event date, for non time delayed events it's just the moment it was created //which will predate this moment now if it's pre-existing var events = await ct.NotifyEvent.Include(z => z.User).Include(z => z.NotifySubscription).AsNoTracking().ToListAsync(); log.LogTrace($"Found {events.Count} NotifyEvents to examine for potential delivery"); //cache translations //Get all subscription unique userId's that aren't inapp deliveries var usersNeedingTranslations = events.Where(z => z.NotifySubscription.DeliveryMethod != NotifyDeliveryMethod.App && z.User.Active).Select(z => z.NotifySubscription.UserId).ToList().Distinct(); foreach (long userid in usersNeedingTranslations) { var usr = await ct.User.AsNoTracking().Include(z => z.UserOptions).SingleAsync(z => z.Id == userid); long transId = usr.UserOptions.TranslationId; _UserTranslationIdCache.Add(userid, transId); await CacheTranslations(transId, usr.Roles); } //cache all translations of the word "Server" for server notifications _ServerTheWordTranslations = await TranslationBiz.GetAllTranslationsForKey("Server"); //iterate and deliver foreach (var notifyevent in events) { //no notifications for inactive users, just delete it as if it was delivered if (!notifyevent.User.Active) { log.LogTrace($"Inactive user {notifyevent.User.Name}, removing notify rather than delivering it: {notifyevent}"); ct.NotifyEvent.Remove(notifyevent); await ct.SaveChangesAsync(); continue; } //TIME DELAYED AGED EVENT? //when to time delay deliver formula:If sub.agevalue!= timespan.zero then deliver on = //NotifyEvent "EventDate"+NotifySubscription.AgeValue timespan - NotifySubscription AdvanceNotice timespan > utcNow //Is it time delayed? //(NOTE: some direct notifications in app e.g. overdue review will have a future date set as a deadman switch to deliver after unless completed before then //their EventDate will be that future date, however regular in app notifications will go through here too but their evendate will be the moment they are created so should still //deliver immediately-ish) if (notifyevent.NotifySubscription.AgeValue != TimeSpan.Zero) { var deliverAfter = notifyevent.EventDate + notifyevent.NotifySubscription.AgeValue - notifyevent.NotifySubscription.AdvanceNotice; if (deliverAfter < DateTime.UtcNow) { if (notifyevent.NotifySubscription.DeliveryMethod == NotifyDeliveryMethod.App) { await DeliverInApp(notifyevent, ct); } if (notifyevent.NotifySubscription.DeliveryMethod == NotifyDeliveryMethod.SMTP) { await DeliverSMTP(notifyevent, ct); } } } else { //COULD BE TIME DELAYED BUT WITHOUT AGE, i.e. EventDate takes precedence? //NORMAL IMMEDIATE DELIVERY EVENT if (notifyevent.NotifySubscription.DeliveryMethod == NotifyDeliveryMethod.App) { await DeliverInApp(notifyevent, ct); } if (notifyevent.NotifySubscription.DeliveryMethod == NotifyDeliveryMethod.SMTP) { await DeliverSMTP(notifyevent, ct); } } } } } catch (Exception ex) { log.LogError(ex, $"Error processing notification event"); } finally { log.LogTrace("Notify is done setting to not running state and tagging lastRun timestamp"); _UserTranslationIdCache.Clear(); _NotifyEventTypeTransCache.Clear(); _AyaTypeTypeTransCache.Clear(); lastRun = DateTime.UtcNow; NotifyIsRunning = false; } } //cache any translations required for email notification private static async Task CacheTranslations(long translationId, AuthorizationRoles roles) { if (!_NotifyEventTypeTransCache.ContainsKey(translationId)) _NotifyEventTypeTransCache.Add(translationId, await AyaNova.Api.Controllers.EnumListController.GetEnumList( "NotifyEventType", translationId, roles)); if (!_AyaTypeTypeTransCache.ContainsKey(translationId)) _AyaTypeTypeTransCache.Add( translationId, await AyaNova.Api.Controllers.EnumListController.GetEnumList( "AyaType", translationId, roles)); } //Used for subject of email and message deliveries private static string GetTranslatedNotifyEventName(NotifyEventType net, long translationId) { return _NotifyEventTypeTransCache.First(z => z.Key == translationId).Value.First(z => z.Id == (int)net).Name; } private static string GetTranslatedAyaTypeName(AyaType at, long translationId) { return _AyaTypeTypeTransCache.First(z => z.Key == translationId).Value.First(z => z.Id == (int)at).Name; } private static async Task DeliverInApp(NotifyEvent ne, AyContext ct) { log.LogTrace($"DeliverInApp deliving notify event: {ne}"); await ct.Notification.AddAsync(new Notification() { UserId = ne.UserId, AyaType = ne.AyaType, ObjectId = ne.ObjectId, EventType = ne.EventType, NotifySubscriptionId = ne.NotifySubscriptionId, Message = ne.Message, Name = ne.Name }); ct.NotifyEvent.Remove(ne); await ct.SaveChangesAsync(); } private static async Task DeliverSMTP(NotifyEvent ne, AyContext ct) { log.LogTrace($"DeliverSMTP delivering notify event: {ne}"); if (string.IsNullOrWhiteSpace(ne.NotifySubscription.DeliveryAddress)) await NotifyEventHelper.AddGeneralNotifyEvent(NotifyEventType.GeneralNotification, $"No email address is set in subscription to deliver email notification for this event:{ne}", "Error", null, ne.UserId); var transid = _UserTranslationIdCache[ne.NotifySubscription.UserId]; var name = ne.Name; if (name == "~SERVER~") name = _ServerTheWordTranslations.First(z => z.Key == transid).Value; //AyaType translation var AyaTypeTranslated = string.Empty; if (ne.AyaType != AyaType.NoType) AyaTypeTranslated = $":{GetTranslatedAyaTypeName(ne.AyaType, transid)}"; var subject = $"AY{AyaTypeTranslated}:{GetTranslatedNotifyEventName(ne.EventType, transid)}:{name}"; //subscription link translation var SubscriptionTypeName = GetTranslatedAyaTypeName(AyaType.NotifySubscription, transid); IMailer m = AyaNova.Util.ServiceProviderProvider.Mailer; try { var body = ""; //NOTE: if need any other exemptions besides backup status make a separate static function "CanOpen(NotifyEventType)" if (ne.ObjectId != 0 || ne.EventType == NotifyEventType.BackupStatus) { body = OpenObjectUrlBuilder(ne.AyaType, ne.ObjectId, ne.EventType) + "\n"; } body += ne.Message; //Add link to subscription //http://localhost:8080/open/51/1 //add subscription link, notifysub is object type 51 if (!body.EndsWith('\n')) body += "\n"; body += $"-----\n({SubscriptionTypeName}: {OpenSubscriptionUrlBuilder(ne.NotifySubscriptionId)} )\n"; if (!ServerGlobalOpsSettingsCache.Notify.SmtpDeliveryActive) { await NotifyEventHelper.AddGeneralNotifyEvent(NotifyEventType.GeneralNotification, $"Email notifications are set to OFF at server, unable to send email notification for this event:{ne}", "Error", null, ne.UserId); log.LogInformation($"** WARNING: SMTP notification is currently set to Active=False; unable to deliver email notification, re-routed to in-app notification instead [UserId={ne.UserId}, Notify subscription={ne.NotifySubscriptionId}]. Change this setting or have users remove email delivery notifications if this is permanent **"); } else { await m.SendEmailAsync(ne.NotifySubscription.DeliveryAddress, subject, body, ServerGlobalOpsSettingsCache.Notify); } } catch (Exception ex) { await NotifyEventHelper.AddOpsProblemEvent("SMTP Notification failed", ex); await NotifyEventHelper.AddGeneralNotifyEvent(NotifyEventType.GeneralNotification, $"An error prevented delivering the following notification via email. System operator users have been notified:{ne}", "Error", null, ne.UserId); } finally { //remove event no matter what ct.NotifyEvent.Remove(ne); await ct.SaveChangesAsync(); } } //Called from ops notification settings to test smtp setup by delivering to address of choosing public static async Task TestSMTPDelivery(string toAddress) { //DO TEST DELIVERY HERE USING EXACT SAME SETTINGS AS FOR DeliverSMTP above //todo: abstract out email sending to it's own class maybe or whatever method I choose supports the best //https://jasonwatmore.com/post/2020/07/15/aspnet-core-3-send-emails-via-smtp-with-mailkit //https://medium.com/@ffimnsr/sending-email-using-mailkit-in-asp-net-core-web-api-71b946380442 IMailer m = AyaNova.Util.ServiceProviderProvider.Mailer; try { await m.SendEmailAsync(toAddress, "Test from Notification system", "This is a test to confirm notification system is working", ServerGlobalOpsSettingsCache.Notify); return "ok"; } catch (Exception ex) { await NotifyEventHelper.AddOpsProblemEvent("SMTP (TEST) Notification failed", ex); return ExceptionUtil.ExtractAllExceptionMessages(ex); } } //Open object url //### NOTE: If this is required anywhere else, move it to a central BizUtils class in Biz folder callable from here and there private static string OpenObjectUrlBuilder(AyaType aType, long id, NotifyEventType net) { var ServerUrl = ServerGlobalOpsSettingsCache.Notify.AyaNovaServerURL; if (string.IsNullOrWhiteSpace(ServerUrl)) { NotifyEventHelper.AddOpsProblemEvent("Notification system: The OPS Notification setting is empty for AyaNova Server URL. This prevents Notification system from linking events to openable objects.").Wait(); return "OPS ERROR NO SERVER URL CONFIGURED"; } ServerUrl = ServerUrl.Trim().TrimEnd('/'); //HANDLE ITEMS WITHOUT TYPE OR ID if (net == NotifyEventType.BackupStatus) { return $"{ServerUrl}/ops-backup"; } //Might not have a type or id in which case nothing directly to open if (aType == AyaType.NoType || id == 0) { return ServerUrl; } if (NotifyEventType.ObjectDeleted == net) { //goto event log for item // path: "/history/:ayatype/:recordid/:userlog?", return $"{ServerUrl}/history/{(int)aType}/{id}"; } //default is to open the object in question directly return $"{ServerUrl}/open/{(int)aType}/{id}"; } //Used to directly open a report at client private static string OpenReportUrlBuilder(long objectId, long reportId) { //CLIENT EXPECTS: open report links to have a query string [CLIENTAPPURL]/viewreport?oid=[objectid]&rid=[reportid] var ServerUrl = ServerGlobalOpsSettingsCache.Notify.AyaNovaServerURL; if (string.IsNullOrWhiteSpace(ServerUrl)) { NotifyEventHelper.AddOpsProblemEvent("Notification system: The OPS Notification setting is empty for AyaNova Server URL. This prevents Notification system from linking events to openable objects.").Wait(); return "OPS ERROR NO SERVER URL CONFIGURED"; } ServerUrl = ServerUrl.Trim().TrimEnd('/'); return $"{ServerUrl}/viewreport?oid={objectId}&rid={reportId}"; } //url to open subscription for editing private static string OpenSubscriptionUrlBuilder(long id) { var ServerUrl = ServerGlobalOpsSettingsCache.Notify.AyaNovaServerURL; if (string.IsNullOrWhiteSpace(ServerUrl)) { NotifyEventHelper.AddOpsProblemEvent("Notification system: The OPS Notification setting is empty for AyaNova Server URL. This prevents Notification system from linking events to openable objects.").Wait(); return "OPS ERROR NO SERVER URL CONFIGURED"; } ServerUrl = ServerUrl.Trim().TrimEnd('/'); //default is to open the object in question directly return $"{ServerUrl}/open/{(int)AyaType.NotifySubscription}/{id}"; } ///////////////////////////////////////////////////////////////////// }//eoc }//eons