Beispiel #1
0
        public async Task DeleteBlobsAsync(int tenantId, IEnumerable <string> blobNames)
        {
            // Basic check
            if (blobNames is null)
            {
                throw new ArgumentNullException(nameof(blobNames));
            }

            var repo = _factory.GetRepository(tenantId);
            await repo.Blobs__Delete(blobNames); // Already bulk
        }
        public async Task HandleCallback(SmsEventNotification smsEvent, CancellationToken cancellation)
        {
            // Nothing to do
            if (smsEvent == null || smsEvent.TenantId == null || smsEvent.TenantId == 0) // Right now we do not handle null tenant Ids, those were probably sent from identity or admin servers
            {
                return;
            }

            // Map the event to the database representation
            var state = smsEvent.Event switch
            {
                SmsEvent.Sent => MessageState.Sent,
                SmsEvent.Failed => MessageState.SendingFailed,
                SmsEvent.Delivered => MessageState.Delivered,
                SmsEvent.Undelivered => MessageState.DeliveryFailed,
                _ => throw new InvalidOperationException($"[Bug] Unknown {nameof(SmsEvent)} = {smsEvent.Event}"), // Future proofing
            };

            // Update the state in the database (should we make it serializable?)
            var repo = _repoFactory.GetRepository(tenantId: smsEvent.TenantId.Value);

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

            await repo.Notifications_Messages__UpdateState(
                id : smsEvent.MessageId,
                state : state,
                timestamp : smsEvent.Timestamp,
                error : smsEvent.Error,
                cancellation : cancellation);;

            trx.Complete();
        }
        public ApplicationServiceBehavior(
            IServiceContextAccessor context,
            IApplicationRepositoryFactory repositoryFactory,
            ApplicationVersions versions,
            AdminRepository adminRepo,
            ILogger <ApplicationServiceBehavior> logger)
        {
            _versions  = versions;
            _adminRepo = adminRepo;
            _logger    = logger;

            // Extract information from the Context Accessor
            _isServiceAccount = context.IsServiceAccount;
            if (_isServiceAccount)
            {
                _externalId = context.ExternalClientId ??
                              throw new InvalidOperationException($"The external client ID was not supplied.");
            }
            else
            {
                // This is a human user, so the external Id and email are required
                _externalId = context.ExternalUserId ??
                              throw new InvalidOperationException($"The external user ID was not supplied.");
                _externalEmail = context.ExternalEmail ??
                                 throw new InvalidOperationException($"The external user email was not supplied.");
            }

            _tenantId = context.TenantId ?? throw new ServiceException($"Tenant id was not supplied.");
            _appRepo  = repositoryFactory.GetRepository(_tenantId);
            _isSilent = context.IsSilent;
        }
Beispiel #4
0
        private int PollingBatchSize => _options.PendingNotificationExpiryInSeconds * 2; // Assuming 2 SMS per second

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

            while (!cancellation.IsCancellationRequested)
            {
                // Grab a hold of a concrete list of adopted tenantIds at the current moment
                var tenantIds = _instanceInfo.AdoptedTenantIds;
                if (tenantIds.Any())
                {
                    // Match every tenantID to the Task of polling
                    // Wait until all adopted tenants have returned
                    await Task.WhenAll(tenantIds.Select(async tenantId =>
                    {
                        try // To make sure the background service keeps running
                        {
                            // Begin serializable transaction
                            using var trx = TransactionFactory.Serializable(TransactionScopeOption.RequiresNew);

                            // Retrieve NEW or stale PENDING SMS messages, after marking them as fresh PENDING
                            var repo = _repoFactory.GetRepository(tenantId);
                            IEnumerable <MessageForSave> smsesReady = await repo.Notifications_Messages__Poll(
                                _options.PendingNotificationExpiryInSeconds, PollingBatchSize, cancellation);

                            // Queue the SMS messages for dispatching
                            foreach (SmsToSend sms in smsesReady.Select(e => NotificationsQueue.FromEntity(e, tenantId)))
                            {
                                _queue.QueueBackgroundWorkItem(sms);
                            }

                            trx.Complete();

                            // Log a warning, since in theory this job should rarely find anything, if it finds stuff too often it means something is wrong
                            if (smsesReady.Any())
                            {
                                _logger.LogWarning($"{nameof(SmsPollingJob)} found {smsesReady.Count()} SMSes in database for tenant {tenantId}.");
                            }
                        }
                        catch (TaskCanceledException) { }
                        catch (OperationCanceledException) { }
                        catch (Exception ex)
                        {
                            _logger.LogError(ex, $"Error in {GetType().Name}.");
                        }
                    }));
                }

                // Go to sleep until the next round
                await Task.Delay(_options.NotificationCheckFrequencyInSeconds * 1000, cancellation);
            }
        }
Beispiel #5
0
        /// <summary>
        /// Implementation of <see cref="VersionCache{TKey, TData}"/>.
        /// </summary>
        protected override async Task <(SettingsForClient data, string version)> GetDataFromSource(int tenantId, CancellationToken cancellation)
        {
            var repo = _repoFactory.GetRepository(tenantId);

            SettingsOutput settingsResult = await repo.Settings__Load(cancellation);

            var version              = settingsResult.Version.ToString();
            var generalSettings      = settingsResult.GeneralSettings;
            var financialSettings    = settingsResult.FinancialSettings;
            var singleBusinessUnitId = settingsResult.SingleBusinessUnitId;
            var featureFlags         = settingsResult.FeatureFlags;

            // Prepare the settings for client
            var forClient = new SettingsForClient();

            foreach (var forClientProp in typeof(SettingsForClient).GetProperties())
            {
                var settingsProp = typeof(GeneralSettings).GetProperty(forClientProp.Name);
                if (settingsProp != null)
                {
                    var value = settingsProp.GetValue(generalSettings);
                    forClientProp.SetValue(forClient, value);
                }
            }

            // Single Business Unit Id
            forClient.SingleBusinessUnitId = singleBusinessUnitId;

            // Financial Settings
            forClient.FunctionalCurrencyId           = financialSettings.FunctionalCurrencyId;
            forClient.TaxIdentificationNumber        = financialSettings.TaxIdentificationNumber;
            forClient.ArchiveDate                    = financialSettings.ArchiveDate ?? DateTime.MinValue;
            forClient.FunctionalCurrencyDecimals     = financialSettings.FunctionalCurrency.E ?? 0;
            forClient.FunctionalCurrencyName         = financialSettings.FunctionalCurrency.Name;
            forClient.FunctionalCurrencyName2        = financialSettings.FunctionalCurrency.Name2;
            forClient.FunctionalCurrencyName3        = financialSettings.FunctionalCurrency.Name3;
            forClient.FunctionalCurrencyDescription  = financialSettings.FunctionalCurrency.Description;
            forClient.FunctionalCurrencyDescription2 = financialSettings.FunctionalCurrency.Description2;
            forClient.FunctionalCurrencyDescription3 = financialSettings.FunctionalCurrency.Description3;

            // Language
            forClient.PrimaryLanguageName   = GetCultureDisplayName(forClient.PrimaryLanguageId);
            forClient.SecondaryLanguageName = GetCultureDisplayName(forClient.SecondaryLanguageId);
            forClient.TernaryLanguageName   = GetCultureDisplayName(forClient.TernaryLanguageId);

            // Feature flags
            forClient.FeatureFlags = featureFlags.ToDictionary(e => e.Key, e => e.Value);

            return(forClient, version);
        }
Beispiel #6
0
        protected override async Task ExecuteAsync(CancellationToken cancellation)
        {
            _logger.LogInformation(GetType().Name + " Started.");

            while (!cancellation.IsCancellationRequested)
            {
                try // To make sure the background service keeps running
                {
                    // When the queue is empty, this goes to sleep until an item is enqueued
                    var(sms, scheduledAt) = await _queue.DequeueAsync(cancellation);

                    // This SMS spent too long in the queue it is considered stale, SmsPollingJob will
                    // (and might have already) pick it up and send it again, so ignore it
                    if (IsStale(scheduledAt))
                    {
                        _logger.LogWarning($"Stale SMS remained in the {nameof(SmsQueue)} for {(DateTimeOffset.Now - scheduledAt).TotalSeconds} seconds. TenantId = {sms.TenantId}, MessageId = {sms.MessageId}.");
                        continue;
                    }

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

                    // Update the state first (since this action can be rolled back)
                    var repo = _repoFactory.GetRepository(tenantId: sms.TenantId);
                    await repo.Notifications_Messages__UpdateState(sms.MessageId, MessageState.Dispatched, DateTimeOffset.Now, cancellation : default); // actions that modify state should not use cancellationToken

                    try
                    {
                        // Send the SMS after you update the state in the DB, since sending SMS
                        // is non-transactional and therefore cannot be rolled back
                        await _smsSender.SendAsync(sms, cancellation : default);
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, $"Failed to Dispatch SMS. TenantId = {sms.TenantId}, MessageId = {sms.MessageId}.");

                        // If sending the SMS fails, update the state to DispatchFailed together with the error message
                        await repo.Notifications_Messages__UpdateState(sms.MessageId, MessageState.DispatchFailed, DateTimeOffset.Now, ex.Message, cancellation : default);
                    }

                    trx.Complete();
                }
                catch (TaskCanceledException) { }
                catch (OperationCanceledException) { }
                catch (Exception ex)
                {
                    _logger.LogError(ex, $"Error in {GetType().Name}.");
                }
            }
        }
        protected override async Task ExecuteAsync(CancellationToken cancellation)
        {
            _logger.LogInformation(GetType().Name + " Started.");

            while (!cancellation.IsCancellationRequested)
            {
                try
                {
                    // Grab a hold of a concrete list of adopted tenantIds at the current moment
                    var tenantIds = _instanceInfo.AdoptedTenantIds;
                    await Task.WhenAll(tenantIds.Select(async tenantId =>
                    {
                        // (1) Retrieve the schedules version
                        // If we fail to retrieve the version, we will refresh the cache to empty it anyways
                        var version = Guid.NewGuid().ToString();
                        try
                        {
                            var repo = _repoFactory.GetRepository(tenantId);
                            version  = await repo.SchedulesVersion__Load(cancellation);
                        }
                        catch (TaskCanceledException) { }
                        catch (OperationCanceledException) { }
                        catch (Exception ex)
                        {
                            _logger.LogError(ex, $"Error in {GetType().Name} while loading the schedule version for tenant Id = {tenantId}.");
                        }

                        // (2) Use the schedules version to ensure freshness of the cache
                        // This call does not throw an exception
                        var isStale = await _cache.RefreshSchedulesIfStale(tenantId, version, cancellation);
                        if (isStale)
                        {
                            // Stale schedules in the cache => Notify the main job
                            _cache.CancelCurrentToken();
                        }
                    }));
                }
                catch (TaskCanceledException) { }
                catch (OperationCanceledException) { }
                catch (Exception ex)
                {
                    _logger.LogError(ex, $"Error in {GetType().Name}.");
                }

                // Go to sleep for 1 minute
                await Task.Delay(1000 * 60, cancellation);
            }
        }
Beispiel #8
0
        public async Task HandleCallback(IEnumerable <EmailEventNotification> emailEvents, CancellationToken cancellation)
        {
            // Group events by tenant Id
            var emailEventsByTenant = emailEvents
                                      .Where(e => e.TenantId != null && e.TenantId != 0) // Right now we do not handle null or zero tenant Ids, those were probably sent from identity or admin servers
                                      .GroupBy(e => e.TenantId.Value);

            // Run all tenants in parallel
            await Task.WhenAll(emailEventsByTenant.Select(async emailEventsOfTenant =>
            {
                var tenantId = emailEventsOfTenant.Key;
                var repo     = _repoFactory.GetRepository(tenantId);

                var stateUpdatesOfTenant = emailEventsOfTenant.Select(emailEvent => new IdStateErrorTimestamp
                {
                    Id        = emailEvent.EmailId,
                    Error     = emailEvent.Error,
                    Timestamp = emailEvent.Timestamp,
                    State     = emailEvent.Event switch
                    {
                        EmailEvent.Dropped => EmailState.DispatchFailed,
                        EmailEvent.Delivered => EmailState.Delivered,
                        EmailEvent.Bounce => EmailState.DeliveryFailed,
                        EmailEvent.Open => EmailState.Opened,
                        EmailEvent.Click => EmailState.Clicked,
                        EmailEvent.SpamReport => EmailState.ReportedSpam,
                        _ => throw new InvalidOperationException($"[Bug] Unknown {nameof(EmailEvent)} = {emailEvent.Event}"), // Future proofing
                    }
                });

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

                // Update the state in the database (should we make it serializable?)
                await repo.Notifications_Emails__UpdateState(stateUpdatesOfTenant, cancellation);

                trx.Complete();
            }));
        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();
        }
Beispiel #10
0
        public async Task <CompaniesForClient> GetForClient(CancellationToken cancellation)
        {
            await Initialize(cancellation);

            var companies = new ConcurrentBag <UserCompany>();

            var(databaseIds, isAdmin) = await _adminRepo.GetAccessibleDatabaseIds(ExternalUserId, ExternalEmail, cancellation);

            // Connect all the databases in parallel and make sure the user can access them all
            await Task.WhenAll(databaseIds.Select(async(databaseId) =>
            {
                try
                {
                    var appRepo = _factory.GetRepository(databaseId);
                    var result  = await appRepo.OnConnect(
                        externalUserId: ExternalUserId,
                        userEmail: ExternalEmail,
                        isServiceAccount: IsServiceAccount,
                        setLastActive: false,
                        cancellation: cancellation);

                    if (result.UserId != null)
                    {
                        var settingsVersion = result.SettingsVersion.ToString();
                        var settings        = (await _settingsCache.GetSettings(databaseId, settingsVersion, cancellation)).Data;

                        companies.Add(new UserCompany
                        {
                            Id    = databaseId,
                            Name  = settings.ShortCompanyName,
                            Name2 = string.IsNullOrWhiteSpace(settings.SecondaryLanguageId) ? null : settings.ShortCompanyName2,
                            Name3 = string.IsNullOrWhiteSpace(settings.TernaryLanguageId) ? null : settings.ShortCompanyName3
                        });
                    }
                }
                catch (Exception ex)
                {
                    // If we fail to connect to a company, this company simply isn't added to the result
                    _logger.LogWarning(ex, $"Exception while connecting to user company: DatabaseId: {databaseId}, User email: {ExternalEmail}.");
                }
            }));

            // Confirm isAdmin by checking with the admin DB
            if (isAdmin)
            {
                var result = await _adminRepo.OnConnect(
                    externalUserId : ExternalUserId,
                    userEmail : ExternalEmail,
                    isServiceAccount : IsServiceAccount,
                    setLastActive : false,
                    cancellation : cancellation);

                isAdmin = result?.UserId != null;
            }

            return(new CompaniesForClient
            {
                IsAdmin = isAdmin,
                Companies = companies.OrderBy(e => e.Id).ToList(),
            });
        }
Beispiel #11
0
        protected override async Task ExecuteAsync(CancellationToken cancellation)
        {
            _logger.LogInformation(GetType().Name + " Started.");

            while (!cancellation.IsCancellationRequested)
            {
                try // To make sure the background service keeps running
                {
                    // When the queue is empty, this goes to sleep until an item is enqueued
                    var(emails, scheduledAt) = await _queue.DequeueAsync(cancellation);

                    if (emails == null || !emails.Any())
                    {
                        continue; // Empty emails collection for some reason
                    }

                    // These Emails spent too long in the queue they're considered stale,.
                    // EmailPollingJob will (and might have already) pick them up and send them again, so ignore them
                    if (IsStale(scheduledAt))
                    {
                        var firstEmail = emails.First();
                        _logger.LogWarning(
                            $"Stale Email remained in the {nameof(EmailQueue)} for {(DateTimeOffset.Now - scheduledAt).TotalMinutes} minutes. First Email TenantId = {firstEmail.TenantId}, EmailId = {firstEmail.EmailId}.");
                        continue;
                    }

                    foreach (var emailsOfTenant in emails.GroupBy(e => e.TenantId))
                    {
                        var tenantId = emailsOfTenant.Key;
                        var repo     = _repoFactory.GetRepository(tenantId);

                        await Task.WhenAll(emailsOfTenant.Select(async email =>
                        {
                            var statusUpdate = new List <IdStateErrorTimestamp> {
                                new IdStateErrorTimestamp
                                {
                                    Id        = email.EmailId,
                                    State     = EmailState.Dispatched,
                                    Timestamp = DateTimeOffset.Now
                                }
                            };

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

                            // Update the state first (since this action can be rolled back)
                            await repo.Notifications_Emails__UpdateState(statusUpdate, cancellation: default); // actions that modify state should not use cancellationToken

                            try
                            {
                                // Send the emails after you update the state in the DB, since sending emails
                                // is non-transactional and therefore cannot be rolled back
                                await _emailSender.SendAsync(email, null, cancellation: default);
                            }
                            catch (Exception ex)
                            {
                                _logger.LogWarning(ex, $"Failed to Dispatch Emails. TenantId = {tenantId}, EmailId = {emailsOfTenant.Select(e => e.EmailId).First()}");

                                // If sending the Email fails, update the state to DispatchFailed together with the error message
                                statusUpdate = new List <IdStateErrorTimestamp> {
                                    new IdStateErrorTimestamp
                                    {
                                        Id        = email.EmailId,
                                        State     = EmailState.DispatchFailed,
                                        Timestamp = DateTimeOffset.Now,
                                        Error     = ex.Message?.Truncate(2048)
                                    }
                                };

                                await repo.Notifications_Emails__UpdateState(statusUpdate, cancellation: default);
                            }

                            trx.Complete();
                        }));
                    }
                }
                catch (TaskCanceledException) { }
                catch (OperationCanceledException) { }
                catch (Exception ex)
                {
                    _logger.LogError(ex, $"Error in {GetType().Name}.");
                }
            }
        }
        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();
        }
Beispiel #13
0
        public virtual async Task <int> OnInitialize(IServiceContextAccessor context, CancellationToken cancellation)
        {
            IsInitialized = true;
            bool isSilent = context.IsSilent;

            _isAnonymous = context.IsAnonymous;

            // Determine if this is a userless account
            if (_isAnonymous)
            {
                // (1) Call OnConnect...
                _tenantId = context.TenantId ?? throw new ServiceException($"Tenant id was not supplied.");
                _appRepo  = _repositoryFactory.GetRepository(_tenantId);

                // Retrieve the settings version and the definitions version
                var result = await _appRepo.OnConnect(
                    externalUserId : null,
                    userEmail : null,
                    isServiceAccount : false,
                    setLastActive : !isSilent,
                    cancellation : cancellation);

                // (2) Set the versions and mark this initializer as initialized
                _versions.SettingsVersion    = result.SettingsVersion.ToString();
                _versions.DefinitionsVersion = result.DefinitionsVersion.ToString();
                _versions.AreSet             = true;

                return(0); // No user Id
            }
            else
            {
                // (1) Extract information from the Context Accessor
                bool   isServiceAccount = context.IsServiceAccount;
                string externalId;
                string externalEmail = null;
                if (context.IsServiceAccount)
                {
                    externalId = context.ExternalClientId ??
                                 throw new InvalidOperationException($"The external client ID was not supplied.");
                }
                else
                {
                    // This is a human user, so the external Id and email are required
                    externalId = context.ExternalUserId ??
                                 throw new InvalidOperationException($"The external user ID was not supplied.");
                    externalEmail = context.ExternalEmail ??
                                    throw new InvalidOperationException($"The external user email was not supplied.");
                }

                _tenantId = context.TenantId ?? throw new ServiceException($"Tenant id was not supplied.");
                _appRepo  = _repositoryFactory.GetRepository(_tenantId);


                // (2) Call OnConnect...
                // The client sometimes makes ambient (silent) API calls, not in response to
                // user interaction, such calls should not update LastAccess of that user
                var result = await _appRepo.OnConnect(
                    externalUserId : externalId,
                    userEmail : externalEmail,
                    isServiceAccount : isServiceAccount,
                    setLastActive : !isSilent,
                    cancellation : cancellation);

                // (3) Make sure the user is a member of this tenant
                if (result.UserId == null)
                {
                    // Either 1) the user is not a member in the database, or 2) the database does not exist
                    // Either way we return the not-member exception so as not to convey information to an attacker
                    throw new ForbiddenException(notMember: true);
                }

                // Extract values from the result
                var userId       = result.UserId.Value;
                var dbExternalId = result.ExternalId;
                var dbEmail      = result.Email;

                // (4) If the user exists but new, set the External Id
                if (dbExternalId == null)
                {
                    // Only possible with human users
                    // Update external Id in this tenant database
                    await _appRepo.Users__SetExternalIdByUserId(userId, externalId);

                    // Update external Id in the central Admin database too (To avoid an awkward situation
                    // where a user exists on the tenant but not on the Admin db, if they change their email in between)
                    await _adminRepo.DirectoryUsers__SetExternalIdByEmail(externalEmail, externalId);
                }

                // (5) Handle edge case
                else if (dbExternalId != externalId)
                {
                    // Only possible with human users

                    // Note: there is the edge case of identity providers who allow email recycling. I.e. we can get the same email twice with
                    // two different external Ids. This issue is so unlikely to naturally occur and cause problems here that we are not going
                    // to handle it for now. It can however happen artificially if the application is re-configured to a new identity provider,
                    // or if someone messed with the identity database directly, but again out of scope for now.
                    throw new InvalidOperationException($"The sign-in email '{dbEmail}' already exists but with a different external Id. TenantId: {TenantId}.");
                }

                // (6) If the user's email address has changed at the identity server, update it locally
                else if (dbEmail != externalEmail && !isServiceAccount)
                {
                    await _appRepo.Users__SetEmailByUserId(userId, externalEmail);

                    await _adminRepo.DirectoryUsers__SetEmailByExternalId(externalId, externalEmail);

                    _logger.LogWarning($"A user's email has been updated from '{dbEmail}' to '{externalEmail}'. TenantId: {TenantId}.");
                }

                // (7) Set the versions and mark this initializer as initialized
                _versions.SettingsVersion     = result.SettingsVersion.ToString();
                _versions.DefinitionsVersion  = result.DefinitionsVersion.ToString();
                _versions.UserSettingsVersion = result.UserSettingsVersion?.ToString();
                _versions.PermissionsVersion  = result.PermissionsVersion?.ToString();
                _versions.AreSet = true;

                _userEmail = dbEmail;
                _userId    = userId;

                // (8) Return the user Id
                return(userId);
            }
        }