Files
raven/server/AyaNova/biz/NotifyEventHelper.cs
2020-12-22 20:33:48 +00:00

669 lines
40 KiB
C#

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 NotifyEventHelper
{
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)
{
//actually, if there is an exception it's already logged anyway so don't re-log it here, just makes dupes
// 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.AsNoTracking().Where(z => z.Id == userId).Select(z => z.Name).FirstOrDefaultAsync();
//if they don't have a regular inapp subscription create one now
NotifySubscription defaultsub = await EnsureDefaultInAppUserNotificationSubscriptionExists(userId, ct);
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
private static async Task<NotifySubscription> EnsureDefaultInAppUserNotificationSubscriptionExists(long userId, AyContext ct)
{
var defaultsub = await ct.NotifySubscription.FirstOrDefaultAsync(z => z.EventType == NotifyEventType.GeneralNotification && z.UserId == userId && z.DeliveryMethod == NotifyDeliveryMethod.App);
if (defaultsub == null)
{
//NOTE: agevalue and advanced notice settings here will ensure that direct in app notifications with a future delivery date ("deadman" switch deliveries) set in their
//notifyevent.eventdate will deliver on that date and not immediately to support all the things that are direct built in notifications for future dates
//such as for an overdue Review which doesn't have or need it's own notifyeventtype and subscription independently
defaultsub = new NotifySubscription()
{
UserId = userId,
EventType = NotifyEventType.GeneralNotification,
DeliveryMethod = NotifyDeliveryMethod.App,
AgeValue = new TimeSpan(0, 0, 1),
AdvanceNotice = new TimeSpan(0, 0, 1)
};
await ct.NotifySubscription.AddAsync(defaultsub);
await ct.SaveChangesAsync();
}
return defaultsub;
}
//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)
{
case AyaType.Review:
{
//set a deadman automatic internal notification if goes past due
var r = (Review)newObject;
//if not completed yet and not overdue already (which could indicate an import or something)
if (r.CompletedDate == null && r.DueDate > DateTime.UtcNow)
{
var userNotifySub = await EnsureDefaultInAppUserNotificationSubscriptionExists(r.UserId, ct);
NotifySubscription supervisorNotifySub = null;
if (r.UserId != r.AssignedByUserId)
{
supervisorNotifySub = await EnsureDefaultInAppUserNotificationSubscriptionExists(r.AssignedByUserId, ct);
}
{
NotifyEvent n = new NotifyEvent()
{
EventType = NotifyEventType.GeneralNotification,
UserId = r.UserId,
ObjectId = newObject.Id,
AyaType = AyaType.Review,
NotifySubscriptionId = userNotifySub.Id,
Name = "LT:ReviewOverDue - " + newObject.Name,
EventDate = r.DueDate
};
await ct.NotifyEvent.AddAsync(n);
log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]");
}
if (supervisorNotifySub != null)
{
NotifyEvent n = new NotifyEvent()
{
EventType = NotifyEventType.GeneralNotification,
UserId = r.AssignedByUserId,
ObjectId = newObject.Id,
AyaType = AyaType.Review,
NotifySubscriptionId = supervisorNotifySub.Id,
Name = "LT:ReviewOverDue - " + newObject.Name,
EventDate = r.DueDate
};
await ct.NotifyEvent.AddAsync(n);
log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]");
}
}
}
break;
//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: be sure to remove any that are being replaced potentially
//------------------------------
// AyaType Specific modified related subscriptions
//
switch (newObject.AyaType)
{
case AyaType.Review:
{
// //Remove prior
// await ClearPriorEventsForObject(ct, newObject.AyaType, newObject.Id, NotifyEventType.GeneralNotification);//assumes only general event for this object type is overdue here
// //set a deadman automatic internal notification if goes past due
// var r = (Review)newObject;
// //it not completed yet and not overdue already (which could indicate an import or something)
// if (r.CompletedDate == null && r.DueDate > DateTime.UtcNow)
// {
// var userNotifySub = await EnsureDefaultInAppUserNotificationSubscriptionExists(r.UserId, ct);
// NotifySubscription supervisorNotifySub = null;
// if (r.UserId != r.AssignedByUserId)
// {
// supervisorNotifySub = await EnsureDefaultInAppUserNotificationSubscriptionExists(r.AssignedByUserId, ct);
// }
// {
// NotifyEvent n = new NotifyEvent()
// {
// EventType = NotifyEventType.GeneralNotification,
// UserId = r.UserId,
// ObjectId = newObject.Id,
// AyaType = AyaType.Review,
// NotifySubscriptionId = userNotifySub.Id,
// Name = "LT:ReviewOverDue - " + newObject.Name,
// EventDate = r.DueDate
// };
// await ct.NotifyEvent.AddAsync(n);
// log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]");
// }
// if (supervisorNotifySub != null)
// {
// NotifyEvent n = new NotifyEvent()
// {
// EventType = NotifyEventType.GeneralNotification,
// UserId = r.AssignedByUserId,
// ObjectId = newObject.Id,
// AyaType = AyaType.Review,
// NotifySubscriptionId = supervisorNotifySub.Id,
// Name = "LT:ReviewOverDue - " + newObject.Name,
// EventDate = r.DueDate
// };
// await ct.NotifyEvent.AddAsync(n);
// log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]");
// }
// }
}
break;
//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<long>();
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
await ClearPriorEventsForObject(ct, newObject.AyaType, newObject.Id);
//------------------------------------------
//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
//////////////////////////////////
// CLEAN OUT OLD EVENTS
//
//
public static async Task ClearPriorEventsForObject(AyContext ct, AyaType ayaType, long objectId, NotifyEventType eventType)
{
var eventsToDelete = await ct.NotifyEvent.Where(z => z.AyaType == ayaType && z.ObjectId == objectId && z.EventType == eventType).ToListAsync();
if (eventsToDelete.Count == 0) return;
ct.NotifyEvent.RemoveRange(eventsToDelete);
await ct.SaveChangesAsync();
}
public static async Task ClearPriorEventsForObject(AyContext ct, AyaType ayaType, long objectId)
{
var eventsToDelete = await ct.NotifyEvent.Where(z => z.AyaType == ayaType && z.ObjectId == objectId).ToListAsync();
if (eventsToDelete.Count == 0) return;
ct.NotifyEvent.RemoveRange(eventsToDelete);
await ct.SaveChangesAsync();
}
//////////////////////////////////
// COMPARE TAGS COLLECTION
//
//
public static bool TagsMatch(List<string> objectTags, List<string> 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