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,
|
WebsitePollVote,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
type PollQuestionType,
|
||||||
|
parsePollAnswerSelections,
|
||||||
|
parsePollOptions,
|
||||||
|
} from "@/lib/polls/poll-semantics";
|
||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import {
|
import {
|
||||||
createPollSchema,
|
createPollSchema,
|
||||||
|
pollQuestionPatchSchema,
|
||||||
pollQuestionSchema,
|
pollQuestionSchema,
|
||||||
updatePollSchema,
|
updatePollSchema,
|
||||||
voteOnPollSchema,
|
voteOnPollSchema,
|
||||||
@@ -54,12 +60,19 @@ export const updatePoll = adminAction(
|
|||||||
.select({
|
.select({
|
||||||
id: WebsitePoll.id,
|
id: WebsitePoll.id,
|
||||||
title: WebsitePoll.title,
|
title: WebsitePoll.title,
|
||||||
|
description: WebsitePoll.description,
|
||||||
status: WebsitePoll.status,
|
status: WebsitePoll.status,
|
||||||
|
showResults: WebsitePoll.showResults,
|
||||||
|
multipleChoice: WebsitePoll.multipleChoice,
|
||||||
|
startsAt: WebsitePoll.startsAt,
|
||||||
|
endsAt: WebsitePoll.endsAt,
|
||||||
})
|
})
|
||||||
.from(WebsitePoll)
|
.from(WebsitePoll)
|
||||||
.where(eq(WebsitePoll.id, id))
|
.where(eq(WebsitePoll.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing) throw new ActionError("Poll not found");
|
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
|
await db
|
||||||
.update(WebsitePoll)
|
.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(),
|
id: z.coerce.number().int().positive(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,6 +134,21 @@ export const updatePollQuestion = adminAction(
|
|||||||
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
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
|
await db
|
||||||
.update(WebsitePollQuestion)
|
.update(WebsitePollQuestion)
|
||||||
.set(data)
|
.set(data)
|
||||||
@@ -145,13 +173,6 @@ export const deletePollQuestion = adminAction(
|
|||||||
|
|
||||||
// ── Public site: vote ───────────────────────────────────────────────
|
// ── Public site: vote ───────────────────────────────────────────────
|
||||||
|
|
||||||
function parsePollOptions(options: string): string[] {
|
|
||||||
return options
|
|
||||||
.split("\n")
|
|
||||||
.map((o) => o.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const voteOnPoll = authAction(
|
export const voteOnPoll = authAction(
|
||||||
{
|
{
|
||||||
schema: voteOnPollSchema,
|
schema: voteOnPollSchema,
|
||||||
@@ -215,26 +236,25 @@ export const voteOnPoll = authAction(
|
|||||||
const answer = vote.answer.trim();
|
const answer = vote.answer.trim();
|
||||||
if (!answer) return actionError("Answer is required");
|
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 (question.type === "text") {
|
||||||
if (answer.length > 500) {
|
if (answer.length > 500) {
|
||||||
return actionError("Answer is too long");
|
return actionError("Answer is too long");
|
||||||
}
|
}
|
||||||
} else {
|
} else if (question.type === "multiple") {
|
||||||
const options = parsePollOptions(question.options);
|
if (selected.length === 0) {
|
||||||
if (question.type === "multiple") {
|
return actionError("Select at least one option");
|
||||||
const selected = answer
|
}
|
||||||
.split("\n")
|
if (selected.some((selection) => !options.includes(selection))) {
|
||||||
.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");
|
return actionError("Invalid option selected");
|
||||||
}
|
}
|
||||||
|
} else if (!options.includes(selected[0] ?? "")) {
|
||||||
|
return actionError("Invalid option selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
WebsitePollVote,
|
WebsitePollVote,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import {
|
||||||
|
type PollQuestionType,
|
||||||
|
parsePollAnswerSelections,
|
||||||
|
parsePollOptions,
|
||||||
|
} from "@/lib/polls/poll-semantics";
|
||||||
import { PollVoteForm } from "./poll-vote-form";
|
import { PollVoteForm } from "./poll-vote-form";
|
||||||
|
|
||||||
export default async function PollDetailPage({
|
export default async function PollDetailPage({
|
||||||
@@ -158,10 +163,7 @@ export default async function PollDetailPage({
|
|||||||
<ContentCard icon="📈" title={t("resultsTitle")}>
|
<ContentCard icon="📈" title={t("resultsTitle")}>
|
||||||
<div style={{ display: "grid", gap: "1.25rem" }}>
|
<div style={{ display: "grid", gap: "1.25rem" }}>
|
||||||
{questions.map((q) => {
|
{questions.map((q) => {
|
||||||
const options = q.options
|
const options = parsePollOptions(q.options);
|
||||||
.split("\n")
|
|
||||||
.map((o) => o.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const votes = votesByQuestion.get(q.id) ?? [];
|
const votes = votesByQuestion.get(q.id) ?? [];
|
||||||
const total = votes.length;
|
const total = votes.length;
|
||||||
|
|
||||||
@@ -181,10 +183,10 @@ export default async function PollDetailPage({
|
|||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const opt of options) counts.set(opt, 0);
|
for (const opt of options) counts.set(opt, 0);
|
||||||
for (const vote of votes) {
|
for (const vote of votes) {
|
||||||
for (const part of vote
|
for (const part of parsePollAnswerSelections(
|
||||||
.split("\n")
|
q.type as PollQuestionType,
|
||||||
.map((a) => a.trim())
|
vote,
|
||||||
.filter(Boolean)) {
|
)) {
|
||||||
counts.set(part, (counts.get(part) ?? 0) + 1);
|
counts.set(part, (counts.get(part) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { voteOnPoll } from "@/actions/polls";
|
import { voteOnPoll } from "@/actions/polls";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
import { parsePollOptions } from "@/lib/polls/poll-semantics";
|
||||||
|
|
||||||
interface Question {
|
interface Question {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,13 +14,6 @@ interface Question {
|
|||||||
options: string;
|
options: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOptions(options: string): string[] {
|
|
||||||
return options
|
|
||||||
.split("\n")
|
|
||||||
.map((o) => o.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PollVoteForm({
|
export function PollVoteForm({
|
||||||
pollId,
|
pollId,
|
||||||
questions,
|
questions,
|
||||||
@@ -75,7 +69,7 @@ export function PollVoteForm({
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} style={{ display: "grid", gap: "1.25rem" }}>
|
<form onSubmit={handleSubmit} style={{ display: "grid", gap: "1.25rem" }}>
|
||||||
{questions.map((q) => {
|
{questions.map((q) => {
|
||||||
const options = parseOptions(q.options);
|
const options = parsePollOptions(q.options);
|
||||||
return (
|
return (
|
||||||
<fieldset
|
<fieldset
|
||||||
key={q.id}
|
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({
|
const result = pollQuestionSchema.safeParse({
|
||||||
pollId: 1,
|
pollId: 1,
|
||||||
question: "What is your favorite color?",
|
question: "What is your favorite color?",
|
||||||
options: "Red|Blue|Green",
|
options: "Red\nBlue\nGreen",
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -71,6 +71,58 @@ describe("pollQuestionSchema", () => {
|
|||||||
pollQuestionSchema.safeParse({ question: "Test?", options: "A" }).success,
|
pollQuestionSchema.safeParse({ question: "Test?", options: "A" }).success,
|
||||||
).toBe(false);
|
).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", () => {
|
describe("pollVoteSchema", () => {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
POLL_QUESTION_TYPES,
|
||||||
|
parsePollOptions,
|
||||||
|
} from "@/lib/polls/poll-semantics";
|
||||||
|
|
||||||
export const createPollSchema = z.object({
|
const pollFields = z.object({
|
||||||
title: z.string().min(1, "Title is required").max(255),
|
title: z.string().trim().min(1, "Title is required").max(255),
|
||||||
description: z.string().max(2000).nullable().optional(),
|
description: z.string().max(2_000).nullable().optional(),
|
||||||
status: z.enum(["draft", "active", "closed"]).default("draft"),
|
status: z.enum(["draft", "active", "closed"]).default("draft"),
|
||||||
showResults: z.coerce.number().int().min(0).max(1).default(1),
|
showResults: z.coerce.number().int().min(0).max(1).default(1),
|
||||||
multipleChoice: z.coerce.number().int().min(0).max(1).default(0),
|
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(),
|
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(),
|
pollId: z.coerce.number().int().positive(),
|
||||||
question: z.string().min(1).max(500),
|
question: z.string().trim().min(1).max(500),
|
||||||
type: z.enum(["single", "multiple", "text"]).default("single"),
|
type: z.enum(POLL_QUESTION_TYPES).default("single"),
|
||||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
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({
|
export const pollVoteSchema = z.object({
|
||||||
questionId: z.coerce.number().int().positive(),
|
questionId: z.coerce.number().int().positive(),
|
||||||
answer: z.string().min(1).max(500),
|
answer: z.string().min(1).max(500),
|
||||||
|
|||||||
Reference in new issue
Block a user