using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PowderCoating.Application.Configuration; using PowderCoating.Application.Interfaces; namespace PowderCoating.Application.Services; /// /// Manages user profile photos stored in Azure Blob Storage. /// Photos are stored in the profileimages container under the /// deterministic path {companyId}/profile-photos/{userId}{ext} /// (e.g. 7/profile-photos/abc-guid.png). /// /// 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 /Profile/Photo endpoint validates that the /// requesting user is authenticated before streaming the image. /// /// public class ProfilePhotoService : IProfilePhotoService { private readonly IAzureBlobStorageService _blobService; private readonly StorageSettings _settings; private readonly ILogger _logger; /// Image extensions accepted for profile photos. private static readonly string[] AllowedImageTypes = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; /// Maximum profile photo size accepted on upload (10 MB). private const long MaxPhotoSize = 10 * 1024 * 1024; // 10 MB /// /// Initialises the service with the blob storage provider, storage /// configuration, and a logger for upload audit messages. /// public ProfilePhotoService( IAzureBlobStorageService blobService, IOptions settings, ILogger logger) { _blobService = blobService; _settings = settings.Value; _logger = logger; } /// /// Validates and uploads a user profile photo, replacing any existing photo /// regardless of its extension. Old blobs at alternative extensions are /// deleted first (via ) 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. /// /// The uploaded image file from the HTTP request. /// The ASP.NET Identity user ID (GUID string). /// The tenant company's database ID (for path scoping). /// /// A tuple with a success flag, the stored blob path (on success), and a /// human-readable error message (on failure). /// public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveProfilePhotoAsync( IFormFile file, string userId, int companyId) { var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize); if (!isValid) return (false, string.Empty, error); // 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 = BlobFileHelper.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); } /// /// 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. /// /// Blob-relative path previously returned by . /// Success flag and an error message on failure. 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); } /// /// Downloads the raw bytes of a profile photo for serving through the /// /Profile/Photo controller endpoint, which enforces authentication /// before streaming the image to the client. /// /// Blob-relative path of the photo. /// /// A tuple with a success flag, the raw file bytes, the MIME content type, /// and an error message on failure. /// public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetProfilePhotoAsync(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) return (false, Array.Empty(), string.Empty, "File path is required."); return await _blobService.DownloadAsync(_settings.Containers.ProfileImages, filePath); } /// /// 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. /// /// Blob-relative path to check. /// true if the blob exists; otherwise false. public async Task ProfilePhotoExistsAsync(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) return false; return await _blobService.ExistsAsync(_settings.Containers.ProfileImages, filePath); } /// /// Attempts to delete any pre-existing profile photo blobs for the user at /// extensions other than 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. /// /// The tenant company's database ID. /// The ASP.NET Identity user ID. /// /// Extension of the incoming upload; blobs with this extension are skipped /// because they will be overwritten immediately afterwards. /// 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); } } }