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
@@ -367,6 +367,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Prep-service definitions within a job template item.</summary>
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
// Customer Intake Kiosk
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
public DbSet<KioskSession> KioskSessions { get; set; }
/// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly.
@@ -746,6 +750,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Customer intake kiosk sessions — tenant-filtered + soft delete.
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<KioskSession>()
.HasIndex(e => e.SessionToken)
.IsUnique();
modelBuilder.Entity<KioskSession>()
.HasOne(k => k.LinkedCustomer)
.WithMany()
.HasForeignKey(k => k.LinkedCustomerId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<KioskSession>()
.HasOne(k => k.LinkedJob)
.WithMany()
.HasForeignKey(k => k.LinkedJobId)
.OnDelete(DeleteBehavior.SetNull);
// Account self-referencing hierarchy
modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount)