Address PR #310 review feedback on the public profile page

This commit is contained in:
Aaron Po
2026-09-13 01:05:01 -04:00
parent 81717eddbf
commit 132f93cc87
12 changed files with 127 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Users.Dtos;
using Features.Users.Queries.GetAllUsers; using Features.Users.Queries.GetAllUsers;
using Features.Users.Queries.GetUserById; using Features.Users.Queries.GetUserById;
using MediatR; using MediatR;
@@ -37,4 +38,19 @@ public class UserController(IMediator mediator) : ControllerBase
UserAccount user = await mediator.Send(new GetUserByIdQuery(id)); UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
return Ok(user); return Ok(user);
} }
/// <summary>Gets the public profile for the user account with the given ID.</summary>
/// <remarks>
/// Exposes only the fields that are safe to show on an unauthenticated, public profile page;
/// unlike <see cref="GetById"/>, this never returns <c>Email</c> or <c>DateOfBirth</c>.
/// </remarks>
/// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
/// </exception>
[HttpGet("{id:guid}/profile")]
public async Task<ActionResult<PublicUserProfileDto>> GetPublicProfile(Guid id)
{
UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
return Ok(user.ToPublicProfileDto());
}
} }

View 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);
}

View 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
);

View File

@@ -8,7 +8,6 @@ const commentItemDescription = `A single comment row on the brewery detail page:
const comment: FillerComment = { const comment: FillerComment = {
id: '1', id: '1',
user: 'malt_kettle', user: 'malt_kettle',
userAccountId: 'malt_kettle',
initials: 'MK', initials: 'MK',
rating: 5, rating: 5,
time: '2 days ago', time: '2 days ago',
@@ -55,3 +54,14 @@ export const Liked: Story = {
await expect(canvas.getByRole('button', { name: /13 likes/i })).toBeInTheDocument(); 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',
);
},
};

View File

@@ -20,9 +20,16 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
</div> </div>
<div className="flex-1 flex flex-col gap-1 min-w-0"> <div className="flex-1 flex flex-col gap-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap"> <div className="flex items-baseline gap-2 flex-wrap">
<Link to={`/users/${comment.userAccountId}`} className="font-bold text-sm link link-hover"> {comment.userAccountId ? (
{comment.user} <Link
</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" /> <StarRating value={comment.rating} size="xs" />
<span className="text-xs text-base-content/60">{comment.time}</span> <span className="text-xs text-base-content/60">{comment.time}</span>
</div> </div>

View File

@@ -88,7 +88,7 @@ export const FILLER_BEERS: FillerBeer[] = [
export interface FillerComment { export interface FillerComment {
id: string; id: string;
user: string; user: string;
userAccountId: string; userAccountId?: string;
initials: string; initials: string;
rating: number; rating: number;
time: string; time: string;
@@ -97,11 +97,12 @@ export interface FillerComment {
liked: boolean; 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[] = [ export const FILLER_COMMENTS: FillerComment[] = [
{ {
id: '1', id: '1',
user: 'malt_kettle', user: 'malt_kettle',
userAccountId: 'malt_kettle',
initials: 'MK', initials: 'MK',
rating: 5, rating: 5,
time: '2 days ago', time: '2 days ago',
@@ -112,7 +113,6 @@ export const FILLER_COMMENTS: FillerComment[] = [
{ {
id: '2', id: '2',
user: 'stoutfan_88', user: 'stoutfan_88',
userAccountId: 'stoutfan_88',
initials: 'SB', initials: 'SB',
rating: 4, rating: 4,
time: '1 week ago', time: '1 week ago',
@@ -123,7 +123,6 @@ export const FILLER_COMMENTS: FillerComment[] = [
{ {
id: '3', id: '3',
user: 'ale_annie', user: 'ale_annie',
userAccountId: 'ale_annie',
initials: 'AL', initials: 'AL',
rating: 4, rating: 4,
time: '3 weeks ago', time: '3 weeks ago',

View File

@@ -13,7 +13,7 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
| Field | Status | Source | Gap | | 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 | | | `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | |
| Bio | ⚠️ | `FILLER_PROFILE_META.bio` | `Social.UserProfile.Biography` exists (`IUserProfileRepository`, `UpdateBiographyHandler`) but there's no query/controller route to read another user's profile — only a write path for the authenticated user's own. | | Bio | ⚠️ | `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`. | | Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. |

View File

@@ -22,13 +22,19 @@ const meta = {
export default meta; export default meta;
type Story = StoryObj<typeof 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 = () => { const ControlledTabs = () => {
const [activeTab, setActiveTab] = useState<ProfileTab>('activity'); const [activeTab, setActiveTab] = useState<ProfileTab>('activity');
return <ProfileTabs activeTab={activeTab} onChange={setActiveTab} />; return <ProfileTabs activeTab={activeTab} onChange={setActiveTab} panels={panels} />;
}; };
export const Default: Story = { export const Default: Story = {
args: { activeTab: 'activity', onChange: fn() }, args: { activeTab: 'activity', onChange: fn(), panels },
render: () => <ControlledTabs />, render: () => <ControlledTabs />,
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
@@ -36,5 +42,6 @@ export const Default: Story = {
await expect(followingTab).toHaveAttribute('aria-selected', 'false'); await expect(followingTab).toHaveAttribute('aria-selected', 'false');
await userEvent.click(followingTab); await userEvent.click(followingTab);
await expect(followingTab).toHaveAttribute('aria-selected', 'true'); await expect(followingTab).toHaveAttribute('aria-selected', 'true');
await expect(canvas.getByText('Following panel')).toBeInTheDocument();
}, },
}; };

View File

@@ -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'; export type ProfileTab = 'activity' | 'following' | 'liked';
@@ -11,9 +12,10 @@ const TABS: { id: ProfileTab; label: string }[] = [
interface ProfileTabsProps { interface ProfileTabsProps {
activeTab: ProfileTab; activeTab: ProfileTab;
onChange: (tab: ProfileTab) => void; onChange: (tab: ProfileTab) => void;
panels: Record<ProfileTab, ReactNode>;
} }
const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => { const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab); const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab);
return ( return (
@@ -28,6 +30,11 @@ const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => {
</Tab> </Tab>
))} ))}
</TabList> </TabList>
<TabPanels className="mt-5">
{TABS.map((tab) => (
<TabPanel key={tab.id}>{panels[tab.id]}</TabPanel>
))}
</TabPanels>
</TabGroup> </TabGroup>
); );
}; };

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { data } from 'react-router'; import { data } from 'react-router';
import RouteErrorState from '../../../components/ui/error/RouteErrorState'; import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import { getOptionalAuth } from '../../auth/auth.server'; 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 ProfileSidebarCard from '../components/profile/ProfileSidebarCard';
import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs'; import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs';
import ActivityTab from '../components/profile/tabs/ActivityTab'; import ActivityTab from '../components/profile/tabs/ActivityTab';
@@ -37,9 +37,17 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
const joinedLabel = (isoDate: string) => const joinedLabel = (isoDate: string) =>
`Joined ${new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(new Date(isoDate))}`; `Joined ${new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(new Date(isoDate))}`;
const UserProfile = ({ loaderData }: Route.ComponentProps) => { interface ProfileContentProps {
const { profile, isOwnProfile } = loaderData; 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 [activeTab, setActiveTab] = useState<ProfileTab>('activity');
const [isFollowingProfile, setIsFollowingProfile] = useState(false); const [isFollowingProfile, setIsFollowingProfile] = useState(false);
const [following, setFollowing] = useState(() => const [following, setFollowing] = useState(() =>
@@ -91,17 +99,21 @@ const UserProfile = ({ loaderData }: Route.ComponentProps) => {
/> />
</div> </div>
<div className="flex flex-col gap-5 mt-12"> <div className="mt-12">
<ProfileTabs activeTab={activeTab} onChange={setActiveTab} /> <ProfileTabs
activeTab={activeTab}
{activeTab === 'activity' && <ActivityTab activity={FILLER_ACTIVITY} />} onChange={setActiveTab}
{activeTab === 'following' && ( panels={{
<FollowingTab activity: <ActivityTab activity={FILLER_ACTIVITY} />,
following={following} following: (
onToggleFollow={handleToggleFollowRow} <FollowingTab
/> following={following}
)} onToggleFollow={handleToggleFollowRow}
{activeTab === 'liked' && <LikedTab liked={FILLER_LIKED} />} />
),
liked: <LikedTab liked={FILLER_LIKED} />,
}}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -109,6 +121,18 @@ const UserProfile = ({ loaderData }: Route.ComponentProps) => {
); );
}; };
const UserProfile = ({ loaderData }: Route.ComponentProps) => {
const { profile, isOwnProfile } = loaderData;
return (
<ProfileContent
key={profile.userAccountId}
profile={profile}
isOwnProfile={isOwnProfile}
/>
);
};
export default UserProfile; export default UserProfile;
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => ( export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (

View File

@@ -11,15 +11,17 @@ export interface PublicUserProfile {
} }
/** /**
* Fetches the public-facing slice of a user account. `GET /api/user/{id}` also returns * Fetches the public-facing slice of a user account from the dedicated public-profile
* `email` and `dateOfBirth`, but those stay out of anything rendered on a public profile. * endpoint, which never includes `email` or `dateOfBirth`.
*/ */
export const getPublicUserProfile = async ( export const getPublicUserProfile = async (
userAccountId: string, userAccountId: string,
): Promise<PublicUserProfile | null> => { ): Promise<PublicUserProfile | null> => {
let res: Response; let res: Response;
try { try {
res = await fetch(`${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}`); res = await fetch(
`${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}/profile`,
);
} catch { } catch {
throw data('The user service is unreachable right now. Please try again in a moment.', { throw data('The user service is unreachable right now. Please try again in a moment.', {
status: 503, status: 503,

View File

@@ -77,11 +77,13 @@ export interface FillerFollowing {
userAccountId?: 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[] = [ export const FILLER_FOLLOWING: FillerFollowing[] = [
{ id: '1', name: 'Breakside Brewery', meta: 'Portland, OR · Brewery' }, { 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: '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 { export interface FillerLiked {