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,