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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -64,6 +64,7 @@ public class PlatformSubscriptionController : Controller
|
||||
AllowAiPhotoQuotes = c.AllowAiPhotoQuotes,
|
||||
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
|
||||
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
|
||||
AllowSms = c.AllowSms,
|
||||
IsActive = c.IsActive,
|
||||
SortOrder = c.SortOrder
|
||||
}).ToList();
|
||||
@@ -104,6 +105,7 @@ public class PlatformSubscriptionController : Controller
|
||||
AllowAiPhotoQuotes = config.AllowAiPhotoQuotes,
|
||||
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
|
||||
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
|
||||
AllowSms = config.AllowSms,
|
||||
IsActive = config.IsActive
|
||||
};
|
||||
|
||||
@@ -149,6 +151,7 @@ public class PlatformSubscriptionController : Controller
|
||||
config.AllowAiPhotoQuotes = dto.AllowAiPhotoQuotes;
|
||||
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
|
||||
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
|
||||
config.AllowSms = dto.AllowSms;
|
||||
config.IsActive = dto.IsActive;
|
||||
|
||||
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
|
||||
|
||||
Reference in New Issue
Block a user