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
@@ -126,9 +126,15 @@ public class CompanySettingsController : Controller
var dto = _mapper.Map<CompanySettingsDto>(company);
// Populate AllowOnlinePayments from subscription plan config
// Populate plan-gated feature flags
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
dto.AllowSms = planConfig?.AllowSms ?? false;
dto.SmsEnabled = company.SmsEnabled;
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
// Flag whether Stripe Connect is configured (non-placeholder client ID)
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
@@ -619,6 +625,74 @@ public class CompanySettingsController : Controller
}
}
/// <summary>
/// Toggles the company-level SMS opt-in flag. When enabling and no current-version agreement
/// exists, the request must include AgreedToTerms=true and a matching TermsVersion — the
/// acceptance is then recorded as a <see cref="CompanySmsAgreement"/> audit row.
/// Disabling never requires agreement.
/// </summary>
// POST: CompanySettings/UpdateSmsPreferences
[HttpPost]
public async Task<IActionResult> UpdateSmsPreferences([FromBody] UpdateSmsPreferencesDto dto)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "User does not have a company ID." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null)
return Json(new { success = false, message = "Company not found." });
if (dto.SmsEnabled)
{
var hasAgreement = await _unitOfWork.CompanySmsAgreements
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
if (!hasAgreement)
{
// Require explicit acceptance of the current terms version
if (!dto.AgreedToTerms || dto.TermsVersion != AppConstants.SmsTermsVersion)
return Json(new { success = false, requiresAgreement = true, message = "You must accept the SMS terms of service to enable SMS notifications." });
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
var userName = User.Identity?.Name ?? string.Empty;
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var ua = Request.Headers.UserAgent.ToString();
var agreement = new CompanySmsAgreement
{
CompanyId = companyId.Value,
AgreedByUserId = userId,
AgreedByUserName = userName,
AgreedAt = DateTime.UtcNow,
IpAddress = ip,
UserAgent = ua,
TermsVersion = AppConstants.SmsTermsVersion,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.CompanySmsAgreements.AddAsync(agreement);
_logger.LogInformation("Company {CompanyId} accepted SMS terms v{Version} by user {UserId} from {Ip}",
companyId, AppConstants.SmsTermsVersion, userId, ip);
}
}
company.SmsEnabled = dto.SmsEnabled;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} SMS opt-in set to {SmsEnabled}", companyId, dto.SmsEnabled);
return Json(new { success = true, message = dto.SmsEnabled ? "SMS notifications enabled." : "SMS notifications disabled." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating SMS preferences");
return Json(new { success = false, message = "An error occurred while saving SMS preferences." });
}
}
/// <summary>
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and