From 456d0542295a9db822bb8a37b4840073adbcb0d2 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Mon, 8 Jun 2026 10:35:48 -0400 Subject: [PATCH] Fix prospect quote conversion losing the job; add reply-to in email footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QuotesController — ConvertToCustomer POST was wrongly setting the quote status to 'Converted' (which means a job exists) and redirecting to the customer page with no job created. The quote then disappeared from the default list filter and the user had no way to create the job without hunting for it. Fix: leave the quote at 'Approved' after customer creation and redirect back to the quote details page with a toast prompting the next step. 'Converted' status is now set exclusively by CreateJobFromQuote when a job actually exists. NotificationService — add tenant reply-to email address as a visible line in the email footer so customers who ignore or whose mail client doesn't honour the Reply-To header still have a clear address to contact. Also adds Warning-level logging when no reply-to is configured for a company so future routing issues are diagnosable from app logs. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/NotificationService.cs | 34 ++++++++++++------- .../Controllers/QuotesController.cs | 17 ++++------ 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/src/PowderCoating.Infrastructure/Services/NotificationService.cs b/src/PowderCoating.Infrastructure/Services/NotificationService.cs index 3c09bc7..747a32e 100644 --- a/src/PowderCoating.Infrastructure/Services/NotificationService.cs +++ b/src/PowderCoating.Infrastructure/Services/NotificationService.cs @@ -94,7 +94,7 @@ public class NotificationService : INotificationService quote.CompanyId, NotificationType.QuoteSent, values, $"Your Quote {quote.QuoteNumber} from {companyName}"); - var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl); + var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail); var plainText = StripHtml(fullHtml); var (success, error) = await _emailService.SendEmailAsync( @@ -137,7 +137,7 @@ public class NotificationService : INotificationService quote.CompanyId, NotificationType.QuoteSent, values, $"Your Quote {quote.QuoteNumber} from {companyName}"); - var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl); + var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail); var plainText = StripHtml(fullHtml); var (success, error, recipientsLog) = await SendToEmailListAsync( @@ -300,7 +300,7 @@ public class NotificationService : INotificationService quote.CompanyId, NotificationType.QuoteApproved, values, $"Quote {quote.QuoteNumber} Approved — {companyName}"); - var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); + var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail); var plainText = StripHtml(fullHtml); var (success, error, recipientsLog) = await SendToEmailListAsync( @@ -383,7 +383,7 @@ public class NotificationService : INotificationService var (subject, htmlBody) = await GetRenderedEmailAsync( job.CompanyId, notifType, values, defaultSubject); - var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); + var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail); var plainText = StripHtml(fullHtml); var (success, error, recipientsLog) = await SendToEmailListAsync( @@ -451,7 +451,7 @@ public class NotificationService : INotificationService job.CompanyId, NotificationType.JobCompleted, values, $"Job {job.JobNumber} Complete — {companyName}"); - var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); + var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail); var plainText = StripHtml(fullHtml); var (success, error, recipientsLog) = await SendToEmailListAsync( @@ -674,7 +674,7 @@ public class NotificationService : INotificationService """; } - var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); + var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail); var plainText = !string.IsNullOrEmpty(paymentUrl) ? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}" : StripHtml(fullHtml); @@ -793,7 +793,7 @@ public class NotificationService : INotificationService invoice.CompanyId, NotificationType.PaymentReceived, values, $"Payment Received — Invoice {invoice.InvoiceNumber}"); - var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); + var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail); var plainText = StripHtml(fullHtml); var (success, error, recipientsLog) = await SendToEmailListAsync( @@ -867,7 +867,7 @@ public class NotificationService : INotificationService invoice.CompanyId, NotificationType.PaymentReminder, values, $"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)"); - var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); + var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail); var plainText = StripHtml(fullHtml); var (success, error, recipientsLog) = await SendToEmailListAsync( @@ -971,7 +971,7 @@ public class NotificationService : INotificationService var (subject, htmlBody) = await GetRenderedEmailAsync( quote.CompanyId, notificationType, values, defaultSubject); - var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync()); + var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail); var plainText = StripHtml(fullHtml); var (success, error) = await _emailService.SendEmailAsync( @@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService var (custSubject, custHtml) = await GetRenderedEmailAsync( appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject); - var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl); + var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail); var custPlainText = StripHtml(custFullHtml); var (custOk, custErr, custLog) = await SendToEmailListAsync( @@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService /// /// Appends CAN-SPAM required footer as HTML. /// - private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null) + private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null) { var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl); - var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address); + var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address); + var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail); - if (!hasUnsubscribeUrl && !hasAddress) + if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo) return htmlBody; var footer = "
" + "

"; + if (hasReplyTo) + { + var encodedEmail = WebUtility.HtmlEncode(replyToEmail!); + footer += $"Questions? Reply to this email or contact us at {encodedEmail}"; + if (hasAddress || hasUnsubscribeUrl) footer += "
"; + } + if (hasAddress) { var addressLine = BuildAddressLine(company!); diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index 7929231..ff3cd3d 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -1957,12 +1957,10 @@ public class QuotesController : Controller if (dto.SmsConsent) await _notificationService.NotifySmsConsentGrantedAsync(customer); - // Get "Converted" status (cached) - var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId); - var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted); - - // Update quote to link to new customer + // Update quote to link to new customer. + // Do NOT set "Converted" status here — that status is reserved for when a job is + // actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the + // user immediately click "Create Job from Quote" on the next screen. quote.CustomerId = customer.Id; // Clear prospect fields @@ -1977,14 +1975,11 @@ public class QuotesController : Controller quote.ProspectSmsConsent = false; quote.ProspectSmsConsentedAt = null; - // Update status to converted - quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId; - await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.CompleteAsync(); - this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated."); - return RedirectToAction("Details", "Customers", new { id = customer.Id }); + this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}."); + return RedirectToAction(nameof(Details), new { id = dto.QuoteId }); } catch (Exception ex) {