-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
739230b
commit cb7501f
Showing
8 changed files
with
371 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 } | ||
) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<number>(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 ( | ||
<div className="flex flex-col gap-4 sm:gap-0 pr-6 sm:w-20 pb-4 sm:pb-0"> | ||
{/* upvote */} | ||
<Button | ||
onClick={() => vote("UP")} | ||
size="sm" | ||
variant="ghost" | ||
aria-label="upvote" | ||
> | ||
<ArrowBigUp | ||
className={cn("h-5 w-5 text-zinc-700", { | ||
"text-emerald-500 fill-emerald-500": currentVote === "UP", | ||
})} | ||
/> | ||
</Button> | ||
|
||
{/* votes */} | ||
<p className="text-center py-2 font-medium text-sm">{votesAmt}</p> | ||
|
||
{/* downvote */} | ||
<Button | ||
onClick={() => vote("DOWN")} | ||
size="sm" | ||
className={cn({ | ||
"text-emerald-500": currentVote === "DOWN", | ||
})} | ||
variant="ghost" | ||
aria-label="downvote" | ||
> | ||
<ArrowBigDown | ||
className={cn("h-5 w-5 text-zinc-700", { | ||
"text-red-500 fill-red-500": currentVote === "DOWN", | ||
})} | ||
/> | ||
</Button> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<PostVoteClient | ||
postId={postId} | ||
initialTotalVotes={_votesAmt} | ||
initialVote={_currentVote ?? null} | ||
/> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { Redis } from "@upstash/redis"; | ||
|
||
export const redis = new Redis({ | ||
url: process.env.REDIS_URL!, | ||
token: process.env.REDIS_SECRET!, | ||
}); |
Oops, something went wrong.