diff --git a/package-lock.json b/package-lock.json index 6fc6c77..1ab0150 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.17.12", "@uploadthing/react": "^4.1.3", + "@upstash/redis": "^1.28.1", "axios": "^1.6.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -1767,6 +1768,14 @@ } } }, + "node_modules/@upstash/redis": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.28.1.tgz", + "integrity": "sha512-px7x2ZP/Tn5HZg0GbM4sf/+LMExrR8zmGAAGeOvptB/5wgTvaease7RdyoEmsb/PwL5GjlJy3xWmy4TsGk9s4w==", + "dependencies": { + "crypto-js": "^4.2.0" + } + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -2384,6 +2393,11 @@ "node": ">= 8" } }, + "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 13f05ce..4af6deb 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.17.12", "@uploadthing/react": "^4.1.3", + "@upstash/redis": "^1.28.1", "axios": "^1.6.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/src/app/api/subreddit/post/vote/route.ts b/src/app/api/subreddit/post/vote/route.ts new file mode 100644 index 0000000..d1a8175 --- /dev/null +++ b/src/app/api/subreddit/post/vote/route.ts @@ -0,0 +1,153 @@ +import { NextResponse } from "next/server"; +import * as z from 'zod'; + +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"; + +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 NextResponse('Unauthorized', { status: 401 }) + } + + // 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 NextResponse('Post not found', { status: 404 }) + } + + if (existingVote) { + // 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 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 = { + id: post.id, + authorUsername: post.author.username ?? "", + content: JSON.stringify(post.content), + title: post.title, + currentVote: voteType, + createdAt: post.createdAt + } + await redis.hset(`post:${postId}`, cachePayload) + } + return new NextResponse('OK') + } + + // if vote type is different, update the vote + await db.vote.update({ + where: { + userId_postId: { + postId, + userId: session.user.id, + }, + }, + data: { + type: voteType, + }, + }) + + //recount the 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 = { + id: post.id, + authorUsername: post.author.username ?? "", + content: JSON.stringify(post.content), + title: post.title, + currentVote: voteType, + createdAt: post.createdAt + } + await redis.hset(`post:${postId}`, cachePayload) + } + return new NextResponse('OK') + } + + //if not existing, create a new vote type + await db.vote.create({ + data: { + type: voteType, + userId: session.user.id, + postId, + }, + }) + + // Recount the 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 NextResponse('OK') + } catch (error) { + (error) + if (error instanceof z.ZodError) { + return new NextResponse(error.message, { status: 400 }) + } + + return new NextResponse( + 'Could not vote at this time. Please try later', + { status: 500 } + ) + } +} + diff --git a/src/components/post-feed.tsx b/src/components/post-feed.tsx index 108e811..141ba75 100644 --- a/src/components/post-feed.tsx +++ b/src/components/post-feed.tsx @@ -4,7 +4,7 @@ import { useInfiniteQuery } from "@tanstack/react-query" import axios from 'axios' import { ExtendedPost } from "@/types/db" -import { useIntersection } from "@/hooks/use-intersection" +import { useIntersection } from "@/hooks/@mantine-hooks/use-intersection" import { INFINITE_SCROLLING_PAGINATION_RESULTS } from "../../config" import Post from "./post" @@ -73,6 +73,8 @@ export function PostFeed({ initialPosts, subredditName, userId }: PostFeedProps) subredditName={post.subreddit.name} post={post} commentAmt={post.comments.length} + votesAmt={totalVotes} + currentVote={currentVote} /> ) @@ -83,6 +85,8 @@ export function PostFeed({ initialPosts, subredditName, userId }: PostFeedProps) subredditName={post.subreddit.name} post={post} commentAmt={post.comments.length} + votesAmt={totalVotes} + currentVote={currentVote} /> ) diff --git a/src/components/post-vote/post-vote-client.tsx b/src/components/post-vote/post-vote-client.tsx new file mode 100644 index 0000000..01fb791 --- /dev/null +++ b/src/components/post-vote/post-vote-client.tsx @@ -0,0 +1,95 @@ +"use client" + +import { usePrevious } from "@/hooks/@mantine-hooks/use-previous"; +import { useCustomToast } from "@/hooks/use-custom-tost"; +import { VoteType } from "@prisma/client"; +import { useEffect, useState } from "react"; +import { Button } from "../ui/Button"; +import { ArrowBigDown, ArrowBigUp } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import { PostVoteRequest } from "@/lib/validators/vote"; +import axios, { AxiosError } from "axios"; +import { toast } from "@/hooks/use-toast"; + +interface PostVoteClientProps { + postId: string; + initialVotesAmount: number; + initialVote?: VoteType | null +} + +export function PostVoteClient({ postId, initialVotesAmount, initialVote }: PostVoteClientProps) { + const { logInToast } = useCustomToast() + const [votesAmount, setVotesAmount] = useState(initialVotesAmount) + const [currentVote, setCurrentVote] = useState(initialVote) + const prevVote = usePrevious(currentVote) + + useEffect(() => { + setCurrentVote(initialVote) + }, [initialVote]) + + const { mutate: vote } = useMutation({ + mutationFn: async (voteType: VoteType) => { + const payload: PostVoteRequest = { + postId, + voteType + } + await axios.patch('/api/subreddit/post/vote', payload) + }, + + onError: (err, voteType) => { + if (voteType === 'UP') setVotesAmount((prev) => prev - 1) + else setVotesAmount((prev) => prev + 1) + + // reset current vote + setCurrentVote(prevVote) + + 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 way again, so remove their vote + setCurrentVote(undefined) + if (type === 'UP') setVotesAmount((prev) => prev - 1) + else if (type === 'DOWN') setVotesAmount((prev) => prev + 1) + } else { + // User is voting in the opposite direction, so subtract 2 + setCurrentVote(type) + if (type === 'UP') setVotesAmount((prev) => prev + (currentVote ? 2 : 1)) + else if (type === 'DOWN') + setVotesAmount((prev) => prev - (currentVote ? 2 : 1)) + } + }, + }) + + return ( +
{votesAmount}
+ +