diff --git a/src/features/housekeeping/domains/content/queries/content-queries-production.ts b/src/features/housekeeping/domains/content/queries/content-queries-production.ts index 96b04c91..3c4f6cd6 100644 --- a/src/features/housekeeping/domains/content/queries/content-queries-production.ts +++ b/src/features/housekeeping/domains/content/queries/content-queries-production.ts @@ -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([ "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 { + 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 { + 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 { @@ -591,6 +778,10 @@ async function localizationFiles( export async function loadProductionContentQuery( input: NormalizedContentQueryInput, ): Promise { + 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); diff --git a/src/features/housekeeping/domains/content/queries/content-queries.test.ts b/src/features/housekeeping/domains/content/queries/content-queries.test.ts index cf7e5271..b45f3885 100644 --- a/src/features/housekeeping/domains/content/queries/content-queries.test.ts +++ b/src/features/housekeeping/domains/content/queries/content-queries.test.ts @@ -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