From 43bbee478e81e3457ba774bd61dc80289f9f22eb Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Wed, 24 Jan 2024 05:49:53 -0700 Subject: [PATCH] add story voting --- app/(stories)/actions.ts | 298 ++++++++++++++++++++++++++++++ app/(stories)/item/[id]/page.tsx | 103 +++++++---- app/(stories)/opengraph-image.tsx | 2 +- app/(stories)/submit/actions.ts | 14 +- app/db.ts | 32 +++- components/comments.tsx | 20 +- components/icons/vote-icon.tsx | 13 ++ components/voting.tsx | 64 +++++++ lib/rate-limit.ts | 14 ++ package.json | 2 +- pnpm-lock.yaml | 14 +- 11 files changed, 511 insertions(+), 65 deletions(-) create mode 100644 components/icons/vote-icon.tsx create mode 100644 components/voting.tsx diff --git a/app/(stories)/actions.ts b/app/(stories)/actions.ts index d36b431..79ad66f 100644 --- a/app/(stories)/actions.ts +++ b/app/(stories)/actions.ts @@ -1,7 +1,305 @@ "use server"; + import { signOut } from "@/app/auth"; +import z from "zod"; +import { db, usersTable, storiesTable, votesTable, genVoteId } from "@/app/db"; +import { auth } from "@/app/auth"; +import { sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { unvoteRateLimit, voteRateLimit } from "@/lib/rate-limit"; +import { redirect } from "next/navigation"; export async function signOutAction() { await signOut(); return {}; } + +const VoteActionSchema = z.object({ + storyId: z.string(), +}); + +export type VoteActionData = { + error?: + | { + code: "INTERNAL_ERROR"; + message: string; + } + | { + code: "VALIDATION_ERROR"; + fieldErrors: { + [field: string]: string[]; + }; + } + | { + code: "RATE_LIMIT_ERROR"; + message: string; + } + | { + code: "ALREADY_VOTED_ERROR"; + message: string; + }; +}; + +export async function voteAction( + formData: FormData +): Promise { + const session = await auth(); + + if (!session?.user?.id) { + redirect("/login"); + } + + const data = VoteActionSchema.safeParse({ + storyId: formData.get("storyId"), + }); + + if (!data.success) { + return { + error: { + code: "VALIDATION_ERROR", + fieldErrors: data.error.flatten().fieldErrors, + }, + }; + } + + const user = ( + await db + .select() + .from(usersTable) + .where(sql`${usersTable.id} = ${session.user.id}`) + .limit(1) + )[0]; + + if (!user) { + return { + error: { + code: "INTERNAL_ERROR", + message: "User not found", + }, + }; + } + + const rl = await voteRateLimit.limit(user.id); + + if (!rl.success) { + return { + error: { + code: "RATE_LIMIT_ERROR", + message: "Too many votes. Try again later", + }, + }; + } + + // TODO: transaction + // await db.transaction(async (tx) => { + const tx = db; + try { + const story = ( + await tx + .select({ + id: storiesTable.id, + username: storiesTable.username, + submitted_by: storiesTable.submitted_by, + vote_id: votesTable.id, + }) + .from(storiesTable) + .where(sql`${storiesTable.id} = ${data.data.storyId}`) + .leftJoin( + votesTable, + sql`${storiesTable.id} = ${votesTable.story_id} AND ${votesTable.user_id} = ${user.id}` + ) + .limit(1) + )[0]; + + if (!story) { + throw new Error("Story not found"); + } + + if (story.vote_id) { + return { + error: { + code: "ALREADY_VOTED_ERROR", + message: "You already voted for this story", + }, + }; + } + + await Promise.all([ + tx.insert(votesTable).values({ + id: genVoteId(), + user_id: user.id, + story_id: story.id, + }), + tx + .update(storiesTable) + .set({ + points: sql`${storiesTable.points} + 1`, + }) + .where(sql`${storiesTable.id} = ${story.id}`), + story.submitted_by + ? tx + .update(usersTable) + .set({ + karma: sql`${usersTable.karma} + 1`, + }) + .where(sql`${usersTable.id} = ${story.submitted_by}`) + : Promise.resolve(), + ]); + + // revalidate all data, including points for all stories + revalidatePath("/", "layout"); + + return {}; + } catch (err) { + console.error(err); + return { + error: { + code: "INTERNAL_ERROR", + message: "Something went wrong", + }, + }; + } +} + +const UnvoteActionSchema = z.object({ + storyId: z.string(), +}); + +export type UnvoteActionData = { + error?: + | { + code: "INTERNAL_ERROR"; + message: string; + } + | { + code: "VALIDATION_ERROR"; + fieldErrors: { + [field: string]: string[]; + }; + } + | { + code: "RATE_LIMIT_ERROR"; + message: string; + } + | { + code: "SELF_UNVOTE_ERROR"; + message: string; + }; +}; + +export async function unvoteAction( + formData: FormData +): Promise { + const session = await auth(); + + if (!session?.user?.id) { + redirect("/login"); + } + + const data = UnvoteActionSchema.safeParse({ + storyId: formData.get("storyId"), + }); + + if (!data.success) { + return { + error: { + code: "VALIDATION_ERROR", + fieldErrors: data.error.flatten().fieldErrors, + }, + }; + } + + const user = ( + await db + .select() + .from(usersTable) + .where(sql`${usersTable.id} = ${session.user.id}`) + .limit(1) + )[0]; + + if (!user) { + return { + error: { + code: "INTERNAL_ERROR", + message: "User not found", + }, + }; + } + + const rl = await unvoteRateLimit.limit(user.id); + + if (!rl.success) { + return { + error: { + code: "RATE_LIMIT_ERROR", + message: "Too many unvotes. Try again later", + }, + }; + } + + // TODO: transaction + // await db.transaction(async (tx) => { + const tx = db; + try { + const story = ( + await tx + .select({ + id: storiesTable.id, + username: storiesTable.username, + submitted_by: storiesTable.submitted_by, + vote_id: votesTable.id, + }) + .from(storiesTable) + .where(sql`${storiesTable.id} = ${data.data.storyId}`) + .leftJoin( + votesTable, + sql`${storiesTable.id} = ${votesTable.story_id} AND ${votesTable.user_id} = ${user.id}` + ) + .limit(1) + )[0]; + + if (!story) { + throw new Error("Story not found"); + } + + if (story.submitted_by === user.id) { + return { + error: { + code: "SELF_UNVOTE_ERROR", + message: "You can't unvote your own story", + }, + }; + } + + await Promise.all([ + tx.delete(votesTable).where(sql`${votesTable.id} = ${story.vote_id}`), + tx + .update(storiesTable) + .set({ + points: sql`${storiesTable.points} - 1`, + }) + .where(sql`${storiesTable.id} = ${story.id}`), + story.submitted_by + ? tx + .update(usersTable) + .set({ + karma: sql`${usersTable.karma} - 1`, + }) + .where(sql`${usersTable.id} = ${story.submitted_by}`) + : Promise.resolve(), + ]); + + // revalidate all data, including points for all stories + revalidatePath("/", "layout"); + + return {}; + } catch (err) { + console.error(err); + return { + error: { + code: "INTERNAL_ERROR", + message: "Something went wrong", + }, + }; + } +} diff --git a/app/(stories)/item/[id]/page.tsx b/app/(stories)/item/[id]/page.tsx index ed584b3..f817d09 100644 --- a/app/(stories)/item/[id]/page.tsx +++ b/app/(stories)/item/[id]/page.tsx @@ -1,4 +1,10 @@ -import { db, usersTable, storiesTable } from "@/app/db"; +import { + db, + usersTable, + storiesTable, + composeStoryId, + votesTable, +} from "@/app/db"; import { TimeAgo } from "@/components/time-ago"; import { notFound } from "next/navigation"; import { headers } from "next/headers"; @@ -8,6 +14,9 @@ import { Suspense } from "react"; import { Comments } from "@/components/comments"; import { ReplyForm } from "./reply-form"; import Link from "next/link"; +import { UnvoteForm, VoteForm } from "@/components/voting"; +import { Session } from "next-auth"; +import { auth } from "@/app/auth"; export const metadata = { openGraph: { @@ -23,29 +32,48 @@ export const metadata = { }, }; -const getStory = async function getStory(idParam: string) { - const id = `story_${idParam}`; - return ( - await db - .select({ +type GetStoryOptions = { idParam: string; session: Session | null }; + +const getStory = async function getStory({ + idParam, + session, +}: GetStoryOptions) { + const id = composeStoryId(idParam); + const userId = session?.user?.id; + + const query = db + .select({ + ...{ id: storiesTable.id, title: storiesTable.title, domain: storiesTable.domain, url: storiesTable.url, username: storiesTable.username, points: storiesTable.points, - submitted_by: usersTable.username, + submitted_by: storiesTable.submitted_by, + author_username: usersTable.username, comments_count: storiesTable.comments_count, created_at: storiesTable.created_at, - }) - .from(storiesTable) - .where(sql`${storiesTable.id} = ${id}`) - .limit(1) - .leftJoin( - usersTable, - sql`${usersTable.id} = ${storiesTable.submitted_by}` - ) - )[0]; + }, + ...(userId + ? { + voted_by_me: sql`${votesTable.id} IS NOT NULL`, + } + : {}), + }) + .from(storiesTable) + .where(sql`${storiesTable.id} = ${id}`) + .limit(1) + .leftJoin(usersTable, sql`${usersTable.id} = ${storiesTable.submitted_by}`); + + if (userId) { + query.leftJoin( + votesTable, + sql`${votesTable.story_id} = ${storiesTable.id} AND ${votesTable.user_id} = ${userId}` + ); + } + + return (await query.execute())[0]; }; /** @@ -59,28 +87,28 @@ export default async function ItemPage({ params: { id: string }; }) { const rid = headers().get("x-vercel-id") ?? nanoid(); + const session = await auth(); + const userId = session?.user?.id; console.time(`fetch story ${idParam} (req: ${rid})`); - const story = await getStory(idParam); + const story = await getStory({ idParam, session }); console.timeEnd(`fetch story ${idParam} (req: ${rid})`); if (!story) { notFound(); } + const submittedByMe = userId && userId === story.submitted_by; + const now = Date.now(); return (
-
-
- - - +
+
+
{story.url != null ? ( @@ -108,19 +136,22 @@ export default async function ItemPage({ )} -

+

{story.points} point{story.points > 1 ? "s" : ""} by{" "} - {story.submitted_by ?? story.username}{" "} - {" "} - | + {story.author_username ?? story.username}{" "} + + | flag - {" "} - | + + {story.voted_by_me && !submittedByMe && ( + + )} + | hide - {" "} - | + + | {story.comments_count} comments -

+
diff --git a/app/(stories)/opengraph-image.tsx b/app/(stories)/opengraph-image.tsx index 59188b5..bf7ac38 100644 --- a/app/(stories)/opengraph-image.tsx +++ b/app/(stories)/opengraph-image.tsx @@ -2,9 +2,9 @@ export const runtime = "edge"; export const revalidate = 60; import { ImageResponse } from "next/og"; -import { getStories, getStoriesCount } from "@/components/stories"; import JSTimeAgo from "javascript-time-ago"; import en from "javascript-time-ago/locale/en"; +import { getStories, getStoriesCount } from "@/components/stories"; let timeAgo: JSTimeAgo | null = null; const numberFormatter = new Intl.NumberFormat("en-US"); diff --git a/app/(stories)/submit/actions.ts b/app/(stories)/submit/actions.ts index 0c884b3..6a9cb1b 100644 --- a/app/(stories)/submit/actions.ts +++ b/app/(stories)/submit/actions.ts @@ -1,7 +1,7 @@ "use server"; import z from "zod"; -import { db, storiesTable, genStoryId } from "@/app/db"; +import { db, storiesTable, genStoryId, votesTable, genVoteId } from "@/app/db"; import { auth } from "@/app/auth"; import { redirect } from "next/navigation"; import { newStoryRateLimit } from "@/lib/rate-limit"; @@ -84,8 +84,11 @@ export async function submitAction( // get hostname from url const id = genStoryId(); + // TODO: transaction + // await db.transaction(async (tx) => { + const tx = db; try { - await db.insert(storiesTable).values({ + await tx.insert(storiesTable).values({ id, type: getType(input.data.title as string), title: input.data.title as string, @@ -95,6 +98,13 @@ export async function submitAction( : null, submitted_by: userId, }); + // each story gets automatically upvoted by its author + // because each story starts with 1 point + await tx.insert(votesTable).values({ + id: genVoteId(), + story_id: id, + user_id: userId, + }); } catch (e) { console.error(e); return { diff --git a/app/db.ts b/app/db.ts index 98fd86d..3f0ee90 100644 --- a/app/db.ts +++ b/app/db.ts @@ -8,6 +8,7 @@ import { varchar, timestamp, AnyPgColumn, + uniqueIndex, } from "drizzle-orm/pg-core"; import { customAlphabet } from "nanoid"; import { nolookalikes } from "nanoid-dictionary"; @@ -79,8 +80,37 @@ export const storiesTable = pgTable( }) ); +export const composeStoryId = (id: string) => { + return `story_${id}`; +}; + export const genStoryId = () => { - return `story_${nanoid(12)}`; + return composeStoryId(nanoid(12)); +}; + +export const votesTable = pgTable( + "votes", + { + id: varchar("id", { length: 256 }).primaryKey().notNull(), + user_id: varchar("user_id", { length: 256 }) + .notNull() + .references(() => usersTable.id, { onDelete: "cascade" }), + story_id: varchar("story_id", { length: 256 }) + .notNull() + .references(() => storiesTable.id, { onDelete: "cascade" }), + created_at: timestamp("created_at").notNull().defaultNow(), + updated_at: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => ({ + story_id_user_id_idx: uniqueIndex("v_story_id_user_id_idx").on( + t.story_id, + t.user_id + ), + }) +); + +export const genVoteId = () => { + return `vote_${nanoid(12)}`; }; export const commentsTable = pgTable( diff --git a/components/comments.tsx b/components/comments.tsx index b8296a2..c08e2ec 100644 --- a/components/comments.tsx +++ b/components/comments.tsx @@ -4,6 +4,7 @@ import { headers } from "next/headers"; import { auth } from "@/app/auth"; import { nanoid } from "nanoid"; import { TimeAgo } from "@/components/time-ago"; +import { VoteIcon } from "@/components/icons/vote-icon"; async function getComments({ storyId, @@ -146,23 +147,8 @@ function CommentItem({ * ) : ( <> - - - - - - + + )}
diff --git a/components/icons/vote-icon.tsx b/components/icons/vote-icon.tsx new file mode 100644 index 0000000..48b77fd --- /dev/null +++ b/components/icons/vote-icon.tsx @@ -0,0 +1,13 @@ +export function VoteIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/components/voting.tsx b/components/voting.tsx new file mode 100644 index 0000000..5d457c9 --- /dev/null +++ b/components/voting.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { unvoteAction, voteAction } from "@/app/(stories)/actions"; +import { VoteIcon } from "@/components/icons/vote-icon"; +import { useFormStatus } from "react-dom"; + +export function VoteForm({ + storyId, + votedByMe, +}: { + storyId: string; + votedByMe: boolean; +}) { + return ( +
+ + + ); +} + +function VoteFormFields({ + storyId, + votedByMe, +}: { + storyId: string; + votedByMe: boolean; +}) { + const { pending } = useFormStatus(); + + return ( + <> + + {!votedByMe && !pending && ( + + )} + + ); +} + +export function UnvoteForm({ storyId }: { storyId: string }) { + return ( +
+ + + ); +} + +function UnvoteFormFields({ storyId }: { storyId: string }) { + const { pending } = useFormStatus(); + + return ( + <> + + {!pending && ( + <> + + + + )} + + ); +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index dd003c9..6a56d84 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -39,3 +39,17 @@ export const newCommentRateLimit = new Ratelimit({ analytics: true, prefix: "ratelimit:newcomment", }); + +export const voteRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(25, "15 m"), + analytics: true, + prefix: "ratelimit:vote", +}); + +export const unvoteRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(25, "15 m"), + analytics: true, + prefix: "ratelimit:unvote", +}); diff --git a/package.json b/package.json index 8f11c3c..bd9ef1f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@types/react-highlight-words": "^0.16.7", "autoprefixer": "^10.0.1", "dotenv": "^16.3.1", - "drizzle-kit": "^0.20.9", + "drizzle-kit": "^0.20.13", "eslint": "^8", "eslint-config-next": "14.0.2", "next": "14.0.5-canary.41", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b635a1..a41a53d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,8 +104,8 @@ devDependencies: specifier: ^16.3.1 version: 16.3.1 drizzle-kit: - specifier: ^0.20.9 - version: 0.20.9 + specifier: ^0.20.13 + version: 0.20.13 eslint: specifier: ^8 version: 8.53.0 @@ -193,8 +193,8 @@ packages: dependencies: regenerator-runtime: 0.14.0 - /@drizzle-team/studio@0.0.37: - resolution: {integrity: sha512-LZyAPGJBX43jsrVZh7+w1Jig/BC6PJx63ReHUYK+GRQYNY9UJNlPXmn1uC/LMRX+A7JwYM4Sr4Fg/hnJSqlfgA==} + /@drizzle-team/studio@0.0.39: + resolution: {integrity: sha512-c5Hkm7MmQC2n5qAsKShjQrHoqlfGslB8+qWzsGGZ+2dHMRTNG60UuzalF0h0rvBax5uzPXuGkYLGaQ+TUX3yMw==} dependencies: superjson: 2.2.1 dev: true @@ -1620,11 +1620,11 @@ packages: wordwrap: 1.0.0 dev: true - /drizzle-kit@0.20.9: - resolution: {integrity: sha512-5oIbPFdfEEfzVSOB3MWGt70VSHv6W7qMAWCJ5xc6W1BxgGASipxuAuyXD59fx9S6QYTNNnuSuQFoIdnNTRWY2A==} + /drizzle-kit@0.20.13: + resolution: {integrity: sha512-j9oZSQXNWG+KBJm0Sg3S/zJpncHGKnpqNfFuM4NUxUMGTcihDHhP9SW6Jncqwb5vsP1Xm0a8JLm3PZUIspC/oA==} hasBin: true dependencies: - '@drizzle-team/studio': 0.0.37 + '@drizzle-team/studio': 0.0.39 '@esbuild-kit/esm-loader': 2.6.5 camelcase: 7.0.1 chalk: 5.3.0