using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using Twilio.Security; namespace PowderCoating.Web.Controllers; /// /// Receives inbound webhooks from Twilio (SMS status / opt-out). /// AllowAnonymous — Twilio posts from their servers, not authenticated users. /// Requests are validated using the Twilio Request Validator (HMAC-SHA1). /// [AllowAnonymous] [ApiController] [EnableRateLimiting(AppConstants.RateLimitPolicies.Webhook)] public class WebhooksController : ControllerBase { private readonly ApplicationDbContext _context; private readonly IConfiguration _configuration; private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; // CTIA-standard opt-out keywords (case-insensitive) private static readonly HashSet StopKeywords = new(StringComparer.OrdinalIgnoreCase) { "STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT" }; // CTIA-standard opt-in keywords private static readonly HashSet StartKeywords = new(StringComparer.OrdinalIgnoreCase) { "START", "YES", "UNSTOP" }; // CTIA-standard help keywords private static readonly HashSet HelpKeywords = new(StringComparer.OrdinalIgnoreCase) { "HELP", "INFO" }; public WebhooksController( ApplicationDbContext context, IConfiguration configuration, IWebHostEnvironment environment, ILogger logger) { _context = context; _configuration = configuration; _environment = environment; _logger = logger; } /// /// Receives inbound SMS messages forwarded by Twilio. Configure this endpoint as the /// "A message comes in" webhook URL in the Twilio Console for the outbound SMS number. /// Validates the request signature, then routes STOP keywords (opts customer out with /// timestamp + DB log + TwiML confirmation), HELP keywords (TwiML help reply + DB log), /// and all other messages (silent acknowledgment). Always returns TwiML — Twilio expects a /// 200 with valid TwiML; any other response triggers automatic retries. /// [HttpPost("Webhooks/TwilioSms")] [IgnoreAntiforgeryToken] public async Task TwilioSms([FromForm] TwilioSmsPayload payload) { if (!ValidateTwilioRequest()) { _logger.LogWarning("Rejected Twilio webhook: invalid signature from {IP}", HttpContext.Connection.RemoteIpAddress); return Forbid(); } if (string.IsNullOrWhiteSpace(payload.From)) return TwimlEmpty(); var body = (payload.Body ?? string.Empty).Trim(); _logger.LogInformation("Twilio inbound SMS from {From}: {Body}", payload.From, body); if (StopKeywords.Contains(body)) return await HandleStopAsync(payload.From); if (StartKeywords.Contains(body)) return await HandleStartAsync(payload.From); if (HelpKeywords.Contains(body)) return await HandleHelpAsync(payload.From); // Unrecognized message — silent acknowledge return TwimlEmpty(); } // ── STOP ────────────────────────────────────────────────────────────────── /// /// Processes a STOP keyword: opts the customer out, stamps SmsOptedOutAt, logs to /// NotificationLog, and returns a TwiML confirmation message. /// private async Task HandleStopAsync(string from) { var (customer, digits10) = await FindCustomerByPhoneAsync(from); if (customer == null) { _logger.LogWarning("Twilio STOP from {From} — no matching customer found", from); // Still send a polite confirmation even if we can't find them return TwimlMessage("You have been unsubscribed and will not receive further messages."); } var companyName = await GetCompanyNameAsync(customer.CompanyId); if (customer.NotifyBySms) { customer.NotifyBySms = false; customer.SmsOptedOutAt = DateTime.UtcNow; customer.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); _logger.LogInformation("Customer {CustomerId} opted out of SMS via STOP reply", customer.Id); } await WriteInboundLogAsync( NotificationType.SmsInboundStop, customer, from, "STOP"); return TwimlMessage( $"{companyName}: You have been unsubscribed. No further messages will be sent. " + $"Reply START to re-subscribe."); } // ── START ───────────────────────────────────────────────────────────────── /// /// Processes a START keyword: re-enables SMS for the customer, clears SmsOptedOutAt, /// logs to NotificationLog, and returns a TwiML confirmation message. /// private async Task HandleStartAsync(string from) { var (customer, digits10) = await FindCustomerByPhoneAsync(from); if (customer == null) { _logger.LogWarning("Twilio START from {From} — no matching customer found", from); return TwimlMessage("You have been re-subscribed and will receive messages again."); } var companyName = await GetCompanyNameAsync(customer.CompanyId); if (!customer.NotifyBySms) { customer.NotifyBySms = true; customer.SmsOptedOutAt = null; customer.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); _logger.LogInformation("Customer {CustomerId} re-subscribed to SMS via START reply", customer.Id); } await WriteInboundLogAsync( NotificationType.SmsInboundStart, customer, from, "START"); return TwimlMessage( $"{companyName}: You have been re-subscribed and will receive messages again. " + $"Reply STOP to unsubscribe at any time."); } // ── HELP ────────────────────────────────────────────────────────────────── /// /// Processes a HELP keyword: logs the inbound message and returns a TwiML help response /// with the program description and opt-out instructions. /// private async Task HandleHelpAsync(string from) { var (customer, _) = await FindCustomerByPhoneAsync(from); string companyName; if (customer != null) { companyName = await GetCompanyNameAsync(customer.CompanyId); await WriteInboundLogAsync( NotificationType.SmsInboundHelp, customer, from, "HELP"); } else { companyName = "Powder Coating Logix"; _logger.LogWarning("Twilio HELP from {From} — no matching customer found", from); } return TwimlMessage( $"{companyName}: Job status & pickup alerts. Msg & data rates may apply. " + $"Reply STOP to unsubscribe. Contact us directly for additional help."); } // ── Helpers ─────────────────────────────────────────────────────────────── /// /// Finds a customer by matching the last 10 digits of the inbound phone number against /// MobilePhone and Phone fields (handles E.164, formatted, and local number variations). /// When the same phone number exists across multiple tenant companies, breaks the tie by /// choosing the customer whose company most recently sent them an outbound SMS — it is /// nearly impossible for two shops to be texting the same number about a job on the same day. /// Falls back to the alphabetically first match if no outbound SMS log exists for any candidate. /// private async Task<(Customer? customer, string digits10)> FindCustomerByPhoneAsync(string from) { var digits10 = from.Length >= 10 ? from[^10..] : from; var candidates = await _context.Customers .IgnoreQueryFilters() .Where(c => !c.IsDeleted && ( (c.MobilePhone != null && c.MobilePhone.Replace("-", "").Replace("(", "").Replace(")", "").Replace(" ", "").Replace("+", "").EndsWith(digits10)) || (c.Phone != null && c.Phone.Replace("-", "").Replace("(", "").Replace(")", "").Replace(" ", "").Replace("+", "").EndsWith(digits10)) )) .ToListAsync(); if (candidates.Count == 0) return (null, digits10); if (candidates.Count == 1) return (candidates[0], digits10); // Multiple tenants share this phone number — pick the one whose company most recently // sent an outbound SMS to this number (tiebreaker: most recent NotificationLog entry). var candidateIds = candidates.Select(c => c.Id).ToList(); var mostRecentLog = await _context.NotificationLogs .IgnoreQueryFilters() .Where(l => l.Channel == NotificationChannel.Sms && l.Status == NotificationStatus.Sent && l.CustomerId.HasValue && candidateIds.Contains(l.CustomerId.Value)) .OrderByDescending(l => l.SentAt) .Select(l => new { l.CustomerId, l.SentAt }) .FirstOrDefaultAsync(); if (mostRecentLog?.CustomerId != null) { var match = candidates.FirstOrDefault(c => c.Id == mostRecentLog.CustomerId.Value); if (match != null) { _logger.LogInformation( "Multi-tenant phone match resolved to customer {CustomerId} via most recent outbound SMS at {SentAt}", match.Id, mostRecentLog.SentAt); return (match, digits10); } } // No outbound log found for any candidate — fall back to first result _logger.LogWarning( "Multi-tenant phone match for {Digits} could not be resolved by SMS log; using first candidate (CustomerId {CustomerId})", digits10, candidates[0].Id); return (candidates[0], digits10); } /// Writes a NotificationLog row for an inbound STOP or HELP message. private async Task WriteInboundLogAsync( NotificationType type, Customer customer, string fromPhone, string body) { try { _context.NotificationLogs.Add(new NotificationLog { Channel = NotificationChannel.Sms, NotificationType = type, Status = NotificationStatus.Sent, RecipientName = GetCustomerDisplayName(customer), Recipient = fromPhone, Message = body, SentAt = DateTime.UtcNow, CustomerId = customer.Id, CompanyId = customer.CompanyId }); await _context.SaveChangesAsync(); } catch (Exception ex) { _logger.LogError(ex, "Failed to write inbound SMS log for customer {CustomerId}", customer.Id); } } private async Task GetCompanyNameAsync(int companyId) { var company = await _context.Companies .AsNoTracking() .IgnoreQueryFilters() .FirstOrDefaultAsync(c => c.Id == companyId); return company?.CompanyName ?? "Powder Coating Logix"; } private static string GetCustomerDisplayName(Customer customer) { var contact = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); if (!string.IsNullOrEmpty(contact)) return contact; return customer.CompanyName ?? "Customer"; } /// Returns an empty TwiML response (no reply sent to sender). private static ContentResult TwimlEmpty() => new ContentResult { Content = "", ContentType = "application/xml" }; /// Returns a TwiML response that sends as an SMS reply. private static ContentResult TwimlMessage(string message) => new ContentResult { Content = $"{System.Net.WebUtility.HtmlEncode(message)}", ContentType = "application/xml" }; /// /// Validates the incoming Twilio webhook request using HMAC-SHA1. Local development may skip /// validation when the auth token is unconfigured, but shared environments fail closed. /// private bool ValidateTwilioRequest() { var authToken = _configuration["Twilio:AuthToken"]; if (string.IsNullOrWhiteSpace(authToken) || authToken.StartsWith("your-")) { if (_environment.IsDevelopment()) { _logger.LogDebug("Twilio auth token not configured in development; skipping signature validation"); return true; } _logger.LogError("Twilio auth token is not configured; rejecting webhook request"); return false; } var signature = Request.Headers["X-Twilio-Signature"].FirstOrDefault() ?? string.Empty; if (string.IsNullOrEmpty(signature)) { _logger.LogWarning("Twilio webhook missing X-Twilio-Signature header"); return false; } var url = $"{Request.Scheme}://{Request.Host}{Request.Path}"; var parameters = new Dictionary(); foreach (var kvp in Request.Form) parameters[kvp.Key] = kvp.Value.ToString(); return new RequestValidator(authToken).Validate(url, parameters, signature); } } /// Bound from the Twilio POST form body. public class TwilioSmsPayload { public string? From { get; set; } public string? Body { get; set; } public string? To { get; set; } }