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

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