mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add authenticated user profile page with locked-down account endpoint (#315)
This commit is contained in:
@@ -264,7 +264,8 @@ everything needed to understand or change one capability lives in one project
|
||||
- Controllers generally bind directly to the Command/Query as the request body;
|
||||
there is no separate request DTO + mapping step for writes
|
||||
- The exception to this is for authenticated routes, when the current user id
|
||||
needs to be extracted from the authentication token to be used in a command.
|
||||
needs to be extracted from the authentication token to be used in a command
|
||||
or query.
|
||||
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
||||
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
|
||||
MediatR pipeline before any handler executes
|
||||
@@ -379,6 +380,7 @@ web/frontend/
|
||||
│ │ - catalog beers, beer styles
|
||||
│ │ - home landing page
|
||||
│ │ - theme theme switcher/guide
|
||||
│ │ - users public user profile page (/users/:id)
|
||||
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
|
||||
│ ├── routes.ts Route table (React Router 8 config-based routing)
|
||||
│ ├── root.tsx App shell and global providers
|
||||
@@ -395,6 +397,8 @@ web/frontend/
|
||||
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
|
||||
search
|
||||
- Render the auth demo, account dashboard, and theme guide routes
|
||||
- Render the public user profile route (`/users/:id`), including a user's
|
||||
following list and (via brewery comments) links back to commenter profiles
|
||||
- Manage cookie-backed website session state
|
||||
- Call the .NET API for login, registration, token refresh, confirmation, and
|
||||
brewery/location data
|
||||
|
||||
@@ -249,9 +249,11 @@ API.Specs/
|
||||
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
|
||||
│ ├── TokenRefresh.feature # Refresh token scenarios
|
||||
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
|
||||
│ └── NotFound.feature # 404 handling
|
||||
│ ├── NotFound.feature # 404 handling
|
||||
│ └── PublicUserProfile.feature # Public profile & locked-down account scenarios
|
||||
├── Steps/
|
||||
│ ├── AuthSteps.cs # Step definitions for the Auth features
|
||||
│ ├── UserSteps.cs # Step definitions for the public profile & user-account features
|
||||
│ └── ApiGeneralSteps.cs # Shared/general step definitions
|
||||
├── Mocks/
|
||||
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
||||
|
||||
66
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal file
66
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal file
@@ -0,0 +1,66 @@
|
||||
Feature: Public User Profile
|
||||
As a client of the API
|
||||
I want to read a user's public-facing profile
|
||||
So that a public profile page can render without exposing private account fields
|
||||
|
||||
Background:
|
||||
Given the API is running
|
||||
|
||||
Scenario: Public profile exposes only allowlisted fields
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should match the registered account
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
Scenario: Public profile for a non-existent account returns not found
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I retrieve the public profile by a non-existent ID
|
||||
Then the response has HTTP status 404
|
||||
|
||||
Scenario: Fetching the public profile without authentication succeeds
|
||||
Given I have registered a new account
|
||||
When I retrieve the public profile by ID without authentication
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
Scenario: A different user can read someone else's public profile
|
||||
Given I have registered a new account
|
||||
And I am logged in as a different user
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
Scenario: Fetching a user account by ID without authentication is rejected
|
||||
When I retrieve the user account by a non-existent ID without authentication
|
||||
Then the response has HTTP status 401
|
||||
|
||||
Scenario: Listing user accounts without authentication is rejected
|
||||
When I list user accounts without authentication
|
||||
Then the response has HTTP status 401
|
||||
|
||||
Scenario: Listing user accounts as an authenticated user excludes private fields
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I list user accounts
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
Scenario: The owner can fetch their own account by ID
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I retrieve the registered account by ID
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should match the registered account
|
||||
|
||||
Scenario: A different user cannot fetch someone else's account by ID
|
||||
Given I have registered a new account
|
||||
And I am logged in as a different user
|
||||
When I retrieve the registered account by ID
|
||||
Then the response has HTTP status 403
|
||||
@@ -167,4 +167,40 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
actualValue
|
||||
);
|
||||
}
|
||||
|
||||
[Then("the response JSON should not contain {string}")]
|
||||
public void ThenTheResponseJsonShouldNotContainString(string field)
|
||||
{
|
||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
AssertFieldAbsent(root, field);
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("payload", out JsonElement payloadElem))
|
||||
AssertFieldAbsent(payloadElem, field);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
|
||||
/// single-resource response.
|
||||
/// </summary>
|
||||
private static void AssertFieldAbsent(JsonElement element, string field)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
element
|
||||
.TryGetProperty(field, out _)
|
||||
.Should()
|
||||
.BeFalse("Expected field '{0}' to be absent from the response", field);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
AssertFieldAbsent(item, field);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,46 @@ public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||
Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
|
||||
}
|
||||
|
||||
[Given("I am logged in as a different user")]
|
||||
public async Task GivenIAmLoggedInAsADifferentUser()
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
string suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var registrationData = new
|
||||
{
|
||||
username = $"otheruser-{suffix}",
|
||||
firstName = "Other",
|
||||
lastName = "User",
|
||||
email = $"otheruser-{suffix}@example.com",
|
||||
dateOfBirth = "1990-01-01",
|
||||
password = "Password1!",
|
||||
};
|
||||
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(registrationData),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
response.EnsureSuccessStatusCode();
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody);
|
||||
JsonElement payload = doc.RootElement.GetProperty("payload");
|
||||
string accessToken =
|
||||
(
|
||||
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
|
||||
? tokenElem.GetString()
|
||||
: null
|
||||
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
|
||||
|
||||
Scenario["accessToken"] = accessToken;
|
||||
}
|
||||
|
||||
[Given("I am logged in")]
|
||||
public async Task GivenIAmLoggedIn()
|
||||
{
|
||||
|
||||
@@ -262,46 +262,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||
await SendAsync(requestMessage);
|
||||
}
|
||||
|
||||
[Given("I am logged in as a different user")]
|
||||
public async Task GivenIAmLoggedInAsADifferentUser()
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
string suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var registrationData = new
|
||||
{
|
||||
username = $"otheruser-{suffix}",
|
||||
firstName = "Other",
|
||||
lastName = "User",
|
||||
email = $"otheruser-{suffix}@example.com",
|
||||
dateOfBirth = "1990-01-01",
|
||||
password = "Password1!",
|
||||
};
|
||||
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(registrationData),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
response.EnsureSuccessStatusCode();
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody);
|
||||
JsonElement payload = doc.RootElement.GetProperty("payload");
|
||||
string accessToken =
|
||||
(
|
||||
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
|
||||
? tokenElem.GetString()
|
||||
: null
|
||||
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
|
||||
|
||||
Scenario[AccessTokenKey] = accessToken;
|
||||
}
|
||||
|
||||
[Then("retrieving the brewery by ID should now return HTTP status {int}")]
|
||||
public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
|
||||
{
|
||||
|
||||
95
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal file
95
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Reqnroll;
|
||||
|
||||
namespace API.Specs.Steps;
|
||||
|
||||
[Binding]
|
||||
public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||
{
|
||||
private const string AccessTokenKey = "accessToken";
|
||||
private const string RegisteredUserIdKey = "registeredUserId";
|
||||
private const string RegisteredUsernameKey = "registeredUsername";
|
||||
|
||||
private async Task SendAsync(HttpRequestMessage requestMessage)
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Scenario[ResponseKey] = response;
|
||||
Scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
|
||||
{
|
||||
HttpRequestMessage requestMessage = new(method, url);
|
||||
if (Scenario.TryGetValue<string>(AccessTokenKey, out string? accessToken))
|
||||
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
private Guid GetRegisteredUserId()
|
||||
{
|
||||
return Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
|
||||
? id
|
||||
: throw new InvalidOperationException("registered user ID not found in scenario");
|
||||
}
|
||||
|
||||
[When("I retrieve the public profile by ID")]
|
||||
public async Task WhenIRetrieveThePublicProfileById()
|
||||
{
|
||||
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
|
||||
}
|
||||
|
||||
[When("I retrieve the public profile by ID without authentication")]
|
||||
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
|
||||
{
|
||||
await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
|
||||
}
|
||||
|
||||
[When("I retrieve the public profile by a non-existent ID")]
|
||||
public async Task WhenIRetrieveThePublicProfileByANonExistentId()
|
||||
{
|
||||
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile"));
|
||||
}
|
||||
|
||||
[When("I retrieve the user account by a non-existent ID without authentication")]
|
||||
public async Task WhenIRetrieveTheUserAccountByANonExistentIdWithoutAuthentication()
|
||||
{
|
||||
await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}"));
|
||||
}
|
||||
|
||||
[When("I list user accounts without authentication")]
|
||||
public async Task WhenIListUserAccountsWithoutAuthentication()
|
||||
{
|
||||
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user"));
|
||||
}
|
||||
|
||||
[When("I list user accounts")]
|
||||
public async Task WhenIListUserAccounts()
|
||||
{
|
||||
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user"));
|
||||
}
|
||||
|
||||
[When("I retrieve the registered account by ID")]
|
||||
public async Task WhenIRetrieveTheRegisteredAccountById()
|
||||
{
|
||||
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}"));
|
||||
}
|
||||
|
||||
[Then("the public profile response should match the registered account")]
|
||||
public void ThenThePublicProfileResponseShouldMatchTheRegisteredAccount()
|
||||
{
|
||||
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? u)
|
||||
? u
|
||||
: throw new InvalidOperationException("registered username not found in scenario");
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId());
|
||||
root.GetProperty("username").GetString().Should().Be(username);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Domain.Entities;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetAllUsers;
|
||||
using Features.Users.Repository;
|
||||
using FluentAssertions;
|
||||
@@ -15,7 +16,7 @@ public class GetAllUsersHandlerTests
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
|
||||
IEnumerable<UserAccount> result = await handler.Handle(
|
||||
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
|
||||
new GetAllUsersQuery(10, 5),
|
||||
CancellationToken.None
|
||||
);
|
||||
@@ -23,4 +24,27 @@ public class GetAllUsersHandlerTests
|
||||
result.Should().BeEmpty();
|
||||
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields()
|
||||
{
|
||||
Mock<IUserListRepository> repoMock = new();
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "someone",
|
||||
FirstName = "Some",
|
||||
LastName = "One",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
|
||||
|
||||
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
|
||||
new GetAllUsersQuery(null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetPublicUserProfileById;
|
||||
using Features.Users.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Users.Tests.Queries;
|
||||
|
||||
public class GetPublicUserProfileByIdHandlerTests
|
||||
{
|
||||
private readonly GetPublicUserProfileByIdHandler _handler;
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
|
||||
public GetPublicUserProfileByIdHandlerTests()
|
||||
{
|
||||
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsPublicProfile_WhenFound()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
Email = "jane@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
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
|
||||
.Should()
|
||||
.Be(
|
||||
new PublicUserProfileDto(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.CreatedAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Throws_WhenNotFound()
|
||||
{
|
||||
Guid id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<Task<PublicUserProfileDto>> act = async () =>
|
||||
await _handler.Handle(new GetPublicUserProfileByIdQuery(id), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public class GetUserByIdHandlerTests
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
UserAccount result = await _handler.Handle(
|
||||
new GetUserByIdQuery(user.UserAccountId),
|
||||
new GetUserByIdQuery(user.UserAccountId, user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
@@ -38,8 +38,21 @@ public class GetUserByIdHandlerTests
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<Task<UserAccount>> act = async () =>
|
||||
await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
|
||||
await _handler.Handle(new GetUserByIdQuery(id, id), CancellationToken.None);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,76 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetAllUsers;
|
||||
using Features.Users.Queries.GetPublicUserProfileById;
|
||||
using Features.Users.Queries.GetUserById;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Contracts;
|
||||
|
||||
namespace Features.Users.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes read endpoints for user accounts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every endpoint requires <c>[Authorize(AuthenticationSchemes = "JWT")]</c> except
|
||||
/// <see cref="GetPublicProfile" />, which is <see cref="AllowAnonymousAttribute" /> since it
|
||||
/// only returns an allowlisted DTO. <see cref="GetById" /> also restricts access to the
|
||||
/// caller's own account. Neither <see cref="GetAll" /> nor <see cref="GetById" /> may expose
|
||||
/// another user's <c>Email</c> or <c>DateOfBirth</c>.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class UserController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>Gets a page of user accounts, ordered by creation date descending.</summary>
|
||||
/// <summary>Gets a page of user accounts' public profiles, ordered by creation date descending.</summary>
|
||||
/// <param name="limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="offset"><c>null</c> for no offset.</param>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
|
||||
public async Task<ActionResult<IEnumerable<PublicUserProfileDto>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset
|
||||
)
|
||||
{
|
||||
IEnumerable<UserAccount> users = await mediator.Send(new GetAllUsersQuery(limit, offset));
|
||||
IEnumerable<PublicUserProfileDto> users = await mediator.Send(
|
||||
new GetAllUsersQuery(limit, offset)
|
||||
);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
/// <summary>Gets the user account with the given ID.</summary>
|
||||
/// <summary>Gets the caller's own user account by ID.</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">
|
||||
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
|
||||
/// </exception>
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||
{
|
||||
UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
|
||||
UserAccount user = await mediator.Send(
|
||||
new GetUserByIdQuery(id, User.GetAuthenticatedUserId())
|
||||
);
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
/// <summary>Gets the public profile for the user account with the given ID.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// Unlike <see cref="GetById"/>, it never includes <c>Email</c> or <c>DateOfBirth</c>.
|
||||
/// </remarks>
|
||||
/// <exception cref="NotFoundException">
|
||||
/// Thrown when no user account exists with the given ID. The API returns HTTP status 404.
|
||||
/// </exception>
|
||||
[HttpGet("{id:guid}/profile")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<PublicUserProfileDto>> GetPublicProfile(Guid id)
|
||||
{
|
||||
PublicUserProfileDto profile = await mediator.Send(new GetPublicUserProfileByIdQuery(id));
|
||||
return Ok(profile);
|
||||
}
|
||||
}
|
||||
|
||||
11
web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
Normal file
11
web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Users.Dtos;
|
||||
|
||||
/// <summary>Converts user domain entities into API response models.</summary>
|
||||
public static class UserDtoMapper
|
||||
{
|
||||
/// <summary>Converts a user account into its public-profile representation.</summary>
|
||||
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user) =>
|
||||
new(user.UserAccountId, user.Username, user.FirstName, user.LastName, user.CreatedAt);
|
||||
}
|
||||
10
web/backend/Features/Features.Users/Dtos/UserDtos.cs
Normal file
10
web/backend/Features/Features.Users/Dtos/UserDtos.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Features.Users.Dtos;
|
||||
|
||||
/// <summary>Represents the subset of a user account that is safe to expose on a public profile.</summary>
|
||||
public record PublicUserProfileDto(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
@@ -1,17 +1,19 @@
|
||||
using Domain.Entities;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetAllUsers;
|
||||
|
||||
public class GetAllUsersHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
|
||||
{
|
||||
public Task<IEnumerable<UserAccount>> Handle(
|
||||
public async Task<IEnumerable<PublicUserProfileDto>> Handle(
|
||||
GetAllUsersQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return repository.GetAllAsync(request.Limit, request.Offset);
|
||||
IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset);
|
||||
return users.Select(user => user.ToPublicProfileDto());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using Domain.Entities;
|
||||
using Features.Users.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetAllUsers;
|
||||
|
||||
/// <summary>
|
||||
/// Requests a page of user accounts, ordered by creation date descending.
|
||||
/// Requests a page of user accounts' public profiles, ordered by creation date descending.
|
||||
/// </summary>
|
||||
/// <param name="Limit"><c>null</c> for no limit.</param>
|
||||
/// <param name="Offset"><c>null</c> for no offset.</param>
|
||||
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>;
|
||||
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<PublicUserProfileDto>>;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||
|
||||
public class GetPublicUserProfileByIdHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
public async Task<PublicUserProfileDto> Handle(
|
||||
GetPublicUserProfileByIdQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||
return user.ToPublicProfileDto();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using Features.Users.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||
|
||||
public record GetPublicUserProfileByIdQuery(Guid UserAccountId) : IRequest<PublicUserProfileDto>;
|
||||
@@ -8,12 +8,18 @@ namespace Features.Users.Queries.GetUserById;
|
||||
public class GetUserByIdHandler(IUserListRepository repository)
|
||||
: 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>
|
||||
public async Task<UserAccount> Handle(
|
||||
GetUserByIdQuery request,
|
||||
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);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||
|
||||
@@ -3,4 +3,9 @@ using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetUserById;
|
||||
|
||||
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
||||
/// <param name="UserAccountId">The account to fetch.</param>
|
||||
/// <param name="RequestingUserId">
|
||||
/// 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>
|
||||
public record GetUserByIdQuery(Guid UserAccountId, Guid RequestingUserId) : IRequest<UserAccount>;
|
||||
|
||||
@@ -33,13 +33,15 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <remarks>
|
||||
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
||||
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
|
||||
/// Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is consumed only as public
|
||||
/// profiles, so those fields are left at their default rather than read from the database.
|
||||
/// </remarks>
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
SELECT UserAccountID, Username, FirstName, LastName, CreatedAt, UpdatedAt, RowVersion
|
||||
FROM Auth.UserAccount
|
||||
ORDER BY CreatedAt DESC
|
||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
import Navbar from './Navbar';
|
||||
|
||||
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
|
||||
@@ -47,8 +47,10 @@ export const Authenticated: Story = {
|
||||
const userButton = canvas.getByRole('button', { name: /hans/i });
|
||||
await expect(userButton).toBeInTheDocument();
|
||||
await userEvent.click(userButton);
|
||||
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
|
||||
// MenuItems is anchor-positioned, which portals it outside canvasElement.
|
||||
await expect(screen.getByRole('menuitem', { name: /my profile/i })).toBeInTheDocument();
|
||||
await expect(screen.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
|
||||
await expect(screen.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -72,7 +72,10 @@ const Navbar = ({ auth }: NavbarProps) => (
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<UserMenu username={auth.username} />
|
||||
<UserMenu
|
||||
username={auth.username}
|
||||
userAccountId={auth.userAccountId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
|
||||
@@ -3,25 +3,40 @@ import { Link } from 'react-router';
|
||||
|
||||
interface UserMenuProps {
|
||||
username: string;
|
||||
userAccountId: string;
|
||||
}
|
||||
|
||||
const UserMenu = ({ username }: UserMenuProps) => (
|
||||
<Menu as="div" className="relative">
|
||||
const UserMenu = ({ username, userAccountId }: UserMenuProps) => (
|
||||
<Menu>
|
||||
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItems
|
||||
anchor="bottom end"
|
||||
transition
|
||||
className="menu z-60 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl [--anchor-gap:8px] focus:outline-none transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
|
||||
>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={`/users/${userAccountId}`}
|
||||
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||
>
|
||||
My profile
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
to="/logout"
|
||||
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
|
||||
>
|
||||
Logout
|
||||
</Link>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
|
||||
let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' };
|
||||
try {
|
||||
const account = await getUserAccount(auth.userAccountId);
|
||||
const account = await getUserAccount(auth.accessToken, auth.userAccountId);
|
||||
details = {
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
|
||||
@@ -162,8 +162,13 @@ export const refreshTokens = async (refreshToken: string) => {
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const getUserAccount = async (userAccountId: string): Promise<UserAccountDetails> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`);
|
||||
export const getUserAccount = async (
|
||||
accessToken: string,
|
||||
userAccountId: string,
|
||||
): Promise<UserAccountDetails> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
|
||||
@@ -54,3 +54,14 @@ export const Liked: Story = {
|
||||
await expect(canvas.getByRole('button', { name: /13 likes/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLinkedAccount: Story = {
|
||||
args: { comment: { ...comment, userAccountId: '11111111-1111-1111-1111-111111111111' } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('link', { name: 'malt_kettle' })).toHaveAttribute(
|
||||
'href',
|
||||
'/users/11111111-1111-1111-1111-111111111111',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Heart, HeartSolid } from 'iconoir-react';
|
||||
import { Link } from 'react-router';
|
||||
import StarRating from '../../shared/StarRating';
|
||||
import type { FillerComment } from '../../../utils/filler-brewery-detail';
|
||||
|
||||
@@ -19,7 +20,16 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="font-bold text-sm">{comment.user}</span>
|
||||
{comment.userAccountId ? (
|
||||
<Link
|
||||
to={`/users/${comment.userAccountId}`}
|
||||
className="font-bold text-sm link link-hover"
|
||||
>
|
||||
{comment.user}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-bold text-sm">{comment.user}</span>
|
||||
)}
|
||||
<StarRating value={comment.rating} size="xs" />
|
||||
<span className="text-xs text-base-content/60">{comment.time}</span>
|
||||
</div>
|
||||
|
||||
@@ -28,13 +28,14 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
brewery,
|
||||
loggedIn: auth !== null,
|
||||
username: auth?.username ?? null,
|
||||
userAccountId: auth?.userAccountId ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const initials = (username: string) => username.slice(0, 2).toUpperCase();
|
||||
|
||||
const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { brewery, loggedIn, username } = loaderData;
|
||||
const { brewery, loggedIn, username, userAccountId } = loaderData;
|
||||
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(FILLER_BREWERY_META.likeCount);
|
||||
@@ -57,6 +58,7 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
user: username ?? 'you',
|
||||
userAccountId: userAccountId ?? undefined,
|
||||
initials: username ? initials(username) : 'YO',
|
||||
rating: yourRating,
|
||||
time: 'just now',
|
||||
|
||||
@@ -88,6 +88,7 @@ export const FILLER_BEERS: FillerBeer[] = [
|
||||
export interface FillerComment {
|
||||
id: string;
|
||||
user: string;
|
||||
userAccountId?: string;
|
||||
initials: string;
|
||||
rating: number;
|
||||
time: string;
|
||||
@@ -96,6 +97,8 @@ export interface FillerComment {
|
||||
liked: boolean;
|
||||
}
|
||||
|
||||
// `userAccountId` is left unset below since these commenters aren't backed by real accounts
|
||||
// yet; CommentItem only links a commenter's name once a real account GUID is available.
|
||||
export const FILLER_COMMENTS: FillerComment[] = [
|
||||
{
|
||||
id: '1',
|
||||
|
||||
57
web/frontend/app/features/users/USER_PROFILE_HANDOFF.md
Normal file
57
web/frontend/app/features/users/USER_PROFILE_HANDOFF.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Public user profile: data requirements
|
||||
|
||||
Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
|
||||
`../breweries/BREWERY_HANDOFF.md`.
|
||||
|
||||
## Status legend
|
||||
|
||||
- ✅ **Available**: real data, wired up.
|
||||
- ⚠️ **Partial**: schema/repository exists but no public read endpoint.
|
||||
- ❌ **Missing**: no backend support; page shows filler or local-only state.
|
||||
|
||||
## Header & sidebar
|
||||
|
||||
| Field | Status | Source | Gap |
|
||||
| ---------------------------------------- | ------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | |
|
||||
| `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. |
|
||||
| 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. |
|
||||
| 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 = ?`). |
|
||||
| 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
|
||||
|
||||
| Field | Status | Source | Gap |
|
||||
| ----------------------------- | ------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Follow / unfollow the profile | ❌ | Local component state | `Social.UserFollow` has no command/query/controller (create, delete, "am I following them" check) — mirrors the brewery-like gap. |
|
||||
| Edit profile (own profile) | ✅ | Links to `/account`, the existing profile-edit surface | |
|
||||
|
||||
## Profile links from other pages
|
||||
|
||||
| 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. |
|
||||
| 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.
|
||||
|
||||
## Activity / Following / Liked tabs
|
||||
|
||||
| Field | Status | Source | Gap |
|
||||
| -------------------------------------- | ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Activity feed (ratings/likes/comments) | ❌ | `FILLER_ACTIVITY` | No unified activity/timeline concept. Needs a query that unions ratings, likes, and comments across brewery/beer posts for a given user, ordered by time. |
|
||||
| Following list (people + breweries) | ❌ | `FILLER_FOLLOWING` | Needs a query over `Social.UserFollow` joined to `Auth.UserAccount`, plus (once the like/rating gaps close) a way to follow breweries specifically. |
|
||||
| Liked list | ❌ | `FILLER_LIKED` | Blocked on the `BreweryPostLike`/`BeerPostLike` gap in `BREWERY_HANDOFF.md`. |
|
||||
| Unfollow from the Following tab | ❌ | Local component state (optimistic toggle) | Same `Social.UserFollow` gap as the header Follow action. |
|
||||
|
||||
## 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).
|
||||
2. A public query + controller route to read a user's stored `Social.UserAvatar`.
|
||||
3. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap).
|
||||
4. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts.
|
||||
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.
|
||||
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.
|
||||
@@ -0,0 +1,9 @@
|
||||
const EmptyState = ({ message }: { message: string }) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body items-center py-12">
|
||||
<p className="text-sm text-base-content/60 text-center m-0">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EmptyState;
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
import ProfileSidebarCard from './ProfileSidebarCard';
|
||||
|
||||
const profileSidebarCardDescription = `Identity card in the public profile page's sidebar: name/handle, the Follow/Edit profile action, bio, location and join date, and the follower/following stat list.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Users/ProfileSidebarCard',
|
||||
component: ProfileSidebarCard,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
displayName: 'Maren Iverson',
|
||||
username: 'mareniverson',
|
||||
bio: 'Homebrewer turned hop enthusiast. Chasing the best stout in the Midwest, one taproom at a time. 🍺',
|
||||
location: 'Portland, OR',
|
||||
joinedLabel: 'Joined March 2023',
|
||||
stats: [
|
||||
{ label: 'Following', value: 68 },
|
||||
{ label: 'Followers', value: 214 },
|
||||
{ label: 'Breweries visited', value: 37 },
|
||||
{ label: 'Ratings given', value: 112 },
|
||||
],
|
||||
onToggleFollow: fn(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: profileSidebarCardDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ProfileSidebarCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Visitor: Story = {
|
||||
args: { isOwnProfile: false, isFollowing: false },
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const followButton = canvas.getByRole('button', { name: /^follow$/i });
|
||||
await userEvent.click(followButton);
|
||||
await expect(args.onToggleFollow).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const FollowingVisitor: Story = {
|
||||
args: { isOwnProfile: false, isFollowing: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('button', { name: /^following$/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const OwnProfile: Story = {
|
||||
args: { isOwnProfile: true, isFollowing: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('link', { name: /edit profile/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Calendar, MapPin } from 'iconoir-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface ProfileStat {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface ProfileSidebarCardProps {
|
||||
displayName: string;
|
||||
username: string;
|
||||
bio: string;
|
||||
location: string;
|
||||
joinedLabel: string;
|
||||
stats: ProfileStat[];
|
||||
isOwnProfile: boolean;
|
||||
isFollowing: boolean;
|
||||
onToggleFollow: () => void;
|
||||
}
|
||||
|
||||
const ProfileSidebarCard = ({
|
||||
displayName,
|
||||
username,
|
||||
bio,
|
||||
location,
|
||||
joinedLabel,
|
||||
stats,
|
||||
isOwnProfile,
|
||||
isFollowing,
|
||||
onToggleFollow,
|
||||
}: ProfileSidebarCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body gap-4 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold m-0 leading-tight">{displayName}</h1>
|
||||
<p className="text-sm text-base-content/60 m-0 mt-0.5">@{username}</p>
|
||||
</div>
|
||||
|
||||
{isOwnProfile ? (
|
||||
<Link to="/account" className="btn btn-outline btn-block">
|
||||
Edit profile
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFollow}
|
||||
className={`btn btn-block ${isFollowing ? 'btn-outline' : 'btn-primary'}`}
|
||||
>
|
||||
{isFollowing ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="text-sm leading-normal m-0">{bio}</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5 text-sm text-base-content/60">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
||||
{location}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="size-4 shrink-0" aria-hidden="true" />
|
||||
{joinedLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider m-0" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-base-content/60">{stat.label}</span>
|
||||
<span className="font-serif text-lg font-bold tabular-nums">
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ProfileSidebarCard;
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
import ProfileTabs, { type ProfileTab } from './ProfileTabs';
|
||||
|
||||
const profileTabsDescription = `Activity / Following / Liked tab bar for the public profile page. Switching tabs is an instant swap with no transition, consistent with the design system's stance on tab bars.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Users/ProfileTabs',
|
||||
component: ProfileTabs,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: profileTabsDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ProfileTabs>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const panels = {
|
||||
activity: <p>Activity panel</p>,
|
||||
following: <p>Following panel</p>,
|
||||
liked: <p>Liked panel</p>,
|
||||
};
|
||||
|
||||
const ControlledTabs = ({ initialTab }: { initialTab: ProfileTab }) => {
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>(initialTab);
|
||||
return <ProfileTabs activeTab={activeTab} onChange={setActiveTab} panels={panels} />;
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: { activeTab: 'activity', onChange: fn(), panels },
|
||||
render: (args) => <ControlledTabs initialTab={args.activeTab} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const followingTab = canvas.getByRole('tab', { name: /following/i });
|
||||
await expect(followingTab).toHaveAttribute('aria-selected', 'false');
|
||||
await userEvent.click(followingTab);
|
||||
await expect(followingTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(canvas.getByText('Following panel')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type ProfileTab = 'activity' | 'following' | 'liked';
|
||||
|
||||
const TABS: { id: ProfileTab; label: string }[] = [
|
||||
{ id: 'activity', label: 'Activity' },
|
||||
{ id: 'following', label: 'Following' },
|
||||
{ id: 'liked', label: 'Liked' },
|
||||
];
|
||||
|
||||
interface ProfileTabsProps {
|
||||
activeTab: ProfileTab;
|
||||
onChange: (tab: ProfileTab) => void;
|
||||
panels: Record<ProfileTab, ReactNode>;
|
||||
}
|
||||
|
||||
const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
|
||||
const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab);
|
||||
|
||||
return (
|
||||
<TabGroup selectedIndex={selectedIndex} onChange={(index) => onChange(TABS[index].id)}>
|
||||
<TabList className="flex gap-6 border-b border-base-300">
|
||||
{TABS.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/60 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels className="mt-5">
|
||||
{TABS.map((tab) => (
|
||||
<TabPanel key={tab.id}>{panels[tab.id]}</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileTabs;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import ActivityTab from './ActivityTab';
|
||||
import { FILLER_ACTIVITY } from '../../../utils/filler-user-profile';
|
||||
|
||||
const activityTabDescription = `A profile's Activity tab: ratings, likes, and comments as a vertical feed, newest first. Renders the empty-state card when the collection is empty.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Users/ActivityTab',
|
||||
component: ActivityTab,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: activityTabDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ActivityTab>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Populated: Story = {
|
||||
args: { activity: FILLER_ACTIVITY },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/interurban ipa/i)).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/best fruited stout/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { activity: [] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.getByText('No activity yet. Ratings, likes, and comments will show up here.'),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ChatBubble, Heart, Star } from 'iconoir-react';
|
||||
import EmptyState from '../EmptyState';
|
||||
import StarRating from '../../../../breweries/components/shared/StarRating';
|
||||
import type { FillerActivity } from '../../../utils/filler-user-profile';
|
||||
|
||||
const ICONS = { star: Star, heart: Heart, comment: ChatBubble } as const;
|
||||
|
||||
interface ActivityTabProps {
|
||||
activity: FillerActivity[];
|
||||
}
|
||||
|
||||
const ActivityTab = ({ activity }: ActivityTabProps) => {
|
||||
if (activity.length === 0) {
|
||||
return (
|
||||
<EmptyState message="No activity yet. Ratings, likes, and comments will show up here." />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{activity.map((item) => {
|
||||
const Icon = ICONS[item.icon];
|
||||
return (
|
||||
<div key={item.id} className="card bg-base-100 shadow">
|
||||
<div className="card-body flex-row gap-4 items-start p-4">
|
||||
<div className="w-[34px] h-[34px] rounded-full bg-accent text-accent-content flex items-center justify-center shrink-0">
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm leading-normal m-0">
|
||||
{item.verb} <span className="font-semibold">{item.target}</span>
|
||||
</p>
|
||||
{item.rating !== undefined && (
|
||||
<div className="mt-1.5">
|
||||
<StarRating value={item.rating} size="xs" />
|
||||
</div>
|
||||
)}
|
||||
{item.comment && (
|
||||
<p className="text-sm text-base-content/60 italic mt-1.5 mb-0">
|
||||
“{item.comment}”
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-base-content/60 mt-1.5 mb-0">
|
||||
{item.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityTab;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
import FollowingTab from './FollowingTab';
|
||||
import { FILLER_FOLLOWING } from '../../../utils/filler-user-profile';
|
||||
|
||||
const followingTabDescription = `A profile's Following tab: the people and breweries this user follows, each with an unfollow toggle.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Users/FollowingTab',
|
||||
component: FollowingTab,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
onToggleFollow: fn(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: followingTabDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof FollowingTab>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Populated: Story = {
|
||||
args: {
|
||||
following: FILLER_FOLLOWING.map((f) => ({ ...f, isFollowing: true })),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = canvas.getAllByRole('button', { name: /^following$/i })[0];
|
||||
await userEvent.click(button);
|
||||
await expect(args.onToggleFollow).toHaveBeenCalledWith(FILLER_FOLLOWING[0].id);
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { following: [] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText('Not following anyone yet.')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Link } from 'react-router';
|
||||
import EmptyState from '../EmptyState';
|
||||
import type { FillerFollowing } from '../../../utils/filler-user-profile';
|
||||
|
||||
const initials = (name: string) =>
|
||||
name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
|
||||
interface FollowingTabProps {
|
||||
following: (FillerFollowing & { isFollowing: boolean })[];
|
||||
onToggleFollow: (id: string) => void;
|
||||
}
|
||||
|
||||
const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => {
|
||||
if (following.length === 0) {
|
||||
return <EmptyState message="Not following anyone yet." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{following.map((f) => (
|
||||
<div key={f.id} className="card bg-base-100 shadow">
|
||||
<div className="card-body flex-row items-center gap-4 p-4">
|
||||
<div className="avatar avatar-placeholder shrink-0">
|
||||
<div className="bg-secondary text-secondary-content w-11 rounded-full">
|
||||
<span className="text-sm font-bold">{initials(f.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{f.userAccountId ? (
|
||||
<Link
|
||||
to={`/users/${f.userAccountId}`}
|
||||
className="font-semibold text-sm truncate link link-hover block"
|
||||
>
|
||||
{f.name}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-semibold text-sm m-0 truncate">{f.name}</p>
|
||||
)}
|
||||
<p className="text-xs text-base-content/60 m-0 truncate">{f.meta}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleFollow(f.id)}
|
||||
className={`btn btn-sm shrink-0 ${f.isFollowing ? 'btn-outline' : 'btn-primary'}`}
|
||||
>
|
||||
{f.isFollowing ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowingTab;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import LikedTab from './LikedTab';
|
||||
import { FILLER_LIKED } from '../../../utils/filler-user-profile';
|
||||
|
||||
const likedTabDescription = `A profile's Liked tab: a 2-column grid of beers this user has liked, each with a rating.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Users/LikedTab',
|
||||
component: LikedTab,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: likedTabDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof LikedTab>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Populated: Story = {
|
||||
args: { liked: FILLER_LIKED },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText('Interurban IPA')).toBeInTheDocument();
|
||||
await expect(canvas.getByText('Fremont Brewing')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { liked: [] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText('No likes yet.')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import EmptyState from '../EmptyState';
|
||||
import StarRating from '../../../../breweries/components/shared/StarRating';
|
||||
import type { FillerLiked } from '../../../utils/filler-user-profile';
|
||||
|
||||
interface LikedTabProps {
|
||||
liked: FillerLiked[];
|
||||
}
|
||||
|
||||
const LikedTab = ({ liked }: LikedTabProps) => {
|
||||
if (liked.length === 0) {
|
||||
return <EmptyState message="No likes yet." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{liked.map((item) => (
|
||||
<div key={item.id} className="card bg-base-100 shadow overflow-hidden">
|
||||
<div className="h-[120px] bg-base-300" />
|
||||
<div className="card-body p-4 gap-1">
|
||||
<p className="font-serif font-bold m-0">{item.name}</p>
|
||||
<p className="text-sm text-base-content/60 m-0">{item.meta}</p>
|
||||
<div className="mt-1">
|
||||
<StarRating value={item.rating} size="xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LikedTab;
|
||||
144
web/frontend/app/features/users/routes/user-profile.tsx
Normal file
144
web/frontend/app/features/users/routes/user-profile.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { data } from 'react-router';
|
||||
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||
import { getOptionalAuth } from '../../auth/auth.server';
|
||||
import { getPublicUserProfile, type PublicUserProfile } from '../users.server';
|
||||
import ProfileSidebarCard from '../components/profile/ProfileSidebarCard';
|
||||
import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs';
|
||||
import ActivityTab from '../components/profile/tabs/ActivityTab';
|
||||
import FollowingTab from '../components/profile/tabs/FollowingTab';
|
||||
import LikedTab from '../components/profile/tabs/LikedTab';
|
||||
import {
|
||||
FILLER_ACTIVITY,
|
||||
FILLER_FOLLOWING,
|
||||
FILLER_LIKED,
|
||||
FILLER_PROFILE_META,
|
||||
} from '../utils/filler-user-profile';
|
||||
import type { Route } from './+types/user-profile';
|
||||
|
||||
export const meta = ({ loaderData }: Route.MetaArgs) => [
|
||||
{
|
||||
title: loaderData
|
||||
? `${loaderData.profile.username} | The Biergarten App`
|
||||
: 'Profile | The Biergarten App',
|
||||
},
|
||||
];
|
||||
|
||||
export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
|
||||
const profile = await getPublicUserProfile(params.id, auth?.accessToken);
|
||||
if (!profile) {
|
||||
throw data('User not found.', { status: 404, statusText: 'Not Found' });
|
||||
}
|
||||
|
||||
return {
|
||||
profile,
|
||||
isOwnProfile: auth?.userAccountId === profile.userAccountId,
|
||||
};
|
||||
};
|
||||
|
||||
const joinedLabel = (isoDate: string) =>
|
||||
`Joined ${new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(new Date(isoDate))}`;
|
||||
|
||||
interface ProfileContentProps {
|
||||
profile: PublicUserProfile;
|
||||
isOwnProfile: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the profile-scoped interactive state. Keyed by `profile.userAccountId` in the
|
||||
* parent so React remounts it (resetting follow/tab state) when the route's `:id` changes,
|
||||
* since React Router reuses the route component itself across param-only navigations.
|
||||
*/
|
||||
const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('activity');
|
||||
const [isFollowingProfile, setIsFollowingProfile] = useState(false);
|
||||
const [following, setFollowing] = useState(() =>
|
||||
FILLER_FOLLOWING.map((f) => ({ ...f, isFollowing: true })),
|
||||
);
|
||||
|
||||
const handleToggleFollowRow = (id: string) => {
|
||||
setFollowing((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, isFollowing: !f.isFollowing } : f)),
|
||||
);
|
||||
};
|
||||
|
||||
const displayName = `${profile.firstName} ${profile.lastName}`.trim() || profile.username;
|
||||
|
||||
const stats = [
|
||||
{ label: 'Following', value: FILLER_PROFILE_META.following },
|
||||
{ label: 'Followers', value: FILLER_PROFILE_META.followers },
|
||||
{ label: 'Breweries visited', value: FILLER_PROFILE_META.breweriesVisited },
|
||||
{ label: 'Ratings given', value: FILLER_PROFILE_META.ratingsGiven },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 pb-16">
|
||||
<div className="max-w-[1080px] mx-auto px-6 pt-8">
|
||||
<div className="rounded-box h-[190px] bg-base-300" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
|
||||
<div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2">
|
||||
<div className="w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md overflow-hidden">
|
||||
<div className="avatar avatar-placeholder w-full h-full">
|
||||
<div className="bg-secondary text-secondary-content w-full h-full flex items-center justify-center">
|
||||
<span className="text-2xl font-bold">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileSidebarCard
|
||||
displayName={displayName}
|
||||
username={profile.username}
|
||||
bio={FILLER_PROFILE_META.bio}
|
||||
location={FILLER_PROFILE_META.location}
|
||||
joinedLabel={joinedLabel(profile.createdAt)}
|
||||
stats={stats}
|
||||
isOwnProfile={isOwnProfile}
|
||||
isFollowing={isFollowingProfile}
|
||||
onToggleFollow={() => setIsFollowingProfile((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<ProfileTabs
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
panels={{
|
||||
activity: <ActivityTab activity={FILLER_ACTIVITY} />,
|
||||
following: (
|
||||
<FollowingTab
|
||||
following={following}
|
||||
onToggleFollow={handleToggleFollowRow}
|
||||
/>
|
||||
),
|
||||
liked: <LikedTab liked={FILLER_LIKED} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UserProfile = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { profile, isOwnProfile } = loaderData;
|
||||
|
||||
return (
|
||||
<ProfileContent
|
||||
key={profile.userAccountId}
|
||||
profile={profile}
|
||||
isOwnProfile={isOwnProfile}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
|
||||
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
|
||||
<RouteErrorState error={error} />
|
||||
);
|
||||
60
web/frontend/app/features/users/users.server.ts
Normal file
60
web/frontend/app/features/users/users.server.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { data } from 'react-router';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
export interface PublicUserProfile {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the public-facing slice of a user account from the dedicated public-profile
|
||||
* endpoint, which never includes `email` or `dateOfBirth`. The endpoint is anonymous-accessible,
|
||||
* so `accessToken` is optional and only sent when the caller is signed in.
|
||||
*/
|
||||
export const getPublicUserProfile = async (
|
||||
userAccountId: string,
|
||||
accessToken?: string,
|
||||
): Promise<PublicUserProfile | null> => {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}/profile`, {
|
||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw data('The user service is unreachable right now. Please try again in a moment.', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw data(`Failed to load user profile (${res.status}).`, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
const account: {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: string;
|
||||
} = await res.json();
|
||||
|
||||
return {
|
||||
userAccountId: account.userAccountId,
|
||||
username: account.username,
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
createdAt: account.createdAt,
|
||||
};
|
||||
};
|
||||
101
web/frontend/app/features/users/utils/filler-user-profile.ts
Normal file
101
web/frontend/app/features/users/utils/filler-user-profile.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// see USER_PROFILE_HANDOFF.md — filler data pending backend support for
|
||||
// bio, location, avatar/cover photos, follower/following stats, and activity feeds.
|
||||
|
||||
export interface FillerProfileMeta {
|
||||
bio: string;
|
||||
location: string;
|
||||
following: number;
|
||||
followers: number;
|
||||
breweriesVisited: number;
|
||||
ratingsGiven: number;
|
||||
}
|
||||
|
||||
export const FILLER_PROFILE_META: FillerProfileMeta = {
|
||||
bio: 'Homebrewer turned hop enthusiast. Chasing the best stout in the Midwest, one taproom at a time. 🍺',
|
||||
location: 'Portland, OR',
|
||||
following: 68,
|
||||
followers: 214,
|
||||
breweriesVisited: 37,
|
||||
ratingsGiven: 112,
|
||||
};
|
||||
|
||||
export interface FillerActivity {
|
||||
id: string;
|
||||
icon: 'star' | 'heart' | 'comment';
|
||||
verb: string;
|
||||
target: string;
|
||||
rating?: number;
|
||||
comment?: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export const FILLER_ACTIVITY: FillerActivity[] = [
|
||||
{
|
||||
id: '1',
|
||||
icon: 'star',
|
||||
verb: 'Rated',
|
||||
target: 'Fremont Brewing — Interurban IPA',
|
||||
rating: 5,
|
||||
time: '2 hours ago',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
icon: 'heart',
|
||||
verb: 'Liked',
|
||||
target: 'Stormbreaker Brewing',
|
||||
time: '1 day ago',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
icon: 'comment',
|
||||
verb: 'Commented on',
|
||||
target: 'Great Notion — Blueberry Muffin',
|
||||
comment: "Best fruited stout I've had this year, worth the line.",
|
||||
time: '3 days ago',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
icon: 'star',
|
||||
verb: 'Rated',
|
||||
target: 'Baerlic Brewing Co.',
|
||||
rating: 4,
|
||||
time: '5 days ago',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
icon: 'heart',
|
||||
verb: 'Liked',
|
||||
target: 'Ecliptic Brewing — Phase Shift IPA',
|
||||
time: '1 week ago',
|
||||
},
|
||||
];
|
||||
|
||||
export interface FillerFollowing {
|
||||
id: string;
|
||||
name: string;
|
||||
meta: string;
|
||||
userAccountId?: string;
|
||||
}
|
||||
|
||||
// `userAccountId` is left unset for rows below since no backend account backs them yet;
|
||||
// FollowingTab only links a row once a real account GUID is available (see USER_PROFILE_HANDOFF.md).
|
||||
export const FILLER_FOLLOWING: FillerFollowing[] = [
|
||||
{ id: '1', name: 'Breakside Brewery', meta: 'Portland, OR · Brewery' },
|
||||
{ id: '2', name: 'Jonah Weckstein', meta: '@jweck · 142 ratings' },
|
||||
{ id: '3', name: 'Wayfinder Beer', meta: 'Portland, OR · Brewery' },
|
||||
{ id: '4', name: 'Casey Blume', meta: '@caseyb · 88 ratings' },
|
||||
];
|
||||
|
||||
export interface FillerLiked {
|
||||
id: string;
|
||||
name: string;
|
||||
meta: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export const FILLER_LIKED: FillerLiked[] = [
|
||||
{ id: '1', name: 'Interurban IPA', meta: 'Fremont Brewing', rating: 5 },
|
||||
{ id: '2', name: 'Blueberry Muffin', meta: 'Great Notion Brewing', rating: 5 },
|
||||
{ id: '3', name: 'Phase Shift IPA', meta: 'Ecliptic Brewing', rating: 4 },
|
||||
{ id: '4', name: 'Snapshot Wheat', meta: 'Deschutes Brewery', rating: 4 },
|
||||
];
|
||||
@@ -15,4 +15,5 @@ export default [
|
||||
route('breweries/nearby', 'features/breweries/routes/resources/breweries-nearby.ts'),
|
||||
route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'),
|
||||
route('beer-styles', 'features/catalog/routes/beer-styles.tsx'),
|
||||
route('users/:id', 'features/users/routes/user-profile.tsx'),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
Reference in New Issue
Block a user