Add staff-presented SMS consent flow on customer record

- New GET/POST Customers/SmsConsent/{id}: full-screen kiosk-layout page staff
  opens and hands to the customer to read TCPA consent language and tap I Agree
- On agreement: sets Customer.NotifyBySms, SmsConsentedAt (UTC), SmsConsentMethod
  = "InPerson", clears SmsOptedOutAt
- Redirects back if customer has already consented (no double-consent)
- Customer Details: "Get SMS Consent" badge link shown when NotifyBySms is false;
  SMS on badge shows consent date on hover when consented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 22:50:49 -04:00
parent 66231822af
commit b69ff6db3a
3 changed files with 120 additions and 1 deletions
@@ -877,6 +877,74 @@ public class CustomersController : Controller
}
}
/// <summary>
/// Displays a full-screen SMS consent form for the customer to read and agree to.
/// Staff opens this page on a tablet and hands it to the customer; no staff account
/// interaction is required — the page is scoped to the customer by ID only.
/// Redirects back to Details if the customer has already consented.
/// </summary>
// GET: Customers/SmsConsent/5
public async Task<IActionResult> SmsConsent(int id)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return NotFound();
if (customer.NotifyBySms)
{
this.ToastInfo("This customer has already given SMS consent.");
return RedirectToAction(nameof(Details), new { id });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
ViewBag.CompanyName = company?.CompanyName;
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath)
? Url.Action("Logo", "Kiosk")
: null;
}
ViewBag.ShowInactivityTimer = false;
ViewBag.CustomerName = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
if (string.IsNullOrWhiteSpace(ViewBag.CustomerName as string) && !string.IsNullOrEmpty(customer.CompanyName))
ViewBag.CustomerName = customer.CompanyName;
return View(customer.Id);
}
/// <summary>
/// Records the customer's SMS consent: sets NotifyBySms, SmsConsentedAt (UTC now),
/// and SmsConsentMethod = "InPerson". Called when the customer taps "I Agree" on the
/// consent form presented by staff.
/// </summary>
// POST: Customers/SmsConsent/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> SmsConsent(int id, bool agreed)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return NotFound();
if (!agreed)
{
this.ToastError("Customer did not agree to SMS consent.");
return RedirectToAction(nameof(Details), new { id });
}
customer.NotifyBySms = true;
customer.SmsConsentedAt = DateTime.UtcNow;
customer.SmsConsentMethod = "InPerson";
customer.SmsOptedOutAt = null;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("SMS consent recorded for customer {CustomerId} via staff-presented form", id);
this.ToastSuccess($"SMS consent recorded for {customer.ContactFirstName} {customer.ContactLastName}.");
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Issues a standalone credit memo and increments the customer's CreditBalance.
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo