From fd1f2b101f53f143ae74f097d08897e3fa8e397e Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sun, 12 Nov 2023 23:24:33 -0500 Subject: [PATCH] Feat: add user header with follow info --- src/components/Account/UserAvatar.tsx | 2 +- .../BeerBreweryComments/CommentCardBody.tsx | 2 +- .../user-follows/useGetUsersFollowedByUser.ts | 64 ++++++++++++++++ .../user-follows/useGetUsersFollowingUser.ts | 64 ++++++++++++++++ src/pages/api/users/[id]/followers.ts | 70 ++++++++++++++++++ src/pages/api/users/[id]/following.ts | 70 ++++++++++++++++++ src/pages/users/[id].tsx | 73 +++++++++++-------- .../seed/create/createNewUserFollows.ts | 9 +-- .../UserFollows/getUsersFollowedByUser.ts | 27 +++++++ .../UserFollows/getUsersFollowingUser.ts | 27 +++++++ .../UserFollows/schema/FollowInfoSchema.ts | 9 +++ 11 files changed, 379 insertions(+), 38 deletions(-) create mode 100644 src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts create mode 100644 src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts create mode 100644 src/pages/api/users/[id]/followers.ts create mode 100644 src/pages/api/users/[id]/following.ts create mode 100644 src/services/UserFollows/getUsersFollowedByUser.ts create mode 100644 src/services/UserFollows/getUsersFollowingUser.ts create mode 100644 src/services/UserFollows/schema/FollowInfoSchema.ts diff --git a/src/components/Account/UserAvatar.tsx b/src/components/Account/UserAvatar.tsx index 42e837c..949ed03 100644 --- a/src/components/Account/UserAvatar.tsx +++ b/src/components/Account/UserAvatar.tsx @@ -19,7 +19,7 @@ const UserAvatar: FC = ({ user }) => { alt="user avatar" width={1000} height={1000} - className="h-full w-full" + className="h-full w-full object-cover mask mask-circle ring ring-primary ring-offset-base-100 ring-offset-2" /> ); }; diff --git a/src/components/BeerBreweryComments/CommentCardBody.tsx b/src/components/BeerBreweryComments/CommentCardBody.tsx index c874a7f..f4887dc 100644 --- a/src/components/BeerBreweryComments/CommentCardBody.tsx +++ b/src/components/BeerBreweryComments/CommentCardBody.tsx @@ -31,7 +31,7 @@ const CommentCardBody: FC = ({ return (
-
+
diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts new file mode 100644 index 0000000..a09e78a --- /dev/null +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts @@ -0,0 +1,64 @@ +import FollowInfoSchema from '@/services/UserFollows/schema/FollowInfoSchema'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWRInfinite from 'swr/infinite'; +import { z } from 'zod'; + +const useGetUsersFollowingUser = ({ + pageSize, + userId, +}: { + pageSize: number; + userId: string; +}) => { + const fetcher = async (url: string) => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.statusText); + } + + const json = await response.json(); + const count = response.headers.get('X-Total-Count'); + + const parsed = APIResponseValidationSchema.safeParse(json); + if (!parsed.success) { + throw new Error('API response validation failed'); + } + + const parsedPayload = z.array(FollowInfoSchema).safeParse(parsed.data.payload); + if (!parsedPayload.success) { + throw new Error('API response validation failed'); + } + + const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize); + + return { following: parsedPayload.data, pageCount, followingCount: count }; + }; + + const { data, error, isLoading, setSize, size } = useSWRInfinite( + (index) => + `/api/users/${userId}/following?page_num=${index + 1}&page_size=${pageSize}`, + fetcher, + { parallel: true }, + ); + + const following = data?.flatMap((d) => d.following) ?? []; + const followingCount = data?.[0].followingCount ?? 0; + + const pageCount = data?.[0].pageCount ?? 0; + const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined'; + const isAtEnd = !(size < data?.[0].pageCount!); + + return { + following, + followingCount, + pageCount, + size, + setSize, + isLoading, + isLoadingMore, + isAtEnd, + error: error as unknown, + }; +}; + +export default useGetUsersFollowingUser; diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts new file mode 100644 index 0000000..a9b4763 --- /dev/null +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts @@ -0,0 +1,64 @@ +import FollowInfoSchema from '@/services/UserFollows/schema/FollowInfoSchema'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWRInfinite from 'swr/infinite'; +import { z } from 'zod'; + +const useGetUsersFollowingUser = ({ + pageSize, + userId, +}: { + pageSize: number; + userId: string; +}) => { + const fetcher = async (url: string) => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.statusText); + } + + const json = await response.json(); + const count = response.headers.get('X-Total-Count'); + + const parsed = APIResponseValidationSchema.safeParse(json); + if (!parsed.success) { + throw new Error('API response validation failed'); + } + + const parsedPayload = z.array(FollowInfoSchema).safeParse(parsed.data.payload); + if (!parsedPayload.success) { + throw new Error('API response validation failed'); + } + + const pageCount = Math.ceil(parseInt(count as string, 10) / pageSize); + + return { followers: parsedPayload.data, pageCount, followerCount: count }; + }; + + const { data, error, isLoading, setSize, size } = useSWRInfinite( + (index) => + `/api/users/${userId}/followers?page_num=${index + 1}&page_size=${pageSize}`, + fetcher, + { parallel: true }, + ); + + const followers = data?.flatMap((d) => d.followers) ?? []; + const followerCount = data?.[0].followerCount ?? 0; + + const pageCount = data?.[0].pageCount ?? 0; + const isLoadingMore = size > 0 && data && typeof data[size - 1] === 'undefined'; + const isAtEnd = !(size < data?.[0].pageCount!); + + return { + followers, + followerCount, + pageCount, + size, + setSize, + isLoading, + isLoadingMore, + isAtEnd, + error: error as unknown, + }; +}; + +export default useGetUsersFollowingUser; diff --git a/src/pages/api/users/[id]/followers.ts b/src/pages/api/users/[id]/followers.ts new file mode 100644 index 0000000..2917802 --- /dev/null +++ b/src/pages/api/users/[id]/followers.ts @@ -0,0 +1,70 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import ServerError from '@/config/util/ServerError'; +import DBClient from '@/prisma/DBClient'; +import findUserById from '@/services/User/findUserById'; +import getUsersFollowingUser from '@/services/UserFollows/getUsersFollowingUser'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +import { NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest { + query: { id: string; page_size: string; page_num: string }; +} + +const router = createRouter< + GetUserFollowInfoRequest, + NextApiResponse> +>(); + +const getFollowingInfo = async ( + req: GetUserFollowInfoRequest, + res: NextApiResponse>, +) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { id, page_num, page_size } = req.query; + + const user = await findUserById(id); + if (!user) { + throw new ServerError('User not found', 404); + } + + const pageNum = parseInt(page_num, 10); + const pageSize = parseInt(page_size, 10); + + const following = await getUsersFollowingUser({ + userId: id, + pageNum, + pageSize, + }); + const followingCount = await DBClient.instance.userFollow.count({ + where: { following: { id } }, + }); + + res.setHeader('X-Total-Count', followingCount); + + res.json({ + message: 'Retrieved users that are followed by queried user', + payload: following, + success: true, + statusCode: 200, + }); +}; + +router.get( + validateRequest({ + querySchema: z.object({ + id: z.string().cuid(), + page_size: z.string().regex(/^\d+$/), + page_num: z.string().regex(/^\d+$/), + }), + }), + getFollowingInfo, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/api/users/[id]/following.ts b/src/pages/api/users/[id]/following.ts new file mode 100644 index 0000000..1c3fa61 --- /dev/null +++ b/src/pages/api/users/[id]/following.ts @@ -0,0 +1,70 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import ServerError from '@/config/util/ServerError'; +import DBClient from '@/prisma/DBClient'; +import findUserById from '@/services/User/findUserById'; +import getUsersFollowedByUser from '@/services/UserFollows/getUsersFollowedByUser'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +import { NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest { + query: { id: string; page_size: string; page_num: string }; +} + +const router = createRouter< + GetUserFollowInfoRequest, + NextApiResponse> +>(); + +const getFollowingInfo = async ( + req: GetUserFollowInfoRequest, + res: NextApiResponse>, +) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { id, page_num, page_size } = req.query; + + const user = await findUserById(id); + if (!user) { + throw new ServerError('User not found', 404); + } + + const pageNum = parseInt(page_num, 10); + const pageSize = parseInt(page_size, 10); + + const following = await getUsersFollowedByUser({ + userId: id, + pageNum, + pageSize, + }); + const followingCount = await DBClient.instance.userFollow.count({ + where: { follower: { id } }, + }); + + res.setHeader('X-Total-Count', followingCount); + + res.json({ + message: 'Retrieved users that are followed by queried user', + payload: following, + success: true, + statusCode: 200, + }); +}; + +router.get( + validateRequest({ + querySchema: z.object({ + id: z.string().cuid(), + page_size: z.string().regex(/^\d+$/), + page_num: z.string().regex(/^\d+$/), + }), + }), + getFollowingInfo, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/users/[id].tsx b/src/pages/users/[id].tsx index 811ec10..795acef 100644 --- a/src/pages/users/[id].tsx +++ b/src/pages/users/[id].tsx @@ -7,7 +7,9 @@ import { GetServerSideProps } from 'next'; import Head from 'next/head'; import { FC } from 'react'; import { z } from 'zod'; -import Image from 'next/image'; +import UserAvatar from '@/components/Account/UserAvatar'; +import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; +import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; interface UserInfoPageProps { user: z.infer; @@ -16,50 +18,63 @@ interface UserInfoPageProps { const UserHeader: FC<{ user: z.infer }> = ({ user }) => { const timeDistance = useTimeDistance(new Date(user.createdAt)); - return ( -
-
-
-
-
-

- {user.firstName} {user.lastName} -

+ const { followingCount } = useGetUsersFollowedByUser({ + userId: user.id, + pageSize: 10, + }); -

- joined{' '} - {timeDistance && ( - - {`${timeDistance} ago`} - - )} -

-
-
-
+ const { followerCount } = useGetUsersFollowingUser({ + userId: user.id, + pageSize: 10, + }); + + return ( +
+
+
+ +
+ +
+

{user.username}

+
+ +
+ {followingCount} Following + {followerCount} Followers +
+ + + joined{' '} + {timeDistance && ( + + {`${timeDistance} ago`} + + )} +
-
+ ); }; const UserInfoPage: FC = ({ user }) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const isDesktop = useMediaQuery('(min-width: 1024px)'); + const title = `${user.username} | The Biergarten App`; + return ( <> - {user ? `${user.firstName} ${user.lastName}` : 'User Info'} + {title} <>
- avatar
- - {isDesktop ? <> : <> }
diff --git a/src/prisma/seed/create/createNewUserFollows.ts b/src/prisma/seed/create/createNewUserFollows.ts index aa87f0b..6834c48 100644 --- a/src/prisma/seed/create/createNewUserFollows.ts +++ b/src/prisma/seed/create/createNewUserFollows.ts @@ -24,20 +24,15 @@ const createNewUserFollows = async ({ const randomUsers = users .filter((randomUser) => randomUser.id !== user.id) .sort(() => Math.random() - Math.random()) - .slice(0, 20); + .slice(0, 100); - // Get the user to follow the random users, and the random users to follow the user. + // Get the user to follow the random users const data = randomUsers.flatMap((randomUser) => [ { followerId: user.id, followingId: randomUser.id, followedAt: faker.date.between({ from: user.createdAt, to: new Date() }), }, - { - followerId: randomUser.id, - followingId: user.id, - followedAt: faker.date.between({ from: randomUser.createdAt, to: new Date() }), - }, ]); userFollows.push(...data); diff --git a/src/services/UserFollows/getUsersFollowedByUser.ts b/src/services/UserFollows/getUsersFollowedByUser.ts new file mode 100644 index 0000000..3f58d19 --- /dev/null +++ b/src/services/UserFollows/getUsersFollowedByUser.ts @@ -0,0 +1,27 @@ +import DBClient from '@/prisma/DBClient'; +import { z } from 'zod'; +import FollowInfoSchema from './schema/FollowInfoSchema'; + +interface GetFollowingInfoByUserIdArgs { + userId: string; + pageNum: number; + pageSize: number; +} +const getUsersFollowedByUser = async ({ + userId, + pageNum, + pageSize, +}: GetFollowingInfoByUserIdArgs): Promise[]> => { + const usersFollowedByQueriedUser = await DBClient.instance.userFollow.findMany({ + take: pageSize, + skip: (pageNum - 1) * pageSize, + where: { following: { id: userId } }, + select: { + follower: { select: { username: true, userAvatar: true, id: true } }, + }, + }); + + return usersFollowedByQueriedUser.map((u) => u.follower); +}; + +export default getUsersFollowedByUser; diff --git a/src/services/UserFollows/getUsersFollowingUser.ts b/src/services/UserFollows/getUsersFollowingUser.ts new file mode 100644 index 0000000..5379b7f --- /dev/null +++ b/src/services/UserFollows/getUsersFollowingUser.ts @@ -0,0 +1,27 @@ +import DBClient from '@/prisma/DBClient'; +import { z } from 'zod'; +import FollowInfoSchema from './schema/FollowInfoSchema'; + +interface GetFollowingInfoByUserIdArgs { + userId: string; + pageNum: number; + pageSize: number; +} +const getUsersFollowingUser = async ({ + userId, + pageNum, + pageSize, +}: GetFollowingInfoByUserIdArgs): Promise[]> => { + const usersFollowingQueriedUser = await DBClient.instance.userFollow.findMany({ + take: pageSize, + skip: (pageNum - 1) * pageSize, + where: { follower: { id: userId } }, + select: { + following: { select: { username: true, userAvatar: true, id: true } }, + }, + }); + + return usersFollowingQueriedUser.map((u) => u.following); +}; + +export default getUsersFollowingUser; diff --git a/src/services/UserFollows/schema/FollowInfoSchema.ts b/src/services/UserFollows/schema/FollowInfoSchema.ts new file mode 100644 index 0000000..86ff7d4 --- /dev/null +++ b/src/services/UserFollows/schema/FollowInfoSchema.ts @@ -0,0 +1,9 @@ +import GetUserSchema from '@/services/User/schema/GetUserSchema'; + +const FollowInfoSchema = GetUserSchema.pick({ + userAvatar: true, + id: true, + username: true, +}); + +export default FollowInfoSchema;