Initial commit
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Runs once daily and purges AuditLog rows older than the AuditLogRetentionDays platform setting (default 365).
|
||||
/// Deletes in batches to avoid large single-transaction locks.
|
||||
/// </summary>
|
||||
public class AuditLogRetentionBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<AuditLogRetentionBackgroundService> _logger;
|
||||
|
||||
private static readonly TimeOnly RunTime = new(2, 0); // 2:00 AM server local time
|
||||
private const int BatchSize = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with the required scope factory and logger.
|
||||
/// <see cref="IServiceScopeFactory"/> is used because <see cref="BackgroundService"/> is a singleton
|
||||
/// and scoped services (DbContext, IPlatformSettingsService) cannot be injected directly.
|
||||
/// </summary>
|
||||
public AuditLogRetentionBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<AuditLogRetentionBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-running loop that wakes once per day at <see cref="RunTime"/> (2:00 AM local) to run
|
||||
/// the purge job. Scheduled at 2 AM to run during off-hours when transaction volume is lowest,
|
||||
/// minimizing the risk of lock contention with active queries.
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("AuditLogRetentionBackgroundService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = GetDelayUntilNextRun();
|
||||
_logger.LogDebug("Audit log retention sleeping {Delay} until next run.", delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
await RunRetentionAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the <c>AuditLogRetentionDays</c> platform setting (default 365) and deletes all
|
||||
/// <c>AuditLog</c> rows whose <c>Timestamp</c> is older than the computed cutoff date.
|
||||
/// Deletions are performed in batches of <see cref="BatchSize"/> rows with a 200 ms pause
|
||||
/// between batches to avoid holding a large exclusive lock that would block audit writes
|
||||
/// from concurrent requests.
|
||||
/// </summary>
|
||||
private async Task RunRetentionAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var platformSettings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
|
||||
|
||||
var raw = await platformSettings.GetAsync(PlatformSettingKeys.AuditLogRetentionDays);
|
||||
var retentionDays = int.TryParse(raw, out var d) ? d : 365;
|
||||
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
_logger.LogInformation("Audit log retention: purging entries older than {Cutoff:yyyy-MM-dd} (retention={Days}d)",
|
||||
cutoff, retentionDays);
|
||||
|
||||
try
|
||||
{
|
||||
var totalDeleted = 0;
|
||||
int deleted;
|
||||
do
|
||||
{
|
||||
// Batch deletes to avoid long-running transactions
|
||||
deleted = await db.AuditLogs
|
||||
.Where(a => a.Timestamp < cutoff)
|
||||
.OrderBy(a => a.Timestamp)
|
||||
.Take(BatchSize)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
totalDeleted += deleted;
|
||||
|
||||
if (deleted > 0)
|
||||
await Task.Delay(200, ct); // brief pause between batches
|
||||
|
||||
} while (deleted == BatchSize && !ct.IsCancellationRequested);
|
||||
|
||||
if (totalDeleted > 0)
|
||||
_logger.LogInformation("Audit log retention: deleted {Count} old entries.", totalDeleted);
|
||||
else
|
||||
_logger.LogDebug("Audit log retention: nothing to purge.");
|
||||
}
|
||||
catch (OperationCanceledException) { /* shutting down — fine */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Audit log retention job failed.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates how long to sleep before the next scheduled run at <see cref="RunTime"/> (2:00 AM local).
|
||||
/// Uses local server time so the maintenance window aligns with the server's overnight period
|
||||
/// regardless of the UTC offset. Returns the delay to tomorrow's 2 AM if that time has already
|
||||
/// passed for today.
|
||||
/// </summary>
|
||||
private static TimeSpan GetDelayUntilNextRun()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = DateTime.Today.Add(RunTime.ToTimeSpan());
|
||||
if (next <= now)
|
||||
next = next.AddDays(1);
|
||||
return next - now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Runs once per day and sends overdue-invoice payment reminders to customers
|
||||
/// for companies that have PaymentRemindersEnabled = true.
|
||||
/// </summary>
|
||||
public class PaymentReminderBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<PaymentReminderBackgroundService> _logger;
|
||||
|
||||
// Run at 9:00 AM server local time every day.
|
||||
private static readonly TimeOnly RunTime = new(9, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with a scope factory and logger.
|
||||
/// <see cref="IServiceScopeFactory"/> is required because <see cref="BackgroundService"/> is a singleton
|
||||
/// and scoped services (DbContext, INotificationService) cannot be injected directly.
|
||||
/// </summary>
|
||||
public PaymentReminderBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<PaymentReminderBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-running loop that wakes once per day at <see cref="RunTime"/> (9:00 AM local) and
|
||||
/// triggers the reminder check. Uses <see cref="Task.Delay"/> with the cancellation token so
|
||||
/// that the service shuts down promptly when the application stops rather than waiting for the
|
||||
/// next scheduled window.
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("PaymentReminderBackgroundService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = GetDelayUntilNextRun();
|
||||
_logger.LogDebug("Payment reminder service sleeping for {Delay} until next run.", delay);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
await RunAsync(stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("PaymentReminderBackgroundService stopped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the daily reminder pass: opens a DI scope, loads companies with reminders enabled,
|
||||
/// and delegates per-company processing to <see cref="ProcessCompanyAsync"/>.
|
||||
/// A fresh scope is used per run so that the DbContext change tracker is clean each day.
|
||||
/// Top-level exceptions are caught here so that a single bad run does not kill the background
|
||||
/// service loop.
|
||||
/// </summary>
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Running daily payment reminder check.");
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
// Load all companies that have reminders enabled.
|
||||
// IgnoreQueryFilters is required because there is no HTTP context (tenant filter
|
||||
// would otherwise resolve to CompanyId == null and return no rows).
|
||||
var enabledPrefs = await db.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(p => p.PaymentRemindersEnabled && !p.IsDeleted)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (enabledPrefs.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No companies have payment reminders enabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var prefs in enabledPrefs)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var thresholds = ParseThresholds(prefs.PaymentReminderDays);
|
||||
if (thresholds.Length == 0) continue;
|
||||
|
||||
await ProcessCompanyAsync(db, notificationService, prefs.CompanyId, thresholds, today, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Payment reminder run failed.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends payment reminder notifications for a single company's overdue invoices.
|
||||
/// Only fires when the number of days overdue exactly matches one of the configured
|
||||
/// <paramref name="thresholds"/>; this prevents re-sending on every subsequent day.
|
||||
/// Deduplication via the <c>NotificationLogs</c> table ensures a reminder is not sent twice
|
||||
/// in the same calendar day if the service is restarted or run manually.
|
||||
/// </summary>
|
||||
private async Task ProcessCompanyAsync(
|
||||
ApplicationDbContext db,
|
||||
INotificationService notificationService,
|
||||
int companyId,
|
||||
int[] thresholds,
|
||||
DateTime today,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Load overdue invoices (Sent, PartiallyPaid, or Overdue) with a past due date.
|
||||
// IgnoreQueryFilters to bypass tenant filter (no HTTP context in background service).
|
||||
var overdueInvoices = await db.Invoices
|
||||
.IgnoreQueryFilters()
|
||||
.Include(i => i.Customer)
|
||||
.Where(i =>
|
||||
i.CompanyId == companyId &&
|
||||
!i.IsDeleted &&
|
||||
i.DueDate.HasValue &&
|
||||
i.DueDate.Value.Date < today &&
|
||||
(i.Status == InvoiceStatus.Sent ||
|
||||
i.Status == InvoiceStatus.PartiallyPaid ||
|
||||
i.Status == InvoiceStatus.Overdue))
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (overdueInvoices.Count == 0) return;
|
||||
|
||||
// Load today's already-sent reminder logs for this company to avoid duplicates
|
||||
var todayStart = today;
|
||||
var todayEnd = today.AddDays(1);
|
||||
|
||||
var sentTodayInvoiceIds = await db.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(l =>
|
||||
l.CompanyId == companyId &&
|
||||
!l.IsDeleted &&
|
||||
l.NotificationType == NotificationType.PaymentReminder &&
|
||||
l.InvoiceId.HasValue &&
|
||||
l.SentAt >= todayStart &&
|
||||
l.SentAt < todayEnd &&
|
||||
l.Status == NotificationStatus.Sent)
|
||||
.Select(l => l.InvoiceId!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var sentTodaySet = new HashSet<int>(sentTodayInvoiceIds);
|
||||
|
||||
foreach (var invoice in overdueInvoices)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
if (sentTodaySet.Contains(invoice.Id)) continue;
|
||||
|
||||
var daysOverdue = (int)(today - invoice.DueDate!.Value.Date).TotalDays;
|
||||
|
||||
// Only send if daysOverdue matches one of the configured thresholds
|
||||
if (!thresholds.Contains(daysOverdue)) continue;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sending payment reminder for invoice {InvoiceNumber} (company {CompanyId}, {DaysOverdue} days overdue).",
|
||||
invoice.InvoiceNumber, companyId, daysOverdue);
|
||||
|
||||
await notificationService.NotifyPaymentReminderAsync(invoice, daysOverdue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the comma-separated reminder threshold string stored in <c>CompanyPreferences.PaymentReminderDays</c>
|
||||
/// (e.g., "1,7,14,30") into a sorted, deduplicated array of positive integers.
|
||||
/// Invalid or zero values are silently skipped so that poorly entered config data does not
|
||||
/// prevent valid thresholds from working.
|
||||
/// </summary>
|
||||
private static int[] ParseThresholds(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return [];
|
||||
|
||||
return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => int.TryParse(s, out var n) && n > 0 ? n : (int?)null)
|
||||
.Where(n => n.HasValue)
|
||||
.Select(n => n!.Value)
|
||||
.Distinct()
|
||||
.OrderBy(n => n)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates how long to sleep before the next scheduled run at <see cref="RunTime"/>.
|
||||
/// Uses local server time rather than UTC because <see cref="RunTime"/> is expressed in local
|
||||
/// time (9:00 AM) so the reminder arrives during business hours regardless of the server's
|
||||
/// UTC offset. If the scheduled time has already passed today, the next run is tomorrow.
|
||||
/// </summary>
|
||||
private static TimeSpan GetDelayUntilNextRun()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var nextRun = now.Date.Add(RunTime.ToTimeSpan());
|
||||
if (nextRun <= now)
|
||||
nextRun = nextRun.AddDays(1);
|
||||
return nextRun - now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Runs once per day and sends an in-app notification to any company that has not yet completed
|
||||
/// the setup wizard. The reminder schedule is: first nudge on day 3, then every 7 days after that
|
||||
/// (days 3, 10, 17, 24, …). Deduplication is done by querying the most recent
|
||||
/// <c>SetupWizardReminder</c> notification in <c>InAppNotifications</c> — no extra DB columns
|
||||
/// are needed.
|
||||
/// </summary>
|
||||
public class SetupWizardReminderBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SetupWizardReminderBackgroundService> _logger;
|
||||
|
||||
// First reminder fires when the company is at least this many days old.
|
||||
private const int FirstReminderAfterDays = 3;
|
||||
|
||||
// Subsequent reminders fire this many days after the previous one.
|
||||
private const int RepeatIntervalDays = 7;
|
||||
|
||||
// Run at 9:00 AM server local time each day — same cadence as other daily services.
|
||||
private static readonly TimeOnly RunTime = new(9, 0);
|
||||
|
||||
// Matches the string stored in InAppNotification.NotificationType for wizard reminders.
|
||||
private const string ReminderNotificationType = "SetupWizardReminder";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with a scope factory (required because BackgroundService is a
|
||||
/// singleton and both DbContext and IInAppNotificationService are scoped) and a logger.
|
||||
/// </summary>
|
||||
public SetupWizardReminderBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<SetupWizardReminderBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-running loop that sleeps until 9:00 AM each day then delegates to
|
||||
/// <see cref="RunAsync"/>. Uses the cancellation token so the service stops promptly on
|
||||
/// application shutdown rather than waiting for the next wake window.
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("SetupWizardReminderBackgroundService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = GetDelayUntilNextRun();
|
||||
_logger.LogDebug("Setup wizard reminder service sleeping for {Delay} until next run.", delay);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
await RunAsync(stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("SetupWizardReminderBackgroundService stopped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a fresh DI scope, loads all companies with an incomplete setup wizard, and sends
|
||||
/// a reminder to each one that is due for a nudge today. A new scope per run keeps the
|
||||
/// DbContext change tracker clean across daily executions. Top-level exceptions are caught
|
||||
/// here so that a single bad run does not kill the service loop.
|
||||
/// </summary>
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Running daily setup-wizard reminder check.");
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var notificationService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
// Load all incomplete companies. IgnoreQueryFilters is required — there is no HTTP
|
||||
// context in a background service so the tenant filter would resolve to null and
|
||||
// return zero rows.
|
||||
var incompletePrefs = await db.CompanyPreferences
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(p => !p.IsDeleted && !p.SetupWizardCompleted)
|
||||
.Select(p => new { p.CompanyId, p.CreatedAt })
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (incompletePrefs.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All companies have completed the setup wizard — nothing to do.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pull the most recent SetupWizardReminder notification per company in one query so
|
||||
// we don't do N round-trips.
|
||||
var companyIds = incompletePrefs.Select(p => p.CompanyId).ToList();
|
||||
|
||||
var lastReminderByCompany = await db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(n =>
|
||||
!n.IsDeleted &&
|
||||
n.NotificationType == ReminderNotificationType &&
|
||||
companyIds.Contains(n.CompanyId))
|
||||
.GroupBy(n => n.CompanyId)
|
||||
.Select(g => new
|
||||
{
|
||||
CompanyId = g.Key,
|
||||
LastSentAt = g.Max(n => n.CreatedAt),
|
||||
Count = g.Count()
|
||||
})
|
||||
.ToDictionaryAsync(x => x.CompanyId, ct);
|
||||
|
||||
foreach (var prefs in incompletePrefs)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var companyAgedays = (today - prefs.CreatedAt.Date).Days;
|
||||
|
||||
if (!lastReminderByCompany.TryGetValue(prefs.CompanyId, out var lastReminder))
|
||||
{
|
||||
// No reminder sent yet — fire the first one once the company is old enough.
|
||||
if (companyAgedays < FirstReminderAfterDays) continue;
|
||||
|
||||
await SendReminderAsync(notificationService, prefs.CompanyId, reminderNumber: 1, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// A reminder was sent before — repeat if the interval has elapsed.
|
||||
var daysSinceLast = (today - lastReminder.LastSentAt.Date).Days;
|
||||
if (daysSinceLast < RepeatIntervalDays) continue;
|
||||
|
||||
await SendReminderAsync(notificationService, prefs.CompanyId,
|
||||
reminderNumber: lastReminder.Count + 1, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Setup wizard reminder run failed.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a single setup-wizard reminder notification for the given company. The title and
|
||||
/// message escalate slightly across reminder numbers: the first nudge is friendly; later ones
|
||||
/// are more direct. The link takes the user straight to Step 1 of the wizard.
|
||||
/// </summary>
|
||||
private static async Task SendReminderAsync(
|
||||
IInAppNotificationService notificationService,
|
||||
int companyId,
|
||||
int reminderNumber,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (title, message) = reminderNumber switch
|
||||
{
|
||||
1 => (
|
||||
"Complete your setup wizard",
|
||||
"Welcome! Your setup wizard is still in progress. Taking a few minutes to finish it will unlock accurate pricing, oven scheduling, and more."),
|
||||
2 => (
|
||||
"Your setup isn't finished yet",
|
||||
"You're leaving features on the table. Complete the setup wizard to configure your operating costs, equipment, and pricing tiers."),
|
||||
_ => (
|
||||
"Reminder: setup wizard incomplete",
|
||||
"Your account setup is still incomplete. Finish the setup wizard so your team can get the most out of Powder Coating Logix.")
|
||||
};
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
companyId,
|
||||
title,
|
||||
message,
|
||||
ReminderNotificationType,
|
||||
link: "/SetupWizard");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the delay until the next 9:00 AM local run. If that time has already passed
|
||||
/// today the next run is scheduled for tomorrow — same logic used by other daily services.
|
||||
/// </summary>
|
||||
private static TimeSpan GetDelayUntilNextRun()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var nextRun = now.Date.Add(RunTime.ToTimeSpan());
|
||||
if (nextRun <= now)
|
||||
nextRun = nextRun.AddDays(1);
|
||||
return nextRun - now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Runs once daily and purges StripeWebhookEvents older than the StripeWebhookRetentionDays platform setting (default 90).
|
||||
/// Processed events are safe to purge; unprocessed ones are kept regardless of age
|
||||
/// so nothing is silently dropped.
|
||||
/// </summary>
|
||||
public class StripeWebhookRetentionBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<StripeWebhookRetentionBackgroundService> _logger;
|
||||
|
||||
private static readonly TimeOnly RunTime = new(3, 0); // 3:00 AM — stagger after AuditLog (2 AM)
|
||||
private const int BatchSize = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with the required scope factory and logger.
|
||||
/// <see cref="IServiceScopeFactory"/> is required because <see cref="BackgroundService"/> is a singleton
|
||||
/// and scoped EF DbContext instances cannot be injected directly into it.
|
||||
/// </summary>
|
||||
public StripeWebhookRetentionBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<StripeWebhookRetentionBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-running loop that wakes once per day at <see cref="RunTime"/> (3:00 AM local) to purge
|
||||
/// old webhook event records. Scheduled at 3 AM — one hour after the audit log retention job —
|
||||
/// to stagger database-intensive maintenance tasks and reduce peak disk I/O.
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("StripeWebhookRetentionBackgroundService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = GetDelayUntilNextRun();
|
||||
_logger.LogDebug("Stripe webhook retention sleeping {Delay} until next run.", delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
await RunRetentionAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the <c>StripeWebhookRetentionDays</c> platform setting (default 90) and hard-deletes
|
||||
/// processed <c>StripeWebhookEvent</c> rows older than the cutoff date.
|
||||
/// Unprocessed events (where <c>ProcessedAt</c> is null) are always preserved regardless of age
|
||||
/// so that no webhook is silently dropped — a stuck event will surface in monitoring rather than
|
||||
/// disappear. Deletions are batched with a 200 ms pause between iterations to minimize lock pressure.
|
||||
/// </summary>
|
||||
private async Task RunRetentionAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var platformSettings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
|
||||
|
||||
var raw = await platformSettings.GetAsync(PlatformSettingKeys.StripeWebhookRetentionDays);
|
||||
var retentionDays = int.TryParse(raw, out var d) ? d : 90;
|
||||
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Stripe webhook retention: purging processed events older than {Cutoff:yyyy-MM-dd} (retention={Days}d)",
|
||||
cutoff, retentionDays);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
var totalDeleted = 0;
|
||||
int deleted;
|
||||
do
|
||||
{
|
||||
// Only purge events that have been processed — never drop unprocessed ones
|
||||
deleted = await db.StripeWebhookEvents
|
||||
.Where(e => e.ReceivedAt < cutoff && e.ProcessedAt != null)
|
||||
.OrderBy(e => e.ReceivedAt)
|
||||
.Take(BatchSize)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
totalDeleted += deleted;
|
||||
|
||||
if (deleted > 0)
|
||||
await Task.Delay(200, ct);
|
||||
|
||||
} while (deleted == BatchSize && !ct.IsCancellationRequested);
|
||||
|
||||
if (totalDeleted > 0)
|
||||
_logger.LogInformation("Stripe webhook retention: deleted {Count} old events.", totalDeleted);
|
||||
else
|
||||
_logger.LogDebug("Stripe webhook retention: nothing to purge.");
|
||||
}
|
||||
catch (OperationCanceledException) { /* shutting down */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Stripe webhook retention job failed.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates how long to sleep before the next scheduled run at <see cref="RunTime"/> (3:00 AM local).
|
||||
/// Uses local server time so the window aligns with the server's overnight low-traffic period.
|
||||
/// Returns the delay to tomorrow's 3 AM if that time has already passed today.
|
||||
/// </summary>
|
||||
private static TimeSpan GetDelayUntilNextRun()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = DateTime.Today.Add(RunTime.ToTimeSpan());
|
||||
if (next <= now)
|
||||
next = next.AddDays(1);
|
||||
return next - now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Runs once per day and:
|
||||
/// 1. Advances Active → GracePeriod and GracePeriod → Expired based on SubscriptionEndDate.
|
||||
/// 2. Sends reminder emails at 14, 7, 3, and 1 day(s) before expiry.
|
||||
///
|
||||
/// Skips companies that are IsComped or managed by Stripe (StripeSubscriptionId set).
|
||||
/// </summary>
|
||||
public class SubscriptionExpiryBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SubscriptionExpiryBackgroundService> _logger;
|
||||
|
||||
// Run at 8:00 AM server local time every day.
|
||||
private static readonly TimeOnly RunTime = new(8, 0);
|
||||
|
||||
// Days-before-expiry at which reminder emails are sent.
|
||||
private static readonly int[] ReminderDays = [14, 7, 3, 1];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with a scope factory and logger.
|
||||
/// <see cref="IServiceScopeFactory"/> is required because <see cref="BackgroundService"/> is a singleton
|
||||
/// while the services it consumes (DbContext, IEmailService) are scoped.
|
||||
/// </summary>
|
||||
public SubscriptionExpiryBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<SubscriptionExpiryBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-running loop that wakes once per day at <see cref="RunTime"/> (8:00 AM local) and runs
|
||||
/// the expiry check. Scheduled 1 hour before the payment reminder service (9 AM) so that
|
||||
/// subscription state transitions are committed before reminder emails reference subscription status.
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("SubscriptionExpiryBackgroundService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = GetDelayUntilNextRun();
|
||||
_logger.LogDebug("Subscription expiry service sleeping {Delay} until next run.", delay);
|
||||
|
||||
try { await Task.Delay(delay, stoppingToken); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
await RunAsync(stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("SubscriptionExpiryBackgroundService stopped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a DI scope, queries non-Stripe-managed companies with active or grace-period subscriptions,
|
||||
/// and calls <see cref="ProcessCompanyAsync"/> for each. A single <c>SaveChangesAsync</c> at the
|
||||
/// end batches all status mutations into one round-trip. Errors are caught to keep the loop alive.
|
||||
/// </summary>
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Running daily subscription expiry check.");
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
|
||||
var adminNotification = scope.ServiceProvider.GetRequiredService<IAdminNotificationService>();
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
// Load all non-comped, non-Stripe-managed companies that might need attention.
|
||||
var companies = await db.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c =>
|
||||
!c.IsDeleted &&
|
||||
!c.IsComped &&
|
||||
string.IsNullOrEmpty(c.StripeSubscriptionId) &&
|
||||
c.SubscriptionEndDate.HasValue &&
|
||||
(c.SubscriptionStatus == SubscriptionStatus.Active ||
|
||||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
|
||||
.ToListAsync(ct);
|
||||
|
||||
_logger.LogDebug("Found {Count} companies to evaluate.", companies.Count);
|
||||
|
||||
foreach (var company in companies)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Subscription expiry run failed.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a single company and performs any required status transitions or reminder sends.
|
||||
/// Transition logic: Active past end date → GracePeriod; GracePeriod past grace deadline → Expired + deactivated.
|
||||
/// Reminder emails at <see cref="ReminderDays"/> offsets are sent only while the company is still Active.
|
||||
/// Platform admin is notified asynchronously (fire-and-forget) for both grace period start and full expiry
|
||||
/// so that operator action can be taken without delaying the main processing loop.
|
||||
/// </summary>
|
||||
private async Task ProcessCompanyAsync(
|
||||
ApplicationDbContext db,
|
||||
IEmailService emailService,
|
||||
IAdminNotificationService adminNotification,
|
||||
PowderCoating.Core.Entities.Company company,
|
||||
DateTime today,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var endDate = company.SubscriptionEndDate!.Value.Date;
|
||||
var gracePeriodDays = AppConstants.SubscriptionConstants.GracePeriodDays;
|
||||
var expiredDate = endDate.AddDays(gracePeriodDays);
|
||||
|
||||
// ── Status transitions ────────────────────────────────────────────
|
||||
if (company.SubscriptionStatus == SubscriptionStatus.GracePeriod && today >= expiredDate)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Company {Id} ({Name}) grace period ended. Marking Expired and deactivating.",
|
||||
company.Id, company.CompanyName);
|
||||
|
||||
company.SubscriptionStatus = SubscriptionStatus.Expired;
|
||||
company.IsActive = false;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
company.UpdatedBy = "System";
|
||||
|
||||
await WriteAuditLogAsync(db, company,
|
||||
$"Auto-expired: grace period ended {gracePeriodDays} days after subscription end {endDate:d}.");
|
||||
|
||||
// Notify platform admin
|
||||
_ = adminNotification.NotifyCompanyExpiredAsync(
|
||||
company.Id, company.CompanyName,
|
||||
company.PrimaryContactEmail ?? string.Empty, expiredDate);
|
||||
}
|
||||
else if (company.SubscriptionStatus == SubscriptionStatus.Active && today > endDate)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Company {Id} ({Name}) subscription ended. Entering grace period.",
|
||||
company.Id, company.CompanyName);
|
||||
|
||||
company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
company.UpdatedBy = "System";
|
||||
|
||||
await WriteAuditLogAsync(db, company,
|
||||
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
|
||||
|
||||
// Send "grace period started" email to company immediately
|
||||
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
||||
NotificationType.SubscriptionExpiryReminder,
|
||||
daysBeforeExpiry: 0,
|
||||
ct);
|
||||
|
||||
// Notify platform admin
|
||||
_ = adminNotification.NotifyCompanyGracePeriodAsync(
|
||||
company.Id, company.CompanyName,
|
||||
company.PrimaryContactEmail ?? string.Empty, expiredDate);
|
||||
}
|
||||
|
||||
// ── Reminder emails (only while still Active) ────────────────────
|
||||
if (company.SubscriptionStatus == SubscriptionStatus.Active)
|
||||
{
|
||||
var daysUntilExpiry = (endDate - today).Days;
|
||||
if (ReminderDays.Contains(daysUntilExpiry))
|
||||
{
|
||||
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
||||
NotificationType.SubscriptionExpiryReminder,
|
||||
daysBeforeExpiry: daysUntilExpiry,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a subscription expiry reminder or grace-period email to the company's primary contact,
|
||||
/// then records the send in <c>NotificationLogs</c> (both on success and failure).
|
||||
/// Deduplication is achieved by checking the log for the same type, company, and day+subject-tag
|
||||
/// before sending, which prevents duplicate emails if the service restarts mid-run.
|
||||
/// <paramref name="daysBeforeExpiry"/> = 0 signals a grace-period notice rather than a pre-expiry reminder.
|
||||
/// </summary>
|
||||
private static async Task SendEmailIfNotSentAsync(
|
||||
ApplicationDbContext db,
|
||||
IEmailService emailService,
|
||||
PowderCoating.Core.Entities.Company company,
|
||||
DateTime today,
|
||||
NotificationType notificationType,
|
||||
int daysBeforeExpiry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(company.PrimaryContactEmail)) return;
|
||||
|
||||
// Prevent duplicate sends — check if we already sent this type today for this company
|
||||
var alreadySent = await db.NotificationLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.AnyAsync(l =>
|
||||
l.CompanyId == company.Id &&
|
||||
!l.IsDeleted &&
|
||||
l.NotificationType == notificationType &&
|
||||
l.SentAt >= today &&
|
||||
l.SentAt < today.AddDays(1) &&
|
||||
l.Status == NotificationStatus.Sent &&
|
||||
l.Subject != null && l.Subject.Contains($"[{daysBeforeExpiry}d]"),
|
||||
ct);
|
||||
|
||||
if (alreadySent) return;
|
||||
|
||||
var (subject, plain, html) = BuildEmail(company, daysBeforeExpiry);
|
||||
|
||||
var (success, error) = await emailService.SendEmailAsync(
|
||||
company.PrimaryContactEmail,
|
||||
company.CompanyName,
|
||||
subject,
|
||||
plain,
|
||||
html);
|
||||
|
||||
db.NotificationLogs.Add(new NotificationLog
|
||||
{
|
||||
CompanyId = company.Id,
|
||||
Channel = NotificationChannel.Email,
|
||||
NotificationType = notificationType,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = company.CompanyName,
|
||||
Recipient = company.PrimaryContactEmail,
|
||||
Subject = subject,
|
||||
Message = plain,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the subject line and both plain-text and HTML email bodies for the reminder or
|
||||
/// grace-period notice. The subject embeds a "[Nd]" tag (e.g., "[7d]", "[0d]") which the
|
||||
/// deduplication query in <see cref="SendEmailIfNotSentAsync"/> uses as a secondary key to
|
||||
/// distinguish emails sent on different days within the same calendar day.
|
||||
/// </summary>
|
||||
private static (string subject, string plain, string html) BuildEmail(
|
||||
PowderCoating.Core.Entities.Company company,
|
||||
int daysBeforeExpiry)
|
||||
{
|
||||
var endDate = company.SubscriptionEndDate!.Value.Date;
|
||||
|
||||
if (daysBeforeExpiry == 0)
|
||||
{
|
||||
// Grace period notice
|
||||
var graceDays = AppConstants.SubscriptionConstants.GracePeriodDays;
|
||||
var expiredOn = endDate.AddDays(graceDays).ToString("MMMM d, yyyy");
|
||||
var subject = $"[0d] Your Powder Coating Logix subscription has expired";
|
||||
var plain = $"Hi {company.CompanyName},\n\n" +
|
||||
$"Your Powder Coating Logix subscription expired on {endDate:MMMM d, yyyy}. " +
|
||||
$"You have a {graceDays}-day grace period — your account will be fully suspended on {expiredOn} if not renewed.\n\n" +
|
||||
$"Log in to renew your subscription and keep your shop running.\n\nThank you,\nThe Powder Coating Logix Team";
|
||||
var html = $"<p>Hi <strong>{company.CompanyName}</strong>,</p>" +
|
||||
$"<p>Your Powder Coating Logix subscription expired on <strong>{endDate:MMMM d, yyyy}</strong>.</p>" +
|
||||
$"<p>You have a <strong>{graceDays}-day grace period</strong>. Your account will be fully suspended on <strong>{expiredOn}</strong> if not renewed.</p>" +
|
||||
$"<p>Log in to renew your subscription and keep your shop running.</p>" +
|
||||
$"<p>Thank you,<br>The Powder Coating Logix Team</p>";
|
||||
return (subject, plain, html);
|
||||
}
|
||||
else
|
||||
{
|
||||
var expiresOn = endDate.ToString("MMMM d, yyyy");
|
||||
var subject = $"[{daysBeforeExpiry}d] Your Powder Coating Logix subscription expires in {daysBeforeExpiry} day{(daysBeforeExpiry == 1 ? "" : "s")}";
|
||||
var plain = $"Hi {company.CompanyName},\n\n" +
|
||||
$"This is a reminder that your Powder Coating Logix subscription expires on {expiresOn} " +
|
||||
$"({daysBeforeExpiry} day{(daysBeforeExpiry == 1 ? "" : "s")} from today).\n\n" +
|
||||
$"Log in to renew your subscription before it expires.\n\nThank you,\nThe Powder Coating Logix Team";
|
||||
var html = $"<p>Hi <strong>{company.CompanyName}</strong>,</p>" +
|
||||
$"<p>Your Powder Coating Logix subscription expires on <strong>{expiresOn}</strong> " +
|
||||
$"({daysBeforeExpiry} day{(daysBeforeExpiry == 1 ? "" : "s")} from today).</p>" +
|
||||
$"<p>Log in to renew your subscription before it expires.</p>" +
|
||||
$"<p>Thank you,<br>The Powder Coating Logix Team</p>";
|
||||
return (subject, plain, html);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a system-generated audit log entry recording the subscription status transition.
|
||||
/// Uses raw SQL rather than the EF change tracker because <c>AuditLog</c> does not inherit
|
||||
/// <c>BaseEntity</c> and its <c>Timestamp</c> column is non-nullable without a default;
|
||||
/// inserting via raw SQL also avoids accidentally triggering any EF interceptors that assume
|
||||
/// a user context is present.
|
||||
/// </summary>
|
||||
private static async Task WriteAuditLogAsync(
|
||||
ApplicationDbContext db,
|
||||
PowderCoating.Core.Entities.Company company,
|
||||
string message)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
INSERT INTO AuditLogs (UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId,
|
||||
EntityDescription, OldValues, NewValues, IpAddress, Timestamp)
|
||||
VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})
|
||||
""",
|
||||
(object?)null,
|
||||
"System",
|
||||
(object?)null,
|
||||
(object?)null,
|
||||
"Updated",
|
||||
"Company",
|
||||
company.Id.ToString(),
|
||||
company.CompanyName,
|
||||
(object?)null,
|
||||
message,
|
||||
(object?)null,
|
||||
DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the delay until the next scheduled run at <see cref="RunTime"/> (8:00 AM local).
|
||||
/// Uses local time so the check fires during business hours on the server's clock.
|
||||
/// If 8 AM has already passed today, returns the delay to 8 AM tomorrow.
|
||||
/// </summary>
|
||||
private static TimeSpan GetDelayUntilNextRun()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var nextRun = now.Date.Add(RunTime.ToTimeSpan());
|
||||
if (nextRun <= now) nextRun = nextRun.AddDays(1);
|
||||
return nextRun - now;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user