322 lines
9.1 KiB
TypeScript
322 lines
9.1 KiB
TypeScript
"use server";
|
|
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
import {
|
|
db,
|
|
WebsitePoll,
|
|
WebsitePollQuestion,
|
|
WebsitePollVote,
|
|
} from "@/lib/db";
|
|
import { PERMS } from "@/lib/permissions";
|
|
import {
|
|
type PollQuestionType,
|
|
parsePollAnswerSelections,
|
|
parsePollOptions,
|
|
} from "@/lib/polls/poll-semantics";
|
|
import { adminAction, authAction } from "@/lib/safe-action";
|
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
|
import { logAudit } from "@/lib/services/audit";
|
|
import {
|
|
createPollSchema,
|
|
pollQuestionPatchSchema,
|
|
pollQuestionSchema,
|
|
updatePollSchema,
|
|
voteOnPollSchema,
|
|
} from "@/lib/validators/poll";
|
|
|
|
// ── Polls ───────────────────────────────────────────────────────────
|
|
|
|
export const createPoll = adminAction(
|
|
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
|
|
async (ctx) => {
|
|
const now = new Date();
|
|
const [result] = await db.insert(WebsitePoll).values({
|
|
...ctx.data,
|
|
updatedAt: now,
|
|
});
|
|
const pollId = Number(result.insertId);
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: "poll_create",
|
|
target: "WebsitePoll",
|
|
targetId: pollId,
|
|
after: { title: ctx.data.title },
|
|
});
|
|
return actionOk({ id: pollId });
|
|
},
|
|
);
|
|
|
|
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 db
|
|
.select({
|
|
id: WebsitePoll.id,
|
|
title: WebsitePoll.title,
|
|
description: WebsitePoll.description,
|
|
status: WebsitePoll.status,
|
|
showResults: WebsitePoll.showResults,
|
|
multipleChoice: WebsitePoll.multipleChoice,
|
|
startsAt: WebsitePoll.startsAt,
|
|
endsAt: WebsitePoll.endsAt,
|
|
})
|
|
.from(WebsitePoll)
|
|
.where(eq(WebsitePoll.id, id))
|
|
.limit(1);
|
|
if (!existing) throw new ActionError("Poll not found");
|
|
const merged = createPollSchema.safeParse({ ...existing, ...data });
|
|
if (!merged.success) throw new ActionError("Invalid poll schedule");
|
|
|
|
await db
|
|
.update(WebsitePoll)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(eq(WebsitePoll.id, id));
|
|
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 db.transaction(
|
|
async (tx) => {
|
|
const [poll] = await tx
|
|
.select({ id: WebsitePoll.id, title: WebsitePoll.title })
|
|
.from(WebsitePoll)
|
|
.where(eq(WebsitePoll.id, ctx.data.id))
|
|
.for("update");
|
|
if (!poll) throw new ActionError("Poll not found");
|
|
|
|
const questionRows = await tx
|
|
.select({ id: WebsitePollQuestion.id })
|
|
.from(WebsitePollQuestion)
|
|
.where(eq(WebsitePollQuestion.pollId, poll.id))
|
|
.orderBy(WebsitePollQuestion.id);
|
|
for (const question of questionRows) {
|
|
await tx
|
|
.select({ id: WebsitePollQuestion.id })
|
|
.from(WebsitePollQuestion)
|
|
.where(eq(WebsitePollQuestion.id, question.id))
|
|
.for("update");
|
|
}
|
|
|
|
const questionIds = questionRows.map(({ id }) => id);
|
|
if (questionIds.length > 0) {
|
|
await tx
|
|
.delete(WebsitePollVote)
|
|
.where(inArray(WebsitePollVote.questionId, questionIds));
|
|
}
|
|
await tx
|
|
.delete(WebsitePollQuestion)
|
|
.where(eq(WebsitePollQuestion.pollId, poll.id));
|
|
await tx.delete(WebsitePoll).where(eq(WebsitePoll.id, poll.id));
|
|
return poll;
|
|
},
|
|
{ isolationLevel: "read committed" },
|
|
);
|
|
|
|
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 [result] = await db.insert(WebsitePollQuestion).values(ctx.data);
|
|
return actionOk({ id: Number(result.insertId) });
|
|
},
|
|
);
|
|
|
|
const updateQuestionInput = pollQuestionPatchSchema.extend({
|
|
id: z.coerce.number().int().positive(),
|
|
});
|
|
|
|
export const updatePollQuestion = adminAction(
|
|
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
|
async (ctx) => {
|
|
const { id, ...data } = ctx.data;
|
|
const [existing] = await db
|
|
.select({
|
|
pollId: WebsitePollQuestion.pollId,
|
|
question: WebsitePollQuestion.question,
|
|
type: WebsitePollQuestion.type,
|
|
sortOrder: WebsitePollQuestion.sortOrder,
|
|
options: WebsitePollQuestion.options,
|
|
})
|
|
.from(WebsitePollQuestion)
|
|
.where(eq(WebsitePollQuestion.id, id))
|
|
.limit(1);
|
|
if (!existing) throw new ActionError("Poll question not found");
|
|
const merged = pollQuestionSchema.safeParse({ ...existing, ...data });
|
|
if (!merged.success) throw new ActionError("Invalid poll question");
|
|
|
|
await db
|
|
.update(WebsitePollQuestion)
|
|
.set(data)
|
|
.where(eq(WebsitePollQuestion.id, id));
|
|
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 db
|
|
.delete(WebsitePollQuestion)
|
|
.where(eq(WebsitePollQuestion.id, ctx.data.id));
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
// ── Public site: vote ───────────────────────────────────────────────
|
|
|
|
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 db
|
|
.select({
|
|
id: WebsitePoll.id,
|
|
status: WebsitePoll.status,
|
|
startsAt: WebsitePoll.startsAt,
|
|
endsAt: WebsitePoll.endsAt,
|
|
})
|
|
.from(WebsitePoll)
|
|
.where(eq(WebsitePoll.id, ctx.data.pollId))
|
|
.limit(1);
|
|
|
|
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 questions = await db
|
|
.select({
|
|
id: WebsitePollQuestion.id,
|
|
pollId: WebsitePollQuestion.pollId,
|
|
type: WebsitePollQuestion.type,
|
|
options: WebsitePollQuestion.options,
|
|
})
|
|
.from(WebsitePollQuestion)
|
|
.where(eq(WebsitePollQuestion.pollId, poll.id));
|
|
|
|
const questionById = new Map(questions.map((q) => [q.id, q]));
|
|
const seen = new Set<number>();
|
|
|
|
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");
|
|
|
|
const options = parsePollOptions(question.options);
|
|
const selected = parsePollAnswerSelections(
|
|
question.type as PollQuestionType,
|
|
answer,
|
|
);
|
|
|
|
if (question.type === "text") {
|
|
if (answer.length > 500) {
|
|
return actionError("Answer is too long");
|
|
}
|
|
} else if (question.type === "multiple") {
|
|
if (selected.length === 0) {
|
|
return actionError("Select at least one option");
|
|
}
|
|
if (selected.some((selection) => !options.includes(selection))) {
|
|
return actionError("Invalid option selected");
|
|
}
|
|
} else if (!options.includes(selected[0] ?? "")) {
|
|
return actionError("Invalid option selected");
|
|
}
|
|
|
|
const [existing] = await db
|
|
.select({ id: WebsitePollVote.id })
|
|
.from(WebsitePollVote)
|
|
.where(
|
|
and(
|
|
eq(WebsitePollVote.questionId, vote.questionId),
|
|
eq(WebsitePollVote.userId, userId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (existing) {
|
|
return actionError("You have already voted on this poll");
|
|
}
|
|
}
|
|
|
|
const votesToInsert = [...ctx.data.votes].sort(
|
|
(left, right) => left.questionId - right.questionId,
|
|
);
|
|
await db.transaction(async (tx) => {
|
|
for (const vote of votesToInsert) {
|
|
await tx.insert(WebsitePollVote).values({
|
|
questionId: vote.questionId,
|
|
userId,
|
|
answer: vote.answer.trim(),
|
|
});
|
|
}
|
|
});
|
|
|
|
revalidatePath("/polls");
|
|
revalidatePath(`/polls/${poll.id}`);
|
|
return actionOk({ pollId: poll.id });
|
|
},
|
|
);
|