Add SMS gating, TCPA terms agreement, and compose-before-send modal

- Three-tier SMS gate: platform kill-switch → admin force-disable → plan AllowSms → company opt-in
- CompanySmsAgreement entity records admin acceptance of TCPA terms with IP, user agent, and terms version
- SMS terms of service modal on Company Settings with versioned re-agreement (AppConstants.SmsTermsVersion)
- Dev redirect: non-production SMS routed to Twilio:DevRedirectPhone to protect real customer numbers
- Removed redundant Ready for Pickup SMS (Job Completed covers it)
- Role-based compose modal on job completion: Admin/Manager reviews and edits before send; ShopFloor auto-sends
- Send SMS button on job details for ad-hoc messages (Admin/Manager only)
- SendJobSmsAsync auto-appends STOP opt-out language if missing
- Migrations: AddSmsGating, AddCompanySmsAgreement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 22:29:39 -04:00
parent 2b89fcf483
commit 6569d9c4ea
32 changed files with 19855 additions and 106 deletions
@@ -143,6 +143,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
/// <summary>Tenant company records. Soft-delete only filter applies (no CompanyId filter — SuperAdmin manages all companies).</summary>
public DbSet<Company> Companies { get; set; }
/// <summary>Immutable audit trail of SMS terms-of-service acceptances per company. Tenant-filtered by CompanyId; never soft-deleted.</summary>
public DbSet<CompanySmsAgreement> CompanySmsAgreements { get; set; }
/// <summary>AI quote-item predictions; tenant-filtered. Both <c>QuoteItem</c> and <c>JobItem</c> share a single prediction record via nullable FK (no duplication on quote→job conversion).</summary>
public DbSet<AiItemPrediction> AiItemPredictions { get; set; }
@@ -912,17 +912,6 @@ New accounts walk through an 18-step setup wizard to configure company informati
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.JobReadyForPickup,
Channel = NotificationChannel.Sms,
DisplayName = "Job Ready for Pickup (SMS)",
Subject = null,
Body = "{{companyName}}: Job {{jobNumber}} is ready for pickup! Reply STOP to opt out.",
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.JobCompleted,
Channel = NotificationChannel.Email,
@@ -1204,6 +1193,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
await context.SaveChangesAsync();
}
// Enable AllowSms for Pro and Enterprise plans if not already set
var smsPlansToFix = await context.SubscriptionPlanConfigs.IgnoreQueryFilters()
.Where(c => (c.Plan == 1 || c.Plan == 2) && !c.AllowSms)
.ToListAsync();
if (smsPlansToFix.Count > 0)
{
foreach (var row in smsPlansToFix)
row.AllowSms = true;
await context.SaveChangesAsync();
}
// Only seed if table is empty
if (await context.SubscriptionPlanConfigs.IgnoreQueryFilters().AnyAsync())
return;
@@ -1256,6 +1256,7 @@ New accounts walk through an 18-step setup wizard to configure company informati
MaxCatalogItems = 500,
MonthlyPrice = 79m,
AnnualPrice = 790m,
AllowSms = true,
IsActive = true,
SortOrder = 3,
CompanyId = 0,
@@ -1273,6 +1274,7 @@ New accounts walk through an 18-step setup wizard to configure company informati
MaxCatalogItems = -1,
MonthlyPrice = 199m,
AnnualPrice = 1990m,
AllowSms = true,
IsActive = true,
SortOrder = 4,
CompanyId = 0,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddSmsGating : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowSms",
table: "SubscriptionPlanConfigs",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SmsDisabledByAdmin",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SmsEnabled",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowSms",
table: "SubscriptionPlanConfigs");
migrationBuilder.DropColumn(
name: "SmsDisabledByAdmin",
table: "Companies");
migrationBuilder.DropColumn(
name: "SmsEnabled",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCompanySmsAgreement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CompanySmsAgreements",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
AgreedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
AgreedByUserName = table.Column<string>(type: "nvarchar(max)", nullable: false),
AgreedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IpAddress = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserAgent = table.Column<string>(type: "nvarchar(max)", nullable: true),
TermsVersion = table.Column<string>(type: "nvarchar(max)", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CompanySmsAgreements", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CompanySmsAgreements");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
}
}
}
@@ -1642,6 +1642,12 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("SmsDisabledByAdmin")
.HasColumnType("bit");
b.Property<bool>("SmsEnabled")
.HasColumnType("bit");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
@@ -2116,6 +2122,64 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CompanyPreferences");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CompanySmsAgreement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AgreedAt")
.HasColumnType("datetime2");
b.Property<string>("AgreedByUserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("AgreedByUserName")
.IsRequired()
.HasColumnType("nvarchar(max)");
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>("IpAddress")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("TermsVersion")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserAgent")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("CompanySmsAgreements");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ContactSubmission", b =>
{
b.Property<int>("Id")
@@ -5866,7 +5930,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171),
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5877,7 +5941,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177),
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5888,7 +5952,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179),
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7228,6 +7292,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AllowOnlinePayments")
.HasColumnType("bit");
b.Property<bool>("AllowSms")
.HasColumnType("bit");
b.Property<decimal>("AnnualPrice")
.HasColumnType("decimal(18,2)");
@@ -36,6 +36,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<Company>? _companies;
private IRepository<CompanyOperatingCosts>? _companyOperatingCosts;
private IRepository<CompanyPreferences>? _companyPreferences;
private IRepository<CompanySmsAgreement>? _companySmsAgreements;
// AI Predictions
private IRepository<AiItemPrediction>? _aiItemPredictions;
@@ -170,6 +171,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<CompanyPreferences> CompanyPreferences =>
_companyPreferences ??= new Repository<CompanyPreferences>(_context);
/// <summary>Repository for <see cref="CompanySmsAgreement"/> audit records. Tenant-filtered; never soft-deleted — legal audit trail.</summary>
public IRepository<CompanySmsAgreement> CompanySmsAgreements =>
_companySmsAgreements ??= new Repository<CompanySmsAgreement>(_context);
// AI Predictions
/// <summary>Repository for <see cref="AiItemPrediction"/> records; tenant-filtered. Shared between QuoteItem and JobItem via a single nullable FK — no duplication on quote→job conversion.</summary>
public IRepository<AiItemPrediction> AiItemPredictions =>
@@ -310,48 +310,6 @@ public class NotificationService : INotificationService
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
// SMS only for READY_FOR_PICKUP
var smsFeatureEnabled = string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase);
if (smsFeatureEnabled && newStatusCode == "READY_FOR_PICKUP")
{
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
{
var smsValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
var smsMessage = await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobReadyForPickup, smsValues,
$"{companyName}: Job {job.JobNumber} is ready for pickup! Reply STOP to opt out.");
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobReadyForPickup,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = smsMessage,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(customer.MobilePhone ?? customer.Phone))
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobReadyForPickup,
customerName, customer.MobilePhone ?? customer.Phone!, job.CompanyId,
customerId: customer.Id, jobId: job.Id));
}
}
}
catch (Exception ex)
{
@@ -364,7 +322,7 @@ public class NotificationService : INotificationService
/// because the completion message may include a link to pay the invoice online, and uses a
/// dedicated JobCompleted template with different wording than a mid-workflow status change.
/// </summary>
public async Task NotifyJobCompletedAsync(Job job)
public async Task NotifyJobCompletedAsync(Job job, bool suppressSms = false)
{
try
{
@@ -419,45 +377,48 @@ public class NotificationService : INotificationService
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
// SMS
var smsOn = string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase);
if (smsOn)
// SMS — skip when the caller (Admin/Manager) will handle it via the compose modal
if (!suppressSms)
{
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
if (smsAllowed)
{
var smsValues = new Dictionary<string, string>
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
var smsValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
var smsMessage = await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobCompleted, smsValues,
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
var smsMessage = await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobCompleted, smsValues,
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
await WriteLog(new NotificationLog
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = smsMessage,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(smsPhone))
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = smsMessage,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(smsPhone))
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
}
}
}
}
@@ -467,6 +428,90 @@ public class NotificationService : INotificationService
}
}
/// <summary>
/// Renders the job-completed SMS text without sending it, for admin review before manual send.
/// Returns null when SMS is not gated on for the company or the customer has not opted in to SMS.
/// </summary>
public async Task<string?> RenderJobCompletedSmsAsync(Job job)
{
try
{
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
if (customer == null) return null;
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company)) return null;
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (!customer.NotifyBySms || string.IsNullOrWhiteSpace(smsPhone)) return null;
var customerName = GetCustomerDisplayName(customer);
var smsValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["jobNumber"] = job.JobNumber ?? string.Empty
};
return await GetRenderedSmsAsync(
job.CompanyId, NotificationType.JobCompleted, smsValues,
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
}
catch (Exception ex)
{
_logger.LogError(ex, "RenderJobCompletedSmsAsync failed for job {JobId}", job.Id);
return null;
}
}
/// <summary>
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
/// Appends opt-out instructions if not already present, then sends and logs the result.
/// </summary>
public async Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message)
{
try
{
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
if (customer == null) return (false, "Customer not found.");
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (string.IsNullOrWhiteSpace(smsPhone)) return (false, "Customer has no phone number on file.");
var (_, company) = await GetCompanyAsync(job.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company)) return (false, "SMS is not enabled for this company.");
// Ensure TCPA-required opt-out language is present
const string stopSuffix = "Reply STOP to opt out.";
if (!message.Contains("STOP", StringComparison.OrdinalIgnoreCase))
message = message.TrimEnd() + " " + stopSuffix;
var (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.JobCompleted,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = GetCustomerDisplayName(customer),
Recipient = smsPhone,
Message = message,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
JobId = job.Id,
CompanyId = job.CompanyId
});
return (success, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "SendJobSmsAsync failed for job {JobId}", job.Id);
return (false, "An unexpected error occurred while sending the SMS.");
}
}
/// <summary>
/// Sends the invoice to the customer with the PDF attached. When Stripe Connect is enabled for
/// the company, includes a "Pay Online" button linking to the Stripe-hosted payment page
@@ -801,13 +846,13 @@ public class NotificationService : INotificationService
/// </summary>
public async Task NotifySmsConsentGrantedAsync(Customer customer)
{
if (!string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase)) return;
try
{
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (string.IsNullOrWhiteSpace(smsPhone)) return;
var (companyName, _) = await GetCompanyAsync(customer.CompanyId);
var (companyName, company) = await GetCompanyAsync(customer.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company)) return;
var values = new Dictionary<string, string>
{
@@ -971,10 +1016,6 @@ public class NotificationService : INotificationService
"Job {{jobNumber}} Ready for Pickup — {{companyName}}",
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is ready for pickup!</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.JobReadyForPickup, NotificationChannel.Sms)] = (
null,
"{{companyName}}: Job {{jobNumber}} is ready for pickup! Reply STOP to opt out."
),
[(NotificationType.JobCompleted, NotificationChannel.Email)] = (
"Job {{jobNumber}} Complete — {{companyName}}",
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is complete. Final price: <strong>{{finalPrice}}</strong>. It is now ready for pickup.</p><p>Thank you for choosing {{companyName}}.</p>"
@@ -1121,6 +1162,28 @@ public class NotificationService : INotificationService
}
}
/// <summary>
/// Returns true only when all four gating tiers allow SMS for this company:
/// platform kill-switch on → not SuperAdmin-disabled → plan AllowSms → company opted in.
/// </summary>
private async Task<bool> IsSmsAllowedForCompanyAsync(Company? company)
{
var platformOn = string.Equals(
await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled),
"true", StringComparison.OrdinalIgnoreCase);
if (!platformOn) return false;
if (company == null) return false;
if (company.SmsDisabledByAdmin) return false;
var planConfig = await _context.SubscriptionPlanConfigs
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
if (planConfig?.AllowSms != true) return false;
return company.SmsEnabled;
}
/// <summary>
/// Loads the company entity and its display name. Returns a safe fallback name
/// so templates never render blank company names even if the row is missing.
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using Twilio;
@@ -10,16 +12,24 @@ namespace PowderCoating.Infrastructure.Services;
public class SmsService : ISmsService
{
private readonly IConfiguration _configuration;
private readonly IWebHostEnvironment _env;
private readonly ILogger<SmsService> _logger;
/// <summary>
/// Initializes a new instance of <see cref="SmsService"/>. Twilio credentials are read
/// per call (not cached here) because they may be rotated without restarting the application,
/// and Twilio's client initialization (<c>TwilioClient.Init</c>) is idempotent and cheap.
/// <para>
/// In non-production environments, all outbound messages are redirected to
/// <c>Twilio:DevRedirectPhone</c> (if configured) so real customer numbers are never
/// texted during development or staging. The original destination is prepended to the
/// message body for traceability.
/// </para>
/// </summary>
public SmsService(IConfiguration configuration, ILogger<SmsService> logger)
public SmsService(IConfiguration configuration, IWebHostEnvironment env, ILogger<SmsService> logger)
{
_configuration = configuration;
_env = env;
_logger = logger;
}
@@ -58,6 +68,21 @@ public class SmsService : ISmsService
return (false, "Invalid phone number");
}
// Non-production guard: redirect all messages to the dev phone so real customers
// are never texted outside of production. Double-gated on environment name AND
// the config value so a misconfigured prod deploy can't accidentally redirect.
var devRedirect = _configuration["Twilio:DevRedirectPhone"];
if (!_env.IsProduction() && !string.IsNullOrWhiteSpace(devRedirect))
{
var devPhone = NormalizePhone(devRedirect);
if (!string.IsNullOrEmpty(devPhone))
{
_logger.LogWarning("Non-production environment: redirecting SMS from {Original} to dev number {Dev}", normalizedPhone, devPhone);
message = $"[DEV → {normalizedPhone}] {message}";
normalizedPhone = devPhone;
}
}
try
{
TwilioClient.Init(accountSid, authToken);