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
@@ -0,0 +1,128 @@
/**
* SMS compose modal for Job Details — Admin/Manager path.
*
* Entry points:
* - Auto-opens after CompleteJob when TempData contains a pending preview.
* - Opened manually via the "Send SMS" button; fetches a fresh template first.
*
* Requires window.__smsCompose to be set by the inline Razor script before this file loads.
*/
(function () {
'use strict';
const MODAL_ID = 'smsComposeModal';
const TEXTAREA = 'smsMessageText';
const CHAR_COUNT = 'smsCharCount';
const STOP_WARN = 'smsStopWarning';
const SEND_BTN = 'smsSendBtn';
const ERROR_DIV = 'smsSendError';
const MAX_CHARS = 160;
const STOP_TOKEN = 'STOP';
function antiForgeryToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
}
function el(id) { return document.getElementById(id); }
function openModal(prefilledText) {
const modal = bootstrap.Modal.getOrCreateInstance(el(MODAL_ID));
const textarea = el(TEXTAREA);
if (textarea) textarea.value = prefilledText ?? '';
updateCharCount();
el(ERROR_DIV)?.classList.add('d-none');
modal.show();
}
function updateCharCount() {
const textarea = el(TEXTAREA);
if (!textarea) return;
const text = textarea.value;
const len = text.length;
const charEl = el(CHAR_COUNT);
const warnEl = el(STOP_WARN);
if (charEl) charEl.textContent = len;
if (warnEl) {
const hasStop = text.toUpperCase().includes(STOP_TOKEN);
warnEl.classList.toggle('d-none', hasStop);
}
}
function showError(msg) {
const div = el(ERROR_DIV);
if (!div) return;
div.textContent = msg;
div.classList.remove('d-none');
}
async function sendSms() {
const cfg = window.__smsCompose ?? {};
const btn = el(SEND_BTN);
let message = el(TEXTAREA)?.value?.trim() ?? '';
if (!message) { showError('Please enter a message.'); return; }
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending…'; }
el(ERROR_DIV)?.classList.add('d-none');
try {
const resp = await fetch(cfg.sendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': antiForgeryToken()
},
body: JSON.stringify({ jobId: cfg.jobIdForSms, message })
});
const data = await resp.json();
if (data.success) {
bootstrap.Modal.getInstance(el(MODAL_ID))?.hide();
// Show a quick success toast using existing toastSuccess pattern if available
const toastEl = document.getElementById('successToast');
const toastMsg = document.getElementById('successToastMessage');
if (toastEl && toastMsg) {
toastMsg.textContent = 'SMS sent successfully.';
bootstrap.Toast.getOrCreateInstance(toastEl).show();
}
} else {
showError(data.error ?? 'Failed to send SMS. Please try again.');
}
} catch {
showError('A network error occurred. Please check your connection and try again.');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-send me-1"></i>Send SMS'; }
}
}
document.addEventListener('DOMContentLoaded', () => {
const cfg = window.__smsCompose ?? {};
// Character counter
el(TEXTAREA)?.addEventListener('input', updateCharCount);
// Send button
el(SEND_BTN)?.addEventListener('click', sendSms);
// "Send SMS" button on the job details action bar
document.getElementById('btnSendSms')?.addEventListener('click', async () => {
if (!cfg.customerOptedIn) {
alert('This customer has not opted in to SMS notifications.');
return;
}
// Fetch a fresh render of the template
try {
const resp = await fetch(`${cfg.renderUrl}?jobId=${cfg.jobIdForSms}`);
const data = await resp.json();
openModal(data.eligible ? data.message : '');
} catch {
openModal('');
}
});
// Auto-open after CompleteJob if server passed a pending preview
if (cfg.pendingPreview) {
// Small delay so the page has fully rendered
setTimeout(() => openModal(cfg.pendingPreview), 400);
}
});
})();