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; }
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); } }
/// <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); }
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); } }
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(); }
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(), }); }
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(); }
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); } }