diff --git a/docs/superpowers/plans/2026-09-01-housekeeping-content-events-vertical.md b/docs/superpowers/plans/2026-09-01-housekeeping-content-events-vertical.md new file mode 100644 index 00000000..c870445f --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-housekeeping-content-events-vertical.md @@ -0,0 +1,237 @@ +# Housekeeping Content Events 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 ASE event and event-type operator workflow backed by real database data and existing audited mutations. + +**Architecture:** Keep the generic Content query/command foundation, add strict route-specific event payloads, and render them through a focused `ContentEventWorkflow`. Use the existing event tables and mutation runtime; introduce dedicated delete command IDs only to enforce reason confirmation. + +**Tech Stack:** Next.js 16, React 19, TypeScript, Drizzle/MySQL, Zod, Vitest, React server actions + +**Spec:** `docs/superpowers/specs/2026-09-01-housekeeping-content-events-vertical-design.md` + +## Global Constraints + +- Work directly on `codex/housekeeping-rebuild-stepwise`; do not create a worktree. +- Preserve `/admin/events` and all unrelated local/untracked files. +- Do not add dependencies or change the database schema. +- Keep Polls and Prefixes behavior unchanged. +- Write and run a failing test before each production behavior change. +- Keep every destructive event or event-type action reason-protected and auditable. + +--- + +### Task 1: Typed event query payloads and production loading + +**Files:** +- Modify: `src/features/housekeeping/domains/content/queries/content-queries.ts` +- Modify: `src/features/housekeeping/domains/content/queries/content-queries-production.ts` +- Test: `src/features/housekeeping/domains/content/queries/content-queries.test.ts` + +**Interfaces:** +- Produces: `ContentEventTypePayload`, `ContentEventSummaryPayload`, `ContentEventDetailPayload`, and their public type guards. +- Produces: validated private payloads for list, create, detail, and type routes. +- Consumes: `ContentQueryItem.privatePayload`, normalized `params.id`, `list.search`, `list.status`, `list.pageSize`, and `list.offset`. + +- [x] **Step 1: Write failing contract tests** + +Add literal fixtures proving that malformed event payloads fail closed, event list input normalizes a bounded status, and a detail adapter returning more than one item is rejected. + +- [x] **Step 2: Run the query test and confirm RED** + +Run: `yarn.cmd vitest run src/features/housekeeping/domains/content/queries/content-queries.test.ts` + +Expected: FAIL because event payload guards and status normalization do not exist. + +- [x] **Step 3: Implement the minimal event contracts** + +Add route-specific interfaces with JSON-safe primitive fields and type guards. Extend list input with `status`, normalized to lowercase and at most 32 characters. Extend `isValidData` so each event route accepts only its corresponding payload and detail accepts at most one selected item. + +- [x] **Step 4: Run the query test and confirm GREEN** + +Run the command from Step 2. Expected: PASS. + +- [x] **Step 5: Write failing production-loader tests** + +Add tests proving: + +```ts +expect(list.items[0]?.privatePayload).toMatchObject({ + typeName: "Tournament", + registrationCount: 12, +}); +expect(detail.items).toHaveLength(1); +expect(detail.items[0]?.privatePayload).toMatchObject({ + description: "Complete event", + eventTypes: [{ id: "2", name: "Tournament" }], + prizes: [{ id: "4", prizeType: "badge" }], + winners: [{ userId: "9", username: "Alice" }], + registrations: [{ userId: "10", username: "Bob" }], +}); +``` + +Also assert that the serialized detail SQL binds the requested ID and that status filtering is bound, not interpolated. + +- [x] **Step 6: Run the loader tests and confirm RED** + +Run the command from Step 2. Expected: FAIL because production definitions only expose generic summaries. + +- [x] **Step 7: Implement production event loaders** + +Update event list and type definitions to project full safe summaries. Load event-create options from active event types. Add a dedicated detail loader that performs bounded, parameterized reads for the selected event, event types, prizes, winners with usernames, and registrations with usernames. Return not-found as an empty successful result. + +- [x] **Step 8: Run the loader tests and confirm GREEN** + +Run the command from Step 2. Expected: PASS. + +### Task 2: Event command safety and form field semantics + +**Files:** +- Modify: `src/features/housekeeping/domains/content/commands/content-commands.ts` +- Modify: `src/features/housekeeping/domains/content/pages/content-command-form.tsx` +- Test: `src/features/housekeeping/domains/content/commands/content-commands.test.ts` +- Test: `src/features/housekeeping/domains/content/pages/content-pages.test.tsx` + +**Interfaces:** +- Produces: `content.engagement.event.delete` and `content.engagement.event-type.delete`, both mapped to existing mutations with `requiresReason: true`. +- Produces: `ContentCommandField.type === "datetime-local"`, submitted as the normalized browser value. + +- [x] **Step 1: Write failing command-policy tests** + +Assert the two delete command IDs exist, use `event.change` and `event-type.change`, retain `PERMS.EVENTS_EDIT`, and require reasons while non-destructive change commands do not. + +- [x] **Step 2: Run command tests and confirm RED** + +Run: `yarn.cmd vitest run src/features/housekeeping/domains/content/commands/content-commands.test.ts` + +Expected: FAIL because the dedicated delete commands are absent. + +- [x] **Step 3: Implement command metadata** + +Extend the command definition tuple with an optional `requiresReason` flag and register both dedicated delete command IDs without adding mutation operations. + +- [x] **Step 4: Run command tests and confirm GREEN** + +Run the command from Step 2. Expected: PASS. + +- [x] **Step 5: Write a failing date/time form test** + +Render a field with `type: "datetime-local"` and assert the real input type and default value. Submit it and assert the existing server action receives the exact normalized date/time string. + +- [x] **Step 6: Run page tests and confirm RED** + +Run: `yarn.cmd vitest run src/features/housekeeping/domains/content/pages/content-pages.test.tsx` + +Expected: FAIL because `datetime-local` is not supported. + +- [x] **Step 7: Implement the minimal field support** + +Add `datetime-local` to the field union and map it to ``; keep the existing bounded string parser. + +- [x] **Step 8: Run page tests and confirm GREEN** + +Run the command from Step 6. Expected: PASS. + +### Task 3: Complete Event workflow UI + +**Files:** +- Create: `src/features/housekeeping/domains/content/pages/event-workflow.tsx` +- Modify: `src/features/housekeeping/domains/content/pages/engagement.tsx` +- Create: `src/features/housekeeping/domains/content/pages/event-workflow.test.tsx` +- Modify: `src/features/housekeeping/domains/content/pages/content-pages.test.tsx` + +**Interfaces:** +- Produces: `ContentEventWorkflow(props)` for the four event routes. +- Consumes: typed payload guards from Task 1 and command IDs/field semantics from Task 2. + +- [x] **Step 1: Write failing list and state tests** + +Render real `HousekeepingResult` fixtures and assert loading, forbidden, dependency-error, empty, and ready states. The ready list must expose search, status filtering, result count, type, schedule, capacity, registrations, create/type links, and bounded previous/next links. + +- [x] **Step 2: Run workflow tests and confirm RED** + +Run: `yarn.cmd vitest run src/features/housekeeping/domains/content/pages/event-workflow.test.tsx` + +Expected: FAIL because the component does not exist. + +- [x] **Step 3: Implement the list/state slice** + +Create the focused workflow component and route the four event route IDs to it from `ContentEngagementPage`, leaving Polls and Prefixes on the existing generic implementation. + +- [x] **Step 4: Run workflow tests and confirm GREEN** + +Run the command from Step 2. Expected: PASS for list/state tests. + +- [x] **Step 5: Write failing create/detail tests** + +Assert create uses real type options and date/time inputs. Assert detail prepopulates title, description, selected type, schedule, capacity, room, status, recurrence, and image; automatically binds event IDs for prizes/winners; renders usernames for registrations; and exposes a reason-required delete form using `content.engagement.event.delete`. + +- [x] **Step 6: Run workflow tests and confirm RED** + +Run the command from Step 2. Expected: FAIL because create/detail workflow sections are incomplete. + +- [x] **Step 7: Implement create/detail** + +Build field factories from the validated payload. Render not-found separately from dependency failure. Keep related forms and lists inside the selected event detail; never expose manual event-ID inputs. + +- [x] **Step 8: Run workflow tests and confirm GREEN** + +Run the command from Step 2. Expected: PASS for create/detail tests. + +- [x] **Step 9: Write failing event-type tests** + +Assert a create form and one prefilled update form per type, plus a reason-required delete form using `content.engagement.event-type.delete`; no operator-entered type ID field is allowed. + +- [x] **Step 10: Run workflow tests and confirm RED** + +Run the command from Step 2. Expected: FAIL until type management is implemented. + +- [x] **Step 11: Implement event-type management and refactor** + +Add create/update/delete sections using typed payloads. Extract small field and formatting helpers while all tests stay green. + +- [x] **Step 12: Run focused Content tests and confirm GREEN** + +Run: + +`yarn.cmd vitest run 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/pages/content-pages.test.tsx src/features/housekeeping/domains/content/pages/event-workflow.test.tsx` + +Expected: all focused tests PASS. + +### Task 4: Full verification and draft PR update + +**Files:** +- Modify: `docs/superpowers/plans/2026-09-01-housekeeping-content-events-vertical.md` +- Modify: draft PR 53 body in English and Dutch + +**Interfaces:** +- Consumes: all deliverables from Tasks 1–3. +- Produces: verified commit(s), pushed branch, and current bilingual PR evidence. + +- [ ] **Step 1: Run static and targeted checks** + +Run the repository TypeScript, Biome, Knip, focused test, and Housekeeping matrix commands from `package.json` and the existing Housekeeping evidence workflow. Fix only failures caused by this vertical. + +- [ ] **Step 2: Run the full test suite with coverage** + +Run: `yarn.cmd test` + +Expected: zero failing test files and zero failing tests. + +- [ ] **Step 3: Run the production build** + +Run: `yarn.cmd build` + +Expected: exit code 0 with canonical `/ase-next` routes generated. + +- [ ] **Step 4: Review the exact diff** + +Run: `git diff --check`, `git status --short`, and `git diff --stat origin/main...HEAD` after committing. Confirm `.remember/` and `.superpowers/brainstorm/` remain untouched and untracked. + +- [ ] **Step 5: Commit and push exact paths** + +Commit query/command/UI/test/plan files with a scoped message, then push `codex/housekeeping-rebuild-stepwise`. + +- [ ] **Step 6: Update and verify the draft PR** + +Add the Events vertical and fresh verification counts to PR 53 in English and Dutch. Confirm the remote head matches local HEAD and inspect CI status without claiming deployment. diff --git a/docs/superpowers/specs/2026-09-01-housekeeping-content-events-vertical-design.md b/docs/superpowers/specs/2026-09-01-housekeeping-content-events-vertical-design.md new file mode 100644 index 00000000..0dd14b53 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-housekeeping-content-events-vertical-design.md @@ -0,0 +1,57 @@ +# Housekeeping Content Events Vertical Design + +**Date:** 2026-09-01 + +**Status:** Approved + +## Objective + +Replace the generic ASE event command forms with a complete operator workflow for finding, creating, editing, deleting, and administering events while preserving the existing `/admin/events` implementation as a stable fallback. + +## Scope + +The vertical covers these canonical routes: + +- `/ase-next/content/engagement/events` +- `/ase-next/content/engagement/events/create` +- `/ase-next/content/engagement/events/:id` +- `/ase-next/content/engagement/events/types` + +It includes event search and status filtering, bounded pagination, active event-type selection, prefilled create/edit forms, event status and schedule fields, prizes, winners, registrations, event-type maintenance, and reason-protected destructive actions. + +Polls and prefixes remain unchanged. The existing database schema and dependencies remain unchanged. + +## Data Contract + +Event routes expose route-specific typed private payloads through the existing `ContentQueryItem.privatePayload` boundary: + +- event list items carry type, schedule, capacity, and registration count; +- event-create items carry active event-type options; +- event-detail returns one selected event with editable fields, all event-type options, prizes, winners, and registrations; +- event-type items carry every editable type field. + +The query contract validates every route-specific payload and fails closed when a production adapter returns malformed or partial data. Event detail is always selected by the canonical route parameter; its returned ID and cardinality must match the request. Event IDs are positive decimal strings, timestamps are canonical UTC ISO values, and nested operator records follow the same identifier/date rules. + +Event-create loads every active type up to an explicit 500-type safety cap instead of applying list pagination. Event detail uses fail-closed caps of 500 types, 100 prizes, 500 winners, and 1,000 registrations. The type-management route keeps normal bounded pagination. + +## Operator Experience + +The event list provides a visible primary action, type-management link, title/type search, status filter, result totals, useful event metadata, and previous/next navigation. + +Create and edit forms use real event-type options and browser-native date/time inputs. Date/time values are displayed and submitted as UTC, then normalized to canonical ISO strings. Optional fields carry explicit clear semantics: blank nullable values become `null`, while the event-type description can be cleared to an empty string. The detail page prepopulates all event fields and groups related operational data below the editor. Prize and winner creation bind the current event ID automatically. Registrations are read-only and display usernames and registration time. + +The event-types screen supports create, prefilled update, and reason-protected delete without requiring operators to copy numeric IDs. + +Event and event-type deletion use dedicated command IDs that require an audit reason and cannot be bypassed through the generic change commands. Event deletion removes registrations, prizes, and winners before the parent inside the existing mutation transaction. Event-type deletion fails with a conflict while any event still references the type. Non-destructive create and update commands keep their current confirmation behavior. + +## Dependency Decision + +No new runtime dependency is needed. The existing React, Zod, Drizzle, and platform date/input APIs cover the workflow with a smaller security and maintenance surface; Knip remains the dependency/source audit for this vertical. + +## Permissions and Failure States + +`events.view` can read the event list. `events.edit` is required for create, detail administration, type administration, and every mutation. Each route preserves explicit loading, forbidden, dependency-error, empty, not-found, and ready states. + +## Verification + +The vertical is complete only when query contract tests, production adapter tests, command policy tests, component rendering tests, the Housekeeping matrix, TypeScript, Biome, Knip, and the production build all pass. The draft PR description is updated in English and Dutch after verified implementation. diff --git a/knip.json b/knip.json index f7774b60..240f5ab5 100644 --- a/knip.json +++ b/knip.json @@ -12,6 +12,12 @@ "scripts/furni-diagnose-now.ts" ], "project": ["src/**/*.{ts,tsx,css}", "scripts/**/*.{ts,js}"], - "ignoreDependencies": ["@sentry/nextjs", "pino-pretty", "husky"], - "ignoreBinaries": ["sendmail"] + "ignoreDependencies": [ + "@sentry/nextjs", + "pino-pretty", + "husky", + "lint-staged" + ], + "ignoreBinaries": ["sendmail"], + "ignoreIssues": { ".husky/*": ["binaries"] } } diff --git a/src/features/housekeeping/domains/content/commands/content-commands.test.ts b/src/features/housekeeping/domains/content/commands/content-commands.test.ts index f2738baa..4a0c7595 100644 --- a/src/features/housekeeping/domains/content/commands/content-commands.test.ts +++ b/src/features/housekeeping/domains/content/commands/content-commands.test.ts @@ -12,7 +12,13 @@ const expected = [ "event-type.change", PERMS.EVENTS_EDIT, ], + [ + "content.engagement.event-type.delete", + "event-type.change", + PERMS.EVENTS_EDIT, + ], ["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT], + ["content.engagement.event.delete", "event.change", PERMS.EVENTS_EDIT], [ "content.engagement.event-prize.change", "event-prize.change", @@ -139,6 +145,54 @@ describe("Content commands", () => { ), ).toBe(true); }); + it("requires reasons for dedicated event deletion commands only", () => { + const commands = createContentCommands({ execute: vi.fn() } as never); + const byId = new Map(commands.map((command) => [command.id, command])); + + for (const id of [ + "content.engagement.event.delete", + "content.engagement.event-type.delete", + ]) { + expect(byId.get(id)?.requiresReason, id).toBe(true); + } + for (const id of [ + "content.engagement.event.change", + "content.engagement.event-type.change", + ]) { + expect(byId.get(id)?.requiresReason, id).toBe(false); + } + }); + it("rejects delete actions through unprotected change commands", () => { + const commands = createContentCommands({ execute: vi.fn() } as never); + const byId = new Map(commands.map((command) => [command.id, command])); + + for (const id of [ + "content.engagement.event.change", + "content.engagement.event-type.change", + ]) { + expect( + byId.get(id)?.input.safeParse({ action: "delete", id: 7 }).success, + id, + ).toBe(false); + expect( + byId.get(id)?.input.safeParse({ action: "update", id: 7 }).success, + id, + ).toBe(true); + } + for (const id of [ + "content.engagement.event.delete", + "content.engagement.event-type.delete", + ]) { + expect( + byId.get(id)?.input.safeParse({ action: "delete", id: 7 }).success, + id, + ).toBe(true); + expect( + byId.get(id)?.input.safeParse({ action: "update", id: 7 }).success, + id, + ).toBe(false); + } + }); it("executes the real mutation service with actor-bound authority", async () => { const execute = vi.fn(async () => ({ diff --git a/src/features/housekeeping/domains/content/commands/content-commands.ts b/src/features/housekeeping/domains/content/commands/content-commands.ts index cdd4f081..2ad78599 100644 --- a/src/features/housekeeping/domains/content/commands/content-commands.ts +++ b/src/features/housekeeping/domains/content/commands/content-commands.ts @@ -19,7 +19,14 @@ const CONTENT_COMMAND_DEFINITIONS = [ "event-type.change", PERMS.EVENTS_EDIT, ], + [ + "content.engagement.event-type.delete", + "event-type.change", + PERMS.EVENTS_EDIT, + true, + ], ["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT], + ["content.engagement.event.delete", "event.change", PERMS.EVENTS_EDIT, true], [ "content.engagement.event-prize.change", "event-prize.change", @@ -107,19 +114,47 @@ type ContentCommand = HousekeepingCommand, unknown> & { }; const commandInput = z.object({}).catchall(z.unknown()); +const eventChangeInput = z + .object({ action: z.enum(["create", "update"]) }) + .catchall(z.unknown()); +const eventDeleteInput = z + .object({ + action: z.literal("delete"), + id: z.union([z.number().int().positive(), z.string().regex(/^[1-9]\d*$/u)]), + }) + .catchall(z.unknown()); + +function inputForCommand( + id: (typeof CONTENT_COMMAND_DEFINITIONS)[number][0], +): z.ZodType> { + if ( + id === "content.engagement.event.change" || + id === "content.engagement.event-type.change" + ) { + return eventChangeInput; + } + if ( + id === "content.engagement.event.delete" || + id === "content.engagement.event-type.delete" + ) { + return eventDeleteInput; + } + return commandInput; +} export function createContentCommands( service: Pick, ): readonly ContentCommand[] { return CONTENT_COMMAND_DEFINITIONS.map( - ([id, operation, permission]): ContentCommand => ({ + ([id, operation, permission, requiresReason = false]): ContentCommand => ({ id, owner: "content", operation, risk: "sensitive", capability: anyCapability(permission), - input: commandInput, + input: inputForCommand(id), requiresReason: + requiresReason || id.startsWith("content.brand.") || id.startsWith("content.localization."), rateLimit: { attempts: 10, windowMs: 60_000 }, diff --git a/src/features/housekeeping/domains/content/pages/content-command-form.tsx b/src/features/housekeeping/domains/content/pages/content-command-form.tsx index 2e62b81c..8871d39a 100644 --- a/src/features/housekeeping/domains/content/pages/content-command-form.tsx +++ b/src/features/housekeeping/domains/content/pages/content-command-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useId } from "react"; import type { HousekeepingResult } from "../../../foundation/contracts"; export interface ContentCommandField { @@ -12,6 +12,7 @@ export interface ContentCommandField { | "text" | "textarea" | "number" + | "datetime-local" | "checkbox" | "select" | "file"; @@ -21,6 +22,7 @@ export interface ContentCommandField { readonly maxLength?: number; readonly defaultValue?: string | number | boolean; readonly allowUnchanged?: boolean; + readonly emptyValue?: null | ""; readonly options?: readonly Readonly<{ value: string | number; label: string; @@ -39,6 +41,20 @@ interface ContentCommandFormProps extends ContentCommandSubmission { } const OMIT_FIELD = Symbol("omit optional Content command field"); +function optionalEmptyValue(field: ContentCommandField): unknown { + return field.emptyValue === null || field.emptyValue === "" + ? field.emptyValue + : OMIT_FIELD; +} + +function normalizeUtcDateTimeLocal(value: string): string { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/u.test(value)) return value; + const iso = `${value}:00.000Z`; + const parsed = new Date(iso); + return Number.isFinite(parsed.getTime()) && parsed.toISOString() === iso + ? iso + : value; +} function parseField(field: ContentCommandField, formData: FormData): unknown { const rawValue = formData.get(field.name); @@ -51,12 +67,12 @@ function parseField(field: ContentCommandField, formData: FormData): unknown { } return rawValue === "on"; } - if (rawValue === null && !field.required) return OMIT_FIELD; + if (rawValue === null && !field.required) return optionalEmptyValue(field); if (field.type === "file") return rawValue instanceof File ? rawValue : null; const raw = String(rawValue ?? "") .normalize("NFC") .trim(); - if (!raw && !field.required) return OMIT_FIELD; + if (!raw && !field.required) return optionalEmptyValue(field); if (field.type === "number") { if (!raw) return raw; const value = Number(raw); @@ -69,6 +85,7 @@ function parseField(field: ContentCommandField, formData: FormData): unknown { )?.value; return selected ?? raw.slice(0, 500); } + if (field.type === "datetime-local") return normalizeUtcDateTimeLocal(raw); if (field.type === "json") { try { return JSON.parse(raw.slice(0, field.maxLength ?? 20_000)); @@ -118,6 +135,7 @@ export function ContentCommandForm({ fields = [], requiresReason = false, }: ContentCommandFormProps) { + const formId = useId(); const [result, submit, pending] = useActionState( submitContentCommandForm.bind(null, { commandId, @@ -136,14 +154,14 @@ export function ContentCommandForm({ {fields.map((field) => (