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 "server-only";
|
||||||
|
|
||||||
import type { SQL } from "drizzle-orm";
|
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 ContentRouteId, contentRouteGroup } from "../routes";
|
||||||
import type {
|
import type {
|
||||||
ContentQueryData,
|
ContentQueryData,
|
||||||
@@ -14,6 +20,9 @@ const EVENT_TYPE_LIMIT = 500;
|
|||||||
const EVENT_PRIZE_LIMIT = 100;
|
const EVENT_PRIZE_LIMIT = 100;
|
||||||
const EVENT_WINNER_LIMIT = 500;
|
const EVENT_WINNER_LIMIT = 500;
|
||||||
const EVENT_REGISTRATION_LIMIT = 1_000;
|
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 {
|
interface QueryDefinition {
|
||||||
readonly statement: string;
|
readonly statement: string;
|
||||||
@@ -88,9 +97,10 @@ export const CONTENT_QUERY_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
"content.engagement.polls": {
|
"content.engagement.polls": {
|
||||||
statement:
|
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/",
|
href: "/ase-next/content/engagement/polls/",
|
||||||
appendId: true,
|
appendId: true,
|
||||||
|
privatePayload: pollSummaryPayload,
|
||||||
},
|
},
|
||||||
"content.engagement.poll-detail": {
|
"content.engagement.poll-detail": {
|
||||||
statement:
|
statement:
|
||||||
@@ -130,7 +140,6 @@ export const CONTENT_QUERY_DEFINITIONS = {
|
|||||||
const EMPTY_ROUTES = new Set<ContentRouteId>([
|
const EMPTY_ROUTES = new Set<ContentRouteId>([
|
||||||
"content.editorial.article-create",
|
"content.editorial.article-create",
|
||||||
"content.media.ad-create",
|
"content.media.ad-create",
|
||||||
"content.engagement.poll-create",
|
|
||||||
"content.help.question-create",
|
"content.help.question-create",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -207,6 +216,27 @@ function eventSummaryPayload(row: RawRow) {
|
|||||||
registrationCount: integer(row.registration_count),
|
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(
|
function response(
|
||||||
input: NormalizedContentQueryInput,
|
input: NormalizedContentQueryInput,
|
||||||
items: readonly ContentQueryItem[],
|
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(
|
async function mediaLibrary(
|
||||||
input: NormalizedContentQueryInput,
|
input: NormalizedContentQueryInput,
|
||||||
): Promise<ContentQueryData> {
|
): Promise<ContentQueryData> {
|
||||||
@@ -591,6 +778,10 @@ async function localizationFiles(
|
|||||||
export async function loadProductionContentQuery(
|
export async function loadProductionContentQuery(
|
||||||
input: NormalizedContentQueryInput,
|
input: NormalizedContentQueryInput,
|
||||||
): Promise<ContentQueryData> {
|
): 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 (EMPTY_ROUTES.has(input.routeId)) return response(input, []);
|
||||||
if (input.routeId === "content.engagement.event-create") {
|
if (input.routeId === "content.engagement.event-create") {
|
||||||
return eventCreate(input);
|
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 () => {
|
it("loads only the selected event with all related operator data", async () => {
|
||||||
queryMocks.execute
|
queryMocks.execute
|
||||||
|
|||||||
Reference in new issue
Block a user