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
@@ -1346,6 +1346,14 @@
<i class="bi bi-check-circle me-2"></i>Complete Job
</button>
}
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false) && Model.CustomerNotifyBySms && !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone))
{
<button type="button" class="btn btn-outline-info" id="btnSendSms"
data-job-id="@Model.Id"
title="Send a custom SMS to @Model.CustomerName">
<i class="bi bi-chat-dots me-2"></i>Send SMS
</button>
}
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
<i class="bi bi-trash me-2"></i>Delete Job
</a>
@@ -1819,6 +1827,51 @@
</div>
</div>
<!-- SMS Compose Modal (Admin/Manager only) -->
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false))
{
<div class="modal fade" id="smsComposeModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-info bg-opacity-10">
<h5 class="modal-title">
<i class="bi bi-chat-dots me-2 text-info"></i>Send SMS to @Model.CustomerName
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" id="smsModalClose"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrWhiteSpace(Model.CustomerMobilePhone))
{
<p class="text-muted small mb-3">
<i class="bi bi-phone me-1"></i>Sending to: <strong>@Model.CustomerMobilePhone</strong>
</p>
}
<div class="mb-2">
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
<textarea class="form-control" id="smsMessageText" rows="5"
placeholder="Type your message…" maxlength="160"></textarea>
<div class="d-flex justify-content-between mt-1">
<div id="smsStopWarning" class="text-warning small d-none">
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
</div>
<div class="ms-auto text-muted small"><span id="smsCharCount">0</span> / 160</div>
</div>
</div>
<div id="smsSendError" class="alert alert-danger d-none mt-2"></div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
Skip — don't send
</button>
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
<i class="bi bi-send me-1"></i>Send SMS
</button>
</div>
</div>
</div>
</div>
}
<!-- Hidden form used by item-wizard.js to collect item data and submit to UpdateItems -->
<form asp-action="UpdateItems" asp-controller="Jobs" method="post" id="jobItemsForm" style="display:none">
@Html.AntiForgeryToken()
@@ -2967,6 +3020,23 @@
});
})();
</script>
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false))
{
<script src="~/js/jobs-sms-compose.js" asp-append-version="true"></script>
<script>
(() => {
const pendingPreview = @Html.Raw(ViewBag.PendingSmsPreview != null
? System.Text.Json.JsonSerializer.Serialize((string)ViewBag.PendingSmsPreview)
: "null");
const jobIdForSms = @Model.Id;
const renderUrl = '@Url.Action("RenderJobSms", "Jobs")';
const sendUrl = '@Url.Action("SendJobSms", "Jobs")';
const customerOptedIn = @(Model.CustomerNotifyBySms ? "true" : "false");
window.__smsCompose = { pendingPreview, jobIdForSms, renderUrl, sendUrl, customerOptedIn };
})();
</script>
}
}
<!-- Save as Template Modal -->