edd7389d7d
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item and quote pricing construction that was duplicated across create, rework copy, and quote-to-job conversion paths - BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by 6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment, Catalog) and BillsController + ExpensesController, removing 8 private copies - PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11 controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs, Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors) - AccountingDropdownHelper: single LoadAsync() call replaces duplicate vendor/account/job queries in BillsController and ExpensesController - JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate through JobTemplatesController snapshot copy and GetTemplatesJson projection, and JobsController template-application path - Test assertions updated for standardized BlobFileHelper error messages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
171 lines
7.9 KiB
C#
171 lines
7.9 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using PowderCoating.Application.Configuration;
|
|
using PowderCoating.Application.Interfaces;
|
|
|
|
namespace PowderCoating.Application.Services;
|
|
|
|
/// <summary>
|
|
/// Manages the two-phase lifecycle of AI photo quote images in Azure Blob Storage.
|
|
/// Phase 1 (temp): photos uploaded during the quote wizard are stored under
|
|
/// <c>temp/{tempId}/{guid}{ext}</c> before the quote is saved and has an ID.
|
|
/// Phase 2 (permanent): when the quote is created or edited, temp blobs are promoted to
|
|
/// <c>{companyId}/quote-photos/{quoteId}/{guid}{ext}</c> and the temp blobs are deleted.
|
|
/// Keeping photos in temp storage until the quote is committed prevents orphaned blobs from
|
|
/// quotes that are abandoned mid-wizard, and ensures blobs are never written to a quote-scoped
|
|
/// path before a valid quote ID exists.
|
|
/// </summary>
|
|
public class QuotePhotoService : IQuotePhotoService
|
|
{
|
|
private readonly IAzureBlobStorageService _blobService;
|
|
private readonly StorageSettings _settings;
|
|
private readonly ILogger<QuotePhotoService> _logger;
|
|
|
|
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
|
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
|
|
|
/// <summary>
|
|
/// Initializes the service with the blob storage wrapper, storage configuration, and logger.
|
|
/// </summary>
|
|
public QuotePhotoService(
|
|
IAzureBlobStorageService blobService,
|
|
IOptions<StorageSettings> settings,
|
|
ILogger<QuotePhotoService> logger)
|
|
{
|
|
_blobService = blobService;
|
|
_settings = settings.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
// Temp blobs: temp/{tempId}/{guid}{ext}
|
|
// Permanent blobs: {companyId}/quote-photos/{quoteId}/{guid}{ext}
|
|
|
|
/// <summary>
|
|
/// Validates and uploads a photo to temporary blob storage, generating a new <c>tempId</c>
|
|
/// (GUID) that the client uses to reference this photo during the rest of the quote wizard.
|
|
/// A fresh GUID blob name is used within the temp folder so that concurrent uploads for the
|
|
/// same session do not overwrite each other.
|
|
/// </summary>
|
|
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
|
|
IFormFile file, int companyId)
|
|
{
|
|
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
|
if (!isValid)
|
|
return (false, string.Empty, string.Empty, validationError);
|
|
|
|
var tempId = Guid.NewGuid().ToString("N");
|
|
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
|
|
var contentType = BlobFileHelper.GetContentType(ext);
|
|
|
|
using var stream = file.OpenReadStream();
|
|
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
|
|
|
|
if (!result.Success)
|
|
return (false, string.Empty, string.Empty, result.ErrorMessage);
|
|
|
|
_logger.LogInformation("Saved temp quote photo {TempId}: {BlobName}", tempId, blobName);
|
|
return (true, tempId, blobName, string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Promotes a previously uploaded temp photo to its permanent blob path once the quote has been
|
|
/// saved and its ID is known. The photo is downloaded from temp storage and re-uploaded to the
|
|
/// permanent path rather than using a server-side copy API, which keeps the service compatible
|
|
/// with blob storage providers that do not support atomic renames. The temp blob is deleted
|
|
/// on a best-effort basis after a successful promotion.
|
|
/// </summary>
|
|
public async Task<(bool Success, string FilePath, string ErrorMessage)> PromoteTempPhotoAsync(
|
|
string tempId, int quoteId, int companyId)
|
|
{
|
|
var prefix = $"temp/{tempId}/";
|
|
var blobs = (await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix)).ToList();
|
|
|
|
if (blobs.Count == 0)
|
|
return (false, string.Empty, "Temp photo not found.");
|
|
|
|
var srcBlob = blobs[0];
|
|
var ext = Path.GetExtension(srcBlob);
|
|
var destBlob = $"{companyId}/quote-photos/{quoteId}/{Guid.NewGuid():N}{ext}";
|
|
|
|
// Download from temp then re-upload to permanent location
|
|
var download = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, srcBlob);
|
|
if (!download.Success)
|
|
return (false, string.Empty, "Failed to read temp photo.");
|
|
|
|
using var ms = new MemoryStream(download.Content);
|
|
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, BlobFileHelper.GetContentType(ext));
|
|
if (!upload.Success)
|
|
return (false, string.Empty, "Failed to save permanent photo.");
|
|
|
|
// Best-effort cleanup of temp blob
|
|
await _blobService.DeleteAsync(_settings.Containers.QuoteImages, srcBlob);
|
|
|
|
_logger.LogInformation("Promoted temp photo {TempId} to {DestBlob}", tempId, destBlob);
|
|
return (true, destBlob, string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Downloads a photo from blob storage and returns its raw bytes and content type.
|
|
/// Used by the <c>Quotes/Photo</c> controller action which streams photos stored outside
|
|
/// the public web root so they are not directly URL-accessible.
|
|
/// </summary>
|
|
public async Task<(bool Success, byte[] Data, string ContentType)> ReadPhotoAsync(string filePath)
|
|
{
|
|
var result = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, filePath);
|
|
if (!result.Success)
|
|
return (false, Array.Empty<byte>(), string.Empty);
|
|
|
|
return (true, result.Content, result.ContentType);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a photo blob from storage. Returns <c>true</c> if deletion succeeded (including the
|
|
/// case where the blob does not exist, which the underlying service treats as a no-op success).
|
|
/// </summary>
|
|
public async Task<bool> DeletePhotoAsync(string filePath)
|
|
{
|
|
var result = await _blobService.DeleteAsync(_settings.Containers.QuoteImages, filePath);
|
|
return result.Success;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Downloads all blobs under the temp prefix for a given <paramref name="tempId"/> and returns
|
|
/// them as in-memory byte arrays. Used by the AI analysis endpoint to supply photo bytes to the
|
|
/// Anthropic API when the quote has not yet been saved and photos have no permanent path.
|
|
/// </summary>
|
|
public async Task<List<(byte[] Data, string ContentType, string FileName)>> ReadTempPhotosAsync(string tempId)
|
|
{
|
|
var prefix = $"temp/{tempId}/";
|
|
var blobs = (await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix)).ToList();
|
|
|
|
var results = new List<(byte[] Data, string ContentType, string FileName)>();
|
|
foreach (var blobName in blobs)
|
|
{
|
|
var download = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, blobName);
|
|
if (download.Success)
|
|
results.Add((download.Content, download.ContentType, Path.GetFileName(blobName)));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes all blobs under the temp prefix for a given <paramref name="tempId"/>.
|
|
/// Called when the user cancels the quote wizard or when promotion fails partway through so
|
|
/// that orphaned temp blobs do not accumulate in storage. Each blob deletion is wrapped in
|
|
/// its own try/catch so that one failed delete does not abort cleanup of the remaining blobs.
|
|
/// </summary>
|
|
public async Task CleanupTempAsync(string tempId)
|
|
{
|
|
var prefix = $"temp/{tempId}/";
|
|
var blobs = await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix);
|
|
foreach (var blob in blobs)
|
|
{
|
|
try { await _blobService.DeleteAsync(_settings.Containers.QuoteImages, blob); }
|
|
catch (Exception ex) { _logger.LogWarning(ex, "Could not clean up temp blob {Blob}", blob); }
|
|
}
|
|
}
|
|
|
|
}
|