using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PowderCoating.Application.Configuration; using PowderCoating.Application.Interfaces; namespace PowderCoating.Application.Services; /// /// 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 /// temp/{tempId}/{guid}{ext} before the quote is saved and has an ID. /// Phase 2 (permanent): when the quote is created or edited, temp blobs are promoted to /// {companyId}/quote-photos/{quoteId}/{guid}{ext} 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. /// public class QuotePhotoService : IQuotePhotoService { private readonly IAzureBlobStorageService _blobService; private readonly StorageSettings _settings; private readonly ILogger _logger; private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB /// /// Initializes the service with the blob storage wrapper, storage configuration, and logger. /// public QuotePhotoService( IAzureBlobStorageService blobService, IOptions settings, ILogger logger) { _blobService = blobService; _settings = settings.Value; _logger = logger; } // Temp blobs: temp/{tempId}/{guid}{ext} // Permanent blobs: {companyId}/quote-photos/{quoteId}/{guid}{ext} /// /// Validates and uploads a photo to temporary blob storage, generating a new tempId /// (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. /// 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); } /// /// 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. /// 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); } /// /// Downloads a photo from blob storage and returns its raw bytes and content type. /// Used by the Quotes/Photo controller action which streams photos stored outside /// the public web root so they are not directly URL-accessible. /// 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(), string.Empty); return (true, result.Content, result.ContentType); } /// /// Deletes a photo blob from storage. Returns true if deletion succeeded (including the /// case where the blob does not exist, which the underlying service treats as a no-op success). /// public async Task DeletePhotoAsync(string filePath) { var result = await _blobService.DeleteAsync(_settings.Containers.QuoteImages, filePath); return result.Success; } /// /// Downloads all blobs under the temp prefix for a given 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. /// public async Task> 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; } /// /// Deletes all blobs under the temp prefix for a given . /// 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. /// 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); } } } }