feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m55s

Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:08:33 +02:00
1 parent 2ff08e5127
commit ed7db6e048
76 files changed
+4834 -1376

No files matched your search

+113 -2
View File
@@ -1,15 +1,17 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
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 ───────────────────────────────────────────────────────────
@@ -113,3 +115,112 @@ export const deletePollQuestion = adminAction(
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<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");
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 });
},
);