Fix company logo missing from PDFs and add AI photo save logging

When a tenant uploads a logo it is stored in Azure Blob Storage and
LogoData (the legacy DB byte[]) is cleared. All PDF controllers were
still reading the now-null LogoData, so logos never appeared on any
PDF after upload. Fixed by injecting ICompanyLogoService into all six
affected controllers (Quotes, Invoices, Deposits, GiftCertificates,
PurchaseOrders, CatalogItems) and loading the blob-stored logo first
before falling back to the legacy DB field.

Also added structured logging to the AI photo promotion path in
QuotesController Create/Edit POST so upload failures are visible in
production logs instead of silently swallowed.

Added onclick safety net to the Create and Edit quote submit buttons
so dynamically-injected hidden fields (AiPhotoTempIds) are written
before iOS Safari collects the form data on submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:27:18 -04:00
parent ca4fb959aa
commit a8fb56e8ec
8 changed files with 231 additions and 22 deletions
@@ -40,6 +40,7 @@ public class QuotesController : Controller
private readonly IWebHostEnvironment _env;
private readonly IJobPhotoService _jobPhotoService;
private readonly IAiUsageLogger _usageLogger;
private readonly ICompanyLogoService _logoService;
public QuotesController(
IUnitOfWork unitOfWork,
@@ -59,7 +60,8 @@ public class QuotesController : Controller
IAiQuoteService aiService,
IWebHostEnvironment env,
IJobPhotoService jobPhotoService,
IAiUsageLogger usageLogger)
IAiUsageLogger usageLogger,
ICompanyLogoService logoService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -79,6 +81,7 @@ public class QuotesController : Controller
_env = env;
_jobPhotoService = jobPhotoService;
_usageLogger = usageLogger;
_logoService = logoService;
}
/// <summary>
@@ -604,10 +607,11 @@ public class QuotesController : Controller
};
// Generate PDF
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
var pdfBytes = await _pdfService.GenerateQuotePdfAsync(
quoteDto,
company.LogoData,
company.LogoContentType,
logoData,
logoContentType,
companyInfo,
template: template
);
@@ -1037,13 +1041,22 @@ public class QuotesController : Controller
await _unitOfWork.CompleteAsync();
// Promote AI temp photos to permanent storage and create QuotePhoto records
_logger.LogInformation("CREATE AI photo promotion: AiPhotoTempIds count={Count}, raw values=[{Values}]",
dto.AiPhotoTempIds?.Count ?? 0,
dto.AiPhotoTempIds == null ? "" : string.Join(",", dto.AiPhotoTempIds));
if (dto.AiPhotoTempIds?.Count > 0)
{
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
{
if (!Guid.TryParse(rawTempId, out var tempGuid)) continue;
if (!Guid.TryParse(rawTempId, out var tempGuid))
{
_logger.LogWarning("CREATE AI photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId);
continue;
}
var tempId = tempGuid.ToString("N");
var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
_logger.LogInformation("CREATE AI photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id);
var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
_logger.LogInformation("CREATE AI photo: promoted={Promoted}, path={Path}, error={Error}", promoted, photoPath, promoteError);
if (promoted)
{
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
@@ -1895,13 +1908,22 @@ public class QuotesController : Controller
await _unitOfWork.CompleteAsync();
// Promote any new AI temp photos and create QuotePhoto records
_logger.LogInformation("EDIT AI photo promotion: AiPhotoTempIds count={Count}, raw values=[{Values}]",
dto.AiPhotoTempIds?.Count ?? 0,
dto.AiPhotoTempIds == null ? "" : string.Join(",", dto.AiPhotoTempIds));
if (dto.AiPhotoTempIds?.Count > 0)
{
foreach (var rawTempId in dto.AiPhotoTempIds.Where(t => !string.IsNullOrWhiteSpace(t)))
{
if (!Guid.TryParse(rawTempId, out var tempGuid)) continue;
if (!Guid.TryParse(rawTempId, out var tempGuid))
{
_logger.LogWarning("EDIT AI photo: Guid.TryParse failed for rawTempId={RawTempId}", rawTempId);
continue;
}
var tempId = tempGuid.ToString("N");
var (promoted, photoPath, _) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
_logger.LogInformation("EDIT AI photo: promoting tempId={TempId} for quoteId={QuoteId}", tempId, quote.Id);
var (promoted, photoPath, promoteError) = await _photoService.PromoteTempPhotoAsync(tempId, quote.Id, currentUser.CompanyId);
_logger.LogInformation("EDIT AI photo: promoted={Promoted}, path={Path}, error={Error}", promoted, photoPath, promoteError);
if (promoted)
{
var ext = Path.GetExtension(photoPath).ToLowerInvariant();
@@ -2826,10 +2848,11 @@ public class QuotesController : Controller
DefaultTerms = prefs?.QtDefaultTerms
};
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
return await _pdfService.GenerateQuotePdfAsync(
quoteDto,
company.LogoData,
company.LogoContentType,
logoData,
logoContentType,
companyInfo,
template: template);
}
@@ -3908,6 +3931,30 @@ public class QuotesController : Controller
return Json(new { success = true });
}
/// <summary>Updates the caption of a non-AI quote photo.</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateQuotePhoto(int id, string? caption)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Json(new { success = false, error = "Not authenticated." });
var photo = await _unitOfWork.QuotePhotos.GetByIdAsync(id);
if (photo == null || photo.CompanyId != user.CompanyId)
return Json(new { success = false, error = "Photo not found." });
if (photo.IsAiAnalysisPhoto)
return Json(new { success = false, error = "AI analysis photos cannot be edited." });
photo.Caption = string.IsNullOrWhiteSpace(caption) ? null : caption.Trim();
photo.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.QuotePhotos.UpdateAsync(photo);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
private async Task<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
{
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
@@ -3936,6 +3983,21 @@ public class QuotesController : Controller
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
}
/// <summary>
/// Returns logo bytes and content type for PDF generation.
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData)
/// so that companies that uploaded a new logo after initial setup see it in PDFs.
/// </summary>
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company company)
{
if (!string.IsNullOrEmpty(company.LogoFilePath))
{
var (ok, content, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
if (ok) return (content, contentType);
}
return (company.LogoData, company.LogoContentType);
}
}
// Request model for AJAX pricing calculation