1076 lines
42 KiB
Markdown
1076 lines
42 KiB
Markdown
# 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<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:
|
|
|
|
```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<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:
|
|
|
|
```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<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:
|
|
|
|
```tsx
|
|
<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**
|
|
|
|
```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 `<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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```powershell
|
|
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**
|
|
|
|
```powershell
|
|
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**
|
|
|
|
```powershell
|
|
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**
|
|
|
|
```powershell
|
|
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**
|
|
|
|
```powershell
|
|
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**
|
|
|
|
```powershell
|
|
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**
|
|
|
|
```powershell
|
|
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:
|
|
|
|
```powershell
|
|
git commit -m "test(housekeeping): verify polls vertical"
|
|
```
|
|
|
|
Skip this commit when verification required no file correction.
|
|
|
|
- [ ] **Step 7: Push and verify remote parity**
|
|
|
|
```powershell
|
|
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.
|