Features/public biography endpoint (#316)

This commit is contained in:
Aaron Po
2026-09-26 19:51:10 -04:00
committed by GitHub
parent e1b6cba1d4
commit c5972b9f04
20 changed files with 158 additions and 104 deletions

View File

@@ -176,7 +176,9 @@ brewery photo upload command) rather than bound to an HTTP route directly.
- All business logic for that feature lives in its command/query handlers - All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through MediatR - No direct controller-to-repository calls; everything flows through MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly - Read endpoints return a dedicated `Dto` rather than the domain entity
directly. The one exception is `UserController.GetAuthenticated`, which
returns the caller's own `UserAccount` entity
#### Shared projects (`Shared.Contracts`, `Shared.Application`) #### Shared projects (`Shared.Contracts`, `Shared.Application`)
@@ -187,7 +189,7 @@ needs it, or because duplicating it four times would be worse than sharing it
| Project | Purpose | | Project | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns | | `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope `AuthController`, `BreweryController`, and `CityController` return; `UserController`'s read endpoints return their DTOs unwrapped |
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>`, the MediatR pipeline behavior that runs FluentValidation before a handler executes | | `Shared.Application` | `ValidationBehavior<TRequest,TResponse>`, the MediatR pipeline behavior that runs FluentValidation before a handler executes |
**Rules**: **Rules**:

View File

@@ -76,7 +76,7 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique | | `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs | | `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email | | `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` | | `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository.GetByIdAsync`'s left join |
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` | | `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing | | `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly | | `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |

View File

@@ -250,7 +250,7 @@ API.Specs/
│ ├── TokenRefresh.feature # Refresh token scenarios │ ├── TokenRefresh.feature # Refresh token scenarios
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios │ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
│ ├── NotFound.feature # 404 handling │ ├── NotFound.feature # 404 handling
│ └── PublicUserProfile.feature # Public profile & locked-down account scenarios │ └── PublicUserProfile.feature # Public profile & authenticated account scenarios
├── Steps/ ├── Steps/
│ ├── AuthSteps.cs # Step definitions for the Auth features │ ├── AuthSteps.cs # Step definitions for the Auth features
│ ├── UserSteps.cs # Step definitions for the public profile & user-account features │ ├── UserSteps.cs # Step definitions for the public profile & user-account features
@@ -282,17 +282,25 @@ Scenario: Successful user registration
Features.Users.Tests/ Features.Users.Tests/
├── Commands/ ├── Commands/
│ ├── RegisterUserHandlerTests.cs │ ├── RegisterUserHandlerTests.cs
│ ├── RegisterUserValidatorTests.cs
│ ├── ConfirmUserHandlerTests.cs │ ├── ConfirmUserHandlerTests.cs
│ ├── ResendConfirmationEmailHandlerTests.cs │ ├── ResendConfirmationEmailHandlerTests.cs
│ ├── RefreshTokenHandlerTests.cs │ ├── RefreshTokenHandlerTests.cs
│ ├── RefreshTokenValidatorTests.cs
│ ├── LoginValidatorTests.cs # tests LoginCommand's validator
│ ├── UpdateUsernameHandlerTests.cs │ ├── UpdateUsernameHandlerTests.cs
│ ├── UpdateUsernameValidatorTests.cs
│ ├── UpdateEmailHandlerTests.cs │ ├── UpdateEmailHandlerTests.cs
│ ├── UpdateEmailValidatorTests.cs
│ ├── UpdatePasswordHandlerTests.cs │ ├── UpdatePasswordHandlerTests.cs
│ ├── UpdatePasswordValidatorTests.cs
│ ├── UpdateProfileHandlerTests.cs │ ├── UpdateProfileHandlerTests.cs
│ ├── UpdateProfileValidatorTests.cs
│ └── DeleteAccountHandlerTests.cs │ └── DeleteAccountHandlerTests.cs
├── Queries/ ├── Queries/
│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name │ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
│ ├── GetAllUsersHandlerTests.cs │ ├── GetAllUsersHandlerTests.cs
│ ├── GetPublicUserProfileByIdHandlerTests.cs
│ └── GetUserByIdHandlerTests.cs │ └── GetUserByIdHandlerTests.cs
├── Services/ ├── Services/
│ ├── TokenServiceRefreshTests.cs │ ├── TokenServiceRefreshTests.cs
@@ -300,10 +308,13 @@ Features.Users.Tests/
└── TestSupport/ # shared test fixtures/helpers └── TestSupport/ # shared test fixtures/helpers
``` ```
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.Breweries.Tests` and `Features.PhotoUpload.Tests` follow the same
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/` shape: a `Commands/`/`Queries/` folder with one test file per handler, plus a
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a `*ValidatorTests.cs` file beside each handler whose command or query has a
validator test file alongside its handler test). FluentValidation validator (`Features.Breweries.Tests` has one of these in
`Queries/` as well, for the nearby-search query). `Features.Emails.Tests`
differs: its handlers respond to MediatR notifications, so its test files live
under `Notifications/`.
## Writing tests ## Writing tests

View File

@@ -12,6 +12,7 @@ Scenario: Public profile exposes only allowlisted fields
When I retrieve the public profile by ID When I retrieve the public profile by ID
Then the response has HTTP status 200 Then the response has HTTP status 200
And the public profile response should match the registered account And the public profile response should match the registered account
And the response JSON should have "biography" equal ""
And the response JSON should not contain "email" And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth" And the response JSON should not contain "dateOfBirth"
@@ -36,8 +37,8 @@ Scenario: A different user can read someone else's public profile
And the response JSON should not contain "email" And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth" And the response JSON should not contain "dateOfBirth"
Scenario: Fetching a user account by ID without authentication is rejected Scenario: Fetching my own account without authentication is rejected
When I retrieve the user account by a non-existent ID without authentication When I retrieve my own account without authentication
Then the response has HTTP status 401 Then the response has HTTP status 401
Scenario: Listing user accounts without authentication is rejected Scenario: Listing user accounts without authentication is rejected
@@ -52,15 +53,16 @@ Scenario: Listing user accounts as an authenticated user excludes private fields
And the response JSON should not contain "email" And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth" And the response JSON should not contain "dateOfBirth"
Scenario: The owner can fetch their own account by ID Scenario: The caller can fetch their own account
Given I have registered a new account Given I have registered a new account
And I have a valid access token for my account And I have a valid access token for my account
When I retrieve the registered account by ID When I retrieve my own account
Then the response has HTTP status 200 Then the response has HTTP status 200
And the public profile response should match the registered account And the public profile response should match the registered account
Scenario: A different user cannot fetch someone else's account by ID Scenario: The account endpoint serves the caller named by the access token
Given I have registered a new account Given I have registered a new account
And I am logged in as a different user And I am logged in as a different user
When I retrieve the registered account by ID When I retrieve my own account
Then the response has HTTP status 403 Then the response has HTTP status 200
And the account response should be for a different account than the registered one

View File

@@ -54,10 +54,10 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile")); await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile"));
} }
[When("I retrieve the user account by a non-existent ID without authentication")] [When("I retrieve my own account without authentication")]
public async Task WhenIRetrieveTheUserAccountByANonExistentIdWithoutAuthentication() public async Task WhenIRetrieveMyOwnAccountWithoutAuthentication()
{ {
await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}")); await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user/authenticated"));
} }
[When("I list user accounts without authentication")] [When("I list user accounts without authentication")]
@@ -72,10 +72,10 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user")); await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user"));
} }
[When("I retrieve the registered account by ID")] [When("I retrieve my own account")]
public async Task WhenIRetrieveTheRegisteredAccountById() public async Task WhenIRetrieveMyOwnAccount()
{ {
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}")); await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user/authenticated"));
} }
[Then("the public profile response should match the registered account")] [Then("the public profile response should match the registered account")]
@@ -92,4 +92,16 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId()); root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId());
root.GetProperty("username").GetString().Should().Be(username); root.GetProperty("username").GetString().Should().Be(username);
} }
[Then("the account response should be for a different account than the registered one")]
public void ThenTheAccountResponseShouldBeForADifferentAccountThanTheRegisteredOne()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.GetProperty("userAccountId")
.GetGuid()
.Should()
.NotBe(GetRegisteredUserId());
}
} }

View File

@@ -19,4 +19,5 @@ public sealed class UserAccount
// Navigation properties // Navigation properties
public UserCredential? UserCredential { get; set; } public UserCredential? UserCredential { get; set; }
public UserProfile? UserProfile { get; set; }
} }

View File

@@ -12,10 +12,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" /> <PackageReference Include="AWSSDK.S3" Version="4.0.102.4" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1"
/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -30,6 +30,7 @@ public class GetPublicUserProfileByIdHandlerTests
Email = "jane@example.com", Email = "jane@example.com",
DateOfBirth = new DateTime(1990, 1, 1), DateOfBirth = new DateTime(1990, 1, 1),
CreatedAt = new DateTime(2024, 1, 1), CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
}; };
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user); _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
@@ -46,11 +47,33 @@ public class GetPublicUserProfileByIdHandlerTests
user.Username, user.Username,
user.FirstName, user.FirstName,
user.LastName, user.LastName,
"Drinks lagers.",
user.CreatedAt user.CreatedAt
) )
); );
} }
[Fact]
public async Task Handle_ReturnsEmptyBiography_WhenProfileMissing()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.Biography.Should().BeEmpty();
}
[Fact] [Fact]
public async Task Handle_Throws_WhenNotFound() public async Task Handle_Throws_WhenNotFound()
{ {

View File

@@ -24,7 +24,7 @@ public class GetUserByIdHandlerTests
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user); _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
UserAccount result = await _handler.Handle( UserAccount result = await _handler.Handle(
new GetUserByIdQuery(user.UserAccountId, user.UserAccountId), new GetUserByIdQuery(user.UserAccountId),
CancellationToken.None CancellationToken.None
); );
@@ -38,21 +38,8 @@ public class GetUserByIdHandlerTests
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null); _repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
Func<Task<UserAccount>> act = async () => Func<Task<UserAccount>> act = async () =>
await _handler.Handle(new GetUserByIdQuery(id, id), CancellationToken.None); await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>(); await act.Should().ThrowAsync<NotFoundException>();
} }
[Fact]
public async Task Handle_Throws_WhenRequestingSomeoneElsesAccount()
{
Guid id = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
Func<Task<UserAccount>> act = async () =>
await _handler.Handle(new GetUserByIdQuery(id, requestingUserId), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>();
_repoMock.Verify(r => r.GetByIdAsync(It.IsAny<Guid>()), Times.Never);
}
} }

View File

@@ -17,9 +17,10 @@ namespace Features.Users.Controllers;
/// <remarks> /// <remarks>
/// Every endpoint requires <c>[Authorize(AuthenticationSchemes = "JWT")]</c> except /// Every endpoint requires <c>[Authorize(AuthenticationSchemes = "JWT")]</c> except
/// <see cref="GetPublicProfile" />, which is <see cref="AllowAnonymousAttribute" /> since it /// <see cref="GetPublicProfile" />, which is <see cref="AllowAnonymousAttribute" /> since it
/// only returns an allowlisted DTO. <see cref="GetById" /> also restricts access to the /// only returns an allowlisted DTO. <see cref="GetAuthenticated" /> serves the caller's own
/// caller's own account. Neither <see cref="GetAll" /> nor <see cref="GetById" /> may expose /// account, taken from the access token. Neither <see cref="GetAll" /> nor
/// another user's <c>Email</c> or <c>DateOfBirth</c>. /// <see cref="GetPublicProfile" /> may expose another user's <c>Email</c> or
/// <c>DateOfBirth</c>.
/// </remarks> /// </remarks>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
@@ -41,18 +42,15 @@ public class UserController(IMediator mediator) : ControllerBase
return Ok(users); return Ok(users);
} }
/// <summary>Gets the caller's own user account by ID.</summary> /// <summary>Gets the authenticated caller's own user account.</summary>
/// <exception cref="ForbiddenException">
/// Thrown when <paramref name="id"/> is not the caller's own account. The API returns HTTP status 403.
/// </exception>
/// <exception cref="NotFoundException"> /// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID, resulting in a 404 response. /// Thrown when no user account exists for the caller's ID, resulting in a 404 response.
/// </exception> /// </exception>
[HttpGet("{id:guid}")] [HttpGet("authenticated")]
public async Task<ActionResult<UserAccount>> GetById(Guid id) public async Task<ActionResult<UserAccount>> GetAuthenticated()
{ {
UserAccount user = await mediator.Send( UserAccount user = await mediator.Send(
new GetUserByIdQuery(id, User.GetAuthenticatedUserId()) new GetUserByIdQuery(User.GetAuthenticatedUserId())
); );
return Ok(user); return Ok(user);
} }
@@ -61,7 +59,7 @@ public class UserController(IMediator mediator) : ControllerBase
/// <remarks> /// <remarks>
/// This endpoint is anonymous-accessible and requires no ownership. Anyone can view any /// This endpoint is anonymous-accessible and requires no ownership. Anyone can view any
/// account's public profile. The response includes only fields that are safe to show publicly. /// account's public profile. The response includes only fields that are safe to show publicly.
/// Unlike <see cref="GetById"/>, it never includes <c>Email</c> or <c>DateOfBirth</c>. /// Unlike <see cref="GetAuthenticated"/>, it never includes <c>Email</c> or <c>DateOfBirth</c>.
/// </remarks> /// </remarks>
/// <exception cref="NotFoundException"> /// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID. The API returns HTTP status 404. /// Thrown when no user account exists with the given ID. The API returns HTTP status 404.

View File

@@ -6,6 +6,15 @@ namespace Features.Users.Dtos;
public static class UserDtoMapper public static class UserDtoMapper
{ {
/// <summary>Converts a user account into its public-profile representation.</summary> /// <summary>Converts a user account into its public-profile representation.</summary>
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user) => public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user)
new(user.UserAccountId, user.Username, user.FirstName, user.LastName, user.CreatedAt); {
return new PublicUserProfileDto(
UserAccountId : user.UserAccountId,
Username : user.Username,
FirstName : user.FirstName,
LastName : user.LastName,
Biography : user.UserProfile?.Biography ?? string.Empty,
CreatedAt : user.CreatedAt
);
}
} }

View File

@@ -6,5 +6,6 @@ public record PublicUserProfileDto(
string Username, string Username,
string FirstName, string FirstName,
string LastName, string LastName,
string Biography,
DateTime CreatedAt DateTime CreatedAt
); );

View File

@@ -8,18 +8,12 @@ namespace Features.Users.Queries.GetUserById;
public class GetUserByIdHandler(IUserListRepository repository) public class GetUserByIdHandler(IUserListRepository repository)
: IRequestHandler<GetUserByIdQuery, UserAccount> : IRequestHandler<GetUserByIdQuery, UserAccount>
{ {
/// <exception cref="ForbiddenException">
/// Thrown when the caller requests an account other than their own.
/// </exception>
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception> /// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
public async Task<UserAccount> Handle( public async Task<UserAccount> Handle(
GetUserByIdQuery request, GetUserByIdQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
if (request.UserAccountId != request.RequestingUserId)
throw new ForbiddenException("You are not authorized to view this account.");
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId); UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
if (user is null) if (user is null)
throw new NotFoundException($"User with ID {request.UserAccountId} not found"); throw new NotFoundException($"User with ID {request.UserAccountId} not found");

View File

@@ -3,9 +3,7 @@ using MediatR;
namespace Features.Users.Queries.GetUserById; namespace Features.Users.Queries.GetUserById;
/// <param name="UserAccountId">The account to fetch.</param> /// <param name="UserAccountId">
/// <param name="RequestingUserId"> /// The account to fetch. This query returns private fields, including <c>Email</c>.
/// The authenticated caller's ID, from the access token. It must equal
/// <paramref name="UserAccountId" />. This query returns private fields, including <c>Email</c>.
/// </param> /// </param>
public record GetUserByIdQuery(Guid UserAccountId, Guid RequestingUserId) : IRequest<UserAccount>; public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;

View File

@@ -13,20 +13,41 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
IUserListRepository IUserListRepository
{ {
/// <inheritdoc /> /// <inheritdoc />
/// <remarks>
/// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is
/// <c>null</c> for an account whose profile has yet to be created.
/// </remarks>
public async Task<UserAccount?> GetByIdAsync(Guid id) public async Task<UserAccount?> GetByIdAsync(Guid id)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>( IEnumerable<UserAccount> results = await connection.QueryAsync<
UserAccount,
UserProfile,
UserAccount
>(
""" """
SELECT SELECT
UserAccountID, ua.UserAccountID,
Username, ua.Username,
FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion ua.FirstName, ua.LastName, ua.Email, ua.CreatedAt, ua.UpdatedAt,
FROM Auth.UserAccount ua.DateOfBirth, ua.RowVersion,
WHERE UserAccountID = @UserAccountId up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion
FROM Auth.UserAccount ua
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
WHERE ua.UserAccountID = @UserAccountId
""", """,
new { UserAccountId = id } MapUserRow,
new { UserAccountId = id },
splitOn: "UserProfileID"
); );
return results.SingleOrDefault();
}
private static UserAccount MapUserRow(UserAccount user, UserProfile? profile)
{
user.UserProfile = profile;
return user;
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -37,7 +37,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' }; let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' };
try { try {
const account = await getUserAccount(auth.accessToken, auth.userAccountId); const account = await getUserAccount(auth.accessToken);
details = { details = {
firstName: account.firstName, firstName: account.firstName,
lastName: account.lastName, lastName: account.lastName,

View File

@@ -162,11 +162,12 @@ export const refreshTokens = async (refreshToken: string) => {
return data.payload; return data.payload;
}; };
export const getUserAccount = async ( /**
accessToken: string, * Fetches the signed-in caller's own account. The backend derives the account from the access
userAccountId: string, * token, so the caller passes no ID.
): Promise<UserAccountDetails> => { */
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`, { export const getUserAccount = async (accessToken: string): Promise<UserAccountDetails> => {
const res = await fetch(`${API_BASE_URL}/api/user/authenticated`, {
headers: { Authorization: `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
}); });

View File

@@ -11,16 +11,16 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
## Header & sidebar ## Header & sidebar
| Field | Status | Source | Gap | | Field | Status | Source | Gap |
| ---------------------------------------- | ------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------- | ------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | | | Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | |
| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | | | `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | |
| Bio | ⚠️ | `FILLER_PROFILE_META.bio` | `Social.UserProfile.Biography` exists (`IUserProfileRepository`, `UpdateBiographyHandler`) but there's no query/controller route to read another user's profile — only a write path for the authenticated user's own. | | Bio | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads `Social.UserProfile.Biography` via the left join in `UserListRepository.GetByIdAsync`; an account with no profile row yet returns an empty string. |
| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. | | Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. |
| Avatar photo | ⚠️ | Initials placeholder (`avatar-placeholder`) | `Social.UserAvatar` + `UploadAvatarHandler` exist but there's no GET endpoint to read a user's stored avatar back. | | Avatar photo | ⚠️ | Initials placeholder (`avatar-placeholder`) | `Social.UserAvatar` + `UploadAvatarHandler` exist but there's no GET endpoint to read a user's stored avatar back. |
| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. | | Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. |
| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). | | Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). |
| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. | | Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. |
## Follow action ## Follow action
@@ -31,10 +31,10 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
## Profile links from other pages ## Profile links from other pages
| Field | Status | Source | Gap | | Field | Status | Source | Gap |
| ----------------------------------------------- | ------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------- | ------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Commenter name links (brewery detail comments) | ⚠️ | `CommentItem` links only when `comment.userAccountId` is set | `FILLER_COMMENTS` has no backing accounts, so those rows render as plain text; a comment added during the session by a signed-in user does carry a real GUID and links correctly. | | Commenter name links (brewery detail comments) | ⚠️ | `CommentItem` links only when `comment.userAccountId` is set | `FILLER_COMMENTS` has no backing accounts, so those rows render as plain text; a comment added during the session by a signed-in user does carry a real GUID and links correctly. |
| Following-list person links (profile page) | ⚠️ | `FollowingTab` links only when `f.userAccountId` is set | Same gap as above — `FILLER_FOLLOWING`'s two person rows have no backing accounts, so they render as plain text until real follow data exists. | | Following-list person links (profile page) | ⚠️ | `FollowingTab` links only when `f.userAccountId` is set | Same gap as above — `FILLER_FOLLOWING`'s two person rows have no backing accounts, so they render as plain text until real follow data exists. |
Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (4) below closes and comments/follows are loader-backed. Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (4) below closes and comments/follows are loader-backed.
@@ -49,9 +49,8 @@ Both components already support linking (see `CommentItem.stories.tsx`'s `WithLi
## Summary of backend work ## Summary of backend work
1. A public query + controller route to read a user's `Social.UserProfile.Biography` by `UserAccountId` (the write path already exists; only the public read is missing). 1. A public query + controller route to read a user's stored `Social.UserAvatar`.
2. A public query + controller route to read a user's stored `Social.UserAvatar`. 2. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap).
3. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap). 3. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts.
4. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts. 4. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first.
5. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first. 5. Once (3) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty.
6. Once (4) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty.

View File

@@ -93,7 +93,7 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
<ProfileSidebarCard <ProfileSidebarCard
displayName={displayName} displayName={displayName}
username={profile.username} username={profile.username}
bio={FILLER_PROFILE_META.bio} bio={profile.biography}
location={FILLER_PROFILE_META.location} location={FILLER_PROFILE_META.location}
joinedLabel={joinedLabel(profile.createdAt)} joinedLabel={joinedLabel(profile.createdAt)}
stats={stats} stats={stats}
@@ -129,11 +129,7 @@ const UserProfile = ({ loaderData }: Route.ComponentProps) => {
const { profile, isOwnProfile } = loaderData; const { profile, isOwnProfile } = loaderData;
return ( return (
<ProfileContent <ProfileContent key={profile.userAccountId} profile={profile} isOwnProfile={isOwnProfile} />
key={profile.userAccountId}
profile={profile}
isOwnProfile={isOwnProfile}
/>
); );
}; };

View File

@@ -7,6 +7,7 @@ export interface PublicUserProfile {
username: string; username: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
biography: string;
createdAt: string; createdAt: string;
} }
@@ -47,6 +48,7 @@ export const getPublicUserProfile = async (
username: string; username: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
biography: string;
createdAt: string; createdAt: string;
} = await res.json(); } = await res.json();
@@ -55,6 +57,7 @@ export const getPublicUserProfile = async (
username: account.username, username: account.username,
firstName: account.firstName, firstName: account.firstName,
lastName: account.lastName, lastName: account.lastName,
biography: account.biography,
createdAt: account.createdAt, createdAt: account.createdAt,
}; };
}; };