refactor(polls): centralize question semantics

This commit is contained in:
Simo committed 2026-09-01 22:15:44 +02:00
1 parent 66b606961b
commit abb04f8362
7 files changed
+228 -47

No files matched your search

+42 -22
View File
@@ -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
+10 -8
View File
@@ -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({
<ContentCard icon="📈" title={t("resultsTitle")}>
<div style={{ display: "grid", gap: "1.25rem" }}>
{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<string, number>();
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);
}
}
+2 -8
View File
@@ -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 (
<form onSubmit={handleSubmit} style={{ display: "grid", gap: "1.25rem" }}>
{questions.map((q) => {
const options = parseOptions(q.options);
const options = parsePollOptions(q.options);
return (
<fieldset
key={q.id}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
parsePollAnswerSelections,
parsePollOptions,
serializePollOptions,
} from "./poll-semantics";
describe("public poll compatibility", () => {
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");
});
});
+26
View File
@@ -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);
}
+53 -1
View File
@@ -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", () => {
+67 -8
View File
@@ -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<typeof pollQuestionFields>,
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),