From 7c87be09cfbc37cdf6fd9c42f7469f4b514573a7 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 27 Nov 2023 22:04:55 -0500 Subject: [PATCH] Feat: implement client side functionality for user follow feature --- src/components/UserPage/UserFollowButton.tsx | 66 +++++++++++++++ src/components/UserPage/UserHeader.tsx | 27 +++++-- .../user-follows/useFollowStatus.ts | 38 +++++++++ .../user-follows/useGetUsersFollowedByUser.ts | 3 +- .../user-follows/useGetUsersFollowingUser.ts | 3 +- src/pages/account/index.tsx | 2 +- src/pages/api/users/[id]/follow-user.ts | 81 +++++++++++++++++++ src/pages/api/users/[id]/is-followed.ts | 71 ++++++++++++++++ src/pages/users/[id].tsx | 18 +---- ...quest.tsx => sendForgotPasswordRequest.ts} | 0 .../UserFollow/sendUserFollowRequest.ts | 16 ++++ 11 files changed, 300 insertions(+), 25 deletions(-) create mode 100644 src/components/UserPage/UserFollowButton.tsx create mode 100644 src/hooks/data-fetching/user-follows/useFollowStatus.ts create mode 100644 src/pages/api/users/[id]/follow-user.ts create mode 100644 src/pages/api/users/[id]/is-followed.ts rename src/requests/User/{sendForgotPasswordRequest.tsx => sendForgotPasswordRequest.ts} (100%) create mode 100644 src/requests/UserFollow/sendUserFollowRequest.ts diff --git a/src/components/UserPage/UserFollowButton.tsx b/src/components/UserPage/UserFollowButton.tsx new file mode 100644 index 0000000..36f141c --- /dev/null +++ b/src/components/UserPage/UserFollowButton.tsx @@ -0,0 +1,66 @@ +import useFollowStatus from '@/hooks/data-fetching/user-follows/useFollowStatus'; +import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; +import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; +import sendUserFollowRequest from '@/requests/UserFollow/sendUserFollowRequest'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import { FC, useState } from 'react'; +import { FaUserCheck, FaUserPlus } from 'react-icons/fa'; +import { z } from 'zod'; + +interface UserFollowButtonProps { + mutateFollowerCount: ReturnType['mutate']; + mutateFollowingCount: ReturnType['mutate']; + user: z.infer; +} + +const UserFollowButton: FC = ({ + user, + mutateFollowerCount, + mutateFollowingCount, +}) => { + const { isFollowed, mutate: mutateFollowStatus } = useFollowStatus(user.id); + + const [isLoading, setIsLoading] = useState(false); + + const onClick = async () => { + try { + setIsLoading(true); + await sendUserFollowRequest(user.id); + await Promise.all([ + mutateFollowStatus(), + mutateFollowerCount(), + mutateFollowingCount(), + ]); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + } + }; + + return ( + + ); +}; + +export default UserFollowButton; diff --git a/src/components/UserPage/UserHeader.tsx b/src/components/UserPage/UserHeader.tsx index be41117..409052e 100644 --- a/src/components/UserPage/UserHeader.tsx +++ b/src/components/UserPage/UserHeader.tsx @@ -1,20 +1,30 @@ import useTimeDistance from '@/hooks/utilities/useTimeDistance'; -import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; -import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; + import { FC } from 'react'; import { z } from 'zod'; import { format } from 'date-fns'; import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; +import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; import UserAvatar from '../Account/UserAvatar'; +import UserFollowButton from './UserFollowButton'; interface UserHeaderProps { user: z.infer; - followerCount: ReturnType['followerCount']; - followingCount: ReturnType['followingCount']; } -const UserHeader: FC = ({ user, followerCount, followingCount }) => { +const UserHeader: FC = ({ user }) => { const timeDistance = useTimeDistance(new Date(user.createdAt)); + const { followingCount, mutate: mutateFollowingCount } = useGetUsersFollowedByUser({ + userId: user.id, + pageSize: 10, + }); + + const { followerCount, mutate: mutateFollowerCount } = useGetUsersFollowingUser({ + userId: user.id, + pageSize: 10, + }); + return (
@@ -42,6 +52,13 @@ const UserHeader: FC = ({ user, followerCount, followingCount } )} +
+ +
); diff --git a/src/hooks/data-fetching/user-follows/useFollowStatus.ts b/src/hooks/data-fetching/user-follows/useFollowStatus.ts new file mode 100644 index 0000000..c750ecf --- /dev/null +++ b/src/hooks/data-fetching/user-follows/useFollowStatus.ts @@ -0,0 +1,38 @@ +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWR from 'swr'; +import { z } from 'zod'; + +const useFollowStatus = (userFollowedId: string) => { + const { data, error, isLoading, mutate } = useSWR( + `/api/users/${userFollowedId}/is-followed`, + async (url) => { + const response = await fetch(url); + const json = await response.json(); + const parsed = APIResponseValidationSchema.safeParse(json); + + if (!parsed.success) { + throw new Error('Invalid API response.'); + } + + const { payload } = parsed.data; + const parsedPayload = z.object({ isFollowed: z.boolean() }).safeParse(payload); + + if (!parsedPayload.success) { + throw new Error('Invalid API response.'); + } + + const { isFollowed } = parsedPayload.data; + + return isFollowed; + }, + ); + + return { + isFollowed: data, + error: error as unknown, + isLoading, + mutate, + }; +}; + +export default useFollowStatus; diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts index d3a3c4a..a1766a7 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowedByUser.ts @@ -34,7 +34,7 @@ const useGetUsersFollowedByUser = ({ return { following: parsedPayload.data, pageCount, followingCount: count }; }; - const { data, error, isLoading, setSize, size } = useSWRInfinite( + const { data, error, isLoading, setSize, size, mutate } = useSWRInfinite( (index) => `/api/users/${userId}/following?page_num=${index + 1}&page_size=${pageSize}`, fetcher, @@ -57,6 +57,7 @@ const useGetUsersFollowedByUser = ({ isLoading, isLoadingMore, isAtEnd, + mutate, error: error as unknown, }; }; diff --git a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts index a9b4763..a92d493 100644 --- a/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts +++ b/src/hooks/data-fetching/user-follows/useGetUsersFollowingUser.ts @@ -34,7 +34,7 @@ const useGetUsersFollowingUser = ({ return { followers: parsedPayload.data, pageCount, followerCount: count }; }; - const { data, error, isLoading, setSize, size } = useSWRInfinite( + const { data, error, isLoading, setSize, size, mutate } = useSWRInfinite( (index) => `/api/users/${userId}/followers?page_num=${index + 1}&page_size=${pageSize}`, fetcher, @@ -57,6 +57,7 @@ const useGetUsersFollowingUser = ({ isLoading, isLoadingMore, isAtEnd, + mutate, error: error as unknown, }; }; diff --git a/src/pages/account/index.tsx b/src/pages/account/index.tsx index 4d9405f..93dcf26 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/account/index.tsx @@ -35,7 +35,7 @@ const AccountPage: NextPage = () => {
-
+
diff --git a/src/pages/api/users/[id]/follow-user.ts b/src/pages/api/users/[id]/follow-user.ts new file mode 100644 index 0000000..0bb438e --- /dev/null +++ b/src/pages/api/users/[id]/follow-user.ts @@ -0,0 +1,81 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +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 APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +import { NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest { + query: { id: string }; +} + +const router = createRouter< + GetUserFollowInfoRequest, + NextApiResponse> +>(); + +const followUser = async ( + req: GetUserFollowInfoRequest, + res: NextApiResponse>, +) => { + const { id } = req.query; + + const user = await findUserById(id); + if (!user) { + throw new ServerError('User not found', 404); + } + + const currentUser = req.user!; + const userIsFollowedBySessionUser = await DBClient.instance.userFollow.findFirst({ + where: { + followerId: currentUser.id, + followingId: id, + }, + }); + + if (!userIsFollowedBySessionUser) { + await DBClient.instance.userFollow.create({ + data: { followerId: currentUser.id, followingId: id }, + }); + + res.status(200).json({ + message: 'Now following user.', + success: true, + statusCode: 200, + }); + + return; + } + + await DBClient.instance.userFollow.delete({ + where: { + followerId_followingId: { + followerId: currentUser.id, + followingId: id, + }, + }, + }); + + res.status(200).json({ + message: 'No longer following user.', + success: true, + statusCode: 200, + }); +}; + +router.post( + validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }), + getCurrentUser, + followUser, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/api/users/[id]/is-followed.ts b/src/pages/api/users/[id]/is-followed.ts new file mode 100644 index 0000000..682b15d --- /dev/null +++ b/src/pages/api/users/[id]/is-followed.ts @@ -0,0 +1,71 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +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 APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +import { NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface GetUserFollowInfoRequest extends UserExtendedNextApiRequest { + query: { id: string }; +} + +const router = createRouter< + GetUserFollowInfoRequest, + NextApiResponse> +>(); + +const checkIfUserIsFollowedBySessionUser = async ( + req: GetUserFollowInfoRequest, + res: NextApiResponse>, +) => { + const { id } = req.query; + + const user = await findUserById(id); + if (!user) { + throw new ServerError('User not found', 404); + } + + const currentUser = req.user!; + + const userIsFollowedBySessionUser = await DBClient.instance.userFollow.findFirst({ + where: { + followerId: currentUser.id, + followingId: id, + }, + }); + + if (!userIsFollowedBySessionUser) { + res.status(200).json({ + message: 'User is not followed by the current user.', + success: true, + statusCode: 200, + payload: { isFollowed: false }, + }); + + return; + } + + res.status(200).json({ + message: 'User is followed by the current user.', + success: true, + statusCode: 200, + payload: { isFollowed: true }, + }); +}; + +router.get( + validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }), + getCurrentUser, + checkIfUserIsFollowedBySessionUser, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/users/[id].tsx b/src/pages/users/[id].tsx index 02d713b..a628cae 100644 --- a/src/pages/users/[id].tsx +++ b/src/pages/users/[id].tsx @@ -7,8 +7,6 @@ import { FC } from 'react'; import { z } from 'zod'; import withPageAuthRequired from '@/util/withPageAuthRequired'; import UserHeader from '@/components/UserPage/UserHeader'; -import useGetUsersFollowedByUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowedByUser'; -import useGetUsersFollowingUser from '@/hooks/data-fetching/user-follows/useGetUsersFollowingUser'; interface UserInfoPageProps { user: z.infer; @@ -19,16 +17,6 @@ const UserInfoPage: FC = ({ user }) => { const isDesktop = useMediaQuery('(min-width: 1024px)'); const title = `${user.username} | The Biergarten App`; - const { followingCount } = useGetUsersFollowedByUser({ - userId: user.id, - pageSize: 10, - }); - - const { followerCount } = useGetUsersFollowingUser({ - userId: user.id, - pageSize: 10, - }); - return ( <> @@ -38,11 +26,7 @@ const UserInfoPage: FC = ({ user }) => { <>
- + {isDesktop ? (
diff --git a/src/requests/User/sendForgotPasswordRequest.tsx b/src/requests/User/sendForgotPasswordRequest.ts similarity index 100% rename from src/requests/User/sendForgotPasswordRequest.tsx rename to src/requests/User/sendForgotPasswordRequest.ts diff --git a/src/requests/UserFollow/sendUserFollowRequest.ts b/src/requests/UserFollow/sendUserFollowRequest.ts new file mode 100644 index 0000000..30f1347 --- /dev/null +++ b/src/requests/UserFollow/sendUserFollowRequest.ts @@ -0,0 +1,16 @@ +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +const sendUserFollowRequest = async (userId: string) => { + const response = await fetch(`/api/users/${userId}/follow-user`, { method: 'POST' }); + const json = await response.json(); + + const parsed = APIResponseValidationSchema.safeParse(json); + + if (!parsed.success) { + throw new Error('Invalid API response.'); + } + + return parsed; +}; + +export default sendUserFollowRequest;