using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using AyaNova.Models; using AyaNova.Util; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; namespace AyaNova.Biz { /// /// Notification processor /// turn notifyEvent records into inappnotification records for in app viewing and / or deliver smtp notifications seperately /// /// internal static class CoreJobCustomerNotify { private static bool NotifyIsRunning = false; private static ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger( "CoreJobCustomerNotify" ); private static DateTime lastRun = DateTime.MinValue; #if (DEBUG) private static TimeSpan RUN_EVERY_INTERVAL = new TimeSpan(0, 0, 21); //no more frequently than once every 20 seconds #else private static TimeSpan RUN_EVERY_INTERVAL = new TimeSpan(0, 1, 1); //no more frequently than once every 1 minute #endif //////////////////////////////////////////////////////////////////////////////////////////////// // DoSweep // public static async Task DoWorkAsync() { log.LogTrace("Checking if CustomerNotify should run"); if (NotifyIsRunning) { log.LogTrace("CustomerNotify 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( $"CustomerNotify ran less than {RUN_EVERY_INTERVAL} ago, exiting this cycle" ); return; } try { NotifyIsRunning = true; log.LogDebug("CustomerNotify set to RUNNING state and starting now"); using (AyContext ct = AyaNova.Util.ServiceProviderProvider.DBContext) { var customerevents = await ct.CustomerNotifyEvent.AsNoTracking().ToListAsync(); log.LogDebug( $"Found {customerevents.Count} CustomerNotifyEvents to examine for potential delivery" ); //iterate and deliver foreach (var customernotifyevent in customerevents) { //no notifications for inactive users, just delete it as if it was delivered var CustInfo = await ct .Customer.AsNoTracking() .Where(x => x.Id == customernotifyevent.CustomerId) .Select(x => new { x.Name, x.Active, x.Tags, x.EmailAddress, }) .FirstOrDefaultAsync(); if (!CustInfo.Active) { log.LogDebug( $"Inactive Customer {CustInfo.Name}, removing notify rather than delivering it: {customernotifyevent}" ); ct.CustomerNotifyEvent.Remove(customernotifyevent); await ct.SaveChangesAsync(); continue; } if (string.IsNullOrWhiteSpace(CustInfo.EmailAddress)) { log.LogDebug( $"Customer {CustInfo.Name} has no email address, removing notify rather than delivering it: {customernotifyevent}" ); ct.CustomerNotifyEvent.Remove(customernotifyevent); await ct.SaveChangesAsync(); continue; } //Get subscription for delivery var Subscription = await ct .CustomerNotifySubscription.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == customernotifyevent.CustomerNotifySubscriptionId ); //NOTE: There is no need to separate out future delivery and immediate delivery because // All events have an event date, it's either immediate upon creation or it's future // but not all events have an age value including ones with future event dates, // and the default agevalue and advancenotice are both zero regardless so the block below works for either future or immediate deliveries var deliverAfter = customernotifyevent.EventDate + Subscription.AgeValue - Subscription.AdvanceNotice; if (deliverAfter < DateTime.UtcNow) { //Do the delivery, it's kosher await DeliverCustomerNotificationSMTP( customernotifyevent, Subscription, CustInfo.EmailAddress, ct ); } } } } catch (Exception ex) { log.LogError(ex, $"Error processing customer notification event"); DbUtil.HandleIfDatabaseUnavailableTypeException(ex); } finally { log.LogDebug( "CustomerNotify is done setting to not running state and tagging lastRun timestamp" ); lastRun = DateTime.UtcNow; NotifyIsRunning = false; } } //=== private static async Task DeliverCustomerNotificationSMTP( CustomerNotifyEvent ne, CustomerNotifySubscription subscription, string deliveryAddress, AyContext ct ) { var DeliveryLogItem = new CustomerNotifyDeliveryLog() { Processed = DateTime.UtcNow, ObjectId = ne.ObjectId, CustomerNotifySubscriptionId = ne.CustomerNotifySubscriptionId, Fail = false, }; try { log.LogDebug($"DeliverCustomerNotificationSMTP delivering notify event: {ne}"); if (string.IsNullOrWhiteSpace(deliveryAddress)) { DeliveryLogItem.Fail = true; DeliveryLogItem.Error = $"No email address provided for smtp delivery; event: {ne}"; } else { if (!ServerGlobalOpsSettingsCache.Notify.SmtpDeliveryActive) { await NotifyEventHelper.AddOpsProblemEvent( $"Email notifications are set to OFF at server, unable to send Customer email notification for this event:{ne}" ); log.LogInformation( $"** WARNING: SMTP notification is currently set to Active=False; unable to deliver Customer email notification, [CustomerId={ne.CustomerId}, Customer Notify subscription={ne.CustomerNotifySubscriptionId}]. Change this setting or remove all Customer notifications if this is permanent **" ); DeliveryLogItem.Fail = true; DeliveryLogItem.Error = $"Email notifications are set to OFF at server, unable to send Customer email notification for this event: {ne}"; } else { //BUILD SUBJECT AND BODY FROM TOKENS IF REQUIRED var Subject = subscription.Subject; var Body = subscription.Template; if (Subject.Contains("{{") || Body.Contains("{{")) { //fetch the object with viz fields for easy templatization switch (ne.AyaType) { case AyaType.Quote: { var qt = await ct .Quote.AsNoTracking() .FirstOrDefaultAsync(z => z.Id == ne.ObjectId); if (qt == null) { //maybe deleted, this can't proceed throw new ApplicationException( $"Unable to make delivery for customer notify event as Quote {ne.Name} was not found during delivery, deleted?" ); } var CustomerName = await ct .Customer.AsNoTracking() .Where(x => x.Id == qt.CustomerId) .Select(x => x.Name) .FirstOrDefaultAsync(); Subject = SetQuoteTokens(Subject, qt, CustomerName); Body = SetQuoteTokens(Body, qt, CustomerName); } break; case AyaType.WorkOrder: { var wo = await ct .WorkOrder.AsNoTracking() .FirstOrDefaultAsync(z => z.Id == ne.ObjectId); if (wo == null) { //maybe deleted, this can't proceed throw new ApplicationException( $"Unable to make delivery for customer notify event as WorkOrder {ne.Name} was not found during delivery, deleted?" ); } var CustomerName = await ct .Customer.AsNoTracking() .Where(x => x.Id == wo.CustomerId) .Select(x => x.Name) .FirstOrDefaultAsync(); var StatusName = await ct .WorkOrderStatus.AsNoTracking() .Where(x => x.Id == wo.LastStatusId) .Select(x => x.Name) .FirstOrDefaultAsync(); Subject = SetWorkOrderTokens( Subject, wo, CustomerName, StatusName ); Body = SetWorkOrderTokens( Body, wo, CustomerName, StatusName ); } break; case AyaType.CustomerServiceRequest: { var csr = await ct .CustomerServiceRequest.AsNoTracking() .FirstOrDefaultAsync(z => z.Id == ne.ObjectId); if (csr == null) { //maybe deleted, this can't proceed throw new ApplicationException( $"Unable to make delivery for customer notify event as CustomerServiceRequest {ne.Name} was not found during delivery, deleted?" ); } var CustomerName = await ct .Customer.AsNoTracking() .Where(x => x.Id == csr.CustomerId) .Select(x => x.Name) .FirstOrDefaultAsync(); var UserName = await ct .User.AsNoTracking() .Where(x => x.Id == csr.RequestedByUserId) .Select(x => x.Name) .FirstOrDefaultAsync(); Subject = SetCSRTokens( Subject, csr, CustomerName, UserName ); Body = SetCSRTokens(Body, csr, CustomerName, UserName); } break; } } IMailer m = AyaNova.Util.ServiceProviderProvider.Mailer; //generate report if applicable bool isReportableEvent = false; switch (ne.EventType) { case NotifyEventType.QuoteStatusChange: case NotifyEventType.WorkorderCompleted: case NotifyEventType.WorkorderStatusChange: isReportableEvent = true; break; } if (isReportableEvent && subscription.LinkReportId != null) { long subTranslationId = (long)subscription.TranslationId; ReportBiz biz = new ReportBiz( ct, 1, subTranslationId, AuthorizationRoles.BizAdmin ); //example with workorder report //{"AType":34,"selectedRowIds":[355],"ReportId":9,"ClientMeta":{"UserName":"AyaNova SuperUser","Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOiIxNjQ2NzgyNTc4IiwiaXNzIjoiYXlhbm92YS5jb20iLCJpZCI6IjEifQ.ad7Acq54JCRGitDWKDJFFnqKkidbdaKaFmj-RA_RG5E","DownloadToken":"NdoU8ca3LG4L39Tj2oi3UReeeM7FLevTgbgopTPhGbA","TimeZoneName":"America/Los_Angeles","LanguageName":"en-US","Hour12":true,"CurrencyName":"USD","DefaultLocale":"en","PDFDate":"3/3/22","PDFTime":"3:38 PM"}} var reportRequest = new DataListReportRequest(); reportRequest.AType = ne.AyaType; reportRequest.ReportId = (long)subscription.LinkReportId; reportRequest.SelectedRowIds = new long[] { ne.ObjectId }; var jwt = Api.Controllers.AuthController.GenRpt(subTranslationId); //this could be adjusted by culture if we allow user to set a culture but that's getting a bit into the weeds, likely the server default is fine var pdfDate = new DateTime().ToShortDateString(); var pdfTime = new DateTime().ToShortTimeString(); var h12 = subscription.Hour12 ? "true" : "false"; reportRequest.ClientMeta = JToken.Parse( $"{{'UserName':'-','Authorization':'Bearer {jwt}','TimeZoneName':'{subscription.TimeZoneOverride}','LanguageName':'{subscription.LanguageOverride}','Hour12':{h12},'CurrencyName':'{subscription.CurrencyName}','DefaultLocale':'en','PDFDate':'{pdfDate}','PDFTime':'{pdfTime}'}}" ); //get port number var match = System.Text.RegularExpressions.Regex.Match( ServerBootConfig.AYANOVA_USE_URLS, "[0-9]+" ); var API_URL = $"http://127.0.0.1:{match.Value}/api/{AyaNovaVersion.CurrentApiVersion}/"; var jobid = await biz.RequestRenderReport( reportRequest, DateTime.UtcNow.AddMinutes( ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT ), API_URL, ServerBootConfig.CUSTOMER_NOTIFICATION_ATTACHED_REPORT_RENDER_USERNAME ); if (jobid == null) { throw new ApplicationException( $"Report render job id is null failed to start" ); } else { bool done = false; DateTime bailAfter = DateTime.Now.AddMinutes( ServerBootConfig.AYANOVA_REPORT_RENDERING_TIMEOUT ); while (!done && DateTime.Now < bailAfter) { var status = await JobsBiz.GetJobStatusAsync((Guid)jobid); switch (status) { case JobStatus.Completed: { done = true; //get job logs and parse file name from it JobOperationsBiz jobopsbiz = new JobOperationsBiz( ct, 1, AuthorizationRoles.BizAdmin ); List log = await jobopsbiz.GetJobLogListAsync((Guid)jobid); var lastLog = log[log.Count - 1]; var lastLogJ = JObject.Parse(lastLog.StatusText); var path = (string)lastLogJ["reportfilename"]; var FilePath = FileUtil.GetFullPathForTemporaryFile( path ); var FileName = FileUtil .StringToSafeFileName( await TranslationBiz.GetTranslationStaticAsync( ne.AyaType.ToString(), subTranslationId, ct ) + $"-{ne.Name}.pdf" ) .ToLowerInvariant(); await m.SendEmailAsync( deliveryAddress, Subject, Body, ServerGlobalOpsSettingsCache.Notify, FilePath, FileName ); break; } case JobStatus.Failed: case JobStatus.Absent: throw new ApplicationException( $"REPORT RENDER JOB {jobid} started but failed" ); } } if (!done) throw new TimeoutException( "JOB FAILED DUE TO REPORT RENDER TIMEOUT" ); } } else await m.SendEmailAsync( deliveryAddress, Subject, Body, ServerGlobalOpsSettingsCache.Notify ); } } } catch (Exception ex) { await NotifyEventHelper.AddOpsProblemEvent("SMTP Customer Notification failed", ex); DeliveryLogItem.Fail = true; DeliveryLogItem.Error = $"SMTP Notification failed to deliver for this Customer notify event: {ne}, message: {ex.Message}"; log.LogDebug(ex, $"DeliverSMTP Failure delivering Customer notify event: {ne}"); } finally { //remove event no matter what ct.CustomerNotifyEvent.Remove(ne); //add delivery log item await ct.CustomerNotifyDeliveryLog.AddAsync(DeliveryLogItem); await ct.SaveChangesAsync(); } } private static string SetQuoteTokens(string TheField, Quote qt, string CustomerName) { MatchCollection matches = Regex.Matches( TheField, @"\{{(.|\n)*?\}}", RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled ); //{{.*?}} foreach (Match KeyMatch in matches) { switch (KeyMatch.Value) { case "{{Customer}}": TheField = TheField.Replace(KeyMatch.Value, CustomerName); break; case "{{QuoteIntroduction}}": TheField = TheField.Replace(KeyMatch.Value, qt.Introduction); break; case "{{WorkOrderCustomerContactName}}": TheField = TheField.Replace(KeyMatch.Value, qt.CustomerContactName); break; case "{{WorkOrderCustomerReferenceNumber}}": TheField = TheField.Replace(KeyMatch.Value, qt.CustomerReferenceNumber); break; case "{{WorkOrderSummary}}": TheField = TheField.Replace(KeyMatch.Value, qt.Notes); break; case "{{QuoteSerialNumber}}": TheField = TheField.Replace(KeyMatch.Value, qt.Serial.ToString()); break; } } return TheField; } private static string SetWorkOrderTokens( string TheField, WorkOrder wo, string CustomerName, string statusName ) { MatchCollection matches = Regex.Matches( TheField, @"\{{(.|\n)*?\}}", RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled ); //{{.*?}} foreach (Match KeyMatch in matches) { switch (KeyMatch.Value) { case "{{Customer}}": TheField = TheField.Replace(KeyMatch.Value, CustomerName); break; case "{{WorkOrderStatus}}": TheField = TheField.Replace(KeyMatch.Value, statusName); break; case "{{WorkOrderCustomerContactName}}": TheField = TheField.Replace(KeyMatch.Value, wo.CustomerContactName); break; case "{{WorkOrderCustomerReferenceNumber}}": TheField = TheField.Replace(KeyMatch.Value, wo.CustomerReferenceNumber); break; case "{{WorkOrderSummary}}": TheField = TheField.Replace(KeyMatch.Value, wo.Notes); break; case "{{WorkOrderSerialNumber}}": TheField = TheField.Replace(KeyMatch.Value, wo.Serial.ToString()); break; } } return TheField; } private static string SetCSRTokens( string TheField, CustomerServiceRequest csr, string CustomerName, string requestedBy ) { MatchCollection matches = Regex.Matches( TheField, @"\{{(.|\n)*?\}}", RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled ); //{{.*?}} foreach (Match KeyMatch in matches) { switch (KeyMatch.Value) { case "{{Customer}}": TheField = TheField.Replace(KeyMatch.Value, CustomerName); break; case "{{CustomerServiceRequestRequestedBy}}": TheField = TheField.Replace(KeyMatch.Value, requestedBy); break; case "{{CustomerServiceRequestCustomerReferenceNumber}}": TheField = TheField.Replace(KeyMatch.Value, csr.CustomerReferenceNumber); break; case "{{CustomerServiceRequestTitle}}": TheField = TheField.Replace(KeyMatch.Value, csr.Name); break; } } return TheField; } //=== ///////////////////////////////////////////////////////////////////// } //eoc } //eons