0af31c39b3
- 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>
924 lines
40 KiB
C#
924 lines
40 KiB
C#
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;
|
|
using PowderCoating.Application.Services;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Shared.Constants;
|
|
using PowderCoating.Web.Hubs;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
/// <summary>
|
|
/// Handles the customer self-service intake kiosk — both the in-person tablet flow
|
|
/// (SignalR-triggered, activation-cookie-authenticated) and the remote email-link flow.
|
|
///
|
|
/// Anonymous intake routes use ignoreQueryFilters:true to load KioskSession by token
|
|
/// because the anonymous HTTP context has no CompanyId claim, so the global tenant
|
|
/// filter would return nothing without that flag.
|
|
///
|
|
/// When creating new Customer or Job records from the kiosk, CompanyId is set explicitly
|
|
/// from session.CompanyId so the EF SaveChanges interceptor doesn't override it with 0.
|
|
/// </summary>
|
|
public class KioskController : Controller
|
|
{
|
|
private const string CookieName = "KioskDevice";
|
|
private const int InPersonExpireHours = 2;
|
|
private const int RemoteExpireHours = 48;
|
|
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IMapper _mapper;
|
|
private readonly ILookupCacheService _lookupCache;
|
|
private readonly IInAppNotificationService _inApp;
|
|
private readonly IEmailService _emailService;
|
|
private readonly IHubContext<KioskHub> _kioskHub;
|
|
private readonly ILogger<KioskController> _logger;
|
|
private readonly ICompanyLogoService _logoService;
|
|
private readonly IMemoryCache _cache;
|
|
|
|
private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}";
|
|
|
|
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
|
|
public KioskController(
|
|
IUnitOfWork unitOfWork,
|
|
IMapper mapper,
|
|
ILookupCacheService lookupCache,
|
|
IInAppNotificationService inApp,
|
|
IEmailService emailService,
|
|
IHubContext<KioskHub> kioskHub,
|
|
ILogger<KioskController> logger,
|
|
ICompanyLogoService logoService,
|
|
IMemoryCache cache)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_mapper = mapper;
|
|
_lookupCache = lookupCache;
|
|
_inApp = inApp;
|
|
_emailService = emailService;
|
|
_kioskHub = kioskHub;
|
|
_logger = logger;
|
|
_logoService = logoService;
|
|
_cache = cache;
|
|
}
|
|
|
|
// =========================================================================
|
|
// WELCOME SCREEN (in-person tablet idle screen)
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// Idle branded screen displayed on the front-desk tablet.
|
|
/// Validates the KioskDevice cookie; returns 403 if missing or token mismatch.
|
|
/// The view polls /Kiosk/PollSession every 3 seconds and navigates when staff
|
|
/// triggers a session via the Dashboard "Start Intake" button.
|
|
/// </summary>
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Welcome()
|
|
{
|
|
var cookie = ReadKioskCookie();
|
|
if (cookie == null)
|
|
return View("KioskError", "This device is not activated as a kiosk. Ask a staff member to activate it at Settings → Kiosk.");
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
|
if (company == null || company.KioskActivationToken != cookie.Value.token)
|
|
return View("KioskError", "Kiosk activation token is invalid or has been revoked. Ask a staff member to re-activate this device.");
|
|
|
|
await PopulateKioskViewBag(company);
|
|
ViewBag.ShowInactivityTimer = false; // Welcome screen stays on indefinitely
|
|
return View();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lightweight polling endpoint called every 3 seconds by the kiosk Welcome screen.
|
|
/// Returns the most recent InPerson KioskSession created in the last 60 seconds so
|
|
/// the tablet can navigate without relying on SignalR (which Azure App Service blocks
|
|
/// for anonymous WebSocket/SSE connections through its ingress proxy).
|
|
/// </summary>
|
|
[AllowAnonymous, HttpGet]
|
|
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
|
public async Task<IActionResult> PollSession()
|
|
{
|
|
var cookie = ReadKioskCookie();
|
|
if (cookie == null) return Json(new { hasSession = false });
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
|
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
|
|
&& s.SessionType == KioskSessionType.InPerson
|
|
&& s.Status == KioskSessionStatus.Active
|
|
&& s.CreatedAt >= window,
|
|
ignoreQueryFilters: true);
|
|
|
|
if (session == null) return Json(new { hasSession = false });
|
|
return Json(new { hasSession = true, sessionToken = session.SessionToken });
|
|
}
|
|
|
|
// =========================================================================
|
|
// SMS CONSENT (staff pushes to kiosk; customer agrees on tablet)
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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 });
|
|
}
|
|
|
|
/// <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 id)
|
|
{
|
|
var cookie = ReadKioskCookie();
|
|
if (cookie == null) return Forbid();
|
|
|
|
// 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);
|
|
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(id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 id, bool agreed)
|
|
{
|
|
var cookie = ReadKioskCookie();
|
|
if (cookie == null) return Forbid();
|
|
|
|
if (agreed)
|
|
{
|
|
var customer = await _unitOfWork.Customers.GetByIdAsync(id, 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}", id);
|
|
|
|
await _inApp.CreateAsync(
|
|
customer.CompanyId,
|
|
"SMS Consent Recorded",
|
|
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
|
|
"KioskConsent",
|
|
link: $"/Customers/Details/{id}",
|
|
customerId: id);
|
|
}
|
|
}
|
|
|
|
return Redirect("/Kiosk/Welcome");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[AllowAnonymous]
|
|
[HttpGet, ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
|
|
public async Task<IActionResult> Logo()
|
|
{
|
|
var cookie = ReadKioskCookie();
|
|
if (cookie == null) return NotFound();
|
|
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
|
if (company == null || string.IsNullOrEmpty(company.LogoFilePath)) return NotFound();
|
|
|
|
var (success, fileContent, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
|
if (!success || fileContent.Length == 0) return NotFound();
|
|
|
|
return File(fileContent, contentType);
|
|
}
|
|
|
|
// =========================================================================
|
|
// DEVICE ACTIVATION (CompanyAdmin-only)
|
|
// =========================================================================
|
|
|
|
/// <summary>Shows the kiosk activation page with the current activation status.</summary>
|
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
|
public async Task<IActionResult> Activate()
|
|
{
|
|
var companyId = GetCurrentCompanyId();
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
|
ViewBag.IsActivated = !string.IsNullOrEmpty(company?.KioskActivationToken);
|
|
return View();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a new activation token, saves it to the Company record,
|
|
/// and writes the KioskDevice cookie so the current browser session becomes the active tablet.
|
|
/// </summary>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
|
public async Task<IActionResult> Activate(string action)
|
|
{
|
|
var companyId = GetCurrentCompanyId();
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
|
if (company == null) return NotFound();
|
|
|
|
if (action == "deactivate")
|
|
{
|
|
company.KioskActivationToken = null;
|
|
DeleteKioskCookie();
|
|
TempData["Success"] = "Kiosk deactivated. The tablet will no longer accept intake sessions.";
|
|
}
|
|
else
|
|
{
|
|
var token = Guid.NewGuid().ToString("N");
|
|
company.KioskActivationToken = token;
|
|
WriteKioskCookie(companyId, token);
|
|
TempData["Success"] = "Kiosk activated. Open /Kiosk/Welcome on the tablet and bookmark it.";
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
return RedirectToAction(nameof(Activate));
|
|
}
|
|
|
|
// =========================================================================
|
|
// START IN-PERSON SESSION (any authenticated staff member)
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// Creates an InPerson KioskSession and pushes a SignalR StartIntake event
|
|
/// to all connections in the company's kiosk group so the tablet navigates automatically.
|
|
/// Called via fetch from the Dashboard "Start Intake" button.
|
|
/// </summary>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
[Authorize]
|
|
public async Task<IActionResult> StartSession()
|
|
{
|
|
var companyId = GetCurrentCompanyId();
|
|
|
|
var session = new KioskSession
|
|
{
|
|
SessionType = KioskSessionType.InPerson,
|
|
ExpiresAt = DateTime.UtcNow.AddHours(InPersonExpireHours),
|
|
CompanyId = companyId
|
|
};
|
|
|
|
await _unitOfWork.KioskSessions.AddAsync(session);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
await _kioskHub.Clients
|
|
.Group($"kiosk-{companyId}")
|
|
.SendAsync("StartIntake", session.SessionToken.ToString());
|
|
|
|
return Json(new { success = true, sessionToken = session.SessionToken });
|
|
}
|
|
|
|
// =========================================================================
|
|
// SEND REMOTE LINK (any authenticated staff member)
|
|
// =========================================================================
|
|
|
|
/// <summary>Form for staff to enter a customer's email address and send an intake link.</summary>
|
|
[Authorize]
|
|
public IActionResult SendRemoteLink() => View(new SendRemoteLinkDto());
|
|
|
|
/// <summary>
|
|
/// Creates a Remote KioskSession, sends the intake link by email, and redirects back
|
|
/// with a success message. The link contains the session token (GUID) — not guessable.
|
|
/// </summary>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
[Authorize]
|
|
public async Task<IActionResult> SendRemoteLink(SendRemoteLinkDto dto)
|
|
{
|
|
if (!ModelState.IsValid) return View(dto);
|
|
|
|
var companyId = GetCurrentCompanyId();
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
|
|
|
var session = new KioskSession
|
|
{
|
|
SessionType = KioskSessionType.Remote,
|
|
ExpiresAt = DateTime.UtcNow.AddHours(RemoteExpireHours),
|
|
RemoteLinkEmail = dto.Email,
|
|
RemoteLinkSentAt = DateTime.UtcNow,
|
|
CompanyId = companyId
|
|
};
|
|
|
|
await _unitOfWork.KioskSessions.AddAsync(session);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
var link = $"{Request.Scheme}://{Request.Host}/Kiosk/Intake/{session.SessionToken}/Contact";
|
|
var recipientName = string.IsNullOrWhiteSpace(dto.CustomerName) ? "Valued Customer" : dto.CustomerName;
|
|
var companyName = company?.CompanyName ?? "Us";
|
|
|
|
var html = $@"
|
|
<div style='font-family:sans-serif;max-width:560px;margin:0 auto;padding:2rem;'>
|
|
<h2 style='color:#1e293b;'>Hi {System.Web.HttpUtility.HtmlEncode(recipientName)},</h2>
|
|
<p style='color:#475569;font-size:1rem;'>
|
|
{System.Web.HttpUtility.HtmlEncode(companyName)} has sent you a quick intake form to fill out before your visit.
|
|
It only takes a couple of minutes.
|
|
</p>
|
|
<a href='{link}' style='display:inline-block;margin:1.5rem 0;padding:1rem 2rem;background:#2563eb;
|
|
color:#fff;font-weight:600;border-radius:8px;text-decoration:none;font-size:1.1rem;'>
|
|
Start My Intake Form
|
|
</a>
|
|
<p style='color:#94a3b8;font-size:0.85rem;'>
|
|
This link expires in 48 hours. If you did not expect this email, you can ignore it.
|
|
</p>
|
|
</div>";
|
|
|
|
await _emailService.SendEmailAsync(
|
|
dto.Email, recipientName,
|
|
$"Your intake form from {companyName}",
|
|
$"Please visit this link to complete your intake form: {link}",
|
|
htmlBody: html);
|
|
|
|
TempData["Success"] = $"Intake link sent to {dto.Email}.";
|
|
return RedirectToAction(nameof(SendRemoteLink));
|
|
}
|
|
|
|
// =========================================================================
|
|
// INTAKE STEPS (anonymous — both InPerson and Remote)
|
|
// =========================================================================
|
|
|
|
// ── Step 1: Contact Info ──────────────────────────────────────────────────
|
|
|
|
/// <summary>Displays the contact-info form for the given session token.</summary>
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Contact(Guid token)
|
|
{
|
|
var session = await LoadSessionAsync(token);
|
|
if (session == null) return View("KioskError", "This intake session could not be found. Please ask a staff member to start a new one.");
|
|
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
|
|
|
await PopulateKioskViewBagFromSession(session);
|
|
ViewBag.KioskStep = 1;
|
|
return View("Intake/Contact", new SubmitKioskContactDto
|
|
{
|
|
FirstName = session.CustomerFirstName,
|
|
LastName = session.CustomerLastName,
|
|
Phone = session.CustomerPhone,
|
|
Email = session.CustomerEmail,
|
|
IsReturningCustomer = session.IsReturningCustomer
|
|
});
|
|
}
|
|
|
|
/// <summary>Saves contact info to the session and advances to Step 2.</summary>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Contact(Guid token, SubmitKioskContactDto dto)
|
|
{
|
|
var session = await LoadSessionAsync(token);
|
|
if (session == null) return View("KioskError", "Session not found.");
|
|
|
|
if (!ModelState.IsValid)
|
|
{
|
|
await PopulateKioskViewBagFromSession(session);
|
|
ViewBag.KioskStep = 1;
|
|
return View("Intake/Contact", dto);
|
|
}
|
|
|
|
session.CustomerFirstName = dto.FirstName.Trim();
|
|
session.CustomerLastName = dto.LastName.Trim();
|
|
session.CustomerPhone = dto.Phone.Trim();
|
|
session.CustomerEmail = dto.Email.Trim().ToLowerInvariant();
|
|
session.IsReturningCustomer = dto.IsReturningCustomer;
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
return RedirectToAction(nameof(Job), new { token });
|
|
}
|
|
|
|
// ── Step 2: Job Description ───────────────────────────────────────────────
|
|
|
|
/// <summary>Displays the job-description form.</summary>
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Job(Guid token)
|
|
{
|
|
var session = await LoadSessionAsync(token);
|
|
if (session == null) return View("KioskError", "Session not found.");
|
|
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
|
|
|
await PopulateKioskViewBagFromSession(session);
|
|
ViewBag.KioskStep = 2;
|
|
return View("Intake/Job", new SubmitKioskJobDto
|
|
{
|
|
JobDescription = session.JobDescription,
|
|
HowDidYouHearAboutUs = session.HowDidYouHearAboutUs
|
|
});
|
|
}
|
|
|
|
/// <summary>Saves the job description and advances to Step 3.</summary>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Job(Guid token, SubmitKioskJobDto dto)
|
|
{
|
|
var session = await LoadSessionAsync(token);
|
|
if (session == null) return View("KioskError", "Session not found.");
|
|
|
|
if (!ModelState.IsValid)
|
|
{
|
|
await PopulateKioskViewBagFromSession(session);
|
|
ViewBag.KioskStep = 2;
|
|
return View("Intake/Job", dto);
|
|
}
|
|
|
|
session.JobDescription = dto.JobDescription.Trim();
|
|
session.HowDidYouHearAboutUs = dto.HowDidYouHearAboutUs?.Trim();
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
return RedirectToAction(nameof(Terms), new { token });
|
|
}
|
|
|
|
// ── Step 3: Terms & Consent ───────────────────────────────────────────────
|
|
|
|
/// <summary>Displays the terms, SMS opt-in checkbox, and (for InPerson) signature pad.</summary>
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Terms(Guid token)
|
|
{
|
|
var session = await LoadSessionAsync(token);
|
|
if (session == null) return View("KioskError", "Session not found.");
|
|
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
|
|
|
await PopulateKioskViewBagFromSession(session);
|
|
ViewBag.KioskStep = 3;
|
|
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
|
return View("Intake/Terms", new SubmitKioskTermsDto());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves terms agreement, triggers customer/job auto-creation, fires staff notification,
|
|
/// and redirects to the Confirmation screen.
|
|
/// </summary>
|
|
[HttpPost, ValidateAntiForgeryToken]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Terms(Guid token, SubmitKioskTermsDto dto)
|
|
{
|
|
var session = await LoadSessionAsync(token);
|
|
if (session == null) return View("KioskError", "Session not found.");
|
|
|
|
// Expired/already-submitted sessions go straight to Confirmation
|
|
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
|
|
|
// Require signature for in-person sessions
|
|
if (session.SessionType == KioskSessionType.InPerson &&
|
|
string.IsNullOrEmpty(dto.SignatureDataBase64))
|
|
{
|
|
ModelState.AddModelError("SignatureDataBase64", "Please sign above before continuing.");
|
|
}
|
|
|
|
if (!ModelState.IsValid)
|
|
{
|
|
await PopulateKioskViewBagFromSession(session);
|
|
ViewBag.KioskStep = 3;
|
|
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
|
return View("Intake/Terms", dto);
|
|
}
|
|
|
|
session.AgreedToTerms = true;
|
|
session.AgreedToTermsAt = DateTime.UtcNow;
|
|
session.SmsOptIn = dto.SmsOptIn;
|
|
session.SignatureDataBase64 = dto.SignatureDataBase64;
|
|
session.Status = KioskSessionStatus.Submitted;
|
|
session.SubmittedAt = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
await ProcessSubmissionAsync(session);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error processing kiosk submission for session {SessionToken}", token);
|
|
// Customer-facing page always succeeds — staff can convert the session manually.
|
|
// Persist the session's agreed/submitted state even if job creation failed.
|
|
try { await _unitOfWork.CompleteAsync(); } catch { /* best-effort */ }
|
|
}
|
|
|
|
return RedirectToAction(nameof(Confirmation), new { token });
|
|
}
|
|
|
|
// ── Confirmation ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Thank-you screen shown after a successful submission.</summary>
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Confirmation(Guid token)
|
|
{
|
|
var session = await LoadSessionAsync(token);
|
|
if (session == null) return View("KioskError", "Session not found.");
|
|
|
|
await PopulateKioskViewBagFromSession(session);
|
|
ViewBag.ShowInactivityTimer = false; // Handled by the countdown JS in the view
|
|
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
|
ViewBag.FirstName = session.CustomerFirstName;
|
|
return View("Intake/Confirmation");
|
|
}
|
|
|
|
// =========================================================================
|
|
// STAFF REVIEW (authenticated)
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// Lists all kiosk intake sessions for the current company — submitted, active, and expired.
|
|
/// Manager or higher access required.
|
|
/// </summary>
|
|
[Authorize]
|
|
public async Task<IActionResult> Intakes(string? filter)
|
|
{
|
|
var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false,
|
|
s => s.LinkedCustomer,
|
|
s => s.LinkedJob);
|
|
|
|
var dtos = sessions
|
|
.OrderByDescending(s => s.CreatedAt)
|
|
.Select(s => new KioskSessionListDto
|
|
{
|
|
Id = s.Id,
|
|
SessionToken = s.SessionToken,
|
|
SessionType = s.SessionType,
|
|
Status = s.Status,
|
|
CustomerFirstName = s.CustomerFirstName,
|
|
CustomerLastName = s.CustomerLastName,
|
|
CustomerEmail = s.CustomerEmail,
|
|
CustomerPhone = s.CustomerPhone,
|
|
JobDescription = s.JobDescription,
|
|
SmsOptIn = s.SmsOptIn,
|
|
SubmittedAt = s.SubmittedAt,
|
|
ExpiresAt = s.ExpiresAt,
|
|
LinkedCustomerId = s.LinkedCustomerId,
|
|
LinkedJobId = s.LinkedJobId,
|
|
LinkedQuoteId = s.LinkedQuoteId,
|
|
RemoteLinkEmail = s.RemoteLinkEmail
|
|
})
|
|
.ToList();
|
|
|
|
// Apply filter tab
|
|
dtos = filter switch
|
|
{
|
|
"submitted" => dtos.Where(d => d.Status == KioskSessionStatus.Submitted).ToList(),
|
|
"active" => dtos.Where(d => d.Status == KioskSessionStatus.Active && !d.IsExpired).ToList(),
|
|
"expired" => dtos.Where(d => d.IsExpired || d.Status == KioskSessionStatus.Expired).ToList(),
|
|
_ => dtos
|
|
};
|
|
|
|
ViewBag.ActiveFilter = filter ?? "all";
|
|
return View(dtos);
|
|
}
|
|
|
|
// =========================================================================
|
|
// PRIVATE HELPERS
|
|
// =========================================================================
|
|
|
|
/// <summary>
|
|
/// Loads a KioskSession by SessionToken using ignoreQueryFilters because anonymous requests
|
|
/// have no CompanyId claim, so the global tenant filter would return nothing without it.
|
|
/// </summary>
|
|
private async Task<KioskSession?> LoadSessionAsync(Guid token)
|
|
{
|
|
return await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
|
|
s => s.SessionToken == token && !s.IsDeleted,
|
|
ignoreQueryFilters: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that the session is still in a usable state.
|
|
/// Returns false (and optionally updates status to Expired) if the session should not proceed.
|
|
/// </summary>
|
|
private async Task<bool> ValidateSessionState(KioskSession session)
|
|
{
|
|
if (session.Status == KioskSessionStatus.Submitted)
|
|
return false; // Already done — redirect to Confirmation (idempotent)
|
|
|
|
if (session.Status == KioskSessionStatus.Cancelled)
|
|
return false;
|
|
|
|
if (DateTime.UtcNow > session.ExpiresAt && session.Status == KioskSessionStatus.Active)
|
|
{
|
|
session.Status = KioskSessionStatus.Expired;
|
|
await _unitOfWork.CompleteAsync();
|
|
return false;
|
|
}
|
|
|
|
return session.Status == KioskSessionStatus.Active;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core submission logic: matches or creates a Customer, creates a Pending Job,
|
|
/// applies SMS consent, and fires a staff in-app notification.
|
|
/// CompanyId is set explicitly on new entities from session.CompanyId so the EF
|
|
/// SaveChanges interceptor does not override it with 0 (the anonymous tenant context).
|
|
/// </summary>
|
|
private async Task ProcessSubmissionAsync(KioskSession session)
|
|
{
|
|
var companyId = session.CompanyId;
|
|
|
|
// 1. Match or create Customer
|
|
Customer? customer = null;
|
|
if (!string.IsNullOrEmpty(session.CustomerEmail))
|
|
{
|
|
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
|
|
c => c.CompanyId == companyId && c.Email == session.CustomerEmail && !c.IsDeleted,
|
|
ignoreQueryFilters: true);
|
|
}
|
|
|
|
if (customer == null && !string.IsNullOrEmpty(session.CustomerPhone))
|
|
{
|
|
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
|
|
c => c.CompanyId == companyId && (c.Phone == session.CustomerPhone || c.MobilePhone == session.CustomerPhone) && !c.IsDeleted,
|
|
ignoreQueryFilters: true);
|
|
}
|
|
|
|
bool isNewCustomer = customer == null;
|
|
if (isNewCustomer)
|
|
{
|
|
customer = new Customer
|
|
{
|
|
CompanyId = companyId,
|
|
ContactFirstName = session.CustomerFirstName,
|
|
ContactLastName = session.CustomerLastName,
|
|
Phone = session.CustomerPhone,
|
|
Email = session.CustomerEmail,
|
|
IsActive = true,
|
|
IsCommercial = false
|
|
};
|
|
await _unitOfWork.Customers.AddAsync(customer);
|
|
await _unitOfWork.CompleteAsync(); // get Customer.Id
|
|
}
|
|
|
|
// 2. Apply SMS consent
|
|
if (session.SmsOptIn)
|
|
{
|
|
customer!.NotifyBySms = true;
|
|
customer.SmsConsentedAt = session.SubmittedAt ?? DateTime.UtcNow;
|
|
customer.SmsConsentMethod = session.SessionType == KioskSessionType.InPerson
|
|
? "KioskIntake"
|
|
: "RemoteIntake";
|
|
}
|
|
|
|
// 3. Resolve company preference: create a Quote (default) or a Job
|
|
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
|
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
|
var intakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
|
var createQuote = !string.Equals(intakeOutput, "Job", StringComparison.OrdinalIgnoreCase);
|
|
|
|
session.LinkedCustomerId = customer!.Id;
|
|
|
|
if (createQuote)
|
|
{
|
|
// 3a. Create a Draft Quote so staff can price and send for approval
|
|
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
|
var draftStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
|
if (draftStatus == null)
|
|
throw new InvalidOperationException($"No Draft quote status found for company {companyId}. Run Seed Data from Platform Management.");
|
|
|
|
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
|
|
var quote = new Quote
|
|
{
|
|
CompanyId = companyId,
|
|
CustomerId = customer.Id,
|
|
QuoteNumber = quoteNumber,
|
|
QuoteStatusId = draftStatus.Id,
|
|
Description = session.JobDescription,
|
|
Notes = $"Source: {session.SessionType} kiosk intake",
|
|
QuoteDate = DateTime.UtcNow,
|
|
ExpirationDate = DateTime.UtcNow.AddDays(prefs?.DefaultQuoteValidityDays ?? 30)
|
|
};
|
|
|
|
await _unitOfWork.Quotes.AddAsync(quote);
|
|
await _unitOfWork.CompleteAsync(); // quote.Id now valid
|
|
|
|
session.LinkedQuoteId = quote.Id;
|
|
}
|
|
else
|
|
{
|
|
// 3b. Create a Pending Job directly (for shops that price on the spot)
|
|
var jobStatuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
|
var pendingStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
|
if (pendingStatus == null)
|
|
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
|
|
|
|
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
|
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL")
|
|
?? priorities.FirstOrDefault();
|
|
if (normalPriority == null)
|
|
throw new InvalidOperationException($"No job priority rows found for company {companyId}. Run Seed Data from Platform Management.");
|
|
|
|
var jobNumber = await GenerateJobNumberAsync(companyId);
|
|
var job = new Job
|
|
{
|
|
CompanyId = companyId,
|
|
CustomerId = customer.Id,
|
|
JobNumber = jobNumber,
|
|
JobStatusId = pendingStatus.Id,
|
|
JobPriorityId = normalPriority.Id,
|
|
Description = session.JobDescription,
|
|
SpecialInstructions = $"Source: {session.SessionType} kiosk intake"
|
|
};
|
|
|
|
await _unitOfWork.Jobs.AddAsync(job);
|
|
await _unitOfWork.CompleteAsync(); // job.Id now valid
|
|
|
|
session.LinkedJobId = job.Id;
|
|
}
|
|
|
|
// 4. Persist session links
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
// 5. Fire staff notification
|
|
var jobDesc = session.JobDescription ?? "";
|
|
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
|
|
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
|
|
var intakeLabel = session.SessionType == KioskSessionType.Remote ? "Remote Intake" : "Walk-in Intake";
|
|
await _inApp.CreateAsync(
|
|
companyId,
|
|
$"{intakeLabel} Submitted",
|
|
$"{fullName} completed their intake form — {snippet}",
|
|
"KioskIntake",
|
|
link: $"/Kiosk/Intakes",
|
|
customerId: customer.Id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates the next sequential quote number using the company's configured prefix.
|
|
/// Mirrors GenerateQuoteNumberAsync in QuotesController — same format: PREFIX-YYMM-####.
|
|
/// Implemented here because KioskController processes anonymous requests and cannot
|
|
/// rely on ITenantContext to resolve the company ID.
|
|
/// </summary>
|
|
private async Task<string> GenerateQuoteNumberAsync(int companyId)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
|
|
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
|
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
|
|
|
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
|
|
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
|
|
|
|
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
|
|
|
|
if (lastQuoteNumber != null)
|
|
{
|
|
var lastNumberStr = lastQuoteNumber[(prefix.Length + 1)..];
|
|
if (int.TryParse(lastNumberStr, out int lastNumber))
|
|
return $"{prefix}-{(lastNumber + 1):D4}";
|
|
}
|
|
|
|
return $"{prefix}-0001";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates the next sequential job number using the company's configured prefix.
|
|
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
|
|
/// </summary>
|
|
private async Task<string> GenerateJobNumberAsync(int companyId)
|
|
{
|
|
var year = DateTime.Now.Year.ToString()[2..];
|
|
var month = DateTime.Now.Month.ToString("D2");
|
|
|
|
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
|
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
|
|
|
var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB";
|
|
var prefix = $"{jobPrefix}-{year}{month}";
|
|
|
|
var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix);
|
|
|
|
if (lastJobNumber != null)
|
|
{
|
|
var lastNumberStr = lastJobNumber[(prefix.Length + 1)..];
|
|
if (int.TryParse(lastNumberStr, out int lastNumber))
|
|
return $"{prefix}-{(lastNumber + 1):D4}";
|
|
}
|
|
|
|
return $"{prefix}-0001";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the KioskDevice cookie and parses the "{companyId}:{token}" value.
|
|
/// Returns null if the cookie is absent or malformed.
|
|
/// </summary>
|
|
private (int companyId, string token)? ReadKioskCookie()
|
|
{
|
|
if (!Request.Cookies.TryGetValue(CookieName, out var raw) || string.IsNullOrEmpty(raw))
|
|
return null;
|
|
|
|
var parts = raw.Split(':', 2);
|
|
if (parts.Length != 2 || !int.TryParse(parts[0], out int id))
|
|
return null;
|
|
|
|
return (id, parts[1]);
|
|
}
|
|
|
|
/// <summary>Writes a long-lived HttpOnly kiosk device cookie.</summary>
|
|
private void WriteKioskCookie(int companyId, string token)
|
|
{
|
|
Response.Cookies.Append(CookieName, $"{companyId}:{token}", new CookieOptions
|
|
{
|
|
HttpOnly = true,
|
|
Secure = true,
|
|
SameSite = SameSiteMode.Lax,
|
|
MaxAge = TimeSpan.FromDays(365)
|
|
});
|
|
}
|
|
|
|
/// <summary>Removes the kiosk device cookie (deactivation).</summary>
|
|
private void DeleteKioskCookie()
|
|
{
|
|
Response.Cookies.Delete(CookieName);
|
|
}
|
|
|
|
/// <summary>Returns the current authenticated user's CompanyId claim.</summary>
|
|
private int GetCurrentCompanyId()
|
|
{
|
|
var claim = User.FindFirst("CompanyId")?.Value;
|
|
return int.TryParse(claim, out int id) ? id : 0;
|
|
}
|
|
|
|
/// <summary>Sets ViewBag properties needed by _KioskLayout from a Company entity.</summary>
|
|
private async Task PopulateKioskViewBag(Company company)
|
|
{
|
|
ViewBag.CompanyId = company.Id;
|
|
ViewBag.CompanyName = company.CompanyName;
|
|
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company.LogoFilePath)
|
|
? Url.Action("Logo", "Kiosk")
|
|
: null;
|
|
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
|
|
|
|
// Pass the intake output setting so Terms.cshtml can show matching wording
|
|
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
|
p => p.CompanyId == company.Id && !p.IsDeleted, ignoreQueryFilters: true);
|
|
ViewBag.KioskIntakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
|
}
|
|
|
|
/// <summary>Loads the company from a session's CompanyId and populates ViewBag.</summary>
|
|
private async Task PopulateKioskViewBagFromSession(KioskSession session)
|
|
{
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(session.CompanyId, ignoreQueryFilters: true);
|
|
if (company != null)
|
|
await PopulateKioskViewBag(company);
|
|
|
|
ViewBag.SessionToken = session.SessionToken;
|
|
ViewBag.SessionType = session.SessionType;
|
|
|
|
// Reset to Welcome screen after 45 s of inactivity on any intake step.
|
|
// The Welcome screen itself stays on indefinitely (no timeout override there).
|
|
ViewBag.InactivityTimeoutMs = 45_000;
|
|
}
|
|
}
|