Files
PowderCoatingLogix/src/PowderCoating.Application/Services/StorageMigrationService.cs
T
spouliot dbe4170986 Add unit tests for 9 new services/controllers and expand existing test coverage
116 tests passing: JobPhotoService, MeasurementConversionService, PlatformSettingsService,
QuoteApprovalController, QuotePhotoService, ShopCapabilityCalculator, StorageMigrationService,
TenantContext, UsageQuotaController — plus expanded PricingCalculation, Registration, and
Subscription tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:27:30 -04:00

234 lines
10 KiB
C#

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());
(bool Success, string ErrorMessage) uploadResult;
await using (var stream = File.OpenRead(fullPath))
{
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"
};
}