diff --git a/src/PowderCoating.Web/Controllers/CustomersController.cs b/src/PowderCoating.Web/Controllers/CustomersController.cs index 2da409c..4e327d8 100644 --- a/src/PowderCoating.Web/Controllers/CustomersController.cs +++ b/src/PowderCoating.Web/Controllers/CustomersController.cs @@ -877,74 +877,6 @@ public class CustomersController : Controller } } - /// - /// 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. - /// - // GET: Customers/SmsConsent/5 - public async Task 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); - } - - /// - /// 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. - /// - // POST: Customers/SmsConsent/5 - [HttpPost, ValidateAntiForgeryToken] - public async Task 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 }); - } - /// /// Issues a standalone credit memo and increments the customer's CreditBalance. /// Restricted to CompanyAdmin because credits affect the financial ledger. The memo diff --git a/src/PowderCoating.Web/Controllers/KioskController.cs b/src/PowderCoating.Web/Controllers/KioskController.cs index 993cc99..5ceb1cb 100644 --- a/src/PowderCoating.Web/Controllers/KioskController.cs +++ b/src/PowderCoating.Web/Controllers/KioskController.cs @@ -2,6 +2,7 @@ using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using PowderCoating.Application.DTOs.Kiosk; using PowderCoating.Application.Interfaces; @@ -39,6 +40,9 @@ public class KioskController : Controller private readonly IHubContext _kioskHub; private readonly ILogger _logger; private readonly ICompanyLogoService _logoService; + private readonly IMemoryCache _cache; + + private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}"; /// Initialises all dependencies for the kiosk controller. public KioskController( @@ -49,7 +53,8 @@ public class KioskController : Controller IEmailService emailService, IHubContext kioskHub, ILogger logger, - ICompanyLogoService logoService) + ICompanyLogoService logoService, + IMemoryCache cache) { _unitOfWork = unitOfWork; _mapper = mapper; @@ -59,6 +64,7 @@ public class KioskController : Controller _kioskHub = kioskHub; _logger = logger; _logoService = logoService; + _cache = cache; } // ========================================================================= @@ -104,6 +110,10 @@ public class KioskController : Controller if (company == null || company.KioskActivationToken != cookie.Value.token) return Json(new { hasSession = false }); + // Check for a staff-pushed SMS consent request before checking for intake sessions. + if (_cache.TryGetValue(SmsConsentCacheKey(cookie.Value.companyId), out (int customerId, string customerName) pending)) + return Json(new { hasSession = false, smsConsentPending = true, customerId = pending.customerId, customerName = pending.customerName }); + var window = DateTime.UtcNow.AddSeconds(-60); var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync( s => s.CompanyId == cookie.Value.companyId @@ -116,6 +126,101 @@ public class KioskController : Controller return Json(new { hasSession = true, sessionToken = session.SessionToken }); } + // ========================================================================= + // SMS CONSENT (staff pushes to kiosk; customer agrees on tablet) + // ========================================================================= + + /// + /// Staff calls this (authenticated) from the Customer Details page to push an SMS + /// consent request to the front-desk kiosk tablet. Stores the customer ID in + /// IMemoryCache under a company-scoped key; the kiosk's PollSession endpoint picks + /// it up and returns smsConsentPending so the tablet can navigate to the consent page. + /// The cache entry expires in 10 minutes in case the customer never approaches the tablet. + /// + [HttpPost, ValidateAntiForgeryToken] + public async Task PushSmsConsent(int customerId) + { + var customer = await _unitOfWork.Customers.GetByIdAsync(customerId); + if (customer == null) return Json(new { success = false, message = "Customer not found." }); + + if (customer.NotifyBySms) + return Json(new { success = false, message = "Customer has already given SMS consent." }); + + var companyId = customer.CompanyId; + var name = !string.IsNullOrWhiteSpace(customer.ContactFirstName) + ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() + : customer.CompanyName ?? "Customer"; + + _cache.Set(SmsConsentCacheKey(companyId), (customerId, name), + new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }); + + _logger.LogInformation("SMS consent pushed to kiosk for customer {CustomerId} by staff", customerId); + return Json(new { success = true }); + } + + /// + /// 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. + /// + [AllowAnonymous] + public async Task SmsConsent(int customerId) + { + var cookie = ReadKioskCookie(); + if (cookie == null) return Forbid(); + + var customer = await _unitOfWork.Customers.GetByIdAsync(customerId, ignoreQueryFilters: true); + if (customer == null) return NotFound(); + + var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true); + ViewBag.CompanyName = company?.CompanyName; + ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath) ? Url.Action("Logo", "Kiosk") : null; + ViewBag.ShowInactivityTimer = false; + ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.ContactFirstName) + ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() + : customer.CompanyName ?? "Customer"; + + return View(customerId); + } + + /// + /// Records the customer's SMS consent from the kiosk tablet and clears the pending cache entry. + /// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record. + /// + [AllowAnonymous, HttpPost] + public async Task SmsConsent(int customerId, 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); + if (customer != null) + { + customer.NotifyBySms = true; + customer.SmsConsentedAt = DateTime.UtcNow; + customer.SmsConsentMethod = "KioskInPerson"; + customer.SmsOptedOutAt = null; + await _unitOfWork.Customers.UpdateAsync(customer); + await _unitOfWork.CompleteAsync(); + _logger.LogInformation("SMS consent recorded via kiosk for customer {CustomerId}", customerId); + + 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); + } + } + + return Redirect("/Kiosk/Welcome"); + } + /// /// Serves the company logo for anonymous kiosk pages. Resolves the company from the /// KioskDevice cookie so no tenant context is needed on the anonymous request. diff --git a/src/PowderCoating.Web/Views/Customers/Details.cshtml b/src/PowderCoating.Web/Views/Customers/Details.cshtml index 6281ff7..cf2bfec 100644 --- a/src/PowderCoating.Web/Views/Customers/Details.cshtml +++ b/src/PowderCoating.Web/Views/Customers/Details.cshtml @@ -185,11 +185,13 @@ SMS off - + } @@ -549,3 +551,8 @@ } + +@section Scripts { + +} + diff --git a/src/PowderCoating.Web/Views/Customers/SmsConsent.cshtml b/src/PowderCoating.Web/Views/Kiosk/SmsConsent.cshtml similarity index 76% rename from src/PowderCoating.Web/Views/Customers/SmsConsent.cshtml rename to src/PowderCoating.Web/Views/Kiosk/SmsConsent.cshtml index f84de17..fb71d76 100644 --- a/src/PowderCoating.Web/Views/Customers/SmsConsent.cshtml +++ b/src/PowderCoating.Web/Views/Kiosk/SmsConsent.cshtml @@ -9,7 +9,7 @@

SMS Notifications

Please read the following and tap I Agree to opt in.

-
+ @Html.AntiForgeryToken() @@ -33,7 +33,9 @@
- No Thanks @@ -42,4 +44,10 @@
+ + @* Separate form for decline so "No Thanks" can POST with agreed=false *@ + diff --git a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml index ae9ed26..a49db1e 100644 --- a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml +++ b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml @@ -2101,8 +2101,14 @@ }); }); - // Load on page ready - document.addEventListener('DOMContentLoaded', load); + // Load on page ready and refresh when dropdown is opened + document.addEventListener('DOMContentLoaded', () => { + load(); + btn?.addEventListener('show.bs.dropdown', load); + }); + + // Fallback poll every 60 s in case SignalR misses a push + setInterval(load, 60_000); return { addItem, incrementBadge, markAllRead, openDetail, markRead }; })(); diff --git a/src/PowderCoating.Web/wwwroot/css/kiosk.css b/src/PowderCoating.Web/wwwroot/css/kiosk.css index b71ab8a..46e6efb 100644 --- a/src/PowderCoating.Web/wwwroot/css/kiosk.css +++ b/src/PowderCoating.Web/wwwroot/css/kiosk.css @@ -60,6 +60,14 @@ body.kiosk-body { width: 100%; } +/* Vertically centre content in any tall kiosk button (covers and