feat(housekeeping): load complete poll operator data
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user