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

@@ -0,0 +1,21 @@
using Domain.Entities;
namespace Features.Auth.Repository;
/// <summary>
/// Read access for user account listing. Everything else on user accounts (lookup by
/// username/email, create/update/delete) already goes through
/// <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" />; this repository only covers
/// what <c>IUserStore</c> has no equivalent for: fetching by ID with full audit fields, and
/// paginated listing.
/// </summary>
public interface IUserListRepository
{
/// <summary>Returns <c>null</c> if no user account exists with the given ID.</summary>
Task<UserAccount?> GetByIdAsync(Guid id);
/// <summary>Returns user accounts ordered by creation date descending.</summary>
/// <param name="limit"><c>null</c> for no limit.</param>
/// <param name="offset"><c>null</c> for no offset.</param>
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
}

View File

@@ -0,0 +1,41 @@
using Domain.Entities;
namespace Features.Auth.Repository;
/// <summary>
/// Repository for user profile records and their avatars.
/// </summary>
public interface IUserProfileRepository
{
/// <summary>
/// Creates a new user profile for <paramref name="userAccountId" /> with the given
/// <paramref name="biography" /> and returns its <c>UserProfileID</c>.
/// </summary>
Task<Guid> CreateProfileAsync(
Guid userAccountId,
string biography,
CancellationToken cancellationToken
);
/// <summary>
/// Returns the <c>UserProfileID</c> for <paramref name="userAccountId" />
/// </summary>
Task<Guid> GetProfileIdAsync(Guid userAccountId, CancellationToken cancellationToken);
/// <summary>
/// Updates the biography of the user profile belonging to <paramref name="userAccountId" />.
/// </summary>
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no user profile exists for <paramref name="userAccountId" />.
/// </exception>
Task UpdateBiographyAsync(
Guid userAccountId,
string biography,
CancellationToken cancellationToken
);
/// <summary>
/// Persists <paramref name="avatar" />, replacing any existing avatar for the same user profile.
/// </summary>
Task SaveAvatarAsync(UserAvatar avatar, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,50 @@
using System.Data.Common;
using Dapper;
using Database.Connection;
using Domain.Entities;
namespace Features.Auth.Repository;
/// <summary>
/// Dapper-based implementation of <see cref="IUserListRepository" />.
/// </summary>
public class UserListRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory),
IUserListRepository
{
/// <inheritdoc />
public async Task<UserAccount?> GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
"""
SELECT
UserAccountID,
Username,
FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = id }
);
}
/// <inheritdoc />
/// <remarks>
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
/// </remarks>
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync<UserAccount>(
"""
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount
ORDER BY CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""",
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
);
}
}

View File

@@ -0,0 +1,139 @@
using System.Data.Common;
using Dapper;
using Database.Connection;
using Domain.Entities;
using Domain.Exceptions;
namespace Features.Auth.Repository;
/// <summary>
/// Dapper-based implementation of <see cref="IUserProfileRepository" />.
/// </summary>
public class UserProfileRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory),
IUserProfileRepository
{
/// <inheritdoc />
public async Task<Guid> CreateProfileAsync(
Guid userAccountId,
string biography,
CancellationToken cancellationToken
)
{
await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid>(
new CommandDefinition(
"""
INSERT INTO dbo.UserProfile (UserAccountID, Biography)
OUTPUT INSERTED.UserProfileID
VALUES (@UserAccountId, @Biography)
""",
new { UserAccountId = userAccountId, Biography = biography },
cancellationToken: cancellationToken
)
);
}
/// <inheritdoc />
public async Task<Guid> GetProfileIdAsync(
Guid userAccountId,
CancellationToken cancellationToken
)
{
await using DbConnection connection = await CreateConnection();
Guid? userProfileId = await connection.ExecuteScalarAsync<Guid?>(
new CommandDefinition(
"""
SELECT UserProfileID
FROM dbo.UserProfile
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = userAccountId },
cancellationToken: cancellationToken
)
);
return userProfileId
?? throw new NotFoundException(
$"No user profile found for user account ID {userAccountId}"
);
}
/// <inheritdoc />
public async Task UpdateBiographyAsync(
Guid userAccountId,
string biography,
CancellationToken cancellationToken
)
{
await using DbConnection connection = await CreateConnection();
int updatedRows = await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE dbo.UserProfile
SET Biography = @Biography
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = userAccountId, Biography = biography },
cancellationToken: cancellationToken
)
);
if (updatedRows == 0)
throw new NotFoundException(
$"No user profile found for user account ID {userAccountId}"
);
}
/// <inheritdoc />
public async Task SaveAvatarAsync(UserAvatar avatar, CancellationToken cancellationToken)
{
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync(
cancellationToken
);
try
{
await connection.ExecuteAsync(
new CommandDefinition(
"""
UPDATE dbo.UserAvatar
SET ValidTo = SYSUTCDATETIME()
WHERE UserProfileID = @UserProfileId AND ValidTo IS NULL
""",
new { avatar.UserProfileId },
transaction: transaction,
cancellationToken: cancellationToken
)
);
await connection.ExecuteAsync(
new CommandDefinition(
"""
INSERT INTO dbo.UserAvatar (UserAvatarID, UserProfileID, PhotoID)
VALUES (@UserAvatarId, @UserProfileId, @PhotoId)
""",
new
{
avatar.UserAvatarId,
avatar.UserProfileId,
avatar.PhotoId,
},
transaction: transaction,
cancellationToken: cancellationToken
)
);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await RollbackQuietlyAsync(transaction);
throw;
}
}
}