mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Features/public biography endpoint (#316)
This commit is contained in:
@@ -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**:
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user