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:
@@ -310,48 +310,6 @@ public class NotificationService : INotificationService
|
||||
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||||
}
|
||||
|
||||
// SMS only for READY_FOR_PICKUP
|
||||
var smsFeatureEnabled = string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (smsFeatureEnabled && newStatusCode == "READY_FOR_PICKUP")
|
||||
{
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
var smsValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||||
};
|
||||
|
||||
var smsMessage = await GetRenderedSmsAsync(
|
||||
job.CompanyId, NotificationType.JobReadyForPickup, smsValues,
|
||||
$"{companyName}: Job {job.JobNumber} is ready for pickup! Reply STOP to opt out.");
|
||||
|
||||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Sms,
|
||||
NotificationType = NotificationType.JobReadyForPickup,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = customerName,
|
||||
Recipient = smsPhone,
|
||||
Message = smsMessage,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
JobId = job.Id,
|
||||
CompanyId = job.CompanyId
|
||||
});
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(customer.MobilePhone ?? customer.Phone))
|
||||
{
|
||||
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobReadyForPickup,
|
||||
customerName, customer.MobilePhone ?? customer.Phone!, job.CompanyId,
|
||||
customerId: customer.Id, jobId: job.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -364,7 +322,7 @@ public class NotificationService : INotificationService
|
||||
/// because the completion message may include a link to pay the invoice online, and uses a
|
||||
/// dedicated JobCompleted template with different wording than a mid-workflow status change.
|
||||
/// </summary>
|
||||
public async Task NotifyJobCompletedAsync(Job job)
|
||||
public async Task NotifyJobCompletedAsync(Job job, bool suppressSms = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -419,45 +377,48 @@ public class NotificationService : INotificationService
|
||||
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||||
}
|
||||
|
||||
// SMS
|
||||
var smsOn = string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (smsOn)
|
||||
// SMS — skip when the caller (Admin/Manager) will handle it via the compose modal
|
||||
if (!suppressSms)
|
||||
{
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
|
||||
if (smsAllowed)
|
||||
{
|
||||
var smsValues = new Dictionary<string, string>
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||||
};
|
||||
var smsValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||||
};
|
||||
|
||||
var smsMessage = await GetRenderedSmsAsync(
|
||||
job.CompanyId, NotificationType.JobCompleted, smsValues,
|
||||
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
|
||||
var smsMessage = await GetRenderedSmsAsync(
|
||||
job.CompanyId, NotificationType.JobCompleted, smsValues,
|
||||
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
|
||||
|
||||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
|
||||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Sms,
|
||||
NotificationType = NotificationType.JobCompleted,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = customerName,
|
||||
Recipient = smsPhone,
|
||||
Message = smsMessage,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
JobId = job.Id,
|
||||
CompanyId = job.CompanyId
|
||||
});
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
Channel = NotificationChannel.Sms,
|
||||
NotificationType = NotificationType.JobCompleted,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = customerName,
|
||||
Recipient = smsPhone,
|
||||
Message = smsMessage,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
JobId = job.Id,
|
||||
CompanyId = job.CompanyId
|
||||
});
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
|
||||
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||||
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
|
||||
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,6 +428,90 @@ public class NotificationService : INotificationService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the job-completed SMS text without sending it, for admin review before manual send.
|
||||
/// Returns null when SMS is not gated on for the company or the customer has not opted in to SMS.
|
||||
/// </summary>
|
||||
public async Task<string?> RenderJobCompletedSmsAsync(Job job)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
|
||||
if (customer == null) return null;
|
||||
|
||||
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
|
||||
if (!await IsSmsAllowedForCompanyAsync(company)) return null;
|
||||
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (!customer.NotifyBySms || string.IsNullOrWhiteSpace(smsPhone)) return null;
|
||||
|
||||
var customerName = GetCustomerDisplayName(customer);
|
||||
var smsValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||||
};
|
||||
|
||||
return await GetRenderedSmsAsync(
|
||||
job.CompanyId, NotificationType.JobCompleted, smsValues,
|
||||
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RenderJobCompletedSmsAsync failed for job {JobId}", job.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
|
||||
/// Appends opt-out instructions if not already present, then sends and logs the result.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
|
||||
if (customer == null) return (false, "Customer not found.");
|
||||
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (string.IsNullOrWhiteSpace(smsPhone)) return (false, "Customer has no phone number on file.");
|
||||
|
||||
var (_, company) = await GetCompanyAsync(job.CompanyId);
|
||||
if (!await IsSmsAllowedForCompanyAsync(company)) return (false, "SMS is not enabled for this company.");
|
||||
|
||||
// Ensure TCPA-required opt-out language is present
|
||||
const string stopSuffix = "Reply STOP to opt out.";
|
||||
if (!message.Contains("STOP", StringComparison.OrdinalIgnoreCase))
|
||||
message = message.TrimEnd() + " " + stopSuffix;
|
||||
|
||||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Sms,
|
||||
NotificationType = NotificationType.JobCompleted,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = GetCustomerDisplayName(customer),
|
||||
Recipient = smsPhone,
|
||||
Message = message,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
JobId = job.Id,
|
||||
CompanyId = job.CompanyId
|
||||
});
|
||||
|
||||
return (success, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SendJobSmsAsync failed for job {JobId}", job.Id);
|
||||
return (false, "An unexpected error occurred while sending the SMS.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the invoice to the customer with the PDF attached. When Stripe Connect is enabled for
|
||||
/// the company, includes a "Pay Online" button linking to the Stripe-hosted payment page
|
||||
@@ -801,13 +846,13 @@ public class NotificationService : INotificationService
|
||||
/// </summary>
|
||||
public async Task NotifySmsConsentGrantedAsync(Customer customer)
|
||||
{
|
||||
if (!string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase)) return;
|
||||
try
|
||||
{
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (string.IsNullOrWhiteSpace(smsPhone)) return;
|
||||
|
||||
var (companyName, _) = await GetCompanyAsync(customer.CompanyId);
|
||||
var (companyName, company) = await GetCompanyAsync(customer.CompanyId);
|
||||
if (!await IsSmsAllowedForCompanyAsync(company)) return;
|
||||
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
@@ -971,10 +1016,6 @@ public class NotificationService : INotificationService
|
||||
"Job {{jobNumber}} Ready for Pickup — {{companyName}}",
|
||||
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is ready for pickup!</p><p>Thank you for choosing {{companyName}}.</p>"
|
||||
),
|
||||
[(NotificationType.JobReadyForPickup, NotificationChannel.Sms)] = (
|
||||
null,
|
||||
"{{companyName}}: Job {{jobNumber}} is ready for pickup! Reply STOP to opt out."
|
||||
),
|
||||
[(NotificationType.JobCompleted, NotificationChannel.Email)] = (
|
||||
"Job {{jobNumber}} Complete — {{companyName}}",
|
||||
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is complete. Final price: <strong>{{finalPrice}}</strong>. It is now ready for pickup.</p><p>Thank you for choosing {{companyName}}.</p>"
|
||||
@@ -1121,6 +1162,28 @@ public class NotificationService : INotificationService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true only when all four gating tiers allow SMS for this company:
|
||||
/// platform kill-switch on → not SuperAdmin-disabled → plan AllowSms → company opted in.
|
||||
/// </summary>
|
||||
private async Task<bool> IsSmsAllowedForCompanyAsync(Company? company)
|
||||
{
|
||||
var platformOn = string.Equals(
|
||||
await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled),
|
||||
"true", StringComparison.OrdinalIgnoreCase);
|
||||
if (!platformOn) return false;
|
||||
|
||||
if (company == null) return false;
|
||||
if (company.SmsDisabledByAdmin) return false;
|
||||
|
||||
var planConfig = await _context.SubscriptionPlanConfigs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
if (planConfig?.AllowSms != true) return false;
|
||||
|
||||
return company.SmsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the company entity and its display name. Returns a safe fallback name
|
||||
/// so templates never render blank company names even if the row is missing.
|
||||
|
||||
Reference in New Issue
Block a user