using System; using System.Linq; using System.Globalization; using System.Text; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore; using AyaNova.Util; using AyaNova.Models; //using System.Diagnostics; namespace AyaNova.Biz { internal static class NotifyEventProcessor { private static ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger("NotifyEventProcessor"); //Add operations message internal static async Task AddOpsProblemEvent(string message, Exception ex = null) { if (string.IsNullOrWhiteSpace(message) && ex == null) return; //Log as a backup in case there is no one to notify and also for the record and support if (ex != null) { log.LogError(ex, $"Ops problem notification: \"{message}\""); message += $"\nException error: {ExceptionUtil.ExtractAllExceptionMessages(ex)}"; } else log.LogWarning($"Ops problem notification: \"{message}\""); await AddGeneralNotifyEvent(NotifyEventType.ServerOperationsProblem, message, "OPS"); } //This handles general notification events not requiring a decision or tied to an object that are basically just a immediate message to the user //e.g. ops problems, GeneralNotification, NotifyHealthCheck etc //optional user id to send directly to them internal static async Task AddGeneralNotifyEvent(NotifyEventType eventType, string message, string name, Exception except = null, long userId = 0) { log.LogDebug($"AddGeneralNotifyEvent processing: [type:{eventType}, userId:{userId}, message:{message}]"); #if (DEBUG) switch (eventType) { case NotifyEventType.BackupStatus: case NotifyEventType.GeneralNotification: case NotifyEventType.NotifyHealthCheck://created by job processor itself case NotifyEventType.ServerOperationsProblem: break; default://this will likely be a development error, not a production error so no need to log etc throw (new System.NotSupportedException($"NotifyEventProcessor:AddGeneralNotifyEvent - Type of event {eventType} is unexpected and not supported")); } if (eventType != NotifyEventType.GeneralNotification && userId != 0) { throw (new System.NotSupportedException($"NotifyEventProcessor:AddGeneralNotifyEvent - event {eventType} was specified with user id {userId} which is unexpected and not supported")); } #endif try { using (AyContext ct = AyaNova.Util.ServiceProviderProvider.DBContext) { //General notification goes to a specific user only //no need to consult subscriptions if (eventType == NotifyEventType.GeneralNotification) { if (userId == 0) { //this will likely be a development error, not a production error so no need to log etc throw new System.ArgumentException("NotifyEventProcessor:AddGeneralNotifyEvent: GeneralNotification requires a user id but none was specified"); } var UserName = await ct.User.Where(z => z.Id == userId).Select(z => z.Name).FirstOrDefaultAsync(); //if they don't have a regular inapp subscription create one now var defaultsub = await ct.NotifySubscription.FirstOrDefaultAsync(z => z.EventType == NotifyEventType.GeneralNotification && z.UserId == userId && z.DeliveryMethod == NotifyDeliveryMethod.App); if (defaultsub == null) { defaultsub = new NotifySubscription() { UserId = userId, EventType = NotifyEventType.GeneralNotification, DeliveryMethod = NotifyDeliveryMethod.App }; await ct.NotifySubscription.AddAsync(defaultsub); await ct.SaveChangesAsync(); } if (string.IsNullOrWhiteSpace(name)) { name = UserName; } NotifyEvent n = new NotifyEvent() { EventType = eventType, UserId = userId, Message = message, NotifySubscriptionId = defaultsub.Id, Name = name }; await ct.NotifyEvent.AddAsync(n); await ct.SaveChangesAsync(); return; } //check subscriptions for event and send accordingly to each user var subs = await ct.NotifySubscription.Where(z => z.EventType == eventType).ToListAsync(); //append exception message if not null if (except != null) message += $"\nException error: {ExceptionUtil.ExtractAllExceptionMessages(except)}"; foreach (var sub in subs) { //note flag ~SERVER~ means to client to substitute "Server" translation key text instead NotifyEvent n = new NotifyEvent() { EventType = eventType, UserId = sub.UserId, Message = message, NotifySubscriptionId = sub.Id, Name = "~SERVER~" }; await ct.NotifyEvent.AddAsync(n); } if (subs.Count > 0) await ct.SaveChangesAsync(); } } catch (Exception ex) { log.LogError(ex, $"Error adding general notify event [type:{eventType}, userId:{userId}, message:{message}]"); } }//eom //This is told about an event and then determines if there are any subscriptions related to that event and proceses them accordingly //todo: this should take some kind of general event type like the AyaEvent types (i.e. which CRUD operation is in effect if relevant) //and also a biz object before and after or just before if not a change and also a AyaType //then *this* code will go through and look for subscriptions related to that event //this way the biz object code can be "dumb" about notifications in general and just let this code handle it as needed //will iterate the subscriptions and see if any apply here internal static async Task HandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel newObject, ICoreBizObjectModel originalObject = null) { if (ServerBootConfig.SEEDING) return; log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{newObject.AyaType}, AyaEvent:{ayaEvent}]"); //set to true if any changes are made to the context (NotifyEvent added) bool SaveContext = false; try { using (AyContext ct = AyaNova.Util.ServiceProviderProvider.DBContext) { //short circuit if there are no subscriptions which would be typical of a v8 migrate scenario or fresh import or just not using notification if (!await ct.NotifySubscription.AnyAsync()) return; //switch through AyaEvent then individual Ayatypes as required for event types switch (ayaEvent) { case AyaEvent.Created: #region Created processing //------------------------------ // AyaType Specific created related subscriptions // Note: these are for specific things only in this block // generally being created notifications are further down below switch (newObject.AyaType) { //AyaTypes with their own special notification related events case AyaType.WorkOrder: { //WorkorderStatusChange { throw new System.NotImplementedException("Awaiting workorder object completion"); var subs = await ct.NotifySubscription.Where(z => z.EventType == NotifyEventType.WorkorderStatusChange).ToListAsync(); foreach (var sub in subs) { if (TagsMatch(newObject.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.ObjectAge, UserId = sub.UserId, ObjectId = newObject.Id, NotifySubscriptionId = sub.Id, //TODO: IdValue=((WorkOrder)newObject).WorkorderStatusId Name = newObject.Name }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); //Note: in same event but for MODIFY below if need to delete old notifyevent here // var deleteEventList = await ct.NotifyEvent.Where(z => z.ObjectId == ev.ObjectId && z.AyaType == ev.AyaType).ToListAsync(); // ct.NotifyEvent.RemoveRange(deleteEventList); SaveContext = true; } } } //ScheduledOnWorkorder https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/892 //ScheduledOnWorkorderImminent https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/892 //WorkorderFinishStatusOverdue //WorkorderFinished [USER, CUSTOMER], if customer then also CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 //OutsideServiceOverdue //OutsideServiceReceived https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/892 //PartRequestReceived https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/892 //CustomerServiceImminent ALSO CopyOfCustomerNotification //WorkorderCreatedForCustomer (conditional ID sb customer id) ALSO CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 //PartRequested https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/892 //WorkorderTotalExceedsThreshold //WorkorderStatusAge } break; case AyaType.Quote: //QuoteStatusChange [USER, CUSTOMER] / ALSO CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 //QuoteStatusAge break; case AyaType.Contract: //ContractExpiring [CUSTOMER/USER] / ALSO CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 break; case AyaType.Reminder: //ReminderImminent break; case AyaType.Unit: //UnitWarrantyExpiry break; case AyaType.UnitMeterReading: //UnitMeterReadingMultipleExceeded break; } //----------------------------------------------- //ObjectAge // { var subs = await ct.NotifySubscription.Where(z => z.EventType == NotifyEventType.ObjectAge && z.AyaType == newObject.AyaType).ToListAsync(); foreach (var sub in subs) { if (TagsMatch(newObject.Tags, sub.Tags)) { //Note: age is set by advance notice which is consulted by CoreJobNotify in it's run so the deliver date is not required here only the reference EventDate to check for deliver //ObjectAge is determined by subscription AgeValue in combo with the EventDate NotifyEvent parameter which together determines at what age from notifyevent.EventDate it's considered for the event to have officially occured //However delivery is determined by sub.advancenotice so all three values play a part // NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.ObjectAge, UserId = sub.UserId, AyaType = newObject.AyaType, ObjectId = newObject.Id, NotifySubscriptionId = sub.Id, Name = newObject.Name }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); SaveContext = true; } } } //------------------------------------------ //ObjectCreated // // [USER for any type, CUSTOMER for workorder only] / if customer then CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 { var subs = await ct.NotifySubscription.Where(z => z.EventType == NotifyEventType.ObjectCreated && z.AyaType == newObject.AyaType).ToListAsync(); foreach (var sub in subs) { if (TagsMatch(newObject.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.ObjectCreated, UserId = sub.UserId, AyaType = newObject.AyaType, ObjectId = newObject.Id, NotifySubscriptionId = sub.Id, Name = newObject.Name }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); SaveContext = true; } } } #endregion created processing break; case AyaEvent.Modified: #region Modified processing //######## NOTE: Any that don't deliver right away should be removed before re-inserting //------------------------------ // AyaType Specific modified related subscriptions // switch (newObject.AyaType) { //AyaTypes with their own special notification related events case AyaType.WorkOrder: { //WorkorderStatusChange [USER, CUSTOMER BOTH] / CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 // { throw new System.NotImplementedException("Awaiting workorder object completion"); // if (((WorkOrder)newObject).WorkorderStatusId != ((WorkOrder)dbObject).WorkorderStatusId) // { // var subs = await ct.NotifySubscription.Where(z => z.EventType == NotifyEventType.WorkorderStatusChange).ToListAsync(); // foreach (var sub in subs) // { // if (TagsMatch(newObject.Tags, sub.Tags)) // { // NotifyEvent n = new NotifyEvent() // { // EventType = NotifyEventType.ObjectAge, // UserId = sub.UserId, // ObjectId = newObject.Id, // NotifySubscriptionId = sub.Id, // //TODO: IdValue=((WorkOrder)newObject).WorkorderStatusId // EventDate = DateTime.UtcNow, //Name=newObject.Name //, // Name=newObject.Name // }; // await ct.NotifyEvent.AddAsync(n); // log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); // //Note: in same event but for MODIFY below if need to delete old notifyevent here // // var deleteEventList = await ct.NotifyEvent.Where(z => z.ObjectId == ev.ObjectId && z.AyaType == ev.AyaType).ToListAsync(); // // ct.NotifyEvent.RemoveRange(deleteEventList); // SaveContext = true; // } // } // } } //ScheduledOnWorkorder (DELETE OLD, USER / DATE COULD CHANGE) //https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/892 //ScheduledOnWorkorderImminent (DELTE OLD) //https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/892 //WorkorderFinishStatusOverdue (DELETE OLD) //WorkorderFinished [USER, CUSTOMER] ALSO CopyOfCustomerNotification applies here https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 //OutsideServiceOverdue (ALSO DELETE OLD) //OutsideServiceReceived //PartRequestReceived //CustomerServiceImminent (ALSO DELETE OLD) / ALSO CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 //PartRequested //https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/892 //WorkorderTotalExceedsThreshold //WorkorderStatusAge (ALSO DELETE OLD) //https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/1137 //WorkorderFinishedFollowUp (uses AGE after finished status) [USER] } break; case AyaType.Quote: //QuoteStatusChange [USER, CUSTOMER] ALSO CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 //QuoteStatusAge (DELETE OLD) break; case AyaType.Contract: //ContractExpiring (DELETE OLD) break; case AyaType.Reminder: //ReminderImminent (DELETE OLD) break; case AyaType.Unit: //UnitWarrantyExpiry (DELETE OLD) break; case AyaType.UnitMeterReading: //UnitMeterReadingMultipleExceeded //case 1254 // This is as a threshold multiple value, i.e. if it's passing a multiple of 50,000 then notify, or notify every 10,000 or something. // In saving code it just checks to see if it has crossed the multiple threshold, i.e. calculate the old multiple, calculate the new multiple if over then send notification immediate. break; case AyaType.CustomerServiceRequest: //CSRAccepted (DELETE OLD might have changed from one to the other) [CUSTOMER] ALSO CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 //CSRRejected (DELETE OLD might have changed from one to the other) [CUSTOMER] ALSO CopyOfCustomerNotification https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3398 break; case AyaType.ServiceBank: //ServiceBankDepleted https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/472 break; case AyaType.User: //This one's a little different, if user has had roles changed, then pre-existing subs may not be allowed anymore //Remove any notification subscriptions user doesn't have rights to: if (((User)originalObject).Roles != ((User)newObject).Roles) { var DeleteList = new List(); var NewRoles = ((User)newObject).Roles; //iterate subs and remove any user shouldn't have var userSubs = await ct.NotifySubscription.Where(z => z.UserId == newObject.Id).ToListAsync(); foreach (var sub in userSubs) { if (sub.AyaType != AyaType.NoType) { //check if user has rights to it or not still //must have read rights to be valid if (!AyaNova.Api.ControllerHelpers.Authorized.HasAnyRole(NewRoles, sub.AyaType)) { //no rights whatsoever, so delete it DeleteList.Add(sub.Id); } } } if (DeleteList.Count > 0) { var NSB = NotifySubscriptionBiz.GetBiz(ct); foreach (var l in DeleteList) { await NSB.DeleteAsync(l); } } } break; } //------------------------------------------ //ObjectModified // { var subs = await ct.NotifySubscription.Where(z => z.EventType == NotifyEventType.ObjectModified && z.AyaType == newObject.AyaType).ToListAsync(); foreach (var sub in subs) { if (TagsMatch(newObject.Tags, sub.Tags)) { NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.ObjectModified, UserId = sub.UserId, AyaType = newObject.AyaType, ObjectId = newObject.Id, NotifySubscriptionId = sub.Id, Name = newObject.Name }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); SaveContext = true; } } } #endregion modified processing break; case AyaEvent.Deleted: #region Deleted processing //------------------------------ // Delete any NotifyEvent records for this exact object // It's gone and shouldn't have any events left for it // NOTE: this is expected to cover all created / modified events above in terms of reversing them if they're still around for whatever reason { var deleteList = await ct.NotifyEvent.Where(z => z.ObjectId == newObject.Id && z.AyaType == newObject.AyaType).ToListAsync(); if (deleteList.Count > 0) { ct.NotifyEvent.RemoveRange(deleteList); SaveContext = true; } } //------------------------------------------ //ObjectDeleted notification // { var subs = await ct.NotifySubscription.Where(z => z.EventType == NotifyEventType.ObjectDeleted && z.AyaType == newObject.AyaType).ToListAsync(); foreach (var sub in subs) { if (TagsMatch(newObject.Tags, sub.Tags)) { //TODO: On deliver should point to history event log record or take from there and insert into delivery message? NotifyEvent n = new NotifyEvent() { EventType = NotifyEventType.ObjectDeleted, UserId = sub.UserId, AyaType = newObject.AyaType, ObjectId = newObject.Id, NotifySubscriptionId = sub.Id, Name = newObject.Name }; await ct.NotifyEvent.AddAsync(n); log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); SaveContext = true; } } } #endregion deleted processing break; default: #if (DEBUG) throw (new System.NotSupportedException($"NotifyEventProcessor:HandlePotentialNotificationEvent - AyaEvent {ayaEvent} was specified which is unexpected and not supported")); #else break; #endif } if (SaveContext) await ct.SaveChangesAsync(); } } catch (Exception ex) { log.LogError(ex, $"Error processing event"); } finally { log.LogDebug($"Notify event processing completed"); } }//eom //################### FIRST ATTEMPT STUFF // //find all the Notification subscription subs that are relevant // //this first query ensures that the equality matching conditions of AyaType and EventType and idValue are matched leaving only more complex matches to rectify below // var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == ev.EventType && z.AyaType == ev.AyaType && z.IdValue == ev.IdValue).ToListAsync(); // foreach (var sub in subs) // { // //CONDITION CHECK Tags condition // if (sub.Tags.Count > 0) // { // if (!HasTags) // continue;//not a match, this sub needs tags and we don't have any // //bool existsCheck = list1.All(x => list2.Any(y => x.SupplierId == y.SupplierId)); // //would tell you if all of list1's items are in list2. // if (!sub.Tags.All(z => inTags.Any(x => x == z))) // continue; // } // //CONDITION CHECK Decimal value, so far only for "The Andy" so the only condition is that the event value be greater than or equal to subscription setting // if (ev.EventType == NotifyEventType.WorkorderTotalExceedsThreshold && ev.DecValue < sub.DecValue) // continue; // //Here ready for delivery // //HANDLE RELATED INDIRECT EVENTS AND CLEANUP // //some event types create related events here automatically (e.g. WorkorderStatusChange in turn will trigger workorderstatusage if subbed here) // //Deleting an object or changing status etc will affect other events in turn so clean up for them here // switch (ev.EventType) // { // case NotifyEventType.ObjectDeleted: // { // //object is deleted so remove any other events pending for it // //current working concept is there is no reason to keep any event related to a freshly deleted object // var deleteEventList = await ct.NotifyEvent.Where(z => z.ObjectId == ev.ObjectId && z.AyaType == ev.AyaType).ToListAsync(); // ct.NotifyEvent.RemoveRange(deleteEventList); // await ct.SaveChangesAsync(); // } // break; // case NotifyEventType.WorkorderStatusChange: // { // //Workorder status changed so remove any other prior workorderstatuschanged events and // //remove any prior workorderstatusAge events for this object id // var deleteEventList = await ct.NotifyEvent.Where(z => z.ObjectId == ev.ObjectId && z.AyaType == ev.AyaType && ev.EventType == NotifyEventType.WorkorderStatusChange).ToListAsync(); // ct.NotifyEvent.RemoveRange(deleteEventList); // await ct.SaveChangesAsync(); // } // break; // case NotifyEventType.WorkorderStatusAge://delete any workorder status age ones because there is potentially a new status invalidating the old status // case NotifyEventType.QuoteStatusAge: // //Delete any existing ones // var StaleAgedEvents = await ct.NotifyEvent.Where(z => z.EventType == ev.EventType && z.ObjectId == ev.ObjectId && z.AyaType == ev.AyaType).ToListAsync(); // break; // } // //AgeValue refresh? // //Age based ones need to replace any prior existing age based ones made for the same object as they are the only ones that can sit in the queue for any length of time // //and become invalidated (i.e. deadman type deliveries that sb removed if no longer applicable e.g. WorkorderStatusAge) // switch (ev.EventType) // { // case NotifyEventType.ObjectAge://this is set on creation so it will never change unless the object is deleted // case NotifyEventType.WorkorderStatusAge://delete any workorder status age ones because there is potentially a new status invalidating the old status // case NotifyEventType.QuoteStatusAge: // //Delete any existing ones // var StaleAgedEvents = await ct.NotifyEvent.Where(z => z.EventType == ev.EventType && z.ObjectId == ev.ObjectId && z.AyaType == ev.AyaType).ToListAsync(); // break; // } // //Here we know the sub matches the event and passes all the conditions so set the deliver date if necessary and save the NotifyEvent here // //todo: create message here (delivery thing?) // //todo: Attach report here or is that a delivery thing (delivery thing) // } private static bool TagsMatch(List objectTags, List subTags) { //no subscription tags? Then it always will match if (subTags.Count == 0) return true; //have sub tags but object has none? Then it's never going to match if (objectTags.Count == 0) return false; //not enought tags on object to match sub tags? if (subTags.Count > objectTags.Count) return false; //ok, here it's worth checking it out return subTags.All(z => objectTags.Any(x => x == z)); } }//eoc }//eons