Initial commit
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[ApiController]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Webhook)]
|
||||
public class WebhooksController : ControllerBase
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<WebhooksController> _logger;
|
||||
|
||||
// CTIA-standard opt-out keywords (case-insensitive)
|
||||
private static readonly HashSet<string> StopKeywords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"
|
||||
};
|
||||
|
||||
// CTIA-standard help keywords
|
||||
private static readonly HashSet<string> HelpKeywords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"HELP", "INFO"
|
||||
};
|
||||
|
||||
public WebhooksController(
|
||||
ApplicationDbContext context,
|
||||
IConfiguration configuration,
|
||||
ILogger<WebhooksController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("Webhooks/TwilioSms")]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public async Task<IActionResult> 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 (HelpKeywords.Contains(body))
|
||||
return await HandleHelpAsync(payload.From);
|
||||
|
||||
// Unrecognized message — silent acknowledge
|
||||
return TwimlEmpty();
|
||||
}
|
||||
|
||||
// ── STOP ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Processes a STOP keyword: opts the customer out, stamps SmsOptedOutAt, logs to
|
||||
/// NotificationLog, and returns a TwiML confirmation message.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> 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.");
|
||||
}
|
||||
|
||||
// ── HELP ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Processes a HELP keyword: logs the inbound message and returns a TwiML help response
|
||||
/// with the program description and opt-out instructions.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Writes a NotificationLog row for an inbound STOP or HELP message.</summary>
|
||||
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<string> 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";
|
||||
}
|
||||
|
||||
/// <summary>Returns an empty TwiML response (no reply sent to sender).</summary>
|
||||
private static ContentResult TwimlEmpty() =>
|
||||
new ContentResult { Content = "<Response/>", ContentType = "application/xml" };
|
||||
|
||||
/// <summary>Returns a TwiML response that sends <paramref name="message"/> as an SMS reply.</summary>
|
||||
private static ContentResult TwimlMessage(string message) =>
|
||||
new ContentResult
|
||||
{
|
||||
Content = $"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response><Message>{System.Net.WebUtility.HtmlEncode(message)}</Message></Response>",
|
||||
ContentType = "application/xml"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates the incoming Twilio webhook request using HMAC-SHA1. Skips validation when the
|
||||
/// auth token is unconfigured so the endpoint works in local development without real Twilio credentials.
|
||||
/// </summary>
|
||||
private bool ValidateTwilioRequest()
|
||||
{
|
||||
var authToken = _configuration["Twilio:AuthToken"];
|
||||
if (string.IsNullOrWhiteSpace(authToken) || authToken.StartsWith("your-"))
|
||||
{
|
||||
_logger.LogDebug("Twilio auth token not configured; skipping signature validation");
|
||||
return true;
|
||||
}
|
||||
|
||||
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<string, string>();
|
||||
foreach (var kvp in Request.Form)
|
||||
parameters[kvp.Key] = kvp.Value.ToString();
|
||||
|
||||
return new RequestValidator(authToken).Validate(url, parameters, signature);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bound from the Twilio POST form body.</summary>
|
||||
public class TwilioSmsPayload
|
||||
{
|
||||
public string? From { get; set; }
|
||||
public string? Body { get; set; }
|
||||
public string? To { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user