diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 5d78666f..ee389bd5 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -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 - 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`) @@ -187,7 +189,7 @@ needs it, or because duplicating it four times would be worse than sharing it | Project | Purpose | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Shared.Contracts` | `ResponseBody`/`ResponseBody`, the API response envelope every controller returns | +| `Shared.Contracts` | `ResponseBody`/`ResponseBody`, the API response envelope `AuthController`, `BreweryController`, and `CityController` return; `UserController`'s read endpoints return their DTOs unwrapped | | `Shared.Application` | `ValidationBehavior`, the MediatR pipeline behavior that runs FluentValidation before a handler executes | **Rules**: diff --git a/docs/web/database.md b/docs/web/database.md index ab7068ee..70204a8d 100644 --- a/docs/web/database.md +++ b/docs/web/database.md @@ -76,7 +76,7 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`). | `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 | | `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` | | `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 | diff --git a/docs/web/testing.md b/docs/web/testing.md index 97a525d9..7ba616e8 100644 --- a/docs/web/testing.md +++ b/docs/web/testing.md @@ -250,7 +250,7 @@ API.Specs/ │ ├── TokenRefresh.feature # Refresh token scenarios │ ├── AccessTokenValidation.feature # Protected endpoint access scenarios │ ├── NotFound.feature # 404 handling -│ └── PublicUserProfile.feature # Public profile & locked-down account scenarios +│ └── PublicUserProfile.feature # Public profile & authenticated account scenarios ├── Steps/ │ ├── AuthSteps.cs # Step definitions for the Auth features │ ├── UserSteps.cs # Step definitions for the public profile & user-account features @@ -282,17 +282,25 @@ Scenario: Successful user registration Features.Users.Tests/ ├── Commands/ │ ├── RegisterUserHandlerTests.cs +│ ├── RegisterUserValidatorTests.cs │ ├── ConfirmUserHandlerTests.cs │ ├── ResendConfirmationEmailHandlerTests.cs │ ├── RefreshTokenHandlerTests.cs +│ ├── RefreshTokenValidatorTests.cs +│ ├── LoginValidatorTests.cs # tests LoginCommand's validator │ ├── UpdateUsernameHandlerTests.cs +│ ├── UpdateUsernameValidatorTests.cs │ ├── UpdateEmailHandlerTests.cs +│ ├── UpdateEmailValidatorTests.cs │ ├── UpdatePasswordHandlerTests.cs +│ ├── UpdatePasswordValidatorTests.cs │ ├── UpdateProfileHandlerTests.cs +│ ├── UpdateProfileValidatorTests.cs │ └── DeleteAccountHandlerTests.cs ├── Queries/ │ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name │ ├── GetAllUsersHandlerTests.cs +│ ├── GetPublicUserProfileByIdHandlerTests.cs │ └── GetUserByIdHandlerTests.cs ├── Services/ │ ├── TokenServiceRefreshTests.cs @@ -300,10 +308,13 @@ Features.Users.Tests/ └── TestSupport/ # shared test fixtures/helpers ``` -Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`, -`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/` -folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a -validator test file alongside its handler test). +`Features.Breweries.Tests` and `Features.PhotoUpload.Tests` follow the same +shape: a `Commands/`/`Queries/` folder with one test file per handler, plus a +`*ValidatorTests.cs` file beside each handler whose command or query has a +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 diff --git a/web/backend/API/API.Specs/Features/PublicUserProfile.feature b/web/backend/API/API.Specs/Features/PublicUserProfile.feature index 160871d8..6469dcfc 100644 --- a/web/backend/API/API.Specs/Features/PublicUserProfile.feature +++ b/web/backend/API/API.Specs/Features/PublicUserProfile.feature @@ -12,6 +12,7 @@ Scenario: Public profile exposes only allowlisted fields 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 have "biography" equal "" And the response JSON should not contain "email" 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 "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 +Scenario: Fetching my own account without authentication is rejected + When I retrieve my own account without authentication Then the response has HTTP status 401 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 "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 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 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 And I am logged in as a different user - When I retrieve the registered account by ID - Then the response has HTTP status 403 + When I retrieve my own account + Then the response has HTTP status 200 + And the account response should be for a different account than the registered one diff --git a/web/backend/API/API.Specs/Steps/UserSteps.cs b/web/backend/API/API.Specs/Steps/UserSteps.cs index 8bb0e588..d1957205 100644 --- a/web/backend/API/API.Specs/Steps/UserSteps.cs +++ b/web/backend/API/API.Specs/Steps/UserSteps.cs @@ -54,10 +54,10 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario) 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() + [When("I retrieve my own account without authentication")] + 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")] @@ -72,10 +72,10 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario) await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user")); } - [When("I retrieve the registered account by ID")] - public async Task WhenIRetrieveTheRegisteredAccountById() + [When("I retrieve my own account")] + 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")] @@ -92,4 +92,16 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario) root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId()); 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(ResponseBodyKey, out string? responseBody).Should().BeTrue(); + + using JsonDocument doc = JsonDocument.Parse(responseBody!); + doc.RootElement.GetProperty("userAccountId") + .GetGuid() + .Should() + .NotBe(GetRegisteredUserId()); + } } diff --git a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs index 1a07ff2e..8a974e94 100644 --- a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs +++ b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs @@ -19,4 +19,5 @@ public sealed class UserAccount // Navigation properties public UserCredential? UserCredential { get; set; } + public UserProfile? UserProfile { get; set; } } diff --git a/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj b/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj index a63c6a0a..a48cc221 100644 --- a/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj +++ b/web/backend/Features/Features.PhotoUpload/Features.PhotoUpload.csproj @@ -12,10 +12,6 @@ - diff --git a/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs index fc732a7c..73d57615 100644 --- a/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs @@ -30,6 +30,7 @@ public class GetPublicUserProfileByIdHandlerTests Email = "jane@example.com", DateOfBirth = new DateTime(1990, 1, 1), CreatedAt = new DateTime(2024, 1, 1), + UserProfile = new UserProfile { Biography = "Drinks lagers." }, }; _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user); @@ -46,11 +47,33 @@ public class GetPublicUserProfileByIdHandlerTests user.Username, user.FirstName, user.LastName, + "Drinks lagers.", 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] public async Task Handle_Throws_WhenNotFound() { diff --git a/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs index af2ee7e0..86f00db5 100644 --- a/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs +++ b/web/backend/Features/Features.Users.Tests/Queries/GetUserByIdHandlerTests.cs @@ -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, user.UserAccountId), + new GetUserByIdQuery(user.UserAccountId), CancellationToken.None ); @@ -38,21 +38,8 @@ public class GetUserByIdHandlerTests _repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null); Func> act = async () => - await _handler.Handle(new GetUserByIdQuery(id, id), CancellationToken.None); + await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None); await act.Should().ThrowAsync(); } - - [Fact] - public async Task Handle_Throws_WhenRequestingSomeoneElsesAccount() - { - Guid id = Guid.NewGuid(); - Guid requestingUserId = Guid.NewGuid(); - - Func> act = async () => - await _handler.Handle(new GetUserByIdQuery(id, requestingUserId), CancellationToken.None); - - await act.Should().ThrowAsync(); - _repoMock.Verify(r => r.GetByIdAsync(It.IsAny()), Times.Never); - } } diff --git a/web/backend/Features/Features.Users/Controllers/UserController.cs b/web/backend/Features/Features.Users/Controllers/UserController.cs index 778453bc..15ef8b47 100644 --- a/web/backend/Features/Features.Users/Controllers/UserController.cs +++ b/web/backend/Features/Features.Users/Controllers/UserController.cs @@ -17,9 +17,10 @@ namespace Features.Users.Controllers; /// /// Every endpoint requires [Authorize(AuthenticationSchemes = "JWT")] except /// , which is since it -/// only returns an allowlisted DTO. also restricts access to the -/// caller's own account. Neither nor may expose -/// another user's Email or DateOfBirth. +/// only returns an allowlisted DTO. serves the caller's own +/// account, taken from the access token. Neither nor +/// may expose another user's Email or +/// DateOfBirth. /// [ApiController] [Route("api/[controller]")] @@ -41,18 +42,15 @@ public class UserController(IMediator mediator) : ControllerBase return Ok(users); } - /// Gets the caller's own user account by ID. - /// - /// Thrown when is not the caller's own account. The API returns HTTP status 403. - /// + /// Gets the authenticated caller's own user account. /// - /// 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. /// - [HttpGet("{id:guid}")] - public async Task> GetById(Guid id) + [HttpGet("authenticated")] + public async Task> GetAuthenticated() { UserAccount user = await mediator.Send( - new GetUserByIdQuery(id, User.GetAuthenticatedUserId()) + new GetUserByIdQuery(User.GetAuthenticatedUserId()) ); return Ok(user); } @@ -61,7 +59,7 @@ public class UserController(IMediator mediator) : ControllerBase /// /// 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 , it never includes Email or DateOfBirth. + /// Unlike , it never includes Email or DateOfBirth. /// /// /// Thrown when no user account exists with the given ID. The API returns HTTP status 404. diff --git a/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs b/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs index a38b96bd..47277365 100644 --- a/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs +++ b/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs @@ -6,6 +6,15 @@ namespace Features.Users.Dtos; public static class UserDtoMapper { /// Converts a user account into its public-profile representation. - public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user) => - new(user.UserAccountId, user.Username, user.FirstName, user.LastName, user.CreatedAt); + public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user) + { + return new PublicUserProfileDto( + UserAccountId : user.UserAccountId, + Username : user.Username, + FirstName : user.FirstName, + LastName : user.LastName, + Biography : user.UserProfile?.Biography ?? string.Empty, + CreatedAt : user.CreatedAt + ); + } } diff --git a/web/backend/Features/Features.Users/Dtos/UserDtos.cs b/web/backend/Features/Features.Users/Dtos/UserDtos.cs index db1188a0..2aee8af4 100644 --- a/web/backend/Features/Features.Users/Dtos/UserDtos.cs +++ b/web/backend/Features/Features.Users/Dtos/UserDtos.cs @@ -6,5 +6,6 @@ public record PublicUserProfileDto( string Username, string FirstName, string LastName, + string Biography, DateTime CreatedAt ); diff --git a/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs index 0193a76f..76e770d7 100644 --- a/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs +++ b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdHandler.cs @@ -8,18 +8,12 @@ namespace Features.Users.Queries.GetUserById; public class GetUserByIdHandler(IUserListRepository repository) : IRequestHandler { - /// - /// Thrown when the caller requests an account other than their own. - /// /// Thrown when no user account exists with the given ID. public async Task 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"); diff --git a/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs index 9dbc90bd..bfa17485 100644 --- a/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs +++ b/web/backend/Features/Features.Users/Queries/GetUserById/GetUserByIdQuery.cs @@ -3,9 +3,7 @@ using MediatR; namespace Features.Users.Queries.GetUserById; -/// The account to fetch. -/// -/// The authenticated caller's ID, from the access token. It must equal -/// . This query returns private fields, including Email. +/// +/// The account to fetch. This query returns private fields, including Email. /// -public record GetUserByIdQuery(Guid UserAccountId, Guid RequestingUserId) : IRequest; +public record GetUserByIdQuery(Guid UserAccountId) : IRequest; diff --git a/web/backend/Features/Features.Users/Repository/UserListRepository.cs b/web/backend/Features/Features.Users/Repository/UserListRepository.cs index ce4940a2..f5dc8543 100644 --- a/web/backend/Features/Features.Users/Repository/UserListRepository.cs +++ b/web/backend/Features/Features.Users/Repository/UserListRepository.cs @@ -13,20 +13,41 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory) IUserListRepository { /// + /// + /// Left-joins Social.UserProfile, so is + /// null for an account whose profile has yet to be created. + /// public async Task GetByIdAsync(Guid id) { await using DbConnection connection = await CreateConnection(); - return await connection.QueryFirstOrDefaultAsync( + IEnumerable results = await connection.QueryAsync< + UserAccount, + UserProfile, + UserAccount + >( """ SELECT - UserAccountID, - Username, - FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion - FROM Auth.UserAccount - WHERE UserAccountID = @UserAccountId + ua.UserAccountID, + ua.Username, + ua.FirstName, ua.LastName, ua.Email, ua.CreatedAt, ua.UpdatedAt, + ua.DateOfBirth, ua.RowVersion, + 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; } /// diff --git a/web/frontend/app/features/account/routes/account.tsx b/web/frontend/app/features/account/routes/account.tsx index a4a3e6cf..91f4a3d4 100644 --- a/web/frontend/app/features/account/routes/account.tsx +++ b/web/frontend/app/features/account/routes/account.tsx @@ -37,7 +37,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => { let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' }; try { - const account = await getUserAccount(auth.accessToken, auth.userAccountId); + const account = await getUserAccount(auth.accessToken); details = { firstName: account.firstName, lastName: account.lastName, diff --git a/web/frontend/app/features/auth/auth.server.ts b/web/frontend/app/features/auth/auth.server.ts index c738e1fc..2eb8d2bd 100644 --- a/web/frontend/app/features/auth/auth.server.ts +++ b/web/frontend/app/features/auth/auth.server.ts @@ -162,11 +162,12 @@ export const refreshTokens = async (refreshToken: string) => { return data.payload; }; -export const getUserAccount = async ( - accessToken: string, - userAccountId: string, -): Promise => { - const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`, { +/** + * Fetches the signed-in caller's own account. The backend derives the account from the access + * token, so the caller passes no ID. + */ +export const getUserAccount = async (accessToken: string): Promise => { + const res = await fetch(`${API_BASE_URL}/api/user/authenticated`, { headers: { Authorization: `Bearer ${accessToken}` }, }); diff --git a/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md index 407ace9f..24fa5a83 100644 --- a/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md +++ b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md @@ -11,16 +11,16 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of ## 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`. | +| 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 | ✅ | `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`. | +| 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 @@ -31,10 +31,10 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of ## 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. | +| 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. @@ -49,9 +49,8 @@ Both components already support linking (see `CommentItem.stories.tsx`'s `WithLi ## 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. +1. 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. `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. 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. diff --git a/web/frontend/app/features/users/routes/user-profile.tsx b/web/frontend/app/features/users/routes/user-profile.tsx index a81daf8f..8a2f9779 100644 --- a/web/frontend/app/features/users/routes/user-profile.tsx +++ b/web/frontend/app/features/users/routes/user-profile.tsx @@ -93,7 +93,7 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => { { const { profile, isOwnProfile } = loaderData; return ( - + ); }; diff --git a/web/frontend/app/features/users/users.server.ts b/web/frontend/app/features/users/users.server.ts index 35205ea9..9f0865e9 100644 --- a/web/frontend/app/features/users/users.server.ts +++ b/web/frontend/app/features/users/users.server.ts @@ -7,6 +7,7 @@ export interface PublicUserProfile { username: string; firstName: string; lastName: string; + biography: string; createdAt: string; } @@ -47,6 +48,7 @@ export const getPublicUserProfile = async ( username: string; firstName: string; lastName: string; + biography: string; createdAt: string; } = await res.json(); @@ -55,6 +57,7 @@ export const getPublicUserProfile = async ( username: account.username, firstName: account.firstName, lastName: account.lastName, + biography: account.biography, createdAt: account.createdAt, }; };