using ErrorOr; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Pictures.Responses; using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.Services { public sealed record UploadRequest( UploadIntentPurpose Purpose, PictureRoutes Routes, string FileType, string ContentType, long? ExpectedSizeBytes); public sealed record IssuedUpload( Guid IntentId, string Key, string ObjectName, string ContentType, string UploadUrl); public sealed class UploadIntentIssuer( IDatabase database, IStorageService storageService, UploadIntentValidator uploadIntentValidator, ApplicationStorageConfiguration storageConfiguration, UploadIntentConfiguration uploadIntentConfiguration, ILogger logger) { public async Task>> IssueAsync( long userId, IReadOnlyList requests, CancellationToken cancellationToken) { var userExists = await database.Users .AsNoTracking() .AnyAsync(user => user.Id == userId, cancellationToken); if (!userExists) return AppErrors.NotFound("User", userId); var planned = requests .Select(request => { var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName); var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose); return (Request: request, ObjectName: objectName, Key: key, Size: size); }) .ToList(); await using var transaction = await database.BeginTransactionAsync( System.Data.IsolationLevel.Serializable, cancellationToken); var quota = await uploadIntentValidator.CheckQuotaAsync( userId, planned.Sum(item => item.Size), planned.Count, cancellationToken); if (quota.IsError) return quota.Errors; var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); var expiresAt = DateTime.UtcNow + expiresIn; var issued = new List(planned.Count); foreach (var item in planned) { var uploadUrl = await storageService.GetUploadUrlAsync( storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken); var intent = new UploadIntent { UserId = userId, Bucket = storageConfiguration.Bucket, Key = item.Key, ObjectName = item.ObjectName, ContentType = item.Request.ContentType, ExpectedSizeBytes = item.Size, Purpose = item.Request.Purpose, ExpiresAt = expiresAt, }; await database.UploadIntents.AddAsync(intent, cancellationToken); issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl)); } await database.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})", issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose))); return issued; } public async Task> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken) { var issued = await IssueAsync(userId, [request], cancellationToken); return issued.IsError ? issued.Errors : issued.Value[0]; } public async Task> IssuePictureAsync( long userId, UploadIntentPurpose purpose, IPictureOwnerConfiguration owner, string fileType, string contentType, long? expectedSizeBytes, CancellationToken cancellationToken) { var issued = await IssueOneAsync( userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken); if (issued.IsError) return issued.Errors; return new PictureUploadResponse( issued.Value.IntentId, storageConfiguration.Bucket, issued.Value.Key, issued.Value.ObjectName, issued.Value.ContentType, uploadIntentConfiguration.UploadUrlExpiresInSeconds, issued.Value.UploadUrl); } private long DefaultSize(UploadIntentPurpose purpose) => purpose == UploadIntentPurpose.TrackAudio ? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes : uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; } }