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>
134 lines
5.5 KiB
C#
134 lines
5.5 KiB
C#
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
using PowderCoating.Application.Configuration;
|
||
using PowderCoating.Application.Interfaces;
|
||
using SixLabors.ImageSharp;
|
||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||
using SixLabors.ImageSharp.Processing;
|
||
|
||
namespace PowderCoating.Application.Services;
|
||
|
||
/// <summary>
|
||
/// Manages catalog item images in Azure Blob Storage. Each upload produces a full-size original and a
|
||
/// 200×200 JPEG thumbnail. Both blobs are stored under <c>{companyId}/catalog/{itemId}/</c> so that
|
||
/// paths are isolated per tenant and per item — existing blobs for the same item are replaced atomically
|
||
/// (delete-then-upload) to avoid orphaned files accumulating over time.
|
||
/// </summary>
|
||
public class CatalogImageService : ICatalogImageService
|
||
{
|
||
private readonly IAzureBlobStorageService _blobService;
|
||
private readonly StorageSettings _settings;
|
||
private readonly ILogger<CatalogImageService> _logger;
|
||
|
||
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
||
private const int ThumbnailSize = 200;
|
||
|
||
public CatalogImageService(
|
||
IAzureBlobStorageService blobService,
|
||
IOptions<StorageSettings> settings,
|
||
ILogger<CatalogImageService> logger)
|
||
{
|
||
_blobService = blobService;
|
||
_settings = settings.Value;
|
||
_logger = logger;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates the upload, removes any existing blobs, stores the original, generates a 200×200 JPEG
|
||
/// thumbnail, stores the thumbnail, and returns both blob paths. The thumbnail is always stored as
|
||
/// JPEG regardless of the source format for predictable browser rendering and smaller file sizes.
|
||
/// </summary>
|
||
public async Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync(
|
||
IFormFile file,
|
||
int itemId,
|
||
int companyId,
|
||
string? existingImagePath,
|
||
string? existingThumbnailPath)
|
||
{
|
||
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||
if (!isValid)
|
||
return (false, string.Empty, string.Empty, validationError);
|
||
|
||
var container = _settings.Containers.CatalogImages;
|
||
var blobId = Guid.NewGuid().ToString("N");
|
||
var imagePath = $"{companyId}/catalog/{itemId}/{blobId}{ext}";
|
||
var thumbPath = $"{companyId}/catalog/{itemId}/thumb_{blobId}.jpg";
|
||
|
||
// Delete existing blobs before uploading replacements.
|
||
await DeleteAsync(existingImagePath, existingThumbnailPath);
|
||
|
||
// Upload original.
|
||
using var originalStream = file.OpenReadStream();
|
||
var uploadResult = await _blobService.UploadAsync(container, imagePath, originalStream, file.ContentType);
|
||
if (!uploadResult.Success)
|
||
return (false, string.Empty, string.Empty, uploadResult.ErrorMessage);
|
||
|
||
// Generate and upload thumbnail.
|
||
using var thumbStream = await GenerateThumbnailAsync(file);
|
||
if (thumbStream == null)
|
||
{
|
||
// Thumbnail generation failed; clean up the original and bail out.
|
||
await _blobService.DeleteAsync(container, imagePath);
|
||
return (false, string.Empty, string.Empty, "Failed to generate thumbnail.");
|
||
}
|
||
|
||
var thumbResult = await _blobService.UploadAsync(container, thumbPath, thumbStream, "image/jpeg");
|
||
if (!thumbResult.Success)
|
||
{
|
||
await _blobService.DeleteAsync(container, imagePath);
|
||
return (false, string.Empty, string.Empty, thumbResult.ErrorMessage);
|
||
}
|
||
|
||
_logger.LogInformation("Catalog image uploaded for item {ItemId}: {ImagePath}", itemId, imagePath);
|
||
return (true, imagePath, thumbPath, string.Empty);
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public async Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath)
|
||
{
|
||
return await _blobService.DownloadAsync(_settings.Containers.CatalogImages, blobPath);
|
||
}
|
||
|
||
/// <inheritdoc/>
|
||
public async Task DeleteAsync(string? imagePath, string? thumbnailPath)
|
||
{
|
||
var container = _settings.Containers.CatalogImages;
|
||
if (!string.IsNullOrEmpty(imagePath))
|
||
await _blobService.DeleteAsync(container, imagePath);
|
||
if (!string.IsNullOrEmpty(thumbnailPath))
|
||
await _blobService.DeleteAsync(container, thumbnailPath);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decodes the uploaded image with ImageSharp, resizes it to fit within a 200×200 square while
|
||
/// preserving aspect ratio, and encodes the result as JPEG. Returns null if decoding fails so the
|
||
/// caller can surface a clean error without propagating an ImageSharp exception.
|
||
/// </summary>
|
||
private async Task<MemoryStream?> GenerateThumbnailAsync(IFormFile file)
|
||
{
|
||
try
|
||
{
|
||
using var inputStream = file.OpenReadStream();
|
||
using var image = await Image.LoadAsync(inputStream);
|
||
|
||
image.Mutate(ctx => ctx.Resize(new ResizeOptions
|
||
{
|
||
Size = new Size(ThumbnailSize, ThumbnailSize),
|
||
Mode = ResizeMode.Max
|
||
}));
|
||
|
||
var ms = new MemoryStream();
|
||
await image.SaveAsync(ms, new JpegEncoder { Quality = 85 });
|
||
ms.Position = 0;
|
||
return ms;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Thumbnail generation failed for file {FileName}", file.FileName);
|
||
return null;
|
||
}
|
||
}
|
||
}
|