diff --git a/src/actions/polls.ts b/src/actions/polls.ts index 63ea030c..2abb4f34 100644 --- a/src/actions/polls.ts +++ b/src/actions/polls.ts @@ -10,11 +10,17 @@ import { 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, @@ -54,12 +60,19 @@ export const updatePoll = adminAction( .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) @@ -113,7 +126,7 @@ export const addPollQuestion = adminAction( }, ); -const updateQuestionInput = pollQuestionSchema.partial().extend({ +const updateQuestionInput = pollQuestionPatchSchema.extend({ id: z.coerce.number().int().positive(), }); @@ -121,6 +134,21 @@ 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) @@ -145,13 +173,6 @@ export const deletePollQuestion = adminAction( // ── Public site: vote ─────────────────────────────────────────────── -function parsePollOptions(options: string): string[] { - return options - .split("\n") - .map((o) => o.trim()) - .filter(Boolean); -} - export const voteOnPoll = authAction( { schema: voteOnPollSchema, @@ -215,26 +236,25 @@ export const voteOnPoll = authAction( 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 { - 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)) { + } 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 diff --git a/src/app/(site)/polls/[id]/page.tsx b/src/app/(site)/polls/[id]/page.tsx index 3c92de01..270b4543 100644 --- a/src/app/(site)/polls/[id]/page.tsx +++ b/src/app/(site)/polls/[id]/page.tsx @@ -11,6 +11,11 @@ import { WebsitePollVote, } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; +import { + type PollQuestionType, + parsePollAnswerSelections, + parsePollOptions, +} from "@/lib/polls/poll-semantics"; import { PollVoteForm } from "./poll-vote-form"; export default async function PollDetailPage({ @@ -158,10 +163,7 @@ export default async function PollDetailPage({
{questions.map((q) => { - const options = q.options - .split("\n") - .map((o) => o.trim()) - .filter(Boolean); + const options = parsePollOptions(q.options); const votes = votesByQuestion.get(q.id) ?? []; const total = votes.length; @@ -181,10 +183,10 @@ export default async function PollDetailPage({ const counts = new Map(); for (const opt of options) counts.set(opt, 0); for (const vote of votes) { - for (const part of vote - .split("\n") - .map((a) => a.trim()) - .filter(Boolean)) { + for (const part of parsePollAnswerSelections( + q.type as PollQuestionType, + vote, + )) { counts.set(part, (counts.get(part) ?? 0) + 1); } } diff --git a/src/app/(site)/polls/[id]/poll-vote-form.tsx b/src/app/(site)/polls/[id]/poll-vote-form.tsx index d3681d2c..7ee0e50f 100644 --- a/src/app/(site)/polls/[id]/poll-vote-form.tsx +++ b/src/app/(site)/polls/[id]/poll-vote-form.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { voteOnPoll } from "@/actions/polls"; import { useServerAction } from "@/hooks/use-server-action"; +import { parsePollOptions } from "@/lib/polls/poll-semantics"; interface Question { id: number; @@ -13,13 +14,6 @@ interface Question { options: string; } -function parseOptions(options: string): string[] { - return options - .split("\n") - .map((o) => o.trim()) - .filter(Boolean); -} - export function PollVoteForm({ pollId, questions, @@ -75,7 +69,7 @@ export function PollVoteForm({ return (
{questions.map((q) => { - const options = parseOptions(q.options); + const options = parsePollOptions(q.options); return (
{ + it("keeps newline-delimited multiple-choice answers", () => { + expect(parsePollAnswerSelections("multiple", "Red\nBlue\n")).toEqual([ + "Red", + "Blue", + ]); + }); + + it("keeps one trimmed answer for single and text questions", () => { + expect(parsePollAnswerSelections("single", " Red ")).toEqual(["Red"]); + expect(parsePollAnswerSelections("text", " Detailed answer ")).toEqual([ + "Detailed answer", + ]); + }); + + it("normalizes option lines without reordering them", () => { + expect(parsePollOptions(" Red \r\n\nBlue ")).toEqual(["Red", "Blue"]); + expect(serializePollOptions("text", "ignored")).toBe(""); + expect(serializePollOptions("multiple", " Red \nBlue ")).toBe("Red\nBlue"); + }); +}); diff --git a/src/lib/polls/poll-semantics.ts b/src/lib/polls/poll-semantics.ts new file mode 100644 index 00000000..fd01ce2a --- /dev/null +++ b/src/lib/polls/poll-semantics.ts @@ -0,0 +1,26 @@ +export const POLL_QUESTION_TYPES = ["single", "multiple", "text"] as const; +export type PollQuestionType = (typeof POLL_QUESTION_TYPES)[number]; + +export function parsePollOptions(value: string): string[] { + return value + .split(/\r?\n/u) + .map((option) => option.normalize("NFC").trim()) + .filter(Boolean); +} + +export function serializePollOptions( + type: PollQuestionType, + value: string, +): string { + return type === "text" ? "" : parsePollOptions(value).join("\n"); +} + +export function parsePollAnswerSelections( + type: PollQuestionType, + answer: string, +): string[] { + const normalized = answer.normalize("NFC"); + return type === "multiple" + ? parsePollOptions(normalized) + : [normalized.trim()].filter(Boolean); +} diff --git a/src/lib/validators/poll.test.ts b/src/lib/validators/poll.test.ts index 28ffc69c..94302020 100644 --- a/src/lib/validators/poll.test.ts +++ b/src/lib/validators/poll.test.ts @@ -50,7 +50,7 @@ describe("pollQuestionSchema", () => { const result = pollQuestionSchema.safeParse({ pollId: 1, question: "What is your favorite color?", - options: "Red|Blue|Green", + options: "Red\nBlue\nGreen", }); expect(result.success).toBe(true); if (result.success) { @@ -71,6 +71,58 @@ describe("pollQuestionSchema", () => { pollQuestionSchema.safeParse({ question: "Test?", options: "A" }).success, ).toBe(false); }); + + it("accepts text questions without options", () => { + expect( + pollQuestionSchema.safeParse({ + pollId: 1, + question: "Why?", + type: "text", + options: "", + }).success, + ).toBe(true); + }); + + it.each([ + ["one option", "single", "Only"], + ["duplicate options", "multiple", "Red\nred"], + ["options on text", "text", "Not allowed"], + ] as const)("rejects %s", (_label, type, options) => { + expect( + pollQuestionSchema.safeParse({ + pollId: 1, + question: "Question", + type, + options, + }).success, + ).toBe(false); + }); + + it("rejects more than 100 options", () => { + const options = Array.from( + { length: 101 }, + (_, index) => `Option ${index}`, + ).join("\n"); + expect( + pollQuestionSchema.safeParse({ + pollId: 1, + question: "Question", + type: "single", + options, + }).success, + ).toBe(false); + }); +}); +describe("poll schedule validation", () => { + it("requires the end time to be later than the start time", () => { + expect( + createPollSchema.safeParse({ + title: "Schedule", + startsAt: "2026-09-02T12:00:00.000Z", + endsAt: "2026-09-02T11:59:00.000Z", + }).success, + ).toBe(false); + }); }); describe("pollVoteSchema", () => { diff --git a/src/lib/validators/poll.ts b/src/lib/validators/poll.ts index 96557d23..4658f109 100644 --- a/src/lib/validators/poll.ts +++ b/src/lib/validators/poll.ts @@ -1,8 +1,12 @@ import { z } from "zod"; +import { + POLL_QUESTION_TYPES, + parsePollOptions, +} from "@/lib/polls/poll-semantics"; -export const createPollSchema = z.object({ - title: z.string().min(1, "Title is required").max(255), - description: z.string().max(2000).nullable().optional(), +const pollFields = z.object({ + title: z.string().trim().min(1, "Title is required").max(255), + description: z.string().max(2_000).nullable().optional(), status: z.enum(["draft", "active", "closed"]).default("draft"), showResults: z.coerce.number().int().min(0).max(1).default(1), multipleChoice: z.coerce.number().int().min(0).max(1).default(0), @@ -10,16 +14,71 @@ export const createPollSchema = z.object({ endsAt: z.coerce.date().nullable().optional(), }); -export const updatePollSchema = createPollSchema.partial(); +function validateSchedule( + data: { readonly startsAt?: Date | null; readonly endsAt?: Date | null }, + context: z.RefinementCtx, +): void { + if (data.startsAt && data.endsAt && data.endsAt <= data.startsAt) { + context.addIssue({ + code: "custom", + path: ["endsAt"], + message: "End time must be later than start time", + }); + } +} -export const pollQuestionSchema = z.object({ +export const createPollSchema = pollFields.superRefine(validateSchedule); +export const updatePollSchema = pollFields + .partial() + .superRefine(validateSchedule); + +const pollQuestionFields = z.object({ pollId: z.coerce.number().int().positive(), - question: z.string().min(1).max(500), - type: z.enum(["single", "multiple", "text"]).default("single"), + question: z.string().trim().min(1).max(500), + type: z.enum(POLL_QUESTION_TYPES).default("single"), sortOrder: z.coerce.number().int().min(0).default(0), - options: z.string().min(1, "Options are required"), + options: z.string().max(20_000).default(""), }); +function validateQuestionOptions( + data: z.infer, + context: z.RefinementCtx, +): void { + const options = parsePollOptions(data.options); + const unique = new Set(options.map((option) => option.toLocaleLowerCase())); + if (data.type === "text" && options.length > 0) + context.addIssue({ + code: "custom", + path: ["options"], + message: "Text questions cannot have options", + }); + if (data.type !== "text" && options.length < 2) + context.addIssue({ + code: "custom", + path: ["options"], + message: "At least two options are required", + }); + if (unique.size !== options.length) + context.addIssue({ + code: "custom", + path: ["options"], + message: "Options must be unique", + }); + if (options.length > 100) + context.addIssue({ + code: "custom", + path: ["options"], + message: "At most 100 options are allowed", + }); +} + +export const pollQuestionSchema = pollQuestionFields.superRefine( + validateQuestionOptions, +); +export const pollQuestionPatchSchema = pollQuestionFields + .omit({ pollId: true }) + .partial(); + export const pollVoteSchema = z.object({ questionId: z.coerce.number().int().positive(), answer: z.string().min(1).max(500),