fix(housekeeping): protect poll destructive actions
This commit is contained in:
1 parent
dba58cad4f
commit
3e52965292
4 files changed
+485
-30
No files matched your search
@@ -30,11 +30,17 @@ const expected = [
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
|
||||
["content.engagement.poll.delete", "poll.change", PERMS.POLLS_EDIT],
|
||||
[
|
||||
"content.engagement.poll-question.change",
|
||||
"poll-question.change",
|
||||
PERMS.POLLS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.engagement.poll-question.delete",
|
||||
"poll-question.change",
|
||||
PERMS.POLLS_EDIT,
|
||||
],
|
||||
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||
@@ -194,6 +200,37 @@ describe("Content commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("requires reasons for dedicated poll deletion commands only", () => {
|
||||
const commands = createContentCommands({ execute: vi.fn() } as never);
|
||||
const byId = new Map(commands.map((command) => [command.id, command]));
|
||||
|
||||
for (const id of [
|
||||
"content.engagement.poll.delete",
|
||||
"content.engagement.poll-question.delete",
|
||||
]) {
|
||||
expect(byId.get(id)?.requiresReason, id).toBe(true);
|
||||
expect(
|
||||
byId.get(id)?.input.safeParse({ action: "delete", id: 7 }).success,
|
||||
id,
|
||||
).toBe(true);
|
||||
expect(
|
||||
byId.get(id)?.input.safeParse({ action: "update", id: 7 }).success,
|
||||
id,
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
for (const id of [
|
||||
"content.engagement.poll.change",
|
||||
"content.engagement.poll-question.change",
|
||||
]) {
|
||||
expect(byId.get(id)?.requiresReason, id).toBe(false);
|
||||
expect(
|
||||
byId.get(id)?.input.safeParse({ action: "delete", id: 7 }).success,
|
||||
id,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("executes the real mutation service with actor-bound authority", async () => {
|
||||
const execute = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
|
||||
@@ -38,11 +38,18 @@ const CONTENT_COMMAND_DEFINITIONS = [
|
||||
PERMS.EVENTS_EDIT,
|
||||
],
|
||||
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
|
||||
["content.engagement.poll.delete", "poll.change", PERMS.POLLS_EDIT, true],
|
||||
[
|
||||
"content.engagement.poll-question.change",
|
||||
"poll-question.change",
|
||||
PERMS.POLLS_EDIT,
|
||||
],
|
||||
[
|
||||
"content.engagement.poll-question.delete",
|
||||
"poll-question.change",
|
||||
PERMS.POLLS_EDIT,
|
||||
true,
|
||||
],
|
||||
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||
@@ -129,13 +136,17 @@ function inputForCommand(
|
||||
): z.ZodType<Record<string, unknown>> {
|
||||
if (
|
||||
id === "content.engagement.event.change" ||
|
||||
id === "content.engagement.event-type.change"
|
||||
id === "content.engagement.event-type.change" ||
|
||||
id === "content.engagement.poll.change" ||
|
||||
id === "content.engagement.poll-question.change"
|
||||
) {
|
||||
return eventChangeInput;
|
||||
}
|
||||
if (
|
||||
id === "content.engagement.event.delete" ||
|
||||
id === "content.engagement.event-type.delete"
|
||||
id === "content.engagement.event-type.delete" ||
|
||||
id === "content.engagement.poll.delete" ||
|
||||
id === "content.engagement.poll-question.delete"
|
||||
) {
|
||||
return eventDeleteInput;
|
||||
}
|
||||
|
||||
+288
-4
@@ -17,13 +17,19 @@ const sqlMocks = vi.hoisted(() => ({
|
||||
const database = vi.hoisted(() => {
|
||||
let selected: Record<string, unknown> = { id: 7, title: "Existing" };
|
||||
let selectedQueue: Record<string, unknown>[] = [];
|
||||
const insertedValues = vi.fn(async (_values: Record<string, unknown>) => [
|
||||
{ insertId: 8 },
|
||||
]);
|
||||
const insert = vi.fn((_table: unknown) => ({ values: insertedValues }));
|
||||
const set = vi.fn((values: Record<string, unknown>) => ({
|
||||
where: vi.fn(async () => undefined),
|
||||
values,
|
||||
}));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
const limit = vi.fn(async () => [selectedQueue.shift() ?? selected]);
|
||||
const where = vi.fn(() => ({ limit }));
|
||||
const nextSelected = () => [selectedQueue.shift() ?? selected];
|
||||
const limit = vi.fn(async () => nextSelected());
|
||||
const lock = vi.fn(async () => nextSelected());
|
||||
const where = vi.fn(() => ({ for: lock, limit }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
const execute = vi.fn(async () => undefined);
|
||||
@@ -31,6 +37,9 @@ const database = vi.hoisted(() => {
|
||||
const remove = vi.fn((_table: unknown) => ({ where: deleteWhere }));
|
||||
return {
|
||||
execute,
|
||||
insert,
|
||||
insertedValues,
|
||||
lock,
|
||||
set,
|
||||
update,
|
||||
select,
|
||||
@@ -52,7 +61,9 @@ const database = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
count: vi.fn(() => "count"),
|
||||
eq: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
sql: Object.assign(sqlMocks.tagged, {
|
||||
join: sqlMocks.join,
|
||||
raw: sqlMocks.raw,
|
||||
@@ -72,6 +83,7 @@ vi.mock("@/lib/db", () => {
|
||||
db: {
|
||||
delete: database.delete,
|
||||
execute: database.execute,
|
||||
insert: database.insert,
|
||||
select: database.select,
|
||||
update: database.update,
|
||||
},
|
||||
@@ -90,8 +102,9 @@ vi.mock("@/lib/db", () => {
|
||||
WebsiteEventType: namedTable("WebsiteEventType"),
|
||||
WebsiteEventWinner: namedTable("WebsiteEventWinner"),
|
||||
WebsiteHelpCenterCategories: table,
|
||||
WebsitePoll: table,
|
||||
WebsitePollQuestion: table,
|
||||
WebsitePoll: namedTable("WebsitePoll"),
|
||||
WebsitePollQuestion: namedTable("WebsitePollQuestion"),
|
||||
WebsitePollVote: namedTable("WebsitePollVote"),
|
||||
WebsiteWriteableBoxes: table,
|
||||
};
|
||||
});
|
||||
@@ -136,6 +149,277 @@ describe("Content database mutation runtime partial updates", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("deletes poll votes, questions, and the poll in child-first order", async () => {
|
||||
database.selected({
|
||||
id: 7,
|
||||
title: "Existing",
|
||||
description: null,
|
||||
status: "draft",
|
||||
showResults: 1,
|
||||
multipleChoice: 0,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
});
|
||||
|
||||
await executeContentDatabaseMutation(
|
||||
"poll.change",
|
||||
{ action: "delete", id: 7 },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(database.removedTables()).toEqual([
|
||||
"WebsitePollVote",
|
||||
"WebsitePollQuestion",
|
||||
"WebsitePoll",
|
||||
]);
|
||||
});
|
||||
|
||||
it("deletes question votes before the question and retains its audit snapshot", async () => {
|
||||
database.selected({
|
||||
id: 7,
|
||||
pollId: 3,
|
||||
question: "Favourite colour?",
|
||||
type: "single",
|
||||
sortOrder: 0,
|
||||
options: "Red\nBlue",
|
||||
});
|
||||
|
||||
const snapshot = await executeContentDatabaseMutation(
|
||||
"poll-question.change",
|
||||
{ action: "delete", id: 7 },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(database.removedTables()).toEqual([
|
||||
"WebsitePollVote",
|
||||
"WebsitePollQuestion",
|
||||
]);
|
||||
expect(snapshot?.before).toMatchObject({
|
||||
id: 7,
|
||||
pollId: 3,
|
||||
question: "Favourite colour?",
|
||||
});
|
||||
expect(snapshot?.after).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects moving a question to another poll during update", async () => {
|
||||
database.selected({
|
||||
id: 7,
|
||||
pollId: 3,
|
||||
question: "Favourite colour?",
|
||||
type: "single",
|
||||
sortOrder: 0,
|
||||
options: "Red\nBlue",
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeContentDatabaseMutation(
|
||||
"poll-question.change",
|
||||
{ action: "update", id: 7, pollId: 9, question: "Moved" },
|
||||
context,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "VALIDATION" });
|
||||
expect(database.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists empty canonical options for text questions", async () => {
|
||||
database.queueSelected({ id: 3, status: "draft" }, { value: 0 });
|
||||
|
||||
await executeContentDatabaseMutation(
|
||||
"poll-question.change",
|
||||
{
|
||||
action: "create",
|
||||
pollId: 3,
|
||||
question: "What should improve?",
|
||||
type: "text",
|
||||
sortOrder: 0,
|
||||
options: " \n ",
|
||||
},
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(database.insertedValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ options: "" }),
|
||||
);
|
||||
expect(database.lock).toHaveBeenCalledWith("update");
|
||||
});
|
||||
|
||||
it("maps duplicate question options to an options field error", async () => {
|
||||
database.queueSelected({ id: 3, status: "draft" }, { value: 0 });
|
||||
|
||||
await expect(
|
||||
executeContentDatabaseMutation(
|
||||
"poll-question.change",
|
||||
{
|
||||
action: "create",
|
||||
pollId: 3,
|
||||
question: "Favourite colour?",
|
||||
type: "single",
|
||||
sortOrder: 0,
|
||||
options: "Red\nred",
|
||||
},
|
||||
context,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "VALIDATION",
|
||||
fieldErrors: { options: ["errors.validation.invalid"] },
|
||||
});
|
||||
expect(database.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates a merged poll schedule and maps the error to endsAt", async () => {
|
||||
database.selected({
|
||||
id: 7,
|
||||
title: "Existing",
|
||||
description: null,
|
||||
status: "draft",
|
||||
showResults: 1,
|
||||
multipleChoice: 0,
|
||||
startsAt: new Date("2026-09-03T10:00:00.000Z"),
|
||||
endsAt: new Date("2026-09-04T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeContentDatabaseMutation(
|
||||
"poll.change",
|
||||
{ action: "update", id: 7, endsAt: "2026-09-02T10:00:00.000Z" },
|
||||
context,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "VALIDATION",
|
||||
fieldErrors: { endsAt: ["errors.validation.invalid"] },
|
||||
});
|
||||
expect(database.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates only submitted poll columns after validating merged state", async () => {
|
||||
database.selected({
|
||||
id: 7,
|
||||
title: "Existing",
|
||||
description: "Keep this",
|
||||
status: "draft",
|
||||
showResults: 1,
|
||||
multipleChoice: 0,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
});
|
||||
|
||||
await executeContentDatabaseMutation(
|
||||
"poll.change",
|
||||
{ action: "update", id: 7, title: "Renamed" },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const values = database.set.mock.calls[0]?.[0];
|
||||
expect(values).toMatchObject({
|
||||
title: "Renamed",
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
for (const omitted of [
|
||||
"description",
|
||||
"status",
|
||||
"showResults",
|
||||
"multipleChoice",
|
||||
"startsAt",
|
||||
"endsAt",
|
||||
]) {
|
||||
expect(values).not.toHaveProperty(omitted);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects question 101 without inserting it", async () => {
|
||||
database.queueSelected({ id: 3, status: "draft" }, { value: 100 });
|
||||
|
||||
await expect(
|
||||
executeContentDatabaseMutation(
|
||||
"poll-question.change",
|
||||
{
|
||||
action: "create",
|
||||
pollId: 3,
|
||||
question: "Question 101",
|
||||
type: "single",
|
||||
options: "Yes\nNo",
|
||||
},
|
||||
context,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(database.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects activating a poll with more than 50 questions", async () => {
|
||||
database.queueSelected(
|
||||
{
|
||||
id: 7,
|
||||
title: "Existing",
|
||||
description: null,
|
||||
status: "draft",
|
||||
showResults: 1,
|
||||
multipleChoice: 0,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
},
|
||||
{ value: 51 },
|
||||
);
|
||||
|
||||
await expect(
|
||||
executeContentDatabaseMutation(
|
||||
"poll.change",
|
||||
{ action: "update", id: 7, status: "active" },
|
||||
context,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(database.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects question 51 for an active poll without inserting it", async () => {
|
||||
database.queueSelected({ id: 3, status: "active" }, { value: 50 });
|
||||
|
||||
await expect(
|
||||
executeContentDatabaseMutation(
|
||||
"poll-question.change",
|
||||
{
|
||||
action: "create",
|
||||
pollId: 3,
|
||||
question: "Question 51",
|
||||
type: "single",
|
||||
options: "Yes\nNo",
|
||||
},
|
||||
context,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(database.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("canonicalizes submitted question options and preserves omitted columns", async () => {
|
||||
database.selected({
|
||||
id: 7,
|
||||
pollId: 3,
|
||||
question: "Favourite colour?",
|
||||
type: "single",
|
||||
sortOrder: 0,
|
||||
options: "Red\nBlue",
|
||||
});
|
||||
|
||||
await executeContentDatabaseMutation(
|
||||
"poll-question.change",
|
||||
{ action: "update", id: 7, options: " Green \nYellow " },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(database.set).toHaveBeenCalledWith({ options: "Green\nYellow" });
|
||||
});
|
||||
|
||||
it("blocks deletion of an event type referenced by an event", async () => {
|
||||
database.queueSelected({ id: 7, name: "Tournament" }, { id: 11 });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq, type SQL, sql } from "drizzle-orm";
|
||||
import { count, eq, inArray, type SQL, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import {
|
||||
db,
|
||||
@@ -21,10 +21,12 @@ import {
|
||||
WebsiteHelpCenterCategories,
|
||||
WebsitePoll,
|
||||
WebsitePollQuestion,
|
||||
WebsitePollVote,
|
||||
WebsiteWriteableBoxes,
|
||||
} from "@/lib/db";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { canonicalize } from "@/lib/foundation/security";
|
||||
import { serializePollOptions } from "@/lib/polls/poll-semantics";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import {
|
||||
createEventSchema,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
} from "@/lib/validators/event";
|
||||
import {
|
||||
createPollSchema,
|
||||
pollQuestionPatchSchema,
|
||||
pollQuestionSchema,
|
||||
updatePollSchema,
|
||||
} from "@/lib/validators/poll";
|
||||
@@ -58,10 +61,17 @@ function record(value: unknown): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function validation(): ContentMutationFailure {
|
||||
function validation(error?: import("zod").ZodError): ContentMutationFailure {
|
||||
const fieldErrors: Record<string, readonly string[]> = {};
|
||||
for (const issue of error?.issues ?? []) {
|
||||
fieldErrors[String(issue.path[0] ?? "input")] = [
|
||||
"errors.validation.invalid",
|
||||
];
|
||||
}
|
||||
return new ContentMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
Object.keys(fieldErrors).length > 0 ? fieldErrors : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -559,6 +569,35 @@ async function eventWinnerAdd(
|
||||
};
|
||||
}
|
||||
|
||||
const POLL_EDITABLE_KEYS = [
|
||||
"title",
|
||||
"description",
|
||||
"status",
|
||||
"showResults",
|
||||
"multipleChoice",
|
||||
"startsAt",
|
||||
"endsAt",
|
||||
] as const;
|
||||
|
||||
const POLL_QUESTION_EDITABLE_KEYS = [
|
||||
"question",
|
||||
"type",
|
||||
"sortOrder",
|
||||
"options",
|
||||
] as const;
|
||||
|
||||
async function pollQuestionCount(
|
||||
connection: ContentDatabase,
|
||||
pollId: number,
|
||||
): Promise<number> {
|
||||
const [row] = await connection
|
||||
.select({ value: count() })
|
||||
.from(WebsitePollQuestion)
|
||||
.where(eq(WebsitePollQuestion.pollId, pollId))
|
||||
.limit(1);
|
||||
return Number(row?.value ?? 0);
|
||||
}
|
||||
|
||||
async function pollChange(
|
||||
input: unknown,
|
||||
transaction: unknown,
|
||||
@@ -568,7 +607,7 @@ async function pollChange(
|
||||
const connection = database(transaction);
|
||||
if (action === "create") {
|
||||
const parsed = createPollSchema.safeParse(data);
|
||||
if (!parsed.success) throw validation();
|
||||
if (!parsed.success) throw validation(parsed.error);
|
||||
const [result] = await connection
|
||||
.insert(WebsitePoll)
|
||||
.values({ ...parsed.data, updatedAt: new Date() });
|
||||
@@ -584,31 +623,64 @@ async function pollChange(
|
||||
.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 notFound();
|
||||
if (action === "delete") {
|
||||
await connection
|
||||
.delete(WebsitePollVote)
|
||||
.where(
|
||||
inArray(
|
||||
WebsitePollVote.questionId,
|
||||
connection
|
||||
.select({ id: WebsitePollQuestion.id })
|
||||
.from(WebsitePollQuestion)
|
||||
.where(eq(WebsitePollQuestion.pollId, id)),
|
||||
),
|
||||
);
|
||||
await connection
|
||||
.delete(WebsitePollQuestion)
|
||||
.where(eq(WebsitePollQuestion.pollId, id));
|
||||
await connection.delete(WebsitePoll).where(eq(WebsitePoll.id, id));
|
||||
return { before: existing, after: null };
|
||||
}
|
||||
if (action !== "update") throw validation();
|
||||
const parsed = updatePollSchema.safeParse(data);
|
||||
if (!parsed.success) throw validation();
|
||||
const {
|
||||
action: _action,
|
||||
id: _id,
|
||||
...values
|
||||
} = parsed.data as Record<string, unknown>;
|
||||
const patchInput: Record<string, unknown> = {};
|
||||
for (const key of POLL_EDITABLE_KEYS) {
|
||||
if (hasOwn(data, key)) patchInput[key] = data[key];
|
||||
}
|
||||
requirePatch(patchInput);
|
||||
const parsedPatch = updatePollSchema.safeParse(patchInput);
|
||||
if (!parsedPatch.success) throw validation(parsedPatch.error);
|
||||
const values: Partial<typeof WebsitePoll.$inferInsert> = {};
|
||||
for (const key of POLL_EDITABLE_KEYS) {
|
||||
if (hasOwn(patchInput, key)) {
|
||||
(values as Record<string, unknown>)[key] = parsedPatch.data[key];
|
||||
}
|
||||
}
|
||||
const merged = createPollSchema.safeParse({ ...existing, ...values });
|
||||
if (!merged.success) throw validation(merged.error);
|
||||
if (
|
||||
merged.data.status === "active" &&
|
||||
(await pollQuestionCount(connection, id)) > 50
|
||||
) {
|
||||
throw conflict();
|
||||
}
|
||||
await connection
|
||||
.update(WebsitePoll)
|
||||
.set({ ...values, updatedAt: new Date() })
|
||||
.where(eq(WebsitePoll.id, id));
|
||||
return {
|
||||
before: existing,
|
||||
after: { id, ...values },
|
||||
after: { id, ...merged.data },
|
||||
output: { id: String(id) },
|
||||
};
|
||||
}
|
||||
@@ -621,11 +693,29 @@ async function pollQuestionChange(
|
||||
const action = text(data.action, 16, true);
|
||||
const connection = database(transaction);
|
||||
if (action === "create") {
|
||||
const pollId = positiveInteger(data.pollId);
|
||||
const [parent] = await connection
|
||||
.select({ id: WebsitePoll.id, status: WebsitePoll.status })
|
||||
.from(WebsitePoll)
|
||||
.where(eq(WebsitePoll.id, pollId))
|
||||
.for("update");
|
||||
if (!parent) throw notFound();
|
||||
const questionCount = await pollQuestionCount(connection, pollId);
|
||||
if (
|
||||
questionCount >= 100 ||
|
||||
(parent.status === "active" && questionCount >= 50)
|
||||
) {
|
||||
throw conflict();
|
||||
}
|
||||
const parsed = pollQuestionSchema.safeParse(data);
|
||||
if (!parsed.success) throw validation();
|
||||
if (!parsed.success) throw validation(parsed.error);
|
||||
const values = {
|
||||
...parsed.data,
|
||||
options: serializePollOptions(parsed.data.type, parsed.data.options),
|
||||
};
|
||||
const [result] = await connection
|
||||
.insert(WebsitePollQuestion)
|
||||
.values(parsed.data);
|
||||
.values(values);
|
||||
const id = insertedId(result);
|
||||
return {
|
||||
before: null,
|
||||
@@ -634,27 +724,60 @@ async function pollQuestionChange(
|
||||
};
|
||||
}
|
||||
const id = positiveInteger(data.id);
|
||||
const [existing] = await connection
|
||||
.select({
|
||||
id: WebsitePollQuestion.id,
|
||||
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 notFound();
|
||||
if (action === "delete") {
|
||||
await connection
|
||||
.delete(WebsitePollVote)
|
||||
.where(eq(WebsitePollVote.questionId, id));
|
||||
await connection
|
||||
.delete(WebsitePollQuestion)
|
||||
.where(eq(WebsitePollQuestion.id, id));
|
||||
return { before: { id }, after: null };
|
||||
return { before: existing, after: null };
|
||||
}
|
||||
if (action !== "update" || hasOwn(data, "pollId")) throw validation();
|
||||
const patchInput: Record<string, unknown> = {};
|
||||
for (const key of POLL_QUESTION_EDITABLE_KEYS) {
|
||||
if (hasOwn(data, key)) patchInput[key] = data[key];
|
||||
}
|
||||
requirePatch(patchInput);
|
||||
const parsedPatch = pollQuestionPatchSchema.safeParse(patchInput);
|
||||
if (!parsedPatch.success) throw validation(parsedPatch.error);
|
||||
const merged = pollQuestionSchema.safeParse({
|
||||
...existing,
|
||||
...parsedPatch.data,
|
||||
});
|
||||
if (!merged.success) throw validation(merged.error);
|
||||
const values: Partial<typeof WebsitePollQuestion.$inferInsert> = {};
|
||||
for (const key of POLL_QUESTION_EDITABLE_KEYS) {
|
||||
if (hasOwn(patchInput, key)) {
|
||||
(values as Record<string, unknown>)[key] = merged.data[key];
|
||||
}
|
||||
}
|
||||
if (hasOwn(patchInput, "options")) {
|
||||
values.options = serializePollOptions(
|
||||
merged.data.type,
|
||||
merged.data.options,
|
||||
);
|
||||
}
|
||||
if (action !== "update") throw validation();
|
||||
const parsed = pollQuestionSchema.partial().safeParse(data);
|
||||
if (!parsed.success) throw validation();
|
||||
const {
|
||||
action: _action,
|
||||
id: _id,
|
||||
...values
|
||||
} = parsed.data as Record<string, unknown>;
|
||||
await connection
|
||||
.update(WebsitePollQuestion)
|
||||
.set(values)
|
||||
.where(eq(WebsitePollQuestion.id, id));
|
||||
return {
|
||||
before: { id },
|
||||
after: { id, ...values },
|
||||
before: existing,
|
||||
after: { ...existing, ...values },
|
||||
output: { id: String(id) },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user