"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; import { adminAction, authAction } from "@/lib/safe-action"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared"; import { logAudit } from "@/lib/services/audit"; import { createPollSchema, pollQuestionSchema, updatePollSchema, voteOnPollSchema, } from "@/lib/validators/poll"; // ── Polls ─────────────────────────────────────────────────────────── export const createPoll = adminAction( { permission: PERMS.POLLS_EDIT, schema: createPollSchema }, async (ctx) => { const poll = await prisma.websitePoll.create({ data: ctx.data }); logAudit({ userId: ctx.session.user.id, action: "poll_create", target: "WebsitePoll", targetId: poll.id, after: { title: poll.title }, }); return actionOk({ id: poll.id }); }, ); const updatePollInput = updatePollSchema.extend({ id: z.coerce.number().int().positive(), }); export const updatePoll = adminAction( { permission: PERMS.POLLS_EDIT, schema: updatePollInput }, async (ctx) => { const { id, ...data } = ctx.data; const existing = await prisma.websitePoll.findUnique({ where: { id } }); if (!existing) throw new ActionError("Poll not found"); await prisma.websitePoll.update({ where: { id }, data }); logAudit({ userId: ctx.session.user.id, action: "poll_update", target: "WebsitePoll", targetId: id, before: { title: existing.title, status: existing.status }, after: data, }); return actionOk({ id }); }, ); const deletePollInput = z.object({ id: z.coerce.number().int().positive(), }); export const deletePoll = adminAction( { permission: PERMS.POLLS_EDIT, schema: deletePollInput }, async (ctx) => { const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id }, }); if (!existing) throw new ActionError("Poll not found"); await prisma.websitePoll.delete({ where: { id: ctx.data.id } }); logAudit({ userId: ctx.session.user.id, action: "poll_delete", target: "WebsitePoll", targetId: ctx.data.id, before: { title: existing.title }, }); return actionOk(); }, ); // ── Questions ─────────────────────────────────────────────────────── export const addPollQuestion = adminAction( { permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema }, async (ctx) => { const question = await prisma.websitePollQuestion.create({ data: ctx.data, }); return actionOk({ id: question.id }); }, ); const updateQuestionInput = pollQuestionSchema.partial().extend({ id: z.coerce.number().int().positive(), }); export const updatePollQuestion = adminAction( { permission: PERMS.POLLS_EDIT, schema: updateQuestionInput }, async (ctx) => { const { id, ...data } = ctx.data; await prisma.websitePollQuestion.update({ where: { id }, data }); return actionOk({ id }); }, ); const deleteQuestionInput = z.object({ id: z.coerce.number().int().positive(), }); export const deletePollQuestion = adminAction( { permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput }, async (ctx) => { await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } }); return actionOk(); }, ); // ── Public site: vote ─────────────────────────────────────────────── function parsePollOptions(options: string): string[] { return options .split("\n") .map((o) => o.trim()) .filter(Boolean); } export const voteOnPoll = authAction( { schema: voteOnPollSchema, rateLimitKey: "poll-vote", rateLimitMax: 20, rateLimitWindowMs: 60_000, }, async (ctx) => { const userId = Number(ctx.session.user.id); if (!Number.isInteger(userId) || userId <= 0) { return actionError("Unauthorized"); } const poll = await prisma.websitePoll.findUnique({ where: { id: ctx.data.pollId }, include: { questions: true }, }); if (!poll) return actionError("Poll not found"); if (poll.status !== "active") { return actionError("This poll is not open for voting"); } const now = Date.now(); if (poll.startsAt && poll.startsAt.getTime() > now) { return actionError("This poll has not started yet"); } if (poll.endsAt && poll.endsAt.getTime() < now) { return actionError("This poll has ended"); } const questionById = new Map(poll.questions.map((q) => [q.id, q])); const seen = new Set(); for (const vote of ctx.data.votes) { if (seen.has(vote.questionId)) { return actionError("Duplicate vote for the same question"); } seen.add(vote.questionId); const question = questionById.get(vote.questionId); if (!question || question.pollId !== poll.id) { return actionError("Invalid question for this poll"); } const answer = vote.answer.trim(); if (!answer) return actionError("Answer is required"); if (question.type === "text") { if (answer.length > 500) { return actionError("Answer is too long"); } } else { const options = parsePollOptions(question.options); if (question.type === "multiple") { const selected = answer .split("\n") .map((a) => a.trim()) .filter(Boolean); if (selected.length === 0) { return actionError("Select at least one option"); } if (selected.some((a) => !options.includes(a))) { return actionError("Invalid option selected"); } } else if (!options.includes(answer)) { return actionError("Invalid option selected"); } } const existing = await prisma.websitePollVote.findUnique({ where: { questionId_userId: { questionId: vote.questionId, userId, }, }, }); if (existing) { return actionError("You have already voted on this poll"); } } await prisma.$transaction( ctx.data.votes.map((vote) => prisma.websitePollVote.create({ data: { questionId: vote.questionId, userId, answer: vote.answer.trim(), }, }), ), ); revalidatePath("/polls"); revalidatePath(`/polls/${poll.id}`); return actionOk({ pollId: poll.id }); }, );