feat(housekeeping): load complete poll operator data

This commit is contained in:
Simo committed 2026-09-02 18:16:25 +02:00
1 parent de94b47cae
commit dba58cad4f
2 files changed
+405 -2

No files matched your search

@@ -1,6 +1,12 @@
import "server-only";
import type { SQL } from "drizzle-orm";
import {
POLL_QUESTION_TYPES,
type PollQuestionType,
parsePollAnswerSelections,
parsePollOptions,
} from "@/lib/polls/poll-semantics";
import { type ContentRouteId, contentRouteGroup } from "../routes";
import type {
ContentQueryData,
@@ -14,6 +20,9 @@ const EVENT_TYPE_LIMIT = 500;
const EVENT_PRIZE_LIMIT = 100;
const EVENT_WINNER_LIMIT = 500;
const EVENT_REGISTRATION_LIMIT = 1_000;
const POLL_QUESTION_LIMIT = 100;
const POLL_OPTION_LIMIT = 100;
const POLL_AGGREGATE_ROW_LIMIT = 10_000;
interface QueryDefinition {
readonly statement: string;
@@ -88,9 +97,10 @@ export const CONTENT_QUERY_DEFINITIONS = {
},
"content.engagement.polls": {
statement:
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
"SELECT p.id, p.title, p.status, p.updated_at, p.show_results, p.multiple_choice, p.starts_at, p.ends_at, (SELECT COUNT(*) FROM website_poll_questions q WHERE q.poll_id = p.id) AS question_count, (SELECT COUNT(DISTINCT v.user_id) FROM website_poll_votes v INNER JOIN website_poll_questions q ON q.id = v.question_id WHERE q.poll_id = p.id) AS voter_count, (SELECT COUNT(*) FROM website_poll_votes v INNER JOIN website_poll_questions q ON q.id = v.question_id WHERE q.poll_id = p.id) AS answer_count FROM website_polls p ORDER BY p.created_at DESC",
href: "/ase-next/content/engagement/polls/",
appendId: true,
privatePayload: pollSummaryPayload,
},
"content.engagement.poll-detail": {
statement:
@@ -130,7 +140,6 @@ export const CONTENT_QUERY_DEFINITIONS = {
const EMPTY_ROUTES = new Set<ContentRouteId>([
"content.editorial.article-create",
"content.media.ad-create",
"content.engagement.poll-create",
"content.help.question-create",
]);
@@ -207,6 +216,27 @@ function eventSummaryPayload(row: RawRow) {
registrationCount: integer(row.registration_count),
};
}
function pollSummaryPayload(row: RawRow) {
return {
kind: "poll-summary" as const,
showResults: Number(row.show_results) === 1,
multipleChoice: Number(row.multiple_choice) === 1,
startsAt: updatedAt(row.starts_at),
endsAt: updatedAt(row.ends_at),
questionCount: integer(row.question_count),
voterCount: integer(row.voter_count),
answerCount: integer(row.answer_count),
};
}
function pollQuestionType(row: RawRow): PollQuestionType {
const type = value(row.type);
if (!POLL_QUESTION_TYPES.includes(type as PollQuestionType)) {
throw new Error("invalid Content poll question type");
}
return type as PollQuestionType;
}
function response(
input: NormalizedContentQueryInput,
items: readonly ContentQueryItem[],
@@ -447,6 +477,163 @@ async function eventDetail(
};
}
async function pollCreate(
_input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
return {
kind: "engagement",
items: [
{
id: "create",
title: "Create poll",
href: "/ase-next/content/engagement/polls/create",
privatePayload: { kind: "poll-create" },
},
],
total: 1,
partialDependencies: [],
};
}
async function pollDetail(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
const id = Number(input.params.id);
if (!Number.isSafeInteger(id) || id <= 0) return response(input, []);
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const [poll] = rows(
await db.execute(
sql`SELECT p.id, p.title, p.description, p.status, p.updated_at, p.show_results, p.multiple_choice, p.starts_at, p.ends_at, (SELECT COUNT(DISTINCT v.user_id) FROM website_poll_votes v INNER JOIN website_poll_questions q ON q.id = v.question_id WHERE q.poll_id = p.id) AS voter_count, (SELECT COUNT(*) FROM website_poll_votes v INNER JOIN website_poll_questions q ON q.id = v.question_id WHERE q.poll_id = p.id) AS answer_count FROM website_polls p WHERE p.id = ${id} LIMIT 1`,
),
);
if (!poll) return response(input, []);
const questionRows = limitedEventRows(
await db.execute(
sql`SELECT id, question, type, sort_order, options FROM website_poll_questions WHERE poll_id = ${id} ORDER BY sort_order, id ${sql.raw("LIMIT 101")}`,
),
POLL_QUESTION_LIMIT,
"poll question",
);
const questions = questionRows.map((row) => {
const options = parsePollOptions(value(row.options));
if (options.length > POLL_OPTION_LIMIT) {
throw new Error("invalid Content poll option limit");
}
return {
id: value(row.id),
question: value(row.question),
type: pollQuestionType(row),
sortOrder: integer(row.sort_order),
options,
answerCount: 0,
choiceResults: options.map((option) => ({ option, count: 0 })),
};
});
const questionById = new Map(
questions.map((question) => [question.id, question]),
);
const aggregateRows = limitedEventRows(
await db.execute(
sql`SELECT v.question_id, v.answer, COUNT(*) AS answer_count FROM website_poll_votes v INNER JOIN website_poll_questions q ON q.id = v.question_id WHERE q.poll_id = ${id} AND q.type IN ('single', 'multiple') GROUP BY v.question_id, v.answer ${sql.raw("LIMIT 10001")}`,
),
POLL_AGGREGATE_ROW_LIMIT,
"poll aggregate",
);
for (const row of aggregateRows) {
const question = questionById.get(value(row.question_id));
if (!question || question.type === "text") continue;
const count = integer(row.answer_count);
question.answerCount += count;
for (const selection of parsePollAnswerSelections(
question.type,
value(row.answer),
)) {
const choice = question.choiceResults.find(
(result) => result.option === selection,
);
if (choice) choice.count += count;
}
}
const requestedTextQuestion = questions.find(
(question) =>
question.id === input.list.responseQuestionId && question.type === "text",
);
const textQuestion =
requestedTextQuestion ??
questions.find((question) => question.type === "text");
const textResponses = {
questionId: textQuestion?.id ?? null,
items: [] as {
id: string;
questionId: string;
userId: string;
username: string | null;
answer: string;
createdAt: string;
}[],
total: 0,
pageSize: input.list.responsePageSize,
offset: input.list.responseOffset,
};
if (textQuestion) {
const questionId = Number(textQuestion.id);
if (!Number.isSafeInteger(questionId) || questionId <= 0) {
throw new Error("invalid Content poll question identifier");
}
textResponses.total = totalFromRows(
rows(
await db.execute(
sql`SELECT COUNT(*) AS total FROM website_poll_votes v WHERE v.question_id = ${questionId}`,
),
),
);
const responseRows = limitedEventRows(
await db.execute(
sql`SELECT v.id, v.question_id, v.user_id, u.username, v.answer, v.created_at FROM website_poll_votes v LEFT JOIN users u ON u.id = v.user_id WHERE v.question_id = ${questionId} ORDER BY v.created_at DESC, v.id DESC LIMIT ${input.list.responsePageSize} OFFSET ${input.list.responseOffset}`,
),
input.list.responsePageSize,
"poll response",
);
textResponses.items = responseRows.map((row) => ({
id: value(row.id),
questionId: value(row.question_id),
userId: value(row.user_id),
username: row.username == null ? null : value(row.username),
answer: value(row.answer),
createdAt: requiredDate(row.created_at),
}));
}
return {
kind: "engagement",
items: [
{
id: value(poll.id),
title: value(poll.title, "Untitled poll"),
status: value(poll.status, "draft"),
updatedAt: updatedAt(poll.updated_at),
href: `/ase-next/content/engagement/polls/${value(poll.id)}`,
privatePayload: {
kind: "poll-detail",
description: value(poll.description),
showResults: Number(poll.show_results) === 1,
multipleChoice: Number(poll.multiple_choice) === 1,
startsAt: updatedAt(poll.starts_at),
endsAt: updatedAt(poll.ends_at),
questionCount: questions.length,
voterCount: integer(poll.voter_count),
answerCount: integer(poll.answer_count),
questions,
textResponses,
},
},
],
total: 1,
partialDependencies: [],
};
}
async function mediaLibrary(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
@@ -591,6 +778,10 @@ async function localizationFiles(
export async function loadProductionContentQuery(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
if (input.routeId === "content.engagement.poll-create")
return pollCreate(input);
if (input.routeId === "content.engagement.poll-detail")
return pollDetail(input);
if (EMPTY_ROUTES.has(input.routeId)) return response(input, []);
if (input.routeId === "content.engagement.event-create") {
return eventCreate(input);
@@ -280,6 +280,218 @@ describe("Content query", () => {
},
});
});
it("loads bounded poll summaries with operator counts and bound filters", async () => {
queryMocks.execute
.mockResolvedValueOnce([[{ total: 1 }]])
.mockResolvedValueOnce([
[
{
id: 7,
title: "Autumn plans",
status: "active",
updated_at: new Date("2026-09-01T08:00:00.000Z"),
show_results: 1,
multiple_choice: 0,
starts_at: new Date("2026-09-02T18:00:00.000Z"),
ends_at: null,
question_count: 3,
voter_count: 12,
answer_count: 30,
},
],
]);
const result = await loadProductionContentQuery({
routeId: "content.engagement.polls",
params: {},
list: {
...normalizedResponsePage,
search: "Autumn",
status: "active",
pageSize: 25,
offset: 0,
},
});
expect(result.items[0]?.privatePayload).toEqual({
kind: "poll-summary",
showResults: true,
multipleChoice: false,
startsAt: "2026-09-02T18:00:00.000Z",
endsAt: null,
questionCount: 3,
voterCount: 12,
answerCount: 30,
});
const sql = JSON.stringify(queryMocks.execute.mock.calls);
expect(sql).toContain("%autumn%");
expect(sql).toContain("active");
});
it("returns the stable poll-create payload without database access", async () => {
const result = await loadProductionContentQuery({
routeId: "content.engagement.poll-create",
params: {},
list: { ...normalizedResponsePage, search: "", pageSize: 25, offset: 0 },
});
expect(result).toEqual({
kind: "engagement",
items: [
{
id: "create",
title: "Create poll",
href: "/ase-next/content/engagement/polls/create",
privatePayload: { kind: "poll-create" },
},
],
total: 1,
partialDependencies: [],
});
expect(queryMocks.execute).not.toHaveBeenCalled();
});
it("loads selected poll results from bounded grouped aggregates and response pages", async () => {
queryMocks.execute
.mockResolvedValueOnce([
[
{
id: 7,
title: "Autumn plans",
description: "Complete poll",
status: "active",
updated_at: new Date("2026-09-01T08:00:00.000Z"),
show_results: 1,
multiple_choice: 0,
starts_at: new Date("2026-09-02T18:00:00.000Z"),
ends_at: null,
question_count: 2,
voter_count: 12,
answer_count: 30,
},
],
])
.mockResolvedValueOnce([
[
{
id: 21,
question: "Favourite colour?",
type: "single",
sort_order: 0,
options: "Red\nBlue",
},
{
id: 22,
question: "What should improve?",
type: "text",
sort_order: 1,
options: "",
},
],
])
.mockResolvedValueOnce([
[
{ question_id: 21, answer: "Red", answer_count: 3 },
{ question_id: 21, answer: "Blue", answer_count: 1 },
],
])
.mockResolvedValueOnce([[{ total: 51 }]])
.mockResolvedValueOnce([
[
{
id: 1,
question_id: 22,
user_id: 9,
username: "Alice",
answer: "More events",
created_at: new Date("2026-09-02T20:00:00.000Z"),
},
{
id: 2,
question_id: 22,
user_id: 10,
username: null,
answer: "Better prizes",
created_at: new Date("2026-09-02T19:00:00.000Z"),
},
],
]);
const result = await loadProductionContentQuery({
routeId: "content.engagement.poll-detail",
params: { id: "7" },
list: {
...normalizedResponsePage,
search: "",
pageSize: 25,
offset: 0,
responseQuestionId: "22",
responsePageSize: 25,
responseOffset: 25,
},
});
expect(result.items[0]?.privatePayload).toMatchObject({
kind: "poll-detail",
questions: [
{
id: "21",
options: ["Red", "Blue"],
answerCount: 4,
choiceResults: [
{ option: "Red", count: 3 },
{ option: "Blue", count: 1 },
],
},
{
id: "22",
type: "text",
options: [],
answerCount: 0,
choiceResults: [],
},
],
textResponses: {
questionId: "22",
total: 51,
pageSize: 25,
offset: 25,
items: [
{ userId: "9", username: "Alice", answer: "More events" },
{ userId: "10", username: null, answer: "Better prizes" },
],
},
});
const sql = JSON.stringify(queryMocks.execute.mock.calls);
expect(sql).toContain("LIMIT 101");
expect(sql).toContain("LIMIT 10001");
expect(sql).toContain("7");
expect(sql).toContain("22");
expect(sql).toContain("25");
expect(queryMocks.execute).toHaveBeenCalledTimes(5);
});
it("returns a valid empty detail result when the selected poll is missing", async () => {
queryMocks.execute.mockResolvedValueOnce([[]]);
await expect(
loadProductionContentQuery({
routeId: "content.engagement.poll-detail",
params: { id: "7" },
list: {
...normalizedResponsePage,
search: "",
pageSize: 25,
offset: 0,
},
}),
).resolves.toEqual({
kind: "engagement",
items: [],
total: 0,
partialDependencies: [],
});
});
it("loads only the selected event with all related operator data", async () => {
queryMocks.execute