# Housekeeping Content Polls Vertical Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Deliver a complete dedicated ASE Polls workflow for discovery, authoring, question management, aggregate analysis, individual free-text review, and safe deletion without changing public voting behaviour. **Architecture:** Keep the existing Content query/command/mutation foundation, add strict route-specific poll payloads, and render them through focused `ContentPollWorkflow` and `ContentPollResults` components. Reuse the current poll tables and audited database transaction boundary, while sharing pure option/answer parsing between ASE and public consumers so single, multiple, and text semantics cannot drift. **Tech Stack:** Next.js 16, React 19, TypeScript, Drizzle/MySQL, Zod, Vitest, React server actions, Biome, Knip **Spec:** `docs/superpowers/specs/2026-09-01-housekeeping-content-polls-vertical-design.md` ## Global Constraints - Work directly in `E:\Users\simol\Desktop\EpicNext-cms` on `codex/housekeeping-rebuild-stepwise`; do not create a worktree. - Preserve `/admin/polls`, public `/polls`, `.remember/`, `.superpowers/brainstorm/`, and unrelated user changes. - Do not add runtime dependencies and do not change the database schema. - Keep question `type` authoritative for public single-choice, multiple-choice, and text vote semantics; the poll-level `multipleChoice` field remains compatibility metadata only. - Keep `showResults` authoritative for public result visibility; individual free-text identity data is ASE-only. - Enforce at most 100 questions per poll, 100 options per question, and 50 individual free-text responses per page. - Require `admin.polls.edit` for mutations; allow `admin.polls.view` to inspect poll lists, details, and results without rendering write controls. - Poll and question deletion must use dedicated reason-protected command IDs and explicit child-first deletion inside the existing audited transaction. - Write and run a failing test before each production behaviour change; commit each task independently. - Update draft PR 53 in English and Dutch only after implementation verification is current. ## File Structure - `src/lib/polls/poll-semantics.ts`: pure canonical parsing and serialization shared by public and ASE code. - `src/lib/polls/poll-semantics.test.ts`: public-compatibility and normalization regression tests. - `src/lib/validators/poll.ts`: schedule and type-aware question validation schemas. - `src/features/housekeeping/domains/content/queries/content-queries.ts`: typed poll payloads, guards, and normalized response-page input. - `src/features/housekeeping/domains/content/queries/content-queries-production.ts`: bounded poll list/create/detail database adapters. - `src/features/housekeeping/domains/content/services/mutation-runtime-database.ts`: validated poll/question writes and explicit child-first deletes. - `src/features/housekeeping/domains/content/pages/content-command-form.tsx`: retained failed submissions and field-level errors. - `src/features/housekeeping/domains/content/pages/poll-results.tsx`: aggregate and free-text result presentation. - `src/features/housekeeping/domains/content/pages/poll-workflow.tsx`: Polls list, create, overview, questions, permissions, and state routing. - `src/features/housekeeping/domains/content/pages/engagement.tsx`: delegates the three poll routes to the dedicated workflow and leaves Prefixes unchanged. --- ### Task 1: Canonical poll semantics and type-aware validation **Files:** - Create: `src/lib/polls/poll-semantics.ts` - Create: `src/lib/polls/poll-semantics.test.ts` - Modify: `src/lib/validators/poll.ts` - Modify: `src/lib/validators/poll.test.ts` - Modify: `src/actions/polls.ts` - Modify: `src/app/(site)/polls/[id]/page.tsx` - Modify: `src/app/(site)/polls/[id]/poll-vote-form.tsx` **Interfaces:** - Produces: `PollQuestionType`, `parsePollOptions(value)`, `serializePollOptions(type, value)`, and `parsePollAnswerSelections(type, answer)`. - Produces: `pollQuestionPatchSchema` for mutation updates that are merged with the stored question before full validation. - Preserves: newline-delimited public multiple-choice answers and trimmed single/text answers. - [ ] **Step 1: Write failing semantics and validator tests** Create `poll-semantics.test.ts` with the public compatibility cases: ```ts import { describe, expect, it } from "vitest"; import { parsePollAnswerSelections, parsePollOptions, serializePollOptions, } from "./poll-semantics"; describe("public poll compatibility", () => { it("keeps newline-delimited multiple-choice answers", () => { expect(parsePollAnswerSelections("multiple", "Red\nBlue\n")).toEqual([ "Red", "Blue", ]); }); it("keeps one trimmed answer for single and text questions", () => { expect(parsePollAnswerSelections("single", " Red ")).toEqual(["Red"]); expect(parsePollAnswerSelections("text", " Detailed answer ")).toEqual([ "Detailed answer", ]); }); it("normalizes option lines without reordering them", () => { expect(parsePollOptions(" Red \r\n\nBlue ")).toEqual(["Red", "Blue"]); expect(serializePollOptions("text", "ignored")).toBe(""); expect(serializePollOptions("multiple", " Red \nBlue ")).toBe("Red\nBlue"); }); }); ``` Extend `poll.test.ts` with these exact assertions: ```ts it("accepts text questions without options", () => { expect( pollQuestionSchema.safeParse({ pollId: 1, question: "Why?", type: "text", options: "", }).success, ).toBe(true); }); it.each([ ["one option", "single", "Only"], ["duplicate options", "multiple", "Red\nred"], ["options on text", "text", "Not allowed"], ] as const)("rejects %s", (_label, type, options) => { expect( pollQuestionSchema.safeParse({ pollId: 1, question: "Question", type, options }) .success, ).toBe(false); }); it("rejects more than 100 options", () => { const options = Array.from({ length: 101 }, (_, index) => `Option ${index}`).join("\n"); expect( pollQuestionSchema.safeParse({ pollId: 1, question: "Question", type: "single", options }) .success, ).toBe(false); }); it("requires the end time to be later than the start time", () => { expect( createPollSchema.safeParse({ title: "Schedule", startsAt: "2026-09-02T12:00:00.000Z", endsAt: "2026-09-02T11:59:00.000Z", }).success, ).toBe(false); }); ``` - [ ] **Step 2: Run the focused tests and confirm RED** Run: ```powershell pnpm vitest run --coverage.enabled=false src/lib/polls/poll-semantics.test.ts src/lib/validators/poll.test.ts ``` Expected: FAIL because the semantics module and type-aware validation do not exist. - [ ] **Step 3: Implement the pure semantics module** Create the module with these signatures and behaviour: ```ts export const POLL_QUESTION_TYPES = ["single", "multiple", "text"] as const; export type PollQuestionType = (typeof POLL_QUESTION_TYPES)[number]; export function parsePollOptions(value: string): string[] { return value .split(/\r?\n/u) .map((option) => option.normalize("NFC").trim()) .filter(Boolean); } export function serializePollOptions( type: PollQuestionType, value: string, ): string { return type === "text" ? "" : parsePollOptions(value).join("\n"); } export function parsePollAnswerSelections( type: PollQuestionType, answer: string, ): string[] { const normalized = answer.normalize("NFC"); return type === "multiple" ? parsePollOptions(normalized) : [normalized.trim()].filter(Boolean); } ``` - [ ] **Step 4: Implement Zod refinements and the patch schema** Build `pollQuestionSchema` from a reusable object, apply case-insensitive uniqueness and type-aware option rules, and export a patch that cannot carry `pollId`: ```ts const pollQuestionFields = z.object({ pollId: z.coerce.number().int().positive(), question: z.string().trim().min(1).max(500), type: z.enum(POLL_QUESTION_TYPES).default("single"), sortOrder: z.coerce.number().int().min(0).default(0), options: z.string().max(20_000).default(""), }); function validateQuestionOptions( data: z.infer, context: z.RefinementCtx, ): void { const options = parsePollOptions(data.options); const unique = new Set(options.map((option) => option.toLocaleLowerCase())); if (data.type === "text" && options.length > 0) context.addIssue({ code: "custom", path: ["options"], message: "Text questions cannot have options" }); if (data.type !== "text" && options.length < 2) context.addIssue({ code: "custom", path: ["options"], message: "At least two options are required" }); if (unique.size !== options.length) context.addIssue({ code: "custom", path: ["options"], message: "Options must be unique" }); if (options.length > 100) context.addIssue({ code: "custom", path: ["options"], message: "At most 100 options are allowed" }); } export const pollQuestionSchema = pollQuestionFields.superRefine(validateQuestionOptions); export const pollQuestionPatchSchema = pollQuestionFields .omit({ pollId: true }) .partial(); ``` Define poll fields once and apply the same schedule refinement to full creation and partial update input; the runtime will also merge partial updates with stored dates before invoking the full schema: ```ts const pollFields = z.object({ title: z.string().trim().min(1, "Title is required").max(255), description: z.string().max(2_000).nullable().optional(), status: z.enum(["draft", "active", "closed"]).default("draft"), showResults: z.coerce.number().int().min(0).max(1).default(1), multipleChoice: z.coerce.number().int().min(0).max(1).default(0), startsAt: z.coerce.date().nullable().optional(), endsAt: z.coerce.date().nullable().optional(), }); function validateSchedule( data: { readonly startsAt?: Date | null; readonly endsAt?: Date | null }, context: z.RefinementCtx, ): void { if (data.startsAt && data.endsAt && data.endsAt <= data.startsAt) { context.addIssue({ code: "custom", path: ["endsAt"], message: "End time must be later than start time", }); } } export const createPollSchema = pollFields.superRefine(validateSchedule); export const updatePollSchema = pollFields.partial().superRefine(validateSchedule); ``` Update the existing valid-question fixture from `"Red|Blue|Green"` to `"Red\nBlue\nGreen"` so it uses the newline format already consumed by the public site. - [ ] **Step 5: Route public parsing through the shared helpers** Delete the three local option/answer split implementations and import the pure helpers. The public action validation loop must use: ```ts const options = parsePollOptions(question.options); const selected = parsePollAnswerSelections( question.type as PollQuestionType, answer, ); ``` The public result page must use `parsePollOptions(q.options)` and `parsePollAnswerSelections(q.type as PollQuestionType, vote)` while keeping its existing `showResults`, voted, and ended conditions unchanged. The client vote form must use `parsePollOptions` and continue submitting multiple answers with `.join("\n")`. - [ ] **Step 6: Run focused tests and confirm GREEN** Run: ```powershell pnpm vitest run --coverage.enabled=false src/lib/polls/poll-semantics.test.ts src/lib/validators/poll.test.ts pnpm typecheck ``` Expected: all tests PASS and TypeScript exits 0. - [ ] **Step 7: Commit Task 1** ```powershell git add -- src/lib/polls/poll-semantics.ts src/lib/polls/poll-semantics.test.ts src/lib/validators/poll.ts src/lib/validators/poll.test.ts src/actions/polls.ts 'src/app/(site)/polls/[id]/page.tsx' 'src/app/(site)/polls/[id]/poll-vote-form.tsx' git commit -m "refactor(polls): centralize question semantics" ``` --- ### Task 2: Typed poll query contract and response-page input **Files:** - Modify: `src/features/housekeeping/domains/content/queries/content-queries.ts` - Modify: `src/features/housekeeping/domains/content/queries/content-queries.test.ts` - Modify: `src/features/housekeeping/domains/content/pages/content-page-frame.tsx` - Modify: `src/features/housekeeping/domains/content/pages/content-pages.test.tsx` **Interfaces:** - Produces: `ContentPollCreatePayload`, `ContentPollSummaryPayload`, `ContentPollQuestionPayload`, `ContentPollTextResponsePayload`, `ContentPollTextResponsePagePayload`, and `ContentPollDetailPayload`. - Produces: `isContentPollCreatePayload`, `isContentPollSummaryPayload`, and `isContentPollDetailPayload`. - Extends: `ContentQueryListInput` and `NormalizedContentQueryInput.list` with `responseQuestionId`, `responsePageSize`, and `responseOffset`. - [ ] **Step 1: Write failing contract and normalization tests** Add a valid detail fixture and mutate one field per case: ```ts 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, }, }; 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", items: [{ id: itemId, title: "Poll", privatePayload: payload }], total: 1, partialDependencies: [], }) }); const result = await query.run(context([PERMS.POLLS_VIEW]), { routeId: "content.engagement.poll-detail", params: { id: "7" }, }); expect(result).toMatchObject({ ok: false, error: { code: "DEPENDENCY_UNAVAILABLE" } }); }); ``` Add an adapter-input assertion: ```ts expect(load).toHaveBeenCalledWith({ routeId: "content.engagement.poll-detail", params: { id: "7" }, list: { search: "", pageSize: 25, offset: 0, responseQuestionId: "22", responsePageSize: 50, responseOffset: 100_000, }, }); ``` - [ ] **Step 2: Run contract tests and confirm RED** ```powershell pnpm vitest run --coverage.enabled=false src/features/housekeeping/domains/content/queries/content-queries.test.ts src/features/housekeeping/domains/content/pages/content-pages.test.tsx ``` Expected: FAIL because poll guards and response-page input fields do not exist. - [ ] **Step 3: Add exact poll payload interfaces** Use these stable field names: ```ts 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; } ``` The guards must enforce positive decimal-string IDs, canonical ISO timestamps, non-negative safe counts, valid question types, maximum array sizes, response item count `<= pageSize`, and response-question ownership. Poll detail must contain at most one item whose ID equals `params.id`; create must contain exactly one `{ id: "create", privatePayload: { kind: "poll-create" } }` item. An offset beyond the exact total is a valid empty response page, not a dependency failure. - [ ] **Step 4: Normalize bounded response-page input** Extend the normalized list with: ```ts 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), ``` Extend `parseContentListInput` using search parameters named `responseQuestionId`, `responsePageSize`, and `responseOffset` with the same bounds. Update the one existing exact normalized-input fixture to include default response values. - [ ] **Step 5: Run contract tests and confirm GREEN** Run the command from Step 2. Expected: all focused tests PASS. - [ ] **Step 6: Commit Task 2** ```powershell git add -- src/features/housekeeping/domains/content/queries/content-queries.ts src/features/housekeeping/domains/content/queries/content-queries.test.ts src/features/housekeeping/domains/content/pages/content-page-frame.tsx src/features/housekeeping/domains/content/pages/content-pages.test.tsx git commit -m "feat(housekeeping): define typed poll query contract" ``` --- ### Task 3: Bounded production poll queries **Files:** - Modify: `src/features/housekeeping/domains/content/queries/content-queries-production.ts` - Modify: `src/features/housekeeping/domains/content/queries/content-queries.test.ts` **Interfaces:** - Consumes: payload types and normalized response input from Task 2. - Consumes: parsing helpers from Task 1. - Produces: real list, create, and selected-detail payloads without materializing raw choice-vote rows in ASE. - [ ] **Step 1: Write failing production-adapter tests** Mock the list count/page calls and assert the safe summary: ```ts 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, }); ``` Mock detail calls for the poll, questions, grouped choice answers, text total, and text page. Assert: ```ts expect(result.items[0]?.privatePayload).toMatchObject({ kind: "poll-detail", questions: [ { id: "21", options: ["Red", "Blue"], answerCount: 3, choiceResults: [ { option: "Red", count: 3 }, { option: "Blue", count: 1 }, ], }, ], textResponses: { questionId: "22", total: 51, pageSize: 25, offset: 25, items: [ { userId: "9", username: "Alice", answer: "More events" }, { userId: "10", username: null, answer: "Better prizes" }, ], }, }); ``` Also assert a selected ID is bound, list status/search are bound, question SQL contains `LIMIT 101`, grouped answer SQL contains `LIMIT 10001`, and free-text SQL binds the selected question, limit, and offset. - [ ] **Step 2: Run query tests and confirm RED** ```powershell pnpm vitest run --coverage.enabled=false src/features/housekeeping/domains/content/queries/content-queries.test.ts ``` Expected: FAIL because Polls still use generic query definitions. - [ ] **Step 3: Implement the enriched list and typed create payload** Replace the list statement with one projected row per poll: ```sql 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 ``` Map it with `pollSummaryPayload(row)`. Remove poll-create from `EMPTY_ROUTES` and return one stable create item: ```ts return { kind: "engagement", items: [{ id: "create", title: "Create poll", href: "/ase-next/content/engagement/polls/create", privatePayload: { kind: "poll-create" }, }], total: 1, partialDependencies: [], }; ``` - [ ] **Step 4: Implement the selected detail loader** Use constants: ```ts const POLL_QUESTION_LIMIT = 100; const POLL_OPTION_LIMIT = 100; const POLL_AGGREGATE_ROW_LIMIT = 10_000; ``` Load the selected poll with `WHERE id = ${id} LIMIT 1`; return a successful empty result when absent. Load questions ordered by `sort_order, id` with `LIMIT 101` and throw on overflow or more than 100 parsed options. Load grouped choice answers with: ```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 LIMIT 10001 ``` For each grouped row, add its weight to every value returned by `parsePollAnswerSelections(question.type, answer)` and add the weight once to the question's `answerCount`. Keep only configured options in `choiceResults` and retain configured option order. Choose `input.list.responseQuestionId` only when it identifies a text question in this poll; otherwise use the first text question or `null`. For a selected text question, run an exact count and a bounded page query: ```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} ``` Return `username: null` when the user join is absent; never synthesize a username. Dispatch both dedicated adapters before the generic definition lookup: ```ts if (input.routeId === "content.engagement.poll-create") return pollCreate(input); if (input.routeId === "content.engagement.poll-detail") return pollDetail(input); ``` - [ ] **Step 5: Run query tests and confirm GREEN** Run the command from Step 2. Expected: all query tests PASS. - [ ] **Step 6: Commit Task 3** ```powershell git add -- src/features/housekeeping/domains/content/queries/content-queries-production.ts src/features/housekeeping/domains/content/queries/content-queries.test.ts git commit -m "feat(housekeeping): load complete poll operator data" ``` --- ### Task 4: Reason-protected commands and transactional poll mutations **Files:** - Modify: `src/features/housekeeping/domains/content/commands/content-commands.ts` - Modify: `src/features/housekeeping/domains/content/commands/content-commands.test.ts` - Modify: `src/features/housekeeping/domains/content/services/mutation-runtime-database.ts` - Modify: `src/features/housekeeping/domains/content/services/mutation-runtime-database.test.ts` **Interfaces:** - Produces: `content.engagement.poll.delete` mapped to `poll.change` with `requiresReason: true`. - Produces: `content.engagement.poll-question.delete` mapped to `poll-question.change` with `requiresReason: true`. - Keeps: existing `poll.change` and `poll-question.change` mutation operation IDs and audited transaction classification. - [ ] **Step 1: Write failing command-policy tests** Extend the expected command matrix and assert protected/unprotected inputs: ```ts for (const id of [ "content.engagement.poll.delete", "content.engagement.poll-question.delete", ]) { expect(byId.get(id)?.requiresReason, id).toBe(true); expect(byId.get(id)?.input.safeParse({ action: "delete", id: 7 }).success).toBe(true); expect(byId.get(id)?.input.safeParse({ action: "update", id: 7 }).success).toBe(false); } for (const id of [ "content.engagement.poll.change", "content.engagement.poll-question.change", ]) { expect(byId.get(id)?.requiresReason, id).toBe(false); expect(byId.get(id)?.input.safeParse({ action: "delete", id: 7 }).success).toBe(false); } ``` - [ ] **Step 2: Write failing database mutation tests** Name the mocked poll tables and add `WebsitePollVote`. Assert exact deletion order: ```ts expect(database.removedTables()).toEqual([ "WebsitePollVote", "WebsitePollQuestion", "WebsitePoll", ]); ``` For question deletion assert `["WebsitePollVote", "WebsitePollQuestion"]`. Add tests that an update carrying another `pollId` fails with `VALIDATION`, text questions persist `options: ""`, duplicate options fail with an `options` field error, an end-before-start update fails with an `endsAt` field error, and creating question 101 fails without insertion. - [ ] **Step 3: Run command and mutation tests and confirm RED** ```powershell pnpm vitest run --coverage.enabled=false src/features/housekeeping/domains/content/commands/content-commands.test.ts src/features/housekeeping/domains/content/services/mutation-runtime-database.test.ts ``` Expected: FAIL because protected poll deletes and child-first mutation behaviour are absent. - [ ] **Step 4: Register dedicated delete commands** Add these definitions: ```ts ["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT], ["content.engagement.poll.delete", "poll.change", PERMS.POLLS_EDIT, true], ["content.engagement.poll-question.change", "poll-question.change", PERMS.POLLS_EDIT], ["content.engagement.poll-question.delete", "poll-question.change", PERMS.POLLS_EDIT, true], ``` Apply the existing create/update-only schema to both unprotected poll commands and the existing delete-only schema to both protected poll commands. - [ ] **Step 5: Map Zod errors to field errors** Extend the local validation helper without changing existing callers: ```ts function validation(error?: import("zod").ZodError): ContentMutationFailure { const fieldErrors: Record = {}; for (const issue of error?.issues ?? []) { fieldErrors[String(issue.path[0] ?? "input")] = ["errors.validation.invalid"]; } return new ContentMutationFailure( "VALIDATION", "errors.housekeeping.validation", Object.keys(fieldErrors).length > 0 ? fieldErrors : undefined, ); } ``` Every Poll Zod failure must call `validation(parsed.error)`. - [ ] **Step 6: Implement safe poll create/update/delete** For update, select every editable stored field, parse the patch with `updatePollSchema`, merge it with the stored values, validate the merged object with `createPollSchema`, and update only submitted columns plus `updatedAt`. For delete, remove child rows in this order inside the existing transaction: ```ts await connection.delete(WebsitePollVote).where( inArray( WebsitePollVote.questionId, connection .select({ id: WebsitePollQuestion.id }) .from(WebsitePollQuestion) .where(eq(WebsitePollQuestion.pollId, id)), ), ); await connection.delete(WebsitePollQuestion).where(eq(WebsitePollQuestion.pollId, id)); await connection.delete(WebsitePoll).where(eq(WebsitePoll.id, id)); ``` - [ ] **Step 7: Implement safe question create/update/delete** On create, lock the parent poll row within the current transaction, count its questions, fail with `CONFLICT` at 100, validate the full input, and persist `serializePollOptions(parsed.data.type, parsed.data.options)`. On update, load the existing question first; reject any supplied `pollId`; parse with `pollQuestionPatchSchema`; merge with stored values; validate the merged full object; and write only submitted keys, replacing submitted options with the canonical serialization. On delete, load the existing question for the audit snapshot, delete its votes first, then delete the question. - [ ] **Step 8: Run command and mutation tests and confirm GREEN** Run the command from Step 3. Expected: all tests PASS. - [ ] **Step 9: Commit Task 4** ```powershell git add -- src/features/housekeeping/domains/content/commands/content-commands.ts src/features/housekeeping/domains/content/commands/content-commands.test.ts src/features/housekeeping/domains/content/services/mutation-runtime-database.ts src/features/housekeeping/domains/content/services/mutation-runtime-database.test.ts git commit -m "fix(housekeeping): protect poll destructive actions" ``` --- ### Task 5: Retained form values and field-level errors **Files:** - Modify: `src/features/housekeeping/domains/content/pages/content-command-form.tsx` - Modify: `src/features/housekeeping/domains/content/pages/content-pages.test.tsx` **Interfaces:** - Produces: `ContentCommandFormState` containing the command result and serializable submitted string values. - Produces: `ContentCommandFieldError({ result, fieldName, errorId })` for accessible field feedback. - Preserves: entered non-file values and reason text when a command returns validation or conflict. - [ ] **Step 1: Write failing retained-value and field-error tests** Mock `executeHousekeepingCommand` to return: ```ts fail( "VALIDATION", "errors.housekeeping.validation", "poll-validation", { title: ["errors.validation.invalid"] }, ) ``` Submit `title=Draft title` and assert: ```ts expect(state.result).toMatchObject({ ok: false, error: { code: "VALIDATION" } }); expect(state.values).toMatchObject({ title: "Draft title" }); ``` Render `ContentCommandFieldError` and assert `role="alert"`, the stable error ID, and `errors.validation.invalid`. Add a conflict submission with a reason and assert both values are retained. - [ ] **Step 2: Run page tests and confirm RED** ```powershell pnpm vitest run --coverage.enabled=false src/features/housekeeping/domains/content/pages/content-pages.test.tsx ``` Expected: FAIL because the action currently returns only `HousekeepingResult` and fields render no error association. - [ ] **Step 3: Implement the serializable form state** Use this shape: ```ts export interface ContentCommandFormState { readonly result: HousekeepingResult | null; readonly values: Readonly>; } const initialState: ContentCommandFormState = { result: null, values: {} }; ``` Before command execution, capture only string `FormData` values for configured fields and `reason`; exclude `File` values. Return `{ result, values: result.ok ? {} : submittedValues }`. In `ContentCommandForm`, use retained values as defaults after failure and include the correlation ID in the remount key so the browser reset cannot erase failed input. - [ ] **Step 4: Render accessible field and command feedback** Each field with an error must have `aria-invalid="true"`, `aria-describedby={errorId}`, and: ```tsx ``` The command-level status must display `result.error.messageKey` for validation, conflict, and dependency failures instead of only the word `Failed`; successful commands keep `Completed`. - [ ] **Step 5: Run page tests and confirm GREEN** Run the command from Step 2. Expected: all page tests PASS. - [ ] **Step 6: Commit Task 5** ```powershell git add -- src/features/housekeeping/domains/content/pages/content-command-form.tsx src/features/housekeeping/domains/content/pages/content-pages.test.tsx git commit -m "feat(housekeeping): retain invalid command input" ``` --- ### Task 6: Dedicated ASE Polls workflow and results UI **Files:** - Create: `src/features/housekeeping/domains/content/pages/poll-results.tsx` - Create: `src/features/housekeeping/domains/content/pages/poll-results.test.tsx` - Create: `src/features/housekeeping/domains/content/pages/poll-workflow.tsx` - Create: `src/features/housekeeping/domains/content/pages/poll-workflow.test.tsx` - Modify: `src/features/housekeeping/domains/content/pages/engagement.tsx` - Modify: `src/features/housekeeping/domains/content/pages/content-pages.test.tsx` - Modify: `src/features/housekeeping/domains/content/routes.ts` - Modify: `src/features/housekeeping/domains/content/routes.test.ts` **Interfaces:** - Produces: `ContentPollResults({ pollId, detail, list })` for aggregate and free-text analysis. - Produces: `ContentPollWorkflow(props)` for list, create, and detail routes. - Consumes: typed guards from Task 2, protected commands from Task 4, and retained-error forms from Task 5. - [ ] **Step 1: Write failing result presentation tests** Render a detail fixture and assert: ```ts expect(html).toContain("Red"); expect(html).toContain("3 votes"); expect(html).toContain("100%"); expect(html).toContain("Alice"); expect(html).toContain("user 9"); expect(html).toContain("Unavailable user"); expect(html).toContain("Better prizes"); expect(html).toContain('dateTime="2026-09-02T20:00:00.000Z"'); expect(html).toContain("responseQuestionId=22"); expect(html).toContain("responseOffset=0"); expect(html).toContain("responseOffset=50"); ``` Use a multiple-choice fixture where one option count is greater than half of `answerCount` and assert the percentage uses respondent answers, not the sum of selected options. - [ ] **Step 2: Write failing workflow and permission tests** Cover loading, forbidden, dependency error, true not-found, empty list, and ready states. The ready list must expose search, status, totals, schedule, question/voter/answer counts, pagination, and a create link only for `POLLS_EDIT`. Create must render native UTC date inputs and no manual ID. Detail must render Overview, Questions, and Results; view-only users see data but no command forms. Editors see prefilled poll/question forms, an automatically bound poll ID, and reason-required dedicated delete commands. Add a route assertion: ```ts expect(contentRouteById("content.engagement.poll-detail").capability.slugs).toEqual([ PERMS.POLLS_VIEW, ]); ``` - [ ] **Step 3: Run UI tests and confirm RED** ```powershell pnpm vitest run --coverage.enabled=false src/features/housekeeping/domains/content/pages/poll-results.test.tsx src/features/housekeeping/domains/content/pages/poll-workflow.test.tsx src/features/housekeeping/domains/content/pages/content-pages.test.tsx src/features/housekeeping/domains/content/routes.test.ts ``` Expected: FAIL because the dedicated components do not exist and detail still requires edit permission. - [ ] **Step 4: Implement `ContentPollResults`** Use this public interface: ```ts export interface ContentPollResultsProps { readonly pollId: string; readonly detail: ContentPollDetailPayload; readonly list: Pick< ContentPollListState, "responseQuestionId" | "responsePageSize" | "responseOffset" >; } ``` Render one aggregate block per choice question. Compute `Math.round((count / Math.max(1, question.answerCount)) * 100)`. Render text-question selector links and the selected page table with username or `Unavailable user`, immutable user ID, answer, and UTC `