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 });
}
/// <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>
/// 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.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> SmsConsent(int customerId)
public async Task<IActionResult> SmsConsent(int id)
{
var cookie = ReadKioskCookie();
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();
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.CompanyName ?? "Customer";
return View(customerId);
return View(id);
}
/// <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.
/// Cache is already cleared by the GET; this handles the agree/decline outcome.
/// </summary>
[AllowAnonymous, HttpPost]
public async Task<IActionResult> SmsConsent(int customerId, bool agreed)
public async Task<IActionResult> SmsConsent(int id, bool agreed)
{
var cookie = ReadKioskCookie();
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)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId, ignoreQueryFilters: true);
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
if (customer != null)
{
customer.NotifyBySms = true;
@@ -206,15 +221,15 @@ public class KioskController : Controller
customer.SmsOptedOutAt = null;
await _unitOfWork.Customers.UpdateAsync(customer);
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(
customer.CompanyId,
"SMS Consent Recorded",
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
"KioskConsent",
link: $"/Customers/Details/{customerId}",
customerId: customerId);
link: $"/Customers/Details/{id}",
customerId: id);
}
}
@@ -185,13 +185,20 @@
<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
</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"
style="cursor:pointer;"
title="Send SMS consent form to the front-desk kiosk tablet"
onclick="pushSmsConsent(@Model.Id)">
<i class="bi bi-chat-dots me-1"></i>Get SMS Consent
</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>
@@ -10,6 +10,8 @@ async function pushSmsConsent(customerId) {
const data = await res.json();
if (data.success) {
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 {
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.');
}
}
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.');
}
}