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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user