From cb7501f51bc031954e3efbee1047f3c450eecd3e Mon Sep 17 00:00:00 2001 From: Soham Gupta Date: Sat, 2 Nov 2024 22:05:35 +0530 Subject: [PATCH] feat: Implement post voting functionality - New API route `/api/sub/post/vote` for handling votes - Cache post data after a certain number of votes using `@upstash/redis` - Fetch voting data dynamically using server-side component or pass it in as props --- app/api/sub/post/vote/route.ts | 156 ++++++++++++++++++++++++++++ components/pages/PostVoteClient.tsx | 116 +++++++++++++++++++++ components/pages/PostVoteServer.tsx | 58 +++++++++++ components/pages/Posts.tsx | 13 ++- lib/redis.ts | 6 ++ package-lock.json | 14 +++ package.json | 1 + types/redis.d.ts | 10 ++ 8 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 app/api/sub/post/vote/route.ts create mode 100644 components/pages/PostVoteClient.tsx create mode 100644 components/pages/PostVoteServer.tsx create mode 100644 lib/redis.ts create mode 100644 types/redis.d.ts diff --git a/app/api/sub/post/vote/route.ts b/app/api/sub/post/vote/route.ts new file mode 100644 index 0000000..69ebf24 --- /dev/null +++ b/app/api/sub/post/vote/route.ts @@ -0,0 +1,156 @@ +import { getAuthSession } from '@/lib/auth' +import { db } from '@/lib/db' +import { redis } from '@/lib/redis' +import { PostVoteValidator } from '@/lib/validators/vote' +import { CachedPost } from '@/types/redis' +import { z } from 'zod' + +const CACHE_AFTER_UPVOTES = 1 + +export async function PATCH(req: Request) { + try { + const body = await req.json() + + const { postId, voteType } = PostVoteValidator.parse(body) + + const session = await getAuthSession() + + if (!session?.user) { + return new Response('Unauthorized', { status: 401 }) + } + + // CHECK: check if user has already voted on this post + const existingVote = await db.vote.findFirst({ + where: { + userId: session.user.id, + postId, + }, + }) + + const post = await db.post.findUnique({ + where: { + id: postId, + }, + include: { + author: true, + votes: true, + }, + }) + + if (!post) { + return new Response('Post not found', { status: 404 }) + } + + if (existingVote) { + // DELETE: if vote type is the same as existing vote, delete the vote + if (existingVote.type === voteType) { + await db.vote.delete({ + where: { + userId_postId: { + postId, + userId: session.user.id, + }, + }, + }) + + // Recount the total number of votes + const votesAmt = post.votes.reduce((acc, vote) => { + if (vote.type === 'UP') return acc + 1 + if (vote.type === 'DOWN') return acc - 1 + return acc + }, 0) + + if (votesAmt >= CACHE_AFTER_UPVOTES) { + const cachePayload: CachedPost = { + authorUsername: post.author.username ?? '', + content: JSON.stringify(post.content), + id: post.id, + title: post.title, + currentVote: null, + createdAt: post.createdAt, + } + + await redis.hset(`post:${postId}`, cachePayload) // Store the post data as a hash + } + + return new Response('OK') + } + + // UPDATE: if user has different vote type, update the vote + await db.vote.update({ + where: { + userId_postId: { + postId, + userId: session.user.id, + }, + }, + data: { + type: voteType, + }, + }) + + // Recount the total number of votes + const votesAmt = post.votes.reduce((acc, vote) => { + if (vote.type === 'UP') return acc + 1 + if (vote.type === 'DOWN') return acc - 1 + return acc + }, 0) + + if (votesAmt >= CACHE_AFTER_UPVOTES) { + const cachePayload: CachedPost = { + authorUsername: post.author.username ?? '', + content: JSON.stringify(post.content), + id: post.id, + title: post.title, + currentVote: voteType, + createdAt: post.createdAt, + } + + await redis.hset(`post:${postId}`, cachePayload) // Store the post data as a hash + } + + return new Response('OK') + } + + // CREATE: if user has not voted, create a new vote + await db.vote.create({ + data: { + type: voteType, + userId: session.user.id, + postId, + }, + }) + + // Recount the total number of votes + const votesAmt = post.votes.reduce((acc, vote) => { + if (vote.type === 'UP') return acc + 1 + if (vote.type === 'DOWN') return acc - 1 + return acc + }, 0) + + if (votesAmt >= CACHE_AFTER_UPVOTES) { + const cachePayload: CachedPost = { + authorUsername: post.author.username ?? '', + content: JSON.stringify(post.content), + id: post.id, + title: post.title, + currentVote: voteType, + createdAt: post.createdAt, + } + + await redis.hset(`post:${postId}`, cachePayload) // Store the post data as a hash + } + + return new Response('OK') + } catch (error) { + (error) + if (error instanceof z.ZodError) { + return new Response(error.message, { status: 400 }) + } + + return new Response( + 'Could not register your vote. Please try again later!', + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/components/pages/PostVoteClient.tsx b/components/pages/PostVoteClient.tsx new file mode 100644 index 0000000..a1de505 --- /dev/null +++ b/components/pages/PostVoteClient.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { toast } from "@/hooks/use-toast"; +import useCustomLoginToast from "@/hooks/useCustomToast"; +import { cn } from "@/lib/utils"; +import { PostVoteRequest } from "@/lib/validators/vote"; +import { usePrevious } from "@mantine/hooks"; +import { VoteType } from "@prisma/client"; +import { useMutation } from "@tanstack/react-query"; +import axios, { AxiosError } from "axios"; +import { ArrowBigDown, ArrowBigUp } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "../ui/button"; + +interface PostVoteClientProps { + postId: string; + initialTotalVotes: number; + initialVote: VoteType | null; +} +export default function PostVoteClient({ + postId, + initialTotalVotes, + initialVote, +}: PostVoteClientProps) { + const { loginToast } = useCustomLoginToast(); + const [votesAmt, setVotesAmt] = useState(initialTotalVotes); + const [currentVote, setCurrentVote] = useState(initialVote); + const prevVote = usePrevious(currentVote); + + // ensure sync with server + useEffect(() => { + setCurrentVote(initialVote); + }, [initialVote]); + + const { mutate: vote } = useMutation({ + mutationFn: async (type: VoteType) => { + const payload: PostVoteRequest = { + voteType: type, + postId: postId, + }; + + await axios.patch("/api/sub/post/vote", payload); + }, + onError: (err, voteType) => { + if (voteType === "UP") setVotesAmt((prev) => prev - 1); + else setVotesAmt((prev) => prev + 1); + + // reset current vote + setCurrentVote(prevVote ?? null); + + if (err instanceof AxiosError) { + if (err.response?.status === 401) { + return loginToast(); + } + } + + return toast({ + title: "Something went wrong.", + description: "Your vote was not registered. Please try again.", + variant: "destructive", + }); + }, + onMutate: (type: VoteType) => { + if (currentVote === type) { + // User is voting the same direction, remove their vote + setCurrentVote(null); + if (type === "UP") setVotesAmt((prev) => prev - 1); + else if (type === "DOWN") setVotesAmt((prev) => prev + 1); + } else { + // User is voting in the opposite direction, subtract 2 + setCurrentVote(type); + if (type === "UP") setVotesAmt((prev) => prev + (currentVote ? 2 : 1)); + else if (type === "DOWN") + setVotesAmt((prev) => prev - (currentVote ? 2 : 1)); + } + }, + }); + + return ( +
+ {/* upvote */} + + + {/* votes */} +

{votesAmt}

+ + {/* downvote */} + +
+ ); +} diff --git a/components/pages/PostVoteServer.tsx b/components/pages/PostVoteServer.tsx new file mode 100644 index 0000000..cef94af --- /dev/null +++ b/components/pages/PostVoteServer.tsx @@ -0,0 +1,58 @@ +import { getAuthSession } from "@/lib/auth"; +import type { Post, Vote } from "@prisma/client"; +import { notFound } from "next/navigation"; +import PostVoteClient from "./PostVoteClient"; + +interface PostVoteServerProps { + postId: string; + initialTotalVotes?: number; + initialVote?: Vote["type"] | null; + getData?: () => Promise<(Post & { votes: Vote[] }) | null>; +} + +/** + * We split the PostVotes into a client and a server component to allow for dynamic data + * fetching inside of this component, allowing for faster page loads via suspense streaming. + * We also have to option to fetch this info on a page-level and pass it in. + * + */ + +export default async function PostVoteServer({ + postId, + initialTotalVotes, + initialVote, + getData, +}: PostVoteServerProps) { + const session = await getAuthSession(); + + let _votesAmt: number = 0; + let _currentVote: Vote["type"] | null | undefined = undefined; + + if (getData) { + // fetch data in component + const post = await getData(); + if (!post) return notFound(); + + _votesAmt = post.votes.reduce((acc, vote) => { + if (vote.type === "UP") return acc + 1; + if (vote.type === "DOWN") return acc - 1; + return acc; + }, 0); + + _currentVote = post.votes.find( + (vote) => vote.userId === session?.user?.id + )?.type; + } else { + // passed as props + _votesAmt = initialTotalVotes!; + _currentVote = initialVote; + } + + return ( + + ); +} diff --git a/components/pages/Posts.tsx b/components/pages/Posts.tsx index 94102fd..37edfac 100644 --- a/components/pages/Posts.tsx +++ b/components/pages/Posts.tsx @@ -5,6 +5,7 @@ import { MessageSquare } from "lucide-react"; import Link from "next/link"; import { useRef } from "react"; import EditorOutput from "../EditorOutput"; +import PostVoteClient from "./PostVoteClient"; type PartialVote = Pick; @@ -16,12 +17,12 @@ interface PostProps { totalVotes: number; subgroupName: string; currentVote?: PartialVote; - commentAmt: number; + commentAmt: number | null; } export default function Posts({ post, - totalVotes: _totalVotes, - currentVote: _currentVote, + totalVotes, + currentVote, subgroupName, commentAmt, }: PostProps) { @@ -30,6 +31,12 @@ export default function Posts({ return (
+ +
{subgroupName ? ( diff --git a/lib/redis.ts b/lib/redis.ts new file mode 100644 index 0000000..53dbc97 --- /dev/null +++ b/lib/redis.ts @@ -0,0 +1,6 @@ +import { Redis } from "@upstash/redis"; + +export const redis = new Redis({ + url: process.env.REDIS_URL!, + token: process.env.REDIS_SECRET!, +}); diff --git a/package-lock.json b/package-lock.json index ccb4c51..1b909e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@tanstack/react-query": "^5.51.23", "@types/bad-words": "^3.0.3", "@uploadthing/react": "^6.8.0", + "@upstash/redis": "^1.34.3", "autoprefixer": "^10.4.19", "axios": "^1.7.3", "bad-words": "^3.0.4", @@ -1680,6 +1681,14 @@ "std-env": "^3.7.0" } }, + "node_modules/@upstash/redis": { + "version": "1.34.3", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.34.3.tgz", + "integrity": "sha512-VT25TyODGy/8ljl7GADnJoMmtmJ1F8d84UXfGonRRF8fWYJz7+2J6GzW+a6ETGtk4OyuRTt7FRSvFG5GvrfSdQ==", + "dependencies": { + "crypto-js": "^4.2.0" + } + }, "node_modules/acorn": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz", @@ -2720,6 +2729,11 @@ "resolved": "", "link": true }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", diff --git a/package.json b/package.json index a9d6a9f..5d65872 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@tanstack/react-query": "^5.51.23", "@types/bad-words": "^3.0.3", "@uploadthing/react": "^6.8.0", + "@upstash/redis": "^1.34.3", "autoprefixer": "^10.4.19", "axios": "^1.7.3", "bad-words": "^3.0.4", diff --git a/types/redis.d.ts b/types/redis.d.ts new file mode 100644 index 0000000..a71c008 --- /dev/null +++ b/types/redis.d.ts @@ -0,0 +1,10 @@ +import { Vote } from '@prisma/client' + +export type CachedPost = { + id: string + title: string + authorUsername: string + content: string + currentVote: Vote['type'] | null + createdAt: Date +} \ No newline at end of file