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:
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace PowderCoating.Core.Enums;
|
||||
|
||||
public enum KioskSessionType
|
||||
{
|
||||
InPerson = 0,
|
||||
Remote = 1
|
||||
}
|
||||
|
||||
public enum KioskSessionStatus
|
||||
{
|
||||
Active = 0,
|
||||
Submitted = 1,
|
||||
Expired = 2,
|
||||
Cancelled = 3
|
||||
}
|
||||
@@ -154,6 +154,9 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<GiftCertificate> GiftCertificates { get; }
|
||||
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
||||
|
||||
// Customer Intake Kiosk
|
||||
IRepository<KioskSession> KioskSessions { get; }
|
||||
|
||||
Task<int> SaveChangesAsync();
|
||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||
|
||||
|
||||
Reference in New Issue
Block a user