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>
This commit is contained in:
2026-05-09 22:12:33 -04:00
parent 61866e1d1e
commit edd7389d7d
37 changed files with 11819 additions and 1211 deletions
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Http;
namespace PowderCoating.Application.Services;
/// <summary>
/// Shared file validation and content-type resolution used across all blob storage services.
/// </summary>
public static class BlobFileHelper
{
/// <summary>
/// Validates an uploaded file against an extension allowlist and a maximum size.
/// Returns the normalized (lowercase) extension on success so callers do not re-derive it.
/// </summary>
public static (bool IsValid, string Extension, string Error) ValidateUpload(
IFormFile? file,
string[] allowedExtensions,
long maxBytes)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided.");
if (file.Length > maxBytes)
return (false, string.Empty, $"File exceeds the {maxBytes / 1024 / 1024} MB limit.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed: {string.Join(", ", allowedExtensions)}.");
return (true, extension, string.Empty);
}
/// <summary>
/// Maps a file extension to its MIME content type, covering common image formats and
/// document types. Falls back to <c>application/octet-stream</c>.
/// </summary>
public static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt" => "text/plain",
_ => "application/octet-stream"
};
/// <summary>
/// Strips OS-invalid filename characters from a base filename (no extension), replacing
/// them with underscores to produce a safe blob path segment.
/// </summary>
public static string SanitizeFileName(string fileName)
{
var sanitized = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
return string.IsNullOrWhiteSpace(sanitized) ? "file" : sanitized;
}
}