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;
+ }
+ }
+
+}