Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button

- 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>
This commit is contained in:
2026-05-19 15:48:16 -04:00
parent dd4785b048
commit 2bf8871892
28 changed files with 39174 additions and 223 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
// These UpdateData calls were generated from an existing live database.
// On a fresh install the PricingTiers table and its seed rows may not exist yet
// (seeding is manual via Platform Management → Seed Data), so guard each update.
migrationBuilder.Sql(@"
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
BEGIN
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
END
");
}
/// <inheritdoc />
@@ -0,0 +1,232 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAppointmentReminderSentAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
table: "Jobs");
migrationBuilder.DropForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.DropForeignKey(
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
table: "MaintenanceRecords");
migrationBuilder.DropTable(
name: "ShopWorkerRoleCosts");
migrationBuilder.DropTable(
name: "ShopWorkers");
migrationBuilder.DropIndex(
name: "IX_MaintenanceRecords_ShopWorkerId",
table: "MaintenanceRecords");
migrationBuilder.DropIndex(
name: "IX_JobTimeEntries_ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.DropIndex(
name: "IX_Jobs_ShopWorkerId",
table: "Jobs");
migrationBuilder.DropColumn(
name: "ShopWorkerId",
table: "MaintenanceRecords");
migrationBuilder.DropColumn(
name: "ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.DropColumn(
name: "ShopWorkerId",
table: "Jobs");
migrationBuilder.AddColumn<DateTime>(
name: "ReminderSentAt",
table: "Appointments",
type: "datetime2",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2970));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2976));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2977));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReminderSentAt",
table: "Appointments");
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "MaintenanceRecords",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "JobTimeEntries",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "Jobs",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "ShopWorkerRoleCosts",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
HourlyRate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkerRoleCosts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ShopWorkers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkers", x => x.Id);
table.ForeignKey(
name: "FK_ShopWorkers_Companies_CompanyId",
column: x => x.CompanyId,
principalTable: "Companies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
migrationBuilder.CreateIndex(
name: "IX_MaintenanceRecords_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_JobTimeEntries_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_ShopWorkerRoleCosts_CompanyId_Role",
table: "ShopWorkerRoleCosts",
columns: new[] { "CompanyId", "Role" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ShopWorkers_CompanyId",
table: "ShopWorkers",
column: "CompanyId");
migrationBuilder.AddForeignKey(
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
}
}
}
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNoExtraLayerChargeToCoats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "JobItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats");
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "JobItemCoats");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3960));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3966));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3967));
}
}
}
@@ -716,6 +716,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("ReminderMinutesBefore")
.HasColumnType("int");
b.Property<DateTime?>("ReminderSentAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ScheduledEndTime")
.HasColumnType("datetime2");
@@ -4252,9 +4255,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ShopSuppliesPercent")
.HasColumnType("decimal(18,2)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("SpecialInstructions")
.HasColumnType("nvarchar(max)");
@@ -4296,8 +4296,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("CompanyId", "CustomerId")
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
@@ -4620,6 +4618,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("JobItemId")
.HasColumnType("int");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
@@ -5439,9 +5440,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("Stage")
.HasColumnType("nvarchar(max)");
@@ -5464,8 +5462,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("JobId");
b.HasIndex("ShopWorkerId");
b.ToTable("JobTimeEntries");
});
@@ -5789,9 +5785,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("ScheduledDate")
.HasColumnType("datetime2");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
@@ -5822,8 +5815,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("Status");
b.HasIndex("CompanyId", "ScheduledDate")
@@ -6720,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6731,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6742,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7417,6 +7408,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
@@ -8019,111 +8013,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("ReworkRecords");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("ShopWorkers");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorkerRoleCost", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("HourlyRate")
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId", "Role")
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
b.ToTable("ShopWorkerRoleCosts");
});
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
{
b.Property<long>("Id")
@@ -9541,10 +9430,6 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedJobs")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser");
b.Navigation("Customer");
@@ -9847,13 +9732,7 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
.WithMany("TimeEntries")
.HasForeignKey("ShopWorkerId");
b.Navigation("Job");
b.Navigation("Worker");
});
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
@@ -9924,10 +9803,6 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany()
.HasForeignKey("RecurrenceParentId");
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedMaintenanceTasks")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser");
b.Navigation("Equipment");
@@ -10411,15 +10286,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("ReworkJob");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
.WithMany("ShopWorkers")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -10582,8 +10448,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes");
b.Navigation("ShopWorkers");
b.Navigation("Users");
b.Navigation("Vendors");
@@ -10749,15 +10613,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Navigation("AssignedJobs");
b.Navigation("AssignedMaintenanceTasks");
b.Navigation("TimeEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{
b.Navigation("BillPayments");
@@ -1152,6 +1152,156 @@ public class NotificationService : INotificationService
};
}
/// <summary>
/// Sends appointment reminder emails when an appointment's reminder window opens.
/// Two emails are dispatched independently:
/// <list type="bullet">
/// <item>Customer email — sent when a customer is linked, has an email address, and has
/// email notifications enabled (<see cref="Customer.NotifyByEmail"/>).</item>
/// <item>Staff email — sent to <see cref="BaseEntity.CreatedBy"/> (the user who created
/// the appointment). This fires regardless of whether a customer is linked.</item>
/// </list>
/// Called exclusively by
/// <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// after it stamps <c>ReminderSentAt</c> — the caller owns deduplication.
/// </summary>
public async Task NotifyAppointmentReminderAsync(Appointment appointment)
{
try
{
var (companyName, company) = await GetCompanyAsync(appointment.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(appointment.CompanyId);
var baseUrl = await GetBaseUrlAsync();
var locationLine = !string.IsNullOrWhiteSpace(appointment.Location)
? $"<br/><strong>Location:</strong> {WebUtility.HtmlEncode(appointment.Location)}"
: string.Empty;
var appointmentDate = appointment.ScheduledStartTime.ToString("dddd, MMMM d, yyyy");
var appointmentTime = appointment.IsAllDay
? "All Day"
: appointment.ScheduledStartTime.ToString("h:mm tt");
var defaultSubject = $"Appointment Reminder — {appointment.Title} on {appointment.ScheduledStartTime:MMMM d, yyyy}";
// ── Customer email ────────────────────────────────────────────────
if (appointment.CustomerId != null)
{
var customer = appointment.Customer
?? await _context.Customers.FindAsync(appointment.CustomerId.Value);
if (customer != null)
{
var customerName = GetCustomerDisplayName(customer);
var reminderEmails = ParseEmailList(customer.Email);
if (!customer.NotifyByEmail || reminderEmails.Count == 0)
{
if (reminderEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.AppointmentReminder,
customerName, string.Join(", ", reminderEmails), appointment.CompanyId,
customerId: customer.Id));
}
}
else
{
var customerValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["locationLine"] = locationLine
};
var (custSubject, custHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
var custPlainText = StripHtml(custFullHtml);
var (custOk, custErr, custLog) = await SendToEmailListAsync(
customer.Email, customerName, custSubject, custPlainText, custFullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminder,
Status = custOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = custLog,
Subject = custSubject,
Message = custPlainText,
ErrorMessage = custErr,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
CompanyId = appointment.CompanyId
});
}
}
}
// ── Staff email ───────────────────────────────────────────────────
// Send to whoever created the appointment so they get an out-of-app reminder.
if (!string.IsNullOrWhiteSpace(appointment.CreatedBy))
{
// Look up the user's display name from Identity if available.
var staffUser = await _context.Users
.FirstOrDefaultAsync(u => u.Email == appointment.CreatedBy);
var staffName = !string.IsNullOrWhiteSpace(staffUser?.FullName)
? staffUser.FullName
: appointment.CreatedBy;
// Include a customer line only when a customer is linked.
var customerLine = appointment.Customer != null
? $"<br/><strong>Customer:</strong> {WebUtility.HtmlEncode(GetCustomerDisplayName(appointment.Customer))}"
: string.Empty;
var staffValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["staffName"] = staffName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["customerLine"] = customerLine,
["locationLine"] = locationLine
};
var staffDefaultSubject = $"[Reminder] {appointment.Title} — {appointment.ScheduledStartTime:MMMM d, yyyy 'at' h:mm tt}";
var (staffSubject, staffHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminderStaff, staffValues, staffDefaultSubject);
var staffPlainText = StripHtml(staffHtml);
var (staffOk, staffErr, staffLog) = await SendToEmailListAsync(
appointment.CreatedBy, staffName, staffSubject, staffPlainText, staffHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminderStaff,
Status = staffOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = staffName,
Recipient = staffLog,
Subject = staffSubject,
Message = staffPlainText,
ErrorMessage = staffErr,
SentAt = DateTime.UtcNow,
CompanyId = appointment.CompanyId
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyAppointmentReminderAsync failed for appointment {AppointmentId}", appointment.Id);
}
}
// -----------------------------------------------------------------------
// Fallback default templates (used when company has no DB template)
// -----------------------------------------------------------------------
@@ -1217,6 +1367,14 @@ public class NotificationService : INotificationService
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
),
[(NotificationType.AppointmentReminder, NotificationChannel.Email)] = (
"Appointment Reminder — {{appointmentTitle}} on {{appointmentDate}}",
"<p>Dear {{customerName}},</p><p>This is a reminder that you have an upcoming appointment with <strong>{{companyName}}</strong>.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{locationLine}}</p><p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.AppointmentReminderStaff, NotificationChannel.Email)] = (
"[Reminder] {{appointmentTitle}} — {{appointmentDate}}",
"<p>Hi {{staffName}},</p><p>This is a reminder that you have an upcoming appointment.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>&#8212; {{companyName}}</p>"
),
};
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)