From d1d089aa73506a497d5c5a0627d7764846e5248e Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 30 Aug 2026 04:13:47 -0400 Subject: [PATCH] Add user profile and avatar upload feature --- web/backend/API/API.Core/Program.cs | 14 ---- .../Database/Database.Migrations/schema.dbml | 23 ++++++- .../scripts/01-schema/01-UserAccount.sql | 2 + .../scripts/01-schema/03-UserAvatar.sql | 29 --------- .../scripts/01-schema/17-UserProfile.sql | 22 +++++++ .../scripts/01-schema/18-UserAvatar.sql | 43 ++++++++++++ .../Domain.Entities/Entities/UserAvatar.cs | 20 ++++++ .../Domain.Entities/Entities/UserProfile.cs | 16 +++++ .../UploadAvatar/UploadAvatarCommand.cs | 7 ++ .../UploadAvatar/UploadAvatarHandler.cs | 45 +++++++++++++ ...FeaturesAuthServiceCollectionExtensions.cs | 5 +- .../Repository/IUserAvatarRepository.cs | 14 ++++ .../Repository/IUserProfileRepository.cs | 13 ++++ .../Repository/UserAvatarRepository.cs | 54 +++++++++++++++ .../Repository/UserProfileRepository.cs | 65 +++++++++++++++++++ 15 files changed, 325 insertions(+), 47 deletions(-) delete mode 100644 web/backend/Database/Database.Migrations/scripts/01-schema/03-UserAvatar.sql create mode 100644 web/backend/Database/Database.Migrations/scripts/01-schema/17-UserProfile.sql create mode 100644 web/backend/Database/Database.Migrations/scripts/01-schema/18-UserAvatar.sql create mode 100644 web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs create mode 100644 web/backend/Domain/Domain.Entities/Entities/UserProfile.cs create mode 100644 web/backend/Features/Features.Users/Commands/UploadAvatar/UploadAvatarCommand.cs create mode 100644 web/backend/Features/Features.Users/Commands/UploadAvatar/UploadAvatarHandler.cs create mode 100644 web/backend/Features/Features.Users/Repository/IUserAvatarRepository.cs create mode 100644 web/backend/Features/Features.Users/Repository/IUserProfileRepository.cs create mode 100644 web/backend/Features/Features.Users/Repository/UserAvatarRepository.cs create mode 100644 web/backend/Features/Features.Users/Repository/UserProfileRepository.cs diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 9c10bd5b..68bb24af 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -123,20 +123,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(); diff --git a/web/backend/Database/Database.Migrations/schema.dbml b/web/backend/Database/Database.Migrations/schema.dbml index f11e88fc..40b4bf50 100644 --- a/web/backend/Database/Database.Migrations/schema.dbml +++ b/web/backend/Database/Database.Migrations/schema.dbml @@ -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] diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/01-UserAccount.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/01-UserAccount.sql index 0d482939..024df8c2 100644 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/01-UserAccount.sql +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/01-UserAccount.sql @@ -29,3 +29,5 @@ CREATE TABLE dbo.UserAccount CONSTRAINT AK_Email UNIQUE (Email) ); + + diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/03-UserAvatar.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/03-UserAvatar.sql deleted file mode 100644 index 7af3198d..00000000 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/03-UserAvatar.sql +++ /dev/null @@ -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); diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/17-UserProfile.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/17-UserProfile.sql new file mode 100644 index 00000000..956ac67e --- /dev/null +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/17-UserProfile.sql @@ -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 +); diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/18-UserAvatar.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/18-UserAvatar.sql new file mode 100644 index 00000000..8c7c09be --- /dev/null +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/18-UserAvatar.sql @@ -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) diff --git a/web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs b/web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs new file mode 100644 index 00000000..67f2891c --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs @@ -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; } +} diff --git a/web/backend/Domain/Domain.Entities/Entities/UserProfile.cs b/web/backend/Domain/Domain.Entities/Entities/UserProfile.cs new file mode 100644 index 00000000..b7a432a0 --- /dev/null +++ b/web/backend/Domain/Domain.Entities/Entities/UserProfile.cs @@ -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; } +} diff --git a/web/backend/Features/Features.Users/Commands/UploadAvatar/UploadAvatarCommand.cs b/web/backend/Features/Features.Users/Commands/UploadAvatar/UploadAvatarCommand.cs new file mode 100644 index 00000000..1b3aa4ac --- /dev/null +++ b/web/backend/Features/Features.Users/Commands/UploadAvatar/UploadAvatarCommand.cs @@ -0,0 +1,7 @@ +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace Features.Auth.Commands.UploadAvatar; + +/// Uploads a new avatar photo for a user account, replacing any existing avatar. +public record UploadAvatarCommand(Guid UserAccountId, IFormFile File) : IRequest; diff --git a/web/backend/Features/Features.Users/Commands/UploadAvatar/UploadAvatarHandler.cs b/web/backend/Features/Features.Users/Commands/UploadAvatar/UploadAvatarHandler.cs new file mode 100644 index 00000000..3023a73f --- /dev/null +++ b/web/backend/Features/Features.Users/Commands/UploadAvatar/UploadAvatarHandler.cs @@ -0,0 +1,45 @@ +using Domain.Entities; +using Features.Auth.Repository; +using Features.ImageUploads.Commands.UploadPhoto; +using MediatR; + +namespace Features.Auth.Commands.UploadAvatar; + +/// +/// Handles : uploads the photo via , +/// then saves the record, replacing any existing avatar for the user. +/// +public class UploadAvatarHandler( + IMediator mediator, + IUserAvatarRepository userAvatarRepository, + IUserProfileRepository userProfileRepository +) : IRequestHandler +{ + public async Task Handle(UploadAvatarCommand request, CancellationToken cancellationToken) + { + Guid photoId = await mediator.Send( + new UploadPhotoCommand( + request.UserAccountId, + $"avatars/{request.UserAccountId}", + request.File + ), + cancellationToken + ); + + Guid profileId = await userProfileRepository.GetOrCreateProfileIdAsync( + request.UserAccountId, + cancellationToken + ); + + UserAvatar avatar = new() + { + UserAvatarId = Guid.NewGuid(), + UserProfileId = profileId, + PhotoId = photoId, + }; + + await userAvatarRepository.SaveAsync(avatar, cancellationToken); + + return photoId; + } +} diff --git a/web/backend/Features/Features.Users/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs b/web/backend/Features/Features.Users/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs index 89dec851..5924661a 100644 --- a/web/backend/Features/Features.Users/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs +++ b/web/backend/Features/Features.Users/DependencyInjection/FeaturesAuthServiceCollectionExtensions.cs @@ -11,7 +11,8 @@ namespace Features.Auth.DependencyInjection; public static class FeaturesAuthServiceCollectionExtensions { /// - /// Adds , , and ASP.NET Core + /// Adds , , + /// , , and ASP.NET Core /// Identity ( backed by and /// ) as scoped services. /// @@ -20,6 +21,8 @@ public static class FeaturesAuthServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // AddDefaultTokenProviders() is deliberately not called: it registers // DataProtectorTokenProvider, which UserManager's constructor resolves eagerly and which diff --git a/web/backend/Features/Features.Users/Repository/IUserAvatarRepository.cs b/web/backend/Features/Features.Users/Repository/IUserAvatarRepository.cs new file mode 100644 index 00000000..fc100e0e --- /dev/null +++ b/web/backend/Features/Features.Users/Repository/IUserAvatarRepository.cs @@ -0,0 +1,14 @@ +using Domain.Entities; + +namespace Features.Auth.Repository; + +/// +/// Repository for persisting user avatar records. +/// +public interface IUserAvatarRepository +{ + /// + /// Persists , replacing any existing avatar for the same user profile. + /// + Task SaveAsync(UserAvatar avatar, CancellationToken cancellationToken); +} diff --git a/web/backend/Features/Features.Users/Repository/IUserProfileRepository.cs b/web/backend/Features/Features.Users/Repository/IUserProfileRepository.cs new file mode 100644 index 00000000..b10e666d --- /dev/null +++ b/web/backend/Features/Features.Users/Repository/IUserProfileRepository.cs @@ -0,0 +1,13 @@ +namespace Features.Auth.Repository; + +/// +/// Repository for resolving user profile records. +/// +public interface IUserProfileRepository +{ + /// + /// Returns the UserProfileID for , creating an empty + /// profile first if one doesn't already exist. + /// + Task GetOrCreateProfileIdAsync(Guid userAccountId, CancellationToken cancellationToken); +} diff --git a/web/backend/Features/Features.Users/Repository/UserAvatarRepository.cs b/web/backend/Features/Features.Users/Repository/UserAvatarRepository.cs new file mode 100644 index 00000000..a3fa0ef8 --- /dev/null +++ b/web/backend/Features/Features.Users/Repository/UserAvatarRepository.cs @@ -0,0 +1,54 @@ +using System.Data.Common; +using Dapper; +using Database.Connection; +using Domain.Entities; + +namespace Features.Auth.Repository; + +/// +/// Dapper-based implementation of . +/// +public class UserAvatarRepository(ISqlConnectionFactory connectionFactory) + : DapperRepository(connectionFactory), + IUserAvatarRepository +{ + /// + public async Task SaveAsync(UserAvatar avatar, CancellationToken cancellationToken) + { + await using DbConnection connection = await CreateConnection(); + await using DbTransaction transaction = await connection.BeginTransactionAsync(); + + try + { + await connection.ExecuteAsync( + new CommandDefinition( + """ + UPDATE dbo.UserAvatar + SET ValidTo = SYSUTCDATETIME() + WHERE UserProfileID = @UserProfileId AND ValidTo IS NULL + """, + new { avatar.UserProfileId }, + transaction + ) + ); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserAvatar (UserAvatarID, UserProfileID, PhotoID) + VALUES (@UserAvatarId, @UserProfileId, @PhotoId) + """, + new { avatar.UserAvatarId, avatar.UserProfileId, avatar.PhotoId }, + transaction + ) + ); + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await RollbackQuietlyAsync(transaction); + throw; + } + } +} diff --git a/web/backend/Features/Features.Users/Repository/UserProfileRepository.cs b/web/backend/Features/Features.Users/Repository/UserProfileRepository.cs new file mode 100644 index 00000000..e8ea4562 --- /dev/null +++ b/web/backend/Features/Features.Users/Repository/UserProfileRepository.cs @@ -0,0 +1,65 @@ +using System.Data.Common; +using Dapper; +using Database.Connection; + +namespace Features.Auth.Repository; + +/// +/// Dapper-based implementation of . +/// +public class UserProfileRepository(ISqlConnectionFactory connectionFactory) + : DapperRepository(connectionFactory), + IUserProfileRepository +{ + /// + public async Task GetOrCreateProfileIdAsync( + Guid userAccountId, + CancellationToken cancellationToken + ) + { + await using DbConnection connection = await CreateConnection(); + await using DbTransaction transaction = await connection.BeginTransactionAsync(); + + try + { + // UPDLOCK/HOLDLOCK: AK_UserProfile_UserAccountID is composite with UserProfileID, so it + // doesn't by itself stop two concurrent callers from creating duplicate profiles for the + // same account; holding the lock through the transaction serializes them instead. + Guid? userProfileId = await connection.ExecuteScalarAsync( + new CommandDefinition( + """ + SELECT UserProfileID + FROM dbo.UserProfile WITH (UPDLOCK, HOLDLOCK) + WHERE UserAccountID = @UserAccountId + """, + new { UserAccountId = userAccountId }, + transaction + ) + ); + + if (userProfileId is null) + { + userProfileId = Guid.NewGuid(); + + await connection.ExecuteAsync( + new CommandDefinition( + """ + INSERT INTO dbo.UserProfile (UserProfileID, Biography, UserAccountID) + VALUES (@UserProfileId, '', @UserAccountId) + """, + new { UserProfileId = userProfileId, UserAccountId = userAccountId }, + transaction + ) + ); + } + + await transaction.CommitAsync(cancellationToken); + return userProfileId.Value; + } + catch + { + await RollbackQuietlyAsync(transaction); + throw; + } + } +}