From 51264d45e61d078f26aeb11f1a4027e46e6e08a1 Mon Sep 17 00:00:00 2001 From: shahadot786 Date: Wed, 7 Jan 2026 12:05:14 +0600 Subject: [PATCH 01/27] feat: Implement robust frontend logout with backend mutation and add backend password reset token table --- .../migration.sql | 16 + backend/src/graphql/user/user.resolvers.ts | 13 +- .../src/components/layout/SettingsMenu.tsx | 58 ++- frontend/src/components/layout/Sidebar.tsx | 81 ++- frontend/yarn.lock | 474 ++++++++++++++++-- 5 files changed, 537 insertions(+), 105 deletions(-) create mode 100644 backend/prisma/migrations/20260107053405_add_like_comment_search_features/migration.sql diff --git a/backend/prisma/migrations/20260107053405_add_like_comment_search_features/migration.sql b/backend/prisma/migrations/20260107053405_add_like_comment_search_features/migration.sql new file mode 100644 index 0000000..b7d63e0 --- /dev/null +++ b/backend/prisma/migrations/20260107053405_add_like_comment_search_features/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_token_key" ON "password_reset_tokens"("token"); + +-- AddForeignKey +ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/src/graphql/user/user.resolvers.ts b/backend/src/graphql/user/user.resolvers.ts index c9df667..ba5f44b 100644 --- a/backend/src/graphql/user/user.resolvers.ts +++ b/backend/src/graphql/user/user.resolvers.ts @@ -124,11 +124,7 @@ export const userResolvers = { }, // Public: Login - login: async ( - _: unknown, - args: LoginPayload, - context: GraphQLContext - ) => { + login: async (_: unknown, args: LoginPayload, context: GraphQLContext) => { const { accessToken, refreshToken, user } = await userService.login(args); // Set httpOnly cookies @@ -141,11 +137,7 @@ export const userResolvers = { }, // Public: Refresh access token - refreshToken: async ( - _: unknown, - __: unknown, - context: GraphQLContext - ) => { + refreshToken: async (_: unknown, __: unknown, context: GraphQLContext) => { const refreshTokenValue = context.req.cookies?.[REFRESH_TOKEN_COOKIE]; if (!refreshTokenValue) { @@ -169,6 +161,7 @@ export const userResolvers = { // Protected: Logout logout: async (_: unknown, __: unknown, context: GraphQLContext) => { + console.log(context, "context"); const refreshTokenValue = context.req.cookies?.[REFRESH_TOKEN_COOKIE]; if (refreshTokenValue) { diff --git a/frontend/src/components/layout/SettingsMenu.tsx b/frontend/src/components/layout/SettingsMenu.tsx index 739ac53..fe1aafe 100644 --- a/frontend/src/components/layout/SettingsMenu.tsx +++ b/frontend/src/components/layout/SettingsMenu.tsx @@ -2,17 +2,13 @@ import React from "react"; import { useTheme } from "next-themes"; +import { useMutation } from "@apollo/client/react"; import { useAuthStore } from "@/stores/auth"; import { useRouter } from "next/navigation"; import { cn } from "@/lib/utils"; -import { - Sun, - Moon, - LogOut, - AlertCircle, - ChevronRight, - Monitor -} from "lucide-react"; +import { Sun, Moon, LogOut, AlertCircle, ChevronRight } from "lucide-react"; +import { LOGOUT_MUTATION } from "@/graphql/mutations/auth"; +import { apolloClient } from "@/lib/apollo-client"; interface SettingsMenuProps { variant?: "popover" | "inline"; @@ -20,15 +16,32 @@ interface SettingsMenuProps { className?: string; } -export function SettingsMenu({ variant = "popover", onClose, className }: SettingsMenuProps) { +export function SettingsMenu({ + variant = "popover", + onClose, + className, +}: SettingsMenuProps) { const { theme, setTheme } = useTheme(); - const logout = useAuthStore(state => state.logout); + const logout = useAuthStore((state) => state.logout); const router = useRouter(); + const [logoutMutation] = useMutation(LOGOUT_MUTATION); - const handleLogout = () => { - logout(); - router.push("/login"); - if (onClose) onClose(); + const handleLogout = async () => { + try { + // Call the backend logout mutation to clear cookies + await logoutMutation(); + } catch (error) { + // Even if the mutation fails (e.g., network error), proceed with local logout + console.error("Logout mutation failed:", error); + } finally { + // Clear Apollo cache + await apolloClient.clearStore(); + // Clear local auth state + logout(); + // Redirect to login page + router.replace("/login"); + if (onClose) onClose(); + } }; const toggleTheme = () => { @@ -44,7 +57,7 @@ export function SettingsMenu({ variant = "popover", onClose, className }: Settin {theme} - ) + ), }, { label: "Report a problem", @@ -52,14 +65,15 @@ export function SettingsMenu({ variant = "popover", onClose, className }: Settin onClick: () => { // Implement report logic if (onClose) onClose(); - } + }, }, { label: "Log out", icon: LogOut, onClick: handleLogout, - className: "text-destructive hover:text-destructive hover:bg-destructive/10" - } + className: + "text-destructive hover:text-destructive hover:bg-destructive/10", + }, ]; if (variant === "inline") { @@ -78,7 +92,9 @@ export function SettingsMenu({ variant = "popover", onClose, className }: Settin {item.label} - {item.rightElement || } + {item.rightElement || ( + + )} ))} @@ -86,7 +102,9 @@ export function SettingsMenu({ variant = "popover", onClose, className }: Settin } return ( -
+
{menuItems.map((item, idx) => ( - + {/* Bottom Actions */} -
+
{showMenu && (
setShowMenu(false)} />
)} - @@ -159,7 +179,11 @@ export function Sidebar() { {/* Mobile Bottom Nav */}
{/* Bio */} {user.bio && ( -

{user.bio}

+

+ {user.bio} +

)} {/* Stats */}
- {formatCount(user.stats?.followersCount || 0)} followers + + {formatCount(user.stats?.followersCount || 0)} followers + {user.website && ( <> ยท - - {user.website.replace(/^https?:\/\//, '')} + + {user.website.replace(/^https?:\/\//, "")} )} @@ -139,18 +156,27 @@ export default function ProfilePage({ params }: ProfilePageProps) {
{isOwnProfile ? ( <> - - - + - Settings + + Settings +
@@ -163,7 +189,10 @@ export default function ProfilePage({ params }: ProfilePageProps) { - @@ -197,9 +226,7 @@ export default function ProfilePage({ params }: ProfilePageProps) {

No threads yet

) : ( - posts.map(({ node: post }) => ( - - )) + posts.map(({ node: post }) => ) )}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 7516e63..0297c69 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -5,7 +5,7 @@ import { MainLayout } from "@/components/layout/MainLayout"; import { Header } from "@/components/layout/Header"; import { PostCard } from "@/components/post/PostCard"; import { PostSkeleton } from "@/components/ui/Loading"; -import { useAuthStore } from "@/stores/auth"; +// import { useAuthStore } from "@/stores/auth"; import { GET_TRENDING_POSTS } from "@/graphql/queries/post"; import type { PostConnection } from "@/types"; @@ -14,16 +14,17 @@ interface TrendingPostsData { } export default function HomePage() { - const isAuthenticated = useAuthStore(state => state.isAuthenticated); - const isLoading = useAuthStore(state => state.isLoading); + // const isAuthenticated = useAuthStore(state => state.isAuthenticated); + // const isLoading = useAuthStore(state => state.isLoading); - const { data, loading: postsLoading, fetchMore } = useQuery( - GET_TRENDING_POSTS, - { - variables: { first: 20 }, - notifyOnNetworkStatusChange: true, - } - ); + const { + data, + loading: postsLoading, + fetchMore, + } = useQuery(GET_TRENDING_POSTS, { + variables: { first: 20 }, + notifyOnNetworkStatusChange: true, + }); const posts = data?.getTrendingPosts?.edges || []; const pageInfo = data?.getTrendingPosts?.pageInfo; @@ -42,7 +43,6 @@ export default function HomePage() {
- {/* Posts Feed */}
{postsLoading && posts.length === 0 ? ( @@ -56,7 +56,9 @@ export default function HomePage() { ) : posts.length === 0 ? ( // Empty state
-

No posts yet. Be the first to share!

+

+ No posts yet. Be the first to share! +

) : ( // Posts list diff --git a/frontend/src/components/post/PostCard.tsx b/frontend/src/components/post/PostCard.tsx index 8c72207..7290ef8 100644 --- a/frontend/src/components/post/PostCard.tsx +++ b/frontend/src/components/post/PostCard.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/Avatar"; +import Image from "next/image"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/Avatar"; import { PostActions } from "./PostActions"; import { formatRelativeTime } from "@/lib/utils"; import type { Post } from "@/types"; @@ -19,18 +20,25 @@ export function PostCard({ post, showThread = false }: PostCardProps) {
{/* Left Column: Avatar & Thread Line */} -
- +
+ - + - {author.firstName[0]}{author.lastName?.[0]} + {author.firstName[0]} + {author.lastName?.[0]} {/* Thread line */} {showThread && ( -
+
)}
@@ -46,12 +54,23 @@ export function PostCard({ post, showThread = false }: PostCardProps) { {author.username} {author.is_verified && ( - - + + )} - + {formatRelativeTime(createdAt)}
@@ -64,30 +83,41 @@ export function PostCard({ post, showThread = false }: PostCardProps) { {/* Content text */} {content && ( -
+
{renderContent(content)}
)} {/* Media */} {media && media.length > 0 && ( -
1 ? 'grid gap-1 grid-cols-2' : ''}`}> +
1 ? "grid gap-1 grid-cols-2" : "" + }`} + > {media.map((item) => ( -
- {item.mediaType === 'VIDEO' ? ( +
+ {item.mediaType === "VIDEO" ? (
@@ -110,18 +140,26 @@ function renderContent(content: string): React.ReactNode { const parts = content.split(/(\s+)/); return parts.map((part, index) => { - if (part.startsWith('#')) { - const tag = part.slice(1).replace(/[^\w]/g, ''); + if (part.startsWith("#")) { + const tag = part.slice(1).replace(/[^\w]/g, ""); return ( - + {part} ); } - if (part.startsWith('@')) { - const username = part.slice(1).replace(/[^\w]/g, ''); + if (part.startsWith("@")) { + const username = part.slice(1).replace(/[^\w]/g, ""); return ( - + {part} ); From f33c9fc8e5a99c2295bdb803ad862314d6ef4030 Mon Sep 17 00:00:00 2001 From: shahadot786 Date: Wed, 7 Jan 2026 16:14:29 +0600 Subject: [PATCH 03/27] feat: Migrate refresh and password reset tokens to JWT with extended expiry, update Apollo Client link handling, and disable Next.js image optimization. --- backend/src/graphql/user/user.service.ts | 56 ++++++++++++++++-------- frontend/next.config.ts | 2 + frontend/src/lib/apollo-client.tsx | 48 +++++++++++--------- 3 files changed, 68 insertions(+), 38 deletions(-) diff --git a/backend/src/graphql/user/user.service.ts b/backend/src/graphql/user/user.service.ts index 6c95a41..166c5e0 100644 --- a/backend/src/graphql/user/user.service.ts +++ b/backend/src/graphql/user/user.service.ts @@ -35,23 +35,24 @@ export interface UpdateUserProfileInput { } const JWT_SECRET = process.env.JWT_SECRET; +const REFRESH_SECRET = process.env.REFRESH_SECRET; const ACCESS_TOKEN_EXPIRES = "15m"; -const REFRESH_TOKEN_EXPIRES_DAYS = 7; +const REFRESH_TOKEN_EXPIRES_DAYS = 30; // Generate password hash with salt function generateHash(password: string, salt: string): string { return createHmac("sha256", salt).update(password).digest("hex"); } -// Generate random token -function generateRandomToken(): string { - return randomBytes(40).toString("hex"); -} - // Generate a unique username based on firstName and lastName -async function generateUniqueUsername(firstName: string, lastName?: string): Promise { +async function generateUniqueUsername( + firstName: string, + lastName?: string +): Promise { // Create base username from firstName and lastName (lowercase, no spaces) - const baseName = `${firstName}${lastName || ""}`.toLowerCase().replace(/\s+/g, ""); + const baseName = `${firstName}${lastName || ""}` + .toLowerCase() + .replace(/\s+/g, ""); let username = baseName; let isUnique = false; @@ -83,7 +84,10 @@ async function generateUniqueUsername(firstName: string, lastName?: string): Pro } // Check if username is unique (for updates) -async function isUsernameUnique(username: string, excludeUserId?: string): Promise { +async function isUsernameUnique( + username: string, + excludeUserId?: string +): Promise { const existingUser = await prisma.user.findUnique({ where: { username }, }); @@ -163,8 +167,12 @@ export const userService = { ...(input.bio !== undefined && { bio: input.bio }), ...(input.website !== undefined && { website: input.website }), ...(input.location !== undefined && { location: input.location }), - ...(input.dob !== undefined && { dob: input.dob ? new Date(input.dob) : null }), - ...(input.profileImageUrl !== undefined && { profileImageUrl: input.profileImageUrl }), + ...(input.dob !== undefined && { + dob: input.dob ? new Date(input.dob) : null, + }), + ...(input.profileImageUrl !== undefined && { + profileImageUrl: input.profileImageUrl, + }), ...(input.is_private !== undefined && { is_private: input.is_private }), }, }); @@ -371,11 +379,17 @@ export const userService = { }, // Generate refresh token and save to DB - async generateRefreshToken(userId: string): Promise { - const token = generateRandomToken(); + async generateRefreshToken(userId: string, email: string): Promise { + if (!REFRESH_SECRET) { + throw Errors.internalError("REFRESH_SECRET is not configured"); + } const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS); + const token = JWT.sign({ id: userId, email }, REFRESH_SECRET, { + expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d`, + } as JWT.SignOptions); + await prisma.refreshToken.create({ data: { token, @@ -407,7 +421,7 @@ export const userService = { } const accessToken = this.generateAccessToken(user.id, user.email); - const refreshToken = await this.generateRefreshToken(user.id); + const refreshToken = await this.generateRefreshToken(user.id, user.email); return { accessToken, @@ -447,7 +461,8 @@ export const userService = { storedToken.user.email ); const newRefreshToken = await this.generateRefreshToken( - storedToken.user.id + storedToken.user.id, + storedToken.user.email ); return { @@ -485,10 +500,15 @@ export const userService = { await prisma.passwordResetToken.deleteMany({ where: { userId: user.id }, }); - - const token = generateRandomToken(); + if (!REFRESH_SECRET) { + throw Errors.internalError("REFRESH_SECRET is not configured"); + } const expiresAt = new Date(); - expiresAt.setHours(expiresAt.getHours() + 1); // Token expires in 1 hour + expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_EXPIRES_DAYS); + + const token = JWT.sign({ id: user.id, email }, REFRESH_SECRET, { + expiresIn: `${REFRESH_TOKEN_EXPIRES_DAYS}d`, + } as JWT.SignOptions); await prisma.passwordResetToken.create({ data: { diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 3848305..a1da2be 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -3,7 +3,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, + images: { + unoptimized: true, remotePatterns: [ { protocol: "http", diff --git a/frontend/src/lib/apollo-client.tsx b/frontend/src/lib/apollo-client.tsx index 69c0cf4..f2789f2 100644 --- a/frontend/src/lib/apollo-client.tsx +++ b/frontend/src/lib/apollo-client.tsx @@ -4,11 +4,10 @@ import { ApolloClient, InMemoryCache, HttpLink, - from, + ApolloLink, Observable, - FetchResult } from "@apollo/client/core"; -import { onError } from "@apollo/client/link/error"; +import { ErrorLink } from "@apollo/client/link/error"; import { ApolloProvider as BaseApolloProvider } from "@apollo/client/react"; import { ReactNode, useEffect, useRef } from "react"; import { useAuthStore } from "@/stores/auth"; @@ -16,7 +15,8 @@ import { REFRESH_TOKEN_MUTATION } from "@/graphql/mutations/auth"; import { GET_CURRENT_USER } from "@/graphql/queries/user"; import type { User } from "@/types"; -const GRAPHQL_URL = process.env.NEXT_PUBLIC_GRAPHQL_URL || "http://localhost:8000/graphql"; +const GRAPHQL_URL = + process.env.NEXT_PUBLIC_GRAPHQL_URL || "http://localhost:8000/graphql"; // Track if we're currently refreshing let isRefreshing = false; @@ -27,7 +27,7 @@ function subscribeTokenRefresh(cb: (retry: boolean) => void) { } function onRefreshComplete(success: boolean) { - refreshSubscribers.forEach(cb => cb(success)); + refreshSubscribers.forEach((cb) => cb(success)); refreshSubscribers = []; } @@ -41,17 +41,17 @@ const httpLink = new HttpLink({ interface ErrorHandlerParams { graphQLErrors?: ReadonlyArray<{ message: string; - extensions?: Record + extensions?: Record; }>; operation: { operationName: string; [key: string]: unknown; }; - forward: (op: unknown) => Observable; + forward: (op: unknown) => Observable; } // Error Link with token refresh -const errorLink = onError((params: unknown) => { +const errorLink = new ErrorLink((params: unknown) => { const { graphQLErrors, operation, forward } = params as ErrorHandlerParams; if (!graphQLErrors) return; @@ -71,7 +71,7 @@ const errorLink = onError((params: unknown) => { return; } - return new Observable(observer => { + return new Observable((observer) => { // If already refreshing, wait for it if (isRefreshing) { subscribeTokenRefresh((success) => { @@ -117,7 +117,7 @@ const errorLink = onError((params: unknown) => { // Apollo Client export const apolloClient = new ApolloClient({ - link: from([errorLink, httpLink]), + link: ApolloLink.from([errorLink, httpLink]), cache: new InMemoryCache({ typePolicies: { Query: { @@ -127,8 +127,12 @@ export const apolloClient = new ApolloClient({ merge(existing, incoming) { if (!existing) return incoming; // Deduplicate by cursor - const existingCursors = new Set(existing.edges.map((e: { cursor: string }) => e.cursor)); - const newEdges = incoming.edges.filter((e: { cursor: string }) => !existingCursors.has(e.cursor)); + const existingCursors = new Set( + existing.edges.map((e: { cursor: string }) => e.cursor) + ); + const newEdges = incoming.edges.filter( + (e: { cursor: string }) => !existingCursors.has(e.cursor) + ); return { ...incoming, edges: [...existing.edges, ...newEdges], @@ -140,8 +144,12 @@ export const apolloClient = new ApolloClient({ merge(existing, incoming) { if (!existing) return incoming; // Deduplicate by cursor - const existingCursors = new Set(existing.edges.map((e: { cursor: string }) => e.cursor)); - const newEdges = incoming.edges.filter((e: { cursor: string }) => !existingCursors.has(e.cursor)); + const existingCursors = new Set( + existing.edges.map((e: { cursor: string }) => e.cursor) + ); + const newEdges = incoming.edges.filter( + (e: { cursor: string }) => !existingCursors.has(e.cursor) + ); return { ...incoming, edges: [...existing.edges, ...newEdges], @@ -166,8 +174,8 @@ export const apolloClient = new ApolloClient({ // Apollo Provider with auth initialization export function ApolloProvider({ children }: { children: ReactNode }) { const hasInitialized = useRef(false); - const setUser = useAuthStore(state => state.setUser); - const setLoading = useAuthStore(state => state.setLoading); + const setUser = useAuthStore((state) => state.setUser); + const setLoading = useAuthStore((state) => state.setLoading); // Initialize auth on mount useEffect(() => { @@ -176,7 +184,9 @@ export function ApolloProvider({ children }: { children: ReactNode }) { async function initAuth() { try { - const { data } = await apolloClient.query<{ getCurrentLoggedInUser: User | null }>({ + const { data } = await apolloClient.query<{ + getCurrentLoggedInUser: User | null; + }>({ query: GET_CURRENT_USER, fetchPolicy: "network-only", }); @@ -191,8 +201,6 @@ export function ApolloProvider({ children }: { children: ReactNode }) { }, [setUser, setLoading]); return ( - - {children} - + {children} ); } From 5e6d0124c8c2085510632657d621ad80a476cd76 Mon Sep 17 00:00:00 2001 From: shahadot786 Date: Wed, 7 Jan 2026 16:57:53 +0600 Subject: [PATCH 04/27] feat: return full user object with token pairs from auth service and prevent auth card rendering during authentication loading --- backend/src/graphql/user/user.resolvers.ts | 5 +---- backend/src/graphql/user/user.service.ts | 9 ++++----- frontend/src/components/layout/MainLayout.tsx | 3 ++- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/backend/src/graphql/user/user.resolvers.ts b/backend/src/graphql/user/user.resolvers.ts index ba5f44b..29d87a4 100644 --- a/backend/src/graphql/user/user.resolvers.ts +++ b/backend/src/graphql/user/user.resolvers.ts @@ -144,15 +144,12 @@ export const userResolvers = { throw Errors.unauthenticated("No refresh token provided"); } - const { accessToken, refreshToken: newRefreshToken } = + const { accessToken, refreshToken: newRefreshToken, user } = await userService.refreshAccessToken(refreshTokenValue); // Set new cookies setAuthCookies(context.res, accessToken, newRefreshToken); - // Get user from new access token - const user = context.user; - return { accessToken, user, diff --git a/backend/src/graphql/user/user.service.ts b/backend/src/graphql/user/user.service.ts index 166c5e0..da9c01c 100644 --- a/backend/src/graphql/user/user.service.ts +++ b/backend/src/graphql/user/user.service.ts @@ -2,6 +2,7 @@ import { randomBytes, createHmac } from "crypto"; import { prisma } from "../../lib/prisma.js"; import JWT from "jsonwebtoken"; import { Errors } from "../errors.js"; +import type { User } from "../../../generated/prisma/client.js"; export interface CreateUserData { firstName: string; @@ -20,6 +21,7 @@ export interface LoginPayload { export interface TokenPair { accessToken: string; refreshToken: string; + user: User; } export interface UpdateUserProfileInput { @@ -426,11 +428,7 @@ export const userService = { return { accessToken, refreshToken, - user: { - id: user.id, - email: user.email, - firstName: user.firstName, - }, + user, }; }, @@ -468,6 +466,7 @@ export const userService = { return { accessToken, refreshToken: newRefreshToken, + user: storedToken.user, }; }, diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index 9ebb8d1..bb353e8 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -13,6 +13,7 @@ interface MainLayoutProps { export function MainLayout({ children, showAuthCard = true }: MainLayoutProps) { const isAuthenticated = useAuthStore(state => state.isAuthenticated); + const isLoading = useAuthStore(state => state.isLoading); return (
@@ -34,7 +35,7 @@ export function MainLayout({ children, showAuthCard = true }: MainLayoutProps) { !showAuthCard && "hidden" )}> {/* Only render if we want to show it */} - {!isAuthenticated && showAuthCard && ( + {!isAuthenticated && !isLoading && showAuthCard && (
From e8377aac07c727056f4aae824e60198f164adf89 Mon Sep 17 00:00:00 2001 From: shahadot786 Date: Wed, 7 Jan 2026 17:02:53 +0600 Subject: [PATCH 05/27] chore: Remove debug console logs from user resolvers and service. --- backend/src/graphql/user/user.resolvers.ts | 8 +++++--- backend/src/graphql/user/user.service.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/src/graphql/user/user.resolvers.ts b/backend/src/graphql/user/user.resolvers.ts index 29d87a4..336f990 100644 --- a/backend/src/graphql/user/user.resolvers.ts +++ b/backend/src/graphql/user/user.resolvers.ts @@ -144,8 +144,11 @@ export const userResolvers = { throw Errors.unauthenticated("No refresh token provided"); } - const { accessToken, refreshToken: newRefreshToken, user } = - await userService.refreshAccessToken(refreshTokenValue); + const { + accessToken, + refreshToken: newRefreshToken, + user, + } = await userService.refreshAccessToken(refreshTokenValue); // Set new cookies setAuthCookies(context.res, accessToken, newRefreshToken); @@ -158,7 +161,6 @@ export const userResolvers = { // Protected: Logout logout: async (_: unknown, __: unknown, context: GraphQLContext) => { - console.log(context, "context"); const refreshTokenValue = context.req.cookies?.[REFRESH_TOKEN_COOKIE]; if (refreshTokenValue) { diff --git a/backend/src/graphql/user/user.service.ts b/backend/src/graphql/user/user.service.ts index da9c01c..014cdd4 100644 --- a/backend/src/graphql/user/user.service.ts +++ b/backend/src/graphql/user/user.service.ts @@ -518,7 +518,7 @@ export const userService = { }); // In a real app, you would send this token via email. - console.log(`[PASS_RESET] Token for ${email}: ${token}`); + // console.log(`[PASS_RESET] Token for ${email}: ${token}`); return true; }, From a601bdf81a9d449746c38738800f4f07f67f6ac3 Mon Sep 17 00:00:00 2001 From: shahadot786 Date: Wed, 7 Jan 2026 18:03:05 +0600 Subject: [PATCH 06/27] refactor: add extensive logging to authentication and token refresh flows for improved debugging --- backend/src/graphql/context.ts | 10 +- backend/src/graphql/user/user.resolvers.ts | 5 + frontend/src/lib/apollo-client.tsx | 169 ++++++++++++--------- 3 files changed, 114 insertions(+), 70 deletions(-) diff --git a/backend/src/graphql/context.ts b/backend/src/graphql/context.ts index 10bb651..954f077 100644 --- a/backend/src/graphql/context.ts +++ b/backend/src/graphql/context.ts @@ -35,8 +35,14 @@ export async function createContext( // Get access token from cookie const token = req.cookies?.[ACCESS_TOKEN_COOKIE]; + console.log(`[AUTH] createContext - Path: ${req.path}, Op: ${req.body?.operationName || 'unknown'}`); + console.log(`[AUTH] Access Token present: ${!!token}`); + // If no token or JWT_SECRET, return unauthenticated context if (!token || !JWT_SECRET) { + if (!token && req.cookies?.[REFRESH_TOKEN_COOKIE]) { + console.log(`[AUTH] Access token missing but Refresh Token IS present.`); + } return { user: null, req, res }; } @@ -46,10 +52,12 @@ export async function createContext( // Fetch user from database const user = await userService.getUserById(decoded.id); + console.log(`[AUTH] User authenticated: ${user?.username || 'user not found in DB'}`); return { user, req, res }; - } catch { + } catch (err: any) { // Token invalid or expired + console.log(`[AUTH] JWT Verification failed: ${err.message}`); return { user: null, req, res }; } } diff --git a/backend/src/graphql/user/user.resolvers.ts b/backend/src/graphql/user/user.resolvers.ts index 336f990..a8637a6 100644 --- a/backend/src/graphql/user/user.resolvers.ts +++ b/backend/src/graphql/user/user.resolvers.ts @@ -140,7 +140,10 @@ export const userResolvers = { refreshToken: async (_: unknown, __: unknown, context: GraphQLContext) => { const refreshTokenValue = context.req.cookies?.[REFRESH_TOKEN_COOKIE]; + console.log(`[AUTH] refreshToken mutation triggered. Cookie present: ${!!refreshTokenValue}`); + if (!refreshTokenValue) { + console.log(`[AUTH] refreshToken mutation failed - NO cookie`); throw Errors.unauthenticated("No refresh token provided"); } @@ -150,6 +153,8 @@ export const userResolvers = { user, } = await userService.refreshAccessToken(refreshTokenValue); + console.log(`[AUTH] refreshToken mutation success for user: ${user.username}`); + // Set new cookies setAuthCookies(context.res, accessToken, newRefreshToken); diff --git a/frontend/src/lib/apollo-client.tsx b/frontend/src/lib/apollo-client.tsx index f2789f2..52d1673 100644 --- a/frontend/src/lib/apollo-client.tsx +++ b/frontend/src/lib/apollo-client.tsx @@ -6,8 +6,10 @@ import { HttpLink, ApolloLink, Observable, + FetchResult, } from "@apollo/client/core"; import { ErrorLink } from "@apollo/client/link/error"; +import { CombinedGraphQLErrors } from "@apollo/client/errors"; import { ApolloProvider as BaseApolloProvider } from "@apollo/client/react"; import { ReactNode, useEffect, useRef } from "react"; import { useAuthStore } from "@/stores/auth"; @@ -37,81 +39,107 @@ const httpLink = new HttpLink({ credentials: "include", }); -// Type for error handler params (Apollo v4 compatible) -interface ErrorHandlerParams { - graphQLErrors?: ReadonlyArray<{ - message: string; - extensions?: Record; - }>; - operation: { - operationName: string; - [key: string]: unknown; - }; - forward: (op: unknown) => Observable; -} - -// Error Link with token refresh -const errorLink = new ErrorLink((params: unknown) => { - const { graphQLErrors, operation, forward } = params as ErrorHandlerParams; - - if (!graphQLErrors) return; - - for (const err of graphQLErrors) { - // Check if it's an auth error - const isAuthError = - err.extensions?.code === "UNAUTHENTICATED" || - err.message.includes("Not authenticated") || - err.message.includes("Token expired"); - if (!isAuthError) continue; - // Don't retry refresh token or logout mutations - const operationName = operation.operationName; - if (operationName === "RefreshToken" || operationName === "Logout") { - return; - } - - return new Observable((observer) => { - // If already refreshing, wait for it - if (isRefreshing) { - subscribeTokenRefresh((success) => { - if (success) { - forward(operation).subscribe(observer); - } else { - observer.error(err); - } - }); +// Error Link with token refresh +const errorLink = new ErrorLink(({ error, operation, forward }) => { + if (CombinedGraphQLErrors.is(error)) { + for (const err of error.errors) { + console.log(`[AUTH] GraphQL Error: ${err.message}`, { + code: err.extensions?.code, + }); + + // Check if it's an auth error + const isAuthError = + err.extensions?.code === "UNAUTHENTICATED" || + err.message.includes("Not authenticated") || + err.message.includes("Token expired"); + + if (!isAuthError) continue; + + console.log( + `[AUTH] Auth error detected in operation: ${operation.operationName}. Triggering refresh flow...` + ); + + // Don't retry refresh token or logout mutations + const operationName = operation.operationName; + if (operationName === "RefreshToken" || operationName === "Logout") { + console.log( + `[AUTH] Error occurred during RefreshToken or Logout. Not retrying.` + ); return; } - isRefreshing = true; - useAuthStore.getState().setRefreshing(true); - - // Try to refresh - apolloClient - .mutate<{ refreshToken: { accessToken: string; user: User } }>({ - mutation: REFRESH_TOKEN_MUTATION, - }) - .then(({ data }) => { - if (data?.refreshToken?.user) { - useAuthStore.getState().login(data.refreshToken.user); + return new Observable((observer) => { + // If already refreshing, wait for it + if (isRefreshing) { + console.log( + `[AUTH] Already refreshing, subscribing operation: ${operation.operationName}` + ); + subscribeTokenRefresh((success) => { + if (success) { + console.log( + `[AUTH] Refresh success, retrying operation: ${operation.operationName}` + ); + forward(operation).subscribe(observer); + } else { + console.log( + `[AUTH] Refresh failed, erroring operation: ${operation.operationName}` + ); + observer.error(err); + } + }); + return; + } + + console.log(`[AUTH] Starting token refresh mutation...`); + isRefreshing = true; + useAuthStore.getState().setRefreshing(true); + + // Try to refresh + apolloClient + .mutate<{ refreshToken: { accessToken: string; user: User } }>({ + mutation: REFRESH_TOKEN_MUTATION, + context: { + headers: { + "x-apollo-operation-name": "RefreshToken", + }, + }, + }) + .then(({ data }) => { + console.log( + `[AUTH] Token refresh mutation successful. User: ${data?.refreshToken?.user?.username}` + ); + if (data?.refreshToken?.user) { + useAuthStore.getState().login(data.refreshToken.user); + isRefreshing = false; + useAuthStore.getState().setRefreshing(false); + onRefreshComplete(true); + + console.log( + `[AUTH] Retrying original operation: ${operation.operationName}` + ); + // Retry the original request + forward(operation).subscribe(observer); + } else { + console.log(`[AUTH] Token refresh mutation returned NO user data.`); + throw new Error("Refresh failed"); + } + }) + .catch((refreshError) => { + console.error(`[AUTH] Token refresh mutation FAILED:`, refreshError); isRefreshing = false; useAuthStore.getState().setRefreshing(false); - onRefreshComplete(true); - // Retry the original request - forward(operation).subscribe(observer); - } else { - throw new Error("Refresh failed"); - } - }) - .catch(() => { - isRefreshing = false; - useAuthStore.getState().setRefreshing(false); - useAuthStore.getState().logout(); - onRefreshComplete(false); - observer.error(err); - }); - }); + useAuthStore.getState().logout(); + onRefreshComplete(false); + observer.error(err); + }); + }); + } + } + + if (error && !CombinedGraphQLErrors.is(error)) { + console.error(`[AUTH] Network error:`, error); } }); @@ -183,6 +211,7 @@ export function ApolloProvider({ children }: { children: ReactNode }) { hasInitialized.current = true; async function initAuth() { + console.log(`[AUTH] initAuth starting...`); try { const { data } = await apolloClient.query<{ getCurrentLoggedInUser: User | null; @@ -190,8 +219,10 @@ export function ApolloProvider({ children }: { children: ReactNode }) { query: GET_CURRENT_USER, fetchPolicy: "network-only", }); + console.log(`[AUTH] initAuth success. User: ${data?.getCurrentLoggedInUser?.username || 'null'}`); setUser(data?.getCurrentLoggedInUser || null); - } catch { + } catch (err: unknown) { + console.error(`[AUTH] initAuth failed:`, err instanceof Error ? err.message : String(err)); // Not authenticated, that's fine setUser(null); } From ad5ce99636170c4000c2a949cfa9a89f31a4cd92 Mon Sep 17 00:00:00 2001 From: shahadot786 Date: Wed, 7 Jan 2026 18:41:11 +0600 Subject: [PATCH 07/27] chore: Reduce verbose console logging in the authentication and token refresh flow. --- backend/src/graphql/context.ts | 7 +--- backend/src/graphql/user/user.resolvers.ts | 5 --- frontend/src/lib/apollo-client.tsx | 44 ++++------------------ 3 files changed, 9 insertions(+), 47 deletions(-) diff --git a/backend/src/graphql/context.ts b/backend/src/graphql/context.ts index 954f077..d0f9862 100644 --- a/backend/src/graphql/context.ts +++ b/backend/src/graphql/context.ts @@ -35,13 +35,10 @@ export async function createContext( // Get access token from cookie const token = req.cookies?.[ACCESS_TOKEN_COOKIE]; - console.log(`[AUTH] createContext - Path: ${req.path}, Op: ${req.body?.operationName || 'unknown'}`); - console.log(`[AUTH] Access Token present: ${!!token}`); - // If no token or JWT_SECRET, return unauthenticated context if (!token || !JWT_SECRET) { if (!token && req.cookies?.[REFRESH_TOKEN_COOKIE]) { - console.log(`[AUTH] Access token missing but Refresh Token IS present.`); + console.warn(`[AUTH] Access token missing but Refresh Token IS present.`); } return { user: null, req, res }; } @@ -52,12 +49,10 @@ export async function createContext( // Fetch user from database const user = await userService.getUserById(decoded.id); - console.log(`[AUTH] User authenticated: ${user?.username || 'user not found in DB'}`); return { user, req, res }; } catch (err: any) { // Token invalid or expired - console.log(`[AUTH] JWT Verification failed: ${err.message}`); return { user: null, req, res }; } } diff --git a/backend/src/graphql/user/user.resolvers.ts b/backend/src/graphql/user/user.resolvers.ts index a8637a6..336f990 100644 --- a/backend/src/graphql/user/user.resolvers.ts +++ b/backend/src/graphql/user/user.resolvers.ts @@ -140,10 +140,7 @@ export const userResolvers = { refreshToken: async (_: unknown, __: unknown, context: GraphQLContext) => { const refreshTokenValue = context.req.cookies?.[REFRESH_TOKEN_COOKIE]; - console.log(`[AUTH] refreshToken mutation triggered. Cookie present: ${!!refreshTokenValue}`); - if (!refreshTokenValue) { - console.log(`[AUTH] refreshToken mutation failed - NO cookie`); throw Errors.unauthenticated("No refresh token provided"); } @@ -153,8 +150,6 @@ export const userResolvers = { user, } = await userService.refreshAccessToken(refreshTokenValue); - console.log(`[AUTH] refreshToken mutation success for user: ${user.username}`); - // Set new cookies setAuthCookies(context.res, accessToken, newRefreshToken); diff --git a/frontend/src/lib/apollo-client.tsx b/frontend/src/lib/apollo-client.tsx index 52d1673..231a0d6 100644 --- a/frontend/src/lib/apollo-client.tsx +++ b/frontend/src/lib/apollo-client.tsx @@ -39,16 +39,10 @@ const httpLink = new HttpLink({ credentials: "include", }); - - // Error Link with token refresh const errorLink = new ErrorLink(({ error, operation, forward }) => { if (CombinedGraphQLErrors.is(error)) { for (const err of error.errors) { - console.log(`[AUTH] GraphQL Error: ${err.message}`, { - code: err.extensions?.code, - }); - // Check if it's an auth error const isAuthError = err.extensions?.code === "UNAUTHENTICATED" || @@ -57,42 +51,24 @@ const errorLink = new ErrorLink(({ error, operation, forward }) => { if (!isAuthError) continue; - console.log( - `[AUTH] Auth error detected in operation: ${operation.operationName}. Triggering refresh flow...` - ); - // Don't retry refresh token or logout mutations const operationName = operation.operationName; if (operationName === "RefreshToken" || operationName === "Logout") { - console.log( - `[AUTH] Error occurred during RefreshToken or Logout. Not retrying.` - ); return; } return new Observable((observer) => { // If already refreshing, wait for it if (isRefreshing) { - console.log( - `[AUTH] Already refreshing, subscribing operation: ${operation.operationName}` - ); subscribeTokenRefresh((success) => { if (success) { - console.log( - `[AUTH] Refresh success, retrying operation: ${operation.operationName}` - ); forward(operation).subscribe(observer); } else { - console.log( - `[AUTH] Refresh failed, erroring operation: ${operation.operationName}` - ); observer.error(err); } }); return; } - - console.log(`[AUTH] Starting token refresh mutation...`); isRefreshing = true; useAuthStore.getState().setRefreshing(true); @@ -107,27 +83,23 @@ const errorLink = new ErrorLink(({ error, operation, forward }) => { }, }) .then(({ data }) => { - console.log( - `[AUTH] Token refresh mutation successful. User: ${data?.refreshToken?.user?.username}` - ); if (data?.refreshToken?.user) { useAuthStore.getState().login(data.refreshToken.user); isRefreshing = false; useAuthStore.getState().setRefreshing(false); onRefreshComplete(true); - console.log( - `[AUTH] Retrying original operation: ${operation.operationName}` - ); // Retry the original request forward(operation).subscribe(observer); } else { - console.log(`[AUTH] Token refresh mutation returned NO user data.`); throw new Error("Refresh failed"); } }) .catch((refreshError) => { - console.error(`[AUTH] Token refresh mutation FAILED:`, refreshError); + console.error( + `[AUTH] Token refresh mutation FAILED:`, + refreshError + ); isRefreshing = false; useAuthStore.getState().setRefreshing(false); useAuthStore.getState().logout(); @@ -139,7 +111,6 @@ const errorLink = new ErrorLink(({ error, operation, forward }) => { } if (error && !CombinedGraphQLErrors.is(error)) { - console.error(`[AUTH] Network error:`, error); } }); @@ -211,7 +182,6 @@ export function ApolloProvider({ children }: { children: ReactNode }) { hasInitialized.current = true; async function initAuth() { - console.log(`[AUTH] initAuth starting...`); try { const { data } = await apolloClient.query<{ getCurrentLoggedInUser: User | null; @@ -219,10 +189,12 @@ export function ApolloProvider({ children }: { children: ReactNode }) { query: GET_CURRENT_USER, fetchPolicy: "network-only", }); - console.log(`[AUTH] initAuth success. User: ${data?.getCurrentLoggedInUser?.username || 'null'}`); setUser(data?.getCurrentLoggedInUser || null); } catch (err: unknown) { - console.error(`[AUTH] initAuth failed:`, err instanceof Error ? err.message : String(err)); + console.error( + `[AUTH] initAuth failed:`, + err instanceof Error ? err.message : String(err) + ); // Not authenticated, that's fine setUser(null); } From b11ee65f02682bf79fd317754c1c9fce2d4d0f7b Mon Sep 17 00:00:00 2001 From: shahadot786 Date: Wed, 7 Jan 2026 19:10:21 +0600 Subject: [PATCH 08/27] feat: Implement dynamic home/trending feed based on user authentication, refine post sorting, and enhance Apollo cache merging for pagination. --- backend/src/graphql/post/post.service.ts | 70 +++++++++++------------- frontend/src/app/page.tsx | 21 ++++--- frontend/src/lib/apollo-client.tsx | 58 ++++++++++++++++++-- 3 files changed, 98 insertions(+), 51 deletions(-) diff --git a/backend/src/graphql/post/post.service.ts b/backend/src/graphql/post/post.service.ts index 96b5482..5c80bca 100644 --- a/backend/src/graphql/post/post.service.ts +++ b/backend/src/graphql/post/post.service.ts @@ -113,14 +113,14 @@ export const postService = { ...postData, ...(media && media.length > 0 ? { - media: { - create: media.map((m, index) => ({ - mediaType: m.mediaType, - mediaUrl: m.mediaUrl, - position: m.position ?? index, - })), - }, - } + media: { + create: media.map((m, index) => ({ + mediaType: m.mediaType, + mediaUrl: m.mediaUrl, + position: m.position ?? index, + })), + }, + } : {}), }; @@ -301,13 +301,13 @@ export const postService = { if (post.author.is_private && post.authorId !== currentUserId) { const isFollowing = currentUserId ? await prisma.follow.findUnique({ - where: { - followerId_followingId: { - followerId: currentUserId, - followingId: post.authorId, - }, + where: { + followerId_followingId: { + followerId: currentUserId, + followingId: post.authorId, }, - }) + }, + }) : null; if (!isFollowing) { @@ -385,13 +385,13 @@ export const postService = { if (targetUser.is_private && userId !== currentUserId) { const isFollowing = currentUserId ? await prisma.follow.findUnique({ - where: { - followerId_followingId: { - followerId: currentUserId, - followingId: userId, - }, + where: { + followerId_followingId: { + followerId: currentUserId, + followingId: userId, }, - }) + }, + }) : null; if (!isFollowing) { @@ -440,21 +440,10 @@ export const postService = { // ===================== // GET HOME FEED (posts from followed users) // ===================== - async getHomeFeed(userId: string, pagination: PaginationInput = {}) { + async getHomeFeed(_userId: string, pagination: PaginationInput = {}) { const { first = 20, after } = pagination; - // Get IDs of users the current user follows - const following = await prisma.follow.findMany({ - where: { followerId: userId }, - select: { followingId: true }, - }); - - const followingIds = following.map((f) => f.followingId); - // Include own posts in feed - followingIds.push(userId); - const whereClause: Prisma.PostWhereInput = { - authorId: { in: followingIds }, parentPostId: null, // Only top-level posts visibility: { in: ["PUBLIC", "FOLLOWERS"] }, }; @@ -528,11 +517,18 @@ export const postService = { }, }); - // Sort by engagement (likes + replies) - posts.sort( - (a, b) => - b._count.likes + b._count.replies - (a._count.likes + a._count.replies) - ); + // Sort by engagement (likes + replies), fallback to recency + posts.sort((a, b) => { + const scoreA = (a._count?.likes || 0) + (a._count?.replies || 0); + const scoreB = (b._count?.likes || 0) + (b._count?.replies || 0); + + if (scoreB !== scoreA) { + return scoreB - scoreA; + } + + // If scores are equal, sort by createdAt desc + return b.createdAt.getTime() - a.createdAt.getTime(); + }); const hasNextPage = posts.length > first; const edges = posts.slice(0, first).map((post) => ({ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0297c69..c0756ab 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -5,8 +5,8 @@ import { MainLayout } from "@/components/layout/MainLayout"; import { Header } from "@/components/layout/Header"; import { PostCard } from "@/components/post/PostCard"; import { PostSkeleton } from "@/components/ui/Loading"; -// import { useAuthStore } from "@/stores/auth"; -import { GET_TRENDING_POSTS } from "@/graphql/queries/post"; +import { useAuthStore } from "@/stores/auth"; +import { GET_TRENDING_POSTS, GET_HOME_FEED } from "@/graphql/queries/post"; import type { PostConnection } from "@/types"; interface TrendingPostsData { @@ -14,20 +14,25 @@ interface TrendingPostsData { } export default function HomePage() { - // const isAuthenticated = useAuthStore(state => state.isAuthenticated); - // const isLoading = useAuthStore(state => state.isLoading); + const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated); + const isLoading = useAuthStore((state: any) => state.isLoading); + + const query = isAuthenticated ? GET_HOME_FEED : GET_TRENDING_POSTS; + const queryField = isAuthenticated ? "getHomeFeed" : "getTrendingPosts"; const { data, loading: postsLoading, fetchMore, - } = useQuery(GET_TRENDING_POSTS, { + } = useQuery(query, { variables: { first: 20 }, notifyOnNetworkStatusChange: true, + // skip: isLoading, // Only query if we know the auth state }); - const posts = data?.getTrendingPosts?.edges || []; - const pageInfo = data?.getTrendingPosts?.pageInfo; + const response = data?.[queryField]; + const posts = response?.edges || []; + const pageInfo = response?.pageInfo; const handleLoadMore = () => { if (!pageInfo?.hasNextPage || postsLoading) return; @@ -63,7 +68,7 @@ export default function HomePage() { ) : ( // Posts list <> - {posts.map(({ node: post }) => ( + {posts.map(({ node: post }: { node: any }) => ( ))} diff --git a/frontend/src/lib/apollo-client.tsx b/frontend/src/lib/apollo-client.tsx index 231a0d6..45c0b25 100644 --- a/frontend/src/lib/apollo-client.tsx +++ b/frontend/src/lib/apollo-client.tsx @@ -123,9 +123,8 @@ export const apolloClient = new ApolloClient({ fields: { getHomeFeed: { keyArgs: false, - merge(existing, incoming) { - if (!existing) return incoming; - // Deduplicate by cursor + merge(existing, incoming, { args }) { + if (!existing || !args?.after) return incoming; const existingCursors = new Set( existing.edges.map((e: { cursor: string }) => e.cursor) ); @@ -140,9 +139,56 @@ export const apolloClient = new ApolloClient({ }, getTrendingPosts: { keyArgs: false, - merge(existing, incoming) { - if (!existing) return incoming; - // Deduplicate by cursor + merge(existing, incoming, { args }) { + if (!existing || !args?.after) return incoming; + const existingCursors = new Set( + existing.edges.map((e: { cursor: string }) => e.cursor) + ); + const newEdges = incoming.edges.filter( + (e: { cursor: string }) => !existingCursors.has(e.cursor) + ); + return { + ...incoming, + edges: [...existing.edges, ...newEdges], + }; + }, + }, + getUserPosts: { + keyArgs: ["userId"], + merge(existing, incoming, { args }) { + if (!existing || !args?.after) return incoming; + const existingCursors = new Set( + existing.edges.map((e: { cursor: string }) => e.cursor) + ); + const newEdges = incoming.edges.filter( + (e: { cursor: string }) => !existingCursors.has(e.cursor) + ); + return { + ...incoming, + edges: [...existing.edges, ...newEdges], + }; + }, + }, + getPostReplies: { + keyArgs: ["postId"], + merge(existing, incoming, { args }) { + if (!existing || !args?.after) return incoming; + const existingCursors = new Set( + existing.edges.map((e: { cursor: string }) => e.cursor) + ); + const newEdges = incoming.edges.filter( + (e: { cursor: string }) => !existingCursors.has(e.cursor) + ); + return { + ...incoming, + edges: [...existing.edges, ...newEdges], + }; + }, + }, + getMyBookmarks: { + keyArgs: false, + merge(existing, incoming, { args }) { + if (!existing || !args?.after) return incoming; const existingCursors = new Set( existing.edges.map((e: { cursor: string }) => e.cursor) ); From a40333d87d44c3e60b9b9a1f6e5ceef71e316241 Mon Sep 17 00:00:00 2001 From: shahadot786 Date: Wed, 7 Jan 2026 21:57:48 +0600 Subject: [PATCH 09/27] feat: Add chronological public feed for guests and set it as the default homepage query. --- backend/src/graphql/post/post.resolvers.ts | 11 ++++ backend/src/graphql/post/post.service.ts | 58 +++++++++++++++++++++- backend/src/graphql/post/post.typeDefs.ts | 3 ++ frontend/src/app/page.tsx | 7 +-- frontend/src/graphql/queries/post.ts | 18 +++++++ frontend/src/lib/apollo-client.tsx | 16 ++++++ 6 files changed, 108 insertions(+), 5 deletions(-) diff --git a/backend/src/graphql/post/post.resolvers.ts b/backend/src/graphql/post/post.resolvers.ts index 6b238c8..da3dfe2 100644 --- a/backend/src/graphql/post/post.resolvers.ts +++ b/backend/src/graphql/post/post.resolvers.ts @@ -141,6 +141,17 @@ export const postResolvers = { }); }, + // Get public feed (PUBLIC - chronological order for guests) + getPublicFeed: async ( + _: unknown, + args: { first?: number; after?: string } + ) => { + return postService.getPublicFeed({ + first: args.first ?? 20, + after: args.after, + }); + }, + // Get posts by hashtag (PUBLIC - guests can view) getPostsByHashtag: async ( _: unknown, diff --git a/backend/src/graphql/post/post.service.ts b/backend/src/graphql/post/post.service.ts index 5c80bca..617fbf6 100644 --- a/backend/src/graphql/post/post.service.ts +++ b/backend/src/graphql/post/post.service.ts @@ -481,6 +481,50 @@ export const postService = { }; }, + // ===================== + // GET PUBLIC FEED (public posts, sorted by createdAt for guests) + // ===================== + async getPublicFeed(pagination: PaginationInput = {}) { + const { first = 20, after } = pagination; + + const whereClause: Prisma.PostWhereInput = { + parentPostId: null, // Only top-level posts + visibility: "PUBLIC", + }; + + if (after) { + const { createdAt, id } = decodeCursor(after); + whereClause.OR = [ + { createdAt: { lt: createdAt } }, + { createdAt, id: { lt: id } }, + ]; + } + + const posts = await prisma.post.findMany({ + where: whereClause, + take: first + 1, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + include: { + author: true, + media: { orderBy: { position: "asc" } }, + }, + }); + + const hasNextPage = posts.length > first; + const edges = posts.slice(0, first).map((post) => ({ + cursor: encodeCursor(post.createdAt, post.id), + node: post, + })); + + return { + edges, + pageInfo: { + hasNextPage, + endCursor: getEndCursor(edges), + }, + }; + }, + // ===================== // GET TRENDING POSTS (public posts, sorted by engagement) // ===================== @@ -518,16 +562,26 @@ export const postService = { }); // Sort by engagement (likes + replies), fallback to recency + // Primary: engagement score (higher first) + // Secondary: createdAt (newer first - higher timestamp first) + // Tertiary: id (for consistent ordering) posts.sort((a, b) => { const scoreA = (a._count?.likes || 0) + (a._count?.replies || 0); const scoreB = (b._count?.likes || 0) + (b._count?.replies || 0); + // Sort by engagement score (higher first) if (scoreB !== scoreA) { return scoreB - scoreA; } - // If scores are equal, sort by createdAt desc - return b.createdAt.getTime() - a.createdAt.getTime(); + // If scores are equal, sort by createdAt DESC (newer posts first) + const timeDiff = b.createdAt.getTime() - a.createdAt.getTime(); + if (timeDiff !== 0) { + return timeDiff; + } + + // Tertiary sort by id for consistent ordering + return b.id.localeCompare(a.id); }); const hasNextPage = posts.length > first; diff --git a/backend/src/graphql/post/post.typeDefs.ts b/backend/src/graphql/post/post.typeDefs.ts index 226e639..fbe71e6 100644 --- a/backend/src/graphql/post/post.typeDefs.ts +++ b/backend/src/graphql/post/post.typeDefs.ts @@ -137,6 +137,9 @@ export const postTypeDefs = /* GraphQL */ ` # Explore / trending (PUBLIC - guests can view) getTrendingPosts(first: Int = 20, after: String): PostConnection! + # Public feed (PUBLIC - chronological order for guests) + getPublicFeed(first: Int = 20, after: String): PostConnection! + # Hashtag search (PUBLIC - guests can view) getPostsByHashtag(tag: String!, first: Int = 20, after: String): PostConnection! diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index c0756ab..4756ce5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -6,7 +6,7 @@ import { Header } from "@/components/layout/Header"; import { PostCard } from "@/components/post/PostCard"; import { PostSkeleton } from "@/components/ui/Loading"; import { useAuthStore } from "@/stores/auth"; -import { GET_TRENDING_POSTS, GET_HOME_FEED } from "@/graphql/queries/post"; +import { GET_PUBLIC_FEED, GET_HOME_FEED } from "@/graphql/queries/post"; import type { PostConnection } from "@/types"; interface TrendingPostsData { @@ -17,8 +17,9 @@ export default function HomePage() { const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated); const isLoading = useAuthStore((state: any) => state.isLoading); - const query = isAuthenticated ? GET_HOME_FEED : GET_TRENDING_POSTS; - const queryField = isAuthenticated ? "getHomeFeed" : "getTrendingPosts"; + // Use getHomeFeed for authenticated users, getPublicFeed (chronological) for guests + const query = isAuthenticated ? GET_HOME_FEED : GET_PUBLIC_FEED; + const queryField = isAuthenticated ? "getHomeFeed" : "getPublicFeed"; const { data, diff --git a/frontend/src/graphql/queries/post.ts b/frontend/src/graphql/queries/post.ts index c913d14..b9cd6d6 100644 --- a/frontend/src/graphql/queries/post.ts +++ b/frontend/src/graphql/queries/post.ts @@ -86,6 +86,24 @@ export const GET_TRENDING_POSTS = gql` ${PAGE_INFO_FRAGMENT} `; +export const GET_PUBLIC_FEED = gql` + query GetPublicFeed($first: Int, $after: String) { + getPublicFeed(first: $first, after: $after) { + edges { + cursor + node { + ...PostFields + } + } + pageInfo { + ...PageInfoFields + } + } + } + ${POST_FRAGMENT} + ${PAGE_INFO_FRAGMENT} +`; + export const GET_POSTS_BY_HASHTAG = gql` query GetPostsByHashtag($tag: String!, $first: Int, $after: String) { getPostsByHashtag(tag: $tag, first: $first, after: $after) { diff --git a/frontend/src/lib/apollo-client.tsx b/frontend/src/lib/apollo-client.tsx index 45c0b25..4f917b9 100644 --- a/frontend/src/lib/apollo-client.tsx +++ b/frontend/src/lib/apollo-client.tsx @@ -153,6 +153,22 @@ export const apolloClient = new ApolloClient({ }; }, }, + getPublicFeed: { + keyArgs: false, + merge(existing, incoming, { args }) { + if (!existing || !args?.after) return incoming; + const existingCursors = new Set( + existing.edges.map((e: { cursor: string }) => e.cursor) + ); + const newEdges = incoming.edges.filter( + (e: { cursor: string }) => !existingCursors.has(e.cursor) + ); + return { + ...incoming, + edges: [...existing.edges, ...newEdges], + }; + }, + }, getUserPosts: { keyArgs: ["userId"], merge(existing, incoming, { args }) { From b31ad99c841f0d9961731193ff884fb4988a740d Mon Sep 17 00:00:00 2001 From: shahadot786 Date: Wed, 7 Jan 2026 22:06:24 +0600 Subject: [PATCH 10/27] feat: Implement post reply modal, global toast notifications, and enhance post actions with reply, share, and bookmark functionalities. --- frontend/src/components/post/PostActions.tsx | 85 +++++++++- frontend/src/components/post/ReplyModal.tsx | 148 ++++++++++++++++++ .../src/components/providers/Providers.tsx | 4 + frontend/src/components/ui/Toast.tsx | 25 +++ frontend/src/stores/ui.ts | 25 +++ 5 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/post/ReplyModal.tsx create mode 100644 frontend/src/components/ui/Toast.tsx diff --git a/frontend/src/components/post/PostActions.tsx b/frontend/src/components/post/PostActions.tsx index 885c13e..de70180 100644 --- a/frontend/src/components/post/PostActions.tsx +++ b/frontend/src/components/post/PostActions.tsx @@ -53,7 +53,7 @@ const BookmarkIcon = ({ filled }: { filled: boolean }) => ( export function PostActions({ post, onUpdate }: PostActionsProps) { const isAuthenticated = useAuthStore(state => state.isAuthenticated); - const openLoginModal = useUIStore(state => state.openLoginModal); + const { openLoginModal, openReplyModal, showToast } = useUIStore(); const [isLiked, setIsLiked] = useState(post.isLiked); const [likesCount, setLikesCount] = useState(post.likesCount); @@ -66,6 +66,7 @@ export function PostActions({ post, onUpdate }: PostActionsProps) { const handleLike = async (e: React.MouseEvent) => { e.preventDefault(); + e.stopPropagation(); if (!isAuthenticated) { openLoginModal(); return; @@ -88,14 +89,77 @@ export function PostActions({ post, onUpdate }: PostActionsProps) { } }; + const handleComment = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!isAuthenticated) { + openLoginModal(); + return; + } + openReplyModal(post); + }; + + const handleRepost = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!isAuthenticated) { + openLoginModal(); + return; + } + // Repost functionality coming soon + showToast("Repost coming soon!"); + }; + + const handleShare = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const postUrl = `${window.location.origin}/post/${post.id}`; + + // Try native share API first (mobile) + if (navigator.share) { + try { + await navigator.share({ + title: `Post by @${post.author.username}`, + text: post.content || "", + url: postUrl, + }); + return; + } catch (err) { + // User cancelled or share failed, fall back to clipboard + } + } + + // Fall back to clipboard copy + try { + await navigator.clipboard.writeText(postUrl); + showToast("Link copied to clipboard!"); + } catch { + showToast("Failed to copy link"); + } + }; + const handleBookmark = async (e: React.MouseEvent) => { e.preventDefault(); - // Bookmarking might not be a primary action on the feed card in some versions, but included if requested. + e.stopPropagation(); if (!isAuthenticated) { openLoginModal(); return; } - // ... impl + + try { + if (isBookmarked) { + setIsBookmarked(false); + await unbookmarkPost({ variables: { postId: post.id } }); + showToast("Removed from bookmarks"); + } else { + setIsBookmarked(true); + await bookmarkPost({ variables: { postId: post.id } }); + showToast("Added to bookmarks"); + } + } catch { + setIsBookmarked(!isBookmarked); + } }; return ( @@ -114,7 +178,10 @@ export function PostActions({ post, onUpdate }: PostActionsProps) { )} - - - +
+ + {/* Original Post Preview */} +
+
+
+ + + + {replyToPost.author.firstName[0]} + {replyToPost.author.lastName?.[0]} + + + {/* Thread connector line */} +
+
+
+
+ + {replyToPost.author.username} + + + {formatRelativeTime(replyToPost.createdAt)} + +
+

+ {replyToPost.content} +

+

+ Replying to @{replyToPost.author.username} +

+
+
+
+ + {/* Reply Input */} +
+
+ + + + {user?.firstName?.[0] || "?"} + {user?.lastName?.[0] || ""} + + +