diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index 953b4f3a..36e47d4c 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -20,10 +20,12 @@ + + diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 6a5b1720..7e6e3299 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -7,10 +7,13 @@ using Features.Breweries.Controllers; using Features.Breweries.DependencyInjection; using Features.Emails.DependencyInjection; using Features.Emails.Services; +using Features.ImageUploads.Commands.UploadPhoto; +using Features.ImageUploads.DependencyInjection; using Features.UserManagement.Controllers; using Features.UserManagement.DependencyInjection; using FluentValidation; using FluentValidation.AspNetCore; +using Infrastructure.FileUpload; using Infrastructure.Jwt; using Microsoft.OpenApi.Models; using Shared.Application.Behaviors; @@ -65,6 +68,7 @@ builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly); +builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly); builder.Services.AddFluentValidationAutoValidation(); // Add MediatR. @@ -75,6 +79,7 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly); cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); + cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); }); @@ -93,10 +98,13 @@ builder.Services.AddFeaturesBreweries(); builder.Services.AddFeaturesUserManagement(); builder.Services.AddFeaturesAuth(); builder.Services.AddFeaturesEmails(); +builder.Services.AddFeaturesPhotoUpload(); // ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly. builder.Services.AddScoped(); +builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder diff --git a/web/backend/Biergarten.API.slnx b/web/backend/Biergarten.API.slnx index fea462df..2abf4157 100644 --- a/web/backend/Biergarten.API.slnx +++ b/web/backend/Biergarten.API.slnx @@ -31,6 +31,7 @@ + diff --git a/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoCommand.cs b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoCommand.cs new file mode 100644 index 00000000..851cfdef --- /dev/null +++ b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoCommand.cs @@ -0,0 +1,11 @@ +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace Features.ImageUploads.Commands.UploadPhoto; + +/// +/// Uploads a new photo to storage under {Key}/{generated photo id} and records it. Sent by +/// other features' handlers (e.g. a brewery photo upload command) rather than bound from a request +/// directly, since this feature exposes no controller of its own. +/// +public record UploadPhotoCommand(Guid UploadedById, string Key, IFormFile File) : IRequest; diff --git a/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs new file mode 100644 index 00000000..73c2d742 --- /dev/null +++ b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoHandler.cs @@ -0,0 +1,40 @@ +using Domain.Entities; +using Features.ImageUploads.Repository; +using Infrastructure.FileUpload; +using MediatR; + +namespace Features.ImageUploads.Commands.UploadPhoto; + +/// Handles . +public class UploadPhotoHandler(IFileStorageProvider fileStorageProvider, IPhotoUploadRepository photoUploadRepository) + : IRequestHandler +{ + public async Task Handle(UploadPhotoCommand request, CancellationToken cancellationToken) + { + Guid photoId = Guid.NewGuid(); + string key = $"{request.Key}/{photoId}"; + + await using Stream stream = request.File.OpenReadStream(); + + + await fileStorageProvider.UploadAsync( + key, + stream, + request.File.ContentType, + cancellationToken + ); + + + Photo photo = new() + { + Hyperlink = key, + UploadedById = request.UploadedById, + }; + + + await photoUploadRepository.CreateAsync(photo,cancellationToken); + + + return photoId; + } +} diff --git a/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs new file mode 100644 index 00000000..4d1549e8 --- /dev/null +++ b/web/backend/Features/Features.PhotoUpload/Commands/UploadPhoto/UploadPhotoValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; + +namespace Features.ImageUploads.Commands.UploadPhoto; + +/// Validates . +public class UploadPhotoValidator : AbstractValidator +{ + public UploadPhotoValidator() + { + RuleFor(x => x.UploadedById) + .NotEmpty() + .WithMessage("UploadedById is required."); + + RuleFor(x => x.File) + .NotNull() + .WithMessage("File is required."); + + RuleFor(x => x.Key) + .NotEmpty() + .WithMessage("Key is required."); + + + RuleFor(x => x.File.Length) + .GreaterThan(0) + .When(x => x.File is not null) + .WithMessage("File cannot be empty."); + + RuleFor(x => x.File.ContentType) + .Must(contentType => contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + .When(x => x.File is not null) + .WithMessage("File must be an image."); + } +} diff --git a/web/backend/Features/Features.PhotoUpload/DependencyInjection/FeaturesPhotoUploadServiceCollectionExtensions.cs b/web/backend/Features/Features.PhotoUpload/DependencyInjection/FeaturesPhotoUploadServiceCollectionExtensions.cs new file mode 100644 index 00000000..6e0a698d --- /dev/null +++ b/web/backend/Features/Features.PhotoUpload/DependencyInjection/FeaturesPhotoUploadServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Features.ImageUploads.Repository; +using Microsoft.Extensions.DependencyInjection; + +namespace Features.ImageUploads.DependencyInjection; + +/// Registers the services required by the PhotoUpload feature. +public static class FeaturesPhotoUploadServiceCollectionExtensions +{ + /// Registers and its implementation. + /// , for chaining. + public static IServiceCollection AddFeaturesPhotoUpload(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj b/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj index e30a34d5..7fdca1f2 100644 --- a/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj +++ b/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj @@ -15,8 +15,10 @@ + + diff --git a/web/backend/Features/Features.PhotoUpload/Repository/IPhotoUploadRepository.cs b/web/backend/Features/Features.PhotoUpload/Repository/IPhotoUploadRepository.cs new file mode 100644 index 00000000..e69de29b diff --git a/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs b/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs new file mode 100644 index 00000000..01ce66b1 --- /dev/null +++ b/web/backend/Features/Features.PhotoUpload/Repository/PhotoUploadRepository.cs @@ -0,0 +1,69 @@ +using System.Data.Common; +using Dapper; +using Database.Connection; +using Domain.Entities; +using Domain.Exceptions; + +namespace Features.ImageUploads.Repository; + +public interface IPhotoUploadRepository +{ + Task CreateAsync(Photo photo, CancellationToken cancellationToken); +} + +public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory) + : DapperRepository(connectionFactory), + IPhotoUploadRepository +{ + public async Task CreateAsync(Photo photo, CancellationToken cancellationToken) + { + + await using DbConnection connection = await CreateConnection(); + await using DbTransaction transaction = await connection.BeginTransactionAsync(); + + try + { + bool userExists = + await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT 1 + FROM + dbo.UserAccount + WHERE + UserAccountID = @UploadedById + """, + new { photo.UploadedById }, + transaction + ) + ) + is not null; + + if (!userExists) + throw new NotFoundException("User not found."); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.Photo (Hyperlink, UploadedByID) + VALUES (@Hyperlink, @UploadedById) + """, + new + { + photo.Hyperlink, + photo.UploadedById + }, + transaction + ) + ); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await RollbackQuietlyAsync(transaction); + throw; + } + } + +}