Add invoice SMS notifications and customer intake kiosk
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>
This commit is contained in:
@@ -0,0 +1,667 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user