using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using PowderCoating.Application.Interfaces; using PowderCoating.Infrastructure.Data; namespace PowderCoating.Web.BackgroundServices; /// /// Polls every 60 seconds for appointments whose reminder window has opened and dispatches /// an email to the linked customer plus an in-app bell notification to company staff. /// /// Deduplication strategy: after selecting candidates the service immediately stamps /// ReminderSentAt on each appointment and saves before calling the notification /// methods. This prevents a second loop iteration from re-sending if notifications are slow /// or the application restarts mid-batch. A 24-hour lookback window caps the query so that /// appointments that slipped through (e.g., server downtime) are silently skipped rather /// than sending a stale reminder. /// public class AppointmentReminderBackgroundService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1); /// /// Appointments whose scheduled start is more than this far in the past are ignored even /// if their reminder was never sent (server was down, etc.). We do not want to blast a /// customer with a "your appointment is in 30 minutes" email hours after it was due. /// private static readonly TimeSpan MaxLookback = TimeSpan.FromHours(24); public AppointmentReminderBackgroundService( IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; } /// /// Long-running loop that wakes every (60 s) and calls /// . Uses with the cancellation token so /// the service shuts down promptly when the application stops. /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("AppointmentReminderBackgroundService started."); while (!stoppingToken.IsCancellationRequested) { try { await Task.Delay(PollingInterval, stoppingToken); } catch (OperationCanceledException) { break; } if (stoppingToken.IsCancellationRequested) break; await RunAsync(stoppingToken); } _logger.LogInformation("AppointmentReminderBackgroundService stopped."); } /// /// One poll iteration: find all appointments whose reminder window has opened, stamp them, /// then dispatch email + in-app notifications. A fresh DI scope is created per poll so that /// the DbContext change tracker is clean each time. /// private async Task RunAsync(CancellationToken ct) { try { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var notificationService = scope.ServiceProvider.GetRequiredService(); var inAppService = scope.ServiceProvider.GetRequiredService(); // ScheduledStartTime is stored as server-local time (no UTC conversion on form submit), // so compare against DateTime.Now rather than UtcNow to avoid a 4-hour EDT offset. var now = DateTime.Now; var lookback = now - MaxLookback; // Find appointments where: // - Reminder is enabled and has not been sent yet // - The reminder window has opened: ScheduledStartTime - ReminderMinutesBefore <= now // - The appointment hasn't been sitting unprocessed for more than MaxLookback // - The appointment status is not terminal (not cancelled, completed, no-show, etc.) // IgnoreQueryFilters bypasses the tenant filter — no HTTP context in a background service. var candidates = await db.Appointments .IgnoreQueryFilters() .Include(a => a.Customer) .Include(a => a.AppointmentStatus) .Where(a => !a.IsDeleted && a.IsReminderEnabled && a.ReminderSentAt == null && a.ScheduledStartTime > lookback && EF.Functions.DateDiffMinute(now, a.ScheduledStartTime) <= a.ReminderMinutesBefore && !a.AppointmentStatus.IsTerminalStatus) .ToListAsync(ct); if (candidates.Count == 0) return; _logger.LogInformation( "AppointmentReminderBackgroundService: {Count} appointment reminder(s) to dispatch.", candidates.Count); // Stamp ReminderSentAt before sending — prevents a restart from re-sending. var stampedAt = now; foreach (var appt in candidates) appt.ReminderSentAt = stampedAt; await db.SaveChangesAsync(ct); // Now send notifications. Failures here don't roll back the stamp because we'd // rather skip one reminder than spam a customer on every restart. foreach (var appt in candidates) { if (ct.IsCancellationRequested) break; try { // Email to linked customer (no-ops internally if customer has opted out) await notificationService.NotifyAppointmentReminderAsync(appt); // In-app bell notification for company staff var when = appt.IsAllDay ? appt.ScheduledStartTime.ToString("MMMM d, yyyy") : appt.ScheduledStartTime.ToString("MMMM d, yyyy 'at' h:mm tt"); await inAppService.CreateAsync( companyId: appt.CompanyId, title: $"Appointment Reminder: {appt.Title}", message: $"{appt.AppointmentNumber} is scheduled for {when}.", notificationType: "AppointmentReminder", link: $"/Appointments/Details/{appt.Id}", customerId: appt.CustomerId); _logger.LogInformation( "Reminder dispatched for appointment {AppointmentNumber} (id {Id}, company {CompanyId}).", appt.AppointmentNumber, appt.Id, appt.CompanyId); } catch (Exception ex) { _logger.LogError(ex, "Failed to dispatch reminder for appointment {AppointmentId}.", appt.Id); } } } catch (Exception ex) { _logger.LogError(ex, "AppointmentReminderBackgroundService poll failed."); } } }