mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)
This commit is contained in:
@@ -5,7 +5,7 @@ using Shared.Application.Emails;
|
||||
namespace Features.Emails.Commands.SendRegistrationEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="SendRegistrationEmailCommand" />, the cross-slice command sent by Features.Auth
|
||||
/// Handles <see cref="SendRegistrationEmailCommand" />, the cross-slice command sent by Features.Users
|
||||
/// after a new user registers.
|
||||
/// </summary>
|
||||
public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher)
|
||||
|
||||
@@ -5,7 +5,7 @@ using Shared.Application.Emails;
|
||||
namespace Features.Emails.Commands.SendResendConfirmationEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="SendResendConfirmationEmailCommand" />, the cross-slice command sent by Features.Auth
|
||||
/// Handles <see cref="SendResendConfirmationEmailCommand" />, the cross-slice command sent by Features.Users
|
||||
/// when a user requests a fresh confirmation link.
|
||||
/// </summary>
|
||||
public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Features.Locations.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Locations.Commands.GetOrCreateCity;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <see cref="City" /> to a City ID, creating the Country, StateProvince, and/or City rows
|
||||
/// if any part of the chain doesn't exist yet. Sent by other features' handlers (e.g. brewery
|
||||
/// creation) rather than bound from a request directly, since this feature exposes no controller of
|
||||
/// its own.
|
||||
/// </summary>
|
||||
public record GetOrCreateCityCommand(CityLocation City) : IRequest<Guid>;
|
||||
@@ -0,0 +1,12 @@
|
||||
using Features.Locations.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Locations.Commands.GetOrCreateCity;
|
||||
|
||||
/// <summary>Handles <see cref="GetOrCreateCityCommand" /> by delegating to <see cref="ILocationRepository" />.</summary>
|
||||
public class GetOrCreateCityHandler(ILocationRepository repository)
|
||||
: IRequestHandler<GetOrCreateCityCommand, Guid>
|
||||
{
|
||||
public Task<Guid> Handle(GetOrCreateCityCommand request, CancellationToken cancellationToken) =>
|
||||
repository.GetOrCreateCityIdAsync(request.City);
|
||||
}
|
||||
@@ -25,7 +25,12 @@ public class UploadPhotoHandler(
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
Photo photo = new() { Hyperlink = key, UploadedById = request.UploadedById };
|
||||
Photo photo = new()
|
||||
{
|
||||
PhotoId = photoId,
|
||||
Hyperlink = key,
|
||||
UploadedById = request.UploadedById,
|
||||
};
|
||||
|
||||
await photoUploadRepository.CreateAsync(photo, cancellationToken);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
|
||||
public async Task CreateAsync(Photo photo, CancellationToken cancellationToken)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -44,10 +44,10 @@ public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.Photo (Hyperlink, UploadedByID)
|
||||
VALUES (@Hyperlink, @UploadedById)
|
||||
INSERT INTO dbo.Photo (PhotoID, Hyperlink, UploadedByID)
|
||||
VALUES (@PhotoId, @Hyperlink, @UploadedById)
|
||||
""",
|
||||
new { photo.Hyperlink, photo.UploadedById },
|
||||
new { photo.PhotoId, photo.Hyperlink, photo.UploadedById },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
using Domain.Entities;
|
||||
using Features.UserManagement.Commands.UpdateUser;
|
||||
using Features.UserManagement.Repository;
|
||||
using Moq;
|
||||
|
||||
namespace Features.UserManagement.Tests.Commands;
|
||||
|
||||
public class UpdateUserHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_DelegatesToRepository()
|
||||
{
|
||||
Mock<IUserAccountRepository> repoMock = new();
|
||||
UpdateUserHandler handler = new(repoMock.Object);
|
||||
UserAccount user = new() { UserAccountId = Guid.NewGuid() };
|
||||
repoMock.Setup(r => r.UpdateAsync(user)).Returns(Task.CompletedTask);
|
||||
|
||||
await handler.Handle(new UpdateUserCommand(user), CancellationToken.None);
|
||||
|
||||
repoMock.Verify(r => r.UpdateAsync(user), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Features.UserManagement.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Features.UserManagement\Features.UserManagement.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,10 +0,0 @@
|
||||
using Domain.Entities;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Commands.UpdateUser;
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing user account. Not currently exposed via any HTTP route, carried forward
|
||||
/// from <c>IUserService.UpdateAsync</c> as-is.
|
||||
/// </summary>
|
||||
public record UpdateUserCommand(UserAccount UserAccount) : IRequest;
|
||||
@@ -1,13 +0,0 @@
|
||||
using Features.UserManagement.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Commands.UpdateUser;
|
||||
|
||||
public class UpdateUserHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<UpdateUserCommand>
|
||||
{
|
||||
public Task Handle(UpdateUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return repository.UpdateAsync(request.UserAccount);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using Features.UserManagement.Repository;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.UserManagement.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the services used by the Features.UserManagement slice.
|
||||
/// </summary>
|
||||
public static class FeaturesUserManagementServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers <see cref="IUserAccountRepository" /> and its Dapper-based implementation.
|
||||
/// </summary>
|
||||
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddFeaturesUserManagement(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Features.UserManagement</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,33 +0,0 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
|
||||
namespace Features.UserManagement.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for CRUD operations on user account records.
|
||||
/// </summary>
|
||||
public interface IUserAccountRepository
|
||||
{
|
||||
/// <summary>Returns <c>null</c> if no user account exists with the given ID.</summary>
|
||||
Task<UserAccount?> GetByIdAsync(Guid id);
|
||||
|
||||
/// <summary>Returns user accounts ordered by creation date descending.</summary>
|
||||
/// <param name="limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
|
||||
|
||||
/// <summary>Updates the mutable fields of an existing user account.</summary>
|
||||
/// <param name="userAccount">Must have a valid <c>UserAccountId</c>.</param>
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
Task UpdateAsync(UserAccount userAccount);
|
||||
|
||||
/// <summary>Deletes the user account with the given ID.</summary>
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
Task DeleteAsync(Guid id);
|
||||
|
||||
/// <summary>Returns <c>null</c> if no user account exists with the given username.</summary>
|
||||
Task<UserAccount?> GetByUsernameAsync(string username);
|
||||
|
||||
/// <summary>Returns <c>null</c> if no user account exists with the given email address.</summary>
|
||||
Task<UserAccount?> GetByEmailAsync(string email);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
|
||||
namespace Features.UserManagement.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IUserAccountRepository" />.
|
||||
/// </summary>
|
||||
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserAccountRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = id }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
||||
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
|
||||
/// </remarks>
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
ORDER BY CreatedAt DESC
|
||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||
""",
|
||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateAsync(UserAccount userAccount)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
int rows = await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE dbo.UserAccount
|
||||
SET Username = @Username, FirstName = @FirstName, LastName = @LastName,
|
||||
DateOfBirth = @DateOfBirth, Email = @Email
|
||||
WHERE UserAccountId = @UserAccountId
|
||||
""",
|
||||
new
|
||||
{
|
||||
userAccount.UserAccountId,
|
||||
userAccount.Username,
|
||||
userAccount.FirstName,
|
||||
userAccount.LastName,
|
||||
userAccount.DateOfBirth,
|
||||
userAccount.Email,
|
||||
}
|
||||
);
|
||||
|
||||
if (rows == 0)
|
||||
throw new NotFoundException("User account not found.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
int rows = await connection.ExecuteAsync(
|
||||
"""
|
||||
DELETE FROM dbo.UserAccount
|
||||
WHERE UserAccountId = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = id }
|
||||
);
|
||||
|
||||
if (rows == 0)
|
||||
throw new NotFoundException("User account not found.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetByUsernameAsync(string username)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE Username = @Username
|
||||
""",
|
||||
new { Username = username }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetByEmailAsync(string email)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE Email = @Email
|
||||
""",
|
||||
new { Email = email }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.ConfirmUser;
|
||||
using Features.Auth.Commands.Authentication.ConfirmUser;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
@@ -1,5 +1,5 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.DeleteAccount;
|
||||
using Features.Auth.Commands.Account.DeleteAccount;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
@@ -1,4 +1,4 @@
|
||||
using Features.Auth.Commands.RefreshToken;
|
||||
using Features.Auth.Commands.Authentication.RefreshToken;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Services;
|
||||
using FluentAssertions;
|
||||
@@ -1,5 +1,5 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.RegisterUser;
|
||||
using Features.Auth.Commands.Authentication.RegisterUser;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
@@ -1,4 +1,4 @@
|
||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||
using Features.Auth.Commands.Authentication.ResendConfirmationEmail;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
@@ -1,5 +1,5 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdateEmail;
|
||||
using Features.Auth.Commands.Account.UpdateEmail;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
@@ -1,5 +1,5 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdatePassword;
|
||||
using Features.Auth.Commands.Account.UpdatePassword;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
@@ -1,5 +1,5 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdateProfile;
|
||||
using Features.Auth.Commands.Profile.UpdateProfile;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
@@ -1,5 +1,5 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdateUsername;
|
||||
using Features.Auth.Commands.Account.UpdateUsername;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
@@ -20,7 +20,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj" />
|
||||
<ProjectReference Include="..\Features.Users\Features.Users.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,17 +1,17 @@
|
||||
using Domain.Entities;
|
||||
using Features.UserManagement.Queries.GetAllUsers;
|
||||
using Features.UserManagement.Repository;
|
||||
using Features.Auth.Queries.GetAllUsers;
|
||||
using Features.Auth.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.UserManagement.Tests.Queries;
|
||||
namespace Features.Auth.Tests.Queries;
|
||||
|
||||
public class GetAllUsersHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
Mock<IUserAccountRepository> repoMock = new();
|
||||
Mock<IUserListRepository> repoMock = new();
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.UserManagement.Queries.GetUserById;
|
||||
using Features.UserManagement.Repository;
|
||||
using Features.Auth.Queries.GetUserById;
|
||||
using Features.Auth.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.UserManagement.Tests.Queries;
|
||||
namespace Features.Auth.Tests.Queries;
|
||||
|
||||
public class GetUserByIdHandlerTests
|
||||
{
|
||||
private readonly GetUserByIdHandler _handler;
|
||||
private readonly Mock<IUserAccountRepository> _repoMock = new();
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
|
||||
public GetUserByIdHandlerTests()
|
||||
{
|
||||
@@ -1,5 +1,5 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.Login;
|
||||
using Features.Auth.Commands.Authentication.Login;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
@@ -1,6 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.DeleteAccount;
|
||||
namespace Features.Auth.Commands.Account.DeleteAccount;
|
||||
|
||||
/// <summary>Permanently deletes the given user account.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
@@ -3,7 +3,7 @@ using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.DeleteAccount;
|
||||
namespace Features.Auth.Commands.Account.DeleteAccount;
|
||||
|
||||
/// <summary>Handles <see cref="DeleteAccountCommand" /> by deleting the user via <see cref="UserManager{TUser}" />.</summary>
|
||||
public class DeleteAccountHandler(UserManager<ApplicationUser> userManager)
|
||||
@@ -1,7 +1,7 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
namespace Features.Auth.Commands.Account.UpdateEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Changes the email address of the given user account. The account's <c>EmailConfirmed</c> status
|
||||
@@ -4,13 +4,13 @@ using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
namespace Features.Auth.Commands.Account.UpdateEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateEmailCommand" /> via <see cref="UserManager{TUser}" />.
|
||||
/// <see cref="UserManager{TUser}.SetEmailAsync" /> also resets <c>EmailConfirmed</c> to
|
||||
/// <see langword="false" />, so the user must re-confirm the new address (see
|
||||
/// <c>Features.Auth.Commands.ResendConfirmationEmail</c>).
|
||||
/// <c>Features.Users.Commands.ResendConfirmationEmail</c>).
|
||||
/// </summary>
|
||||
public class UpdateEmailHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateEmailCommand, UpdateEmailPayload>
|
||||
@@ -1,6 +1,6 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
namespace Features.Auth.Commands.Account.UpdateEmail;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateEmailCommand" />, mirroring the email rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateEmailValidator : AbstractValidator<UpdateEmailCommand>
|
||||
@@ -1,7 +1,7 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
namespace Features.Auth.Commands.Account.UpdatePassword;
|
||||
|
||||
/// <summary>Changes the password of the given user account, verifying the current password first.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
@@ -4,7 +4,7 @@ using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
namespace Features.Auth.Commands.Account.UpdatePassword;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdatePasswordCommand" /> via <see cref="UserManager{TUser}.ChangePasswordAsync" />,
|
||||
@@ -1,6 +1,6 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
namespace Features.Auth.Commands.Account.UpdatePassword;
|
||||
|
||||
/// <summary>Validates <see cref="UpdatePasswordCommand" />, mirroring the password rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdatePasswordValidator : AbstractValidator<UpdatePasswordCommand>
|
||||
@@ -1,7 +1,7 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
namespace Features.Auth.Commands.Account.UpdateUsername;
|
||||
|
||||
/// <summary>Changes the username of the given user account.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
@@ -4,7 +4,7 @@ using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
namespace Features.Auth.Commands.Account.UpdateUsername;
|
||||
|
||||
/// <summary>Handles <see cref="UpdateUsernameCommand" /> via <see cref="UserManager{TUser}" />.</summary>
|
||||
public class UpdateUsernameHandler(UserManager<ApplicationUser> userManager)
|
||||
@@ -1,6 +1,6 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
namespace Features.Auth.Commands.Account.UpdateUsername;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateUsernameCommand" />, mirroring the username rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateUsernameValidator : AbstractValidator<UpdateUsernameCommand>
|
||||
@@ -1,7 +1,7 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
namespace Features.Auth.Commands.Authentication.ConfirmUser;
|
||||
|
||||
/// <summary>Validates a confirmation token and confirms the corresponding user account.</summary>
|
||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||
@@ -5,7 +5,7 @@ using Features.Auth.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
namespace Features.Auth.Commands.Authentication.ConfirmUser;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
||||
@@ -1,7 +1,7 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.Login;
|
||||
namespace Features.Auth.Commands.Authentication.Login;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user using their username and password and issues new tokens. Bound directly
|
||||
@@ -5,7 +5,7 @@ using Features.Auth.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.Login;
|
||||
namespace Features.Auth.Commands.Authentication.Login;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="LoginCommand" /> by verifying credentials via
|
||||
@@ -1,6 +1,6 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.Login;
|
||||
namespace Features.Auth.Commands.Authentication.Login;
|
||||
|
||||
/// <summary>Validates that <see cref="LoginCommand.Username" /> and <see cref="LoginCommand.Password" /> are present.</summary>
|
||||
public class LoginValidator : AbstractValidator<LoginCommand>
|
||||
@@ -1,7 +1,7 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
namespace Features.Auth.Commands.Authentication.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the
|
||||
@@ -2,7 +2,7 @@ using Features.Auth.Dtos;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
namespace Features.Auth.Commands.Authentication.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
|
||||
@@ -1,6 +1,6 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
namespace Features.Auth.Commands.Authentication.RefreshToken;
|
||||
|
||||
/// <summary>Validates that <see cref="RefreshTokenCommand.RefreshToken" /> is present.</summary>
|
||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||
@@ -1,7 +1,7 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
namespace Features.Auth.Commands.Authentication.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
|
||||
@@ -1,5 +1,6 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.Profile.CreateUserProfile;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
@@ -7,7 +8,7 @@ using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
namespace Features.Auth.Commands.Authentication.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" />
|
||||
@@ -48,6 +49,8 @@ public class RegisterUserHandler(
|
||||
);
|
||||
}
|
||||
|
||||
await mediator.Send(new CreateUserProfileCommand(user.Id, string.Empty), cancellationToken);
|
||||
|
||||
string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName);
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
|
||||
@@ -1,6 +1,6 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
namespace Features.Auth.Commands.Authentication.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the field-level constraints on <see cref="RegisterUserCommand" />: username format and
|
||||
@@ -1,6 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail;
|
||||
|
||||
/// <summary>Resends the account confirmation email to a user, generating a fresh confirmation token.</summary>
|
||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||
@@ -4,7 +4,7 @@ using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.Profile.CreateUserProfile;
|
||||
|
||||
/// <summary>Creates a new user profile for a user account.</summary>
|
||||
public record CreateUserProfileCommand(Guid UserAccountId, string Biography) : IRequest<Guid>;
|
||||
@@ -0,0 +1,19 @@
|
||||
using Features.Auth.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.Profile.CreateUserProfile;
|
||||
|
||||
/// <summary>Handles <see cref="CreateUserProfileCommand" /> by inserting the new profile record.</summary>
|
||||
public class CreateUserProfileHandler(IUserProfileRepository userProfileRepository)
|
||||
: IRequestHandler<CreateUserProfileCommand, Guid>
|
||||
{
|
||||
public Task<Guid> Handle(
|
||||
CreateUserProfileCommand request,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
userProfileRepository.CreateProfileAsync(
|
||||
request.UserAccountId,
|
||||
request.Biography,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.Profile.UpdateBiography;
|
||||
|
||||
/// <summary>Updates the biography of the given user account's profile.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateBiographyCommand(Guid UserAccountId, string Biography) : IRequest;
|
||||
@@ -0,0 +1,19 @@
|
||||
using Features.Auth.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.Profile.UpdateBiography;
|
||||
|
||||
/// <summary>Handles <see cref="UpdateBiographyCommand" /> by updating the stored biography.</summary>
|
||||
public class UpdateBiographyHandler(IUserProfileRepository userProfileRepository)
|
||||
: IRequestHandler<UpdateBiographyCommand>
|
||||
{
|
||||
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||
/// Thrown when no user profile exists for the given user account.
|
||||
/// </exception>
|
||||
public Task Handle(UpdateBiographyCommand request, CancellationToken cancellationToken) =>
|
||||
userProfileRepository.UpdateBiographyAsync(
|
||||
request.UserAccountId,
|
||||
request.Biography,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
namespace Features.Auth.Commands.Profile.UpdateProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the non-credential profile fields (first name, last name, date of birth) of the given
|
||||
@@ -4,7 +4,7 @@ using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
namespace Features.Auth.Commands.Profile.UpdateProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateProfileCommand" /> via <see cref="UserManager{TUser}" />. First/last name
|
||||
@@ -1,6 +1,6 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
namespace Features.Auth.Commands.Profile.UpdateProfile;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateProfileCommand" />, mirroring the profile rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateProfileValidator : AbstractValidator<UpdateProfileCommand>
|
||||
@@ -0,0 +1,7 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Features.Auth.Commands.Profile.UploadAvatar;
|
||||
|
||||
/// <summary>Uploads a new avatar photo for a user account, replacing any existing avatar.</summary>
|
||||
public record UploadAvatarCommand(Guid UserAccountId, IFormFile File) : IRequest<Guid>;
|
||||
@@ -0,0 +1,42 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Repository;
|
||||
using Features.ImageUploads.Commands.UploadPhoto;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.Profile.UploadAvatar;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UploadAvatarCommand" />: uploads the photo via <see cref="UploadPhotoCommand" />,
|
||||
/// then saves the <see cref="UserAvatar" /> record, replacing any existing avatar for the user.
|
||||
/// </summary>
|
||||
public class UploadAvatarHandler(IMediator mediator, IUserProfileRepository userProfileRepository)
|
||||
: IRequestHandler<UploadAvatarCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(UploadAvatarCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
Guid photoId = await mediator.Send(
|
||||
new UploadPhotoCommand(
|
||||
request.UserAccountId,
|
||||
$"avatars/{request.UserAccountId}",
|
||||
request.File
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
Guid profileId = await userProfileRepository.GetProfileIdAsync(
|
||||
request.UserAccountId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
UserAvatar avatar = new()
|
||||
{
|
||||
UserAvatarId = Guid.NewGuid(),
|
||||
UserProfileId = profileId,
|
||||
PhotoId = photoId,
|
||||
};
|
||||
|
||||
await userProfileRepository.SaveAvatarAsync(avatar, cancellationToken);
|
||||
|
||||
return photoId;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.ConfirmUser;
|
||||
using Features.Auth.Commands.DeleteAccount;
|
||||
using Features.Auth.Commands.Login;
|
||||
using Features.Auth.Commands.RefreshToken;
|
||||
using Features.Auth.Commands.RegisterUser;
|
||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||
using Features.Auth.Commands.UpdateEmail;
|
||||
using Features.Auth.Commands.UpdatePassword;
|
||||
using Features.Auth.Commands.UpdateProfile;
|
||||
using Features.Auth.Commands.UpdateUsername;
|
||||
using Features.Auth.Commands.Account.DeleteAccount;
|
||||
using Features.Auth.Commands.Account.UpdateEmail;
|
||||
using Features.Auth.Commands.Account.UpdatePassword;
|
||||
using Features.Auth.Commands.Account.UpdateUsername;
|
||||
using Features.Auth.Commands.Authentication.ConfirmUser;
|
||||
using Features.Auth.Commands.Authentication.Login;
|
||||
using Features.Auth.Commands.Authentication.RefreshToken;
|
||||
using Features.Auth.Commands.Authentication.RegisterUser;
|
||||
using Features.Auth.Commands.Authentication.ResendConfirmationEmail;
|
||||
using Features.Auth.Commands.Profile.UpdateProfile;
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -1,11 +1,11 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.UserManagement.Queries.GetAllUsers;
|
||||
using Features.UserManagement.Queries.GetUserById;
|
||||
using Features.Auth.Queries.GetAllUsers;
|
||||
using Features.Auth.Queries.GetUserById;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Features.UserManagement.Controllers;
|
||||
namespace Features.Auth.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes read endpoints for user accounts.
|
||||
@@ -1,22 +1,27 @@
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Auth.DependencyInjection;
|
||||
namespace Features.Users.DependencyInjection;
|
||||
|
||||
/// <summary>Registers the services required by Features.Auth with the dependency injection container.</summary>
|
||||
public static class FeaturesAuthServiceCollectionExtensions
|
||||
/// <summary>Registers the services required by Features.Users with the dependency injection container.</summary>
|
||||
public static class FeaturesUsersServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds <see cref="ITokenService" /> and ASP.NET Core Identity (<see cref="UserManager{TUser}" />
|
||||
/// backed by <see cref="DapperUserStore" /> and <see cref="Argon2PasswordHasher" />) as scoped services.
|
||||
/// Adds <see cref="ITokenService" />, <see cref="IUserListRepository" />,
|
||||
/// <see cref="IUserProfileRepository" />, and ASP.NET Core Identity
|
||||
/// (<see cref="UserManager{TUser}" /> backed by <see cref="DapperUserStore" /> and
|
||||
/// <see cref="Argon2PasswordHasher" />) as scoped services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||
public static IServiceCollection AddFeaturesUsers(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
services.AddScoped<IUserListRepository, UserListRepository>();
|
||||
services.AddScoped<IUserProfileRepository, UserProfileRepository>();
|
||||
|
||||
// AddDefaultTokenProviders() is deliberately not called: it registers
|
||||
// DataProtectorTokenProvider<TUser>, which UserManager's constructor resolves eagerly and which
|
||||
@@ -18,11 +18,8 @@
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
<ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Queries\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -2,7 +2,7 @@ namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The user type driving <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" /> for
|
||||
/// Features.Auth. A plain POCO (not <c>IdentityUser<TKey></c>) backed by <see cref="DapperUserStore" />
|
||||
/// Features.Users. A plain POCO (not <c>IdentityUser<TKey></c>) backed by <see cref="DapperUserStore" />
|
||||
/// rather than EF Core, matching the rest of the solution's Dapper-only persistence.
|
||||
/// </summary>
|
||||
public sealed class ApplicationUser
|
||||
@@ -186,8 +186,9 @@ public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A plain <c>DELETE</c> against <c>dbo.UserAccount</c>: its foreign keys from
|
||||
/// <c>dbo.UserCredential</c>, <c>dbo.UserVerification</c>, and <c>dbo.UserAvatar</c> cascade, so no
|
||||
/// manual child cleanup is needed. Other tables (posts, comments, photos, follows) reference the
|
||||
/// <c>dbo.UserCredential</c>, <c>dbo.UserVerification</c>, and <c>dbo.UserProfile</c> cascade
|
||||
/// (<c>dbo.UserAvatar</c> cascades transitively through <c>dbo.UserProfile</c>), so no manual
|
||||
/// child cleanup is needed. Other tables (posts, comments, photos, follows) reference the
|
||||
/// account with <c>ON DELETE NO ACTION</c> by design; deleting an account that still has any of
|
||||
/// those fails with a foreign key violation, which is surfaced as a failed
|
||||
/// <see cref="IdentityResult" /> rather than an unhandled <see cref="SqlException" />.
|
||||
@@ -5,7 +5,7 @@ namespace Features.Auth.Identity;
|
||||
/// <summary>
|
||||
/// Converts the persistence-agnostic <see cref="UserAccount" /> domain entity into the
|
||||
/// <see cref="ApplicationUser" /> shape required by <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" />.
|
||||
/// Kept here, in Features.Auth, rather than on <see cref="UserAccount" /> itself so the domain layer has
|
||||
/// Kept here, in Features.Users, rather than on <see cref="UserAccount" /> itself so the domain layer has
|
||||
/// no dependency on ASP.NET Core Identity.
|
||||
/// </summary>
|
||||
public static class UserAccountExtensions
|
||||
@@ -1,10 +1,10 @@
|
||||
using Domain.Entities;
|
||||
using Features.UserManagement.Repository;
|
||||
using Features.Auth.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Queries.GetAllUsers;
|
||||
namespace Features.Auth.Queries.GetAllUsers;
|
||||
|
||||
public class GetAllUsersHandler(IUserAccountRepository repository)
|
||||
public class GetAllUsersHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
||||
{
|
||||
public Task<IEnumerable<UserAccount>> Handle(
|
||||
@@ -1,7 +1,7 @@
|
||||
using Domain.Entities;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Queries.GetAllUsers;
|
||||
namespace Features.Auth.Queries.GetAllUsers;
|
||||
|
||||
/// <summary>
|
||||
/// Requests a page of user accounts, ordered by creation date descending.
|
||||
@@ -1,11 +1,11 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.UserManagement.Repository;
|
||||
using Features.Auth.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Queries.GetUserById;
|
||||
namespace Features.Auth.Queries.GetUserById;
|
||||
|
||||
public class GetUserByIdHandler(IUserAccountRepository repository)
|
||||
public class GetUserByIdHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
@@ -1,6 +1,6 @@
|
||||
using Domain.Entities;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Queries.GetUserById;
|
||||
namespace Features.Auth.Queries.GetUserById;
|
||||
|
||||
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
||||
@@ -0,0 +1,21 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Read access for user account listing. Everything else on user accounts (lookup by
|
||||
/// username/email, create/update/delete) already goes through
|
||||
/// <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" />; this repository only covers
|
||||
/// what <c>IUserStore</c> has no equivalent for: fetching by ID with full audit fields, and
|
||||
/// paginated listing.
|
||||
/// </summary>
|
||||
public interface IUserListRepository
|
||||
{
|
||||
/// <summary>Returns <c>null</c> if no user account exists with the given ID.</summary>
|
||||
Task<UserAccount?> GetByIdAsync(Guid id);
|
||||
|
||||
/// <summary>Returns user accounts ordered by creation date descending.</summary>
|
||||
/// <param name="limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for user profile records and their avatars.
|
||||
/// </summary>
|
||||
public interface IUserProfileRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new user profile for <paramref name="userAccountId" /> with the given
|
||||
/// <paramref name="biography" /> and returns its <c>UserProfileID</c>.
|
||||
/// </summary>
|
||||
Task<Guid> CreateProfileAsync(
|
||||
Guid userAccountId,
|
||||
string biography,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the <c>UserProfileID</c> for <paramref name="userAccountId" />
|
||||
/// </summary>
|
||||
Task<Guid> GetProfileIdAsync(Guid userAccountId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the biography of the user profile belonging to <paramref name="userAccountId" />.
|
||||
/// </summary>
|
||||
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||
/// Thrown when no user profile exists for <paramref name="userAccountId" />.
|
||||
/// </exception>
|
||||
Task UpdateBiographyAsync(
|
||||
Guid userAccountId,
|
||||
string biography,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Persists <paramref name="avatar" />, replacing any existing avatar for the same user profile.
|
||||
/// </summary>
|
||||
Task SaveAvatarAsync(UserAvatar avatar, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IUserListRepository" />.
|
||||
/// </summary>
|
||||
public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserListRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT
|
||||
UserAccountID,
|
||||
Username,
|
||||
FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = id }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
||||
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
|
||||
/// </remarks>
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
ORDER BY CreatedAt DESC
|
||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||
""",
|
||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IUserProfileRepository" />.
|
||||
/// </summary>
|
||||
public class UserProfileRepository(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserProfileRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Guid> CreateProfileAsync(
|
||||
Guid userAccountId,
|
||||
string biography,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
return await connection.ExecuteScalarAsync<Guid>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserProfile (UserAccountID, Biography)
|
||||
OUTPUT INSERTED.UserProfileID
|
||||
VALUES (@UserAccountId, @Biography)
|
||||
""",
|
||||
new { UserAccountId = userAccountId, Biography = biography },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Guid> GetProfileIdAsync(
|
||||
Guid userAccountId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
Guid? userProfileId = await connection.ExecuteScalarAsync<Guid?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT UserProfileID
|
||||
FROM dbo.UserProfile
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
return userProfileId
|
||||
?? throw new NotFoundException(
|
||||
$"No user profile found for user account ID {userAccountId}"
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateBiographyAsync(
|
||||
Guid userAccountId,
|
||||
string biography,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
int updatedRows = await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserProfile
|
||||
SET Biography = @Biography
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId, Biography = biography },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (updatedRows == 0)
|
||||
throw new NotFoundException(
|
||||
$"No user profile found for user account ID {userAccountId}"
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveAvatarAsync(UserAvatar avatar, CancellationToken cancellationToken)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync(
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserAvatar
|
||||
SET ValidTo = SYSUTCDATETIME()
|
||||
WHERE UserProfileID = @UserProfileId AND ValidTo IS NULL
|
||||
""",
|
||||
new { avatar.UserProfileId },
|
||||
transaction: transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserAvatar (UserAvatarID, UserProfileID, PhotoID)
|
||||
VALUES (@UserAvatarId, @UserProfileId, @PhotoId)
|
||||
""",
|
||||
new
|
||||
{
|
||||
avatar.UserAvatarId,
|
||||
avatar.UserProfileId,
|
||||
avatar.PhotoId,
|
||||
},
|
||||
transaction: transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await RollbackQuietlyAsync(transaction);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user