Add user profile and avatar upload feature

This commit is contained in:
Aaron Po
2026-08-30 04:13:47 -04:00
parent bd78fe6533
commit d1d089aa73
15 changed files with 325 additions and 47 deletions

View File

@@ -0,0 +1,14 @@
using Domain.Entities;
namespace Features.Auth.Repository;
/// <summary>
/// Repository for persisting user avatar records.
/// </summary>
public interface IUserAvatarRepository
{
/// <summary>
/// Persists <paramref name="avatar" />, replacing any existing avatar for the same user profile.
/// </summary>
Task SaveAsync(UserAvatar avatar, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,13 @@
namespace Features.Auth.Repository;
/// <summary>
/// Repository for resolving user profile records.
/// </summary>
public interface IUserProfileRepository
{
/// <summary>
/// Returns the <c>UserProfileID</c> for <paramref name="userAccountId" />, creating an empty
/// profile first if one doesn't already exist.
/// </summary>
Task<Guid> GetOrCreateProfileIdAsync(Guid userAccountId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,54 @@
using System.Data.Common;
using Dapper;
using Database.Connection;
using Domain.Entities;
namespace Features.Auth.Repository;
/// <summary>
/// Dapper-based implementation of <see cref="IUserAvatarRepository" />.
/// </summary>
public class UserAvatarRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory),
IUserAvatarRepository
{
/// <inheritdoc />
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;
}
}
}

View File

@@ -0,0 +1,65 @@
using System.Data.Common;
using Dapper;
using Database.Connection;
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> 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<Guid?>(
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;
}
}
}