Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/WebhooksController.cs
T
spouliot 3ff6a96bc8 Add SMS START/re-subscribe handling to Twilio webhook
Customers who replied STOP by mistake can now reply START, YES, or
UNSTOP to automatically re-enable their SMS opt-in — no staff action
needed. Adds SmsInboundStart notification type, HandleStartAsync in
WebhooksController, and updates AI knowledge base and help docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:09:49 -04:00

363 lines
15 KiB
C#

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 IWebHostEnvironment _environment;
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 opt-in keywords
private static readonly HashSet<string> StartKeywords = new(StringComparer.OrdinalIgnoreCase)
{
"START", "YES", "UNSTOP"
};
// CTIA-standard help keywords
private static readonly HashSet<string> HelpKeywords = new(StringComparer.OrdinalIgnoreCase)
{
"HELP", "INFO"
};
public WebhooksController(
ApplicationDbContext context,
IConfiguration configuration,
IWebHostEnvironment environment,
ILogger<WebhooksController> logger)
{
_context = context;
_configuration = configuration;
_environment = environment;
_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 (StartKeywords.Contains(body))
return await HandleStartAsync(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.");
}
// ── START ─────────────────────────────────────────────────────────────────
/// <summary>
/// Processes a START keyword: re-enables SMS for the customer, clears SmsOptedOutAt,
/// logs to NotificationLog, and returns a TwiML confirmation message.
/// </summary>
private async Task<IActionResult> 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 ──────────────────────────────────────────────────────────────────
/// <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. Local development may skip
/// validation when the auth token is unconfigured, but shared environments fail closed.
/// </summary>
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<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; }
}