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
@@ -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);