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
@@ -69,19 +69,13 @@ public class JobPhotoService : IJobPhotoService
string? caption = null,
JobPhotoType photoType = JobPhotoType.Progress)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file was uploaded.");
if (file.Length > MaxPhotoSize)
return (false, string.Empty, "Photo must be smaller than 10 MB.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
if (!isValid)
return (false, string.Empty, error);
// SECURITY: Use GUID for blob name to prevent enumeration
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
var contentType = GetContentType(extension);
var contentType = BlobFileHelper.GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
@@ -137,19 +131,4 @@ public class JobPhotoService : IJobPhotoService
return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath);
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
/// allowed extensions are image types and browsers will render them correctly.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string.</returns>
private static string GetContentType(string extension) => extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}