Fix kiosk SMS consent routing loop and stuck tablet

- Route param renamed customerId→id so /Kiosk/SmsConsent/15307 binds correctly
  (default MVC route uses {id}; mismatched name caused GetByIdAsync(0)→404→loop)
- Cache entry cleared in GET (not just POST) so returning to Welcome after seeing
  the form never redirects again
- Added POST /Kiosk/CancelSmsConsent for staff to free the kiosk if they pushed
  consent accidentally — Customer Details shows a Cancel button after pushing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 23:25:37 -04:00
parent e1256503be
commit 0af31c39b3
3 changed files with 55 additions and 13 deletions
@@ -158,17 +158,34 @@ public class KioskController : Controller
return Json(new { success = true }); return Json(new { success = true });
} }
/// <summary>
/// Cancels a pending kiosk SMS consent request, freeing the kiosk to return to the Welcome
/// screen. Called by staff if they pushed consent accidentally or the customer isn't coming.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public IActionResult CancelSmsConsent()
{
var companyId = HttpContext.User.FindFirst("CompanyId")?.Value;
if (int.TryParse(companyId, out var cid))
_cache.Remove(SmsConsentCacheKey(cid));
return Json(new { success = true });
}
/// <summary> /// <summary>
/// Displays the full-screen SMS consent form on the kiosk tablet (anonymous, kiosk layout). /// Displays the full-screen SMS consent form on the kiosk tablet (anonymous, kiosk layout).
/// Loads the customer by ID with ignoreQueryFilters because the kiosk has no tenant context. /// Loads the customer by ID with ignoreQueryFilters because the kiosk has no tenant context.
/// </summary> /// </summary>
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> SmsConsent(int customerId) public async Task<IActionResult> SmsConsent(int id)
{ {
var cookie = ReadKioskCookie(); var cookie = ReadKioskCookie();
if (cookie == null) return Forbid(); if (cookie == null) return Forbid();
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId, ignoreQueryFilters: true); // Clear the pending entry immediately — the kiosk is now showing the form,
// so Welcome must not redirect again if the customer cancels or navigates back.
_cache.Remove(SmsConsentCacheKey(cookie.Value.companyId));
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
if (customer == null) return NotFound(); if (customer == null) return NotFound();
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true); var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
@@ -179,25 +196,23 @@ public class KioskController : Controller
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: customer.CompanyName ?? "Customer"; : customer.CompanyName ?? "Customer";
return View(customerId); return View(id);
} }
/// <summary> /// <summary>
/// Records the customer's SMS consent from the kiosk tablet and clears the pending cache entry. /// Records the customer's SMS consent from the kiosk tablet.
/// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record. /// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record.
/// Cache is already cleared by the GET; this handles the agree/decline outcome.
/// </summary> /// </summary>
[AllowAnonymous, HttpPost] [AllowAnonymous, HttpPost]
public async Task<IActionResult> SmsConsent(int customerId, bool agreed) public async Task<IActionResult> SmsConsent(int id, bool agreed)
{ {
var cookie = ReadKioskCookie(); var cookie = ReadKioskCookie();
if (cookie == null) return Forbid(); if (cookie == null) return Forbid();
// Always clear the pending consent so the kiosk stops showing the form
_cache.Remove(SmsConsentCacheKey(cookie.Value.companyId));
if (agreed) if (agreed)
{ {
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId, ignoreQueryFilters: true); var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
if (customer != null) if (customer != null)
{ {
customer.NotifyBySms = true; customer.NotifyBySms = true;
@@ -206,15 +221,15 @@ public class KioskController : Controller
customer.SmsOptedOutAt = null; customer.SmsOptedOutAt = null;
await _unitOfWork.Customers.UpdateAsync(customer); await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
_logger.LogInformation("SMS consent recorded via kiosk for customer {CustomerId}", customerId); _logger.LogInformation("SMS consent recorded via kiosk for customer {CustomerId}", id);
await _inApp.CreateAsync( await _inApp.CreateAsync(
customer.CompanyId, customer.CompanyId,
"SMS Consent Recorded", "SMS Consent Recorded",
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.", $"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
"KioskConsent", "KioskConsent",
link: $"/Customers/Details/{customerId}", link: $"/Customers/Details/{id}",
customerId: customerId); customerId: id);
} }
} }
@@ -185,13 +185,20 @@
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25"> <span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
<i class="bi bi-chat-slash me-1"></i>SMS off <i class="bi bi-chat-slash me-1"></i>SMS off
</span> </span>
<button type="button" <button type="button" id="btnGetSmsConsent"
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 border-0" class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 border-0"
style="cursor:pointer;" style="cursor:pointer;"
title="Send SMS consent form to the front-desk kiosk tablet" title="Send SMS consent form to the front-desk kiosk tablet"
onclick="pushSmsConsent(@Model.Id)"> onclick="pushSmsConsent(@Model.Id)">
<i class="bi bi-chat-dots me-1"></i>Get SMS Consent <i class="bi bi-chat-dots me-1"></i>Get SMS Consent
</button> </button>
<button type="button" id="btnCancelSmsConsent"
class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25 border-0 d-none"
style="cursor:pointer;"
title="Cancel the pending kiosk consent request"
onclick="cancelSmsConsent()">
<i class="bi bi-x-circle me-1"></i>Cancel Consent
</button>
} }
</div> </div>
</div> </div>
@@ -10,6 +10,8 @@ async function pushSmsConsent(customerId) {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
toastr.success('Consent form sent to the kiosk tablet — hand it to the customer.', 'Sent to Kiosk'); toastr.success('Consent form sent to the kiosk tablet — hand it to the customer.', 'Sent to Kiosk');
document.getElementById('btnGetSmsConsent')?.classList.add('d-none');
document.getElementById('btnCancelSmsConsent')?.classList.remove('d-none');
} else { } else {
toastr.warning(data.message || 'Could not send consent to kiosk.'); toastr.warning(data.message || 'Could not send consent to kiosk.');
} }
@@ -17,3 +19,21 @@ async function pushSmsConsent(customerId) {
toastr.error('An error occurred. Please try again.'); toastr.error('An error occurred. Please try again.');
} }
} }
async function cancelSmsConsent() {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch('/Kiosk/CancelSmsConsent', {
method: 'POST',
headers: { 'RequestVerificationToken': tok }
});
const data = await res.json();
if (data.success) {
toastr.info('Consent request cancelled — kiosk is free.');
document.getElementById('btnCancelSmsConsent')?.classList.add('d-none');
document.getElementById('btnGetSmsConsent')?.classList.remove('d-none');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}