using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; 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; /// /// 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. /// 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; private readonly ILogger _logger; /// Initialises all dependencies for the kiosk controller. public KioskController( IUnitOfWork unitOfWork, IMapper mapper, ILookupCacheService lookupCache, IInAppNotificationService inApp, IEmailService emailService, IHubContext kioskHub, ILogger logger) { _unitOfWork = unitOfWork; _mapper = mapper; _lookupCache = lookupCache; _inApp = inApp; _emailService = emailService; _kioskHub = kioskHub; _logger = logger; } // ========================================================================= // WELCOME SCREEN (in-person tablet idle screen) // ========================================================================= /// /// Idle branded screen displayed on the front-desk tablet. /// Validates the KioskDevice cookie; returns 403 if missing or token mismatch. /// The view connects to KioskHub and listens for StartIntake events. /// [AllowAnonymous] public async Task 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(); } // ========================================================================= // DEVICE ACTIVATION (CompanyAdmin-only) // ========================================================================= /// Shows the kiosk activation page with the current activation status. [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task Activate() { var companyId = GetCurrentCompanyId(); var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true); ViewBag.IsActivated = !string.IsNullOrEmpty(company?.KioskActivationToken); return View(); } /// /// 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. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public async Task 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) // ========================================================================= /// /// 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. /// [HttpPost, ValidateAntiForgeryToken] [Authorize] public async Task 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) // ========================================================================= /// Form for staff to enter a customer's email address and send an intake link. [Authorize] public IActionResult SendRemoteLink() => View(new SendRemoteLinkDto()); /// /// 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. /// [HttpPost, ValidateAntiForgeryToken] [Authorize] public async Task 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 = $@"

Hi {System.Web.HttpUtility.HtmlEncode(recipientName)},

{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.

Start My Intake Form

This link expires in 48 hours. If you did not expect this email, you can ignore it.

"; 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 ────────────────────────────────────────────────── /// Displays the contact-info form for the given session token. [AllowAnonymous] public async Task 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(new SubmitKioskContactDto { FirstName = session.CustomerFirstName, LastName = session.CustomerLastName, Phone = session.CustomerPhone, Email = session.CustomerEmail, IsReturningCustomer = session.IsReturningCustomer }); } /// Saves contact info to the session and advances to Step 2. [HttpPost, ValidateAntiForgeryToken] [AllowAnonymous] public async Task 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(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 ─────────────────────────────────────────────── /// Displays the job-description form. [AllowAnonymous] public async Task 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(new SubmitKioskJobDto { JobDescription = session.JobDescription, HowDidYouHearAboutUs = session.HowDidYouHearAboutUs }); } /// Saves the job description and advances to Step 3. [HttpPost, ValidateAntiForgeryToken] [AllowAnonymous] public async Task 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(dto); } session.JobDescription = dto.JobDescription.Trim(); session.HowDidYouHearAboutUs = dto.HowDidYouHearAboutUs?.Trim(); await _unitOfWork.CompleteAsync(); return RedirectToAction(nameof(Terms), new { token }); } // ── Step 3: Terms & Consent ─────────────────────────────────────────────── /// Displays the terms, SMS opt-in checkbox, and (for InPerson) signature pad. [AllowAnonymous] public async Task 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(new SubmitKioskTermsDto()); } /// /// Saves terms agreement, triggers customer/job auto-creation, fires staff notification, /// and redirects to the Confirmation screen. /// [HttpPost, ValidateAntiForgeryToken] [AllowAnonymous] public async Task Terms(Guid token, SubmitKioskTermsDto dto) { var session = await LoadSessionAsync(token); if (session == null) return View("KioskError", "Session not found."); // 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(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); // Don't fail the customer-facing page — save what we have and let staff convert manually await _unitOfWork.CompleteAsync(); } return RedirectToAction(nameof(Confirmation), new { token }); } // ── Confirmation ────────────────────────────────────────────────────────── /// Thank-you screen shown after a successful submission. [AllowAnonymous] public async Task 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(); } // ========================================================================= // STAFF REVIEW (authenticated) // ========================================================================= /// /// Lists all kiosk intake sessions for the current company — submitted, active, and expired. /// Manager or higher access required. /// [Authorize] public async Task 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, 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 // ========================================================================= /// /// Loads a KioskSession by SessionToken using ignoreQueryFilters because anonymous requests /// have no CompanyId claim, so the global tenant filter would return nothing without it. /// private async Task LoadSessionAsync(Guid token) { return await _unitOfWork.KioskSessions.FirstOrDefaultAsync( s => s.SessionToken == token && !s.IsDeleted, ignoreQueryFilters: true); } /// /// Validates that the session is still in a usable state. /// Returns false (and optionally updates status to Expired) if the session should not proceed. /// private async Task 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; } /// /// 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). /// 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. Create Job in Pending status var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId); var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending); var jobNumber = await GenerateJobNumberAsync(companyId); var job = new Job { CompanyId = companyId, CustomerId = customer!.Id, JobNumber = jobNumber, JobStatusId = pendingStatus?.Id ?? 1, SpecialInstructions = session.JobDescription, Description = $"Walk-in intake — {session.CustomerFirstName} {session.CustomerLastName}".Trim() }; await _unitOfWork.Jobs.AddAsync(job); // 4. Update session links session.LinkedCustomerId = customer.Id; session.LinkedJobId = job.Id; // will be populated after SaveChanges below await _unitOfWork.CompleteAsync(); // job.Id is now set — update session again if needed if (session.LinkedJobId == 0) { session.LinkedJobId = job.Id; await _unitOfWork.CompleteAsync(); } // 5. Fire staff notification var snippet = session.JobDescription.Length > 60 ? session.JobDescription[..60] + "…" : session.JobDescription; var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim(); await _inApp.CreateAsync( companyId, "Walk-in Intake Submitted", $"{fullName} completed their intake form — {snippet}", "KioskIntake", link: $"/Kiosk/Intakes", customerId: customer.Id); } /// /// Generates the next sequential job number using the company's configured prefix. /// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####. /// private async Task 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"; } /// /// Reads the KioskDevice cookie and parses the "{companyId}:{token}" value. /// Returns null if the cookie is absent or malformed. /// 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]); } /// Writes a long-lived HttpOnly kiosk device cookie. 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) }); } /// Removes the kiosk device cookie (deactivation). private void DeleteKioskCookie() { Response.Cookies.Delete(CookieName); } /// Returns the current authenticated user's CompanyId claim. private int GetCurrentCompanyId() { var claim = User.FindFirst("CompanyId")?.Value; return int.TryParse(claim, out int id) ? id : 0; } /// Sets ViewBag properties needed by _KioskLayout from a Company entity. private async Task PopulateKioskViewBag(Company company) { ViewBag.CompanyName = company.CompanyName; ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company.LogoFilePath) ? $"/CompanyLogo/{company.Id}" : null; ViewBag.WelcomeUrl = "/Kiosk/Welcome"; await Task.CompletedTask; } /// Loads the company from a session's CompanyId and populates ViewBag. 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; } }