Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)

This commit is contained in:
Aaron Po
2026-08-31 00:11:48 -04:00
committed by GitHub
parent ade2128a73
commit 39c5a89ca7
130 changed files with 1005 additions and 613 deletions

View File

@@ -22,8 +22,7 @@
<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.UserManagement\Features.UserManagement.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.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" />

View File

@@ -22,8 +22,6 @@ COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Datab
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
@@ -32,8 +30,8 @@ COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Feature
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]

View File

@@ -2,15 +2,13 @@ using API.Core;
using API.Core.Authentication;
using Database.Connection.DependencyInjection;
using Features.Auth.Controllers;
using Features.Auth.DependencyInjection;
using Features.Users.DependencyInjection;
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;
@@ -25,12 +23,8 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Add current controllers to the exception filter
builder
.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
})
.Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); })
.AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(UserController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddEndpointsApiExplorer();
@@ -69,7 +63,6 @@ builder.Services.AddOpenApi();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
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();
@@ -80,7 +73,6 @@ builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
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);
@@ -98,8 +90,7 @@ if (!builder.Environment.IsProduction())
builder.Services.AddDatabaseConnection();
builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement();
builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesUsers();
builder.Services.AddFeaturesEmails();
builder.Services.AddFeaturesPhotoUpload();
@@ -130,20 +121,6 @@ app.MapHealthChecks("/health");
app.MapControllers();
app.MapPost(
"/upload-test",
async (IFormFile file, ClaimsPrincipal user, IMediator mediator) =>
{
Guid uploadedById = Guid.Parse(user.FindFirst(JwtRegisteredClaimNames.Sub)!.Value);
Guid photoId = await mediator.Send(
new UploadPhotoCommand(uploadedById, "upload-test", file)
);
return Results.Ok(photoId);
}
)
.RequireAuthorization()
.DisableAntiforgery();
app.MapFallbackToController("Handle404", "NotFound");
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
@@ -152,4 +129,4 @@ lifetime.ApplicationStopping.Register(() =>
app.Logger.LogInformation("Application is shutting down gracefully...");
});
app.Run();
app.Run();

View File

@@ -12,8 +12,6 @@ COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Datab
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
@@ -22,8 +20,8 @@ COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Feature
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]

View File

@@ -12,6 +12,10 @@
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
</Folder>
<Folder Name="/Features/Users/">
<Project Path="Features\Features.Users.Tests\Features.Users.Tests.csproj" />
<Project Path="Features\Features.Users\Features.Users.csproj" />
</Folder>
<Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" />
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
@@ -20,18 +24,21 @@
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
</Folder>
<Folder Name="/Features/">
<Folder Name="/Features/" />
<Folder Name="/Features/Breweries/">
<Project Path="Features/Features.Breweries/Features.Breweries.csproj" />
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" />
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj" />
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj" />
<Project Path="Features/Features.Auth/Features.Auth.csproj" />
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" />
</Folder>
<Folder Name="/Features/Emails/">
<Project Path="Features/Features.Emails/Features.Emails.csproj" />
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
</Folder>
<Folder Name="/Features/Locations/">
<Project Path="Features/Features.Locations/Features.Locations.csproj" />
<Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" />
<Project Path="Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
</Folder>
<Folder Name="/Features/PhotoUpload/">
<Project Path="Features/Features.PhotoUpload/Features.PhotoUpload.csproj" />
<Project Path="Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj" />
</Folder>
<Folder Name="/Shared/">

View File

@@ -31,8 +31,24 @@ Table Photo {
Table UserAvatar {
UserAvatarID uniqueidentifier [pk, default: `newid()`]
UserAccountID uniqueidentifier [not null, unique]
UserProfileID uniqueidentifier [not null]
PhotoID uniqueidentifier [not null]
ValidFrom "datetime2(3)" [not null, default: `sysutcdatetime()`]
ValidTo "datetime2(3)" [note: 'NULL = currently active']
RowVersion rowversion
Note: 'Deleted when the user profile is deleted. At most one row per profile has ValidTo = NULL.'
Indexes {
UserProfileID [unique, name: 'UX_UserAvatar_ActiveOne', note: 'filtered: WHERE ValidTo IS NULL']
PhotoID [name: 'IX_UserAvatar_Photo']
}
}
Table UserProfile {
UserProfileID uniqueidentifier [pk, default: `newid()`]
Biography "nvarchar(max)" [not null]
UserAccountID uniqueidentifier [not null, unique]
RowVersion rowversion
Note: 'Deleted when the user account is deleted'
@@ -216,8 +232,9 @@ Table BeerPostComment {
}
Ref: Photo.UploadedByID > UserAccount.UserAccountID [delete: no action]
Ref: UserAvatar.UserAccountID - UserAccount.UserAccountID [delete: cascade]
Ref: UserAvatar.PhotoID > Photo.PhotoID
Ref: UserAvatar.PhotoID > Photo.PhotoID [delete: no action]
Ref: UserAvatar.UserProfileID > UserProfile.UserProfileID [delete: cascade]
Ref: UserProfile.UserAccountID - UserAccount.UserAccountID [delete: cascade]
Ref: UserVerification.UserAccountID - UserAccount.UserAccountID [delete: cascade]
Ref: UserCredential.UserAccountID > UserAccount.UserAccountID [delete: cascade]
Ref: UserFollow.UserAccountID > UserAccount.UserAccountID [delete: no action]

View File

@@ -29,3 +29,5 @@ CREATE TABLE dbo.UserAccount
CONSTRAINT AK_Email
UNIQUE (Email)
);

View File

@@ -1,29 +0,0 @@
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
(
UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
RowVersion ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
CONSTRAINT FK_UserAvatar_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_PhotoID
FOREIGN KEY (PhotoID)
REFERENCES Photo (PhotoID),
CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID)
);
CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID);

View File

@@ -0,0 +1,22 @@
CREATE TABLE dbo.UserProfile
(
UserProfileID UNIQUEIDENTIFIER
CONSTRAINT DF_UserProfileID DEFAULT NEWID(),
Biography NVARCHAR(MAX) NOT NULL,
UserAccountID UNIQUEIDENTIFIER NOT NULL,
RowVersion ROWVERSION,
CONSTRAINT PK_UserProfile
PRIMARY KEY (UserProfileID),
CONSTRAINT AK_UserProfile_UserAccountID
UNIQUE (UserAccountID),
CONSTRAINT FK_UserProfile_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE
);

View File

@@ -0,0 +1,43 @@
CREATE TABLE dbo.UserAvatar
(
UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserProfileID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
ValidFrom DATETIME2(3) NOT NULL
CONSTRAINT DF_UserAvatar_ValidFrom DEFAULT SYSUTCDATETIME(),
ValidTo DATETIME2(3) NULL, -- NULL = currently active
RowVersion ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
CONSTRAINT FK_UserAvatar_UserProfile
FOREIGN KEY (UserProfileID)
REFERENCES dbo.UserProfile (UserProfileID)
ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_Photo
FOREIGN KEY (PhotoID)
REFERENCES dbo.Photo (PhotoID)
ON DELETE NO ACTION
ON UPDATE NO ACTION,
CONSTRAINT CK_UserAvatar_ValidRange
CHECK (ValidTo IS NULL OR ValidTo > ValidFrom)
);
-- At most one active avatar per profile
CREATE UNIQUE NONCLUSTERED INDEX UX_UserAvatar_ActiveOne
ON dbo.UserAvatar (UserProfileID)
WHERE ValidTo IS NULL;
CREATE NONCLUSTERED INDEX IX_UserAvatar_Photo
ON dbo.UserAvatar (PhotoID);
CREATE NONCLUSTERED INDEX ix_UserAvatar_Profile
ON dbo.UserAvatar (UserProfileID)

View File

@@ -9,8 +9,11 @@
<ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageReference Include="SkiaSharp" Version="4.151.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="4.151.1" />
<PackageReference Include="Spectre.Console" Version="0.57.2" />
</ItemGroup>
@@ -18,13 +21,12 @@
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
</ItemGroup>
<ItemGroup>
<None
CopyToOutputDirectory="PreserveNewest"
Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
/>
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,27 @@
using SkiaSharp;
namespace Database.Seed.DatabaseHelpers;
/// <summary>Generates minimal solid-color square avatars for seeded users.</summary>
public static class AvatarGenerator
{
private const int SizePx = 128;
/// <summary>
/// Generates a deterministic solid-color PNG avatar, with the color derived from
/// <paramref name="userId" />.
/// </summary>
public static byte[] GeneratePng(Guid userId)
{
byte[] idBytes = userId.ToByteArray();
SKColor color = new(idBytes[0], idBytes[1], idBytes[2]);
using SKBitmap bitmap = new(SizePx, SizePx);
bitmap.Erase(color);
using SKImage image = SKImage.FromBitmap(bitmap);
using SKData data = image.Encode(SKEncodedImageFormat.Png, quality: 100);
return data.ToArray();
}
}

View File

@@ -0,0 +1,21 @@
using Features.Auth.Services;
namespace Database.Seed.DatabaseHelpers;
public class NoOpTokenService : ITokenService
{
public string GenerateAccessToken(Guid userId, string username) => string.Empty;
public string GenerateRefreshToken(Guid userId, string username) => string.Empty;
public string GenerateConfirmationToken(Guid userId, string username) => string.Empty;
public Task<ValidatedToken> ValidateRefreshTokenAsync(string token) =>
throw new NotSupportedException("Token validation is not supported during seeding.");
public Task<ValidatedToken> ValidateConfirmationTokenAsync(string token) =>
throw new NotSupportedException("Token validation is not supported during seeding.");
public Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString) =>
throw new NotSupportedException("Token validation is not supported during seeding.");
}

View File

@@ -11,6 +11,8 @@ ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Database.Seed.dll"]

View File

@@ -3,15 +3,23 @@ using Database.Seed.DatabaseHelpers;
using Database.Seed.PipelineData;
using Database.Seed.Sqlite;
using Domain.Entities;
using Features.Auth.DependencyInjection;
using Features.Auth.Identity;
using Features.Auth.Commands.Authentication.RegisterUser;
using Features.Auth.Commands.Profile.UpdateBiography;
using Features.Auth.Commands.Profile.UploadAvatar;
using Features.Users.DependencyInjection;
using Features.Auth.Dtos;
using Features.Auth.Services;
using Features.Breweries.Commands.CreateBrewery;
using Features.Breweries.DependencyInjection;
using Features.Breweries.Repository;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection;
using Features.Locations.Commands.GetOrCreateCity;
using Features.Locations.DependencyInjection;
using Features.Locations.Dtos;
using Features.Locations.Repository;
using idunno.Password;
using Microsoft.AspNetCore.Identity;
using Infrastructure.FileUpload;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
@@ -24,20 +32,24 @@ static async Task<int> RunAsync()
{
IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build();
ServiceCollection services = [];
services.AddSingleton(configuration);
services.AddDatabaseConnection();
services.AddFeaturesBreweries();
services.AddFeaturesLocations();
services.AddFeaturesAuth();
IServiceCollection services = new ServiceCollection()
.AddSingleton(configuration)
.AddDatabaseConnection()
.AddFeaturesBreweries()
.AddFeaturesLocations()
.AddFeaturesUsers()
.AddFeaturesPhotoUpload()
.AddSingleton<IFileStorageProvider, S3FileStorageProvider>()
.AddScoped<ITokenService, NoOpTokenService>()
.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>()
.RegisterServicesFromAssemblyContaining<GetOrCreateCityCommand>()
.RegisterServicesFromAssemblyContaining<UploadAvatarCommand>()
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
);
await using ServiceProvider provider = services.BuildServiceProvider();
IBreweryRepository breweryRepository = provider.GetRequiredService<IBreweryRepository>();
ILocationRepository locationRepository = provider.GetRequiredService<ILocationRepository>();
UserManager<ApplicationUser> userManager = provider.GetRequiredService<
UserManager<ApplicationUser>
>();
IMediator mediator = provider.GetRequiredService<IMediator>();
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified());
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]");
@@ -46,7 +58,7 @@ static async Task<int> RunAsync()
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
SeedData seedData = null!;
IReadOnlyList<Guid> postedByIds = [];
IReadOnlyList<Guid> userIds = [];
await AnsiConsole
.Status()
@@ -87,11 +99,24 @@ static async Task<int> RunAsync()
"Loading user data into target database...",
async ctx =>
{
postedByIds = await LoadUsersIntoDatabaseAsync(userManager, seedData.Users);
userIds = await LoadUsersIntoDatabaseAsync(mediator, seedData.Users);
ctx.Status("User data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading avatars into target database...",
async ctx =>
{
await LoadAvatarsIntoDatabaseAsync(mediator, userIds, seedData.Users, ctx);
ctx.Status("Avatar data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
@@ -101,10 +126,9 @@ static async Task<int> RunAsync()
async ctx =>
{
await LoadBreweriesIntoDatabaseAsync(
breweryRepository,
locationRepository,
mediator,
seedData.Breweries,
postedByIds,
userIds,
ctx
);
ctx.Status("Brewery data loaded into target database.");
@@ -122,7 +146,7 @@ static async Task<int> RunAsync()
}
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
UserManager<ApplicationUser> userManager,
IMediator mediator,
IReadOnlyList<UserRecord> users
)
{
@@ -130,37 +154,57 @@ static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
foreach (UserRecord userRecord in users)
{
ApplicationUser user = new()
{
FirstName = userRecord.User.FirstName,
LastName = userRecord.User.LastName,
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
UserName = userRecord.User.Username,
Email = userRecord.Email,
};
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
// digit characters (0-9), which idunno.Password rejects outright.
IdentityResult result = await userManager.CreateAsync(
user,
PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true)
string password = PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true);
RegistrationPayload registration = await mediator.Send(
new RegisterUserCommand(
userRecord.User.Username,
userRecord.User.FirstName,
userRecord.User.LastName,
userRecord.Email,
DateTime.Parse(userRecord.DateOfBirth),
password
)
);
if (!result.Succeeded)
throw new InvalidOperationException(
$"Failed to seed user '{userRecord.User.Username}': "
+ string.Join("; ", result.Errors.Select(e => e.Description))
);
userAccountIds.Add(user.Id);
userAccountIds.Add(registration.UserAccountId);
}
return userAccountIds;
}
static async Task LoadAvatarsIntoDatabaseAsync(
IMediator mediator,
IReadOnlyList<Guid> userIds,
IReadOnlyList<UserRecord> users,
StatusContext ctx
)
{
for (int i = 0; i < userIds.Count; i++)
{
ctx.Status($"Loading avatar {i + 1}/{userIds.Count} into target database...");
Guid userId = userIds[i];
await mediator.Send(new UpdateBiographyCommand(userId, users[i].User.Bio));
byte[] avatarPng = AvatarGenerator.GeneratePng(userId);
await using MemoryStream stream = new(avatarPng);
IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png")
{
Headers = new HeaderDictionary(),
ContentType = "image/png",
};
await mediator.Send(new UploadAvatarCommand(userId, file));
}
}
static async Task LoadBreweriesIntoDatabaseAsync(
IBreweryRepository breweryRepository,
ILocationRepository locationRepository,
IMediator mediator,
IReadOnlyList<BreweryRecord> breweries,
IReadOnlyList<Guid> posterUserIds,
StatusContext ctx
@@ -175,35 +219,34 @@ static async Task LoadBreweriesIntoDatabaseAsync(
ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database...");
Guid cityId = await locationRepository.GetOrCreateCityIdAsync(
new CityLocation(
breweryRecord.Address.City.CityName,
breweryRecord.Address.City.StateProvince,
breweryRecord.Address.City.Iso31662,
breweryRecord.Address.City.Country,
breweryRecord.Address.City.Iso31661
Guid cityId = await mediator.Send(
new GetOrCreateCityCommand(
new CityLocation(
breweryRecord.Address.City.CityName,
breweryRecord.Address.City.StateProvince,
breweryRecord.Address.City.Iso31662,
breweryRecord.Address.City.Country,
breweryRecord.Address.City.Iso31661
)
)
);
await breweryRepository.CreateAsync(
new BreweryPost
{
BreweryPostId = Guid.NewGuid(),
BreweryName = breweryRecord.Brewery.NameEn,
Description = breweryRecord.Brewery.DescriptionEn,
PostedById = posterUserIds[i % posterUserIds.Count],
Location = new BreweryPostLocation
{
BreweryPostLocationId = Guid.NewGuid(),
CityId = cityId,
AddressLine1 = breweryRecord.Address.AddressLine1,
PostalCode = breweryRecord.Address.PostalCode,
Coordinates = new CoordinateData(
await mediator.Send(
new CreateBreweryCommand(
posterUserIds[i % posterUserIds.Count],
breweryRecord.Brewery.NameEn,
breweryRecord.Brewery.DescriptionEn,
new CreateBreweryLocation(
cityId,
breweryRecord.Address.AddressLine1,
null,
breweryRecord.Address.PostalCode,
new CoordinateData(
breweryRecord.Address.Latitude,
breweryRecord.Address.Longitude
),
},
}
)
)
)
);
}
}

View File

@@ -0,0 +1,20 @@
namespace Domain.Entities;
public sealed class UserAvatar
{
// PK
public Guid UserAvatarId { get; set; }
// FK References
public Guid UserProfileId { get; set; }
public Guid PhotoId { get; set; }
// Attributes
public DateTime ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
// Navigation Field
public Photo? Photo { get; set; }
// Audit Fields
public byte[]? RowVersion { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Domain.Entities;
public sealed class UserProfile
{
// PK
public Guid UserProfileId { get; set; }
// FK References
public Guid UserAccountId { get; set; }
// Attributes
public string Biography { get; set; } = string.Empty;
// Audit Fields
public byte[]? RowVersion { get; set; }
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>;

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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
)
);

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}
}

View File

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

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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 }
);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>());

View File

@@ -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()
{

View File

@@ -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;

View File

@@ -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">

View File

@@ -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)

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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" />,

View File

@@ -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>

View File

@@ -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">

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>.

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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>;

View File

@@ -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
);
}

View File

@@ -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;

View File

@@ -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
);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>;

View File

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

View File

@@ -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;

View File

@@ -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.

View File

@@ -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

View File

@@ -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>

View File

@@ -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&lt;TKey&gt;</c>) backed by <see cref="DapperUserStore" />
/// Features.Users. A plain POCO (not <c>IdentityUser&lt;TKey&gt;</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

Some files were not shown because too many files have changed in this diff Show More