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:
@@ -165,6 +165,9 @@ public class UpdateCompanyDto
|
||||
public bool? OnlinePaymentsOverride { get; set; }
|
||||
public bool? AccountingOverride { get; set; }
|
||||
|
||||
/// <summary>When true, SuperAdmin has force-disabled SMS for this company regardless of plan or company settings.</summary>
|
||||
public bool SmsDisabledByAdmin { get; set; }
|
||||
|
||||
public string? TimeZone { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,26 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
public decimal OnlinePaymentSurchargeValue { get; set; }
|
||||
public bool OnlineSurchargeAcknowledged { get; set; }
|
||||
public bool AllowOnlinePayments { get; set; }
|
||||
|
||||
// SMS gating
|
||||
public bool AllowSms { get; set; }
|
||||
public bool SmsEnabled { get; set; }
|
||||
public bool SmsDisabledByAdmin { get; set; }
|
||||
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
|
||||
public bool HasCurrentSmsAgreement { get; set; }
|
||||
public string SmsTermsVersion { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for the company admin SMS opt-in/out toggle.
|
||||
/// When enabling for the first time (or after a terms version change), AgreedToTerms must
|
||||
/// be true and TermsVersion must match <c>AppConstants.SmsTermsVersion</c>.
|
||||
/// </summary>
|
||||
public class UpdateSmsPreferencesDto
|
||||
{
|
||||
public bool SmsEnabled { get; set; }
|
||||
public bool AgreedToTerms { get; set; }
|
||||
public string? TermsVersion { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -52,6 +52,10 @@ public class JobDto
|
||||
public bool RequiresCustomerApproval { get; set; }
|
||||
public bool IsCustomerApproved { get; set; }
|
||||
|
||||
// Customer SMS opt-in — used for SMS compose modal on job details
|
||||
public bool CustomerNotifyBySms { get; set; }
|
||||
public string? CustomerMobilePhone { get; set; }
|
||||
|
||||
// Job Completion Details
|
||||
public decimal? ActualTimeSpentHours { get; set; }
|
||||
|
||||
@@ -380,6 +384,13 @@ public class CompleteJobDto
|
||||
public bool SendEmailToCustomer { get; set; } = false;
|
||||
}
|
||||
|
||||
// DTO for the Admin/Manager compose-before-send SMS endpoint
|
||||
public class SendJobSmsRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// DTO for tracking actual powder usage per coat
|
||||
public class JobItemCoatUsageDto
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ public class SubscriptionPlanConfigDto
|
||||
public bool AllowAiPhotoQuotes { get; set; }
|
||||
public bool AllowAiInventoryAssist { get; set; }
|
||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||
public bool AllowSms { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -72,6 +73,7 @@ public class UpdateSubscriptionPlanConfigDto
|
||||
public bool AllowAiPhotoQuotes { get; set; }
|
||||
public bool AllowAiInventoryAssist { get; set; }
|
||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||
public bool AllowSms { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
@@ -23,8 +23,23 @@ public interface INotificationService
|
||||
|
||||
/// <summary>
|
||||
/// Notify customer when a job is completed and ready for pickup.
|
||||
/// When <paramref name="suppressSms"/> is true the SMS is skipped so an admin can review
|
||||
/// the message via <see cref="RenderJobCompletedSmsAsync"/> before sending manually.
|
||||
/// </summary>
|
||||
Task NotifyJobCompletedAsync(Job job);
|
||||
Task NotifyJobCompletedAsync(Job job, bool suppressSms = false);
|
||||
|
||||
/// <summary>
|
||||
/// Renders the job-completed SMS text for admin preview without sending it.
|
||||
/// Returns null when SMS is not allowed for the company or the customer has not opted in.
|
||||
/// </summary>
|
||||
Task<string?> RenderJobCompletedSmsAsync(Job job);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
|
||||
/// Appends "Reply STOP to opt out." if not already present, sends, and writes a NotificationLog row.
|
||||
/// Returns (success, errorMessage).
|
||||
/// </summary>
|
||||
Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a welcome/confirmation SMS after staff records verbal SMS consent.
|
||||
|
||||
@@ -57,7 +57,13 @@ public class JobProfile : Profile
|
||||
.ForMember(dest => dest.OriginalJobNumber,
|
||||
opt => opt.MapFrom(src => src.OriginalJob != null ? src.OriginalJob.JobNumber : null))
|
||||
.ForMember(dest => dest.IntakeCheckedByName,
|
||||
opt => opt.MapFrom(src => src.IntakeCheckedBy != null ? src.IntakeCheckedBy.FullName : null));
|
||||
opt => opt.MapFrom(src => src.IntakeCheckedBy != null ? src.IntakeCheckedBy.FullName : null))
|
||||
.ForMember(dest => dest.CustomerNotifyBySms,
|
||||
opt => opt.MapFrom(src => src.Customer != null && src.Customer.NotifyBySms))
|
||||
.ForMember(dest => dest.CustomerMobilePhone,
|
||||
opt => opt.MapFrom(src => src.Customer != null
|
||||
? (src.Customer.MobilePhone ?? src.Customer.Phone)
|
||||
: null));
|
||||
|
||||
// JobTimeEntry → JobTimeEntryDto
|
||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||
|
||||
Reference in New Issue
Block a user