mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add user profile and avatar upload feature
This commit is contained in:
@@ -123,20 +123,6 @@ app.MapHealthChecks("/health");
|
|||||||
|
|
||||||
app.MapControllers();
|
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");
|
app.MapFallbackToController("Handle404", "NotFound");
|
||||||
|
|
||||||
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||||
|
|||||||
@@ -31,8 +31,24 @@ Table Photo {
|
|||||||
|
|
||||||
Table UserAvatar {
|
Table UserAvatar {
|
||||||
UserAvatarID uniqueidentifier [pk, default: `newid()`]
|
UserAvatarID uniqueidentifier [pk, default: `newid()`]
|
||||||
UserAccountID uniqueidentifier [not null, unique]
|
UserProfileID uniqueidentifier [not null]
|
||||||
PhotoID 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
|
RowVersion rowversion
|
||||||
|
|
||||||
Note: 'Deleted when the user account is deleted'
|
Note: 'Deleted when the user account is deleted'
|
||||||
@@ -216,8 +232,9 @@ Table BeerPostComment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ref: Photo.UploadedByID > UserAccount.UserAccountID [delete: no action]
|
Ref: Photo.UploadedByID > UserAccount.UserAccountID [delete: no action]
|
||||||
Ref: UserAvatar.UserAccountID - UserAccount.UserAccountID [delete: cascade]
|
Ref: UserAvatar.PhotoID > Photo.PhotoID [delete: no action]
|
||||||
Ref: UserAvatar.PhotoID > Photo.PhotoID
|
Ref: UserAvatar.UserProfileID > UserProfile.UserProfileID [delete: cascade]
|
||||||
|
Ref: UserProfile.UserAccountID - UserAccount.UserAccountID [delete: cascade]
|
||||||
Ref: UserVerification.UserAccountID - UserAccount.UserAccountID [delete: cascade]
|
Ref: UserVerification.UserAccountID - UserAccount.UserAccountID [delete: cascade]
|
||||||
Ref: UserCredential.UserAccountID > UserAccount.UserAccountID [delete: cascade]
|
Ref: UserCredential.UserAccountID > UserAccount.UserAccountID [delete: cascade]
|
||||||
Ref: UserFollow.UserAccountID > UserAccount.UserAccountID [delete: no action]
|
Ref: UserFollow.UserAccountID > UserAccount.UserAccountID [delete: no action]
|
||||||
|
|||||||
@@ -29,3 +29,5 @@ CREATE TABLE dbo.UserAccount
|
|||||||
CONSTRAINT AK_Email
|
CONSTRAINT AK_Email
|
||||||
UNIQUE (Email)
|
UNIQUE (Email)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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)
|
||||||
20
web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs
Normal file
20
web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
16
web/backend/Domain/Domain.Entities/Entities/UserProfile.cs
Normal file
16
web/backend/Domain/Domain.Entities/Entities/UserProfile.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Features.Auth.Commands.UploadAvatar;
|
||||||
|
|
||||||
|
/// <summary>Uploads a new avatar photo for a user account, replacing any existing avatar.</summary>
|
||||||
|
public record UploadAvatarCommand(Guid UserAccountId, IFormFile File) : IRequest<Guid>;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.ImageUploads.Commands.UploadPhoto;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Auth.Commands.UploadAvatar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="UploadAvatarCommand" />: uploads the photo via <see cref="UploadPhotoCommand" />,
|
||||||
|
/// then saves the <see cref="UserAvatar" /> record, replacing any existing avatar for the user.
|
||||||
|
/// </summary>
|
||||||
|
public class UploadAvatarHandler(
|
||||||
|
IMediator mediator,
|
||||||
|
IUserAvatarRepository userAvatarRepository,
|
||||||
|
IUserProfileRepository userProfileRepository
|
||||||
|
) : IRequestHandler<UploadAvatarCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ namespace Features.Auth.DependencyInjection;
|
|||||||
public static class FeaturesAuthServiceCollectionExtensions
|
public static class FeaturesAuthServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds <see cref="ITokenService" />, <see cref="IUserListRepository" />, and ASP.NET Core
|
/// Adds <see cref="ITokenService" />, <see cref="IUserListRepository" />,
|
||||||
|
/// <see cref="IUserAvatarRepository" />, <see cref="IUserProfileRepository" />, and ASP.NET Core
|
||||||
/// Identity (<see cref="UserManager{TUser}" /> backed by <see cref="DapperUserStore" /> and
|
/// Identity (<see cref="UserManager{TUser}" /> backed by <see cref="DapperUserStore" /> and
|
||||||
/// <see cref="Argon2PasswordHasher" />) as scoped services.
|
/// <see cref="Argon2PasswordHasher" />) as scoped services.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -20,6 +21,8 @@ public static class FeaturesAuthServiceCollectionExtensions
|
|||||||
services.AddScoped<ITokenService, TokenService>();
|
services.AddScoped<ITokenService, TokenService>();
|
||||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||||
services.AddScoped<IUserListRepository, UserListRepository>();
|
services.AddScoped<IUserListRepository, UserListRepository>();
|
||||||
|
services.AddScoped<IUserAvatarRepository, UserAvatarRepository>();
|
||||||
|
services.AddScoped<IUserProfileRepository, UserProfileRepository>();
|
||||||
|
|
||||||
// AddDefaultTokenProviders() is deliberately not called: it registers
|
// AddDefaultTokenProviders() is deliberately not called: it registers
|
||||||
// DataProtectorTokenProvider<TUser>, which UserManager's constructor resolves eagerly and which
|
// DataProtectorTokenProvider<TUser>, which UserManager's constructor resolves eagerly and which
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user