6a918c2afc
Invoice SMS:
- Send Invoice modal now prompts Email/SMS/Both based on customer contact data
- New /invoice/{token} customer-facing view page with full line items and pay button
- PublicViewToken (permanent) added to Invoice; separate from expiring PaymentLinkToken
- InvoiceSent SMS default template added; customizable via Notification Templates settings
- {{viewUrl}} placeholder documented in template editor
Customer Intake Kiosk:
- Tablet kiosk flow: Contact → Job → Terms/Signature → Confirmation
- Remote link mode for off-site customers (lighter form, no signature)
- KioskHub (AllowAnonymous SignalR) for staff-to-tablet push without login
- Staff activates tablet via cookie; sends remote link manually
- Submitted sessions create Customer + Job automatically; fires in-app notification
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
668 lines
27 KiB
C#
668 lines
27 KiB
C#
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;
|
|
|
|
/// <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;
|
|
|
|
/// <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)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_mapper = mapper;
|
|
_lookupCache = lookupCache;
|
|
_inApp = inApp;
|
|
_emailService = emailService;
|
|
_kioskHub = kioskHub;
|
|
_logger = logger;
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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 connects to KioskHub and listens for StartIntake events.
|
|
/// </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();
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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(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(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(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(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(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.");
|
|
|
|
// 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 ──────────────────────────────────────────────────────────
|
|
|
|
/// <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();
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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,
|
|
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. 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);
|
|
}
|
|
|
|
/// <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.CompanyName = company.CompanyName;
|
|
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company.LogoFilePath)
|
|
? $"/CompanyLogo/{company.Id}"
|
|
: null;
|
|
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|