From 6bd2d4713e3ddce2b350dff5b31c5403fb1afd8c Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 23 Oct 2023 22:50:43 -0400 Subject: [PATCH] feat: add beer style likes --- .../BeerStyleById/BeerStyleHeader.tsx | 22 +++-- .../BeerStyleById/BeerStyleLikeButton.tsx | 34 ++++++++ .../beer-style-likes/useBeerStyleLikeCount.ts | 52 +++++++++++ .../useCheckIfUserLikesBeerPost.ts | 57 ++++++++++++ src/pages/api/beers/styles/[id]/like/index.ts | 86 +++++++++++++++++++ .../api/beers/styles/[id]/like/is-liked.ts | 53 ++++++++++++ .../BeerStyleLike/sendBeerStyleLikeRequest.ts | 31 +++++++ .../BeerStyleLike/createBeerStyleLike.ts | 18 ++++ .../BeerStyleLike/findBeerStyleLikeById.ts | 16 ++++ .../BeerStyleLike/getBeerStyleLikeCount.ts | 10 +++ .../BeerStyleLike/removeBeerStyleLikeById.ts | 12 +++ 11 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 src/components/BeerStyleById/BeerStyleLikeButton.tsx create mode 100644 src/hooks/data-fetching/beer-style-likes/useBeerStyleLikeCount.ts create mode 100644 src/hooks/data-fetching/beer-style-likes/useCheckIfUserLikesBeerPost.ts create mode 100644 src/pages/api/beers/styles/[id]/like/index.ts create mode 100644 src/pages/api/beers/styles/[id]/like/is-liked.ts create mode 100644 src/requests/BeerStyleLike/sendBeerStyleLikeRequest.ts create mode 100644 src/services/BeerStyleLike/createBeerStyleLike.ts create mode 100644 src/services/BeerStyleLike/findBeerStyleLikeById.ts create mode 100644 src/services/BeerStyleLike/getBeerStyleLikeCount.ts create mode 100644 src/services/BeerStyleLike/removeBeerStyleLikeById.ts diff --git a/src/components/BeerStyleById/BeerStyleHeader.tsx b/src/components/BeerStyleById/BeerStyleHeader.tsx index bffd909..342a759 100644 --- a/src/components/BeerStyleById/BeerStyleHeader.tsx +++ b/src/components/BeerStyleById/BeerStyleHeader.tsx @@ -8,6 +8,8 @@ import { FaRegEdit } from 'react-icons/fa'; import { z } from 'zod'; import useTimeDistance from '@/hooks/utilities/useTimeDistance'; import BeerStyleQueryResult from '@/services/BeerStyles/schema/BeerStyleQueryResult'; +import useBeerStyleLikeCount from '@/hooks/data-fetching/beer-style-likes/useBeerStyleLikeCount'; +import BeerStyleLikeButton from './BeerStyleLikeButton'; interface BeerInfoHeaderProps { beerStyle: z.infer; @@ -21,7 +23,7 @@ const BeerStyleHeader: FC = ({ beerStyle }) => { const idMatches = user && beerStyle.postedBy.id === user.id; const isPostOwner = !!(user && idMatches); - // const { likeCount, mutate } = useBeerStyleLikeCount(beerStyle.id); + const { likeCount, mutate } = useBeerStyleLikeCount(beerStyle.id); return (
@@ -82,14 +84,18 @@ const BeerStyleHeader: FC = ({ beerStyle }) => { {beerStyle.glassware.name}
+
+ {(!!likeCount || likeCount === 0) && ( + + Liked by {likeCount} + {likeCount !== 1 ? ' users' : ' user'} + + )} +
- {/* {user && ( - - )} */} + {user && ( + + )}
diff --git a/src/components/BeerStyleById/BeerStyleLikeButton.tsx b/src/components/BeerStyleById/BeerStyleLikeButton.tsx new file mode 100644 index 0000000..0d31c14 --- /dev/null +++ b/src/components/BeerStyleById/BeerStyleLikeButton.tsx @@ -0,0 +1,34 @@ +import { FC, useEffect, useState } from 'react'; + +import useGetBeerPostLikeCount from '@/hooks/data-fetching/beer-likes/useBeerPostLikeCount'; +import useCheckIfUserLikesBeerStyle from '@/hooks/data-fetching/beer-style-likes/useCheckIfUserLikesBeerPost'; +import sendBeerStyleLikeRequest from '@/requests/BeerStyleLike/sendBeerStyleLikeRequest'; +import LikeButton from '../ui/LikeButton'; + +const BeerStyleLikeButton: FC<{ + beerStyleId: string; + mutateCount: ReturnType['mutate']; +}> = ({ beerStyleId, mutateCount }) => { + const { isLiked, mutate: mutateLikeStatus } = useCheckIfUserLikesBeerStyle(beerStyleId); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(false); + }, [isLiked]); + + const handleLike = async () => { + try { + setLoading(true); + await sendBeerStyleLikeRequest(beerStyleId); + + await Promise.all([mutateCount(), mutateLikeStatus()]); + setLoading(false); + } catch (e) { + setLoading(false); + } + }; + + return ; +}; + +export default BeerStyleLikeButton; diff --git a/src/hooks/data-fetching/beer-style-likes/useBeerStyleLikeCount.ts b/src/hooks/data-fetching/beer-style-likes/useBeerStyleLikeCount.ts new file mode 100644 index 0000000..908fa8f --- /dev/null +++ b/src/hooks/data-fetching/beer-style-likes/useBeerStyleLikeCount.ts @@ -0,0 +1,52 @@ +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { z } from 'zod'; +import useSWR from 'swr'; + +/** + * Custom hook to fetch the like count for a beer style from the server. + * + * @param beerStyleId - The ID of the beer style to fetch the like count for. + * @returns An object with the following properties: + * + * - `error`: The error that occurred while fetching the like count. + * - `isLoading`: A boolean indicating whether the like count is being fetched. + * - `mutate`: A function to mutate the like count. + * - `likeCount`: The like count for the beer style. + */ + +const useGetBeerStyleLikeCount = (beerStyleId: string) => { + const { error, mutate, data, isLoading } = useSWR( + `/api/beers/styles/${beerStyleId}/like`, + async (url) => { + const response = await fetch(url); + const json = await response.json(); + + const parsed = APIResponseValidationSchema.safeParse(json); + + if (!parsed.success) { + throw new Error('Failed to parse API response'); + } + + const parsedPayload = z + .object({ + likeCount: z.number(), + }) + .safeParse(parsed.data.payload); + + if (!parsedPayload.success) { + throw new Error('Failed to parse API response payload'); + } + + return parsedPayload.data.likeCount; + }, + ); + + return { + error: error as unknown, + isLoading, + mutate, + likeCount: data as number | undefined, + }; +}; + +export default useGetBeerStyleLikeCount; diff --git a/src/hooks/data-fetching/beer-style-likes/useCheckIfUserLikesBeerPost.ts b/src/hooks/data-fetching/beer-style-likes/useCheckIfUserLikesBeerPost.ts new file mode 100644 index 0000000..3f2bd1e --- /dev/null +++ b/src/hooks/data-fetching/beer-style-likes/useCheckIfUserLikesBeerPost.ts @@ -0,0 +1,57 @@ +import UserContext from '@/contexts/UserContext'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { z } from 'zod'; + +/** + * A custom React hook that checks if the current user has liked a beer style by fetching + * data from the server. + * + * @param beerStyleId The ID of the beer style to check for likes. + * @returns An object with the following properties: + * + * - `error`: The error that occurred while fetching the data. + * - `isLoading`: A boolean indicating whether the data is being fetched. + * - `mutate`: A function to mutate the data. + * - `isLiked`: A boolean indicating whether the current user has liked the beer style. + */ +const useCheckIfUserLikesBeerStyle = (beerStyleId: string) => { + const { user } = useContext(UserContext); + const { data, error, isLoading, mutate } = useSWR( + `/api/beers/styles/${beerStyleId}/like/is-liked`, + async (url) => { + if (!user) { + throw new Error('User is not logged in.'); + } + + 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({ isLiked: z.boolean() }).safeParse(payload); + + if (!parsedPayload.success) { + throw new Error('Invalid API response.'); + } + + const { isLiked } = parsedPayload.data; + + return isLiked; + }, + ); + + return { + isLiked: data, + error: error as unknown, + isLoading, + mutate, + }; +}; + +export default useCheckIfUserLikesBeerStyle; diff --git a/src/pages/api/beers/styles/[id]/like/index.ts b/src/pages/api/beers/styles/[id]/like/index.ts new file mode 100644 index 0000000..7562d42 --- /dev/null +++ b/src/pages/api/beers/styles/[id]/like/index.ts @@ -0,0 +1,86 @@ +import { createRouter } from 'next-connect'; +import { z } from 'zod'; +import { NextApiRequest, NextApiResponse } from 'next'; + +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import ServerError from '@/config/util/ServerError'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; + +import getBeerStyleById from '@/services/BeerStyles/getBeerStyleById'; +import findBeerStyleLikeById from '@/services/BeerStyleLike/findBeerStyleLikeById'; +import getBeerStyleLikeCount from '@/services/BeerStyleLike/getBeerStyleLikeCount'; +import createBeerStyleLike from '@/services/BeerStyleLike/createBeerStyleLike'; +import removeBeerStyleLikeById from '@/services/BeerStyleLike/removeBeerStyleLikeById'; + +const sendLikeRequest = async ( + req: UserExtendedNextApiRequest, + res: NextApiResponse>, +) => { + const user = req.user!; + const id = req.query.id as string; + + const beerStyle = await getBeerStyleById(id); + if (!beerStyle) { + throw new ServerError('Could not find a beer style with that id', 404); + } + + const alreadyLiked = await findBeerStyleLikeById({ + beerStyleId: beerStyle.id, + likedById: user.id, + }); + + const jsonResponse = { + success: true as const, + message: '', + statusCode: 200 as const, + }; + + if (alreadyLiked) { + await removeBeerStyleLikeById({ beerStyleLikeId: alreadyLiked.id }); + jsonResponse.message = 'Successfully unliked beer style.'; + } else { + await createBeerStyleLike({ beerStyleId: beerStyle.id, user }); + jsonResponse.message = 'Successfully liked beer style.'; + } + + res.status(200).json(jsonResponse); +}; + +const getLikeCount = async ( + req: NextApiRequest, + res: NextApiResponse>, +) => { + const id = req.query.id as string; + + const likeCount = await getBeerStyleLikeCount({ beerStyleId: id }); + res.status(200).json({ + success: true, + message: 'Successfully retrieved like count.', + statusCode: 200, + payload: { likeCount }, + }); +}; + +const router = createRouter< + UserExtendedNextApiRequest, + NextApiResponse> +>(); + +router.post( + getCurrentUser, + validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }), + sendLikeRequest, +); + +router.get( + validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }), + getLikeCount, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/api/beers/styles/[id]/like/is-liked.ts b/src/pages/api/beers/styles/[id]/like/is-liked.ts new file mode 100644 index 0000000..ad7679a --- /dev/null +++ b/src/pages/api/beers/styles/[id]/like/is-liked.ts @@ -0,0 +1,53 @@ +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; +import DBClient from '@/prisma/DBClient'; + +interface FindBeerStyleLikeByIdArgs { + beerStyleId: string; + likedById: string; +} + +const findBeerStyleLikeById = async ({ + beerStyleId, + likedById, +}: FindBeerStyleLikeByIdArgs) => { + return DBClient.instance.beerStyleLike.findFirst({ + where: { beerStyleId, likedById }, + }); +}; + +const checkIfLiked = async ( + req: UserExtendedNextApiRequest, + res: NextApiResponse>, +) => { + const user = req.user!; + const beerStyleId = req.query.id as string; + + const alreadyLiked = await findBeerStyleLikeById({ beerStyleId, likedById: user.id }); + res.status(200).json({ + success: true, + message: alreadyLiked ? 'Beer style is liked.' : 'Beer style is not liked.', + statusCode: 200, + payload: { isLiked: !!alreadyLiked }, + }); +}; + +const router = createRouter< + UserExtendedNextApiRequest, + NextApiResponse> +>(); + +router.get( + getCurrentUser, + validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }), + checkIfLiked, +); + +const handler = router.handler(NextConnectOptions); +export default handler; diff --git a/src/requests/BeerStyleLike/sendBeerStyleLikeRequest.ts b/src/requests/BeerStyleLike/sendBeerStyleLikeRequest.ts new file mode 100644 index 0000000..73fd503 --- /dev/null +++ b/src/requests/BeerStyleLike/sendBeerStyleLikeRequest.ts @@ -0,0 +1,31 @@ +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +/** + * Sends a POST request to the server to like or unlike a beer post. + * + * @param beerStyleId The ID of the beer post to like or unlike. + * @returns An object containing a success boolean and a message string. + * @throws An error if the response is not ok or if the API response is invalid. + */ +const sendBeerStyleLikeRequest = async (beerStyleId: string) => { + const response = await fetch(`/api/beers/styles/${beerStyleId}/like`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('Something went wrong.'); + } + + const data = await response.json(); + const parsed = APIResponseValidationSchema.safeParse(data); + + if (!parsed.success) { + throw new Error('Invalid API response.'); + } + + const { success, message } = parsed.data; + + return { success, message }; +}; + +export default sendBeerStyleLikeRequest; diff --git a/src/services/BeerStyleLike/createBeerStyleLike.ts b/src/services/BeerStyleLike/createBeerStyleLike.ts new file mode 100644 index 0000000..9d4652b --- /dev/null +++ b/src/services/BeerStyleLike/createBeerStyleLike.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import DBClient from '@/prisma/DBClient'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; + +interface CreateBeerStyleLikeArgs { + beerStyleId: string; + user: z.infer; +} +const createBeerStyleLike = async ({ beerStyleId, user }: CreateBeerStyleLikeArgs) => { + return DBClient.instance.beerStyleLike.create({ + data: { + beerStyleId, + likedById: user.id, + }, + }); +}; + +export default createBeerStyleLike; diff --git a/src/services/BeerStyleLike/findBeerStyleLikeById.ts b/src/services/BeerStyleLike/findBeerStyleLikeById.ts new file mode 100644 index 0000000..b9ec621 --- /dev/null +++ b/src/services/BeerStyleLike/findBeerStyleLikeById.ts @@ -0,0 +1,16 @@ +import DBClient from '@/prisma/DBClient'; + +interface FindBeerStyleLikeByIdArgs { + beerStyleId: string; + likedById: string; +} +const findBeerStyleLikeById = async ({ + beerStyleId, + likedById, +}: FindBeerStyleLikeByIdArgs) => { + return DBClient.instance.beerStyleLike.findFirst({ + where: { beerStyleId, likedById }, + }); +}; + +export default findBeerStyleLikeById; diff --git a/src/services/BeerStyleLike/getBeerStyleLikeCount.ts b/src/services/BeerStyleLike/getBeerStyleLikeCount.ts new file mode 100644 index 0000000..7ba4061 --- /dev/null +++ b/src/services/BeerStyleLike/getBeerStyleLikeCount.ts @@ -0,0 +1,10 @@ +import DBClient from '@/prisma/DBClient'; + +interface GetBeerStyleLikeCountArgs { + beerStyleId: string; +} +const getBeerStyleLikeCount = async ({ beerStyleId }: GetBeerStyleLikeCountArgs) => { + return DBClient.instance.beerStyleLike.count({ where: { beerStyleId } }); +}; + +export default getBeerStyleLikeCount; diff --git a/src/services/BeerStyleLike/removeBeerStyleLikeById.ts b/src/services/BeerStyleLike/removeBeerStyleLikeById.ts new file mode 100644 index 0000000..164eee4 --- /dev/null +++ b/src/services/BeerStyleLike/removeBeerStyleLikeById.ts @@ -0,0 +1,12 @@ +import DBClient from '@/prisma/DBClient'; + +interface RemoveBeerStyleLikeByIdArgs { + beerStyleLikeId: string; +} +const removeBeerStyleLikeById = async ({ + beerStyleLikeId, +}: RemoveBeerStyleLikeByIdArgs) => { + return DBClient.instance.beerStyleLike.delete({ where: { id: beerStyleLikeId } }); +}; + +export default removeBeerStyleLikeById;