Add PhotoUpload feature for uploading photos to file storage

Adds UploadPhotoCommand/Handler/Validator and PhotoUploadRepository, invoked via MediatR by other features' commands (e.g. a future CreateBreweryPhoto) rather than exposed through its own controller. Wires the feature and IFileStorageProvider into API.Core.
This commit is contained in:
Aaron Po
2026-08-28 00:32:53 -04:00
parent 999c94eedc
commit 05882f36e4
10 changed files with 182 additions and 0 deletions

View File

@@ -20,10 +20,12 @@
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" /> <ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" /> <ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -7,10 +7,13 @@ using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection; using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection; using Features.Emails.DependencyInjection;
using Features.Emails.Services; using Features.Emails.Services;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection;
using Features.UserManagement.Controllers; using Features.UserManagement.Controllers;
using Features.UserManagement.DependencyInjection; using Features.UserManagement.DependencyInjection;
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.FileUpload;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors; using Shared.Application.Behaviors;
@@ -65,6 +68,7 @@ builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly);
builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddFluentValidationAutoValidation();
// Add MediatR. // Add MediatR.
@@ -75,6 +79,7 @@ builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly); cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
}); });
@@ -93,10 +98,13 @@ builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement(); builder.Services.AddFeaturesUserManagement();
builder.Services.AddFeaturesAuth(); builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesEmails(); builder.Services.AddFeaturesEmails();
builder.Services.AddFeaturesPhotoUpload();
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly. // ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>(); builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddSingleton<IFileStorageProvider, S3FileStorageProvider>();
builder.Services.AddScoped<GlobalExceptionFilter>(); builder.Services.AddScoped<GlobalExceptionFilter>();
builder builder

View File

@@ -31,6 +31,7 @@
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" /> <Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
<Project Path="Features/Features.Locations/Features.Locations.csproj" /> <Project Path="Features/Features.Locations/Features.Locations.csproj" />
<Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" /> <Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" />
<Project Path="Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
</Folder> </Folder>
<Folder Name="/Shared/"> <Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" /> <Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />

View File

@@ -0,0 +1,11 @@
using MediatR;
using Microsoft.AspNetCore.Http;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>
/// Uploads a new photo to storage under <c>{Key}/{generated photo id}</c> 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.
/// </summary>
public record UploadPhotoCommand(Guid UploadedById, string Key, IFormFile File) : IRequest<Guid>;

View File

@@ -0,0 +1,40 @@
using Domain.Entities;
using Features.ImageUploads.Repository;
using Infrastructure.FileUpload;
using MediatR;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>Handles <see cref="UploadPhotoCommand" />.</summary>
public class UploadPhotoHandler(IFileStorageProvider fileStorageProvider, IPhotoUploadRepository photoUploadRepository)
: IRequestHandler<UploadPhotoCommand, Guid>
{
public async Task<Guid> 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;
}
}

View File

@@ -0,0 +1,33 @@
using FluentValidation;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>Validates <see cref="UploadPhotoCommand" />.</summary>
public class UploadPhotoValidator : AbstractValidator<UploadPhotoCommand>
{
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.");
}
}

View File

@@ -0,0 +1,16 @@
using Features.ImageUploads.Repository;
using Microsoft.Extensions.DependencyInjection;
namespace Features.ImageUploads.DependencyInjection;
/// <summary>Registers the services required by the PhotoUpload feature.</summary>
public static class FeaturesPhotoUploadServiceCollectionExtensions
{
/// <summary>Registers <see cref="IPhotoUploadRepository" /> and its implementation.</summary>
/// <returns><paramref name="services" />, for chaining.</returns>
public static IServiceCollection AddFeaturesPhotoUpload(this IServiceCollection services)
{
services.AddScoped<IPhotoUploadRepository, PhotoUploadRepository>();
return services;
}
}

View File

@@ -15,8 +15,10 @@
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -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<int?>(
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;
}
}
}