Features/public biography endpoint (#316)

This commit is contained in:
Aaron Po
2026-09-26 19:51:10 -04:00
committed by GitHub
parent e1b6cba1d4
commit c5972b9f04
20 changed files with 158 additions and 104 deletions

View File

@@ -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,

View File

@@ -162,11 +162,12 @@ export const refreshTokens = async (refreshToken: string) => {
return data.payload;
};
export const getUserAccount = async (
accessToken: string,
userAccountId: string,
): Promise<UserAccountDetails> => {
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<UserAccountDetails> => {
const res = await fetch(`${API_BASE_URL}/api/user/authenticated`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

View File

@@ -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.

View File

@@ -93,7 +93,7 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
<ProfileSidebarCard
displayName={displayName}
username={profile.username}
bio={FILLER_PROFILE_META.bio}
bio={profile.biography}
location={FILLER_PROFILE_META.location}
joinedLabel={joinedLabel(profile.createdAt)}
stats={stats}
@@ -129,11 +129,7 @@ const UserProfile = ({ loaderData }: Route.ComponentProps) => {
const { profile, isOwnProfile } = loaderData;
return (
<ProfileContent
key={profile.userAccountId}
profile={profile}
isOwnProfile={isOwnProfile}
/>
<ProfileContent key={profile.userAccountId} profile={profile} isOwnProfile={isOwnProfile} />
);
};

View File

@@ -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,
};
};