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>
125 lines
5.4 KiB
C#
125 lines
5.4 KiB
C#
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.Extensions.Options;
|
||
using PowderCoating.Application.Configuration;
|
||
using PowderCoating.Application.Interfaces;
|
||
|
||
namespace PowderCoating.Application.Services;
|
||
|
||
/// <summary>
|
||
/// Manages equipment manual documents stored in Azure Blob Storage.
|
||
/// Manuals are stored in the <c>manuals</c> container under the path
|
||
/// <c>{companyId}/equipment-manuals/{equipmentId}/{sanitizedFilename}{ext}</c>.
|
||
/// <para>
|
||
/// The 50 MB limit (5× larger than other upload types) is intentional —
|
||
/// equipment OEM manuals are often large PDF scans. Only document formats
|
||
/// are accepted; image uploads are rejected to keep this container clean.
|
||
/// </para>
|
||
/// </summary>
|
||
public class EquipmentManualService : IEquipmentManualService
|
||
{
|
||
private readonly IAzureBlobStorageService _blobService;
|
||
private readonly StorageSettings _settings;
|
||
|
||
/// <summary>Maximum manual file size accepted on upload (50 MB).</summary>
|
||
private const long MaxFileSize = 50 * 1024 * 1024; // 50 MB
|
||
|
||
/// <summary>
|
||
/// Document formats permitted for equipment manuals.
|
||
/// Images and spreadsheets are deliberately excluded to keep
|
||
/// the container purpose-specific.
|
||
/// </summary>
|
||
private static readonly string[] AllowedExtensions = [".pdf", ".doc", ".docx", ".txt"];
|
||
|
||
/// <summary>
|
||
/// Initialises the service with the blob storage provider and storage
|
||
/// configuration (container names, etc.).
|
||
/// </summary>
|
||
public EquipmentManualService(
|
||
IAzureBlobStorageService blobService,
|
||
IOptions<StorageSettings> settings)
|
||
{
|
||
_blobService = blobService;
|
||
_settings = settings.Value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates, sanitizes, and uploads an equipment manual to Azure Blob Storage.
|
||
/// The original filename (minus invalid characters) is preserved in the blob
|
||
/// name so operators can recognise the document from the path alone.
|
||
/// </summary>
|
||
/// <param name="file">The uploaded file from the HTTP request.</param>
|
||
/// <param name="companyId">The tenant company's database ID (for path scoping).</param>
|
||
/// <param name="equipmentId">The equipment record's database ID.</param>
|
||
/// <returns>
|
||
/// A tuple with a success flag, the stored blob path (on success), and a
|
||
/// human-readable error message (on failure).
|
||
/// </returns>
|
||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
|
||
{
|
||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||
if (!isValid)
|
||
return (false, string.Empty, error);
|
||
|
||
// Sanitize filename — replace OS-invalid characters with underscores to
|
||
// prevent path traversal and blob naming errors in Azure.
|
||
var fileName = BlobFileHelper.SanitizeFileName(Path.GetFileNameWithoutExtension(file.FileName));
|
||
|
||
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
|
||
var contentType = BlobFileHelper.GetContentType(extension);
|
||
|
||
using var stream = file.OpenReadStream();
|
||
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
|
||
|
||
if (!result.Success)
|
||
return (false, string.Empty, result.ErrorMessage);
|
||
|
||
return (true, blobName, string.Empty);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Deletes the equipment manual blob at the given path from Azure Blob Storage.
|
||
/// </summary>
|
||
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveEquipmentManualAsync"/>.</param>
|
||
/// <returns>Success flag and an error message on failure.</returns>
|
||
public async Task<(bool Success, string ErrorMessage)> DeleteEquipmentManualAsync(string filePath)
|
||
{
|
||
if (string.IsNullOrEmpty(filePath))
|
||
return (false, "File path is empty");
|
||
|
||
return await _blobService.DeleteAsync(_settings.Containers.Manuals, filePath);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Downloads the raw bytes of an equipment manual so the controller can
|
||
/// stream it to the browser with the appropriate content-disposition header.
|
||
/// </summary>
|
||
/// <param name="filePath">Blob-relative path of the manual.</param>
|
||
/// <returns>
|
||
/// A tuple with a success flag, the raw file bytes, the MIME content type,
|
||
/// and an error message on failure.
|
||
/// </returns>
|
||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetEquipmentManualAsync(string filePath)
|
||
{
|
||
if (string.IsNullOrEmpty(filePath))
|
||
return (false, Array.Empty<byte>(), string.Empty, "File path is empty");
|
||
|
||
return await _blobService.DownloadAsync(_settings.Containers.Manuals, filePath);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Checks whether a manual blob exists at the given path without downloading it.
|
||
/// Used by the Equipment Details view to determine whether a "Download Manual"
|
||
/// button should be rendered.
|
||
/// </summary>
|
||
/// <param name="filePath">Blob-relative path to check.</param>
|
||
/// <returns><c>true</c> if the blob exists; otherwise <c>false</c>.</returns>
|
||
public async Task<bool> EquipmentManualExistsAsync(string filePath)
|
||
{
|
||
if (string.IsNullOrEmpty(filePath))
|
||
return false;
|
||
|
||
return await _blobService.ExistsAsync(_settings.Containers.Manuals, filePath);
|
||
}
|
||
|
||
}
|