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
@@ -149,6 +149,16 @@
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">SMS Override</h5>
<p class="text-muted small mb-3">Use this to immediately cut off SMS for a company — for example if they are sending abusive messages or have a billing dispute. This overrides the plan entitlement and the company's own opt-in setting.</p>
<div class="mb-4">
<div class="form-check form-switch">
<input asp-for="SmsDisabledByAdmin" class="form-check-input" type="checkbox" role="switch" id="SmsDisabledByAdmin" />
<label asp-for="SmsDisabledByAdmin" class="form-check-label fw-medium text-danger">Force-disable SMS for this company</label>
</div>
<div class="form-text">When checked, no outbound SMS will be sent for this company regardless of their plan or own settings.</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
@@ -1079,6 +1079,45 @@
</a>
</h5>
<p class="text-muted">Control which events trigger email notifications and alert thresholds.</p>
@if (ViewBag.SmsEnabled == true)
{
<div class="card border mb-4">
<div class="card-body">
<h6 class="card-title fw-semibold"><i class="bi bi-phone me-1"></i> SMS Notifications</h6>
@if (Model.SmsDisabledByAdmin)
{
<div class="alert alert-danger alert-permanent mb-3 py-2">
<i class="bi bi-slash-circle me-1"></i>
<strong>SMS has been disabled by an administrator.</strong> Contact support to re-enable.
</div>
}
else if (!Model.AllowSms)
{
<div class="alert alert-info alert-permanent mb-3 py-2">
<i class="bi bi-info-circle me-1"></i>
SMS notifications are not included in your current plan. Upgrade to Pro or Enterprise to enable customer SMS alerts.
</div>
}
else
{
<p class="text-muted small mb-3">When enabled, customers who have given SMS consent will receive text alerts for job status changes (e.g. ready for pickup).</p>
<div class="form-check form-switch" id="smsToggleWrap">
<input class="form-check-input" type="checkbox" id="smsEnabledToggle" @(Model.SmsEnabled ? "checked" : "")
data-has-agreement="@(Model.HasCurrentSmsAgreement ? "true" : "false")"
data-terms-version="@Model.SmsTermsVersion">
<label class="form-check-label fw-medium" for="smsEnabledToggle">Enable SMS Notifications</label>
</div>
@if (!Model.HasCurrentSmsAgreement && !Model.SmsEnabled)
{
<div class="form-text text-warning mt-1">
<i class="bi bi-info-circle me-1"></i>You'll need to accept the SMS terms of service the first time you enable this.
</div>
}
}
</div>
</div>
}
<form id="notificationsForm">
<h6 class="border-bottom pb-2 mb-3">Email Sender</h6>
<div class="row mb-3">
@@ -1995,6 +2034,54 @@
</div>
</div>
<!-- SMS Terms of Service Agreement Modal -->
<div class="modal fade" id="smsTermsModal" tabindex="-1" aria-labelledby="smsTermsModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="smsTermsModalLabel">
<i class="bi bi-phone me-2"></i>SMS Notifications — Terms of Service
</h5>
</div>
<div class="modal-body">
<div class="alert alert-warning alert-permanent mb-3">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Read carefully before enabling.</strong> Sending unsolicited text messages carries significant legal risk. By enabling SMS you are personally accepting responsibility for your company's compliance.
</div>
<h6 class="fw-bold">1. Prior Express Written Consent Required</h6>
<p class="text-muted small">You <strong>must obtain clear, documented consent</strong> from each customer before sending them any SMS message. This means each customer must have explicitly agreed — in writing or through a recorded digital interaction — that they wish to receive text messages from your business. Enabling this feature is not consent on their behalf. You must collect and record their authorization individually, before enabling SMS for their account in this system.</p>
<h6 class="fw-bold">2. Federal Law Governs SMS — Fines Are Real</h6>
<p class="text-muted small">The <strong>Telephone Consumer Protection Act (TCPA)</strong>, enforced by the Federal Communications Commission (FCC), imposes fines of <strong>$500 to $1,500 per individual message</strong> sent without proper authorization. These fines apply per text, not per customer. A single campaign to 100 unconsented recipients could result in exposure of $50,000 to $150,000. The FCC and private plaintiffs both actively pursue TCPA violations.</p>
<h6 class="fw-bold">3. Opt-Out Requests Must Be Honored Immediately</h6>
<p class="text-muted small">Any customer who replies <strong>STOP, UNSUBSCRIBE, CANCEL, END, or QUIT</strong> must be removed from all future SMS immediately. This system will process inbound opt-out replies automatically, but you must also honor any opt-out communicated by phone, email, or in person. Continuing to text a customer after an opt-out is a TCPA violation.</p>
<h6 class="fw-bold">4. Message Rates &amp; Content Restrictions</h6>
<p class="text-muted small">Every message sent must include your business name and an opt-out reminder (e.g., "Reply STOP to opt out"). Messages must be directly relevant to the service the customer consented to receive and must not contain solicitations, promotions, or third-party offers unless the customer has separately consented to those.</p>
<h6 class="fw-bold">5. Your Responsibility — Not Ours</h6>
<p class="text-muted small">Powder Coating Logix provides this feature as a communication tool only. <strong>We are not responsible for how you use it.</strong> You agree that your company is solely responsible for obtaining proper consent, maintaining records of that consent, honoring opt-outs, and ensuring all outbound messages comply with the TCPA, FCC regulations, and any applicable state laws. You agree to indemnify and hold Powder Coating Logix harmless from any claims, fines, or damages arising from your company's use of SMS.</p>
<hr />
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="smsTermsAgreementCheck">
<label class="form-check-label fw-semibold" for="smsTermsAgreementCheck">
I have read and understood the above terms. I confirm that my company will obtain proper customer consent before enabling SMS for any individual customer, and I accept full responsibility for our compliance with all applicable laws.
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="smsTermsDeclineBtn">Cancel — Keep SMS Disabled</button>
<button type="button" class="btn btn-primary" id="smsTermsAcceptBtn" disabled>
<i class="bi bi-check-circle me-1"></i>I Agree &amp; Enable SMS
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
$(document).ready(function () {
@@ -2415,6 +2502,83 @@
};
}, 'Save Retention Policy');
// SMS toggle — shows terms modal on first enable (or after terms version change)
(function () {
const toggle = document.getElementById('smsEnabledToggle');
if (!toggle) return;
const smsTermsModal = new bootstrap.Modal(document.getElementById('smsTermsModal'));
const acceptBtn = document.getElementById('smsTermsAcceptBtn');
const declineBtn = document.getElementById('smsTermsDeclineBtn');
const agreeCheck = document.getElementById('smsTermsAgreementCheck');
// Unlock the accept button only when checkbox is ticked
agreeCheck.addEventListener('change', function () {
acceptBtn.disabled = !this.checked;
});
function postSmsPreference(enabled, agreedToTerms, termsVersion) {
$.ajax({
url: '@Url.Action("UpdateSmsPreferences", "CompanySettings")',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ SmsEnabled: enabled, AgreedToTerms: agreedToTerms, TermsVersion: termsVersion }),
headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').first().val() },
success: function (res) {
if (res.success) {
toggle.dataset.hasAgreement = 'true';
showToast('success', res.message);
} else {
// Revert toggle on failure
toggle.checked = !enabled;
showToast('error', res.message || 'Failed to save SMS preference.');
}
},
error: function () {
toggle.checked = !enabled;
showToast('error', 'Failed to save SMS preference.');
}
});
}
toggle.addEventListener('change', function () {
const enabled = this.checked;
const hasAgreement = this.dataset.hasAgreement === 'true';
const termsVersion = this.dataset.termsVersion;
if (!enabled) {
// Disabling: no agreement needed
postSmsPreference(false, false, null);
return;
}
if (hasAgreement) {
// Re-enabling: already agreed to this version
postSmsPreference(true, false, null);
return;
}
// First enable (or terms version changed): show the modal
agreeCheck.checked = false;
acceptBtn.disabled = true;
smsTermsModal.show();
// Revert toggle until they explicitly agree
toggle.checked = false;
acceptBtn.onclick = function () {
smsTermsModal.hide();
toggle.checked = true;
postSmsPreference(true, true, termsVersion);
};
declineBtn.onclick = function () {
smsTermsModal.hide();
toggle.checked = false;
};
});
})();
// Toast helper function (exposed globally for lookup management)
window.showToast = function(type, message) {
if (type === 'success') {
@@ -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 -->
@@ -132,6 +132,19 @@
</div>
</div>
<h5 class="mb-3 pb-2 border-bottom mt-4">SMS Notifications</h5>
<div class="mb-4">
<div class="form-check form-switch">
<input asp-for="AllowSms" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="AllowSms" class="form-check-label fw-medium">Allow SMS Notifications</label>
</div>
<div class="form-text">
When enabled, companies on this plan can send SMS job-status notifications to customers
(subject to the platform SMS kill-switch and the company's own opt-in setting).
</div>
</div>
<h5 class="mb-3 pb-2 border-bottom mt-4">AI Features</h5>
<div class="mb-3">
@@ -169,6 +169,19 @@
}
</td>
</tr>
<tr>
<td class="text-muted">SMS Notifications</td>
<td>
@if (plan.AllowSms)
{
<span class="badge bg-success">Enabled</span>
}
else
{
<span class="badge bg-secondary">Disabled</span>
}
</td>
</tr>
<tr class="table-light">
<td colspan="2" class="fw-semibold small text-uppercase text-muted py-1">Stripe</td>
</tr>