Add SMS quote approval, fix Twilio credentials, fix passkey post-login redirect

- Add 'Send Quote via SMS' button on quote details page that sends the approval
  link to the customer via SMS (respects NotifyBySms, handles prospects via ProspectPhone)
- Reuses existing valid approval token rather than regenerating, so a previously
  emailed link stays valid when SMS is also sent
- Fix Twilio appsettings.json placeholders (real credentials moved to gitignored
  appsettings.Development.json)
- Fix passkey login ignoring ReturnUrl: biometric login on the login page now
  respects the form's ReturnUrl hidden field so QR-code and deep-link flows
  redirect correctly after authentication instead of always going to the dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 09:38:55 -04:00
parent d9bf80cc9a
commit a2d48c8b58
6 changed files with 238 additions and 5 deletions
@@ -168,6 +168,89 @@ public class NotificationService : INotificationService
}
}
/// <summary>
/// Sends the quote approval link to the customer via SMS.
/// Prospect quotes use ProspectPhone; registered customers require NotifyBySms + a phone number.
/// Returns (success, errorMessage) so the controller can surface the result.
/// </summary>
public async Task<(bool Success, string? Error)> NotifyQuoteSentSmsAsync(Quote quote)
{
try
{
var (companyName, company) = await GetCompanyAsync(quote.CompanyId);
if (!await IsSmsAllowedForCompanyAsync(company))
return (false, "SMS is not enabled for this account.");
var baseUrl = await GetBaseUrlAsync();
var approvalUrl = !string.IsNullOrEmpty(quote.ApprovalToken) && !string.IsNullOrEmpty(baseUrl)
? $"{baseUrl}/quote-approval/{quote.ApprovalToken}"
: null;
if (string.IsNullOrEmpty(approvalUrl))
return (false, "No approval link available for this quote.");
string? smsPhone;
string recipientName;
int? customerId = null;
if (quote.CustomerId == null)
{
// Prospect — use ProspectPhone; no opt-in check (they explicitly provided a phone)
smsPhone = quote.ProspectPhone;
if (string.IsNullOrWhiteSpace(smsPhone))
return (false, "No phone number on file for this prospect.");
recipientName = !string.IsNullOrWhiteSpace(quote.ProspectContactName)
? quote.ProspectContactName
: quote.ProspectCompanyName ?? "Valued Customer";
}
else
{
var customer = await _context.Customers.FindAsync(quote.CustomerId.Value);
if (customer == null) return (false, "Customer not found.");
recipientName = GetCustomerDisplayName(customer);
customerId = customer.Id;
smsPhone = customer.MobilePhone ?? customer.Phone;
if (string.IsNullOrWhiteSpace(smsPhone))
return (false, $"{recipientName} has no phone number on file.");
if (!customer.NotifyBySms)
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.QuoteSent,
recipientName, smsPhone, quote.CompanyId, customerId: customer.Id, quoteId: quote.Id));
return (false, $"{recipientName} has SMS notifications disabled.");
}
}
var message = $"{companyName}: Quote {quote.QuoteNumber} for {quote.Total:C} is ready for your review. Approve or decline: {approvalUrl} Reply STOP to opt out.";
var (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.QuoteSent,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = recipientName,
Recipient = smsPhone,
Message = message,
ErrorMessage = error,
SentAt = DateTime.UtcNow,
CustomerId = customerId,
QuoteId = quote.Id,
CompanyId = quote.CompanyId
});
return (success, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyQuoteSentSmsAsync failed for quote {QuoteId}", quote.Id);
return (false, "An unexpected error occurred while sending the SMS.");
}
}
/// <summary>
/// Sends a confirmation email to the customer when a quote is approved (either by the
/// customer via the online portal or internally by staff). Prospect quotes (no CustomerId)