Fix prospect quote conversion losing the job; add reply-to in email footer
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 <noreply@anthropic.com>
This commit is contained in:
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
|
|||||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
$"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 plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error) = await _emailService.SendEmailAsync(
|
var (success, error) = await _emailService.SendEmailAsync(
|
||||||
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
|
|||||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
$"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 plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
|
|||||||
quote.CompanyId, NotificationType.QuoteApproved, values,
|
quote.CompanyId, NotificationType.QuoteApproved, values,
|
||||||
$"Quote {quote.QuoteNumber} Approved — {companyName}");
|
$"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 plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
|
|||||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||||
job.CompanyId, notifType, values, defaultSubject);
|
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 plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
|
|||||||
job.CompanyId, NotificationType.JobCompleted, values,
|
job.CompanyId, NotificationType.JobCompleted, values,
|
||||||
$"Job {job.JobNumber} Complete — {companyName}");
|
$"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 plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
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)
|
var plainText = !string.IsNullOrEmpty(paymentUrl)
|
||||||
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
||||||
: StripHtml(fullHtml);
|
: StripHtml(fullHtml);
|
||||||
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
|
|||||||
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
||||||
$"Payment Received — Invoice {invoice.InvoiceNumber}");
|
$"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 plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
|
|||||||
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
||||||
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
|
$"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 plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
|
|||||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||||
quote.CompanyId, notificationType, values, defaultSubject);
|
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 plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error) = await _emailService.SendEmailAsync(
|
var (success, error) = await _emailService.SendEmailAsync(
|
||||||
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
|
|||||||
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||||
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
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 custPlainText = StripHtml(custFullHtml);
|
||||||
|
|
||||||
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||||
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Appends CAN-SPAM required footer as HTML.
|
/// Appends CAN-SPAM required footer as HTML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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 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;
|
return htmlBody;
|
||||||
|
|
||||||
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
||||||
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
||||||
|
|
||||||
|
if (hasReplyTo)
|
||||||
|
{
|
||||||
|
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
|
||||||
|
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
|
||||||
|
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
|
||||||
|
}
|
||||||
|
|
||||||
if (hasAddress)
|
if (hasAddress)
|
||||||
{
|
{
|
||||||
var addressLine = BuildAddressLine(company!);
|
var addressLine = BuildAddressLine(company!);
|
||||||
|
|||||||
@@ -1957,12 +1957,10 @@ public class QuotesController : Controller
|
|||||||
if (dto.SmsConsent)
|
if (dto.SmsConsent)
|
||||||
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
||||||
|
|
||||||
// Get "Converted" status (cached)
|
// Update quote to link to new customer.
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
// Do NOT set "Converted" status here — that status is reserved for when a job is
|
||||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
// actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
|
||||||
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
|
// user immediately click "Create Job from Quote" on the next screen.
|
||||||
|
|
||||||
// Update quote to link to new customer
|
|
||||||
quote.CustomerId = customer.Id;
|
quote.CustomerId = customer.Id;
|
||||||
|
|
||||||
// Clear prospect fields
|
// Clear prospect fields
|
||||||
@@ -1977,14 +1975,11 @@ public class QuotesController : Controller
|
|||||||
quote.ProspectSmsConsent = false;
|
quote.ProspectSmsConsent = false;
|
||||||
quote.ProspectSmsConsentedAt = null;
|
quote.ProspectSmsConsentedAt = null;
|
||||||
|
|
||||||
// Update status to converted
|
|
||||||
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
|
|
||||||
|
|
||||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated.");
|
this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
|
||||||
return RedirectToAction("Details", "Customers", new { id = customer.Id });
|
return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user