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
@@ -501,6 +501,14 @@ public class JobsController : Controller
ViewBag.CanResyncFromQuote = preProductionCodes.Contains(job.JobStatus?.StatusCode ?? "");
}
// SMS compose modal: pass pending preview (set by CompleteJob for Admin/Manager) and role flags
if (TempData["PendingSmsPreview"] is string smsPreview)
ViewBag.PendingSmsPreview = smsPreview;
var detailsCompanyRole = User.FindFirst("CompanyRole")?.Value ?? string.Empty;
ViewBag.IsAdminOrManager = detailsCompanyRole is "CompanyAdmin" or "Administrator" or "Manager";
ViewBag.SmsEnabled = HttpContext.Items["AllowSms"] is true;
var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId);
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
&& jobPrefs?.FirstWorkflowCompleted == false)
@@ -2751,14 +2759,20 @@ public class JobsController : Controller
await _unitOfWork.CompleteAsync();
// Admin/Manager gets an SMS compose modal; ShopFloor workers trigger auto-send.
var companyRole = User.FindFirst("CompanyRole")?.Value ?? string.Empty;
var isAdminOrManager = companyRole is "CompanyAdmin" or "Administrator" or "Manager";
// Load job with customer for notification + SMS render
var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId, false, j => j.Customer);
// Notify customer that job is completed (only if user opted in)
if (dto.SendEmailToCustomer)
if (dto.SendEmailToCustomer && jobForNotify != null)
{
try
{
var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId, false, j => j.Customer);
if (jobForNotify != null)
await _notificationService.NotifyJobCompletedAsync(jobForNotify);
// Admin/Manager path: suppress auto-SMS so they can review via compose modal
await _notificationService.NotifyJobCompletedAsync(jobForNotify, suppressSms: isAdminOrManager);
}
catch (Exception ex)
{
@@ -2769,6 +2783,21 @@ public class JobsController : Controller
this.SetNotificationResultToast(completeNotifLog);
}
// For Admin/Manager: render the SMS template and store it for the compose modal
if (isAdminOrManager && jobForNotify != null)
{
try
{
var smsPreview = await _notificationService.RenderJobCompletedSmsAsync(jobForNotify);
if (smsPreview != null)
TempData["PendingSmsPreview"] = smsPreview;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SMS render failed for job {Id}", dto.JobId);
}
}
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "Completed", "Job completed");
if (completedStatus != null)
@@ -2797,6 +2826,46 @@ public class JobsController : Controller
#endregion
#region SMS Compose
/// <summary>
/// Returns the pre-rendered job-completed SMS text so the Admin/Manager compose modal
/// can pre-fill the textarea when the "Send SMS" button is clicked from the job details page
/// (as opposed to the auto-populated TempData path from CompleteJob).
/// </summary>
[HttpGet]
public async Task<IActionResult> RenderJobSms(int jobId)
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId, false, j => j.Customer);
if (job == null) return NotFound();
var text = await _notificationService.RenderJobCompletedSmsAsync(job);
if (text == null)
return Json(new { eligible = false, reason = "SMS is not enabled or customer has not opted in." });
return Json(new { eligible = true, message = text });
}
/// <summary>
/// Sends a manually-composed SMS for a job. Validates and auto-appends STOP language,
/// sends via Twilio, and writes a NotificationLog entry.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SendJobSms([FromBody] SendJobSmsRequest request)
{
if (request.JobId <= 0 || string.IsNullOrWhiteSpace(request.Message))
return Json(new { success = false, error = "Job ID and message are required." });
var job = await _unitOfWork.Jobs.GetByIdAsync(request.JobId, false, j => j.Customer);
if (job == null) return Json(new { success = false, error = "Job not found." });
var (success, error) = await _notificationService.SendJobSmsAsync(job, request.Message.Trim());
return Json(new { success, error });
}
#endregion
#region Edit Job Items (Wizard)
/// <summary>