Files
PowderCoatingLogix/src/PowderCoating.Application/Services/EquipmentManualService.cs
T
spouliot edd7389d7d Refactor: extract shared helpers, fix field drift, add assembly services
- 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>
2026-05-09 22:12:33 -04:00

125 lines
5.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}