6569d9c4ea
- 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>
139 lines
6.2 KiB
C#
139 lines
6.2 KiB
C#
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using PowderCoating.Application.Interfaces;
|
|
using Twilio;
|
|
using Twilio.Rest.Api.V2010.Account;
|
|
using Twilio.Types;
|
|
|
|
namespace PowderCoating.Infrastructure.Services;
|
|
|
|
public class SmsService : ISmsService
|
|
{
|
|
private readonly IConfiguration _configuration;
|
|
private readonly IWebHostEnvironment _env;
|
|
private readonly ILogger<SmsService> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of <see cref="SmsService"/>. Twilio credentials are read
|
|
/// per call (not cached here) because they may be rotated without restarting the application,
|
|
/// and Twilio's client initialization (<c>TwilioClient.Init</c>) is idempotent and cheap.
|
|
/// <para>
|
|
/// In non-production environments, all outbound messages are redirected to
|
|
/// <c>Twilio:DevRedirectPhone</c> (if configured) so real customer numbers are never
|
|
/// texted during development or staging. The original destination is prepended to the
|
|
/// message body for traceability.
|
|
/// </para>
|
|
/// </summary>
|
|
public SmsService(IConfiguration configuration, IWebHostEnvironment env, ILogger<SmsService> logger)
|
|
{
|
|
_configuration = configuration;
|
|
_env = env;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends an SMS message via the Twilio REST API and returns a success/error tuple.
|
|
/// The method short-circuits with a logged warning (not an exception) when Twilio is not
|
|
/// configured, so callers in notification workflows are not broken in development
|
|
/// environments where Twilio credentials are absent. Phone numbers are normalized via
|
|
/// <see cref="NormalizePhone"/> before sending because users enter phone numbers in many
|
|
/// formats (with dashes, parentheses, spaces, or a leading "1") and Twilio requires strict
|
|
/// E.164 format. The method returns the Twilio message SID on success so callers can log
|
|
/// it for delivery tracking. Carrier regulations (TCPA in the US) require that the first
|
|
/// SMS to a new number include opt-out instructions ("Reply STOP to unsubscribe") — this
|
|
/// is enforced in the message body by the calling controller or notification service before
|
|
/// this method is invoked; <see cref="SmsService"/> itself is transport-only and does not
|
|
/// inspect or modify the message body.
|
|
/// </summary>
|
|
public async Task<(bool Success, string? ErrorMessage)> SendSmsAsync(string toPhone, string message)
|
|
{
|
|
var accountSid = _configuration["Twilio:AccountSid"];
|
|
var authToken = _configuration["Twilio:AuthToken"];
|
|
var fromNumber = _configuration["Twilio:FromNumber"];
|
|
|
|
if (string.IsNullOrWhiteSpace(accountSid) || accountSid.StartsWith("your-") ||
|
|
string.IsNullOrWhiteSpace(authToken) || authToken.StartsWith("your-") ||
|
|
string.IsNullOrWhiteSpace(fromNumber) || fromNumber.Contains("XXXXXXXXXX"))
|
|
{
|
|
_logger.LogWarning("Twilio is not configured. SMS to {ToPhone} skipped.", toPhone);
|
|
return (false, "Twilio not configured");
|
|
}
|
|
|
|
var normalizedPhone = NormalizePhone(toPhone);
|
|
if (string.IsNullOrEmpty(normalizedPhone))
|
|
{
|
|
_logger.LogWarning("Invalid phone number: {ToPhone}", toPhone);
|
|
return (false, "Invalid phone number");
|
|
}
|
|
|
|
// Non-production guard: redirect all messages to the dev phone so real customers
|
|
// are never texted outside of production. Double-gated on environment name AND
|
|
// the config value so a misconfigured prod deploy can't accidentally redirect.
|
|
var devRedirect = _configuration["Twilio:DevRedirectPhone"];
|
|
if (!_env.IsProduction() && !string.IsNullOrWhiteSpace(devRedirect))
|
|
{
|
|
var devPhone = NormalizePhone(devRedirect);
|
|
if (!string.IsNullOrEmpty(devPhone))
|
|
{
|
|
_logger.LogWarning("Non-production environment: redirecting SMS from {Original} to dev number {Dev}", normalizedPhone, devPhone);
|
|
message = $"[DEV → {normalizedPhone}] {message}";
|
|
normalizedPhone = devPhone;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
TwilioClient.Init(accountSid, authToken);
|
|
|
|
var smsMessage = await MessageResource.CreateAsync(
|
|
to: new PhoneNumber(normalizedPhone),
|
|
from: new PhoneNumber(fromNumber),
|
|
body: message);
|
|
|
|
_logger.LogInformation("SMS sent to {ToPhone}: SID={Sid}", normalizedPhone, smsMessage.Sid);
|
|
return (true, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception sending SMS to {ToPhone}", normalizedPhone);
|
|
return (false, ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes a phone number string to E.164 format (e.g., "+12025551234") required by
|
|
/// Twilio. Strips all non-digit characters, then applies three rules in priority order:
|
|
/// an 11-digit string starting with "1" is assumed to be a North American number with
|
|
/// country code already included; a 10-digit string is assumed to be a US/Canada number
|
|
/// and gets "+1" prepended; a string that already starts with "+" and has more than 10
|
|
/// digits is treated as an international number and reformatted with "+". Numbers that
|
|
/// do not match any pattern return null, causing <see cref="SendSmsAsync"/> to abort with
|
|
/// a warning rather than sending to a malformed number (which would generate a Twilio 400
|
|
/// error and count against the account's error rate).
|
|
/// </summary>
|
|
private static string? NormalizePhone(string phone)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(phone))
|
|
return null;
|
|
|
|
// Strip everything except digits
|
|
var digits = new string(phone.Where(char.IsDigit).ToArray());
|
|
|
|
// Already E.164 with country code
|
|
if (digits.Length == 11 && digits[0] == '1')
|
|
return "+" + digits;
|
|
|
|
// 10-digit US number — prepend +1
|
|
if (digits.Length == 10)
|
|
return "+1" + digits;
|
|
|
|
// Already formatted (e.g. +44...)
|
|
if (phone.StartsWith("+") && digits.Length > 10)
|
|
return "+" + digits;
|
|
|
|
return null;
|
|
}
|
|
}
|