Skip to content

Commit

Permalink
fix: ratelimiting claps and toast feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
akshat-OwO committed Apr 15, 2024
1 parent f8ea856 commit 3a5a57b
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 12 deletions.
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
NEXT_PUBLIC_URL=
CONTENTFUL_SPACE_ID=
CONTENTFUL_ACCESS_TOKEN=
RESEND_API_KEY=
RESEND_API_KEY=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@types/node": "20.9.1",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@upstash/ratelimit": "^1.0.3",
"@upstash/redis": "^1.29.0",
"@vercel/analytics": "^1.2.2",
"ai": "^2.2.37",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 29 additions & 2 deletions src/app/api/clap/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { redis } from "@/lib/redis";
import { NextRequest, NextResponse } from "next/server";

import { Ratelimit } from "@upstash/ratelimit";

const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(1, "5s"),
analytics: true,
});

const EXPIRATION_TIME_SECONDS = 30 * 24 * 60 * 60;

export async function GET(req: NextRequest) {
const url = new URL(req.url);
const id = url.searchParams.get("id");
Expand Down Expand Up @@ -51,9 +61,26 @@ export async function POST(req: NextRequest) {
);
}

const { success, remaining } = await ratelimit.limit(RAW_IP);

if (!success) {
return NextResponse.json(
{
error: `Please wait ${remaining}s to vote again!`,
},
{ status: 429 }
);
}

try {
await redis.incr(`clap:${id}:${name}`);
await redis.set(`clap:${id}:${name}:${RAW_IP}`, "1");
const p = redis.pipeline();

p.incr(`clap:${id}:${name}`);
p.expire(`clap:${id}:${name}`, EXPIRATION_TIME_SECONDS);
p.set(`clap:${id}:${name}:${RAW_IP}`, "1");
p.expire(`clap:${id}:${name}:${RAW_IP}`, EXPIRATION_TIME_SECONDS);

await p.exec();

const clapCount = await redis.get(`clap:${id}:${name}`);

Expand Down
62 changes: 54 additions & 8 deletions src/components/Clap.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { cn } from "@/lib/utils";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import axios, { AxiosResponse } from "axios";
import { Loader2 } from "lucide-react";
import { Loader } from "lucide-react";
import { FC } from "react";
import { toast } from "sonner";
import { Icons } from "./Icons";

interface ClapProps {
Expand All @@ -12,7 +13,7 @@ interface ClapProps {
const Clap: FC<ClapProps> = ({ material }) => {
const queryClient = useQueryClient();

const { data, isLoading } = useQuery({
const { data, isFetching } = useQuery({
queryKey: ["clap", material.id, material.name],
queryFn: async () => {
const response: AxiosResponse<{
Expand All @@ -24,10 +25,11 @@ const Clap: FC<ClapProps> = ({ material }) => {

return response.data;
},
refetchInterval: 1000 * 60 * 60 * 8,
staleTime: 1000 * 60 * 60,
gcTime: 1000 * 60 * 60,
});

const { mutate } = useMutation({
const { mutate, isPending } = useMutation({
mutationKey: ["clap", material.id, material.name],
mutationFn: async () => {
const response: AxiosResponse<{ clapCount: number }> =
Expand All @@ -38,7 +40,44 @@ const Clap: FC<ClapProps> = ({ material }) => {

return response.data;
},
onMutate: async (newCount: {
clapCount: number;
hasClapped: boolean;
}) => {
await queryClient.cancelQueries({ queryKey: ["clap"] });

const previousCount = queryClient.getQueryData([
"clap",
material.id,
material.name,
]);

if (previousCount) {
queryClient.setQueryData(["clap", material.id, material.name], {
clapCount: newCount.clapCount + 1,
hasClapped: true,
});
}

return { previousCount };
},
onError: (err: any, variables, context) => {
if (context?.previousCount) {
queryClient.setQueryData(
["clap", material.id, material.name],
context.previousCount
);
}
if (err) {
toast.error(err.response.data.error);
}
},
onSuccess: () => {
toast.success(
"Thanks for clapping! Your vote will be removed within 30 days!"
);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["clap", material.id, material.name],
});
Expand All @@ -52,14 +91,21 @@ const Clap: FC<ClapProps> = ({ material }) => {
className={cn(
"flex flex-1 items-center gap-2 rounded-bl-md bg-background/75 px-2 py-1 font-semibold text-secondary-foreground transition-colors hover:bg-background/60",
{
"bg-primary text-primary-foreground hover:bg-primary/90":
"pointer-events-none bg-primary text-primary-foreground hover:bg-primary/90":
data ? data.hasClapped : false,
}
)}
onClick={() => mutate()}
onClick={() => {
if (data && !data.hasClapped) {
mutate({
clapCount: data.clapCount,
hasClapped: data.hasClapped,
});
}
}}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
{isFetching || isPending ? (
<Loader className="h-4 w-4 animate-spin" />
) : (
<Icons.clap
className={cn("h-4 w-4 fill-primary transition-colors", {
Expand Down
8 changes: 7 additions & 1 deletion src/components/StudyMaterial.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
QueryObserverResult,
RefetchOptions,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { AlertCircle, Download, Heart, RotateCw } from "lucide-react";
import Clap from "./Clap";
Expand Down Expand Up @@ -234,13 +235,18 @@ StudyMaterial.Header = function StudyMaterialHeader({
options?: RefetchOptions | undefined
) => Promise<QueryObserverResult<Drive[] | null, Error>>;
}) {
const queryClient = useQueryClient();

return (
<div className="mb-2 flex items-center justify-end gap-2">
<Button
variant={"secondary"}
size={"icon"}
disabled={isFetching}
onClick={() => refetch()}
onClick={() => {
queryClient.invalidateQueries({ queryKey: ["clap"] });
refetch();
}}
>
<RotateCw
className={cn("h-4 w-4", isFetching ? "animate-spin" : "")}
Expand Down

0 comments on commit 3a5a57b

Please sign in to comment.