Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,184 @@
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)
{
if (file == null || file.Length == 0)
return (false, string.Empty, string.Empty, "No file provided.");
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed.");
var tempId = Guid.NewGuid().ToString("N");
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
var contentType = 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, 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); }
}
}
private static string GetContentType(string ext) => ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}