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; /// /// 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; private readonly ICompanyLogoService _logoService; private readonly IMemoryCache _cache; private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}"; /// Initialises all dependencies for the kiosk controller. public KioskController( IUnitOfWork unitOfWork, IMapper mapper, ILookupCacheService lookupCache, IInAppNotificationService inApp, IEmailService emailService, IHubContext kioskHub, ILogger 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) // ========================================================================= /// /// 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. /// [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(); } /// /// 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). /// [AllowAnonymous, HttpGet] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public async Task 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) // ========================================================================= /// /// Staff calls this (authenticated) from the Customer Details page to push an SMS /// consent request to the front-desk kiosk tablet. Stores the customer ID in /// IMemoryCache under a company-scoped key; the kiosk's PollSession endpoint picks /// it up and returns smsConsentPending so the tablet can navigate to the consent page. /// The cache entry expires in 10 minutes in case the customer never approaches the tablet. /// [HttpPost, ValidateAntiForgeryToken] public async Task PushSmsConsent(int customerId) { var customer = await _unitOfWork.Customers.GetByIdAsync(customerId); if (customer == null) return Json(new { success = false, message = "Customer not found." }); if (customer.NotifyBySms) return Json(new { success = false, message = "Customer has already given SMS consent." }); var companyId = customer.CompanyId; var name = !string.IsNullOrWhiteSpace(customer.ContactFirstName) ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() : customer.CompanyName ?? "Customer"; _cache.Set(SmsConsentCacheKey(companyId), (customerId, name), new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }); _logger.LogInformation("SMS consent pushed to kiosk for customer {CustomerId} by staff", customerId); return Json(new { success = true }); } /// /// 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. /// [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 }); } /// /// Displays the full-screen SMS consent form on the kiosk tablet (anonymous, kiosk layout). /// Loads the customer by ID with ignoreQueryFilters because the kiosk has no tenant context. /// [AllowAnonymous] public async Task SmsConsent(int 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); } /// /// 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. /// [AllowAnonymous, HttpPost] public async Task 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"); } /// /// 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. /// [AllowAnonymous] [HttpGet, ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] public async Task 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) // ========================================================================= /// 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("Intake/Contact", 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("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 ─────────────────────────────────────────────── /// 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("Intake/Job", 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("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 ─────────────────────────────────────────────── /// 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("Intake/Terms", 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."); // 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 ────────────────────────────────────────────────────────── /// 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("Intake/Confirmation"); } // ========================================================================= // 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, 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 // ========================================================================= /// /// 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. 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); } /// /// 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. /// private async Task 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"; } /// /// 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.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"; } /// 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; // 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; } }