Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/KioskController.cs
T
spouliot 0af31c39b3 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>
2026-05-13 23:25:37 -04:00

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;
}
}