refactor(polls): centralize question semantics
This commit is contained in:
1 parent
66b606961b
commit
abb04f8362
7 files changed
+228
-47
No files matched your search
+42
-22
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user