diff --git a/docs/superpowers/plans/2026-09-01-housekeeping-content-polls-vertical.md b/docs/superpowers/plans/2026-09-01-housekeeping-content-polls-vertical.md new file mode 100644 index 00000000..a230cf93 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-housekeeping-content-polls-vertical.md @@ -0,0 +1,1075 @@ +# 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 `