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:
2026-05-13 16:25:27 -04:00
parent 27bfd4db4d
commit 6a918c2afc
41 changed files with 24265 additions and 23 deletions
@@ -123,6 +123,16 @@ public class Company : BaseEntity
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
// Kiosk
/// <summary>
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
/// tablet can serve the intake form without requiring a logged-in user.
/// Null = kiosk not activated. Regenerate to revoke the current device.
/// </summary>
public string? KioskActivationToken { get; set; }
// Navigation Properties
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
/// <summary>
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
/// </summary>
public string? PublicViewToken { get; set; }
// Online payments (Stripe Connect)
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
@@ -0,0 +1,51 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents one customer self-service intake session — either completed on the front-desk tablet
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
/// </summary>
public class KioskSession : BaseEntity
{
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
public Guid SessionToken { get; set; } = Guid.NewGuid();
public KioskSessionType SessionType { get; set; }
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
// ── Step 1 — Contact ─────────────────────────────────────────────────────
public string CustomerFirstName { get; set; } = string.Empty;
public string CustomerLastName { get; set; } = string.Empty;
public string CustomerPhone { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public bool IsReturningCustomer { get; set; }
// ── Step 2 — Job Description ──────────────────────────────────────────────
public string JobDescription { get; set; } = string.Empty;
public string? HowDidYouHearAboutUs { get; set; }
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
public bool AgreedToTerms { get; set; }
public DateTime? AgreedToTermsAt { get; set; }
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
public bool SmsOptIn { get; set; }
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
public string? SignatureDataBase64 { get; set; }
// ── Outcome ───────────────────────────────────────────────────────────────
public int? LinkedCustomerId { get; set; }
public int? LinkedJobId { get; set; }
public DateTime? SubmittedAt { get; set; }
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
public DateTime ExpiresAt { get; set; }
// ── Remote-only ───────────────────────────────────────────────────────────
public string? RemoteLinkEmail { get; set; }
public DateTime? RemoteLinkSentAt { get; set; }
// ── Navigation ────────────────────────────────────────────────────────────
public virtual Customer? LinkedCustomer { get; set; }
public virtual Job? LinkedJob { get; set; }
}