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:
@@ -381,6 +381,7 @@ public class JobItemCoatDto
|
||||
public decimal? PowderCostPerLb { get; set; }
|
||||
public decimal? PowderToOrder { get; set; }
|
||||
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -801,6 +801,7 @@ public class QuoteItemCoatDto
|
||||
public decimal CoatMaterialCost { get; set; }
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -91,4 +91,11 @@ public interface INotificationService
|
||||
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
|
||||
/// </summary>
|
||||
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
|
||||
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
|
||||
/// when the reminder window opens. In-app bell notification is handled by the caller.
|
||||
/// </summary>
|
||||
Task NotifyAppointmentReminderAsync(Appointment appointment);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -192,7 +193,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -289,7 +291,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -375,6 +378,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostPerLb = seed.PowderCostPerLb,
|
||||
PowderToOrder = seed.PowderToOrder,
|
||||
Notes = seed.Notes,
|
||||
NoExtraLayerCharge = seed.NoExtraLayerCharge,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
@@ -493,6 +497,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public decimal? PowderCostPerLb { get; init; }
|
||||
public decimal? PowderToOrder { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool NoExtraLayerCharge { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
|
||||
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
|
||||
/// </summary>
|
||||
public int ReminderMinutesBefore { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
|
||||
/// The background service uses this as a deduplication guard to prevent double-sending.
|
||||
/// </summary>
|
||||
public DateTime? ReminderSentAt { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual Customer? Customer { get; set; }
|
||||
public virtual Job? Job { get; set; }
|
||||
|
||||
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
|
||||
public string? PowderReceivedByUserId { get; set; }
|
||||
public decimal? PowderReceivedLbs { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -20,5 +20,7 @@ public enum NotificationType
|
||||
SmsInboundStop = 12,
|
||||
SmsInboundHelp = 13,
|
||||
AdminEmail = 14,
|
||||
SmsInboundStart = 15
|
||||
SmsInboundStart = 15,
|
||||
AppointmentReminder = 17,
|
||||
AppointmentReminderStaff = 18
|
||||
}
|
||||
|
||||
+10633
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 />
|
||||
|
||||
Generated
+10633
File diff suppressed because it is too large
Load Diff
+232
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10639
File diff suppressed because it is too large
Load Diff
+83
@@ -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 & 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 & Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>— {{companyName}}</p>"
|
||||
),
|
||||
};
|
||||
|
||||
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,7 @@ builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
|
||||
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
||||
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
||||
builder.Services.AddHostedService<RecurringTransactionService>();
|
||||
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeService, StripeService>();
|
||||
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
||||
|
||||
@@ -185,12 +185,13 @@
|
||||
<div class="mobile-card-view">
|
||||
@if (!Model.Items.Any())
|
||||
{
|
||||
var isMobileCustomerListFiltered = !string.IsNullOrEmpty(ViewBag.SearchTerm as string);
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No customers found</h5>
|
||||
<p class="text-muted mb-4">@(isCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
|
||||
<p class="text-muted mb-4">@(isMobileCustomerListFiltered ? "No customers match your search." : "Get started by adding your first customer.")</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>@(isCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
|
||||
<i class="bi bi-plus-circle me-2"></i>@(isMobileCustomerListFiltered ? "Add Customer" : "Add Your First Customer")
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -869,42 +869,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showSendModal)
|
||||
{
|
||||
<!-- Send Channel Choice Modal (shown when customer has both email + SMS) -->
|
||||
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title" id="sendChannelModalLabel">
|
||||
<i class="bi bi-send text-primary me-2"></i>Send Invoice
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-envelope me-2"></i>Email only
|
||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-phone me-2"></i>SMS only
|
||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
}
|
||||
|
||||
@if (showSendModal)
|
||||
{
|
||||
<!-- Send Channel Choice Modal (shown when customer has both email + SMS available).
|
||||
Lives outside the isDraft block so it also renders for Sent/Overdue invoices
|
||||
where the customer's email was added after an SMS-only initial send. -->
|
||||
<div class="modal fade" id="sendChannelModal" tabindex="-1" aria-labelledby="sendChannelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title" id="sendChannelModalLabel">
|
||||
<i class="bi bi-send text-primary me-2"></i>Send Invoice
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="mb-3">How would you like to send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(true, false)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-envelope me-2"></i>Email only
|
||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary text-start" onclick="submitSendInvoice(false, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-phone me-2"></i>SMS only
|
||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary text-start" onclick="submitSendInvoice(true, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (canPay)
|
||||
|
||||
@@ -370,7 +370,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
}),
|
||||
prepServices = item.PrepServices.Select(ps => new {
|
||||
prepServiceId = ps.PrepServiceId,
|
||||
|
||||
@@ -653,7 +653,10 @@
|
||||
<span class="mobile-card-value">
|
||||
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
||||
{
|
||||
<small class="d-block">@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }</small>
|
||||
<small class="d-block">
|
||||
@coat.CoatName@if (!string.IsNullOrEmpty(coat.ColorName)) { <text> – @coat.ColorName</text> }
|
||||
@if (!string.IsNullOrEmpty(coat.Notes)) { <text><br /><span class="fst-italic text-muted ms-2">@coat.Notes</span></text> }
|
||||
</small>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -357,7 +357,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
}),
|
||||
prepServices = item.PrepServices.Select(ps => new {
|
||||
prepServiceId = ps.PrepServiceId,
|
||||
|
||||
@@ -153,7 +153,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
}),
|
||||
prepServices = item.PrepServices.Select(ps => new {
|
||||
prepServiceId = ps.PrepServiceId,
|
||||
|
||||
@@ -479,7 +479,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
})
|
||||
})))
|
||||
</script>
|
||||
|
||||
@@ -525,7 +525,8 @@
|
||||
transferEfficiency = c.TransferEfficiency,
|
||||
powderCostPerLb = c.PowderCostPerLb,
|
||||
powderToOrder = c.PowderToOrder,
|
||||
notes = c.Notes
|
||||
notes = c.Notes,
|
||||
noExtraLayerCharge = c.NoExtraLayerCharge
|
||||
})
|
||||
})))
|
||||
</script>
|
||||
|
||||
@@ -82,6 +82,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const ownerForm = hfc?.closest('form');
|
||||
if (ownerForm) {
|
||||
ownerForm.addEventListener('submit', writeHiddenFields, { capture: true });
|
||||
|
||||
// Save scroll position before the form causes a full-page reload so we can
|
||||
// restore it after the server redirects back to this page. Key is path-specific
|
||||
// so navigating away and back doesn't restore a stale position.
|
||||
const scrollKey = 'wizardScrollY:' + location.pathname;
|
||||
ownerForm.addEventListener('submit', () => {
|
||||
sessionStorage.setItem(scrollKey, String(Math.round(window.scrollY)));
|
||||
}, { capture: true });
|
||||
|
||||
// Restore on load — fire after layout is painted so scrollTo lands correctly.
|
||||
const savedY = sessionStorage.getItem(scrollKey);
|
||||
if (savedY !== null) {
|
||||
sessionStorage.removeItem(scrollKey);
|
||||
requestAnimationFrame(() => window.scrollTo({ top: parseInt(savedY, 10), behavior: 'instant' }));
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open powder combobox or catalog lookup dropdown when clicking outside it
|
||||
@@ -2731,8 +2746,9 @@ function buildCardHtml(item, i) {
|
||||
const orderBadge = (!c.inventoryItemId && c.powderToOrder)
|
||||
? ` <span class="badge bg-warning text-dark" style="font-size:.65em;vertical-align:middle;" title="Custom powder — must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER ${parseFloat(c.powderToOrder).toFixed(2)} lbs</span>`
|
||||
: '';
|
||||
const coatNotes = c.notes ? `<span class="fst-italic ms-1 opacity-75">— ${escHtml(c.notes)}</span>` : '';
|
||||
return `<div style="font-size:.8rem;" class="text-muted mt-1">
|
||||
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` — ${color}${code}` : ''}${orderBadge}
|
||||
<i class="bi bi-layers me-1"></i><span class="fw-semibold">${escHtml(c.coatName || 'Coat')}</span>${color ? ` — ${color}${code}` : ''}${orderBadge}${coatNotes}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
@@ -3414,7 +3430,7 @@ function loadItemsFromTemplate(templateItems) {
|
||||
coverageSqFtPerLb: c.coverageSqFtPerLb || 30,
|
||||
transferEfficiency: c.transferEfficiency || 65,
|
||||
powderCostPerLb: c.powderCostPerLb || null,
|
||||
noExtraLayerCharge: false
|
||||
noExtraLayerCharge: !!c.noExtraLayerCharge
|
||||
})),
|
||||
prepServices: (ti.prepServices || []).map(p => ({
|
||||
prepServiceId: p.prepServiceId,
|
||||
|
||||
Reference in New Issue
Block a user