WIP: Unified Housekeeping rebuild / Gecombineerde Housekeeping-herbouw #53

Closed
Simo wants to merge 68 commits from codex/housekeeping-rebuild-stepwise into main
pull from: codex/housekeeping-rebuild-stepwise
7 changed files with 547 additions and 61 deletions
Showing only changes of commit 50598472ad - Show all commits

No files matched your search

File diff suppressed because it is too large. Load diff
+172 -34
View File
File diff suppressed because it is too large. Load diff
@@ -486,14 +486,17 @@ describe("Content database mutation runtime partial updates", () => {
});
it("canonicalizes submitted question options and preserves omitted columns", async () => {
database.selected({
database.queueSelected(
{
id: 7,
pollId: 3,
question: "Favourite colour?",
type: "single",
sortOrder: 0,
options: "Red\nBlue",
});
},
[],
);
await executeContentDatabaseMutation(
"poll-question.change",
@@ -508,6 +511,53 @@ describe("Content database mutation runtime partial updates", () => {
]);
});
it("rejects semantic question edits after votes exist", async () => {
database.queueSelected(
{
id: 7,
pollId: 3,
question: "Favourite colour?",
type: "single",
sortOrder: 0,
options: "Red\nBlue",
},
{ id: 91 },
);
await expect(
executeContentDatabaseMutation(
"poll-question.change",
{ action: "update", id: 7, options: "Green\nYellow" },
context,
undefined,
),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(database.set).not.toHaveBeenCalled();
});
it("allows sort-order-only question edits after votes exist", async () => {
database.queueSelected(
{
id: 7,
pollId: 3,
question: "Favourite colour?",
type: "single",
sortOrder: 0,
options: "Red\nBlue",
},
{ id: 91 },
);
await executeContentDatabaseMutation(
"poll-question.change",
{ action: "update", id: 7, sortOrder: 2 },
context,
undefined,
);
expect(database.set).toHaveBeenCalledWith({ sortOrder: 2 });
});
it("blocks deletion of an event type referenced by an event", async () => {
database.queueSelected({ id: 7, name: "Tournament" }, { id: 11 });
@@ -26,7 +26,13 @@ import {
} from "@/lib/db";
import { slugify } from "@/lib/format";
import { canonicalize } from "@/lib/foundation/security";
import { serializePollOptions } from "@/lib/polls/poll-semantics";
import {
hasPollQuestionSemanticChange,
POLL_PUBLIC_QUESTION_LIMIT,
type PollQuestionType,
pollQuestionLimitReached,
serializePollOptions,
} from "@/lib/polls/poll-semantics";
import { logStaffActivity } from "@/lib/services/staff-activity";
import {
createEventSchema,
@@ -682,7 +688,7 @@ async function pollChange(
if (!merged.success) throw validation(merged.error);
if (
merged.data.status === "active" &&
(await pollQuestionCount(connection, id)) > 50
(await pollQuestionCount(connection, id)) > POLL_PUBLIC_QUESTION_LIMIT
) {
throw conflict();
}
@@ -713,10 +719,7 @@ async function pollQuestionChange(
.for("update");
if (!parent) throw notFound();
const questionCount = await pollQuestionCount(connection, pollId);
if (
questionCount >= 100 ||
(parent.status === "active" && questionCount >= 50)
) {
if (pollQuestionLimitReached(parent.status, questionCount)) {
throw conflict();
}
const parsed = pollQuestionSchema.safeParse(data);
@@ -783,6 +786,19 @@ async function pollQuestionChange(
merged.data.options,
);
}
if (
hasPollQuestionSemanticChange(
{ ...existing, type: existing.type as PollQuestionType },
merged.data,
)
) {
const [vote] = await connection
.select({ id: WebsitePollVote.id })
.from(WebsitePollVote)
.where(eq(WebsitePollVote.questionId, id))
.limit(1);
if (vote) throw conflict();
}
await connection
.update(WebsitePollQuestion)
.set(values)
+33
View File
@@ -1,5 +1,8 @@
export const POLL_QUESTION_TYPES = ["single", "multiple", "text"] as const;
export type PollQuestionType = (typeof POLL_QUESTION_TYPES)[number];
export const POLL_PUBLIC_QUESTION_LIMIT = 50;
export const POLL_DRAFT_QUESTION_LIMIT = 100;
export const POLL_ANSWER_MAX_LENGTH = 500;
export function parsePollOptions(value: string): string[] {
return value
@@ -24,3 +27,33 @@ export function parsePollAnswerSelections(
? parsePollOptions(normalized)
: [normalized.trim()].filter(Boolean);
}
export function pollQuestionLimitReached(
status: string,
currentCount: number,
): boolean {
const limit =
status === "active"
? POLL_PUBLIC_QUESTION_LIMIT
: POLL_DRAFT_QUESTION_LIMIT;
return currentCount >= limit;
}
interface PollQuestionSemantics {
readonly question: string;
readonly type: PollQuestionType;
readonly options: string;
}
export function hasPollQuestionSemanticChange(
before: PollQuestionSemantics,
after: PollQuestionSemantics,
): boolean {
return (
before.question.normalize("NFC").trim() !==
after.question.normalize("NFC").trim() ||
before.type !== after.type ||
serializePollOptions(before.type, before.options) !==
serializePollOptions(after.type, after.options)
);
}
+55
View File
@@ -112,6 +112,50 @@ describe("pollQuestionSchema", () => {
}).success,
).toBe(false);
});
it("keeps every single-choice option compatible with the public answer limit", () => {
const exactly500 = `A${"x".repeat(499)}`;
const tooLong = `B${"x".repeat(500)}`;
expect(
pollQuestionSchema.safeParse({
pollId: 1,
question: "Boundary?",
type: "single",
options: `${exactly500}\nShort`,
}).success,
).toBe(true);
expect(
pollQuestionSchema.safeParse({
pollId: 1,
question: "Boundary?",
type: "single",
options: `${tooLong}\nShort`,
}).success,
).toBe(false);
});
it("bounds the possible serialized multiple-choice answer at 500 characters", () => {
const exactly500 = `${"A".repeat(249)}\n${"B".repeat(250)}`;
const tooLong = `${"A".repeat(250)}\n${"B".repeat(250)}`;
expect(
pollQuestionSchema.safeParse({
pollId: 1,
question: "Boundary?",
type: "multiple",
options: exactly500,
}).success,
).toBe(true);
expect(
pollQuestionSchema.safeParse({
pollId: 1,
question: "Boundary?",
type: "multiple",
options: tooLong,
}).success,
).toBe(false);
});
});
describe("poll schedule validation", () => {
it("requires the end time to be later than the start time", () => {
@@ -145,6 +189,17 @@ describe("pollVoteSchema", () => {
pollVoteSchema.safeParse({ questionId: 0, answer: "x" }).success,
).toBe(false);
});
it("accepts 500 characters and rejects 501", () => {
expect(
pollVoteSchema.safeParse({ questionId: 5, answer: "A".repeat(500) })
.success,
).toBe(true);
expect(
pollVoteSchema.safeParse({ questionId: 5, answer: "A".repeat(501) })
.success,
).toBe(false);
});
});
describe("voteOnPollSchema", () => {
+42 -11
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import {
POLL_ANSWER_MAX_LENGTH,
POLL_QUESTION_TYPES,
parsePollOptions,
} from "@/lib/polls/poll-semantics";
@@ -7,9 +8,9 @@ import {
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),
status: z.enum(["draft", "active", "closed"]),
showResults: z.coerce.number().int().min(0).max(1),
multipleChoice: z.coerce.number().int().min(0).max(1),
startsAt: z.coerce.date().nullable().optional(),
endsAt: z.coerce.date().nullable().optional(),
});
@@ -27,7 +28,13 @@ function validateSchedule(
}
}
export const createPollSchema = pollFields.superRefine(validateSchedule);
export const createPollSchema = pollFields
.extend({
status: pollFields.shape.status.default("draft"),
showResults: pollFields.shape.showResults.default(1),
multipleChoice: pollFields.shape.multipleChoice.default(0),
})
.superRefine(validateSchedule);
export const updatePollSchema = pollFields
.partial()
.superRefine(validateSchedule);
@@ -35,9 +42,9 @@ export const updatePollSchema = pollFields
const pollQuestionFields = z.object({
pollId: z.coerce.number().int().positive(),
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().max(20_000).default(""),
type: z.enum(POLL_QUESTION_TYPES),
sortOrder: z.coerce.number().int().min(0),
options: z.string().max(20_000),
});
function validateQuestionOptions(
@@ -70,18 +77,42 @@ function validateQuestionOptions(
path: ["options"],
message: "At most 100 options are allowed",
});
if (
data.type !== "text" &&
options.some((option) => option.length > POLL_ANSWER_MAX_LENGTH)
) {
context.addIssue({
code: "custom",
path: ["options"],
message: "Each option must fit the public answer limit",
});
}
if (
data.type === "multiple" &&
options.join("\n").length > POLL_ANSWER_MAX_LENGTH
) {
context.addIssue({
code: "custom",
path: ["options"],
message: "All selected options must fit the public answer limit",
});
}
}
export const pollQuestionSchema = pollQuestionFields.superRefine(
validateQuestionOptions,
);
export const pollQuestionSchema = pollQuestionFields
.extend({
type: pollQuestionFields.shape.type.default("single"),
sortOrder: pollQuestionFields.shape.sortOrder.default(0),
options: pollQuestionFields.shape.options.default(""),
})
.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),
answer: z.string().min(1).max(POLL_ANSWER_MAX_LENGTH),
});
export const voteOnPollSchema = z.object({