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
@@ -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.