Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,174 @@
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Application.Services;
/// <summary>
/// Azure Blob Storage wrapper that provides a uniform, result-tuple API for blob operations used
/// throughout the application (company logos, quote photos, documents). Containers are created
/// on first use with private access so blobs are never publicly URL-accessible; all serving goes
/// through controller actions that perform authorization checks before streaming bytes.
/// All methods catch exceptions and return a failure result rather than propagating so that blob
/// storage errors degrade gracefully without crashing the request pipeline.
/// </summary>
public class AzureBlobStorageService : IAzureBlobStorageService
{
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<AzureBlobStorageService> _logger;
/// <summary>
/// Initializes the service using the storage connection string from <see cref="StorageSettings"/>.
/// The <see cref="BlobServiceClient"/> is created once and reused across requests because it is
/// registered as a singleton in DI, matching the thread-safe design of the Azure SDK client.
/// </summary>
public AzureBlobStorageService(
IOptions<StorageSettings> settings,
ILogger<AzureBlobStorageService> logger)
{
_blobServiceClient = new BlobServiceClient(settings.Value.ConnectionString);
_logger = logger;
}
/// <summary>
/// Uploads a stream to the specified blob path, setting the Content-Type header so that the
/// Azure portal and download responses report the correct MIME type. The container is created
/// with private access if it does not already exist, preventing accidental public exposure.
/// </summary>
public async Task<(bool Success, string ErrorMessage)> UploadAsync(
string containerName,
string blobName,
Stream content,
string contentType)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
await containerClient.CreateIfNotExistsAsync(PublicAccessType.None);
var blobClient = containerClient.GetBlobClient(blobName);
var uploadOptions = new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = contentType }
};
await blobClient.UploadAsync(content, uploadOptions);
_logger.LogInformation("Uploaded blob {BlobName} to container {Container}", blobName, containerName);
return (true, string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading blob {BlobName} to container {Container}", blobName, containerName);
return (false, "An error occurred while uploading the file.");
}
}
/// <summary>
/// Downloads a blob's full content into memory and returns the byte array along with the
/// stored Content-Type. Checks blob existence before attempting download to return a clean
/// "not found" failure rather than surfacing an Azure SDK exception to the caller.
/// </summary>
public async Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(
string containerName,
string blobName)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(blobName);
if (!await blobClient.ExistsAsync())
{
return (false, Array.Empty<byte>(), string.Empty, "File not found.");
}
var response = await blobClient.DownloadContentAsync();
var content = response.Value.Content.ToArray();
var contentType = response.Value.Details.ContentType ?? "application/octet-stream";
return (true, content, contentType, string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading blob {BlobName} from container {Container}", blobName, containerName);
return (false, Array.Empty<byte>(), string.Empty, "An error occurred while retrieving the file.");
}
}
/// <summary>
/// Deletes a blob if it exists, returning success in both the deleted and not-found cases.
/// Using <c>DeleteIfExistsAsync</c> makes the operation idempotent so callers do not need to
/// check existence before deleting (e.g., during temp photo cleanup after quote promotion).
/// </summary>
public async Task<(bool Success, string ErrorMessage)> DeleteAsync(
string containerName,
string blobName)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(blobName);
var deleted = await blobClient.DeleteIfExistsAsync();
if (deleted)
_logger.LogInformation("Deleted blob {BlobName} from container {Container}", blobName, containerName);
return (true, string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting blob {BlobName} from container {Container}", blobName, containerName);
return (false, "An error occurred while deleting the file.");
}
}
/// <summary>
/// Checks whether a blob exists without downloading its content.
/// Used by controllers to determine whether to show a file download link or a
/// "no file available" placeholder, avoiding a full download just for an existence check.
/// Returns <c>false</c> on exception so that storage errors do not surface as unhandled faults.
/// </summary>
public async Task<bool> ExistsAsync(string containerName, string blobName)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(blobName);
var response = await blobClient.ExistsAsync();
return response.Value;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking existence of blob {BlobName} in container {Container}", blobName, containerName);
return false;
}
}
/// <summary>
/// Lists all blob names in a container that share the given prefix, used by
/// <see cref="QuotePhotoService"/> to enumerate all photos belonging to a temp session or a
/// specific quote. Returns an empty enumerable on exception so callers can safely iterate
/// without null checks.
/// </summary>
public async Task<IEnumerable<string>> ListBlobsByPrefixAsync(string containerName, string prefix)
{
try
{
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
var names = new List<string>();
await foreach (var item in containerClient.GetBlobsAsync(prefix: prefix))
names.Add(item.Name);
return names;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing blobs with prefix {Prefix} in container {Container}", prefix, containerName);
return [];
}
}
}
@@ -0,0 +1,171 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace PowderCoating.Application.Services;
/// <summary>
/// Defines a thin wrapper around <see cref="IMemoryCache"/> that adds thread-safe
/// get-or-create semantics and prefix-based bulk invalidation.
/// </summary>
public interface ICachingService
{
/// <summary>
/// Returns the cached value for <paramref name="key"/> if present; otherwise
/// invokes <paramref name="factory"/>, stores the result, and returns it.
/// </summary>
/// <typeparam name="T">Type of the cached value.</typeparam>
/// <param name="key">Unique cache key.</param>
/// <param name="factory">Async delegate used to produce the value on a cache miss.</param>
/// <param name="absoluteExpiration">
/// How long the entry lives before being evicted regardless of access.
/// Defaults to 15 minutes when <c>null</c>.
/// </param>
Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpiration = null);
/// <summary>Removes a single cache entry by its exact key.</summary>
/// <param name="key">The key to remove.</param>
void Remove(string key);
/// <summary>
/// Removes all cache entries whose keys begin with <paramref name="prefix"/>.
/// This enables coarse-grained invalidation (e.g. all keys for a given company)
/// without knowing every individual key in advance.
/// </summary>
/// <param name="prefix">The key prefix to match.</param>
void RemoveByPrefix(string prefix);
}
/// <summary>
/// In-process memory cache wrapper that enforces a double-checked locking pattern
/// to prevent cache stampedes under concurrent load.
/// <para>
/// Design notes:
/// <list type="bullet">
/// <item>
/// A static <see cref="HashSet{T}"/> tracks every key written so that
/// <see cref="RemoveByPrefix"/> can iterate them — <see cref="IMemoryCache"/>
/// does not expose its key set natively.
/// </item>
/// <item>
/// A static <see cref="SemaphoreSlim"/> (capacity 1) serialises factory
/// invocations for the same key, preventing multiple simultaneous DB calls
/// for an identical cache miss.
/// </item>
/// <item>
/// Both statics are process-wide intentionally: this service is registered as
/// Scoped but the underlying <see cref="IMemoryCache"/> is Singleton, so the
/// key registry must outlive individual request scopes.
/// </item>
/// </list>
/// </para>
/// </summary>
public class CachingService : ICachingService
{
private readonly IMemoryCache _cache;
private readonly ILogger<CachingService> _logger;
/// <summary>
/// Process-wide set of all keys written to the cache.
/// Required because <see cref="IMemoryCache"/> has no built-in key enumeration.
/// Static so the registry survives across DI scope lifetimes.
/// </summary>
private static readonly HashSet<string> _cacheKeys = new();
/// <summary>
/// Process-wide semaphore that serialises factory invocations to prevent
/// cache stampedes when multiple requests miss the same key simultaneously.
/// </summary>
private static readonly SemaphoreSlim _semaphore = new(1, 1);
/// <summary>
/// Initialises a new <see cref="CachingService"/> with the required dependencies.
/// </summary>
/// <param name="cache">The underlying ASP.NET Core memory cache.</param>
/// <param name="logger">Logger for debug-level cache hit/miss diagnostics.</param>
public CachingService(IMemoryCache cache, ILogger<CachingService> logger)
{
_cache = cache;
_logger = logger;
}
/// <summary>
/// Returns the cached value for <paramref name="key"/>; on a miss, acquires the
/// semaphore and double-checks before invoking <paramref name="factory"/> to
/// prevent multiple simultaneous DB hits for the same key.
/// Combined absolute + sliding expiration means infrequently-accessed entries
/// are evicted early while hot entries live up to the absolute limit.
/// </summary>
/// <typeparam name="T">Type of the cached value.</typeparam>
/// <param name="key">Unique cache key.</param>
/// <param name="factory">Async delegate that loads the value from the source of truth.</param>
/// <param name="absoluteExpiration">
/// Hard upper bound on entry lifetime; defaults to 15 minutes.
/// </param>
/// <returns>The cached or freshly loaded value.</returns>
public async Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan? absoluteExpiration = null)
{
if (_cache.TryGetValue(key, out T cachedValue))
{
_logger.LogDebug("Cache hit for key: {Key}", key);
return cachedValue;
}
await _semaphore.WaitAsync();
try
{
// Double-check after acquiring lock
if (_cache.TryGetValue(key, out cachedValue))
{
return cachedValue;
}
_logger.LogDebug("Cache miss for key: {Key}. Loading from source...", key);
var value = await factory();
var cacheEntryOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = absoluteExpiration ?? TimeSpan.FromMinutes(15),
SlidingExpiration = TimeSpan.FromMinutes(5)
};
_cache.Set(key, value, cacheEntryOptions);
_cacheKeys.Add(key);
_logger.LogDebug("Cached value for key: {Key}", key);
return value;
}
finally
{
_semaphore.Release();
}
}
/// <summary>
/// Removes the cache entry with the given <paramref name="key"/> from both the
/// underlying <see cref="IMemoryCache"/> and the internal key registry.
/// </summary>
/// <param name="key">The exact key to remove.</param>
public void Remove(string key)
{
_cache.Remove(key);
_cacheKeys.Remove(key);
_logger.LogDebug("Removed cache key: {Key}", key);
}
/// <summary>
/// Removes all cache entries whose keys start with <paramref name="prefix"/>.
/// Used for coarse-grained invalidation — for example, wiping all lookup data
/// for a single company by passing <c>"JobStatus_42"</c> prefix patterns.
/// Snapshot the matching keys first to avoid mutating the set while iterating.
/// </summary>
/// <param name="prefix">Key prefix to match.</param>
public void RemoveByPrefix(string prefix)
{
var keysToRemove = _cacheKeys.Where(k => k.StartsWith(prefix)).ToList();
foreach (var key in keysToRemove)
{
Remove(key);
}
_logger.LogDebug("Removed {Count} cache keys with prefix: {Prefix}", keysToRemove.Count, prefix);
}
}
@@ -0,0 +1,177 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Application.Services;
/// <summary>
/// Manages company logo files stored in Azure Blob Storage.
/// Logos are stored in the <c>companylogos</c> container using the path
/// <c>{companyId}/company-logo{ext}</c> (e.g. <c>7/company-logo.png</c>).
/// The service enforces a 10 MB size limit and restricts uploads to common
/// web-safe image formats. It also handles the "replace" case by deleting
/// blobs of every other allowed extension before writing the new one, so a
/// company can never end up with multiple active logos at different paths.
/// </summary>
public class CompanyLogoService : ICompanyLogoService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
/// <summary>Maximum logo file size accepted on upload (10 MB).</summary>
private const long MaxFileSize = 10 * 1024 * 1024; // 10 MB
/// <summary>
/// Exhaustive list of image extensions permitted for company logos.
/// SVG is included because some companies upload vector-format brand assets.
/// </summary>
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
/// <summary>
/// Initialises the service with the blob storage provider and storage
/// configuration (container names, etc.).
/// </summary>
public CompanyLogoService(
IAzureBlobStorageService blobService,
IOptions<StorageSettings> settings)
{
_blobService = blobService;
_settings = settings.Value;
}
/// <summary>
/// Returns the deterministic blob path for a company logo given its file
/// extension. Using a fixed name (rather than a GUID) allows the path to
/// be derived from <c>companyId</c> alone — useful for PDF embedding and
/// sidebar rendering without an extra DB lookup.
/// </summary>
/// <param name="companyId">The tenant company's database ID.</param>
/// <param name="extension">File extension including the leading dot (e.g. <c>.png</c>).</param>
/// <returns>Blob-relative path such as <c>7/company-logo.png</c>.</returns>
public string GetCompanyLogoPath(int companyId, string extension)
{
return $"{companyId}/company-logo{extension}";
}
/// <summary>
/// Validates and uploads a new company logo, replacing any existing logo
/// regardless of its extension. Old blobs at alternative extensions are
/// deleted first so the container never holds stale copies.
/// </summary>
/// <param name="file">The uploaded file from the HTTP request.</param>
/// <param name="companyId">The tenant company's database ID.</param>
/// <returns>
/// A tuple indicating success, the stored blob path (on success), and a
/// human-readable error message (on failure).
/// </returns>
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided");
if (file.Length > MaxFileSize)
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
// Delete old logo (any extension) before saving new one
await DeleteOldLogosAsync(companyId, extension);
var blobName = GetCompanyLogoPath(companyId, extension);
var contentType = GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
if (!result.Success)
return (false, string.Empty, result.ErrorMessage);
return (true, blobName, string.Empty);
}
/// <summary>
/// Deletes the company logo blob at the given path from Azure Blob Storage.
/// </summary>
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveCompanyLogoAsync"/>.</param>
/// <returns>Success flag and an error message on failure.</returns>
public async Task<(bool Success, string ErrorMessage)> DeleteCompanyLogoAsync(string filePath)
{
if (string.IsNullOrEmpty(filePath))
return (false, "File path is empty");
return await _blobService.DeleteAsync(_settings.Containers.CompanyLogos, filePath);
}
/// <summary>
/// Downloads the raw bytes of the company logo for embedding in PDFs or
/// serving through the <c>/CompanyLogo</c> controller endpoint.
/// </summary>
/// <param name="filePath">Blob-relative path of the logo.</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)> GetCompanyLogoAsync(string filePath)
{
if (string.IsNullOrEmpty(filePath))
return (false, Array.Empty<byte>(), string.Empty, "File path is empty");
return await _blobService.DownloadAsync(_settings.Containers.CompanyLogos, filePath);
}
/// <summary>
/// Checks whether a logo blob exists at the given path without downloading it.
/// Used by the sidebar to decide whether to show the tenant logo or fall back
/// to the default PCL logo.
/// </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> CompanyLogoExistsAsync(string filePath)
{
if (string.IsNullOrEmpty(filePath))
return false;
return await _blobService.ExistsAsync(_settings.Containers.CompanyLogos, filePath);
}
/// <summary>
/// Deletes any pre-existing logo blobs at extensions other than
/// <paramref name="currentExtension"/> so that uploading a PNG logo
/// automatically removes a previously stored JPG logo (and vice versa).
/// Errors are intentionally swallowed — a missing old blob is not a failure.
/// </summary>
/// <param name="companyId">The tenant company's database ID.</param>
/// <param name="currentExtension">
/// The extension of the incoming upload; blobs with this extension are
/// skipped because they will be overwritten by the caller.
/// </param>
private async Task DeleteOldLogosAsync(int companyId, string currentExtension)
{
foreach (var ext in AllowedExtensions)
{
if (ext == currentExtension) continue;
var oldBlobName = GetCompanyLogoPath(companyId, ext);
await _blobService.DeleteAsync(_settings.Containers.CompanyLogos, oldBlobName);
}
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// The correct content type is required so that browsers render the image
/// inline rather than triggering a download.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
private 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",
_ => "application/octet-stream"
};
}
@@ -0,0 +1,148 @@
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)
{
if (file == null || file.Length == 0)
return (false, string.Empty, "No file provided");
if (file.Length > MaxFileSize)
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
// Sanitize filename — replace OS-invalid characters with underscores to
// prevent path traversal and blob naming errors in Azure.
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(fileName))
fileName = "manual";
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
var contentType = 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);
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type.
/// Correct MIME types are required so browsers open PDFs inline and
/// Word documents prompt a compatible application rather than a raw download.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
private static string GetContentType(string extension) => extension switch
{
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt" => "text/plain",
_ => "application/octet-stream"
};
}
@@ -0,0 +1,215 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Application.Services;
/// <summary>
/// On-premises file storage service that saves, retrieves, and deletes files under the
/// application's <c>wwwroot/uploads/</c> directory. This service is the legacy storage path for
/// self-hosted deployments; cloud-hosted tenants use <see cref="AzureBlobStorageService"/> instead.
/// All file names are prefixed with a new GUID to prevent collisions and block path traversal
/// attacks that embed directory separators in the original file name.
/// </summary>
public class FileService : IFileService
{
private readonly IWebHostEnvironment _environment;
private readonly ILogger<FileService> _logger;
/// <summary>
/// Initializes the service with the hosting environment (provides <c>WebRootPath</c>) and logger.
/// </summary>
public FileService(IWebHostEnvironment environment, ILogger<FileService> logger)
{
_environment = environment;
_logger = logger;
}
/// <summary>
/// Validates and saves an uploaded file to the specified subfolder under <c>wwwroot/uploads/</c>.
/// Validation order: null/empty check, size limit, then extension allowlist. The original file
/// name is sanitised with <see cref="Path.GetFileName"/> to strip any directory components before
/// prepending the GUID prefix, preventing path traversal if the browser supplies a name with
/// slashes. Returns a relative path (from <c>wwwroot</c>) suitable for storing in the database.
/// </summary>
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveFileAsync(
IFormFile file,
string subfolder,
string[] allowedExtensions,
long maxFileSize)
{
try
{
// Validate file
if (file == null || file.Length == 0)
{
return (false, string.Empty, "No file was uploaded.");
}
// Validate file size
if (file.Length > maxFileSize)
{
var maxSizeMB = maxFileSize / (1024 * 1024);
return (false, string.Empty, $"File size exceeds the maximum allowed size of {maxSizeMB} MB.");
}
// Validate file extension
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
{
var allowedExts = string.Join(", ", allowedExtensions);
return (false, string.Empty, $"Invalid file type. Allowed types: {allowedExts}");
}
// Create upload directory if it doesn't exist
// NOTE: WebRootPath is read-only on Azure Linux App Service; this service is legacy
// and should only be called for on-premises deployments. New uploads use Azure Blob.
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads", subfolder);
if (!Directory.Exists(uploadPath))
{
try
{
Directory.CreateDirectory(uploadPath);
_logger.LogInformation("Created upload directory: {UploadPath}", uploadPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not create upload directory {UploadPath} — filesystem may be read-only", uploadPath);
return (false, string.Empty, "File storage is not available in this environment.");
}
}
// Generate unique filename — strip directory components from the original name
// to prevent path traversal if the browser sends a filename with slashes.
var safeOriginalName = Path.GetFileName(file.FileName);
var uniqueFileName = $"{Guid.NewGuid()}-{safeOriginalName}";
var filePath = Path.Combine(uploadPath, uniqueFileName);
// Save the file
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
// Return relative path from wwwroot
var relativePath = Path.Combine("uploads", subfolder, uniqueFileName).Replace("\\", "/");
_logger.LogInformation("File saved successfully: {FilePath}", relativePath);
return (true, relativePath, string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving file: {FileName}", file?.FileName);
return (false, string.Empty, "An error occurred while saving the file.");
}
}
/// <summary>
/// Deletes a file given its relative path from <c>wwwroot</c>.
/// Returns success if the file does not exist (idempotent) so that callers do not need to check
/// existence before calling. The relative path is converted to an absolute path with
/// <see cref="Path.Combine"/> rather than string concatenation to prevent directory traversal.
/// </summary>
public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath)
{
try
{
if (string.IsNullOrWhiteSpace(filePath))
{
return (false, "File path is required.");
}
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullPath))
{
_logger.LogWarning("File not found for deletion: {FilePath}", filePath);
return (true, string.Empty); // Consider it successful if file doesn't exist
}
await Task.Run(() => File.Delete(fullPath));
_logger.LogInformation("File deleted successfully: {FilePath}", filePath);
return (true, string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting file: {FilePath}", filePath);
return (false, "An error occurred while deleting the file.");
}
}
/// <summary>
/// Reads a file from disk and returns its raw bytes along with a derived MIME content type.
/// Intended for serving files that are stored outside <c>wwwroot</c> (or otherwise not directly
/// accessible via the static-files middleware) so controllers can stream them as file responses.
/// </summary>
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath)
{
try
{
if (string.IsNullOrWhiteSpace(filePath))
{
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
}
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullPath))
{
_logger.LogWarning("File not found: {FilePath}", filePath);
return (false, Array.Empty<byte>(), string.Empty, "File not found.");
}
var fileBytes = await File.ReadAllBytesAsync(fullPath);
var contentType = GetContentType(filePath);
return (true, fileBytes, contentType, string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving file: {FilePath}", filePath);
return (false, Array.Empty<byte>(), string.Empty, "An error occurred while retrieving the file.");
}
}
/// <summary>
/// Checks whether a file exists at the given <c>wwwroot</c>-relative path without reading it.
/// Used by views and controllers to conditionally show download links only when the file is present.
/// </summary>
public bool FileExists(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
return false;
}
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
return File.Exists(fullPath);
}
/// <summary>
/// Maps a file name or path to its MIME content type based on the extension.
/// Falls back to <c>application/octet-stream</c> for unrecognised extensions so the browser
/// triggers a download rather than attempting to render an unknown format inline.
/// </summary>
public string GetContentType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".pdf" => "application/pdf",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "application/octet-stream"
};
}
}
@@ -0,0 +1,155 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.Services;
/// <summary>
/// Manages job progress/completion photos stored in Azure Blob Storage.
/// Photos are stored in the <c>jobimages</c> container under the path
/// <c>{companyId}/job-photos/{jobId}/{guid}{ext}</c>.
/// <para>
/// A random GUID is used in the blob name (instead of a sequential ID or the
/// original filename) to prevent direct enumeration of photos across jobs by
/// guessing predictable URLs — a common IDOR (Insecure Direct Object Reference)
/// vulnerability pattern.
/// </para>
/// </summary>
public class JobPhotoService : IJobPhotoService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger<JobPhotoService> _logger;
/// <summary>Image extensions accepted for job photos.</summary>
private static readonly string[] AllowedImageTypes = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
/// <summary>Maximum photo size accepted on upload (10 MB).</summary>
private const long MaxPhotoSize = 10 * 1024 * 1024; // 10 MB
/// <summary>
/// Initialises the service with the blob storage provider, storage
/// configuration, and a logger for upload audit messages.
/// </summary>
public JobPhotoService(
IAzureBlobStorageService blobService,
IOptions<StorageSettings> settings,
ILogger<JobPhotoService> logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
/// <summary>
/// Validates and uploads a job photo to Azure Blob Storage.
/// A GUID is generated for the blob name to prevent IDOR enumeration attacks —
/// without it, an attacker could iterate <c>/job-photos/42/1.jpg</c>,
/// <c>/job-photos/42/2.jpg</c>, etc. to access another company's photos.
/// The <paramref name="caption"/> and <paramref name="photoType"/> parameters
/// are accepted for API symmetry but are persisted by the caller (controller)
/// in the <c>JobPhoto</c> entity, not by this service.
/// </summary>
/// <param name="file">The uploaded image file from the HTTP request.</param>
/// <param name="jobId">The job record's database ID.</param>
/// <param name="companyId">The tenant company's database ID (for path scoping).</param>
/// <param name="caption">Optional display caption stored by the caller.</param>
/// <param name="photoType">Photo classification (Before, After, Progress, etc.).</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)> SaveJobPhotoAsync(
IFormFile file,
int jobId,
int companyId,
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.");
// SECURITY: Use GUID for blob name to prevent enumeration
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
var contentType = GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
if (!result.Success)
return (false, string.Empty, result.ErrorMessage);
_logger.LogInformation("Job photo saved: {BlobName} for job {JobId}", blobName, jobId);
return (true, blobName, string.Empty);
}
/// <summary>
/// Deletes the job photo blob at the given path from Azure Blob Storage.
/// Called when a user removes a photo from the Job Details view.
/// </summary>
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveJobPhotoAsync"/>.</param>
/// <returns>Success flag and an error message on failure.</returns>
public async Task<(bool Success, string ErrorMessage)> DeleteJobPhotoAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return (false, "File path is required.");
return await _blobService.DeleteAsync(_settings.Containers.JobImages, filePath);
}
/// <summary>
/// Downloads the raw bytes of a job photo for serving through the controller's
/// photo-proxy endpoint (which enforces tenant authorization before streaming).
/// </summary>
/// <param name="filePath">Blob-relative path of the photo.</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)> GetJobPhotoAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
return await _blobService.DownloadAsync(_settings.Containers.JobImages, filePath);
}
/// <summary>
/// Checks whether a photo blob exists without downloading its content.
/// </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> JobPhotoExistsAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return false;
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"
};
}
@@ -0,0 +1,162 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Application.Services;
/// <summary>
/// Provides cached access to application lookup tables (job statuses,
/// job priorities, quote statuses, appointment statuses/types) to avoid
/// repeated database round-trips on every page load.
/// <para>
/// Lookup data is essentially static for normal operation — statuses and
/// priorities are only changed by SuperAdmins during platform configuration.
/// A 1-hour TTL is therefore appropriate, with <see cref="InvalidateCompanyCache"/>
/// available for immediate invalidation after any admin change.
/// </para>
/// </summary>
public interface ILookupCacheService
{
/// <summary>Returns all job status lookup rows, from cache if available.</summary>
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
Task<IEnumerable<JobStatusLookup>> GetJobStatusLookupsAsync(int companyId);
/// <summary>Returns all job priority lookup rows, from cache if available.</summary>
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
Task<IEnumerable<JobPriorityLookup>> GetJobPriorityLookupsAsync(int companyId);
/// <summary>Returns all quote status lookup rows, from cache if available.</summary>
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
Task<IEnumerable<QuoteStatusLookup>> GetQuoteStatusLookupsAsync(int companyId);
/// <summary>Returns all appointment status lookup rows, from cache if available.</summary>
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
Task<IEnumerable<AppointmentStatusLookup>> GetAppointmentStatusLookupsAsync(int companyId);
/// <summary>Returns all appointment type lookup rows, from cache if available.</summary>
/// <param name="companyId">Tenant company ID used to scope the cache key.</param>
Task<IEnumerable<AppointmentTypeLookup>> GetAppointmentTypeLookupsAsync(int companyId);
/// <summary>
/// Removes all lookup cache entries for the given company.
/// Call this after any SuperAdmin operation that modifies lookup tables so
/// subsequent requests reload fresh data from the database.
/// </summary>
/// <param name="companyId">The company whose cached lookups should be purged.</param>
void InvalidateCompanyCache(int companyId);
}
/// <summary>
/// Implementation of <see cref="ILookupCacheService"/> that delegates to
/// <see cref="ICachingService"/> for thread-safe get-or-create semantics and
/// to <see cref="IUnitOfWork"/> for database access.
/// <para>
/// Cache keys are scoped per company (e.g. <c>JobStatus_42</c>) even though
/// lookup tables are currently platform-wide. The per-company scoping is
/// forward-compatible with future per-tenant lookup customisation and allows
/// <see cref="InvalidateCompanyCache"/> to operate at company granularity
/// without flushing other tenants' cached data.
/// </para>
/// </summary>
public class LookupCacheService : ILookupCacheService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ICachingService _cachingService;
/// <summary>
/// Initialises the service with the unit-of-work (for DB access on misses)
/// and the caching service (for in-memory storage).
/// </summary>
public LookupCacheService(IUnitOfWork unitOfWork, ICachingService cachingService)
{
_unitOfWork = unitOfWork;
_cachingService = cachingService;
}
/// <summary>
/// Returns all job status rows. Cached for 1 hour because statuses change
/// only when a platform admin updates the lookup table configuration.
/// </summary>
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
public async Task<IEnumerable<JobStatusLookup>> GetJobStatusLookupsAsync(int companyId)
{
var cacheKey = $"JobStatus_{companyId}";
return await _cachingService.GetOrCreateAsync(
cacheKey,
async () => await _unitOfWork.JobStatusLookups.GetAllAsync(),
TimeSpan.FromHours(1) // Lookup data rarely changes
);
}
/// <summary>
/// Returns all job priority rows. Cached for 1 hour for the same reason as
/// <see cref="GetJobStatusLookupsAsync"/>.
/// </summary>
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
public async Task<IEnumerable<JobPriorityLookup>> GetJobPriorityLookupsAsync(int companyId)
{
var cacheKey = $"JobPriority_{companyId}";
return await _cachingService.GetOrCreateAsync(
cacheKey,
async () => await _unitOfWork.JobPriorityLookups.GetAllAsync(),
TimeSpan.FromHours(1)
);
}
/// <summary>
/// Returns all quote status rows. Cached for 1 hour.
/// </summary>
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
public async Task<IEnumerable<QuoteStatusLookup>> GetQuoteStatusLookupsAsync(int companyId)
{
var cacheKey = $"QuoteStatus_{companyId}";
return await _cachingService.GetOrCreateAsync(
cacheKey,
async () => await _unitOfWork.QuoteStatusLookups.GetAllAsync(),
TimeSpan.FromHours(1)
);
}
/// <summary>
/// Returns all appointment status rows. Cached for 1 hour.
/// </summary>
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
public async Task<IEnumerable<AppointmentStatusLookup>> GetAppointmentStatusLookupsAsync(int companyId)
{
var cacheKey = $"AppointmentStatus_{companyId}";
return await _cachingService.GetOrCreateAsync(
cacheKey,
async () => await _unitOfWork.AppointmentStatusLookups.GetAllAsync(),
TimeSpan.FromHours(1)
);
}
/// <summary>
/// Returns all appointment type rows. Cached for 1 hour.
/// </summary>
/// <param name="companyId">Tenant company ID — scopes the cache key.</param>
public async Task<IEnumerable<AppointmentTypeLookup>> GetAppointmentTypeLookupsAsync(int companyId)
{
var cacheKey = $"AppointmentType_{companyId}";
return await _cachingService.GetOrCreateAsync(
cacheKey,
async () => await _unitOfWork.AppointmentTypeLookups.GetAllAsync(),
TimeSpan.FromHours(1)
);
}
/// <summary>
/// Purges all five lookup cache entries for <paramref name="companyId"/>.
/// Each key is removed individually (rather than by prefix) because the
/// key names are known statically, making this more explicit and efficient
/// than a prefix scan of the key registry.
/// </summary>
/// <param name="companyId">The company whose cached lookups should be purged.</param>
public void InvalidateCompanyCache(int companyId)
{
_cachingService.Remove($"JobStatus_{companyId}");
_cachingService.Remove($"JobPriority_{companyId}");
_cachingService.Remove($"QuoteStatus_{companyId}");
_cachingService.Remove($"AppointmentStatus_{companyId}");
_cachingService.Remove($"AppointmentType_{companyId}");
}
}
@@ -0,0 +1,173 @@
namespace PowderCoating.Application.Services;
/// <summary>
/// Converts measurement values and produces display labels for the two unit systems the pricing
/// engine supports: Imperial (sq ft, lb) and Metric (sq m, kg). Centralised here so that every
/// caller — the pricing service, quote wizard, and reports — uses identical conversion factors
/// and rounding instead of duplicating them.
/// </summary>
public interface IMeasurementConversionService
{
/// <summary>
/// Converts square feet to square meters using the exact SI factor (1 sq ft = 0.09290304 sq m).
/// Result is rounded to 2 decimal places for display and database storage.
/// </summary>
decimal SquareFeetToMeters(decimal squareFeet);
/// <summary>
/// Converts square meters back to square feet by dividing by the same SI factor.
/// Inverse of <see cref="SquareFeetToMeters"/>; rounding to 2 decimals is applied consistently.
/// </summary>
decimal SquareMetersToFeet(decimal squareMeters);
/// <summary>
/// Converts pounds to kilograms using the exact avoirdupois factor (1 lb = 0.45359237 kg).
/// Used by the pricing engine when computing powder weight in metric mode.
/// </summary>
decimal PoundsToKilograms(decimal pounds);
/// <summary>
/// Converts kilograms to pounds by dividing by the avoirdupois factor.
/// Inverse of <see cref="PoundsToKilograms"/>; used when displaying metric-entered weights
/// in Imperial mode.
/// </summary>
decimal KilogramsToPounds(decimal kilograms);
/// <summary>
/// Returns the localised area unit label ("sq ft" or "sq m") for use in UI display strings
/// and report headers so that callers do not hard-code unit strings.
/// </summary>
string GetAreaUnitLabel(bool useMetric);
/// <summary>
/// Returns the localised weight unit label ("lb" or "kg") for UI display strings and reports.
/// </summary>
string GetWeightUnitLabel(bool useMetric);
/// <summary>
/// Returns the localised powder coverage unit label ("sq ft/lb" or "sq m/kg").
/// Coverage rate is the key input for powder quantity calculations.
/// </summary>
string GetCoverageUnitLabel(bool useMetric);
/// <summary>
/// Converts an area value between unit systems based on the source and target preferences.
/// When <paramref name="fromImperial"/> and <paramref name="toMetric"/> are the same system,
/// the value is returned unchanged to avoid double-conversion bugs.
/// </summary>
decimal ConvertArea(decimal value, bool fromImperial, bool toMetric);
/// <summary>
/// Converts a weight value between unit systems based on the source and target preferences.
/// Returns the value unchanged when both parameters indicate the same system.
/// </summary>
decimal ConvertWeight(decimal value, bool fromImperial, bool toMetric);
}
/// <summary>
/// Concrete implementation of <see cref="IMeasurementConversionService"/> using SI-exact conversion
/// constants. All results are rounded to 2 decimal places to match the precision stored in the
/// database and shown in the UI, preventing floating-point drift across conversion round-trips.
/// </summary>
public class MeasurementConversionService : IMeasurementConversionService
{
// Conversion constants
private const decimal SQ_FT_TO_SQ_M = 0.09290304m; // 1 sq ft = 0.09290304 sq m
private const decimal LB_TO_KG = 0.45359237m; // 1 lb = 0.45359237 kg
/// <summary>
/// Converts square feet to square meters. Rounds to 2 decimal places for storage consistency.
/// </summary>
public decimal SquareFeetToMeters(decimal squareFeet)
{
return Math.Round(squareFeet * SQ_FT_TO_SQ_M, 2);
}
/// <summary>
/// Converts square meters to square feet by dividing by the sq-ft-to-sq-m factor.
/// Rounds to 2 decimal places.
/// </summary>
public decimal SquareMetersToFeet(decimal squareMeters)
{
return Math.Round(squareMeters / SQ_FT_TO_SQ_M, 2);
}
/// <summary>
/// Converts pounds to kilograms. Rounds to 2 decimal places.
/// </summary>
public decimal PoundsToKilograms(decimal pounds)
{
return Math.Round(pounds * LB_TO_KG, 2);
}
/// <summary>
/// Converts kilograms to pounds by dividing by the lb-to-kg factor.
/// Rounds to 2 decimal places.
/// </summary>
public decimal KilogramsToPounds(decimal kilograms)
{
return Math.Round(kilograms / LB_TO_KG, 2);
}
/// <summary>
/// Returns "sq m" for metric mode, "sq ft" for imperial mode.
/// </summary>
public string GetAreaUnitLabel(bool useMetric)
{
return useMetric ? "sq m" : "sq ft";
}
/// <summary>
/// Returns "kg" for metric mode, "lb" for imperial mode.
/// </summary>
public string GetWeightUnitLabel(bool useMetric)
{
return useMetric ? "kg" : "lb";
}
/// <summary>
/// Returns "sq m/kg" for metric mode, "sq ft/lb" for imperial mode.
/// </summary>
public string GetCoverageUnitLabel(bool useMetric)
{
return useMetric ? "sq m/kg" : "sq ft/lb";
}
/// <summary>
/// Converts an area value from one unit system to another.
/// The parameter names use <c>fromImperial</c> and <c>toMetric</c> rather than a single enum
/// so that callers can independently specify the source data format and the desired output format.
/// No conversion is applied when source and target are the same system.
/// </summary>
public decimal ConvertArea(decimal value, bool fromImperial, bool toMetric)
{
// If converting from imperial to metric
if (fromImperial && toMetric)
return SquareFeetToMeters(value);
// If converting from metric to imperial
if (!fromImperial && !toMetric)
return SquareMetersToFeet(value);
// No conversion needed (same system)
return value;
}
/// <summary>
/// Converts a weight value from one unit system to another.
/// No conversion is applied when source and target are the same system.
/// </summary>
public decimal ConvertWeight(decimal value, bool fromImperial, bool toMetric)
{
// If converting from imperial to metric
if (fromImperial && toMetric)
return PoundsToKilograms(value);
// If converting from metric to imperial
if (!fromImperial && !toMetric)
return KilogramsToPounds(value);
// No conversion needed (same system)
return value;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,814 @@
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Application.Services;
/// <summary>
/// Core pricing engine for quotes and jobs. All dollar amounts produced here are stored as
/// snapshots on the Quote/Job rows at save time — they are NOT recalculated on every page load.
/// This means a quote's displayed total is always exactly what was presented to the customer,
/// even if operating costs change after the quote was created.
///
/// Pricing flow (high-level):
/// 1. Per-coat: material cost + labor cost → <see cref="CalculateCoatPriceAsync"/>
/// 2. Per-item: base + additional coats + complexity → <see cref="CalculateQuoteItemPriceAsync"/>
/// 3. Quote total: items + oven batch + shop supplies + discounts + rush + tax → <see cref="CalculateQuoteTotalsAsync"/>
///
/// Key design decisions documented inline:
/// - Custom powder charges the full ordered quantity; in-stock charges calculated usage only.
/// - Markup is baked into item prices, NOT added as a separate line at the quote level.
/// - Overhead was removed as a separate charge — it is now absorbed into the markup %.
/// - AI items bypass the pricing engine entirely (ManualUnitPrice used as-is).
/// - Oven cost is a quote-level batch charge, not per-item; scaled for AI item fraction.
/// </summary>
public class PricingCalculationService : IPricingCalculationService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PricingCalculationService> _logger;
private readonly IMeasurementConversionService _measurementService;
private readonly ITenantContext _tenantContext;
// Constants for additional cost calculations
private const decimal ConsumablesSurchargePercent = 0.05m; // 5% of material costs
// Sandblasting and masking multipliers removed - labor is the same for all tasks
// private const decimal SandblastingLaborMultiplier = 1.5m; // 1.5x base labor
// private const decimal MaskingLaborMultiplier = 0.5m; // 0.5x base labor
// private const decimal SandblastingTimePercent = 0.3m; // 30% of estimated time
public PricingCalculationService(
IUnitOfWork unitOfWork,
ILogger<PricingCalculationService> logger,
IMeasurementConversionService measurementService,
ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_logger = logger;
_measurementService = measurementService;
_tenantContext = tenantContext;
}
/// <summary>
/// Loads the operating costs row for a company. Returns null (and logs a warning) if none
/// is configured — callers fall back to zero pricing rather than throwing, so a misconfigured
/// company shows $0 quotes rather than crashing the wizard.
/// </summary>
public async Task<CompanyOperatingCosts?> GetOperatingCostsAsync(int companyId)
{
try
{
var costs = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId);
return costs.FirstOrDefault();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving operating costs for company {CompanyId}", companyId);
return null;
}
}
/// <summary>
/// Calculates the material and labor cost for a single coat on a single quote item.
///
/// Material cost rules:
/// - Custom powder (no InventoryItemId, manual PowderCostPerLb): charges for the full
/// PowderToOrder quantity, not just calculated usage. The shop must purchase the whole
/// bag/order for this job, so the customer pays for all of it.
/// - In-stock powder (InventoryItemId set): charges calculated usage only
/// (surface area × lbs/sqft ÷ transfer efficiency × unit cost).
/// - No surface area, no PowderToOrder → $0 material cost.
///
/// Labor cost rules:
/// - First coat (coatIndex 0): 100% of EstimatedMinutes × StandardLaborRate.
/// - Additional coats: AdditionalCoatLaborPercent % of base minutes.
/// - NoExtraLayerCharge flag → 0% multiplier for that coat (used for clear coats, etc.).
///
/// Surface area is converted from m² to sqft when the tenant uses metric, so all internal
/// math stays in imperial regardless of the UI unit setting.
/// </summary>
public async Task<QuoteItemCoatPricingResult> CalculateCoatPriceAsync(
CreateQuoteItemCoatDto coat,
decimal itemSurfaceAreaSqFt,
decimal quantity,
int coatIndex,
int estimatedMinutesBase,
int companyId)
{
var costs = await GetOperatingCostsAsync(companyId);
if (costs == null)
{
_logger.LogWarning("No operating costs configured for company {CompanyId}, using default values", companyId);
return new QuoteItemCoatPricingResult
{
CoatMaterialCost = 0,
CoatLaborCost = 0,
CoatTotalCost = 0
};
}
// Convert from metric if needed
var useMetric = await _tenantContext.UseMetricSystemAsync();
var perItemSurfaceAreaSqFt = itemSurfaceAreaSqFt;
if (useMetric)
{
perItemSurfaceAreaSqFt = _measurementService.SquareMetersToFeet(itemSurfaceAreaSqFt);
_logger.LogInformation("Converted surface area from {SqM} sq m to {SqFt} sq ft",
itemSurfaceAreaSqFt, perItemSurfaceAreaSqFt);
}
// 1. Calculate powder cost per sq ft for this coat (also track raw costPerLb for order-quantity billing)
decimal powderCostPerSqFt = costs.PowderCoatingCostPerSqFt; // Default
decimal costPerLb = 0m;
// A coat is "custom" (must be purchased) when it has no inventory item but has a manual price.
// In-stock coats reference an inventory item that already has stock on hand.
bool isCustomPowder = !coat.InventoryItemId.HasValue
&& coat.PowderCostPerLb.HasValue
&& coat.PowderCostPerLb.Value > 0;
if (coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
// Custom powder with manual cost
costPerLb = coat.PowderCostPerLb.Value;
var poundsPerSqFt = 1m / coat.CoverageSqFtPerLb;
var actualPoundsPerSqFt = poundsPerSqFt / (coat.TransferEfficiency / 100m);
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
_logger.LogInformation("Coat {CoatName}: Using custom powder cost: {Cost}/sqft",
coat.CoatName, powderCostPerSqFt);
}
else if (coat.InventoryItemId.HasValue && coat.InventoryItemId.Value > 0)
{
// In-stock powder - use inventory cost
try
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (inventoryItem != null && inventoryItem.UnitCost > 0)
{
costPerLb = inventoryItem.UnitCost;
var coverage = coat.CoverageSqFtPerLb;
var transferEfficiency = coat.TransferEfficiency;
var poundsPerSqFt = 1m / coverage;
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem}, UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
coat.CoatName, inventoryItem.Name, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not retrieve inventory item {InventoryItemId} for coat {CoatName}, using default powder cost",
coat.InventoryItemId.Value, coat.CoatName);
}
}
// 2. Calculate material cost for this coat
var batchSurfaceAreaSqFt = perItemSurfaceAreaSqFt * quantity;
decimal coatMaterialCost;
if (batchSurfaceAreaSqFt > 0 && isCustomPowder && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Custom powder that must be purchased: charge for the full ordered quantity, not just
// the calculated usage. The shop is spending money on the entire order for this job.
coatMaterialCost = coat.PowderToOrder.Value * costPerLb;
_logger.LogInformation("Coat {CoatName}: Custom powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
coat.CoatName, coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
}
else if (batchSurfaceAreaSqFt > 0)
{
// In-stock powder: charge for calculated usage only
coatMaterialCost = batchSurfaceAreaSqFt * powderCostPerSqFt;
}
else if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// No surface area (generic item): use PowderToOrder × cost per lb directly
coatMaterialCost = coat.PowderToOrder.Value * costPerLb;
_logger.LogInformation("Coat {CoatName}: Using PowderToOrder={Lbs}lb × ${CostPerLb}/lb = ${Total}",
coat.CoatName, coat.PowderToOrder.Value, costPerLb, coatMaterialCost);
}
else
{
coatMaterialCost = 0;
}
// 3. Calculate labor cost for this coat
// First coat = 100% of base minutes, each additional coat = configurable percent from operating costs
// NoExtraLayerCharge flag skips the additional layer charge (0x) for that specific coat
var additionalCoatPercent = costs.AdditionalCoatLaborPercent / 100m;
var laborMultiplier = coatIndex == 0 ? 1.0m : (coat.NoExtraLayerCharge ? 0m : additionalCoatPercent);
var coatMinutes = estimatedMinutesBase * laborMultiplier * quantity;
var coatLaborHours = coatMinutes / 60m;
var coatLaborCost = coatLaborHours * costs.StandardLaborRate;
_logger.LogInformation("Coat {CoatName} (index {Index}): Labor={LaborMult}x, Minutes={Minutes}, LaborCost=${LaborCost}",
coat.CoatName, coatIndex, laborMultiplier, coatMinutes, coatLaborCost);
return new QuoteItemCoatPricingResult
{
CoatMaterialCost = coatMaterialCost,
CoatLaborCost = coatLaborCost,
CoatTotalCost = coatMaterialCost + coatLaborCost
};
}
/// <summary>
/// Calculates the total price for a single quote line item, routing to the correct pricing
/// path based on item type:
///
/// • AI item (IsAiItem = true) — ManualUnitPrice used directly; engine skipped entirely.
/// • Sales item (IsSalesItem) — ManualUnitPrice × Qty; no coating math.
/// • Generic item (IsGenericItem) — ManualUnitPrice as base; custom powder coat material added on top.
/// • Labor item (IsLaborItem) — Qty treated as hours × StandardLaborRate; no markup.
/// • Catalog, no coats — DefaultPrice × Qty (or PowderCostOverride if set).
/// • Catalog with coats — DefaultPrice base + custom powder coat costs on top;
/// in-stock powder assumed baked into the catalog price.
/// • Calculated (surface area) — Material + labor + consumables surcharge + coating booth
/// + markup + additional coat % + complexity surcharge.
///
/// Markup (GeneralMarkupPercentage) is applied to materials only for calculated items —
/// labor and equipment rates are already the billable customer rates, not cost rates.
/// Prep service labor is added to the base for non-AI items when IncludePrepCost is true.
/// </summary>
public async Task<QuoteItemPricingResult> CalculateQuoteItemPriceAsync(CreateQuoteItemDto item, int companyId, decimal? ovenCostOverride = null)
{
var costs = await GetOperatingCostsAsync(companyId);
if (costs == null)
{
_logger.LogWarning("No operating costs configured for company {CompanyId}, using default values", companyId);
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = 0,
UnitPrice = 0,
TotalPrice = 0
};
}
// AI items use ManualUnitPrice directly (set to either the AI estimate or the user's price override).
// The AI already factored in all costs — skip the pricing engine entirely.
if (item.IsAiItem && item.ManualUnitPrice.HasValue)
{
var aiUnitPrice = item.ManualUnitPrice.Value;
var aiTotal = aiUnitPrice * item.Quantity;
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = aiTotal,
UnitPrice = aiUnitPrice,
TotalPrice = aiTotal
};
}
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
{
var total = item.ManualUnitPrice.Value * item.Quantity;
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = total,
UnitPrice = item.ManualUnitPrice.Value,
TotalPrice = total
};
}
// Generic items use a manually-entered flat price as the base;
// coat material costs (powder) are calculated and added on top.
if (item.IsGenericItem && item.ManualUnitPrice.HasValue)
{
decimal coatMaterialCost = 0;
if (item.Coats != null && item.Coats.Any())
{
for (int i = 0; i < item.Coats.Count; i++)
{
var coatResult = await CalculateCoatPriceAsync(
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
coatMaterialCost += coatResult.CoatMaterialCost;
}
}
var baseTotal = item.ManualUnitPrice.Value * item.Quantity;
var grandTotal = baseTotal + coatMaterialCost;
return new QuoteItemPricingResult
{
MaterialCost = coatMaterialCost,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = grandTotal,
UnitPrice = grandTotal / item.Quantity,
TotalPrice = grandTotal
};
}
// Labor items: Quantity = hours, priced at StandardLaborRate.
// No markup applied — the labor rate is already the billable customer rate.
if (item.IsLaborItem)
{
var laborHours = (decimal)item.Quantity;
var laborTotal = laborHours * costs.StandardLaborRate;
_logger.LogInformation("Labor item '{Description}': {Hours}h × ${Rate}/h = ${Total}",
item.Description, laborHours, costs.StandardLaborRate, laborTotal);
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = laborTotal,
EquipmentCost = 0,
ItemSubtotal = laborTotal,
UnitPrice = costs.StandardLaborRate,
TotalPrice = laborTotal
};
}
// If catalog item with no coats, use override price or catalog default price
if (item.CatalogItemId.HasValue && (item.Coats == null || !item.Coats.Any()))
{
decimal noCoatUnitPrice = item.PowderCostOverride ?? 0m;
if (noCoatUnitPrice == 0m)
{
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
noCoatUnitPrice = catalogItem?.DefaultPrice ?? 0m;
}
var catalogTotal = noCoatUnitPrice * item.Quantity;
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = catalogTotal,
UnitPrice = noCoatUnitPrice,
TotalPrice = catalogTotal
};
}
// ── Step 1: calculate base price ──────────────────────────────────────────
// For catalog items: DefaultPrice × Qty is the base (covers one standard coat).
// For non-catalog items: calculate first coat's material + labor + markup as the base.
bool isCatalogItem = item.CatalogItemId.HasValue;
_logger.LogInformation("Item {Description}: Qty={Qty}, SqFt={SqFt}, Coats={CoatCount}, IsCatalog={IsCatalog}",
item.Description, item.Quantity, item.SurfaceAreaSqFt, item.Coats?.Count ?? 0, isCatalogItem);
decimal baseSubtotal;
decimal totalMaterialCost = 0m;
decimal totalLaborCost = 0m;
decimal totalEquipmentCost = 0m;
if (isCatalogItem)
{
decimal baseUnitPrice = item.PowderCostOverride ?? 0m;
if (baseUnitPrice == 0m)
{
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId!.Value);
baseUnitPrice = catalogItem?.DefaultPrice ?? 0m;
}
baseSubtotal = baseUnitPrice * item.Quantity;
_logger.LogInformation("Catalog base: ${Price} × {Qty} = ${Base}", baseUnitPrice, item.Quantity, baseSubtotal);
// Optionally add prep service labor cost for catalog items (opt-in toggle in wizard)
// AI items exclude prep cost — it's already baked into the AI estimate
if (!item.IsAiItem && item.IncludePrepCost && item.PrepServices != null && item.PrepServices.Any())
{
var totalPrepMinutes = item.PrepServices.Sum(ps => ps.EstimatedMinutes);
var prepLaborCost = (totalPrepMinutes / 60m) * costs.StandardLaborRate;
baseSubtotal += prepLaborCost;
totalLaborCost += prepLaborCost;
_logger.LogInformation("Catalog item prep cost: {Min}min × ${Rate}/h = ${Cost}", totalPrepMinutes, costs.StandardLaborRate, prepLaborCost);
}
// Custom (non-inventory) powder coats must be purchased separately — add their material
// cost on top of the catalog base price. Inventory powder is assumed baked into DefaultPrice.
if (item.Coats != null && item.Coats.Any())
{
for (int ci = 0; ci < item.Coats.Count; ci++)
{
var coat = item.Coats[ci];
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
totalMaterialCost += coatResult.CoatMaterialCost;
baseSubtotal += coatResult.CoatMaterialCost;
_logger.LogInformation("Catalog item custom powder coat {Name}: +${Cost}", coat.CoatName, coatResult.CoatMaterialCost);
}
}
}
}
else
{
// Non-catalog: derive base from first coat's material + labor + equipment + markup
if (item.Coats != null && item.Coats.Count > 0)
{
var firstCoatResult = await CalculateCoatPriceAsync(
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
totalMaterialCost = firstCoatResult.CoatMaterialCost;
totalLaborCost = firstCoatResult.CoatLaborCost;
}
// Prep service labor (done once per item batch)
// AI items exclude prep cost — it's already baked into the AI estimate
if (!item.IsAiItem && item.PrepServices != null && item.PrepServices.Any())
{
var totalPrepMinutes = item.PrepServices.Sum(ps => ps.EstimatedMinutes);
var prepLaborHours = totalPrepMinutes / 60m;
totalLaborCost += prepLaborHours * costs.StandardLaborRate;
_logger.LogInformation("PrepServices={Count}, TotalPrepMinutes={Min}", item.PrepServices.Count, totalPrepMinutes);
}
// Consumables surcharge (5% of material)
totalMaterialCost += totalMaterialCost * ConsumablesSurchargePercent;
// Equipment cost: coating booth only (oven cost moved to quote-level batch calculation)
var totalLaborHours = totalLaborCost / costs.StandardLaborRate;
totalEquipmentCost = totalLaborHours * costs.CoatingBoothCostPerHour;
// Apply pricing mode: markup on material only, or target margin on total cost
if (costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost)
{
var totalCost = totalMaterialCost + totalLaborCost + totalEquipmentCost;
var margin = Math.Min(costs.TargetMarginPercent / 100m, 0.99m); // clamp to avoid divide-by-zero
baseSubtotal = margin < 0.001m ? totalCost : totalCost / (1m - margin);
_logger.LogInformation("Non-catalog base (margin mode): TotalCost=${C}, Margin={M}% → Base=${Base}",
totalCost, costs.TargetMarginPercent, baseSubtotal);
}
else
{
var materialWithMarkup = totalMaterialCost * (1 + costs.GeneralMarkupPercentage / 100m);
baseSubtotal = materialWithMarkup + totalLaborCost + totalEquipmentCost;
_logger.LogInformation("Non-catalog base (markup mode): Material=${M} (markup {Mk}% → ${MW}), Labor=${L}, Equipment=${E}, Base=${Base}",
totalMaterialCost, costs.GeneralMarkupPercentage, materialWithMarkup, totalLaborCost, totalEquipmentCost, baseSubtotal);
}
}
// ── Step 2: additional coat charges (% of base per additional coat) ──────
// Each additional coat (index 1+) without NoExtraLayerCharge adds
// AdditionalCoatLaborPercent % of the base subtotal.
int additionalCoatCount = 0;
if (item.Coats != null)
{
for (int i = 1; i < item.Coats.Count; i++)
{
if (!item.Coats[i].NoExtraLayerCharge)
additionalCoatCount++;
}
}
var additionalCoatCharge = baseSubtotal * additionalCoatCount * (costs.AdditionalCoatLaborPercent / 100m);
var itemSubtotal = baseSubtotal + additionalCoatCharge;
_logger.LogInformation("Additional coats: {Count} × {Pct}% of ${Base} = ${Charge}; Total=${Total}",
additionalCoatCount, costs.AdditionalCoatLaborPercent, baseSubtotal, additionalCoatCharge, itemSubtotal);
// ── Step 3: apply complexity multiplier (calculated items only) ────────
if (!isCatalogItem && !item.IsGenericItem && !item.IsLaborItem)
{
var complexityPercent = item.Complexity switch
{
"Moderate" => costs.ComplexityModeratePercent,
"Complex" => costs.ComplexityComplexPercent,
"Extreme" => costs.ComplexityExtremePercent,
_ => costs.ComplexitySimplePercent // "Simple" or null
};
if (complexityPercent > 0)
{
var complexityCharge = itemSubtotal * (complexityPercent / 100m);
_logger.LogInformation("Complexity '{Complexity}': +{Pct}% = +${Charge}",
item.Complexity ?? "Simple", complexityPercent, complexityCharge);
itemSubtotal += complexityCharge;
}
}
var unitPrice = item.Quantity > 0 ? itemSubtotal / item.Quantity : 0m;
var totalPrice = itemSubtotal;
return new QuoteItemPricingResult
{
MaterialCost = totalMaterialCost,
LaborCost = totalLaborCost,
EquipmentCost = totalEquipmentCost,
ItemSubtotal = itemSubtotal,
UnitPrice = unitPrice,
TotalPrice = totalPrice
};
}
/// <summary>
/// Calculates the complete quote total from all line items, applying quote-level charges,
/// discounts, and tax. The calculation sequence is strictly ordered (steps 114) and must
/// not be reordered, as each step feeds the next:
///
/// 1. Sum item subtotals (via <see cref="CalculateQuoteItemPriceAsync"/> per item).
/// 2. Add oven batch cost (batches × cycle hours × oven rate) — quote-level, not per-item.
/// AI items are excluded from the oven charge by scaling with the non-AI surface-area fraction.
/// 3. Add shop supplies charge (ShopSuppliesRate % of items + oven subtotal).
/// 4. Apply customer pricing-tier discount (percentage off subtotal before any quote discount).
/// 5. Apply quote-level discount (percentage or fixed amount, applied after tier discount).
/// 6. Apply rush fee (percentage or fixed amount from operating costs, if IsRushJob).
/// 7. Apply tax (manualTaxPercent if provided — used for tax-exempt customers — else CompanyTaxPercent).
/// 8. Round all values to 2 decimal places.
///
/// Overhead and profit margin are NOT added here — they were removed as separate line items.
/// Markup is now baked into per-item prices (step 1). The ProfitPercent field in the result
/// is the markup % from operating costs, kept for the breakdown display only.
/// </summary>
public async Task<QuotePricingResult> CalculateQuoteTotalsAsync(
List<CreateQuoteItemDto> items,
int companyId,
int? customerId = null,
decimal? manualTaxPercent = null,
string discountType = "None",
decimal discountValue = 0,
bool isRushJob = false,
decimal? ovenCostOverride = null,
int ovenBatches = 1,
int? ovenCycleMinutes = null)
{
var costs = await GetOperatingCostsAsync(companyId);
if (costs == null)
{
_logger.LogWarning("No operating costs configured for company {CompanyId}, returning zero pricing", companyId);
return new QuotePricingResult
{
ItemsSubtotal = 0,
ShopSuppliesAmount = 0,
ShopSuppliesPercent = 0,
OverheadCosts = 0,
OverheadPercent = 0,
ProfitMargin = 0,
ProfitPercent = 0,
SubtotalBeforeDiscount = 0,
DiscountAmount = 0,
DiscountPercent = 0,
SubtotalAfterDiscount = 0,
TaxAmount = 0,
TaxPercent = 0,
Total = 0,
MaterialCosts = 0,
LaborCosts = 0,
EquipmentCosts = 0
};
}
// Calculate all items
var itemResults = new List<QuoteItemPricingResult>();
foreach (var item in items)
{
QuoteItemPricingResult itemResult;
// Catalog items - if they have coats, add coat costs to catalog base price
if (item.CatalogItemId.HasValue)
{
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
if (catalogItem != null)
{
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
// (which already includes the catalog base price + coat costs)
if (item.Coats != null && item.Coats.Any())
{
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
}
else
{
// No coats - use simple catalog default price
var catalogItemTotal = catalogItem.DefaultPrice * item.Quantity;
itemResult = new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = catalogItemTotal,
UnitPrice = catalogItem.DefaultPrice,
TotalPrice = catalogItemTotal
};
}
}
else
{
// Catalog item not found, create zero result
itemResult = new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = 0,
UnitPrice = 0,
TotalPrice = 0
};
}
}
else
{
// Calculated items use the full pricing calculation
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
}
itemResults.Add(itemResult);
}
// 1. SEPARATE CATALOG ITEMS FROM CALCULATED ITEMS
// Catalog items WITHOUT coats are final prices - no breakdown
// Catalog items WITH coats should include their cost breakdown
var catalogItemsWithoutCoatsTotal = items
.Where(i => i.CatalogItemId.HasValue && (i.Coats == null || !i.Coats.Any()))
.Zip(itemResults.Where((r, idx) => items[idx].CatalogItemId.HasValue && (items[idx].Coats == null || !items[idx].Coats.Any())), (item, result) => result.ItemSubtotal)
.Sum();
// Include calculated items AND catalog items with coats in the breakdown
var itemsWithBreakdown = itemResults
.Where((r, idx) => !items[idx].CatalogItemId.HasValue || (items[idx].Coats != null && items[idx].Coats.Any()))
.ToList();
var calculatedItemsSubtotal = itemsWithBreakdown.Sum(r => r.ItemSubtotal);
var totalMaterialCosts = itemsWithBreakdown.Sum(r => r.MaterialCost);
var totalLaborCosts = itemsWithBreakdown.Sum(r => r.LaborCost);
var totalEquipmentCosts = itemsWithBreakdown.Sum(r => r.EquipmentCost);
// 2. OVERHEAD COSTS (removed - was calculated separately)
var overheadCosts = 0m;
// 3. PROFIT MARGIN (now baked into item prices, not added separately)
// Store whichever % was active so the breakdown display shows the right label.
var profitPercent = costs.PricingMode == PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost
? costs.TargetMarginPercent
: costs.GeneralMarkupPercentage;
var profitMargin = 0m; // Already included in item prices
// 4. TOTAL ITEMS SUBTOTAL
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
// AI items already have oven cost baked into their AI-estimated price, so we only
// charge the proportion of the oven that's attributable to non-AI items.
var effectiveOvenRate = ovenCostOverride ?? costs.OvenOperatingCostPerHour;
var effectiveCycleMinutes = ovenCycleMinutes.HasValue && ovenCycleMinutes.Value > 0
? ovenCycleMinutes.Value
: costs.DefaultOvenCycleMinutes;
var effectiveBatches = Math.Max(1, ovenBatches);
var fullOvenBatchCost = effectiveBatches * (effectiveCycleMinutes / 60m) * effectiveOvenRate;
// Scale oven cost by the fraction of total surface area coming from non-AI items.
// Use item count as a fallback when surface areas are all zero.
var totalSqFt = items.Sum(i => i.SurfaceAreaSqFt * i.Quantity);
var aiSqFt = items.Where(i => i.IsAiItem).Sum(i => i.SurfaceAreaSqFt * i.Quantity);
var nonAiSqFt = totalSqFt - aiSqFt;
decimal nonAiFraction;
if (totalSqFt > 0)
{
nonAiFraction = nonAiSqFt / totalSqFt;
}
else
{
var totalCount = items.Count;
var aiCount = items.Count(i => i.IsAiItem);
nonAiFraction = totalCount > 0 ? (decimal)(totalCount - aiCount) / totalCount : 1m;
}
var ovenBatchCost = fullOvenBatchCost * nonAiFraction;
_logger.LogInformation(
"Oven batch cost: {Batches} × {Min}min × ${Rate}/hr = ${Full}; non-AI fraction {Frac:P0} → charged ${Cost}",
effectiveBatches, effectiveCycleMinutes, effectiveOvenRate, fullOvenBatchCost, nonAiFraction, ovenBatchCost);
var itemsAndOvenSubtotal = itemsSubtotal + ovenBatchCost;
// 5. SHOP SUPPLIES (percentage of items + oven subtotal)
var shopSuppliesPercent = costs.ShopSuppliesRate;
var shopSuppliesAmount = itemsAndOvenSubtotal * (shopSuppliesPercent / 100m);
// 6. SUBTOTAL BEFORE DISCOUNT (items + oven + shop supplies)
var subtotalBeforeDiscount = itemsAndOvenSubtotal + shopSuppliesAmount;
// 7. CUSTOMER PRICING TIER DISCOUNT (if applicable)
var pricingTierDiscountPercent = 0m;
var pricingTierDiscountAmount = 0m;
if (customerId.HasValue)
{
try
{
var customers = await _unitOfWork.Customers.FindAsync(c => c.Id == customerId.Value);
var customer = customers.FirstOrDefault();
if (customer != null)
{
// Get pricing tier if assigned
if (customer.PricingTierId.HasValue)
{
var pricingTier = await _unitOfWork.PricingTiers.GetByIdAsync(customer.PricingTierId.Value);
if (pricingTier != null)
{
pricingTierDiscountPercent = pricingTier.DiscountPercent;
pricingTierDiscountAmount = subtotalBeforeDiscount * (pricingTierDiscountPercent / 100m);
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving customer {CustomerId} for discount calculation", customerId.Value);
}
}
var subtotalAfterTierDiscount = subtotalBeforeDiscount - pricingTierDiscountAmount;
// 8. QUOTE-LEVEL DISCOUNT (applied after pricing tier discount)
var quoteDiscountPercent = 0m;
var quoteDiscountAmount = 0m;
if (discountType != "None" && discountValue > 0)
{
if (discountType == "Percentage")
{
quoteDiscountPercent = discountValue;
quoteDiscountAmount = subtotalAfterTierDiscount * (discountValue / 100m);
}
else if (discountType == "FixedAmount")
{
quoteDiscountAmount = discountValue;
// Calculate what percentage this is for display purposes
if (subtotalAfterTierDiscount > 0)
{
quoteDiscountPercent = (discountValue / subtotalAfterTierDiscount) * 100m;
}
}
}
// 9. COMBINED DISCOUNT (pricing tier + quote-level)
var totalDiscountPercent = pricingTierDiscountPercent + quoteDiscountPercent;
var totalDiscountAmount = pricingTierDiscountAmount + quoteDiscountAmount;
// 10. SUBTOTAL AFTER ALL DISCOUNTS
var subtotalAfterDiscount = subtotalAfterTierDiscount - quoteDiscountAmount;
// 11. RUSH FEE (if applicable)
var rushFee = 0m;
if (isRushJob)
{
if (costs.RushChargeType == "Percentage")
{
rushFee = subtotalAfterDiscount * (costs.RushChargePercentage / 100m);
}
else // FixedAmount
{
rushFee = costs.RushChargeFixedAmount;
}
}
// 12. SUBTOTAL INCLUDING RUSH FEE
var subtotalWithRushFee = subtotalAfterDiscount + rushFee;
// 13. TAX
var taxPercent = manualTaxPercent ?? costs.TaxPercent;
var taxAmount = subtotalWithRushFee * (taxPercent / 100m);
// 14. FINAL TOTAL
var total = subtotalWithRushFee + taxAmount;
return new QuotePricingResult
{
ItemsSubtotal = Math.Round(itemsSubtotal, 2),
OvenBatchCost = Math.Round(ovenBatchCost, 2),
OvenBatches = effectiveBatches,
OvenCycleMinutes = effectiveCycleMinutes,
ShopSuppliesAmount = Math.Round(shopSuppliesAmount, 2),
ShopSuppliesPercent = Math.Round(shopSuppliesPercent, 2),
OverheadCosts = Math.Round(overheadCosts, 2),
OverheadPercent = 0m, // Overhead removed
ProfitMargin = Math.Round(profitMargin, 2), // 0 - now baked into item prices
ProfitPercent = Math.Round(profitPercent, 2), // Markup % used (for reference)
SubtotalBeforeDiscount = Math.Round(subtotalBeforeDiscount, 2),
// Separate discounts
PricingTierDiscountAmount = Math.Round(pricingTierDiscountAmount, 2),
PricingTierDiscountPercent = Math.Round(pricingTierDiscountPercent, 2),
QuoteDiscountAmount = Math.Round(quoteDiscountAmount, 2),
QuoteDiscountPercent = Math.Round(quoteDiscountPercent, 2),
// Combined discount (for backward compatibility)
DiscountAmount = Math.Round(totalDiscountAmount, 2),
DiscountPercent = Math.Round(totalDiscountPercent, 2),
SubtotalAfterDiscount = Math.Round(subtotalAfterDiscount, 2),
RushFee = Math.Round(rushFee, 2),
TaxAmount = Math.Round(taxAmount, 2),
TaxPercent = Math.Round(taxPercent, 2),
Total = Math.Round(total, 2),
MaterialCosts = Math.Round(totalMaterialCosts, 2),
LaborCosts = Math.Round(totalLaborCosts, 2),
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
ItemResults = itemResults
};
}
}
@@ -0,0 +1,190 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Application.Services;
/// <summary>
/// Manages user profile photos stored in Azure Blob Storage.
/// Photos are stored in the <c>profileimages</c> container under the
/// deterministic path <c>{companyId}/profile-photos/{userId}{ext}</c>
/// (e.g. <c>7/profile-photos/abc-guid.png</c>).
/// <para>
/// A deterministic path (keyed by userId rather than a random GUID) is used
/// here intentionally — unlike job photos, profile photos are accessed by
/// user ID from multiple places (nav bar, worker cards) and a predictable
/// path avoids the need to store it in an additional DB column. Access is
/// still protected: the <c>/Profile/Photo</c> endpoint validates that the
/// requesting user is authenticated before streaming the image.
/// </para>
/// </summary>
public class ProfilePhotoService : IProfilePhotoService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger<ProfilePhotoService> _logger;
/// <summary>Image extensions accepted for profile photos.</summary>
private static readonly string[] AllowedImageTypes = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
/// <summary>Maximum profile photo size accepted on upload (10 MB).</summary>
private const long MaxPhotoSize = 10 * 1024 * 1024; // 10 MB
/// <summary>
/// Initialises the service with the blob storage provider, storage
/// configuration, and a logger for upload audit messages.
/// </summary>
public ProfilePhotoService(
IAzureBlobStorageService blobService,
IOptions<StorageSettings> settings,
ILogger<ProfilePhotoService> logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
/// <summary>
/// Validates and uploads a user profile photo, replacing any existing photo
/// regardless of its extension. Old blobs at alternative extensions are
/// deleted first (via <see cref="DeleteOldPhotosForUserAsync"/>) so a user
/// never has multiple active photos in the container.
/// The blob path mirrors the former filesystem path to simplify the migration
/// from local storage to Azure without requiring a database schema change.
/// </summary>
/// <param name="file">The uploaded image file from the HTTP request.</param>
/// <param name="userId">The ASP.NET Identity user ID (GUID string).</param>
/// <param name="companyId">The tenant company's database ID (for path scoping).</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)> SaveProfilePhotoAsync(
IFormFile file,
string userId,
int companyId)
{
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.");
// Delete old photos for this user with different extensions
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
// Blob path mirrors former filesystem path
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
var contentType = GetContentType(extension);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
if (!result.Success)
return (false, string.Empty, result.ErrorMessage);
_logger.LogInformation("Profile photo saved: {BlobName} for user {UserId}", blobName, userId);
return (true, blobName, string.Empty);
}
/// <summary>
/// Deletes the profile photo blob at the given path from Azure Blob Storage.
/// Called when a user removes their avatar from the Profile settings page.
/// </summary>
/// <param name="filePath">Blob-relative path previously returned by <see cref="SaveProfilePhotoAsync"/>.</param>
/// <returns>Success flag and an error message on failure.</returns>
public async Task<(bool Success, string ErrorMessage)> DeleteProfilePhotoAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return (false, "File path is required.");
return await _blobService.DeleteAsync(_settings.Containers.ProfileImages, filePath);
}
/// <summary>
/// Downloads the raw bytes of a profile photo for serving through the
/// <c>/Profile/Photo</c> controller endpoint, which enforces authentication
/// before streaming the image to the client.
/// </summary>
/// <param name="filePath">Blob-relative path of the photo.</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)> GetProfilePhotoAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
return await _blobService.DownloadAsync(_settings.Containers.ProfileImages, filePath);
}
/// <summary>
/// Checks whether a profile photo blob exists without downloading its content.
/// Used by the nav bar and worker card views to decide whether to show the
/// user's avatar or a generic placeholder icon.
/// </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> ProfilePhotoExistsAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return false;
return await _blobService.ExistsAsync(_settings.Containers.ProfileImages, filePath);
}
/// <summary>
/// Attempts to delete any pre-existing profile photo blobs for the user at
/// extensions other than <paramref name="currentExtension"/> so that uploading
/// a new PNG replaces an old JPG (and vice versa).
/// Errors are caught and logged as warnings rather than propagated because a
/// missing old blob is not a blocking failure — the new photo should still be
/// saved successfully.
/// </summary>
/// <param name="companyId">The tenant company's database ID.</param>
/// <param name="userId">The ASP.NET Identity user ID.</param>
/// <param name="currentExtension">
/// Extension of the incoming upload; blobs with this extension are skipped
/// because they will be overwritten immediately afterwards.
/// </param>
private async Task DeleteOldPhotosForUserAsync(int companyId, string userId, string currentExtension)
{
try
{
foreach (var ext in AllowedImageTypes)
{
if (ext == currentExtension) continue;
var oldBlobName = $"{companyId}/profile-photos/{userId}{ext}";
await _blobService.DeleteAsync(_settings.Containers.ProfileImages, oldBlobName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error deleting old profile photos for user {UserId}", userId);
}
}
/// <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"
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Application.Services;
/// <summary>
/// Manages the two-phase lifecycle of AI photo quote images in Azure Blob Storage.
/// Phase 1 (temp): photos uploaded during the quote wizard are stored under
/// <c>temp/{tempId}/{guid}{ext}</c> before the quote is saved and has an ID.
/// Phase 2 (permanent): when the quote is created or edited, temp blobs are promoted to
/// <c>{companyId}/quote-photos/{quoteId}/{guid}{ext}</c> and the temp blobs are deleted.
/// Keeping photos in temp storage until the quote is committed prevents orphaned blobs from
/// quotes that are abandoned mid-wizard, and ensures blobs are never written to a quote-scoped
/// path before a valid quote ID exists.
/// </summary>
public class QuotePhotoService : IQuotePhotoService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger<QuotePhotoService> _logger;
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
/// <summary>
/// Initializes the service with the blob storage wrapper, storage configuration, and logger.
/// </summary>
public QuotePhotoService(
IAzureBlobStorageService blobService,
IOptions<StorageSettings> settings,
ILogger<QuotePhotoService> logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
// Temp blobs: temp/{tempId}/{guid}{ext}
// Permanent blobs: {companyId}/quote-photos/{quoteId}/{guid}{ext}
/// <summary>
/// Validates and uploads a photo to temporary blob storage, generating a new <c>tempId</c>
/// (GUID) that the client uses to reference this photo during the rest of the quote wizard.
/// A fresh GUID blob name is used within the temp folder so that concurrent uploads for the
/// same session do not overwrite each other.
/// </summary>
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
IFormFile file, int companyId)
{
if (file == null || file.Length == 0)
return (false, string.Empty, string.Empty, "No file provided.");
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed.");
var tempId = Guid.NewGuid().ToString("N");
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
var contentType = GetContentType(ext);
using var stream = file.OpenReadStream();
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
if (!result.Success)
return (false, string.Empty, string.Empty, result.ErrorMessage);
_logger.LogInformation("Saved temp quote photo {TempId}: {BlobName}", tempId, blobName);
return (true, tempId, blobName, string.Empty);
}
/// <summary>
/// Promotes a previously uploaded temp photo to its permanent blob path once the quote has been
/// saved and its ID is known. The photo is downloaded from temp storage and re-uploaded to the
/// permanent path rather than using a server-side copy API, which keeps the service compatible
/// with blob storage providers that do not support atomic renames. The temp blob is deleted
/// on a best-effort basis after a successful promotion.
/// </summary>
public async Task<(bool Success, string FilePath, string ErrorMessage)> PromoteTempPhotoAsync(
string tempId, int quoteId, int companyId)
{
var prefix = $"temp/{tempId}/";
var blobs = (await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix)).ToList();
if (blobs.Count == 0)
return (false, string.Empty, "Temp photo not found.");
var srcBlob = blobs[0];
var ext = Path.GetExtension(srcBlob);
var destBlob = $"{companyId}/quote-photos/{quoteId}/{Guid.NewGuid():N}{ext}";
// Download from temp then re-upload to permanent location
var download = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, srcBlob);
if (!download.Success)
return (false, string.Empty, "Failed to read temp photo.");
using var ms = new MemoryStream(download.Content);
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, GetContentType(ext));
if (!upload.Success)
return (false, string.Empty, "Failed to save permanent photo.");
// Best-effort cleanup of temp blob
await _blobService.DeleteAsync(_settings.Containers.QuoteImages, srcBlob);
_logger.LogInformation("Promoted temp photo {TempId} to {DestBlob}", tempId, destBlob);
return (true, destBlob, string.Empty);
}
/// <summary>
/// Downloads a photo from blob storage and returns its raw bytes and content type.
/// Used by the <c>Quotes/Photo</c> controller action which streams photos stored outside
/// the public web root so they are not directly URL-accessible.
/// </summary>
public async Task<(bool Success, byte[] Data, string ContentType)> ReadPhotoAsync(string filePath)
{
var result = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, filePath);
if (!result.Success)
return (false, Array.Empty<byte>(), string.Empty);
return (true, result.Content, result.ContentType);
}
/// <summary>
/// Deletes a photo blob from storage. Returns <c>true</c> if deletion succeeded (including the
/// case where the blob does not exist, which the underlying service treats as a no-op success).
/// </summary>
public async Task<bool> DeletePhotoAsync(string filePath)
{
var result = await _blobService.DeleteAsync(_settings.Containers.QuoteImages, filePath);
return result.Success;
}
/// <summary>
/// Downloads all blobs under the temp prefix for a given <paramref name="tempId"/> and returns
/// them as in-memory byte arrays. Used by the AI analysis endpoint to supply photo bytes to the
/// Anthropic API when the quote has not yet been saved and photos have no permanent path.
/// </summary>
public async Task<List<(byte[] Data, string ContentType, string FileName)>> ReadTempPhotosAsync(string tempId)
{
var prefix = $"temp/{tempId}/";
var blobs = (await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix)).ToList();
var results = new List<(byte[] Data, string ContentType, string FileName)>();
foreach (var blobName in blobs)
{
var download = await _blobService.DownloadAsync(_settings.Containers.QuoteImages, blobName);
if (download.Success)
results.Add((download.Content, download.ContentType, Path.GetFileName(blobName)));
}
return results;
}
/// <summary>
/// Deletes all blobs under the temp prefix for a given <paramref name="tempId"/>.
/// Called when the user cancels the quote wizard or when promotion fails partway through so
/// that orphaned temp blobs do not accumulate in storage. Each blob deletion is wrapped in
/// its own try/catch so that one failed delete does not abort cleanup of the remaining blobs.
/// </summary>
public async Task CleanupTempAsync(string tempId)
{
var prefix = $"temp/{tempId}/";
var blobs = await _blobService.ListBlobsByPrefixAsync(_settings.Containers.QuoteImages, prefix);
foreach (var blob in blobs)
{
try { await _blobService.DeleteAsync(_settings.Containers.QuoteImages, blob); }
catch (Exception ex) { _logger.LogWarning(ex, "Could not clean up temp blob {Blob}", blob); }
}
}
private static string GetContentType(string ext) => ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
@@ -0,0 +1,149 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.Services;
/// <summary>
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
///
/// Formula:
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
///
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
/// All multipliers are relative to that baseline.
/// </summary>
public static class ShopCapabilityCalculator
{
// ── Blast rate derivation ─────────────────────────────────────────────────
/// <summary>
/// Returns the effective blast rate in sqft/hr.
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
/// </summary>
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
{
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
return costs.BlastRateSqFtPerHourOverride.Value;
if (costs.CompressorCfm <= 0)
return 0m;
var baseRate = BaseByCfm(costs.CompressorCfm);
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
var setup = SetupMultiplier(costs.BlastSetupType);
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
return Math.Round(baseRate * nozzle * setup * substrate, 1);
}
/// <summary>
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
/// otherwise derives from the setup's equipment specs.
/// </summary>
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
{
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
return setup.BlastRateSqFtPerHourOverride.Value;
if (setup.CompressorCfm <= 0)
return 0m;
var baseRate = BaseByCfm(setup.CompressorCfm);
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
var setupMult = SetupMultiplier(setup.SetupType);
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
}
/// <summary>
/// Returns the effective coating application rate in sqft/hr.
/// If override is set, returns it directly.
/// Otherwise derives a sensible default from gun type.
/// </summary>
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
{
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
return costs.CoatingRateSqFtPerHourOverride.Value;
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
// Without more equipment data (voltage, gun model) we use a single reasonable default.
return costs.CoatingGunType switch
{
CoatingGunType.Corona => 40m,
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
CoatingGunType.Both => 40m,
_ => 40m
};
}
/// <summary>
/// Returns default equipment field values for a given capability tier.
/// Applied during Setup Wizard tier selection so the shop gets reasonable
/// starting values even if they never visit the Quoting Calibration tab.
/// </summary>
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
TierDefaults(ShopCapabilityTier tier) => tier switch
{
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
};
// ── Private helpers ───────────────────────────────────────────────────────
/// <summary>
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
/// Calibrated so that real-world examples produce expected results:
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
/// </summary>
private static decimal BaseByCfm(decimal cfm) => cfm switch
{
< 10 => 5m,
< 20 => 9m,
< 40 => 15m,
< 80 => 25m,
< 120 => 35m,
_ => 45m
};
private static decimal NozzleMultiplier(int nozzle) => nozzle switch
{
2 => 0.35m,
3 => 0.55m,
4 => 0.75m,
5 => 1.00m,
6 => 1.30m,
7 => 1.65m,
8 => 2.00m,
_ => 1.00m
};
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
{
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
BlastSetupType.SiphonPot => 0.70m,
BlastSetupType.PressurePot => 1.00m, // baseline
BlastSetupType.WetBlasting => 0.60m,
_ => 1.00m
};
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
{
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
BlastSubstrateType.Paint => 1.00m, // baseline
BlastSubstrateType.Mixed => 0.90m,
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
_ => 0.90m
};
}
@@ -0,0 +1,230 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Application.Services;
/// <summary>
/// One-time migration utility that copies files from the legacy local filesystem
/// (<c>wwwroot/media/</c>) to Azure Blob Storage containers, then optionally
/// deletes the originals.
/// <para>
/// The migration is <strong>idempotent</strong>: if a blob already exists in Azure
/// it is skipped (not overwritten). This means the operation is safe to run
/// multiple times — for example if a previous run was interrupted part-way through.
/// </para>
/// <para>
/// Container assignment is inferred from the relative path of each file
/// (see <see cref="DetermineContainer"/>). Files that do not match any known
/// path pattern are reported as failures but do not abort the overall run.
/// </para>
/// </summary>
public class StorageMigrationService : IStorageMigrationService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger<StorageMigrationService> _logger;
/// <summary>
/// Initialises the service with the blob storage provider, storage
/// configuration, and a logger for per-file progress messages.
/// </summary>
public StorageMigrationService(
IAzureBlobStorageService blobService,
IOptions<StorageSettings> settings,
ILogger<StorageMigrationService> logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
/// <summary>
/// Recursively enumerates all files under <paramref name="mediaBasePath"/>,
/// determines the correct Azure Blob Storage container for each file, and
/// uploads those that do not already exist in Azure.
/// </summary>
/// <param name="mediaBasePath">
/// Absolute path to the root media directory on the local filesystem
/// (e.g. <c>C:\app\wwwroot\media</c>).
/// </param>
/// <param name="deleteLocalAfterMigration">
/// When <c>true</c>, the local file is deleted immediately after a successful
/// upload. Defaults to <c>false</c> so a dry-run can be performed first.
/// <strong>Warning:</strong> set to <c>true</c> only after verifying the
/// migration result — deleted local files cannot be recovered without a backup.
/// </param>
/// <returns>
/// A <see cref="StorageMigrationResult"/> summarising how many files were
/// migrated, skipped (already in Azure), and failed, along with total bytes
/// transferred and elapsed time.
/// </returns>
public async Task<StorageMigrationResult> MigrateFilesystemToAzureAsync(
string mediaBasePath,
bool deleteLocalAfterMigration = false)
{
var result = new StorageMigrationResult();
var stopwatch = Stopwatch.StartNew();
if (!Directory.Exists(mediaBasePath))
{
result.Errors.Add($"Media directory not found: {mediaBasePath}");
result.Duration = stopwatch.Elapsed;
return result;
}
var allFiles = Directory.EnumerateFiles(mediaBasePath, "*.*", SearchOption.AllDirectories).ToList();
_logger.LogInformation("Starting filesystem-to-Azure migration. Found {Count} files in {Path}", allFiles.Count, mediaBasePath);
foreach (var fullPath in allFiles)
{
var relativePath = Path.GetRelativePath(mediaBasePath, fullPath).Replace("\\", "/");
var container = DetermineContainer(relativePath);
if (container is null)
{
_logger.LogWarning("Could not determine container for file: {RelativePath} — skipping", relativePath);
result.Errors.Add($"Unknown file type (no matching container): {relativePath}");
result.Failed++;
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = "unknown",
FileSize = new FileInfo(fullPath).Length,
Status = MigrationFileStatus.Failed
});
continue;
}
try
{
var fileInfo = new FileInfo(fullPath);
// Skip if blob already exists in Azure
var alreadyExists = await _blobService.ExistsAsync(container, relativePath);
if (alreadyExists)
{
_logger.LogDebug("Blob already exists, skipping: {Container}/{BlobName}", container, relativePath);
result.Skipped++;
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = container,
FileSize = fileInfo.Length,
Status = MigrationFileStatus.Skipped
});
continue;
}
var contentType = GetContentType(Path.GetExtension(fullPath).ToLowerInvariant());
await using var stream = File.OpenRead(fullPath);
var uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
if (!uploadResult.Success)
{
result.Failed++;
result.Errors.Add($"Upload failed for {relativePath}: {uploadResult.ErrorMessage}");
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = container,
FileSize = fileInfo.Length,
Status = MigrationFileStatus.Failed
});
continue;
}
result.Migrated++;
result.BytesMigrated += fileInfo.Length;
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = container,
FileSize = fileInfo.Length,
Status = MigrationFileStatus.Migrated
});
if (deleteLocalAfterMigration)
{
File.Delete(fullPath);
_logger.LogInformation("Deleted local file after migration: {FullPath}", fullPath);
}
_logger.LogInformation("Migrated: {RelativePath} → {Container}", relativePath, container);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error migrating file: {RelativePath}", relativePath);
result.Failed++;
result.Errors.Add($"Exception for {relativePath}: {ex.Message}");
result.Files.Add(new MigratedFileEntry
{
RelativePath = relativePath,
Container = container,
FileSize = 0,
Status = MigrationFileStatus.Failed
});
}
}
stopwatch.Stop();
result.Duration = stopwatch.Elapsed;
_logger.LogInformation(
"Migration complete. Migrated: {Migrated}, Skipped: {Skipped}, Failed: {Failed}, Duration: {Duration}",
result.Migrated, result.Skipped, result.Failed, result.Duration);
return result;
}
/// <summary>
/// Determines which Azure Blob Storage container a local file belongs in by
/// inspecting its path segments. The mapping mirrors the blob naming
/// conventions used by each file service:
/// <list type="bullet">
/// <item><c>/profile-photos/</c> → <c>profileimages</c> container</item>
/// <item><c>/job-photos/</c> → <c>jobimages</c> container</item>
/// <item><c>/equipment-manuals/</c> → <c>manuals</c> container</item>
/// <item><c>company-logo</c> (anywhere in path) → <c>companylogos</c> container</item>
/// </list>
/// Returns <c>null</c> for files that do not match any known pattern; the
/// caller treats these as failures and continues with the next file.
/// </summary>
/// <param name="relativePath">
/// Path of the file relative to the media root, with forward slashes.
/// </param>
/// <returns>Container name, or <c>null</c> if no match is found.</returns>
private string? DetermineContainer(string relativePath)
{
if (relativePath.Contains("/profile-photos/")) return _settings.Containers.ProfileImages;
if (relativePath.Contains("/job-photos/")) return _settings.Containers.JobImages;
if (relativePath.Contains("/equipment-manuals/"))return _settings.Containers.Manuals;
if (relativePath.Contains("company-logo")) return _settings.Containers.CompanyLogos;
return null;
}
/// <summary>
/// Maps a lowercase file extension to its canonical MIME content type for
/// the <c>Content-Type</c> metadata stored with each Azure blob. Correct
/// MIME types ensure browsers handle downloads appropriately (inline vs.
/// prompt for application) when blobs are accessed via signed URLs.
/// </summary>
/// <param name="extension">Lowercase file extension including the leading dot.</param>
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
private 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"
};
}