diff --git a/web/backend/Features/Features.Users/Controllers/UserController.cs b/web/backend/Features/Features.Users/Controllers/UserController.cs index beb75d8c..eeff6436 100644 --- a/web/backend/Features/Features.Users/Controllers/UserController.cs +++ b/web/backend/Features/Features.Users/Controllers/UserController.cs @@ -1,5 +1,6 @@ using Domain.Entities; using Domain.Exceptions; +using Features.Users.Dtos; using Features.Users.Queries.GetAllUsers; using Features.Users.Queries.GetUserById; using MediatR; @@ -37,4 +38,19 @@ public class UserController(IMediator mediator) : ControllerBase UserAccount user = await mediator.Send(new GetUserByIdQuery(id)); return Ok(user); } + + /// Gets the public profile for the user account with the given ID. + /// + /// Exposes only the fields that are safe to show on an unauthenticated, public profile page; + /// unlike , this never returns Email or DateOfBirth. + /// + /// + /// Thrown when no user account exists with the given ID, resulting in a 404 response. + /// + [HttpGet("{id:guid}/profile")] + public async Task> GetPublicProfile(Guid id) + { + UserAccount user = await mediator.Send(new GetUserByIdQuery(id)); + return Ok(user.ToPublicProfileDto()); + } } diff --git a/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs b/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs new file mode 100644 index 00000000..a38b96bd --- /dev/null +++ b/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs @@ -0,0 +1,11 @@ +using Domain.Entities; + +namespace Features.Users.Dtos; + +/// Converts user domain entities into API response models. +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); +} diff --git a/web/backend/Features/Features.Users/Dtos/UserDtos.cs b/web/backend/Features/Features.Users/Dtos/UserDtos.cs new file mode 100644 index 00000000..db1188a0 --- /dev/null +++ b/web/backend/Features/Features.Users/Dtos/UserDtos.cs @@ -0,0 +1,10 @@ +namespace Features.Users.Dtos; + +/// Represents the subset of a user account that is safe to expose on a public profile. +public record PublicUserProfileDto( + Guid UserAccountId, + string Username, + string FirstName, + string LastName, + DateTime CreatedAt +); diff --git a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx index ce47e50f..18b45014 100644 --- a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx +++ b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx @@ -8,7 +8,6 @@ const commentItemDescription = `A single comment row on the brewery detail page: const comment: FillerComment = { id: '1', user: 'malt_kettle', - userAccountId: 'malt_kettle', initials: 'MK', rating: 5, time: '2 days ago', @@ -55,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', + ); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx index 1f49ef56..c412eef7 100644 --- a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx +++ b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx @@ -20,9 +20,16 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
- - {comment.user} - + {comment.userAccountId ? ( + + {comment.user} + + ) : ( + {comment.user} + )} {comment.time}
diff --git a/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts b/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts index 690aa184..d9298434 100644 --- a/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts +++ b/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts @@ -88,7 +88,7 @@ export const FILLER_BEERS: FillerBeer[] = [ export interface FillerComment { id: string; user: string; - userAccountId: string; + userAccountId?: string; initials: string; rating: number; time: string; @@ -97,11 +97,12 @@ 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', user: 'malt_kettle', - userAccountId: 'malt_kettle', initials: 'MK', rating: 5, time: '2 days ago', @@ -112,7 +113,6 @@ export const FILLER_COMMENTS: FillerComment[] = [ { id: '2', user: 'stoutfan_88', - userAccountId: 'stoutfan_88', initials: 'SB', rating: 4, time: '1 week ago', @@ -123,7 +123,6 @@ export const FILLER_COMMENTS: FillerComment[] = [ { id: '3', user: 'ale_annie', - userAccountId: 'ale_annie', initials: 'AL', rating: 4, time: '3 weeks ago', diff --git a/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md index 2f94f86d..31cc65e0 100644 --- a/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md +++ b/web/frontend/app/features/users/USER_PROFILE_HANDOFF.md @@ -13,7 +13,7 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of | Field | Status | Source | Gap | | ---------------------------------------- | ------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Username, name, joined date | ✅ | `GET /api/user/{id}` (`UserAccount`) | | +| 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`. | diff --git a/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx b/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx index 9ff96ec7..80c889ee 100644 --- a/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx +++ b/web/frontend/app/features/users/components/profile/ProfileTabs.stories.tsx @@ -22,13 +22,19 @@ const meta = { export default meta; type Story = StoryObj; +const panels = { + activity:

Activity panel

, + following:

Following panel

, + liked:

Liked panel

, +}; + const ControlledTabs = () => { const [activeTab, setActiveTab] = useState('activity'); - return ; + return ; }; export const Default: Story = { - args: { activeTab: 'activity', onChange: fn() }, + args: { activeTab: 'activity', onChange: fn(), panels }, render: () => , play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -36,5 +42,6 @@ export const Default: Story = { 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(); }, }; diff --git a/web/frontend/app/features/users/components/profile/ProfileTabs.tsx b/web/frontend/app/features/users/components/profile/ProfileTabs.tsx index 174b7690..1713f021 100644 --- a/web/frontend/app/features/users/components/profile/ProfileTabs.tsx +++ b/web/frontend/app/features/users/components/profile/ProfileTabs.tsx @@ -1,4 +1,5 @@ -import { Tab, TabGroup, TabList } from '@headlessui/react'; +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'; +import type { ReactNode } from 'react'; export type ProfileTab = 'activity' | 'following' | 'liked'; @@ -11,9 +12,10 @@ const TABS: { id: ProfileTab; label: string }[] = [ interface ProfileTabsProps { activeTab: ProfileTab; onChange: (tab: ProfileTab) => void; + panels: Record; } -const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => { +const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => { const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab); return ( @@ -28,6 +30,11 @@ const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => { ))} + + {TABS.map((tab) => ( + {panels[tab.id]} + ))} + ); }; diff --git a/web/frontend/app/features/users/routes/user-profile.tsx b/web/frontend/app/features/users/routes/user-profile.tsx index 6948a62a..f2b7877f 100644 --- a/web/frontend/app/features/users/routes/user-profile.tsx +++ b/web/frontend/app/features/users/routes/user-profile.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { data } from 'react-router'; import RouteErrorState from '../../../components/ui/error/RouteErrorState'; import { getOptionalAuth } from '../../auth/auth.server'; -import { getPublicUserProfile } from '../users.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'; @@ -37,9 +37,17 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => { const joinedLabel = (isoDate: string) => `Joined ${new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(new Date(isoDate))}`; -const UserProfile = ({ loaderData }: Route.ComponentProps) => { - const { profile, isOwnProfile } = loaderData; +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('activity'); const [isFollowingProfile, setIsFollowingProfile] = useState(false); const [following, setFollowing] = useState(() => @@ -91,17 +99,21 @@ const UserProfile = ({ loaderData }: Route.ComponentProps) => { />
-
- - - {activeTab === 'activity' && } - {activeTab === 'following' && ( - - )} - {activeTab === 'liked' && } +
+ , + following: ( + + ), + liked: , + }} + />
@@ -109,6 +121,18 @@ const UserProfile = ({ loaderData }: Route.ComponentProps) => { ); }; +const UserProfile = ({ loaderData }: Route.ComponentProps) => { + const { profile, isOwnProfile } = loaderData; + + return ( + + ); +}; + export default UserProfile; export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => ( diff --git a/web/frontend/app/features/users/users.server.ts b/web/frontend/app/features/users/users.server.ts index 0bdd2146..52e66a9a 100644 --- a/web/frontend/app/features/users/users.server.ts +++ b/web/frontend/app/features/users/users.server.ts @@ -11,15 +11,17 @@ export interface PublicUserProfile { } /** - * Fetches the public-facing slice of a user account. `GET /api/user/{id}` also returns - * `email` and `dateOfBirth`, but those stay out of anything rendered on a public profile. + * Fetches the public-facing slice of a user account from the dedicated public-profile + * endpoint, which never includes `email` or `dateOfBirth`. */ export const getPublicUserProfile = async ( userAccountId: string, ): Promise => { let res: Response; try { - res = await fetch(`${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}`); + res = await fetch( + `${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}/profile`, + ); } catch { throw data('The user service is unreachable right now. Please try again in a moment.', { status: 503, diff --git a/web/frontend/app/features/users/utils/filler-user-profile.ts b/web/frontend/app/features/users/utils/filler-user-profile.ts index 3b9ec258..28831dc7 100644 --- a/web/frontend/app/features/users/utils/filler-user-profile.ts +++ b/web/frontend/app/features/users/utils/filler-user-profile.ts @@ -77,11 +77,13 @@ export interface FillerFollowing { 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', userAccountId: 'jweck' }, + { 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', userAccountId: 'caseyb' }, + { id: '4', name: 'Casey Blume', meta: '@caseyb · 88 ratings' }, ]; export interface FillerLiked {