Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,113 @@
using Microsoft.Extensions.Configuration;
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 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.
/// </summary>
public SmsService(IConfiguration configuration, ILogger<SmsService> logger)
{
_configuration = configuration;
_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");
}
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;
}
}