diff --git a/components/posts/vote-button.tsx b/components/posts/vote-button.tsx
new file mode 100644
index 0000000..d966921
--- /dev/null
+++ b/components/posts/vote-button.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useOptimistic, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { ChevronUp } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { toggleVote } from "@/server/actions/votes";
+
+type Props = {
+ postId: string;
+ voteCount: number;
+ hasVoted: boolean;
+ authenticated: boolean;
+ boardSlug: string;
+ size?: "sm" | "md";
+};
+
+export function VoteButton({
+ postId,
+ voteCount,
+ hasVoted,
+ authenticated,
+ boardSlug,
+ size = "sm",
+}: Props) {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+
+ const [optimistic, setOptimistic] = useOptimistic(
+ { voteCount, hasVoted },
+ (state, nextHasVoted: boolean) => ({
+ voteCount: state.voteCount + (nextHasVoted ? 1 : -1),
+ hasVoted: nextHasVoted,
+ }),
+ );
+
+ function handleClick() {
+ if (!authenticated) {
+ router.push(`/login?callbackUrl=/b/${boardSlug}`);
+ return;
+ }
+ startTransition(async () => {
+ setOptimistic(!optimistic.hasVoted);
+ const result = await toggleVote({ postId });
+ if (!result.ok) {
+ toast.error(result.error);
+ }
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/lib/validators/comments.ts b/lib/validators/comments.ts
new file mode 100644
index 0000000..07eb7aa
--- /dev/null
+++ b/lib/validators/comments.ts
@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+export const createCommentSchema = z.object({
+ postId: z.cuid2(),
+ content: z.string().trim().min(1, "Comment cannot be empty").max(1000),
+});
+
+export const deleteCommentSchema = z.object({
+ commentId: z.cuid2(),
+});
+
+export type CreateCommentInput = z.infer
;
diff --git a/lib/validators/votes.ts b/lib/validators/votes.ts
new file mode 100644
index 0000000..7d9f765
--- /dev/null
+++ b/lib/validators/votes.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const toggleVoteSchema = z.object({
+ postId: z.cuid2(),
+});
+
+export type ToggleVoteInput = z.infer;
diff --git a/package.json b/package.json
index 88ee056..a2f59b7 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,7 @@
{
"name": "feedbackflow",
- "version": "0.5.0",
+ "version": "0.5.9",
"private": true,
- "engines": {
- "node": ">=20.9.0"
- },
"scripts": {
"dev": "next dev",
"build": "next build",
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 71389e5..2ab9a9c 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -63,6 +63,49 @@ async function main() {
}
console.warn(`Seeded board /b/${board.slug} with ${samplePosts.length} posts`);
+
+ const createdPosts = await db.post.findMany({
+ where: { boardId: board.id },
+ select: { id: true },
+ });
+
+ const voters = await Promise.all(
+ Array.from({ length: 8 }).map((_, i) =>
+ db.user.upsert({
+ where: { email: `voter${i}@feedbackflow.app` },
+ update: {},
+ create: { email: `voter${i}@feedbackflow.app`, name: `Voter ${i + 1}` },
+ }),
+ ),
+ );
+
+ for (const post of createdPosts) {
+ const voteCount = Math.floor(Math.random() * voters.length);
+ for (let i = 0; i < voteCount; i++) {
+ const voter = voters[i];
+ if (!voter) continue;
+ await db.vote.upsert({
+ where: { postId_userId: { postId: post.id, userId: voter.id } },
+ update: {},
+ create: { postId: post.id, userId: voter.id },
+ });
+ }
+
+ const commentCount = Math.floor(Math.random() * 3);
+ for (let i = 0; i < commentCount; i++) {
+ const voter = voters[i];
+ if (!voter) continue;
+ await db.comment.create({
+ data: {
+ postId: post.id,
+ authorId: voter.id,
+ content: `Great idea. This would really improve my workflow.`,
+ },
+ });
+ }
+ }
+
+ console.warn(`Seeded ${voters.length} voters with random votes and comments`);
}
main()
diff --git a/server/actions/comments.ts b/server/actions/comments.ts
new file mode 100644
index 0000000..2f6cfd9
--- /dev/null
+++ b/server/actions/comments.ts
@@ -0,0 +1,77 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import {
+ createCommentSchema,
+ deleteCommentSchema,
+ type CreateCommentInput,
+} from "@/lib/validators/comments";
+import type { z } from "zod";
+
+type ActionResult =
+ | { ok: true; data: T }
+ | { ok: false; error: string; issues?: Record };
+
+export async function createComment(
+ input: CreateCommentInput,
+): Promise> {
+ const parsed = createCommentSchema.safeParse(input);
+ if (!parsed.success) {
+ return {
+ ok: false,
+ error: "Invalid input",
+ issues: parsed.error.flatten().fieldErrors,
+ };
+ }
+
+ const session = await auth();
+ if (!session?.user?.id) return { ok: false, error: "Unauthorized" };
+
+ const post = await db.post.findUnique({
+ where: { id: parsed.data.postId },
+ select: { id: true, board: { select: { slug: true, isPublic: true } } },
+ });
+ if (!post || !post.board.isPublic) return { ok: false, error: "Post not found" };
+
+ const comment = await db.comment.create({
+ data: {
+ content: parsed.data.content,
+ postId: post.id,
+ authorId: session.user.id,
+ },
+ select: { id: true },
+ });
+
+ revalidatePath(`/b/${post.board.slug}/posts/${post.id}`);
+ return { ok: true, data: { commentId: comment.id } };
+}
+
+export async function deleteComment(
+ input: z.infer,
+): Promise {
+ const parsed = deleteCommentSchema.safeParse(input);
+ if (!parsed.success) return { ok: false, error: "Invalid input" };
+
+ const session = await auth();
+ if (!session?.user?.id) return { ok: false, error: "Unauthorized" };
+
+ const comment = await db.comment.findUnique({
+ where: { id: parsed.data.commentId },
+ select: {
+ authorId: true,
+ post: { select: { id: true, board: { select: { slug: true, ownerId: true } } } },
+ },
+ });
+ if (!comment) return { ok: false, error: "Comment not found" };
+
+ const isAuthor = comment.authorId === session.user.id;
+ const isBoardOwner = comment.post.board.ownerId === session.user.id;
+ if (!isAuthor && !isBoardOwner) return { ok: false, error: "Forbidden" };
+
+ await db.comment.delete({ where: { id: parsed.data.commentId } });
+
+ revalidatePath(`/b/${comment.post.board.slug}/posts/${comment.post.id}`);
+ return { ok: true, data: undefined };
+}
diff --git a/server/actions/posts.ts b/server/actions/posts.ts
index 4557dfc..671a3b0 100644
--- a/server/actions/posts.ts
+++ b/server/actions/posts.ts
@@ -84,6 +84,7 @@ export async function changeStatus(
});
revalidatePath(`/b/${post.board.slug}`);
+ revalidatePath(`/b/${post.board.slug}/roadmap`);
revalidatePath(`/posts`);
return { ok: true, data: undefined };
}
diff --git a/server/actions/votes.ts b/server/actions/votes.ts
new file mode 100644
index 0000000..ad2b2d7
--- /dev/null
+++ b/server/actions/votes.ts
@@ -0,0 +1,44 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { toggleVoteSchema, type ToggleVoteInput } from "@/lib/validators/votes";
+
+type ActionResult = { ok: true; data: T } | { ok: false; error: string };
+
+export async function toggleVote(
+ input: ToggleVoteInput,
+): Promise> {
+ const parsed = toggleVoteSchema.safeParse(input);
+ if (!parsed.success) return { ok: false, error: "Invalid input" };
+
+ const session = await auth();
+ if (!session?.user?.id) return { ok: false, error: "Unauthorized" };
+
+ const post = await db.post.findUnique({
+ where: { id: parsed.data.postId },
+ select: { id: true, board: { select: { slug: true, isPublic: true } } },
+ });
+ if (!post || !post.board.isPublic) return { ok: false, error: "Post not found" };
+
+ const existing = await db.vote.findUnique({
+ where: { postId_userId: { postId: post.id, userId: session.user.id } },
+ select: { id: true },
+ });
+
+ if (existing) {
+ await db.vote.delete({ where: { id: existing.id } });
+ } else {
+ await db.vote.create({
+ data: { postId: post.id, userId: session.user.id },
+ });
+ }
+
+ const voteCount = await db.vote.count({ where: { postId: post.id } });
+
+ revalidatePath(`/b/${post.board.slug}`);
+ revalidatePath(`/b/${post.board.slug}/posts/${post.id}`);
+
+ return { ok: true, data: { hasVoted: !existing, voteCount } };
+}
diff --git a/server/queries/comments.ts b/server/queries/comments.ts
new file mode 100644
index 0000000..d24f9d3
--- /dev/null
+++ b/server/queries/comments.ts
@@ -0,0 +1,17 @@
+import { db } from "@/lib/db";
+
+export type PublicComment = Awaited>[number];
+
+export async function listPostComments(postId: string) {
+ return db.comment.findMany({
+ where: { postId },
+ orderBy: { createdAt: "asc" },
+ select: {
+ id: true,
+ content: true,
+ createdAt: true,
+ authorId: true,
+ author: { select: { name: true, image: true } },
+ },
+ });
+}
diff --git a/server/queries/roadmap.ts b/server/queries/roadmap.ts
new file mode 100644
index 0000000..77b4bfb
--- /dev/null
+++ b/server/queries/roadmap.ts
@@ -0,0 +1,27 @@
+import { db } from "@/lib/db";
+import type { PostStatus } from "@prisma/client";
+
+const ROADMAP_STATUSES: PostStatus[] = ["PLANNED", "IN_PROGRESS", "DONE"];
+
+export type RoadmapPost = Awaited>["PLANNED"][number];
+
+export async function getBoardRoadmap(boardId: string) {
+ const posts = await db.post.findMany({
+ where: { boardId, status: { in: ROADMAP_STATUSES } },
+ orderBy: [{ status: "asc" }, { votes: { _count: "desc" } }],
+ select: {
+ id: true,
+ title: true,
+ content: true,
+ status: true,
+ _count: { select: { votes: true, comments: true } },
+ },
+ });
+
+ type RoadmapPostItem = (typeof posts)[number];
+ return {
+ PLANNED: posts.filter((p: RoadmapPostItem) => p.status === "PLANNED"),
+ IN_PROGRESS: posts.filter((p: RoadmapPostItem) => p.status === "IN_PROGRESS"),
+ DONE: posts.filter((p: RoadmapPostItem) => p.status === "DONE"),
+ };
+}
diff --git a/server/queries/stats.ts b/server/queries/stats.ts
new file mode 100644
index 0000000..0f2bc5d
--- /dev/null
+++ b/server/queries/stats.ts
@@ -0,0 +1,45 @@
+import { db } from "@/lib/db";
+import type { PostStatus } from "@prisma/client";
+
+export type BoardStats = Awaited>;
+export type TopPost = BoardStats["topPosts"][number];
+
+export async function getBoardStats(boardId: string) {
+ const [total, byStatus, totalVotes, totalComments, topPosts] = await Promise.all([
+ db.post.count({ where: { boardId } }),
+ db.post.groupBy({
+ by: ["status"],
+ where: { boardId },
+ _count: { _all: true },
+ }),
+ db.vote.count({ where: { post: { boardId } } }),
+ db.comment.count({ where: { post: { boardId } } }),
+ db.post.findMany({
+ where: { boardId },
+ orderBy: { votes: { _count: "desc" } },
+ take: 5,
+ select: {
+ id: true,
+ title: true,
+ status: true,
+ _count: { select: { votes: true } },
+ },
+ }),
+ ]);
+
+ const statusMap = byStatus.reduce>(
+ (acc: Record, row: (typeof byStatus)[number]) => {
+ acc[row.status as PostStatus] = row._count._all;
+ return acc;
+ },
+ { OPEN: 0, PLANNED: 0, IN_PROGRESS: 0, DONE: 0, REJECTED: 0 },
+ );
+
+ return {
+ total,
+ byStatus: statusMap,
+ totalVotes,
+ totalComments,
+ topPosts,
+ };
+}