2bf8871892
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email; AppointmentReminderStaff notification type + default template added; DateTime.Now used instead of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed - NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it to false and re-applied the extra layer charge; added column to both entities (migration AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads, JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS template path that hard-coded noExtraLayerCharge: false - Coat notes not visible: notes were rendered in desktop job details but missing from the wizard item card summary and the mobile card view; both fixed - Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner form submit handler; path-keyed so cross-page navigation does not restore stale position; requestAnimationFrame used for reliable mobile scroll restoration - Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal moved outside the Draft guard - InitialCreate migration added for fresh database installs; Baseline migration guarded with IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
7.2 KiB
C#
163 lines
7.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>ReminderSentAt</c> 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.
|
|
/// </summary>
|
|
public class AppointmentReminderBackgroundService : BackgroundService
|
|
{
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly ILogger<AppointmentReminderBackgroundService> _logger;
|
|
|
|
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static readonly TimeSpan MaxLookback = TimeSpan.FromHours(24);
|
|
|
|
public AppointmentReminderBackgroundService(
|
|
IServiceScopeFactory scopeFactory,
|
|
ILogger<AppointmentReminderBackgroundService> logger)
|
|
{
|
|
_scopeFactory = scopeFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Long-running loop that wakes every <see cref="PollingInterval"/> (60 s) and calls
|
|
/// <see cref="RunAsync"/>. Uses <see cref="Task.Delay"/> with the cancellation token so
|
|
/// the service shuts down promptly when the application stops.
|
|
/// </summary>
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task RunAsync(CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
|
var inAppService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
|
|
|
|
// 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.");
|
|
}
|
|
}
|
|
}
|