Files
EpicNext-Cms/docs/superpowers/plans/2026-09-01-housekeeping-content-polls-vertical.md
T
Simo 66b606961b
CI / runtime-diagnostics (pull_request) Skipped
CI / check (pull_request) Successful in 38s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
docs(housekeeping): plan content polls vertical
2026-09-01 21:53:02 +02:00

42 KiB

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:

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:

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:

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:

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:

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<typeof pollQuestionFields>,
  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:

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:

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:

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
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:

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:

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
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:

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:

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
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:

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:

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
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:

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:

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:

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:

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:

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:

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
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:

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:

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
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:

["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:

function validation(error?: import("zod").ZodError): ContentMutationFailure {
  const fieldErrors: Record<string, readonly string[]> = {};
  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:

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
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:

fail(
  "VALIDATION",
  "errors.housekeeping.validation",
  "poll-validation",
  { title: ["errors.validation.invalid"] },
)

Submit title=Draft title and assert:

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
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:

export interface ContentCommandFormState {
  readonly result: HousekeepingResult<unknown> | null;
  readonly values: Readonly<Record<string, string>>;
}

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:

<p id={errorId} role="alert" className="text-xs text-[var(--admin-error)]">
  {messages.join(" · ")}
</p>

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
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:

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:

expect(contentRouteById("content.engagement.poll-detail").capability.slugs).toEqual([
  PERMS.POLLS_VIEW,
]);
  • Step 3: Run UI tests and confirm RED
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:

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 <time>. Build previous/next links from /ase-next/content/engagement/polls/${pollId} with URLSearchParams, preserving responseQuestionId and responsePageSize and bounding the previous offset at zero.

  • Step 5: Implement list, create, and detail workflow

Use the route union and list state:

type ContentPollRouteId =
  | "content.engagement.polls"
  | "content.engagement.poll-create"
  | "content.engagement.poll-detail";

export interface ContentPollListState {
  readonly search: string;
  readonly status: string;
  readonly pageSize: number;
  readonly offset: number;
  readonly responseQuestionId: string;
  readonly responsePageSize: number;
  readonly responseOffset: number;
}

The poll field factory must provide title, nullable description, draft/active/closed status, showResults, compatibility multipleChoice, and nullable datetime-local UTC schedule fields. The question field factory must provide question, single/multiple/text type, non-negative sort order, and options; it must never expose pollId or question ID fields.

Create uses content.engagement.poll.change with { action: "create" }. Detail update uses { action: "update", id: item.id }. New question binds { action: "create", pollId: item.id }; question update binds { action: "update", id: question.id }. Danger zones use content.engagement.poll.delete and content.engagement.poll-question.delete with requiresReason. Render results as <ContentPollResults pollId={item.id} detail={detail} list={list} /> so every selector and page link stays scoped to the canonical poll route.

Return a dedicated not-found section only when the successful detail result has no item. Treat a malformed typed payload as an error state. Hide every form unless context.has(PERMS.POLLS_EDIT).

  • Step 6: Wire the dedicated vertical and remove generic Poll forms

In ContentEngagementPage, route all three poll IDs before the Prefixes fallback:

if (
  routeId === "content.engagement.polls" ||
  routeId === "content.engagement.poll-create" ||
  routeId === "content.engagement.poll-detail"
) {
  return (
    <ContentPollWorkflow
      title={title ?? "Polls"}
      context={context}
      result={result}
      routeId={routeId}
      list={{
        search: search ?? "",
        status,
        pageSize,
        offset,
        responseQuestionId,
        responsePageSize,
        responseOffset,
      }}
    />
  );
}

Delete pollFields, creatingPoll, and both generic Poll command forms. Keep the Prefixes branch byte-for-byte except for surrounding dead-code cleanup. Pass all response fields returned by parseContentListInput from renderContentEngagementPage. Change only poll-detail route capability from POLLS_EDIT to POLLS_VIEW; create remains edit-only.

  • Step 7: Run focused Content tests and confirm GREEN

Run:

pnpm vitest run --coverage.enabled=false src/lib/polls/poll-semantics.test.ts src/lib/validators/poll.test.ts src/features/housekeeping/domains/content/queries/content-queries.test.ts src/features/housekeeping/domains/content/commands/content-commands.test.ts src/features/housekeeping/domains/content/services/mutation-runtime-database.test.ts src/features/housekeeping/domains/content/pages/content-pages.test.tsx 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/routes.test.ts

Expected: all focused tests PASS with no skipped Poll tests.

  • Step 8: Commit Task 6
git add -- src/features/housekeeping/domains/content/pages/poll-results.tsx src/features/housekeeping/domains/content/pages/poll-results.test.tsx src/features/housekeeping/domains/content/pages/poll-workflow.tsx src/features/housekeeping/domains/content/pages/poll-workflow.test.tsx src/features/housekeeping/domains/content/pages/engagement.tsx src/features/housekeeping/domains/content/pages/content-pages.test.tsx src/features/housekeeping/domains/content/routes.ts src/features/housekeeping/domains/content/routes.test.ts
git commit -m "feat(housekeeping): add dedicated polls workflow"

Task 7: Full verification, review, and draft PR refresh

Files:

  • Modify when checking boxes: docs/superpowers/plans/2026-09-01-housekeeping-content-polls-vertical.md
  • Modify through forge API: draft PR 53 body in English and Dutch

Interfaces:

  • Consumes: all deliverables from Tasks 1-6.

  • Produces: verified commits, remote branch parity, bilingual PR evidence, and a CI status report without a deployment claim.

  • Step 1: Run the focused formatting and static checks

pnpm typecheck
pnpm exec biome check src/lib/polls 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' src/features/housekeeping/domains/content
pnpm exec knip

Expected: all commands exit 0. Apply only fixes caused by this vertical and rerun the failed command.

  • Step 2: Run the Housekeeping matrix
pnpm test:housekeeping

Expected: zero failing Housekeeping test files and the route/migration matrix remains complete.

  • Step 3: Run the full test suite with coverage
pnpm test

Expected: zero failing test files and zero failing tests; record exact passed/skipped counts for the PR.

  • Step 4: Run the production build
pnpm build

Expected: exit code 0 and the three canonical /ase-next/content/engagement/polls routes are generated.

  • Step 5: Review the exact diff and safety boundaries
git diff --check
git status --short
git diff --stat origin/main...HEAD
git diff origin/main...HEAD -- src/app/admin/polls src/db/schema.ts package.json pnpm-lock.yaml

Expected: no whitespace errors; legacy admin, schema, dependencies, lockfile, .remember/, and .superpowers/brainstorm/ are unchanged. Review every Poll diff for manual IDs, unbounded collections, public identity leakage, or unprotected delete actions.

  • Step 6: Commit any verification-only corrections

Stage exact corrected paths only and use:

git commit -m "test(housekeeping): verify polls vertical"

Skip this commit when verification required no file correction.

  • Step 7: Push and verify remote parity
git push origin codex/housekeeping-rebuild-stepwise
git rev-parse HEAD
git rev-parse origin/codex/housekeeping-rebuild-stepwise

Expected: both SHA values match.

  • Step 8: Update and inspect draft PR 53

Add a Polls section and current verification evidence to both the English and Dutch halves of the existing PR description. Preserve all previous vertical notes. Confirm the PR remains draft, its head is codex/housekeeping-rebuild-stepwise, and CI reports success before calling the vertical complete.