diff --git a/src/PowderCoating.Application/Interfaces/INotificationService.cs b/src/PowderCoating.Application/Interfaces/INotificationService.cs index 019ca18..b5f691c 100644 --- a/src/PowderCoating.Application/Interfaces/INotificationService.cs +++ b/src/PowderCoating.Application/Interfaces/INotificationService.cs @@ -11,6 +11,13 @@ public interface INotificationService /// Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null); + /// + /// Sends the quote approval link to the customer via SMS. + /// Handles both registered customers (respects NotifyBySms) and prospects (ProspectPhone). + /// Returns (success, errorMessage) so the caller can surface the result to the user. + /// + Task<(bool Success, string? Error)> NotifyQuoteSentSmsAsync(Quote quote); + /// /// Notify when a quote is approved by a customer. /// diff --git a/src/PowderCoating.Infrastructure/Services/NotificationService.cs b/src/PowderCoating.Infrastructure/Services/NotificationService.cs index fceeff0..16b3e7e 100644 --- a/src/PowderCoating.Infrastructure/Services/NotificationService.cs +++ b/src/PowderCoating.Infrastructure/Services/NotificationService.cs @@ -168,6 +168,89 @@ public class NotificationService : INotificationService } } + /// + /// 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. + /// + 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."); + } + } + /// /// 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) diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index 24af915..35a7f95 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -3291,6 +3291,68 @@ public class QuotesController : Controller } } + /// + /// Sends the quote approval link to the customer via SMS. + /// Reuses the existing approval token when valid; generates a new one only when none exists or it is expired. + /// Does NOT regenerate a live token — so a previously emailed link stays valid. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SendQuoteApprovalSms(int id) + { + try + { + var quote = await _unitOfWork.Quotes.GetByIdAsync(id, false, q => q.Customer, q => q.QuoteStatus); + if (quote == null) + return Json(new { success = false, message = "Quote not found." }); + + // Determine recipient phone for the feedback message + string? recipientPhone = quote.CustomerId.HasValue + ? (quote.Customer?.MobilePhone ?? quote.Customer?.Phone) + : quote.ProspectPhone; + + string recipientName = quote.CustomerId.HasValue && quote.Customer != null + ? (!string.IsNullOrWhiteSpace(quote.Customer.CompanyName) + ? quote.Customer.CompanyName + : $"{quote.Customer.ContactFirstName} {quote.Customer.ContactLastName}".Trim()) + : (!string.IsNullOrWhiteSpace(quote.ProspectContactName) ? quote.ProspectContactName + : quote.ProspectCompanyName ?? "Prospect"); + + // Ensure a valid (non-expired) approval token exists — generate only if missing or expired + bool tokenChanged = false; + if (string.IsNullOrEmpty(quote.ApprovalToken) || + (quote.ApprovalTokenExpiresAt.HasValue && quote.ApprovalTokenExpiresAt.Value < DateTime.UtcNow)) + { + var tokenBytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32); + quote.ApprovalToken = Convert.ToBase64String(tokenBytes) + .Replace('+', '-').Replace('/', '_').TrimEnd('='); + quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays( + int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var td) ? td : 30); + quote.ApprovalTokenUsedAt = null; + tokenChanged = true; + } + + if (tokenChanged) + { + await _unitOfWork.Quotes.UpdateAsync(quote); + await _unitOfWork.CompleteAsync(); + } + + var (success, error) = await _notificationService.NotifyQuoteSentSmsAsync(quote); + + if (!success) + return Json(new { success = false, message = error ?? "SMS could not be sent." }); + + var phone = string.IsNullOrWhiteSpace(recipientPhone) ? "their phone" : recipientPhone; + return Json(new { success = true, message = $"Approval link sent to {recipientName} via SMS ({phone})." }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending quote approval SMS for quote {QuoteId}", id); + return Json(new { success = false, message = "An unexpected error occurred. Please try again." }); + } + } + /// /// Returns the notification delivery history for a quote as a JSON array. /// Used by the "Notifications Sent" tab on the Details page to show which emails/SMS diff --git a/src/PowderCoating.Web/Views/Quotes/Details.cshtml b/src/PowderCoating.Web/Views/Quotes/Details.cshtml index 96be585..bfdab0b 100644 --- a/src/PowderCoating.Web/Views/Quotes/Details.cshtml +++ b/src/PowderCoating.Web/Views/Quotes/Details.cshtml @@ -1478,7 +1478,10 @@ } + @if (!Model.ConvertedToJobId.HasValue) { @@ -2012,6 +2015,33 @@ } + + +