Skip to content

Commit

Permalink
feat: Implement post voting functionality
Browse files Browse the repository at this point in the history
- 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
gupta-soham committed Nov 4, 2024
1 parent 739230b commit cb7501f
Show file tree
Hide file tree
Showing 8 changed files with 371 additions and 3 deletions.
156 changes: 156 additions & 0 deletions app/api/sub/post/vote/route.ts
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 }
)
}
}
116 changes: 116 additions & 0 deletions components/pages/PostVoteClient.tsx
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>
);
}
58 changes: 58 additions & 0 deletions components/pages/PostVoteServer.tsx
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}
/>
);
}
13 changes: 10 additions & 3 deletions components/pages/Posts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vote, "type">;

Expand All @@ -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) {
Expand All @@ -30,6 +31,12 @@ export default function Posts({
return (
<div className="rounded-md bg-white dark:bg-black/30 shadow">
<div className="px-6 py-4 flex justify-between">
<PostVoteClient
postId={post.id}
initialTotalVotes={totalVotes}
initialVote={currentVote?.type ?? null}
/>

<div className="w-0 flex-1">
<div className="max-h-40 mt-1 text-xs text-gray-500">
{subgroupName ? (
Expand Down
6 changes: 6 additions & 0 deletions lib/redis.ts
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!,
});
Loading

0 comments on commit cb7501f

Please sign in to comment.