From cbc3075e5ac93d4d74e928ac560da1141f7dd761 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 2 Sep 2026 17:52:41 +0200 Subject: [PATCH] feat(housekeeping): define typed poll query contract --- .../content/pages/content-page-frame.tsx | 3 + .../content/pages/content-pages.test.tsx | 3 + .../content/queries/content-queries.test.ts | 207 +++++++++++++++- .../content/queries/content-queries.ts | 227 ++++++++++++++++++ 4 files changed, 432 insertions(+), 8 deletions(-) diff --git a/src/features/housekeeping/domains/content/pages/content-page-frame.tsx b/src/features/housekeeping/domains/content/pages/content-page-frame.tsx index 8b9d8777..1528d355 100644 --- a/src/features/housekeeping/domains/content/pages/content-page-frame.tsx +++ b/src/features/housekeeping/domains/content/pages/content-page-frame.tsx @@ -92,5 +92,8 @@ export function parseContentListInput( status: text(searchParams.status, 32).toLocaleLowerCase(), pageSize: bounded(searchParams.pageSize, 25, 1, 100), offset: bounded(searchParams.offset, 0, 0, 100_000), + responseQuestionId: text(searchParams.responseQuestionId, 32), + responsePageSize: bounded(searchParams.responsePageSize, 25, 1, 50), + responseOffset: bounded(searchParams.responseOffset, 0, 0, 100_000), }; } diff --git a/src/features/housekeeping/domains/content/pages/content-pages.test.tsx b/src/features/housekeeping/domains/content/pages/content-pages.test.tsx index dd977026..af376659 100644 --- a/src/features/housekeeping/domains/content/pages/content-pages.test.tsx +++ b/src/features/housekeeping/domains/content/pages/content-pages.test.tsx @@ -482,6 +482,9 @@ describe("Content actionable form wiring", () => { status: "published", pageSize: 25, offset: 25, + responseQuestionId: "", + responsePageSize: 25, + responseOffset: 0, }); const html = renderToStaticMarkup( 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 c047ea10..9a735152 100644 --- a/src/features/housekeeping/domains/content/queries/content-queries.test.ts +++ b/src/features/housekeeping/domains/content/queries/content-queries.test.ts @@ -57,6 +57,35 @@ const ready: ContentQueryData = { partialDependencies: [], }; +const pollDetail = { + kind: "poll-detail" as const, + description: "Complete poll", + showResults: true, + multipleChoice: false, + startsAt: "2026-09-02T18:00:00.000Z", + endsAt: null, + questionCount: 1, + voterCount: 2, + answerCount: 2, + questions: [ + { + id: "21", + question: "Favourite colour?", + type: "single" as const, + sortOrder: 0, + options: ["Red", "Blue"], + answerCount: 2, + choiceResults: [{ option: "Red", count: 2 }], + }, + ], + textResponses: { + questionId: null, + items: [], + total: 0, + pageSize: 25, + offset: 0, + }, +}; describe("Content query", () => { beforeEach(() => queryMocks.execute.mockReset()); @@ -355,7 +384,14 @@ describe("Content query", () => { expect(load).toHaveBeenCalledWith({ routeId: "content.editorial.articles", params: {}, - list: { search: "launch", pageSize: 100, offset: 100_000 }, + list: { + search: "launch", + pageSize: 100, + offset: 100_000, + responseQuestionId: "", + responsePageSize: 25, + responseOffset: 0, + }, }); expect(result).toMatchObject({ ok: true, @@ -367,6 +403,146 @@ describe("Content query", () => { }); }); + it("normalizes bounded poll response-page input before invoking the adapter", async () => { + const load = vi.fn(async () => ({ + kind: "engagement" as const, + items: [], + total: 0, + partialDependencies: [], + })); + const query = createContentQuery({ load }); + + await query.run(context([PERMS.POLLS_VIEW, PERMS.POLLS_EDIT]), { + routeId: "content.engagement.poll-detail", + params: { id: "7" }, + list: { + responseQuestionId: " 22 ", + responsePageSize: 50, + responseOffset: 100_000, + }, + }); + + expect(load).toHaveBeenCalledWith({ + routeId: "content.engagement.poll-detail", + params: { id: "7" }, + list: { + search: "", + pageSize: 25, + offset: 0, + responseQuestionId: "22", + responsePageSize: 50, + responseOffset: 100_000, + }, + }); + }); + + it.each([ + ["mismatched id", "8", pollDetail], + [ + "too many questions", + "7", + { ...pollDetail, questions: Array(101).fill(pollDetail.questions[0]) }, + ], + [ + "invalid response user", + "7", + { + ...pollDetail, + textResponses: { + questionId: "22", + items: [ + { + id: "1", + questionId: "22", + userId: "0", + username: null, + answer: "Text", + createdAt: "2026-09-02T18:00:00.000Z", + }, + ], + total: 1, + pageSize: 25, + offset: 0, + }, + }, + ], + ] as const)( + "fails closed for poll detail with %s", + async (_label, itemId, payload) => { + const query = createContentQuery({ + load: async () => ({ + kind: "engagement" as const, + items: [{ id: itemId, title: "Poll", privatePayload: payload }], + total: 1, + partialDependencies: [], + }), + }); + const result = await query.run( + context([PERMS.POLLS_VIEW, PERMS.POLLS_EDIT]), + { + routeId: "content.engagement.poll-detail", + params: { id: "7" }, + }, + ); + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + }, + ); + + it("fails closed when poll creation does not return the exact create payload", async () => { + const query = createContentQuery({ + load: async () => ({ + kind: "engagement" as const, + items: [ + { id: "7", title: "Poll", privatePayload: { kind: "poll-create" } }, + ], + total: 1, + partialDependencies: [], + }), + }); + expect( + await query.run(context([PERMS.POLLS_EDIT]), { + routeId: "content.engagement.poll-create", + }), + ).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + }); + + it("accepts an empty poll response page past its total", async () => { + const query = createContentQuery({ + load: async () => ({ + kind: "engagement" as const, + items: [ + { + id: "7", + title: "Poll", + privatePayload: { + ...pollDetail, + textResponses: { + questionId: null, + items: [], + total: 1, + pageSize: 25, + offset: 100_000, + }, + }, + }, + ], + total: 1, + partialDependencies: [], + }), + }); + expect( + await query.run(context([PERMS.POLLS_VIEW, PERMS.POLLS_EDIT]), { + routeId: "content.engagement.poll-detail", + params: { id: "7" }, + }), + ).toMatchObject({ ok: true }); + }); it("fails closed when an article detail payload is incomplete", async () => { const query = createContentQuery({ load: async () => ({ @@ -417,6 +593,9 @@ describe("Content query", () => { status: `published${"x".repeat(23)}`, pageSize: 25, offset: 0, + responseQuestionId: "", + responsePageSize: 25, + responseOffset: 0, }, }); }); @@ -488,19 +667,31 @@ describe("Content query", () => { }); it("has an authorized production query path for every Content route", async () => { - const load = vi.fn(async (input: ContentQueryInput) => ({ - kind: input.routeId.split(".")[1] as ContentQueryData["kind"], - items: [], - total: 0, - partialDependencies: [], - })); + const load = vi.fn(async (input: ContentQueryInput) => { + const isPollCreate = input.routeId === "content.engagement.poll-create"; + return { + kind: input.routeId.split(".")[1] as ContentQueryData["kind"], + items: isPollCreate + ? [ + { + id: "create", + title: "Create poll", + privatePayload: { kind: "poll-create" }, + }, + ] + : [], + total: isPollCreate ? 1 : 0, + partialDependencies: [], + }; + }); const query = createContentQuery({ load }); const all = context(Object.values(PERMS)); for (const routeId of CONTENT_ROUTE_IDS) { const result = await query.run(all, { routeId, - ...(routeId === "content.engagement.event-detail" + ...(routeId === "content.engagement.event-detail" || + routeId === "content.engagement.poll-detail" ? { params: { id: "1" } } : {}), }); diff --git a/src/features/housekeeping/domains/content/queries/content-queries.ts b/src/features/housekeeping/domains/content/queries/content-queries.ts index f89bcace..eb1346a2 100644 --- a/src/features/housekeeping/domains/content/queries/content-queries.ts +++ b/src/features/housekeeping/domains/content/queries/content-queries.ts @@ -1,3 +1,7 @@ +import { + POLL_QUESTION_TYPES, + type PollQuestionType, +} from "@/lib/polls/poll-semantics"; import { authorizeHousekeeping } from "../../../foundation/authorization"; import { fail, @@ -17,6 +21,9 @@ export interface ContentQueryListInput { readonly status?: string; readonly pageSize?: number; readonly offset?: number; + readonly responseQuestionId?: string; + readonly responsePageSize?: number; + readonly responseOffset?: number; } export interface ContentQueryInput { @@ -248,6 +255,183 @@ export function isContentEventDetailPayload( payload.registrations.every(isContentEventRegistrationPayload) ); } +export type ContentPollQuestionType = PollQuestionType; + +export interface ContentPollCreatePayload { + readonly kind: "poll-create"; +} + +export interface ContentPollSummaryPayload { + readonly kind: "poll-summary"; + readonly showResults: boolean; + readonly multipleChoice: boolean; + readonly startsAt: string | null; + readonly endsAt: string | null; + readonly questionCount: number; + readonly voterCount: number; + readonly answerCount: number; +} + +export interface ContentPollChoiceResultPayload { + readonly option: string; + readonly count: number; +} + +export interface ContentPollQuestionPayload { + readonly id: string; + readonly question: string; + readonly type: ContentPollQuestionType; + readonly sortOrder: number; + readonly options: readonly string[]; + readonly answerCount: number; + readonly choiceResults: readonly ContentPollChoiceResultPayload[]; +} + +export interface ContentPollTextResponsePayload { + readonly id: string; + readonly questionId: string; + readonly userId: string; + readonly username: string | null; + readonly answer: string; + readonly createdAt: string; +} + +export interface ContentPollTextResponsePagePayload { + readonly questionId: string | null; + readonly items: readonly ContentPollTextResponsePayload[]; + readonly total: number; + readonly pageSize: number; + readonly offset: number; +} + +export interface ContentPollDetailPayload { + readonly kind: "poll-detail"; + readonly description: string; + readonly showResults: boolean; + readonly multipleChoice: boolean; + readonly startsAt: string | null; + readonly endsAt: string | null; + readonly questionCount: number; + readonly voterCount: number; + readonly answerCount: number; + readonly questions: readonly ContentPollQuestionPayload[]; + readonly textResponses: ContentPollTextResponsePagePayload; +} + +function isContentPollQuestionPayload( + value: unknown, +): value is ContentPollQuestionPayload { + const payload = object(value); + const options = payload?.options; + const choiceResults = payload?.choiceResults; + return ( + positiveIdentifier(payload?.id) && + typeof payload.question === "string" && + typeof payload.type === "string" && + POLL_QUESTION_TYPES.includes(payload.type as ContentPollQuestionType) && + safeCount(payload.sortOrder) && + Array.isArray(options) && + options.length <= 100 && + options.every((option) => typeof option === "string") && + safeCount(payload.answerCount) && + Array.isArray(choiceResults) && + choiceResults.length <= 100 && + choiceResults.every((choice) => { + const result = object(choice); + return ( + typeof result?.option === "string" && + options.includes(result.option) && + safeCount(result.count) + ); + }) + ); +} + +function isContentPollTextResponsePayload( + value: unknown, +): value is ContentPollTextResponsePayload { + const payload = object(value); + return ( + positiveIdentifier(payload?.id) && + positiveIdentifier(payload.questionId) && + positiveIdentifier(payload.userId) && + stringOrNull(payload.username) && + typeof payload.answer === "string" && + canonicalDate(payload.createdAt) + ); +} + +export function isContentPollCreatePayload( + value: unknown, +): value is ContentPollCreatePayload { + return object(value)?.kind === "poll-create"; +} + +export function isContentPollSummaryPayload( + value: unknown, +): value is ContentPollSummaryPayload { + const payload = object(value); + return ( + payload?.kind === "poll-summary" && + typeof payload.showResults === "boolean" && + typeof payload.multipleChoice === "boolean" && + dateOrNull(payload.startsAt) && + dateOrNull(payload.endsAt) && + safeCount(payload.questionCount) && + safeCount(payload.voterCount) && + safeCount(payload.answerCount) + ); +} + +export function isContentPollDetailPayload( + value: unknown, +): value is ContentPollDetailPayload { + const payload = object(value); + if ( + payload?.kind !== "poll-detail" || + typeof payload.description !== "string" || + typeof payload.showResults !== "boolean" || + typeof payload.multipleChoice !== "boolean" || + !dateOrNull(payload.startsAt) || + !dateOrNull(payload.endsAt) || + !safeCount(payload.questionCount) || + !safeCount(payload.voterCount) || + !safeCount(payload.answerCount) || + !Array.isArray(payload.questions) || + payload.questions.length > 100 || + !payload.questions.every(isContentPollQuestionPayload) || + payload.questionCount !== payload.questions.length + ) + return false; + + const responses = object(payload.textResponses); + if ( + !responses || + (responses.questionId !== null && + !positiveIdentifier(responses.questionId)) || + !Array.isArray(responses.items) || + responses.items.length > 50 || + !safeCount(responses.total) || + !positiveCount(responses.pageSize) || + responses.pageSize > 50 || + !safeCount(responses.offset) || + responses.items.length > responses.pageSize || + responses.total < responses.items.length || + !responses.items.every(isContentPollTextResponsePayload) + ) + return false; + + if (responses.questionId === null) return responses.items.length === 0; + return ( + payload.questions.some( + (question) => + question.id === responses.questionId && question.type === "text", + ) && + responses.items.every( + (response) => response.questionId === responses.questionId, + ) + ); +} export interface ContentQueryItem { readonly id: string; @@ -274,6 +458,9 @@ export interface NormalizedContentQueryInput { status?: string; pageSize: number; offset: number; + responseQuestionId?: string; + responsePageSize?: number; + responseOffset?: number; }>; } @@ -314,6 +501,12 @@ function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput { ...(status ? { status } : {}), pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100), offset: boundedInteger(input.list?.offset, 0, 0, 100_000), + responseQuestionId: String(input.list?.responseQuestionId ?? "") + .normalize("NFC") + .trim() + .slice(0, 32), + responsePageSize: boundedInteger(input.list?.responsePageSize, 25, 1, 50), + responseOffset: boundedInteger(input.list?.responseOffset, 0, 0, 100_000), }, }; } @@ -390,6 +583,40 @@ function isValidData( ) ); } + if (input.routeId === "content.engagement.polls") { + return ( + data.items.length <= input.list.pageSize && + data.items.length <= data.total && + data.items.every( + (item) => + positiveIdentifier(item.id) && + isContentPollSummaryPayload(item.privatePayload), + ) + ); + } + if (input.routeId === "content.engagement.poll-create") { + return ( + data.total === 1 && + data.items.length === 1 && + data.items[0]?.id === "create" && + isContentPollCreatePayload(data.items[0]?.privatePayload) + ); + } + if (input.routeId === "content.engagement.poll-detail") { + const requestedId = input.params.id; + return ( + positiveIdentifier(requestedId) && + data.items.length <= 1 && + data.total === data.items.length && + data.items.every( + (item) => + item.id === requestedId && + positiveIdentifier(item.id) && + isContentPollDetailPayload(item.privatePayload), + ) + ); + } + return true; }