From 2ab2799155f3a9a5cf9f44fb17288a9d11a147df Mon Sep 17 00:00:00 2001 From: Neo Isaac Amao Date: Thu, 23 Jul 2026 17:36:03 +0800 Subject: [PATCH 1/6] fix(avatar): render avataaars objects consistently across the app Parse avatar qualities vs URL in Avatar, pass raw avatar values through chat/comments/activity, sync login/nav avatar from GET_USER, and keep store avatar updates typed for objects. Co-authored-by: Cursor --- .../hooks/useSyncCurrentUserProfile.test.tsx | 124 ++++++++++++++++ .../src/app/dashboard/layout.tsx | 2 + .../profile/[username]/avatar/page.tsx | 51 +++++-- quotevote-frontend/src/components/Avatar.tsx | 138 ++++++++++-------- .../components/BuddyList/BuddyItemList.tsx | 2 +- .../src/components/Chat/AddBuddyDialog.tsx | 2 +- .../src/components/Chat/MessageItemList.tsx | 2 +- .../components/Chat/QuoteHeaderMessage.tsx | 2 +- .../src/components/Chat/UserSearchResults.tsx | 2 +- .../src/components/Comment/Comment.tsx | 14 +- .../src/components/Comment/CommentInput.tsx | 15 +- .../src/components/PostChat/PostChatSend.tsx | 2 +- .../src/components/ui/ActivityCard.tsx | 30 ++-- quotevote-frontend/src/graphql/queries.ts | 4 + .../src/hooks/useSyncCurrentUserProfile.ts | 110 ++++++++++++++ quotevote-frontend/src/store/useAppStore.ts | 2 +- quotevote-frontend/src/types/activity.ts | 4 +- quotevote-frontend/src/types/buddylist.ts | 4 +- quotevote-frontend/src/types/chat.ts | 6 +- quotevote-frontend/src/types/components.ts | 11 +- quotevote-frontend/src/types/postChat.ts | 2 +- 21 files changed, 409 insertions(+), 120 deletions(-) create mode 100644 quotevote-frontend/src/__tests__/hooks/useSyncCurrentUserProfile.test.tsx create mode 100644 quotevote-frontend/src/hooks/useSyncCurrentUserProfile.ts diff --git a/quotevote-frontend/src/__tests__/hooks/useSyncCurrentUserProfile.test.tsx b/quotevote-frontend/src/__tests__/hooks/useSyncCurrentUserProfile.test.tsx new file mode 100644 index 00000000..e8392486 --- /dev/null +++ b/quotevote-frontend/src/__tests__/hooks/useSyncCurrentUserProfile.test.tsx @@ -0,0 +1,124 @@ +/** + * @jest-environment jsdom + */ + +import { renderHook, waitFor } from '@testing-library/react' +import { MockedProvider } from '@apollo/client/testing/react' +import type { ReactNode } from 'react' +import { useSyncCurrentUserProfile } from '@/hooks/useSyncCurrentUserProfile' +import { GET_USER } from '@/graphql/queries' +import { useAppStore } from '@/store/useAppStore' +import { resetStore } from '@/__tests__/utils/test-utils' + +const qualities = { topType: 'LongHairStraight', hairColor: 'Brown' } + +function makeGetUserMock( + presence?: { + status: string + statusMessage: string + preferredStatus?: string + preferredStatusMessage?: string + } | null +) { + return { + request: { + query: GET_USER, + variables: { username: 'alice' }, + }, + result: { + data: { + user: { + _id: 'user-1', + name: 'Alice', + username: 'alice', + bio: 'About me', + upvotes: 0, + downvotes: 0, + _followingId: [], + _followersId: [], + avatar: qualities, + contributorBadge: false, + presence: + presence === undefined + ? { status: 'away', statusMessage: 'In a meeting', preferredStatus: 'away', preferredStatusMessage: 'In a meeting' } + : presence, + reputation: null, + }, + }, + }, + } +} + +describe('useSyncCurrentUserProfile', () => { + beforeEach(() => { + resetStore() + useAppStore.getState().setUserData({ + _id: 'user-1', + username: 'alice', + name: 'Alice', + // No avatar in store — mirrors login response before avatar was included + }) + }) + + it('copies avatar from GET_USER into the store for nav/account menu', async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + renderHook(() => useSyncCurrentUserProfile(), { wrapper }) + + await waitFor(() => { + expect(useAppStore.getState().user.data.avatar).toEqual(qualities) + }) + }) + + it('restores status and status message from GET_USER presence', async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + renderHook(() => useSyncCurrentUserProfile(), { wrapper }) + + await waitFor(() => { + expect(useAppStore.getState().chat.userStatus).toBe('away') + expect(useAppStore.getState().chat.userStatusMessage).toBe('In a meeting') + }) + }) + + it('maps offline presence to online when rehydrating own session', async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + renderHook(() => useSyncCurrentUserProfile(), { wrapper }) + + await waitFor(() => { + expect(useAppStore.getState().chat.userStatus).toBe('online') + expect(useAppStore.getState().chat.userStatusMessage).toBe('Back soon') + }) + }) + + it('restores preferredStatus when cleanup marked the user offline', async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + renderHook(() => useSyncCurrentUserProfile(), { wrapper }) + + await waitFor(() => { + expect(useAppStore.getState().chat.userStatus).toBe('dnd') + expect(useAppStore.getState().chat.userStatusMessage).toBe('Heads down') + }) + }) +}) diff --git a/quotevote-frontend/src/app/dashboard/layout.tsx b/quotevote-frontend/src/app/dashboard/layout.tsx index 8fbde3b3..f93ebb61 100644 --- a/quotevote-frontend/src/app/dashboard/layout.tsx +++ b/quotevote-frontend/src/app/dashboard/layout.tsx @@ -30,6 +30,7 @@ import { routeHasPersistentChatPanel } from '@/lib/utils/chatLayout'; import { usePresenceHeartbeat } from '@/hooks/usePresenceHeartbeat'; import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; import { useRosterManagement } from '@/hooks/useRosterManagement'; +import { useSyncCurrentUserProfile } from '@/hooks/useSyncCurrentUserProfile'; import ChatContent from '@/components/Chat/ChatContent'; import { GET_NOTIFICATIONS, GET_CHAT_ROOMS } from '@/graphql/queries'; import { DisplayAvatar } from '@/components/DisplayAvatar'; @@ -64,6 +65,7 @@ function DashboardClient() { usePresenceHeartbeat(); usePresenceSubscription(); useRosterManagement(); + useSyncCurrentUserProfile(); return null; } diff --git a/quotevote-frontend/src/app/dashboard/profile/[username]/avatar/page.tsx b/quotevote-frontend/src/app/dashboard/profile/[username]/avatar/page.tsx index 6fe7451b..71023a47 100644 --- a/quotevote-frontend/src/app/dashboard/profile/[username]/avatar/page.tsx +++ b/quotevote-frontend/src/app/dashboard/profile/[username]/avatar/page.tsx @@ -12,14 +12,15 @@ * redirects back to the user's profile page. */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { useMutation } from '@apollo/client/react'; import { toast } from 'sonner'; import { Dices, Save, ArrowLeft, Loader2 } from 'lucide-react'; import { useAppStore } from '@/store'; import { UPDATE_USER_AVATAR } from '@/graphql/mutations'; -import { buildAvatarUrl, type AvatarQualities } from '@/lib/avatar'; +import { GET_USER } from '@/graphql/queries'; +import { buildAvatarUrl, getDefaultAvatar, type AvatarQualities } from '@/lib/avatar'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { @@ -333,7 +334,7 @@ interface UpdateUserAvatarData { username: string; name: string; email: string; - avatar: string; + avatar: string | Record; }; } @@ -348,16 +349,30 @@ export default function AvatarEditorPage(): React.ReactNode { const updateStoreAvatar = useAppStore((state) => state.updateAvatar); const userId = (userData._id || userData.id) as string | undefined; - // Initialise avatar state from the store or random values - const initialAvatar = useMemo(() => { + // Deterministic default so SSR and the first client paint match. + // Persist-rehydrated store avatar is applied after mount. + const [avatar, setAvatar] = useState(() => + getDefaultAvatar('avatar-editor') + ); + const seededFromStore = useRef(false); + + useEffect(() => { + if (seededFromStore.current) return; + const stored = parseStoredAvatar( userData.avatar as string | Record | undefined ); - return stored ?? getRandomAvatar(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (stored) { + setAvatar(stored); + seededFromStore.current = true; + return; + } - const [avatar, setAvatar] = useState(initialAvatar); + // Store finished rehydrating (user id present) with no editable avatar — keep default. + if (userId) { + seededFromStore.current = true; + } + }, [userData.avatar, userId]); const [updateUserAvatar, { loading: saving }] = useMutation(UPDATE_USER_AVATAR); @@ -380,15 +395,25 @@ export default function AvatarEditorPage(): React.ReactNode { return; } + const username = typeof userData.username === 'string' ? userData.username : undefined; + try { const result = await updateUserAvatar({ variables: { user_id: userId, avatarQualities: avatar }, + refetchQueries: username + ? [{ query: GET_USER, variables: { username } }] + : [], + awaitRefetchQueries: Boolean(username), }); const returnedAvatar = result.data?.updateUserAvatar?.avatar; - if (returnedAvatar) { - updateStoreAvatar(returnedAvatar); - } + // Prefer the qualities we just saved so the store keeps a parseable object + // even if the API returns a serialized/odd shape. + updateStoreAvatar( + returnedAvatar && typeof returnedAvatar === 'object' + ? returnedAvatar + : avatar + ); toast.success('Avatar updated successfully!'); router.back(); @@ -397,7 +422,7 @@ export default function AvatarEditorPage(): React.ReactNode { err instanceof Error ? err.message : 'Failed to update avatar.'; toast.error(message); } - }, [userId, avatar, updateUserAvatar, updateStoreAvatar, router]); + }, [userId, avatar, updateUserAvatar, updateStoreAvatar, router, userData.username]); const handleBack = useCallback(() => { router.back(); diff --git a/quotevote-frontend/src/components/Avatar.tsx b/quotevote-frontend/src/components/Avatar.tsx index 409332e6..c6fa1649 100644 --- a/quotevote-frontend/src/components/Avatar.tsx +++ b/quotevote-frontend/src/components/Avatar.tsx @@ -5,24 +5,13 @@ import { useState, useMemo } from 'react'; import { User } from 'lucide-react'; import type { AvatarProps } from '@/types/components'; import { cn } from '@/lib/utils'; +import { parseAvatarToUrl } from '@/lib/avatar'; /** * Avatar Component - * - * A fully typed, reusable Avatar component that displays user profile images - * with fallback support for missing images. Supports initials or a default icon - * as fallback content. - * - * @param src - URL of the avatar image - * @param alt - Alt text for the image (required for accessibility) - * @param fallback - Fallback text (typically user initials) or React node - * @param size - Size variant: 'sm', 'md', 'lg', or a custom number in pixels - * @param className - Additional CSS classes - * @param onClick - Optional click handler - * - * @example - * - * + * + * Displays a user profile image with fallback support. Accepts URL strings or + * avataaars qualities objects (resolved via `parseAvatarToUrl`). */ export default function Avatar({ src, @@ -33,8 +22,10 @@ export default function Avatar({ onClick, ...props }: AvatarProps) { - const [imageError, setImageError] = useState(false); - const [imageLoaded, setImageLoaded] = useState(false); + const resolvedSrc = useMemo( + () => parseAvatarToUrl(src ?? undefined) ?? (typeof src === 'string' ? src : undefined), + [src] + ); // Calculate size classes and dimensions const sizeConfig = useMemo(() => { @@ -76,9 +67,6 @@ export default function Avatar({ return null; }, [alt, fallback]); - const showImage = !!src && typeof src === 'string' && !imageError; - const showFallback = !showImage; - const baseClasses = cn( 'relative inline-flex items-center justify-center', 'rounded-full overflow-hidden', @@ -117,52 +105,80 @@ export default function Avatar({ } {...props} > - {showImage && ( - {alt setImageError(true)} - onLoad={() => setImageLoaded(true)} - {...(src?.startsWith('data:') || src?.startsWith('blob:') || src?.includes('avataaars.io') - ? { unoptimized: true } - : {})} - /> - )} + {/* Keyed by src so load/error state resets when the avatar changes */} + + + ); +} + +function AvatarMedia({ + resolvedSrc, + alt, + dimension, + textSize, + fallbackContent, +}: { + resolvedSrc?: string; + alt?: string; + dimension: number; + textSize: string; + fallbackContent: string | React.ReactNode | null; +}) { + const [imageError, setImageError] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); - {showFallback && ( -
setImageError(true)} + onLoad={() => setImageLoaded(true)} + {...(resolvedSrc.startsWith('data:') || + resolvedSrc.startsWith('blob:') || + resolvedSrc.includes('avataaars.io') + ? { unoptimized: true } + : {})} + /> + ); + } + + return ( + + /> )}
); } - diff --git a/quotevote-frontend/src/components/BuddyList/BuddyItemList.tsx b/quotevote-frontend/src/components/BuddyList/BuddyItemList.tsx index c027c9d9..1e3466fe 100644 --- a/quotevote-frontend/src/components/BuddyList/BuddyItemList.tsx +++ b/quotevote-frontend/src/components/BuddyList/BuddyItemList.tsx @@ -101,7 +101,7 @@ export default function BuddyItemList({ buddyList, className }: BuddyItemListPro const staged: StagedChatRoom = { _id: null, title: item.user.name || item.user.username || 'Chat', - avatar: typeof item.user.avatar === 'string' ? item.user.avatar : null, + avatar: (item.user.avatar as string | Record | null | undefined) ?? null, messageType: 'USER', users: [currentUser._id!.toString(), item.user._id], username: item.user.username, diff --git a/quotevote-frontend/src/components/Chat/AddBuddyDialog.tsx b/quotevote-frontend/src/components/Chat/AddBuddyDialog.tsx index 2dcc9894..50b0cadc 100644 --- a/quotevote-frontend/src/components/Chat/AddBuddyDialog.tsx +++ b/quotevote-frontend/src/components/Chat/AddBuddyDialog.tsx @@ -101,7 +101,7 @@ const AddBuddyDialog = ({ open, onClose }: AddBuddyDialogProps) => { >
diff --git a/quotevote-frontend/src/components/Chat/MessageItemList.tsx b/quotevote-frontend/src/components/Chat/MessageItemList.tsx index 054d66cc..b8ab168f 100644 --- a/quotevote-frontend/src/components/Chat/MessageItemList.tsx +++ b/quotevote-frontend/src/components/Chat/MessageItemList.tsx @@ -140,7 +140,7 @@ export default function MessageItemList({ room }: MessageItemListProps) { id: postCreator._id, username: postCreator.username || '', name: postCreator.name || undefined, - avatar: typeof postCreator.avatar === 'string' ? postCreator.avatar : undefined, + avatar: postCreator.avatar as string | Record | undefined, contributorBadge: postCreator.contributorBadge || undefined, } : null diff --git a/quotevote-frontend/src/components/Chat/QuoteHeaderMessage.tsx b/quotevote-frontend/src/components/Chat/QuoteHeaderMessage.tsx index 9f3f7c7e..645460a3 100644 --- a/quotevote-frontend/src/components/Chat/QuoteHeaderMessage.tsx +++ b/quotevote-frontend/src/components/Chat/QuoteHeaderMessage.tsx @@ -39,7 +39,7 @@ const QuoteHeaderMessage: FC = ({
{postCreator && ( = ({ searchQuery }) => { className="flex items-center gap-3 rounded-xl border bg-card px-3 py-2.5 text-sm shadow-sm transition hover:bg-accent/40" > router.push(`/dashboard/profile/${username}`)} > - - - - {(name || username || '').slice(0, 2).toUpperCase()} - - + | undefined} + username={name || username} + size={32} + className="ring-1 ring-border/50" + /> {/* Content */} diff --git a/quotevote-frontend/src/components/Comment/CommentInput.tsx b/quotevote-frontend/src/components/Comment/CommentInput.tsx index 4148b8b4..62eda937 100644 --- a/quotevote-frontend/src/components/Comment/CommentInput.tsx +++ b/quotevote-frontend/src/components/Comment/CommentInput.tsx @@ -6,7 +6,7 @@ import { useMutation } from '@apollo/client/react' import { gql } from '@apollo/client' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { DisplayAvatar } from '@/components/DisplayAvatar' import { ADD_COMMENT } from '@/graphql/mutations' import { useAppStore } from '@/store/useAppStore' import { toast } from 'sonner' @@ -41,7 +41,6 @@ export default function CommentInput({ const userData = useAppStore((state) => state.user.data) const ensureAuth = useGuestGuard() - const avatarSrc = typeof userData.avatar === 'string' ? userData.avatar : undefined const displayName = (userData.name as string) || (userData.username as string) || '' const [addComment, { loading }] = useMutation(ADD_COMMENT, { @@ -109,12 +108,12 @@ export default function CommentInput({
{/* User avatar */}
- - - - {displayName.slice(0, 2).toUpperCase() || 'U'} - - + | undefined} + username={displayName} + size={32} + className="ring-1 ring-border/50" + />
{/* Input area */} diff --git a/quotevote-frontend/src/components/PostChat/PostChatSend.tsx b/quotevote-frontend/src/components/PostChat/PostChatSend.tsx index 3bd933f6..984ad485 100644 --- a/quotevote-frontend/src/components/PostChat/PostChatSend.tsx +++ b/quotevote-frontend/src/components/PostChat/PostChatSend.tsx @@ -78,7 +78,7 @@ export default function PostChatSend({ messageRoomId, title, postId }: PostChatS _id: ((user._id || user.id) as string) || '', name: (user.name as string) || '', username: (user.username as string) || '', - avatar: typeof user.avatar === 'string' ? user.avatar : '', + avatar: user.avatar as string | Record | undefined, }, }, }, diff --git a/quotevote-frontend/src/components/ui/ActivityCard.tsx b/quotevote-frontend/src/components/ui/ActivityCard.tsx index 4ebc4b13..2699e4e3 100644 --- a/quotevote-frontend/src/components/ui/ActivityCard.tsx +++ b/quotevote-frontend/src/components/ui/ActivityCard.tsx @@ -5,7 +5,7 @@ import moment from 'moment' import { isEmpty } from 'lodash' import { Bookmark, BookmarkCheck } from 'lucide-react' import { Card, CardContent, CardFooter } from '@/components/ui/card' -import Avatar from '@/components/Avatar' +import { DisplayAvatar } from '@/components/DisplayAvatar' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import type { ActivityCardProps } from '@/types/activity' @@ -62,7 +62,7 @@ function ActivityContent({ }: { date: string | number content: string - avatar?: string | { src?: string; alt?: string } + avatar?: string | Record | { src?: string; alt?: string } width?: 'lg' | 'md' | 'sm' | 'xl' | 'xs' | number handleRedirectToProfile?: (username: string) => void username: string @@ -73,24 +73,32 @@ function ActivityContent({ const contentLength = numericWidth > 500 ? 1000 : 500 const isPosted = activityType?.toUpperCase() === 'POSTED' const title = post?.title ? (isPosted ? post.title : post.title.substring(0, 100)) : '' - - const avatarSrc = typeof avatar === 'string' ? avatar : avatar?.src - const avatarAlt = typeof avatar === 'string' ? undefined : avatar?.alt || username + + // Legacy ActivityCard shape used `{ src, alt }`; API avatars are URLs or qualities objects. + const isLegacyAvatarShape = + typeof avatar === 'object' && + avatar !== null && + ('src' in avatar || 'alt' in avatar) && + !('topType' in avatar) && + !('url' in avatar) + const avatarValue = isLegacyAvatarShape + ? (avatar as { src?: string }).src + : (avatar as string | Record | undefined) return (
- { e.stopPropagation() if (handleRedirectToProfile) { handleRedirectToProfile(username) } }} - /> + > + +
| null + name?: string | null + bio?: string | null + email?: string | null + contributorBadge?: boolean | null + presence?: SyncedPresence | null +} + +const PRESENCE_STATUSES = new Set(['online', 'away', 'dnd', 'invisible', 'offline']) + +function resolveOwnStatus(presence: SyncedPresence): { status: string; statusMessage: string } { + const preferred = + typeof presence.preferredStatus === 'string' && PRESENCE_STATUSES.has(presence.preferredStatus) + ? presence.preferredStatus + : null + const raw = preferred ?? presence.status ?? 'online' + // You're actively loading the app — don't show yourself as offline. + const status = raw === 'offline' ? 'online' : raw + const fromPreferred = + typeof presence.preferredStatusMessage === 'string' ? presence.preferredStatusMessage : null + const fromCurrent = + typeof presence.statusMessage === 'string' ? presence.statusMessage : null + return { + status, + statusMessage: fromPreferred ?? fromCurrent ?? '', + } +} + +/** + * Keeps the persisted Zustand user (used by nav / account menu avatars) in sync + * with the latest profile from GraphQL. Login historically omitted `avatar`, so + * without this sync the nav shows a seeded default while the profile page is correct. + * + * Also restores presence (status + message) from the server — Zustand defaults to + * online/empty on hard reload even though Presence is saved in MongoDB. + */ +export function useSyncCurrentUserProfile(): void { + const username = useAppStore((state) => + typeof state.user.data.username === 'string' ? state.user.data.username : undefined + ) + const setUserData = useAppStore((state) => state.setUserData) + const setUserStatus = useAppStore((state) => state.setUserStatus) + + const { data } = useQuery<{ user: SyncedUserFields | null }>(GET_USER, { + variables: { username: username ?? '' }, + skip: !username, + fetchPolicy: 'cache-and-network', + }) + + useEffect(() => { + const fetched = data?.user + if (!fetched || !username) return + + const current = useAppStore.getState().user.data + const nextAvatar = fetched.avatar ?? undefined + const avatarChanged = + JSON.stringify(current.avatar ?? null) !== JSON.stringify(nextAvatar ?? null) + const nameChanged = + typeof fetched.name === 'string' && fetched.name.length > 0 && fetched.name !== current.name + const bioChanged = + typeof fetched.bio === 'string' && fetched.bio !== (current.bio as string | undefined) + const emailChanged = + typeof fetched.email === 'string' && + fetched.email.length > 0 && + fetched.email !== current.email + const badgeChanged = + typeof fetched.contributorBadge === 'boolean' && + fetched.contributorBadge !== current.contributorBadge + + if (avatarChanged || nameChanged || bioChanged || emailChanged || badgeChanged) { + setUserData({ + ...current, + ...(avatarChanged ? { avatar: nextAvatar ?? undefined } : {}), + ...(nameChanged && typeof fetched.name === 'string' ? { name: fetched.name } : {}), + ...(bioChanged && typeof fetched.bio === 'string' ? { bio: fetched.bio } : {}), + ...(emailChanged && typeof fetched.email === 'string' ? { email: fetched.email } : {}), + ...(badgeChanged && typeof fetched.contributorBadge === 'boolean' + ? { contributorBadge: fetched.contributorBadge } + : {}), + }) + } + + const presence = fetched.presence + if (!presence?.status && !presence?.preferredStatus) return + if (presence.status && !PRESENCE_STATUSES.has(presence.status) && !presence.preferredStatus) { + return + } + + const { status, statusMessage } = resolveOwnStatus(presence) + const chat = useAppStore.getState().chat + if (chat.userStatus === status && chat.userStatusMessage === statusMessage) return + + setUserStatus(status, statusMessage) + }, [data?.user, username, setUserData, setUserStatus]) +} diff --git a/quotevote-frontend/src/store/useAppStore.ts b/quotevote-frontend/src/store/useAppStore.ts index ac7d6622..b81e1c57 100644 --- a/quotevote-frontend/src/store/useAppStore.ts +++ b/quotevote-frontend/src/store/useAppStore.ts @@ -77,7 +77,7 @@ interface AppStore extends AppState { setUserLoading: (loading: boolean) => void; setLoginError: (error: string | null) => void; logout: () => void; - updateAvatar: (avatar: string) => void; + updateAvatar: (avatar: string | Record) => void; updateFollowing: (followingId: string[]) => void; // UI actions diff --git a/quotevote-frontend/src/types/activity.ts b/quotevote-frontend/src/types/activity.ts index f1b44947..56c24c87 100644 --- a/quotevote-frontend/src/types/activity.ts +++ b/quotevote-frontend/src/types/activity.ts @@ -14,7 +14,7 @@ export interface ActivityUser { _id: string name?: string username: string - avatar?: string + avatar?: string | Record contributorBadge?: boolean } @@ -164,7 +164,7 @@ export type ActivityEmptyListProps = Record // ActivityCard props export interface ActivityCardProps { - avatar?: string | { src?: string; alt?: string } + avatar?: string | Record | { src?: string; alt?: string } cardColor?: string name?: string username: string diff --git a/quotevote-frontend/src/types/buddylist.ts b/quotevote-frontend/src/types/buddylist.ts index 2566d03f..19580143 100644 --- a/quotevote-frontend/src/types/buddylist.ts +++ b/quotevote-frontend/src/types/buddylist.ts @@ -12,7 +12,7 @@ export interface BuddyUser { _id: string; name?: string; username?: string; - avatar?: string | null; + avatar?: string | Record | null; } export interface Buddy { @@ -31,7 +31,7 @@ export interface BuddyItem { Text?: string; messageType?: 'USER' | 'POST'; type?: 'USER' | 'POST'; - avatar?: string | { url: string } | null; + avatar?: string | Record | { url: string } | null; unreadMessages?: number; presence?: Presence; statusMessage?: string; diff --git a/quotevote-frontend/src/types/chat.ts b/quotevote-frontend/src/types/chat.ts index 7788aac5..1badb8d0 100644 --- a/quotevote-frontend/src/types/chat.ts +++ b/quotevote-frontend/src/types/chat.ts @@ -79,8 +79,8 @@ export interface ChatRoom { messageType?: string | null /** Room title (for groups or resolved DM display) */ title?: string | null - /** Optional avatar URL resolved by backend */ - avatar?: string | null + /** Optional avatar URL or avataaars qualities resolved by backend */ + avatar?: string | Record | null /** ISO timestamp when room was created */ created: string /** ISO timestamp of last message */ @@ -185,7 +185,7 @@ export interface TypingUser { export interface StagedChatRoom { _id: null; title: string; - avatar: string | null; + avatar: string | Record | null; messageType: 'USER'; users: string[]; username?: string; diff --git a/quotevote-frontend/src/types/components.ts b/quotevote-frontend/src/types/components.ts index b883a562..ec7902b3 100644 --- a/quotevote-frontend/src/types/components.ts +++ b/quotevote-frontend/src/types/components.ts @@ -14,9 +14,10 @@ export interface LoadingSpinnerProps { export interface AvatarProps extends React.HTMLAttributes { /** - * URL of the avatar image + * Avatar image URL, avataaars qualities object, or JSON-encoded qualities. + * Objects are resolved via `parseAvatarToUrl`. */ - src?: string; + src?: string | Record | null; /** * Alt text for the image (required for accessibility) */ @@ -521,7 +522,7 @@ export interface ProfileHeaderProps { username: string; _followingId?: string[]; _followersId?: string[]; - avatar?: string; + avatar?: string | Record; contributorBadge?: boolean; [key: string]: unknown; }; @@ -937,7 +938,7 @@ export interface SearchCreatorResult { _id: string; name: string; username?: string; - avatar?: string; + avatar?: string | Record; __typename?: string; } @@ -966,7 +967,7 @@ export interface UsernameSearchUser { _id: string; username: string; name: string; - avatar?: string; + avatar?: string | Record; contributorBadge?: boolean; } diff --git a/quotevote-frontend/src/types/postChat.ts b/quotevote-frontend/src/types/postChat.ts index fb9d2664..526f55f1 100644 --- a/quotevote-frontend/src/types/postChat.ts +++ b/quotevote-frontend/src/types/postChat.ts @@ -127,7 +127,7 @@ export interface CreateMessageData { _id: string name: string username: string - avatar: string + avatar?: string | Record } } } From bb8406967f73b00bb75d2935310e4e1180b6873a Mon Sep 17 00:00:00 2001 From: Neo Isaac Amao Date: Thu, 23 Jul 2026 17:36:52 +0800 Subject: [PATCH 2/6] feat(presence): persist preferred status across reload and cleanup Keep preferredStatus/message through stale offline cleanup, restore on heartbeat, expose updatePresence, and rehydrate the client from GET_USER and heartbeat responses. Co-authored-by: Cursor --- .../unit/resolvers/heartbeatResolver.test.ts | 166 ++++++++++++++++++ quotevote-backend/app/data/models/Presence.ts | 100 ++++++++--- .../app/data/resolvers/heartbeatResolver.ts | 109 +++++++++++- quotevote-backend/app/data/types/Presence.ts | 6 + .../utils/presence/cleanupStalePresence.ts | 10 +- quotevote-backend/app/server.ts | 3 + quotevote-backend/app/types/common.ts | 3 + quotevote-frontend/src/graphql/mutations.ts | 2 + quotevote-frontend/src/graphql/queries.ts | 2 + .../src/hooks/usePresenceHeartbeat.ts | 135 ++++++++------ 10 files changed, 446 insertions(+), 90 deletions(-) create mode 100644 quotevote-backend/__tests__/unit/resolvers/heartbeatResolver.test.ts diff --git a/quotevote-backend/__tests__/unit/resolvers/heartbeatResolver.test.ts b/quotevote-backend/__tests__/unit/resolvers/heartbeatResolver.test.ts new file mode 100644 index 00000000..14467a31 --- /dev/null +++ b/quotevote-backend/__tests__/unit/resolvers/heartbeatResolver.test.ts @@ -0,0 +1,166 @@ +import { GraphQLError } from 'graphql'; +import { heartbeatResolver } from '~/data/resolvers/heartbeatResolver'; +import Presence from '~/data/models/Presence'; +import { pubsub } from '~/data/utils/pubsub'; +import { SUBSCRIPTION_EVENTS } from '~/types/graphql'; +import type { GraphQLContext } from '~/types/graphql'; + +jest.mock('~/data/models/Presence'); +jest.mock('~/data/utils/pubsub', () => ({ + pubsub: { + publish: jest.fn().mockResolvedValue(undefined), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + asyncIterableIterator: jest.fn(), + }, +})); + +const actorId = '60d5ec49ad414d7a8d5464a0'; + +function mockContext(overrides: Partial> = {}): GraphQLContext { + return { + req: {} as GraphQLContext['req'], + res: {} as GraphQLContext['res'], + pubsub: {} as GraphQLContext['pubsub'], + user: { + _id: actorId, + username: 'alice', + email: 'alice@example.com', + admin: false, + ...overrides, + } as NonNullable, + }; +} + +describe('heartbeatResolver', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Mutation.heartbeat', () => { + it('requires authentication', async () => { + await expect( + heartbeatResolver.Mutation.heartbeat(null, {}, { ...mockContext(), user: null }) + ).rejects.toThrow(GraphQLError); + }); + + it('updates heartbeat and returns success', async () => { + (Presence.updateHeartbeat as jest.Mock).mockResolvedValue({ + lastHeartbeat: new Date('2024-01-15T12:00:00.000Z'), + status: 'away', + statusMessage: 'In a meeting', + }); + + const result = await heartbeatResolver.Mutation.heartbeat(null, {}, mockContext()); + + expect(Presence.updateHeartbeat).toHaveBeenCalledWith(actorId); + expect(result).toEqual({ + success: true, + timestamp: '2024-01-15T12:00:00.000Z', + status: 'away', + statusMessage: 'In a meeting', + }); + }); + }); + + describe('Mutation.updatePresence', () => { + it('requires authentication', async () => { + await expect( + heartbeatResolver.Mutation.updatePresence( + null, + { presence: { status: 'away', statusMessage: 'BRB' } }, + { ...mockContext(), user: null } + ) + ).rejects.toThrow(GraphQLError); + }); + + it('rejects invalid status', async () => { + await expect( + heartbeatResolver.Mutation.updatePresence( + null, + { presence: { status: 'busy' } }, + mockContext() + ) + ).rejects.toThrow(/status must be one of/); + expect(Presence.findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('upserts presence and publishes update', async () => { + const now = new Date('2024-01-15T12:00:00.000Z'); + jest.useFakeTimers().setSystemTime(now); + + (Presence.findOneAndUpdate as jest.Mock).mockResolvedValue({ + _id: { toString: () => 'presence-1' }, + userId: { toString: () => actorId }, + status: 'away', + statusMessage: 'In a meeting', + preferredStatus: 'away', + preferredStatusMessage: 'In a meeting', + lastHeartbeat: now, + lastSeen: now, + }); + + const result = await heartbeatResolver.Mutation.updatePresence( + null, + { presence: { status: 'away', statusMessage: ' In a meeting ' } }, + mockContext() + ); + + expect(Presence.findOneAndUpdate).toHaveBeenCalledWith( + { userId: actorId }, + { + $set: { + status: 'away', + statusMessage: 'In a meeting', + preferredStatus: 'away', + preferredStatusMessage: 'In a meeting', + lastHeartbeat: now, + lastSeen: now, + }, + }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + expect(pubsub.publish).toHaveBeenCalledWith(SUBSCRIPTION_EVENTS.PRESENCE_UPDATED, { + presence: { + userId: actorId, + status: 'away', + statusMessage: 'In a meeting', + lastSeen: now, + }, + }); + expect(result).toEqual({ + _id: 'presence-1', + userId: actorId, + status: 'away', + statusMessage: 'In a meeting', + lastHeartbeat: now, + lastSeen: now, + }); + + jest.useRealTimers(); + }); + + it('truncates status messages over 200 characters', async () => { + const longMessage = 'x'.repeat(250); + (Presence.findOneAndUpdate as jest.Mock).mockResolvedValue({ + _id: { toString: () => 'presence-1' }, + userId: { toString: () => actorId }, + status: 'online', + statusMessage: 'x'.repeat(200), + lastHeartbeat: new Date(), + lastSeen: new Date(), + }); + + await heartbeatResolver.Mutation.updatePresence( + null, + { presence: { status: 'online', statusMessage: longMessage } }, + mockContext() + ); + + const updateArg = (Presence.findOneAndUpdate as jest.Mock).mock.calls[0][1] as { + $set: { statusMessage: string }; + }; + expect(updateArg.$set.statusMessage).toHaveLength(200); + }); + }); +}); diff --git a/quotevote-backend/app/data/models/Presence.ts b/quotevote-backend/app/data/models/Presence.ts index 46777604..ac9b12a5 100644 --- a/quotevote-backend/app/data/models/Presence.ts +++ b/quotevote-backend/app/data/models/Presence.ts @@ -1,47 +1,91 @@ import mongoose, { Schema } from 'mongoose'; import type { PresenceDocument, PresenceModel } from '../../types/mongoose'; +import type { PresenceStatus } from '../../types/common'; + +const STATUS_ENUM = ['online', 'away', 'dnd', 'offline', 'invisible'] as const; const PresenceSchema = new Schema( - { - userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, unique: true }, - status: { - type: String, - enum: ['online', 'away', 'dnd', 'offline', 'invisible'], - default: 'offline' - }, - statusMessage: { type: String }, - lastHeartbeat: { type: Date, default: Date.now }, - lastSeen: { type: Date, default: Date.now }, + { + userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, unique: true }, + status: { + type: String, + enum: STATUS_ENUM, + default: 'offline', }, - { - timestamps: true, - toJSON: { virtuals: true }, - toObject: { virtuals: true }, - } + statusMessage: { type: String }, + // Survives stale cleanup (which sets status to offline) so refresh can restore + // the user's chosen status + message. + preferredStatus: { + type: String, + enum: STATUS_ENUM, + }, + preferredStatusMessage: { type: String }, + lastHeartbeat: { type: Date, default: Date.now }, + lastSeen: { type: Date, default: Date.now }, + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + } ); -// Indexes PresenceSchema.index({ status: 1 }); PresenceSchema.index({ lastHeartbeat: 1 }); -// Static method: findByUserId PresenceSchema.statics.findByUserId = function (userId: string) { - return this.findOne({ userId }); + return this.findOne({ userId }); }; -// Static method: updateHeartbeat -PresenceSchema.statics.updateHeartbeat = async function (userId: string) { +/** + * Refresh liveness only. Do not overwrite a user-chosen status/message. + * If stale cleanup marked the user offline, restore their preferred status. + */ +async function updateHeartbeatImpl( + this: PresenceModel, + userId: string +): Promise { + const now = new Date(); + const existing = await this.findOne({ userId }); + + if (!existing) { return this.findOneAndUpdate( - { userId }, - { - lastHeartbeat: new Date(), - status: 'online' + { userId }, + { + $set: { lastHeartbeat: now, lastSeen: now }, + $setOnInsert: { + status: 'online', + preferredStatus: 'online', + preferredStatusMessage: '', }, - { upsert: true, new: true, setDefaultsOnInsert: true } + }, + { upsert: true, new: true, setDefaultsOnInsert: true } ); -}; + } + + existing.lastHeartbeat = now; + existing.lastSeen = now; + + if (existing.status === 'offline') { + const preferred = existing.preferredStatus; + const restore = + preferred && preferred !== 'offline' ? (preferred as PresenceStatus) : 'online'; + existing.status = restore; + if (typeof existing.preferredStatusMessage === 'string') { + existing.statusMessage = existing.preferredStatusMessage; + } + } + + return existing.save(); +} + +PresenceSchema.statics.updateHeartbeat = updateHeartbeatImpl; + +// Always bind the latest statics — mongoose.models.Presence may already exist after hot reload. +const Presence = + (mongoose.models.Presence as PresenceModel) || + mongoose.model('Presence', PresenceSchema); -// Check if model already exists -const Presence = (mongoose.models.Presence as PresenceModel) || mongoose.model('Presence', PresenceSchema); +Presence.updateHeartbeat = updateHeartbeatImpl.bind(Presence); export default Presence; diff --git a/quotevote-backend/app/data/resolvers/heartbeatResolver.ts b/quotevote-backend/app/data/resolvers/heartbeatResolver.ts index 2223a849..09d914c9 100644 --- a/quotevote-backend/app/data/resolvers/heartbeatResolver.ts +++ b/quotevote-backend/app/data/resolvers/heartbeatResolver.ts @@ -1,14 +1,57 @@ import { GraphQLError } from 'graphql'; import Presence from '../models/Presence'; +import { pubsub } from '../utils/pubsub'; +import { SUBSCRIPTION_EVENTS } from '../../types/graphql'; import type { GraphQLContext } from '~/types/graphql'; +import type * as Common from '~/types/common'; + +const ALLOWED_STATUSES: ReadonlySet = new Set([ + 'online', + 'away', + 'dnd', + 'offline', + 'invisible', +]); + +const STATUS_MESSAGE_MAX_LENGTH = 200; + +type UpdatePresenceArgs = { + presence: { + status: string; + statusMessage?: string | null; + }; +}; + +function toPublicPresence(doc: { + _id: { toString(): string }; + userId: { toString(): string }; + status: Common.PresenceStatus; + statusMessage?: string | null; + lastHeartbeat?: Date | string | number; + lastSeen?: Date | string | number | null; +}): Common.Presence { + return { + _id: doc._id.toString(), + userId: doc.userId.toString(), + status: doc.status, + statusMessage: doc.statusMessage ?? undefined, + lastHeartbeat: doc.lastHeartbeat, + lastSeen: doc.lastSeen ?? undefined, + }; +} export const heartbeatResolver = { Mutation: { heartbeat: async ( _parent: unknown, _args: unknown, - context: GraphQLContext, - ): Promise<{ success: boolean; timestamp: string }> => { + context: GraphQLContext + ): Promise<{ + success: boolean; + timestamp: string; + status: string; + statusMessage: string; + }> => { const { user } = context; if (!user) { throw new GraphQLError('Authentication required', { @@ -21,7 +64,69 @@ export const heartbeatResolver = { return { success: true, timestamp: new Date(presence.lastHeartbeat).toISOString(), + status: presence.status, + statusMessage: presence.statusMessage ?? '', }; }, + + updatePresence: async ( + _parent: unknown, + args: UpdatePresenceArgs, + context: GraphQLContext + ): Promise => { + if (!context.user?._id) { + throw new GraphQLError('Authentication required', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } + + const status = args.presence?.status?.trim(); + if (!status || !ALLOWED_STATUSES.has(status as Common.PresenceStatus)) { + throw new GraphQLError( + 'status must be one of: online, away, dnd, offline, invisible', + { extensions: { code: 'BAD_USER_INPUT' } } + ); + } + + const rawMessage = args.presence.statusMessage ?? ''; + const statusMessage = + typeof rawMessage === 'string' ? rawMessage.trim().slice(0, STATUS_MESSAGE_MAX_LENGTH) : ''; + + const userId = context.user._id.toString(); + const now = new Date(); + + const preferredStatus = status === 'offline' ? 'online' : status; + const updated = await Presence.findOneAndUpdate( + { userId }, + { + $set: { + status, + statusMessage, + preferredStatus, + preferredStatusMessage: statusMessage, + lastHeartbeat: now, + lastSeen: now, + }, + }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + if (!updated) { + throw new GraphQLError('Failed to update presence', { + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }); + } + + await pubsub.publish(SUBSCRIPTION_EVENTS.PRESENCE_UPDATED, { + presence: { + userId, + status: updated.status, + statusMessage: updated.statusMessage ?? '', + lastSeen: updated.lastSeen, + }, + }); + + return toPublicPresence(updated); + }, }, }; diff --git a/quotevote-backend/app/data/types/Presence.ts b/quotevote-backend/app/data/types/Presence.ts index 6a860885..ad1fea95 100644 --- a/quotevote-backend/app/data/types/Presence.ts +++ b/quotevote-backend/app/data/types/Presence.ts @@ -28,6 +28,8 @@ export const PresenceType: GraphQLObjectType = ne userId: { type: new GraphQLNonNull(GraphQLString) }, status: { type: new GraphQLNonNull(PresenceStatusEnum) }, statusMessage: { type: GraphQLString }, + preferredStatus: { type: PresenceStatusEnum }, + preferredStatusMessage: { type: GraphQLString }, lastHeartbeat: { type: new GraphQLNonNull(DateScalar) }, lastSeen: { type: DateScalar }, user: { @@ -59,6 +61,8 @@ export const PresenceUpdateType: GraphQLObjectType = @@ -68,6 +72,8 @@ export const HeartbeatResponseType: GraphQLObjectType => ({ success: { type: new GraphQLNonNull(GraphQLBoolean) }, timestamp: { type: new GraphQLNonNull(DateScalar) }, + status: { type: PresenceStatusEnum }, + statusMessage: { type: GraphQLString }, }), }); diff --git a/quotevote-backend/app/data/utils/presence/cleanupStalePresence.ts b/quotevote-backend/app/data/utils/presence/cleanupStalePresence.ts index 1a97d71c..8f508079 100644 --- a/quotevote-backend/app/data/utils/presence/cleanupStalePresence.ts +++ b/quotevote-backend/app/data/utils/presence/cleanupStalePresence.ts @@ -19,12 +19,18 @@ export const cleanupStalePresence = async (): Promise => { }); for (const presence of stalePresences) { - // Update to offline + // Mark offline for peers, but keep preferredStatus / preferredStatusMessage + // (and statusMessage) so a refresh can restore the user's chosen status. + if (!presence.preferredStatus) { + presence.preferredStatus = presence.status; + } + if (presence.preferredStatusMessage === undefined) { + presence.preferredStatusMessage = presence.statusMessage ?? ''; + } presence.status = 'offline'; presence.lastSeen = new Date(); await presence.save(); - // Publish offline event await pubsub.publish(SUBSCRIPTION_EVENTS.PRESENCE_UPDATED, { presence: { userId: presence.userId.toString(), diff --git a/quotevote-backend/app/server.ts b/quotevote-backend/app/server.ts index 24b8e626..085fb622 100644 --- a/quotevote-backend/app/server.ts +++ b/quotevote-backend/app/server.ts @@ -13,6 +13,7 @@ import { groupResolver } from './data/resolvers/groupResolver'; import { chatResolver } from './data/resolvers/chatResolver'; import { rosterResolver } from './data/resolvers/rosterResolver'; import { quoteResolver } from './data/resolvers/quoteResolver'; +import { heartbeatResolver } from './data/resolvers/heartbeatResolver'; import { domainTypeDefs } from './data/types'; import type { GraphQLContext, PubSub } from './types/graphql'; import { requireAuth } from './data/utils/requireAuth'; @@ -130,6 +131,7 @@ async function startServer() { solidPushPortableState(input: PortableStateInput!): Boolean solidAppendActivityEvent(input: ActivityEventInput!): Boolean heartbeat: HeartbeatResponse + updatePresence(presence: PresenceInput!): Presence updateUser(user: UserInput!): User updateUserAvatar(user_id: String!, avatarQualities: JSON): User } @@ -179,6 +181,7 @@ async function startServer() { chatResolver, rosterResolver, quoteResolver, + heartbeatResolver, ], }); diff --git a/quotevote-backend/app/types/common.ts b/quotevote-backend/app/types/common.ts index 734b80fb..9a131243 100644 --- a/quotevote-backend/app/types/common.ts +++ b/quotevote-backend/app/types/common.ts @@ -327,6 +327,9 @@ export interface Presence { userId: string; status: PresenceStatus; statusMessage?: string; + /** Chosen status that survives stale cleanup marking the user offline. */ + preferredStatus?: PresenceStatus; + preferredStatusMessage?: string; lastHeartbeat?: Date | string | number; lastSeen?: Date | string | number; } diff --git a/quotevote-frontend/src/graphql/mutations.ts b/quotevote-frontend/src/graphql/mutations.ts index 8441b937..902704f8 100644 --- a/quotevote-frontend/src/graphql/mutations.ts +++ b/quotevote-frontend/src/graphql/mutations.ts @@ -8,6 +8,8 @@ export const HEARTBEAT = gql` heartbeat { success timestamp + status + statusMessage } } ` diff --git a/quotevote-frontend/src/graphql/queries.ts b/quotevote-frontend/src/graphql/queries.ts index 146dd25e..66ba7654 100644 --- a/quotevote-frontend/src/graphql/queries.ts +++ b/quotevote-frontend/src/graphql/queries.ts @@ -544,6 +544,8 @@ export const GET_USER = gql` presence { status statusMessage + preferredStatus + preferredStatusMessage } reputation { _id diff --git a/quotevote-frontend/src/hooks/usePresenceHeartbeat.ts b/quotevote-frontend/src/hooks/usePresenceHeartbeat.ts index 4dd16bac..1611fee6 100644 --- a/quotevote-frontend/src/hooks/usePresenceHeartbeat.ts +++ b/quotevote-frontend/src/hooks/usePresenceHeartbeat.ts @@ -4,83 +4,102 @@ import { useEffect, useRef } from 'react' import { useMutation } from '@apollo/client/react' import { HEARTBEAT } from '@/graphql/mutations' import { isAuthenticated } from '@/lib/utils/auth' +import { useAppStore } from '@/store/useAppStore' import type { UsePresenceHeartbeatReturn } from '@/types/hooks' +type HeartbeatResult = { + heartbeat?: { + success: boolean + timestamp?: string + status?: string | null + statusMessage?: string | null + } | null +} + +const PRESENCE_STATUSES = new Set(['online', 'away', 'dnd', 'invisible']) + /** * Custom hook to send periodic heartbeat to keep presence alive * @param interval - Heartbeat interval in milliseconds (default: 45000 = 45 seconds) */ export const usePresenceHeartbeat = (interval: number = 45000): UsePresenceHeartbeatReturn => { - const [heartbeat, { error }] = useMutation(HEARTBEAT) - const retryCountRef = useRef(0) - const maxRetries = 3 - const backoffMultiplier = 2 - - useEffect(() => { - if (!isAuthenticated()) { - return - } + const [heartbeat, { error }] = useMutation(HEARTBEAT) + const setUserStatus = useAppStore((state) => state.setUserStatus) + const retryCountRef = useRef(0) + const maxRetries = 3 + const backoffMultiplier = 2 - const getRetryDelay = (attempt: number): number => { - return Math.min(interval * Math.pow(backoffMultiplier, attempt), 300000) - } + useEffect(() => { + if (!isAuthenticated()) { + return + } - const sendHeartbeat = async (): Promise => { - try { - await heartbeat() - retryCountRef.current = 0 - } catch { - if (retryCountRef.current < maxRetries) { - retryCountRef.current += 1 - const delay = getRetryDelay(retryCountRef.current) - setTimeout(() => { - sendHeartbeat() - }, delay) - } else { - setTimeout(() => { - retryCountRef.current = 0 - }, interval * 5) - } - } - } + const getRetryDelay = (attempt: number): number => { + return Math.min(interval * Math.pow(backoffMultiplier, attempt), 300000) + } - let timer: ReturnType | null = null + const applyPresenceFromHeartbeat = (payload: HeartbeatResult['heartbeat']): void => { + if (!payload?.status || !PRESENCE_STATUSES.has(payload.status)) return + const statusMessage = typeof payload.statusMessage === 'string' ? payload.statusMessage : '' + const chat = useAppStore.getState().chat + if (chat.userStatus === payload.status && chat.userStatusMessage === statusMessage) return + setUserStatus(payload.status, statusMessage) + } - const startHeartbeat = () => { - sendHeartbeat() - timer = setInterval(() => { - sendHeartbeat() - }, interval) + const sendHeartbeat = async (): Promise => { + try { + const result = await heartbeat() + retryCountRef.current = 0 + applyPresenceFromHeartbeat(result.data?.heartbeat) + } catch { + if (retryCountRef.current < maxRetries) { + retryCountRef.current += 1 + const delay = getRetryDelay(retryCountRef.current) + setTimeout(() => { + void sendHeartbeat() + }, delay) + } else { + setTimeout(() => { + retryCountRef.current = 0 + }, interval * 5) } + } + } - const stopHeartbeat = () => { - if (timer) { - clearInterval(timer) - timer = null - } - } + let timer: ReturnType | null = null - const handleVisibilityChange = () => { - if (document.hidden) { - stopHeartbeat() - } else { - startHeartbeat() - } - } + const startHeartbeat = () => { + void sendHeartbeat() + timer = setInterval(() => { + void sendHeartbeat() + }, interval) + } - // Start heartbeat immediately + const stopHeartbeat = () => { + if (timer) { + clearInterval(timer) + timer = null + } + } + + const handleVisibilityChange = () => { + if (document.hidden) { + stopHeartbeat() + } else { startHeartbeat() + } + } - // Listen for visibility changes to pause/resume - document.addEventListener('visibilitychange', handleVisibilityChange) + startHeartbeat() + document.addEventListener('visibilitychange', handleVisibilityChange) - return () => { - stopHeartbeat() - document.removeEventListener('visibilitychange', handleVisibilityChange) - } - }, [heartbeat, interval]) + return () => { + stopHeartbeat() + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [heartbeat, interval, setUserStatus]) - return { error } + return { error } } export default usePresenceHeartbeat From 9ce0cdf0a1c7119bd23a7facc0503221c1d3a114 Mon Sep 17 00:00:00 2001 From: Neo Isaac Amao Date: Thu, 23 Jul 2026 17:37:13 +0800 Subject: [PATCH 3/6] feat(api): wire notifications/activities and harden guest GraphQL auth Register notification and activity resolvers, and improve guest-session handling in Apollo/auth utilities so unauthenticated flows fail cleanly. Co-authored-by: Cursor --- .../unit/resolvers/activityResolver.test.ts | 136 ++++++++++++++++++ .../resolvers/notificationResolver.test.ts | 54 +++++++ .../app/data/resolvers/activityResolver.ts | 121 ++++++++++++++++ .../data/resolvers/notificationResolver.ts | 40 ++++++ quotevote-backend/app/server.ts | 18 +++ .../src/__tests__/utils/auth.test.ts | 39 +++-- .../src/__tests__/utils/getServerUrl.test.ts | 12 +- quotevote-frontend/src/hooks/useGuestGuard.ts | 25 ++-- .../src/lib/apollo/apollo-client.ts | 71 ++++++--- quotevote-frontend/src/lib/utils/auth.ts | 11 +- .../src/lib/utils/getServerUrl.ts | 12 +- 11 files changed, 495 insertions(+), 44 deletions(-) create mode 100644 quotevote-backend/__tests__/unit/resolvers/activityResolver.test.ts create mode 100644 quotevote-backend/__tests__/unit/resolvers/notificationResolver.test.ts create mode 100644 quotevote-backend/app/data/resolvers/activityResolver.ts create mode 100644 quotevote-backend/app/data/resolvers/notificationResolver.ts diff --git a/quotevote-backend/__tests__/unit/resolvers/activityResolver.test.ts b/quotevote-backend/__tests__/unit/resolvers/activityResolver.test.ts new file mode 100644 index 00000000..c47ea645 --- /dev/null +++ b/quotevote-backend/__tests__/unit/resolvers/activityResolver.test.ts @@ -0,0 +1,136 @@ +import mongoose from 'mongoose'; +import { GraphQLError } from 'graphql'; +import { activityResolver } from '~/data/resolvers/activityResolver'; +import Activity from '~/data/models/Activity'; +import User from '~/data/models/User'; +import type { GraphQLContext } from '~/types/graphql'; + +jest.mock('~/data/models/Activity'); +jest.mock('~/data/models/User'); + +const actorId = '60d5ec49ad414d7a8d5464a0'; +const profileId = '60d5ec49ad414d7a8d5464a1'; + +function mockContext(overrides: Partial> = {}): GraphQLContext { + return { + req: {} as GraphQLContext['req'], + res: {} as GraphQLContext['res'], + pubsub: {} as GraphQLContext['pubsub'], + user: { + _id: actorId, + username: 'alice', + email: 'alice@example.com', + admin: false, + ...overrides, + } as NonNullable, + }; +} + +describe('activityResolver', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Query.activities', () => { + it('requires authentication', async () => { + await expect( + activityResolver.Query.activities( + null, + { + user_id: profileId, + limit: 10, + offset: 0, + searchKey: '', + activityEvent: ['VOTED'], + }, + { ...mockContext(), user: null } + ) + ).rejects.toThrow(GraphQLError); + }); + + it('returns paginated activities for a user', async () => { + const activityId = new mongoose.Types.ObjectId(); + (Activity.countDocuments as jest.Mock).mockResolvedValue(1); + (Activity.find as jest.Mock).mockReturnValue({ + sort: jest.fn().mockReturnValue({ + skip: jest.fn().mockReturnValue({ + limit: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { + _id: activityId, + userId: new mongoose.Types.ObjectId(profileId), + postId: new mongoose.Types.ObjectId(), + activityType: 'VOTED', + content: 'voted', + created: new Date('2024-01-01T00:00:00Z'), + }, + ]), + }), + }), + }), + }); + + const result = await activityResolver.Query.activities( + null, + { + user_id: profileId, + limit: 15, + offset: 0, + searchKey: '', + activityEvent: ['VOTED'], + }, + mockContext() + ); + + expect(Activity.countDocuments).toHaveBeenCalledWith( + expect.objectContaining({ + userId: profileId, + activityType: { $in: ['VOTED'] }, + }) + ); + expect(result.pagination).toEqual({ total_count: 1, limit: 15, offset: 0 }); + expect(result.entities).toHaveLength(1); + expect(result.entities[0].activityType).toBe('VOTED'); + expect(result.entities[0]._id).toBe(activityId.toString()); + }); + + it('falls back to following feed when user_id is omitted', async () => { + const followingId = '60d5ec49ad414d7a8d5464a2'; + (User.findById as jest.Mock).mockReturnValue({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _followingId: [new mongoose.Types.ObjectId(followingId)], + }), + }), + }); + (Activity.countDocuments as jest.Mock).mockResolvedValue(0); + (Activity.find as jest.Mock).mockReturnValue({ + sort: jest.fn().mockReturnValue({ + skip: jest.fn().mockReturnValue({ + limit: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([]), + }), + }), + }), + }); + + await activityResolver.Query.activities( + null, + { + user_id: '', + limit: 10, + offset: 0, + searchKey: '', + activityEvent: [], + }, + mockContext() + ); + + expect(Activity.countDocuments).toHaveBeenCalledWith( + expect.objectContaining({ + userId: { $in: [followingId] }, + }) + ); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/resolvers/notificationResolver.test.ts b/quotevote-backend/__tests__/unit/resolvers/notificationResolver.test.ts new file mode 100644 index 00000000..06bdcc7f --- /dev/null +++ b/quotevote-backend/__tests__/unit/resolvers/notificationResolver.test.ts @@ -0,0 +1,54 @@ +import Notification from '~/data/models/Notification'; +import { notificationResolver } from '~/data/resolvers/notificationResolver'; +import type { GraphQLContext } from '~/types/graphql'; + +jest.mock('~/data/models/Notification'); + +const userId = '60d5ec49ad414d7a8d5464a0'; + +function mockContext(user: GraphQLContext['user'] = null): GraphQLContext { + return { + req: {} as GraphQLContext['req'], + res: {} as GraphQLContext['res'], + pubsub: {} as GraphQLContext['pubsub'], + user, + }; +} + +describe('notificationResolver', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Query.notifications', () => { + it('requires authentication', async () => { + await expect( + notificationResolver.Query.notifications(null, {}, mockContext(null)) + ).rejects.toThrow(/Authentication required/); + }); + + it('returns an empty list when the user has no notifications', async () => { + (Notification.find as jest.Mock).mockReturnValue({ + sort: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([]), + }), + }); + + const result = await notificationResolver.Query.notifications( + null, + {}, + mockContext({ + _id: userId, + username: 'alice', + email: 'alice@example.com', + } as NonNullable) + ); + + expect(Notification.find).toHaveBeenCalledWith({ + userId, + status: 'new', + }); + expect(result).toEqual([]); + }); + }); +}); diff --git a/quotevote-backend/app/data/resolvers/activityResolver.ts b/quotevote-backend/app/data/resolvers/activityResolver.ts new file mode 100644 index 00000000..74e4c485 --- /dev/null +++ b/quotevote-backend/app/data/resolvers/activityResolver.ts @@ -0,0 +1,121 @@ +import { GraphQLError } from 'graphql'; +import Activity from '../models/Activity'; +import User from '../models/User'; +import type * as Common from '~/types/common'; +import type { ActivityQueryArgs, GraphQLContext } from '~/types/graphql'; + +type ActivityFilter = Record; + +function normalizeActivityEvents( + activityEvent: ActivityQueryArgs['activityEvent'] | string | null | undefined +): Common.ActivityEventType[] { + if (activityEvent == null) return []; + + let parsed: unknown = activityEvent; + if (typeof activityEvent === 'string') { + try { + parsed = JSON.parse(activityEvent); + } catch { + return []; + } + } + + if (!Array.isArray(parsed)) return []; + return parsed.filter((v): v is Common.ActivityEventType => typeof v === 'string'); +} + +function toActivityEntity(doc: { + _id: { toString(): string }; + userId?: { toString(): string } | string | null; + postId?: { toString(): string } | string | null; + voteId?: { toString(): string } | string | null; + commentId?: { toString(): string } | string | null; + quoteId?: { toString(): string } | string | null; + activityType: string; + content?: string | null; + created?: Date | string | null; +}): Common.Activity { + const toId = (value: { toString(): string } | string | null | undefined): string | undefined => { + if (value == null) return undefined; + return typeof value === 'string' ? value : value.toString(); + }; + + return { + _id: doc._id.toString(), + userId: toId(doc.userId) ?? '', + postId: toId(doc.postId), + voteId: toId(doc.voteId), + commentId: toId(doc.commentId), + quoteId: toId(doc.quoteId), + activityType: doc.activityType as Common.ActivityEventType, + content: doc.content ?? undefined, + created: doc.created ?? new Date(), + }; +} + +export const activityResolver = { + Query: { + /** + * Paginated activity feed for a user (or the caller's following list). + * Matches legacy getUserActivities return shape: { entities, pagination }. + */ + activities: async ( + _parent: unknown, + args: ActivityQueryArgs, + context: GraphQLContext + ): Promise> => { + if (!context.user?._id) { + throw new GraphQLError('Authentication required', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } + + const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : 10; + const offset = typeof args.offset === 'number' && args.offset >= 0 ? args.offset : 0; + + const searchArgs: ActivityFilter = {}; + + const searchKey = args.searchKey?.trim(); + if (searchKey) { + searchArgs.$text = { + $search: searchKey, + $caseSensitive: false, + }; + } + + const events = normalizeActivityEvents(args.activityEvent); + if (events.length > 0) { + searchArgs.activityType = { $in: events }; + } + + if (args.user_id) { + searchArgs.userId = args.user_id; + } else { + const viewer = await User.findById(context.user._id).select('_followingId').lean(); + const followingIds = (viewer?._followingId ?? []).map((id) => id.toString()); + searchArgs.userId = { $in: followingIds }; + } + + if (args.startDateRange && args.endDateRange) { + searchArgs.created = { + $gte: new Date(args.startDateRange), + $lte: new Date(args.endDateRange), + }; + } + + const [total, activitiesResult] = await Promise.all([ + Activity.countDocuments(searchArgs), + Activity.find(searchArgs).sort({ created: -1 }).skip(offset).limit(limit).lean(), + ]); + + return { + entities: activitiesResult.map((doc) => toActivityEntity(doc)), + pagination: { + total_count: total, + limit, + offset, + }, + }; + }, + }, +}; diff --git a/quotevote-backend/app/data/resolvers/notificationResolver.ts b/quotevote-backend/app/data/resolvers/notificationResolver.ts new file mode 100644 index 00000000..6e259097 --- /dev/null +++ b/quotevote-backend/app/data/resolvers/notificationResolver.ts @@ -0,0 +1,40 @@ +import { GraphQLError } from 'graphql'; +import Notification from '../models/Notification'; +import type * as Common from '~/types/common'; +import type { GraphQLContext } from '~/types/graphql'; + +export const notificationResolver = { + Query: { + /** + * Returns unread notifications for the authenticated user. + * Matches legacy getNotifications behavior (status: 'new', newest first). + */ + notifications: async ( + _parent: unknown, + _args: unknown, + context: GraphQLContext + ): Promise => { + if (!context.user?._id) { + throw new GraphQLError('Authentication required', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + } + + const userId = context.user._id.toString(); + const notifications = await Notification.find({ + userId, + status: 'new', + }) + .sort({ created: -1 }) + .lean(); + + return notifications.map((n) => ({ + ...n, + _id: n._id.toString(), + userId: n.userId?.toString?.() ?? String(n.userId), + userIdBy: n.userIdBy?.toString?.() ?? String(n.userIdBy), + postId: n.postId ? n.postId.toString() : undefined, + })) as unknown as Common.Notification[]; + }, + }, +}; diff --git a/quotevote-backend/app/server.ts b/quotevote-backend/app/server.ts index 085fb622..22a05383 100644 --- a/quotevote-backend/app/server.ts +++ b/quotevote-backend/app/server.ts @@ -13,6 +13,8 @@ import { groupResolver } from './data/resolvers/groupResolver'; import { chatResolver } from './data/resolvers/chatResolver'; import { rosterResolver } from './data/resolvers/rosterResolver'; import { quoteResolver } from './data/resolvers/quoteResolver'; +import { notificationResolver } from './data/resolvers/notificationResolver'; +import { activityResolver } from './data/resolvers/activityResolver'; import { heartbeatResolver } from './data/resolvers/heartbeatResolver'; import { domainTypeDefs } from './data/types'; import type { GraphQLContext, PubSub } from './types/graphql'; @@ -121,6 +123,20 @@ async function startServer() { # Token verification verifyUserPasswordResetToken(token: String!): Boolean + + # Notifications (auth required) + notifications: [Notification!]! + + # Activity feed (auth required) + activities( + offset: Int + limit: Int + searchKey: String + startDateRange: String + endDateRange: String + user_id: String + activityEvent: JSON + ): Activities } type Mutation { @@ -181,6 +197,8 @@ async function startServer() { chatResolver, rosterResolver, quoteResolver, + notificationResolver, + activityResolver, heartbeatResolver, ], }); diff --git a/quotevote-frontend/src/__tests__/utils/auth.test.ts b/quotevote-frontend/src/__tests__/utils/auth.test.ts index 6ddb53fd..836817fe 100644 --- a/quotevote-frontend/src/__tests__/utils/auth.test.ts +++ b/quotevote-frontend/src/__tests__/utils/auth.test.ts @@ -1,19 +1,34 @@ -import { isAuthenticated, requireAuth } from '@/lib/utils/auth' +import { isAuthenticated, hasActiveSession, requireAuth } from '@/lib/utils/auth' +import { useAppStore } from '@/store/useAppStore' beforeEach(() => { - localStorage.clear() + localStorage.clear() + useAppStore.getState().logout() }) describe('auth utils', () => { - it('isAuthenticated returns false when no token', () => { - expect(isAuthenticated()).toBe(false) - }) + it('isAuthenticated returns false when no token', () => { + expect(isAuthenticated()).toBe(false) + }) - it('requireAuth prevents action execution when not authenticated', () => { - const action = jest.fn((s: string) => s) - const guarded = requireAuth(action) - guarded('x') - expect(action).not.toHaveBeenCalled() - // Redirect side-effect is environment-specific; ensure no action execution - }) + it('hasActiveSession is true when store has a user even without a token', () => { + useAppStore.getState().setUserData({ _id: 'user-1', username: 'alice' }) + expect(isAuthenticated()).toBe(false) + expect(hasActiveSession()).toBe(true) + }) + + it('requireAuth prevents action execution when not authenticated', () => { + const action = jest.fn((s: string) => s) + const guarded = requireAuth(action) + guarded('x') + expect(action).not.toHaveBeenCalled() + }) + + it('requireAuth allows action when store session exists', () => { + useAppStore.getState().setUserData({ _id: 'user-1', username: 'alice' }) + const action = jest.fn((s: string) => s) + const guarded = requireAuth(action) + guarded('x') + expect(action).toHaveBeenCalledWith('x') + }) }) diff --git a/quotevote-frontend/src/__tests__/utils/getServerUrl.test.ts b/quotevote-frontend/src/__tests__/utils/getServerUrl.test.ts index 9edbdda8..e96af1c2 100644 --- a/quotevote-frontend/src/__tests__/utils/getServerUrl.test.ts +++ b/quotevote-frontend/src/__tests__/utils/getServerUrl.test.ts @@ -1,4 +1,4 @@ -import { getBaseServerUrl, getGraphqlServerUrl, getGraphqlWsServerUrl } from '@/lib/utils/getServerUrl' +import { getBaseServerUrl, getGraphqlServerUrl, getGraphqlWsServerUrl, areGraphqlSubscriptionsEnabled } from '@/lib/utils/getServerUrl' describe('getServerUrl', () => { const OLD_ENV = process.env @@ -43,4 +43,14 @@ describe('getServerUrl', () => { process.env.NEXT_PUBLIC_SERVER_URL = 'http://localhost:4000' expect(getGraphqlWsServerUrl()).toBe('ws://localhost:4000/graphql') }) + + it('disables subscriptions on localhost', () => { + process.env.NEXT_PUBLIC_SERVER_URL = 'http://localhost:4000' + expect(areGraphqlSubscriptionsEnabled()).toBe(false) + }) + + it('enables subscriptions on hosted APIs', () => { + process.env.NEXT_PUBLIC_SERVER_URL = 'https://api.quote.vote' + expect(areGraphqlSubscriptionsEnabled()).toBe(true) + }) }) diff --git a/quotevote-frontend/src/hooks/useGuestGuard.ts b/quotevote-frontend/src/hooks/useGuestGuard.ts index 108c4eee..4440f162 100644 --- a/quotevote-frontend/src/hooks/useGuestGuard.ts +++ b/quotevote-frontend/src/hooks/useGuestGuard.ts @@ -1,19 +1,26 @@ import { useCallback } from 'react' -import { isAuthenticated } from '@/lib/utils/auth' +import { hasActiveSession } from '@/lib/utils/auth' import { useAuthModal } from '@/context/AuthModalContext' +import { useAppStore } from '@/store/useAppStore' /** * Guard guest interactions by showing the auth modal instead of redirecting. * Returns a function that resolves to false when not authenticated. + * + * Uses the Zustand session (same signal as the dashboard chrome) in addition to + * the JWT check, so a persisted logged-in user is not treated as a guest when + * the access token is briefly missing/expired. */ export default function useGuestGuard() { - const { openAuthModal } = useAuthModal() + const { openAuthModal } = useAuthModal() + // Re-subscribe so the guard updates when login/logout changes store state. + useAppStore((state) => state.user.data._id || state.user.data.id) - return useCallback(() => { - if (!isAuthenticated()) { - openAuthModal({ view: 'login' }) - return false - } - return true - }, [openAuthModal]) + return useCallback(() => { + if (hasActiveSession()) { + return true + } + openAuthModal({ view: 'login' }) + return false + }, [openAuthModal]) } diff --git a/quotevote-frontend/src/lib/apollo/apollo-client.ts b/quotevote-frontend/src/lib/apollo/apollo-client.ts index 51dd1673..f7fc66d0 100644 --- a/quotevote-frontend/src/lib/apollo/apollo-client.ts +++ b/quotevote-frontend/src/lib/apollo/apollo-client.ts @@ -5,13 +5,14 @@ import { ErrorLink } from '@apollo/client/link/error'; import { getMainDefinition } from '@apollo/client/utilities'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { createClient } from 'graphql-ws'; -import { map } from 'rxjs'; +import { map, Observable } from 'rxjs'; import { toast } from 'sonner'; import { env } from '@/config/env'; -import { getGraphqlWsServerUrl } from '@/lib/utils/getServerUrl'; +import { getGraphqlWsServerUrl, areGraphqlSubscriptionsEnabled } from '@/lib/utils/getServerUrl'; import { serializeObjectIds } from '@/lib/utils/objectIdSerializer'; import { getToken, removeToken } from '@/lib/auth'; import { triggerAuthGate } from '@/lib/auth-gate'; +import { useAppStore } from '@/store/useAppStore'; /** * Get the GraphQL endpoint URL from validated environment configuration @@ -80,6 +81,11 @@ function createWsLink(): GraphQLWsLink | null { return null; } + // Local backend has no subscription transport — avoid noisy reconnect loops. + if (!areGraphqlSubscriptionsEnabled()) { + return null; + } + // Track retry attempts to prevent infinite loops let retryCount = 0; let retryResetTimeout: NodeJS.Timeout | null = null; @@ -192,7 +198,19 @@ function createErrorLink() { if (code === 'UNAUTHENTICATED') { if (typeof window !== 'undefined') { removeToken(); - triggerAuthGate({ view: 'login' }); + // Keep UI auth state in sync with the cleared token. + useAppStore.getState().logout(); + + // Background queries (notifications, rooms, activities) must not + // pop the invite/login modal while the user still looks signed in. + // Interactive mutations still open the login gate. + const definition = getMainDefinition(operation.query); + const isMutation = + definition.kind === 'OperationDefinition' && + definition.operation === 'mutation'; + if (isMutation) { + triggerAuthGate({ view: 'login' }); + } } return; } @@ -258,6 +276,17 @@ function createObjectIdSerializationLink() { }); } +/** + * Local backends have no graphql-ws transport. Completing immediately keeps + * useSubscription callers quiet instead of sending subscriptions over HTTP + * (which produces INTERNAL_SERVER_ERROR / validation noise). + */ +function createNoopSubscriptionLink(): ApolloLink { + return new ApolloLink(() => new Observable((subscriber) => { + subscriber.complete(); + })); +} + /** * Create and configure Apollo Client instance * This is SSR-aware and safe to use in both server and client components @@ -281,23 +310,25 @@ function createApolloClient(): ApolloClientType { httpLink, ]); - // Split link: subscriptions go to WebSocket, queries/mutations go to HTTP - // On server, only use HTTP link (no WebSocket support) - // Note: Type assertion needed due to pnpm dependency resolution creating - // separate instances of ApolloLink types from different packages - const link = typeof window !== 'undefined' && wsLink - ? (split( - ({ query }) => { - const definition = getMainDefinition(query); - return ( - definition.kind === 'OperationDefinition' && - definition.operation === 'subscription' - ); - }, - wsLink as unknown as ApolloLink, - httpLinkChain - ) as ApolloLink) - : httpLinkChain; + // Split link: subscriptions go to WebSocket (or a no-op on localhost), + // queries/mutations go to HTTP. On the server, only use HTTP. + const subscriptionLink = + (wsLink as unknown as ApolloLink | null) ?? createNoopSubscriptionLink(); + + const link = + typeof window !== 'undefined' + ? (split( + ({ query }) => { + const definition = getMainDefinition(query); + return ( + definition.kind === 'OperationDefinition' && + definition.operation === 'subscription' + ); + }, + subscriptionLink, + httpLinkChain + ) as ApolloLink) + : httpLinkChain; return new ApolloClient({ link, diff --git a/quotevote-frontend/src/lib/utils/auth.ts b/quotevote-frontend/src/lib/utils/auth.ts index 654d683c..1874bd37 100644 --- a/quotevote-frontend/src/lib/utils/auth.ts +++ b/quotevote-frontend/src/lib/utils/auth.ts @@ -1,6 +1,7 @@ import { jwtDecode } from 'jwt-decode' import { DecodedToken } from '@/types/store' import { triggerAuthGate } from '@/lib/auth-gate' +import { useAppStore } from '@/store/useAppStore' export function isAuthenticated(): boolean { const token = localStorage.getItem('token') @@ -17,9 +18,17 @@ export function isAuthenticated(): boolean { } } +/** True when JWT is valid or the persisted session user is present (dashboard chrome). */ +export function hasActiveSession(): boolean { + if (typeof window === 'undefined') return false + const data = useAppStore.getState().user.data + if (data._id || data.id) return true + return isAuthenticated() +} + export function requireAuth(action: (...args: Args) => R) { return (...args: Args): R | void => { - if (!isAuthenticated()) { + if (!hasActiveSession()) { triggerAuthGate({ view: 'login' }) return } diff --git a/quotevote-frontend/src/lib/utils/getServerUrl.ts b/quotevote-frontend/src/lib/utils/getServerUrl.ts index 6ae667eb..3bfddf9c 100644 --- a/quotevote-frontend/src/lib/utils/getServerUrl.ts +++ b/quotevote-frontend/src/lib/utils/getServerUrl.ts @@ -48,4 +48,14 @@ export const getGraphqlWsServerUrl = (): string => { } const replacedUrl = baseUrl.replace('https://', 'wss://').replace('http://', 'ws://') return `${replacedUrl}/graphql` -} \ No newline at end of file +} + +/** + * Local quotevote-backend is HTTP-only (no graphql-ws). Skip the Apollo WS + * link on localhost/127.0.0.1 so the console is not flooded with connection errors. + * Hosted APIs still use subscriptions over WSS. + */ +export const areGraphqlSubscriptionsEnabled = (): boolean => { + const baseUrl = getBaseServerUrl() + return !baseUrl.includes('localhost') && !baseUrl.includes('127.0.0.1') +} From 3b4613a3d0ea00d69a45630b15cabfa29abf7e4a Mon Sep 17 00:00:00 2001 From: Neo Isaac Amao Date: Fri, 24 Jul 2026 17:29:12 +0800 Subject: [PATCH 4/6] fix(presence): clarify static rebind and skip redundant heartbeat updates Re-bind all Presence statics after model resolve so hot reload cannot leave stale methods, and coalesce chat status defaults before applying heartbeat payloads to avoid no-op setState when values already match. Co-authored-by: Cursor --- quotevote-backend/app/data/models/Presence.ts | 28 ++++++++++++++----- .../src/hooks/usePresenceHeartbeat.ts | 5 +++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/quotevote-backend/app/data/models/Presence.ts b/quotevote-backend/app/data/models/Presence.ts index ac9b12a5..9e9bb0a0 100644 --- a/quotevote-backend/app/data/models/Presence.ts +++ b/quotevote-backend/app/data/models/Presence.ts @@ -33,9 +33,9 @@ const PresenceSchema = new Schema( PresenceSchema.index({ status: 1 }); PresenceSchema.index({ lastHeartbeat: 1 }); -PresenceSchema.statics.findByUserId = function (userId: string) { +function findByUserIdImpl(this: PresenceModel, userId: string) { return this.findOne({ userId }); -}; +} /** * Refresh liveness only. Do not overwrite a user-chosen status/message. @@ -79,13 +79,27 @@ async function updateHeartbeatImpl( return existing.save(); } +PresenceSchema.statics.findByUserId = findByUserIdImpl; PresenceSchema.statics.updateHeartbeat = updateHeartbeatImpl; -// Always bind the latest statics — mongoose.models.Presence may already exist after hot reload. -const Presence = - (mongoose.models.Presence as PresenceModel) || - mongoose.model('Presence', PresenceSchema); +/** + * Re-bind every schema static onto the live model. + * + * Under ts-node-dev / hot reload, `mongoose.models.Presence` often already exists + * from a previous module evaluation. In that case `mongoose.model(...)` is skipped + * and schema.statics assigned above never replace the stale methods on the cached + * model. Binding here (for *all* statics, not just one) keeps call sites on the + * latest implementation when new statics are added later. + */ +function bindPresenceStatics(model: PresenceModel): PresenceModel { + model.findByUserId = findByUserIdImpl.bind(model); + model.updateHeartbeat = updateHeartbeatImpl.bind(model); + return model; +} -Presence.updateHeartbeat = updateHeartbeatImpl.bind(Presence); +const Presence = bindPresenceStatics( + (mongoose.models.Presence as PresenceModel) || + mongoose.model('Presence', PresenceSchema) +); export default Presence; diff --git a/quotevote-frontend/src/hooks/usePresenceHeartbeat.ts b/quotevote-frontend/src/hooks/usePresenceHeartbeat.ts index 1611fee6..ffcade2c 100644 --- a/quotevote-frontend/src/hooks/usePresenceHeartbeat.ts +++ b/quotevote-frontend/src/hooks/usePresenceHeartbeat.ts @@ -41,8 +41,11 @@ export const usePresenceHeartbeat = (interval: number = 45000): UsePresenceHeart const applyPresenceFromHeartbeat = (payload: HeartbeatResult['heartbeat']): void => { if (!payload?.status || !PRESENCE_STATUSES.has(payload.status)) return const statusMessage = typeof payload.statusMessage === 'string' ? payload.statusMessage : '' + // Store defaults to online/'' — coalesce so a partial rehydrate can't force a no-op miss. const chat = useAppStore.getState().chat - if (chat.userStatus === payload.status && chat.userStatusMessage === statusMessage) return + const currentStatus = chat.userStatus || 'online' + const currentMessage = chat.userStatusMessage || '' + if (currentStatus === payload.status && currentMessage === statusMessage) return setUserStatus(payload.status, statusMessage) } From ca06d4d39e22582e3ab28cc3b678df57bdea7281 Mon Sep 17 00:00:00 2001 From: Neo Isaac Amao Date: Fri, 24 Jul 2026 17:36:53 +0800 Subject: [PATCH 5/6] fix(api): tighten activities/notifications based on PR review Use ActivityEventType[] instead of JSON for activityEvent, fail loudly on missing activity userId, paginate notifications with a capped limit, and add a userId+created index for the activity feed. Co-authored-by: Cursor --- .../unit/resolvers/activityResolver.test.ts | 58 ++++++++++++++++++- .../resolvers/notificationResolver.test.ts | 29 ++++++++-- quotevote-backend/app/data/models/Activity.ts | 3 + .../app/data/resolvers/activityResolver.ts | 43 ++++++++++++-- .../data/resolvers/notificationResolver.ts | 13 ++++- quotevote-backend/app/server.ts | 6 +- quotevote-backend/app/types/graphql.ts | 4 +- quotevote-frontend/src/graphql/queries.ts | 6 +- 8 files changed, 140 insertions(+), 22 deletions(-) diff --git a/quotevote-backend/__tests__/unit/resolvers/activityResolver.test.ts b/quotevote-backend/__tests__/unit/resolvers/activityResolver.test.ts index c47ea645..910be2e9 100644 --- a/quotevote-backend/__tests__/unit/resolvers/activityResolver.test.ts +++ b/quotevote-backend/__tests__/unit/resolvers/activityResolver.test.ts @@ -1,12 +1,19 @@ import mongoose from 'mongoose'; import { GraphQLError } from 'graphql'; -import { activityResolver } from '~/data/resolvers/activityResolver'; +import { activityResolver, normalizeActivityEvents } from '~/data/resolvers/activityResolver'; import Activity from '~/data/models/Activity'; import User from '~/data/models/User'; import type { GraphQLContext } from '~/types/graphql'; jest.mock('~/data/models/Activity'); jest.mock('~/data/models/User'); +jest.mock('~/data/utils/logger', () => ({ + logger: { + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); const actorId = '60d5ec49ad414d7a8d5464a0'; const profileId = '60d5ec49ad414d7a8d5464a1'; @@ -26,6 +33,20 @@ function mockContext(overrides: Partial> = { }; } +describe('normalizeActivityEvents', () => { + it('accepts ActivityEventType arrays', () => { + expect(normalizeActivityEvents(['VOTED', 'POSTED'])).toEqual(['VOTED', 'POSTED']); + }); + + it('parses legacy JSON array strings', () => { + expect(normalizeActivityEvents('["COMMENTED"]')).toEqual(['COMMENTED']); + }); + + it('drops unknown event strings', () => { + expect(normalizeActivityEvents(['VOTED', 'NOT_A_REAL_EVENT'] as string[])).toEqual(['VOTED']); + }); +}); + describe('activityResolver', () => { beforeEach(() => { jest.clearAllMocks(); @@ -92,6 +113,41 @@ describe('activityResolver', () => { expect(result.entities).toHaveLength(1); expect(result.entities[0].activityType).toBe('VOTED'); expect(result.entities[0]._id).toBe(activityId.toString()); + expect(result.entities[0].userId).toBe(profileId); + }); + + it('rejects activities missing userId', async () => { + (Activity.countDocuments as jest.Mock).mockResolvedValue(1); + (Activity.find as jest.Mock).mockReturnValue({ + sort: jest.fn().mockReturnValue({ + skip: jest.fn().mockReturnValue({ + limit: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue([ + { + _id: new mongoose.Types.ObjectId(), + userId: null, + activityType: 'VOTED', + created: new Date(), + }, + ]), + }), + }), + }), + }); + + await expect( + activityResolver.Query.activities( + null, + { + user_id: profileId, + limit: 10, + offset: 0, + searchKey: '', + activityEvent: ['VOTED'], + }, + mockContext() + ) + ).rejects.toThrow(/missing required userId/); }); it('falls back to following feed when user_id is omitted', async () => { diff --git a/quotevote-backend/__tests__/unit/resolvers/notificationResolver.test.ts b/quotevote-backend/__tests__/unit/resolvers/notificationResolver.test.ts index 06bdcc7f..854e29b4 100644 --- a/quotevote-backend/__tests__/unit/resolvers/notificationResolver.test.ts +++ b/quotevote-backend/__tests__/unit/resolvers/notificationResolver.test.ts @@ -28,11 +28,10 @@ describe('notificationResolver', () => { }); it('returns an empty list when the user has no notifications', async () => { - (Notification.find as jest.Mock).mockReturnValue({ - sort: jest.fn().mockReturnValue({ - lean: jest.fn().mockResolvedValue([]), - }), - }); + const lean = jest.fn().mockResolvedValue([]); + const limit = jest.fn().mockReturnValue({ lean }); + const sort = jest.fn().mockReturnValue({ limit }); + (Notification.find as jest.Mock).mockReturnValue({ sort }); const result = await notificationResolver.Query.notifications( null, @@ -48,7 +47,27 @@ describe('notificationResolver', () => { userId, status: 'new', }); + expect(limit).toHaveBeenCalledWith(50); expect(result).toEqual([]); }); + + it('clamps limit to a maximum of 100', async () => { + const lean = jest.fn().mockResolvedValue([]); + const limit = jest.fn().mockReturnValue({ lean }); + const sort = jest.fn().mockReturnValue({ limit }); + (Notification.find as jest.Mock).mockReturnValue({ sort }); + + await notificationResolver.Query.notifications( + null, + { limit: 500 }, + mockContext({ + _id: userId, + username: 'alice', + email: 'alice@example.com', + } as NonNullable) + ); + + expect(limit).toHaveBeenCalledWith(100); + }); }); }); diff --git a/quotevote-backend/app/data/models/Activity.ts b/quotevote-backend/app/data/models/Activity.ts index dd3d4340..c72312f4 100644 --- a/quotevote-backend/app/data/models/Activity.ts +++ b/quotevote-backend/app/data/models/Activity.ts @@ -16,6 +16,9 @@ const ActivitySchema = new Schema( { timestamps: true } ); +// Supports activities feed: filter by user, sort newest-first with skip/limit. +ActivitySchema.index({ userId: 1, created: -1 }); + const Activity = (mongoose.models.Activity as ActivityModel) || mongoose.model('Activity', ActivitySchema); diff --git a/quotevote-backend/app/data/resolvers/activityResolver.ts b/quotevote-backend/app/data/resolvers/activityResolver.ts index 74e4c485..3de0d1e4 100644 --- a/quotevote-backend/app/data/resolvers/activityResolver.ts +++ b/quotevote-backend/app/data/resolvers/activityResolver.ts @@ -1,13 +1,21 @@ import { GraphQLError } from 'graphql'; import Activity from '../models/Activity'; import User from '../models/User'; +import { logger } from '../utils/logger'; +import { ActivityEventTypeValues } from '../utils/constants'; import type * as Common from '~/types/common'; import type { ActivityQueryArgs, GraphQLContext } from '~/types/graphql'; type ActivityFilter = Record; -function normalizeActivityEvents( - activityEvent: ActivityQueryArgs['activityEvent'] | string | null | undefined +const ALLOWED_ACTIVITY_EVENTS = new Set(Object.values(ActivityEventTypeValues)); + +/** + * Normalize activityEvent filter from GraphQL. + * Prefer `[ActivityEventType!]`; still accepts a legacy JSON-encoded array string. + */ +export function normalizeActivityEvents( + activityEvent: ActivityQueryArgs['activityEvent'] | string | string[] | null | undefined ): Common.ActivityEventType[] { if (activityEvent == null) return []; @@ -15,13 +23,26 @@ function normalizeActivityEvents( if (typeof activityEvent === 'string') { try { parsed = JSON.parse(activityEvent); - } catch { + } catch (err) { + logger.warn('activities.activityEvent JSON parse failed; ignoring filter', { + raw: activityEvent.slice(0, 200), + error: err instanceof Error ? err.message : String(err), + }); return []; } } - if (!Array.isArray(parsed)) return []; - return parsed.filter((v): v is Common.ActivityEventType => typeof v === 'string'); + if (!Array.isArray(parsed)) { + logger.warn('activities.activityEvent is not an array; ignoring filter', { + receivedType: typeof parsed, + }); + return []; + } + + return parsed.filter( + (v): v is Common.ActivityEventType => + typeof v === 'string' && ALLOWED_ACTIVITY_EVENTS.has(v) + ); } function toActivityEntity(doc: { @@ -40,9 +61,19 @@ function toActivityEntity(doc: { return typeof value === 'string' ? value : value.toString(); }; + const userId = toId(doc.userId); + if (!userId) { + throw new GraphQLError('Activity document is missing required userId', { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + activityId: doc._id.toString(), + }, + }); + } + return { _id: doc._id.toString(), - userId: toId(doc.userId) ?? '', + userId, postId: toId(doc.postId), voteId: toId(doc.voteId), commentId: toId(doc.commentId), diff --git a/quotevote-backend/app/data/resolvers/notificationResolver.ts b/quotevote-backend/app/data/resolvers/notificationResolver.ts index 6e259097..ba25eef5 100644 --- a/quotevote-backend/app/data/resolvers/notificationResolver.ts +++ b/quotevote-backend/app/data/resolvers/notificationResolver.ts @@ -3,15 +3,19 @@ import Notification from '../models/Notification'; import type * as Common from '~/types/common'; import type { GraphQLContext } from '~/types/graphql'; +const DEFAULT_NOTIFICATION_LIMIT = 50; +const MAX_NOTIFICATION_LIMIT = 100; + export const notificationResolver = { Query: { /** * Returns unread notifications for the authenticated user. - * Matches legacy getNotifications behavior (status: 'new', newest first). + * Matches legacy getNotifications behavior (status: 'new', newest first), + * with an optional limit to avoid unbounded reads. */ notifications: async ( _parent: unknown, - _args: unknown, + args: { limit?: number | null }, context: GraphQLContext ): Promise => { if (!context.user?._id) { @@ -20,12 +24,17 @@ export const notificationResolver = { }); } + const requested = + typeof args.limit === 'number' && Number.isFinite(args.limit) ? Math.floor(args.limit) : DEFAULT_NOTIFICATION_LIMIT; + const limit = Math.min(Math.max(requested, 1), MAX_NOTIFICATION_LIMIT); + const userId = context.user._id.toString(); const notifications = await Notification.find({ userId, status: 'new', }) .sort({ created: -1 }) + .limit(limit) .lean(); return notifications.map((n) => ({ diff --git a/quotevote-backend/app/server.ts b/quotevote-backend/app/server.ts index 22a05383..bad29be7 100644 --- a/quotevote-backend/app/server.ts +++ b/quotevote-backend/app/server.ts @@ -124,8 +124,8 @@ async function startServer() { # Token verification verifyUserPasswordResetToken(token: String!): Boolean - # Notifications (auth required) - notifications: [Notification!]! + # Notifications (auth required); limit defaults to 50 (max 100) in the resolver + notifications(limit: Int): [Notification!]! # Activity feed (auth required) activities( @@ -135,7 +135,7 @@ async function startServer() { startDateRange: String endDateRange: String user_id: String - activityEvent: JSON + activityEvent: [ActivityEventType!] ): Activities } diff --git a/quotevote-backend/app/types/graphql.ts b/quotevote-backend/app/types/graphql.ts index f27bace0..97bc2ad1 100644 --- a/quotevote-backend/app/types/graphql.ts +++ b/quotevote-backend/app/types/graphql.ts @@ -131,7 +131,7 @@ export interface QueryResolvers { activities: ResolverFn, unknown, ActivityQueryArgs>; // Notification queries - notifications: ResolverFn; + notifications: ResolverFn; // Message queries messages: ResolverFn; @@ -399,7 +399,7 @@ export interface ActivityQueryArgs { searchKey?: string; startDateRange?: string; endDateRange?: string; - activityEvent: Common.ActivityEventType[]; + activityEvent?: Common.ActivityEventType[] | null; } // ============================================================================ diff --git a/quotevote-frontend/src/graphql/queries.ts b/quotevote-frontend/src/graphql/queries.ts index 66ba7654..16f5551a 100644 --- a/quotevote-frontend/src/graphql/queries.ts +++ b/quotevote-frontend/src/graphql/queries.ts @@ -710,7 +710,7 @@ export const GET_USER_ACTIVITY = gql` $searchKey: String! $startDateRange: String $endDateRange: String - $activityEvent: JSON! + $activityEvent: [ActivityEventType!] ) { activities( user_id: $user_id @@ -807,8 +807,8 @@ export const GET_USER_ACTIVITY = gql` * Get notifications query */ export const GET_NOTIFICATIONS = gql` - query notifications { - notifications { + query notifications($limit: Int) { + notifications(limit: $limit) { _id userId userIdBy From 5255016b22912a41d962fee2bdafe56df8a5fc47 Mon Sep 17 00:00:00 2001 From: Neo Isaac Amao Date: Sun, 26 Jul 2026 01:37:08 +0800 Subject: [PATCH 6/6] fix(ci): align Presence heartbeat tests and avatar editor lint Update Presence.schema tests for the preferred-status heartbeat path, and allow one-time Zustand avatar seeding in the editor effect. Co-authored-by: Cursor --- .../models/schema/Presence.schema.test.ts | 86 ++++++++++++++++++- .../profile/[username]/avatar/page.tsx | 3 +- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/quotevote-backend/__tests__/unit/models/schema/Presence.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Presence.schema.test.ts index d8566f9a..2d7ed184 100644 --- a/quotevote-backend/__tests__/unit/models/schema/Presence.schema.test.ts +++ b/quotevote-backend/__tests__/unit/models/schema/Presence.schema.test.ts @@ -59,20 +59,98 @@ describe('Presence Schema', () => { findOneSpy.mockRestore(); }); - it('updateHeartbeat should use findOneAndUpdate with upsert', async () => { + it('updateHeartbeat upserts when no presence exists', async () => { const userId = createObjectId().toHexString(); + const findOneSpy = jest.spyOn(Presence, 'findOne').mockResolvedValue(null); + const upserted = { + userId, + status: 'online', + preferredStatus: 'online', + lastHeartbeat: new Date(), + lastSeen: new Date(), + }; const findOneAndUpdateSpy = jest .spyOn(Presence, 'findOneAndUpdate') - .mockResolvedValue(null); + .mockResolvedValue(upserted as never); - await Presence.updateHeartbeat(userId); + const result = await Presence.updateHeartbeat(userId); + expect(findOneSpy).toHaveBeenCalledWith({ userId }); expect(findOneAndUpdateSpy).toHaveBeenCalledWith( { userId }, - expect.objectContaining({ status: 'online' }), + expect.objectContaining({ + $set: expect.objectContaining({ + lastHeartbeat: expect.any(Date), + lastSeen: expect.any(Date), + }), + $setOnInsert: expect.objectContaining({ + status: 'online', + preferredStatus: 'online', + }), + }), expect.objectContaining({ upsert: true, new: true, setDefaultsOnInsert: true }) ); + expect(result).toEqual(upserted); + + findOneSpy.mockRestore(); findOneAndUpdateSpy.mockRestore(); }); + + it('updateHeartbeat refreshes timestamps without forcing online when already present', async () => { + const userId = createObjectId().toHexString(); + const save = jest.fn().mockImplementation(function (this: { status: string }) { + return Promise.resolve(this); + }); + const existing = { + userId, + status: 'away', + statusMessage: 'In a meeting', + preferredStatus: 'away', + preferredStatusMessage: 'In a meeting', + lastHeartbeat: new Date('2020-01-01T00:00:00.000Z'), + lastSeen: new Date('2020-01-01T00:00:00.000Z'), + save, + }; + const findOneSpy = jest.spyOn(Presence, 'findOne').mockResolvedValue(existing as never); + const findOneAndUpdateSpy = jest.spyOn(Presence, 'findOneAndUpdate'); + + const result = await Presence.updateHeartbeat(userId); + + expect(findOneAndUpdateSpy).not.toHaveBeenCalled(); + expect(save).toHaveBeenCalled(); + expect(result.status).toBe('away'); + expect(result.statusMessage).toBe('In a meeting'); + expect(result.lastHeartbeat).toBeInstanceOf(Date); + expect(result.lastHeartbeat).not.toEqual(new Date('2020-01-01T00:00:00.000Z')); + + findOneSpy.mockRestore(); + findOneAndUpdateSpy.mockRestore(); + }); + + it('updateHeartbeat restores preferred status when currently offline', async () => { + const userId = createObjectId().toHexString(); + const save = jest.fn().mockImplementation(function (this: unknown) { + return Promise.resolve(this); + }); + const existing = { + userId, + status: 'offline', + statusMessage: '', + preferredStatus: 'dnd', + preferredStatusMessage: 'Focusing', + lastHeartbeat: new Date('2020-01-01T00:00:00.000Z'), + lastSeen: new Date('2020-01-01T00:00:00.000Z'), + save, + }; + const findOneSpy = jest.spyOn(Presence, 'findOne').mockResolvedValue(existing as never); + + const result = await Presence.updateHeartbeat(userId); + + expect(result.status).toBe('dnd'); + expect(result.statusMessage).toBe('Focusing'); + expect(save).toHaveBeenCalled(); + + findOneSpy.mockRestore(); + }); }); }); diff --git a/quotevote-frontend/src/app/dashboard/profile/[username]/avatar/page.tsx b/quotevote-frontend/src/app/dashboard/profile/[username]/avatar/page.tsx index 71023a47..ba8981dc 100644 --- a/quotevote-frontend/src/app/dashboard/profile/[username]/avatar/page.tsx +++ b/quotevote-frontend/src/app/dashboard/profile/[username]/avatar/page.tsx @@ -356,6 +356,7 @@ export default function AvatarEditorPage(): React.ReactNode { ); const seededFromStore = useRef(false); + /* eslint-disable react-hooks/set-state-in-effect -- seed editor from rehydrated Zustand avatar once */ useEffect(() => { if (seededFromStore.current) return; @@ -373,7 +374,7 @@ export default function AvatarEditorPage(): React.ReactNode { seededFromStore.current = true; } }, [userData.avatar, userId]); - + /* eslint-enable react-hooks/set-state-in-effect */ const [updateUserAvatar, { loading: saving }] = useMutation(UPDATE_USER_AVATAR); // Live preview URL