Add SMS gating, TCPA terms agreement, and compose-before-send modal

- Three-tier SMS gate: platform kill-switch → admin force-disable → plan AllowSms → company opt-in
- CompanySmsAgreement entity records admin acceptance of TCPA terms with IP, user agent, and terms version
- SMS terms of service modal on Company Settings with versioned re-agreement (AppConstants.SmsTermsVersion)
- Dev redirect: non-production SMS routed to Twilio:DevRedirectPhone to protect real customer numbers
- Removed redundant Ready for Pickup SMS (Job Completed covers it)
- Role-based compose modal on job completion: Admin/Manager reviews and edits before send; ShopFloor auto-sends
- Send SMS button on job details for ad-hoc messages (Admin/Manager only)
- SendJobSmsAsync auto-appends STOP opt-out language if missing
- Migrations: AddSmsGating, AddCompanySmsAgreement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 22:29:39 -04:00
parent 2b89fcf483
commit 6569d9c4ea
32 changed files with 19855 additions and 106 deletions
@@ -0,0 +1,33 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Immutable audit record of a company admin accepting the SMS terms of service.
/// One record is written each time a user accepts (including re-accepts after a terms update).
/// The most recent record whose <see cref="TermsVersion"/> matches
/// <c>AppConstants.SmsTermsVersion</c> is the authoritative acceptance for that company.
/// Never soft-deleted — this is a legal audit trail.
/// </summary>
public class CompanySmsAgreement : BaseEntity
{
/// <summary>The Identity user ID of the admin who clicked "I Agree".</summary>
public string AgreedByUserId { get; set; } = string.Empty;
/// <summary>Display name snapshot of the user at the time of agreement (for audit readability after user changes).</summary>
public string AgreedByUserName { get; set; } = string.Empty;
/// <summary>UTC timestamp of acceptance.</summary>
public DateTime AgreedAt { get; set; }
/// <summary>Client IP address at the time of acceptance. Stored for legal/fraud purposes.</summary>
public string? IpAddress { get; set; }
/// <summary>HTTP User-Agent header at the time of acceptance.</summary>
public string? UserAgent { get; set; }
/// <summary>
/// The version of the SMS terms that was accepted (matches <c>AppConstants.SmsTermsVersion</c>
/// at the moment of acceptance). When the platform bumps this version, existing records become
/// stale and the company must re-accept.
/// </summary>
public string TermsVersion { get; set; } = string.Empty;
}