Exemplo n.º 1
0
        public static bool PushEnabled => false; // TODO

        /// <summary>
        /// Queues the notifications in the database and if the database table was clear it queues them immediately for processing.<br/>
        /// If the database contains stale notifications, then do not queue the new ones immediately. Instead wait for <see cref="SmsPollingJob"/>
        /// to pick them out in order and schedule them, this prevent notifications being dispatched grossly out of order.
        /// </summary>
        public async Task Enqueue(
            int tenantId,
            List <EmailToSend> emails           = null,
            List <SmsToSend> smsMessages        = null,
            List <PushToSend> pushNotifications = null,
            EmailCommandToSend command          = null,
            CancellationToken cancellation      = default)
        {
            // (1) Map notifications to Entities and validate them
            // Email
            emails ??= new List <EmailToSend>();
            if (emails.Count > 0 && !EmailEnabled)
            {
                // Developer mistake
                throw new InvalidOperationException("Attempt to Enqueue emails while email is disabled in this installation.");
            }

            var validEmails   = new List <EmailToSend>(emails.Count);
            var emailEntities = new List <EmailForSave>(emails.Count);
            var blobs         = new List <(string name, byte[] content)>();

            foreach (var email in emails)
            {
                var(emailEntity, emailBlobs) = ToEntity(email);
                emailEntities.Add(emailEntity);
                blobs.AddRange(emailBlobs);

                var error = EmailValidation.Validate(email);
                if (error != null)
                {
                    emailEntity.State        = EmailState.ValidationFailed;
                    emailEntity.ErrorMessage = error;

                    // The following ensures it will fit in the table
                    emailEntity.To      = emailEntity.To?.Truncate(EmailValidation.MaximumEmailAddressLength);
                    emailEntity.Cc      = emailEntity.Cc?.Truncate(EmailValidation.MaximumEmailAddressLength);
                    emailEntity.Bcc     = emailEntity.Bcc?.Truncate(EmailValidation.MaximumEmailAddressLength);
                    emailEntity.Subject = emailEntity.Subject?.Truncate(EmailValidation.MaximumSubjectLength);
                    foreach (var att in emailEntity.Attachments)
                    {
                        if (string.IsNullOrWhiteSpace(att.Name))
                        {
                            att.Name = "(Missing Name)";
                        }
                        else
                        {
                            att.Name = att.Name?.Truncate(EmailValidation.MaximumAttchmentNameLength);
                        }
                    }
                }
                else
                {
                    validEmails.Add(email);
                }
            }

            // SMS
            smsMessages ??= new List <SmsToSend>();
            if (smsMessages.Count > 0 && !SmsEnabled)
            {
                // Developer mistake
                throw new InvalidOperationException("Attempt to Enqueue SMS messages while SMS is disabled in this installation.");
            }
            var validSmsMessages = new List <SmsToSend>(smsMessages.Count);
            var smsEntities      = new List <MessageForSave>(smsMessages.Count);

            foreach (var sms in smsMessages)
            {
                var smsEntity = ToEntity(sms);
                smsEntities.Add(smsEntity);

                var error = SmsValidation.Validate(sms);
                if (error != null)
                {
                    smsEntity.State        = MessageState.ValidationFailed;
                    smsEntity.ErrorMessage = error;
                }
                else
                {
                    validSmsMessages.Add(sms);
                }
            }

            // Push
            pushNotifications ??= new List <PushToSend>();
            if (pushNotifications.Count > 0 && !PushEnabled)
            {
                // Developer mistake
                throw new InvalidOperationException("Attempt to Enqueue Push notifications while Push is disabled in this installation.");
            }
            var validPushNotifications = new List <PushToSend>(pushNotifications.Count);
            var pushEntities           = new List <PushNotificationForSave>(pushNotifications.Count);

            // TODO

            // Start a serializable transaction
            using var trx = TransactionFactory.Serializable(TransactionScopeOption.RequiresNew);

            // If the table already contains notifications that are 90% expired, do not queue the new notifications
            int expiryInSeconds = _options.PendingNotificationExpiryInSeconds * 9 / 10;

            // (2) Call the stored procedure
            // Persist the notifications in the database, the returned booleans will tell us which notifications we can queue immediately
            var repo = _repoFactory.GetRepository(tenantId);

            var(queueEmails, queueSmsMessages, queuePushNotifications, emailCommandId, messageCommandId) = await repo.Notifications_Enqueue(
                expiryInSeconds : expiryInSeconds,
                emails : emailEntities,
                messages : smsEntities,
                pushes : pushEntities,
                templateId : command?.TemplateId,
                entityId : command?.EntityId,
                caption : command?.Caption,
                scheduledTime : command?.ScheduledTime,
                createdbyId : command?.CreatedById,
                cancellation : cancellation);

            await _blobService.SaveBlobsAsync(tenantId, blobs);

            // (3) Map the Ids back to the DTOs and queue valid notifications
            // Email
            if (queueEmails && validEmails.Any())
            {
                // Map the Ids
                for (int i = 0; i < emailEntities.Count; i++)
                {
                    var entity = emailEntities[i];
                    var dto    = emails[i];
                    dto.EmailId  = entity.Id;
                    dto.TenantId = tenantId;
                }

                // Queue (Emails are queued in bulk unlike SMS and Push)
                _emailQueue.QueueBackgroundWorkItem(validEmails);
            }

            // SMS
            if (queueSmsMessages && validSmsMessages.Any())
            {
                // Map the Ids
                for (int i = 0; i < smsEntities.Count; i++)
                {
                    var smsEntity = smsEntities[i];
                    var sms       = smsMessages[i];
                    sms.MessageId = smsEntity.Id;
                    sms.TenantId  = tenantId;
                }

                // Queue
                _smsQueue.QueueAllBackgroundWorkItems(validSmsMessages);
            }

            // Push
            if (queuePushNotifications && validPushNotifications.Any())
            {
                // Map the Ids
                for (int i = 0; i < smsEntities.Count; i++)
                {
                    var entity = pushEntities[i];
                    var dto    = pushNotifications[i];
                    dto.PushId   = entity.Id;
                    dto.TenantId = tenantId;
                }

                // Queue
                _pushQueue.QueueAllBackgroundWorkItems(validPushNotifications);
            }

            if (command != null)
            {
                command.EmailCommandId   = emailCommandId;
                command.MessageCommandId = messageCommandId;
            }

            trx.Complete();
        }
        protected override async Task ExecuteAsync(CancellationToken cancellation)
        {
            _logger.LogInformation(GetType().Name + " Started.");

            // Enter the background job
            while (!cancellation.IsCancellationRequested)
            {
                try
                {
                    ///////////// (1) Create and grab the cancellation token that indicates an updated schedule
                    // It is important to create the token before grabbing the schedules, because
                    // the updater job updates the schedules and THEN cancels the token.
                    var signalSchedulesChange = _schedulesCache.CreateCancellationToken();

                    ///////////// (2) Compute the earliest schedule
                    DateTimeOffset minNext = DateTimeOffset.MaxValue;
                    Dictionary <int, List <ScheduleInfo> > allTemplates = new();

                    var allSchedules = await _schedulesCache.GetSchedules(cancellation);

                    foreach (var schedule in allSchedules.Where(e => !e.IsError))
                    {
                        var tenantId = schedule.TenantId;
                        foreach (var cron in schedule.Crons)
                        {
                            var next = cron.GetNextOccurrence(schedule.LastExecuted, TimeZoneInfo.Utc);
                            if (next != null)
                            {
                                if (minNext.UtcTicks > next.Value.UtcTicks)
                                {
                                    // If it's an earlier time, create a new dictionary
                                    allTemplates = new() { { tenantId, new() { schedule } } };
                                    minNext      = next.Value;
                                }
                                else if (minNext.UtcTicks == next.Value.UtcTicks)
                                {
                                    if (!allTemplates.TryGetValue(tenantId, out List <ScheduleInfo> list))
                                    {
                                        allTemplates.Add(tenantId, list = new());
                                    }

                                    list.Add(schedule);
                                }
                            }
                        }
                    }

                    ///////////// (3) Wait the necessary duration if any, or forever if there are no schedules
                    var spanTillNext = minNext - DateTimeOffset.UtcNow;
                    if (spanTillNext.Ticks > 0)
                    {
                        // Combine 1. the token to stop the service with 2. the token to signal schedules changes
                        using var combinedSource = CancellationTokenSource
                                                   .CreateLinkedTokenSource(cancellation, signalSchedulesChange);

                        // Wait for the time of the notification
                        var milliseconds = Convert.ToInt64(spanTillNext.TotalMilliseconds);
                        await Delay(milliseconds, combinedSource.Token);
                    }

                    ///////////// (4) Run the templates (if they haven't changed)
                    if (!signalSchedulesChange.IsCancellationRequested && !cancellation.IsCancellationRequested)
                    {
                        await Task.WhenAll(allTemplates.Select(async pair =>
                        {
                            // A. Load the templates from the DB
                            // B. IF they are outdated, skip all and update the schedules of this company, then leave it to be handled by the next iteration
                            // C. ELSE run the templates in sequence, and update LastExecuted both in DB and in memory
                            // D. If there is an error evaluating the template or sending it, notify the support email of the company

                            var tenantId  = pair.Key;
                            var schedules = pair.Value;

                            try // To prevent failure in one tenant to affect other tenants
                            {
                                // Get deployed automatic templates of this tenant
                                var emailTemplateIds   = schedules.Where(e => e.Channel == ScheduleChannel.Email).Select(e => e.TemplateId).Distinct();
                                var messageTemplateIds = schedules.Where(e => e.Channel == ScheduleChannel.Message).Select(e => e.TemplateId).Distinct();

                                var repo   = _repoFactory.GetRepository(tenantId);
                                var output = await repo.Templates__Load(emailTemplateIds, messageTemplateIds, cancellation);

                                // If the schedules version is stale skip running them
                                var isStale = await _schedulesCache.RefreshSchedulesIfStale(tenantId, output.SchedulesVersion, cancellation);
                                if (!isStale)
                                {
                                    foreach (var template in output.EmailTemplates)
                                    {
                                        await _schedulesCache.UpdateEmailTemplateLastExecuted(tenantId, template.Id, minNext, output.SupportEmails, async() =>
                                        {
                                            // (1) Prepare the Email
                                            var preview = await _helper.CreateEmailCommandPreview(
                                                tenantId: tenantId,
                                                userId: 0,                 // Irrelevant
                                                settingsVersion: output.SettingsVersion,
                                                userSettingsVersion: null, // Irrelevant
                                                template: template,
                                                preloadedQuery: null,
                                                localVariables: null,
                                                fromIndex: 0,
                                                toIndex: int.MaxValue,
                                                cultureString: "en", // TODO culture?
                                                now: minNext,
                                                isAnonymous: true,   // Bypasses permission check
                                                getReadPermissions: null,
                                                cancellation: cancellation);

                                            // (2) Send Emails
                                            if (preview.Emails.Count > 0)
                                            {
                                                var emailsToSend = preview.Emails.Select(email => new EmailToSend
                                                {
                                                    TenantId    = tenantId,
                                                    To          = email.To,
                                                    Subject     = email.Subject,
                                                    Cc          = email.Cc,
                                                    Bcc         = email.Bcc,
                                                    Body        = email.Body,
                                                    Attachments = email.Attachments.Select(e => new EmailAttachmentToSend
                                                    {
                                                        Name     = e.DownloadName,
                                                        Contents = Encoding.UTF8.GetBytes(e.Body)
                                                    }).ToList()
                                                }).ToList();

                                                var command = new EmailCommandToSend(template.Id)
                                                {
                                                    Caption       = preview.Caption,
                                                    ScheduledTime = minNext
                                                };

                                                await _notificationsQueue.Enqueue(tenantId, emails: emailsToSend, command: command, cancellation: cancellation);
                                                //foreach (var email in emailsToSend)
                                                //{
                                                //    _logger.LogInformation($"{minNext.LocalDateTime}: {string.Join("; ", email.To)}: {email.Subject}");
                                                //}
                                            }
                                        },
                                                                                              cancellation);
                                    }

                                    foreach (var template in output.MessageTemplates)
                                    {
                                        await _schedulesCache.UpdateMessageTemplateLastExecuted(tenantId, template.Id, minNext, output.SupportEmails, async() =>
                                        {
                                            // (1) Prepare the Message
                                            var preview = await _helper.CreateMessageCommandPreview(
                                                tenantId: tenantId,
                                                userId: 0,                 // Irrelevant
                                                settingsVersion: output.SettingsVersion,
                                                userSettingsVersion: null, // Irrelevant
                                                template: template,
                                                preloadedQuery: null,
                                                localVariables: null,
                                                cultureString: "en", // TODO culture?
                                                now: minNext,
                                                isAnonymous: true,   // Bypasses permission check
                                                getReadPermissions: null,
                                                cancellation: cancellation);

                                            // (2) Send Messages
                                            if (preview.Messages.Count > 0)
                                            {
                                                var messagesToSend = preview.Messages.Select(msg => new SmsToSend(
                                                                                                 phoneNumber: msg.PhoneNumber,
                                                                                                 content: msg.Content)
                                                {
                                                    TenantId = tenantId
                                                }).ToList();

                                                var command = new EmailCommandToSend(template.Id)
                                                {
                                                    Caption       = preview.Caption,
                                                    ScheduledTime = minNext
                                                };

                                                await _notificationsQueue.Enqueue(tenantId, smsMessages: messagesToSend, command: command, cancellation: cancellation);
                                                //foreach (var msg in messagesToSend)
                                                //{
                                                //    _logger.LogInformation($"{minNext.LocalDateTime}: {msg.PhoneNumber}: {msg.Content}");
                                                //}
                                            }
                                        },
                                                                                                cancellation);
                                    }
                                }
                            }
                            catch (TaskCanceledException) { }
                            catch (OperationCanceledException) { }
                            catch (Exception ex)
                            {
                                _logger.LogError(ex, $"Error in {GetType().Name} while running the templates of tenant Id {tenantId}.");
                                await Task.Delay(30 * 1000, cancellation); // Wait 30 seconds to prevent a tight infinite loop
                            }
                        }));
                    }
                }
                catch (TaskCanceledException) { }
                catch (OperationCanceledException) { }
                catch (Exception ex)
                {
                    _logger.LogError(ex, $"Error in {GetType().Name}.");
                    await Task.Delay(60 * 1000, cancellation); // Wait a minute to prevent a tight infinite loop
                }
            }

            _schedulesCache.DisposeCurrentToken();
        }