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,
|
PERMS.EVENTS_EDIT,
|
||||||
],
|
],
|
||||||
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
|
["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT],
|
||||||
|
["content.engagement.poll.delete", "poll.change", PERMS.POLLS_EDIT],
|
||||||
[
|
[
|
||||||
"content.engagement.poll-question.change",
|
"content.engagement.poll-question.change",
|
||||||
"poll-question.change",
|
"poll-question.change",
|
||||||
PERMS.POLLS_EDIT,
|
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.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||||
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||||
["content.media.asset.delete", "media.delete", 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 () => {
|
it("executes the real mutation service with actor-bound authority", async () => {
|
||||||
const execute = vi.fn(async () => ({
|
const execute = vi.fn(async () => ({
|
||||||
ok: true as const,
|
ok: true as const,
|
||||||
|
|||||||
@@ -38,11 +38,18 @@ const CONTENT_COMMAND_DEFINITIONS = [
|
|||||||
PERMS.EVENTS_EDIT,
|
PERMS.EVENTS_EDIT,
|
||||||
],
|
],
|
||||||
["content.engagement.poll.change", "poll.change", PERMS.POLLS_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",
|
"content.engagement.poll-question.change",
|
||||||
"poll-question.change",
|
"poll-question.change",
|
||||||
PERMS.POLLS_EDIT,
|
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.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||||
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||||
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||||
@@ -129,13 +136,17 @@ function inputForCommand(
|
|||||||
): z.ZodType<Record<string, unknown>> {
|
): z.ZodType<Record<string, unknown>> {
|
||||||
if (
|
if (
|
||||||
id === "content.engagement.event.change" ||
|
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;
|
return eventChangeInput;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
id === "content.engagement.event.delete" ||
|
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;
|
return eventDeleteInput;
|
||||||
}
|
}
|
||||||
|
|||||||
+288
-4
@@ -17,13 +17,19 @@ const sqlMocks = vi.hoisted(() => ({
|
|||||||
const database = vi.hoisted(() => {
|
const database = vi.hoisted(() => {
|
||||||
let selected: Record<string, unknown> = { id: 7, title: "Existing" };
|
let selected: Record<string, unknown> = { id: 7, title: "Existing" };
|
||||||
let selectedQueue: Record<string, unknown>[] = [];
|
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>) => ({
|
const set = vi.fn((values: Record<string, unknown>) => ({
|
||||||
where: vi.fn(async () => undefined),
|
where: vi.fn(async () => undefined),
|
||||||
values,
|
values,
|
||||||
}));
|
}));
|
||||||
const update = vi.fn(() => ({ set }));
|
const update = vi.fn(() => ({ set }));
|
||||||
const limit = vi.fn(async () => [selectedQueue.shift() ?? selected]);
|
const nextSelected = () => [selectedQueue.shift() ?? selected];
|
||||||
const where = vi.fn(() => ({ limit }));
|
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 from = vi.fn(() => ({ where }));
|
||||||
const select = vi.fn(() => ({ from }));
|
const select = vi.fn(() => ({ from }));
|
||||||
const execute = vi.fn(async () => undefined);
|
const execute = vi.fn(async () => undefined);
|
||||||
@@ -31,6 +37,9 @@ const database = vi.hoisted(() => {
|
|||||||
const remove = vi.fn((_table: unknown) => ({ where: deleteWhere }));
|
const remove = vi.fn((_table: unknown) => ({ where: deleteWhere }));
|
||||||
return {
|
return {
|
||||||
execute,
|
execute,
|
||||||
|
insert,
|
||||||
|
insertedValues,
|
||||||
|
lock,
|
||||||
set,
|
set,
|
||||||
update,
|
update,
|
||||||
select,
|
select,
|
||||||
@@ -52,7 +61,9 @@ const database = vi.hoisted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("drizzle-orm", () => ({
|
vi.mock("drizzle-orm", () => ({
|
||||||
|
count: vi.fn(() => "count"),
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
|
inArray: vi.fn(),
|
||||||
sql: Object.assign(sqlMocks.tagged, {
|
sql: Object.assign(sqlMocks.tagged, {
|
||||||
join: sqlMocks.join,
|
join: sqlMocks.join,
|
||||||
raw: sqlMocks.raw,
|
raw: sqlMocks.raw,
|
||||||
@@ -72,6 +83,7 @@ vi.mock("@/lib/db", () => {
|
|||||||
db: {
|
db: {
|
||||||
delete: database.delete,
|
delete: database.delete,
|
||||||
execute: database.execute,
|
execute: database.execute,
|
||||||
|
insert: database.insert,
|
||||||
select: database.select,
|
select: database.select,
|
||||||
update: database.update,
|
update: database.update,
|
||||||
},
|
},
|
||||||
@@ -90,8 +102,9 @@ vi.mock("@/lib/db", () => {
|
|||||||
WebsiteEventType: namedTable("WebsiteEventType"),
|
WebsiteEventType: namedTable("WebsiteEventType"),
|
||||||
WebsiteEventWinner: namedTable("WebsiteEventWinner"),
|
WebsiteEventWinner: namedTable("WebsiteEventWinner"),
|
||||||
WebsiteHelpCenterCategories: table,
|
WebsiteHelpCenterCategories: table,
|
||||||
WebsitePoll: table,
|
WebsitePoll: namedTable("WebsitePoll"),
|
||||||
WebsitePollQuestion: table,
|
WebsitePollQuestion: namedTable("WebsitePollQuestion"),
|
||||||
|
WebsitePollVote: namedTable("WebsitePollVote"),
|
||||||
WebsiteWriteableBoxes: table,
|
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 () => {
|
it("blocks deletion of an event type referenced by an event", async () => {
|
||||||
database.queueSelected({ id: 7, name: "Tournament" }, { id: 11 });
|
database.queueSelected({ id: 7, name: "Tournament" }, { id: 11 });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "server-only";
|
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 type { ResultSetHeader } from "mysql2";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
@@ -21,10 +21,12 @@ import {
|
|||||||
WebsiteHelpCenterCategories,
|
WebsiteHelpCenterCategories,
|
||||||
WebsitePoll,
|
WebsitePoll,
|
||||||
WebsitePollQuestion,
|
WebsitePollQuestion,
|
||||||
|
WebsitePollVote,
|
||||||
WebsiteWriteableBoxes,
|
WebsiteWriteableBoxes,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
import { canonicalize } from "@/lib/foundation/security";
|
import { canonicalize } from "@/lib/foundation/security";
|
||||||
|
import { serializePollOptions } from "@/lib/polls/poll-semantics";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import {
|
import {
|
||||||
createEventSchema,
|
createEventSchema,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
} from "@/lib/validators/event";
|
} from "@/lib/validators/event";
|
||||||
import {
|
import {
|
||||||
createPollSchema,
|
createPollSchema,
|
||||||
|
pollQuestionPatchSchema,
|
||||||
pollQuestionSchema,
|
pollQuestionSchema,
|
||||||
updatePollSchema,
|
updatePollSchema,
|
||||||
} from "@/lib/validators/poll";
|
} from "@/lib/validators/poll";
|
||||||
@@ -58,10 +61,17 @@ function record(value: unknown): Record<string, unknown> {
|
|||||||
return value as 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(
|
return new ContentMutationFailure(
|
||||||
"VALIDATION",
|
"VALIDATION",
|
||||||
"errors.housekeeping.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(
|
async function pollChange(
|
||||||
input: unknown,
|
input: unknown,
|
||||||
transaction: unknown,
|
transaction: unknown,
|
||||||
@@ -568,7 +607,7 @@ async function pollChange(
|
|||||||
const connection = database(transaction);
|
const connection = database(transaction);
|
||||||
if (action === "create") {
|
if (action === "create") {
|
||||||
const parsed = createPollSchema.safeParse(data);
|
const parsed = createPollSchema.safeParse(data);
|
||||||
if (!parsed.success) throw validation();
|
if (!parsed.success) throw validation(parsed.error);
|
||||||
const [result] = await connection
|
const [result] = await connection
|
||||||
.insert(WebsitePoll)
|
.insert(WebsitePoll)
|
||||||
.values({ ...parsed.data, updatedAt: new Date() });
|
.values({ ...parsed.data, updatedAt: new Date() });
|
||||||
@@ -584,31 +623,64 @@ async function pollChange(
|
|||||||
.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 notFound();
|
if (!existing) throw notFound();
|
||||||
if (action === "delete") {
|
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));
|
await connection.delete(WebsitePoll).where(eq(WebsitePoll.id, id));
|
||||||
return { before: existing, after: null };
|
return { before: existing, after: null };
|
||||||
}
|
}
|
||||||
if (action !== "update") throw validation();
|
if (action !== "update") throw validation();
|
||||||
const parsed = updatePollSchema.safeParse(data);
|
const patchInput: Record<string, unknown> = {};
|
||||||
if (!parsed.success) throw validation();
|
for (const key of POLL_EDITABLE_KEYS) {
|
||||||
const {
|
if (hasOwn(data, key)) patchInput[key] = data[key];
|
||||||
action: _action,
|
}
|
||||||
id: _id,
|
requirePatch(patchInput);
|
||||||
...values
|
const parsedPatch = updatePollSchema.safeParse(patchInput);
|
||||||
} = parsed.data as Record<string, unknown>;
|
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
|
await connection
|
||||||
.update(WebsitePoll)
|
.update(WebsitePoll)
|
||||||
.set({ ...values, updatedAt: new Date() })
|
.set({ ...values, updatedAt: new Date() })
|
||||||
.where(eq(WebsitePoll.id, id));
|
.where(eq(WebsitePoll.id, id));
|
||||||
return {
|
return {
|
||||||
before: existing,
|
before: existing,
|
||||||
after: { id, ...values },
|
after: { id, ...merged.data },
|
||||||
output: { id: String(id) },
|
output: { id: String(id) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -621,11 +693,29 @@ async function pollQuestionChange(
|
|||||||
const action = text(data.action, 16, true);
|
const action = text(data.action, 16, true);
|
||||||
const connection = database(transaction);
|
const connection = database(transaction);
|
||||||
if (action === "create") {
|
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);
|
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
|
const [result] = await connection
|
||||||
.insert(WebsitePollQuestion)
|
.insert(WebsitePollQuestion)
|
||||||
.values(parsed.data);
|
.values(values);
|
||||||
const id = insertedId(result);
|
const id = insertedId(result);
|
||||||
return {
|
return {
|
||||||
before: null,
|
before: null,
|
||||||
@@ -634,27 +724,60 @@ async function pollQuestionChange(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const id = positiveInteger(data.id);
|
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") {
|
if (action === "delete") {
|
||||||
|
await connection
|
||||||
|
.delete(WebsitePollVote)
|
||||||
|
.where(eq(WebsitePollVote.questionId, id));
|
||||||
await connection
|
await connection
|
||||||
.delete(WebsitePollQuestion)
|
.delete(WebsitePollQuestion)
|
||||||
.where(eq(WebsitePollQuestion.id, id));
|
.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
|
await connection
|
||||||
.update(WebsitePollQuestion)
|
.update(WebsitePollQuestion)
|
||||||
.set(values)
|
.set(values)
|
||||||
.where(eq(WebsitePollQuestion.id, id));
|
.where(eq(WebsitePollQuestion.id, id));
|
||||||
return {
|
return {
|
||||||
before: { id },
|
before: existing,
|
||||||
after: { id, ...values },
|
after: { ...existing, ...values },
|
||||||
output: { id: String(id) },
|
output: { id: String(id) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user