mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Read access for user account listing. Everything else on user accounts (lookup by
|
||||
/// username/email, create/update/delete) already goes through
|
||||
/// <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" />; this repository only covers
|
||||
/// what <c>IUserStore</c> has no equivalent for: fetching by ID with full audit fields, and
|
||||
/// paginated listing.
|
||||
/// </summary>
|
||||
public interface IUserListRepository
|
||||
{
|
||||
/// <summary>Returns <c>null</c> if no user account exists with the given ID.</summary>
|
||||
Task<UserAccount?> GetByIdAsync(Guid id);
|
||||
|
||||
/// <summary>Returns user accounts ordered by creation date descending.</summary>
|
||||
/// <param name="limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for user profile records and their avatars.
|
||||
/// </summary>
|
||||
public interface IUserProfileRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new user profile for <paramref name="userAccountId" /> with the given
|
||||
/// <paramref name="biography" /> and returns its <c>UserProfileID</c>.
|
||||
/// </summary>
|
||||
Task<Guid> CreateProfileAsync(
|
||||
Guid userAccountId,
|
||||
string biography,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the <c>UserProfileID</c> for <paramref name="userAccountId" />
|
||||
/// </summary>
|
||||
Task<Guid> GetProfileIdAsync(Guid userAccountId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the biography of the user profile belonging to <paramref name="userAccountId" />.
|
||||
/// </summary>
|
||||
/// <exception cref="Domain.Exceptions.NotFoundException">
|
||||
/// Thrown when no user profile exists for <paramref name="userAccountId" />.
|
||||
/// </exception>
|
||||
Task UpdateBiographyAsync(
|
||||
Guid userAccountId,
|
||||
string biography,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Persists <paramref name="avatar" />, replacing any existing avatar for the same user profile.
|
||||
/// </summary>
|
||||
Task SaveAvatarAsync(UserAvatar avatar, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IUserListRepository" />.
|
||||
/// </summary>
|
||||
public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserListRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT
|
||||
UserAccountID,
|
||||
Username,
|
||||
FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = id }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
||||
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
|
||||
/// </remarks>
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
ORDER BY CreatedAt DESC
|
||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||
""",
|
||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IUserProfileRepository" />.
|
||||
/// </summary>
|
||||
public class UserProfileRepository(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IUserProfileRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Guid> CreateProfileAsync(
|
||||
Guid userAccountId,
|
||||
string biography,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
return await connection.ExecuteScalarAsync<Guid>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserProfile (UserAccountID, Biography)
|
||||
OUTPUT INSERTED.UserProfileID
|
||||
VALUES (@UserAccountId, @Biography)
|
||||
""",
|
||||
new { UserAccountId = userAccountId, Biography = biography },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Guid> GetProfileIdAsync(
|
||||
Guid userAccountId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
Guid? userProfileId = await connection.ExecuteScalarAsync<Guid?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT UserProfileID
|
||||
FROM dbo.UserProfile
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
return userProfileId
|
||||
?? throw new NotFoundException(
|
||||
$"No user profile found for user account ID {userAccountId}"
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateBiographyAsync(
|
||||
Guid userAccountId,
|
||||
string biography,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
int updatedRows = await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserProfile
|
||||
SET Biography = @Biography
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId, Biography = biography },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (updatedRows == 0)
|
||||
throw new NotFoundException(
|
||||
$"No user profile found for user account ID {userAccountId}"
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveAvatarAsync(UserAvatar avatar, CancellationToken cancellationToken)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync(
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserAvatar
|
||||
SET ValidTo = SYSUTCDATETIME()
|
||||
WHERE UserProfileID = @UserProfileId AND ValidTo IS NULL
|
||||
""",
|
||||
new { avatar.UserProfileId },
|
||||
transaction: transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserAvatar (UserAvatarID, UserProfileID, PhotoID)
|
||||
VALUES (@UserAvatarId, @UserProfileId, @PhotoId)
|
||||
""",
|
||||
new
|
||||
{
|
||||
avatar.UserAvatarId,
|
||||
avatar.UserProfileId,
|
||||
avatar.PhotoId,
|
||||
},
|
||||
transaction: transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await RollbackQuietlyAsync(transaction);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user