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; }
}