feat(housekeeping): complete content events workflow

This commit is contained in:
Simo committed 2026-09-01 21:08:26 +02:00
1 parent f2e67ebace
commit d6235c37a1
42 files changed
+2868 -307

No files matched your search

@@ -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 `<input type="datetime-local">`; 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.
@@ -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.
+8 -2
View File
@@ -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"] }
}
@@ -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 () => ({
@@ -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<Record<string, unknown>, 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<Record<string, unknown>> {
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<ContentMutationService, "execute">,
): 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 },
@@ -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) => (
<label
key={field.name}
htmlFor={`${commandId}-${field.name}`}
htmlFor={`${formId}-${field.name}`}
className="block text-sm"
>
{field.type === "checkbox" && field.allowUnchanged ? (
<>
{field.label}
<select
id={`${commandId}-${field.name}`}
id={`${formId}-${field.name}`}
name={field.name}
defaultValue=""
className="mt-1 block w-full"
@@ -156,7 +174,7 @@ export function ContentCommandForm({
) : field.type === "checkbox" ? (
<>
<input
id={`${commandId}-${field.name}`}
id={`${formId}-${field.name}`}
name={field.name}
type="checkbox"
defaultChecked={field.defaultValue === true}
@@ -167,7 +185,7 @@ export function ContentCommandForm({
<>
{field.label}
<select
id={`${commandId}-${field.name}`}
id={`${formId}-${field.name}`}
name={field.name}
defaultValue={
typeof field.defaultValue === "boolean"
@@ -189,7 +207,7 @@ export function ContentCommandForm({
<>
{field.label}
<textarea
id={`${commandId}-${field.name}`}
id={`${formId}-${field.name}`}
name={field.name}
defaultValue={
typeof field.defaultValue === "boolean"
@@ -205,14 +223,16 @@ export function ContentCommandForm({
<>
{field.label}
<input
id={`${commandId}-${field.name}`}
id={`${formId}-${field.name}`}
name={field.name}
type={
field.type === "number"
? "number"
: field.type === "file"
? "file"
: "text"
: field.type === "datetime-local"
? "datetime-local"
: field.type === "file"
? "file"
: "text"
}
defaultValue={
field.type === "file" ||
@@ -231,10 +251,10 @@ export function ContentCommandForm({
</label>
))}
{requiresReason ? (
<label htmlFor={`${commandId}-reason`} className="block text-sm">
<label htmlFor={`${formId}-reason`} className="block text-sm">
Reason
<textarea
id={`${commandId}-reason`}
id={`${formId}-reason`}
name="reason"
required
maxLength={1000}
@@ -71,9 +71,26 @@ export function parseContentListInput(
) {
const first = (value: string | readonly string[] | undefined) =>
Array.isArray(value) ? value[0] : value;
const text = (value: string | readonly string[] | undefined, limit: number) =>
String(first(value) ?? "")
.normalize("NFC")
.trim()
.slice(0, limit);
const bounded = (
value: string | readonly string[] | undefined,
fallback: number,
minimum: number,
maximum: number,
) => {
const parsed = Number(first(value));
return Number.isSafeInteger(parsed)
? Math.min(maximum, Math.max(minimum, parsed))
: fallback;
};
return {
search: first(searchParams.search),
pageSize: Number(first(searchParams.pageSize) ?? 25),
offset: Number(first(searchParams.offset) ?? 0),
search: text(searchParams.search, 128),
status: text(searchParams.status, 32).toLocaleLowerCase(),
pageSize: bounded(searchParams.pageSize, 25, 1, 100),
offset: bounded(searchParams.offset, 0, 0, 100_000),
};
}
@@ -8,7 +8,11 @@ import {
ok,
} from "../../../foundation/contracts";
import { ContentBrandPage } from "./brand";
import { submitContentCommandForm } from "./content-command-form";
import {
ContentCommandForm,
submitContentCommandForm,
} from "./content-command-form";
import { parseContentListInput } from "./content-page-frame";
import { ContentEditorialPage } from "./editorial";
import { ContentEngagementPage } from "./engagement";
import { ContentHelpPage } from "./help";
@@ -39,6 +43,44 @@ const cases = [
["localization", ContentLocalizationPage, PERMS.SETTINGS_EDIT],
] as const;
const eventTypeItem = {
id: "2",
title: "Tournament",
status: "active",
privatePayload: {
kind: "event-type" as const,
name: "Tournament",
slug: "tournament",
description: "Ranked competition",
color: "#ff9900",
icon: "trophy",
isActive: true,
minRank: 1,
},
};
const eventDetailItem = {
id: "11",
title: "Battle Ball",
status: "published",
privatePayload: {
kind: "event-detail" as const,
description: "Complete event",
typeId: "2",
hostUserId: "5",
roomId: "99",
startsAt: "2026-09-02T18:00:00.000Z",
endsAt: null,
maxPlayers: 20,
isRecurring: false,
recurrenceRule: null,
image: null,
eventTypes: [{ id: eventTypeItem.id, ...eventTypeItem.privatePayload }],
prizes: [],
winners: [],
registrations: [],
},
};
describe.each(cases)("Content %s page", (kind, Component, editPermission) => {
it("renders loading, forbidden, empty, and real ready states", () => {
const read = context(Object.values(PERMS));
@@ -83,7 +125,93 @@ describe.each(cases)("Content %s page", (kind, Component, editPermission) => {
describe("Content actionable form wiring", () => {
beforeEach(() => vi.clearAllMocks());
it("renders and submits native event date-time fields", async () => {
const html = renderToStaticMarkup(
<ContentCommandForm
commandId="content.engagement.event.change"
buttonLabel="Save event"
input={{ action: "create" }}
fields={[
{
name: "startsAt",
label: "Starts at",
type: "datetime-local",
required: true,
defaultValue: "2026-09-02T18:00",
},
]}
/>,
);
expect(html).toMatch(/type="datetime-local"[^>]*value="2026-09-02T18:00"/u);
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { id: "11" } }, "event-date"),
);
const formData = new FormData();
formData.set("startsAt", " 2026-09-02T18:00 ");
await submitContentCommandForm(
{
commandId: "content.engagement.event.change",
input: { action: "create" },
fields: [
{
name: "startsAt",
label: "Starts at",
type: "datetime-local",
required: true,
},
],
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "content.engagement.event.change",
input: { action: "create", startsAt: "2026-09-02T18:00:00.000Z" },
});
});
it("submits explicit empty values for clearable fields", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: { id: "11" }, after: { id: "11" } }, "event-clear"),
);
const formData = new FormData();
formData.set("endsAt", "");
formData.set("description", "");
await submitContentCommandForm(
{
commandId: "content.engagement.event.change",
input: { action: "update", id: "11" },
fields: [
{
name: "endsAt",
label: "Ends at",
type: "datetime-local",
emptyValue: null,
},
{
name: "description",
label: "Description",
type: "textarea",
emptyValue: "",
},
],
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "content.engagement.event.change",
input: {
action: "update",
id: "11",
endsAt: null,
description: "",
},
});
});
it("submits canonical identifiers and a bounded reason through the real server action", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { id: "18446744073709551615" } }, "form"),
@@ -324,7 +452,12 @@ describe("Content actionable form wiring", () => {
<ContentEngagementPage
context={context([PERMS.EVENTS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
{
kind: "engagement",
items: [eventTypeItem],
total: 1,
partialDependencies: [],
},
"event-form",
)}
routeId="content.engagement.event-create"
@@ -336,12 +469,77 @@ describe("Content actionable form wiring", () => {
}
});
it("routes event lists through the focused workflow with parsed filters", () => {
expect(
parseContentListInput({
search: "ball",
status: "published",
pageSize: "25",
offset: "25",
}),
).toEqual({
search: "ball",
status: "published",
pageSize: 25,
offset: 25,
});
const html = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.EVENTS_VIEW, PERMS.EVENTS_EDIT])}
result={ok(
{
kind: "engagement",
items: [
{
id: "11",
title: "Battle Ball",
status: "published",
privatePayload: {
kind: "event-summary",
typeId: "2",
typeName: "Tournament",
typeColor: "#ff9900",
startsAt: "2026-09-02T18:00:00.000Z",
endsAt: null,
maxPlayers: 20,
registrationCount: 12,
},
},
],
total: 1,
partialDependencies: [],
},
"event-route",
)}
routeId="content.engagement.events"
search="ball"
status="published"
pageSize={25}
offset={0}
/>,
);
expect(html).toContain("New event");
expect(html).toContain('name="search"');
expect(html).toContain('value="ball"');
expect(html).not.toContain('name="id"');
expect(html).not.toContain(
'data-housekeeping-command="content.engagement.event-prize.change"',
);
});
it("renders validator-shaped prize, question, and prefix inputs", () => {
const eventHtml = renderToStaticMarkup(
<ContentEngagementPage
context={context([PERMS.EVENTS_EDIT])}
result={ok(
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
{
kind: "engagement",
items: [eventDetailItem],
total: 1,
partialDependencies: [],
},
"event-prize-form",
)}
routeId="content.engagement.event-detail"
@@ -11,80 +11,43 @@ import {
type ContentPageProps,
parseContentListInput,
} from "./content-page-frame";
import { ContentEventWorkflow } from "./event-workflow";
interface ContentEngagementPageProps extends ContentPageProps {
readonly status?: string;
readonly pageSize?: number;
readonly offset?: number;
}
export function ContentEngagementPage({
title,
search,
status = "",
pageSize = 25,
offset = 0,
context,
result,
routeId,
}: ContentPageProps) {
const canEvents = context.has(PERMS.EVENTS_EDIT);
}: ContentEngagementPageProps) {
if (
routeId === "content.engagement.events" ||
routeId === "content.engagement.event-create" ||
routeId === "content.engagement.event-detail" ||
routeId === "content.engagement.event-types"
) {
return (
<ContentEventWorkflow
title={title ?? "Events"}
context={context}
result={result}
routeId={routeId}
list={{ search: search ?? "", status, pageSize, offset }}
/>
);
}
const canPolls = context.has(PERMS.POLLS_EDIT);
const canPrefixes = context.has(PERMS.PREFIXES_EDIT);
const creatingEvent = routeId === "content.engagement.event-create";
const creatingPoll = routeId === "content.engagement.poll-create";
const eventFields: readonly ContentCommandField[] = [
{
name: "id",
label: "Event ID",
type: "identifier",
required: !creatingEvent,
},
{
name: "title",
label: "Title",
type: "text",
required: creatingEvent,
maxLength: 255,
},
{
name: "description",
label: "Description",
type: "textarea",
required: creatingEvent,
maxLength: 20_000,
},
{
name: "typeId",
label: "Event type ID",
type: "identifier",
required: creatingEvent,
},
{ name: "roomId", label: "Room ID", type: "identifier" },
{
name: "startsAt",
label: "Starts at",
type: "text",
required: creatingEvent,
maxLength: 50,
},
{ name: "endsAt", label: "Ends at", type: "text", maxLength: 50 },
{ name: "maxPlayers", label: "Maximum players", type: "number", min: 1 },
{
name: "isRecurring",
label: "Recurring",
type: "checkbox",
allowUnchanged: !creatingEvent,
},
{
name: "recurrenceRule",
label: "Recurrence rule",
type: "text",
maxLength: 255,
},
{
name: "status",
label: "Status",
type: "select",
options: [
{ value: "draft", label: "Draft" },
{ value: "published", label: "Published" },
{ value: "cancelled", label: "Cancelled" },
{ value: "completed", label: "Completed" },
],
},
{ name: "image", label: "Image URL", type: "text", maxLength: 500 },
];
const pollFields: readonly ContentCommandField[] = [
{
name: "id",
@@ -138,148 +101,6 @@ export function ContentEngagementPage({
result={result}
forms={
<div className="grid gap-3 lg:grid-cols-2">
{canEvents && routeId === "content.engagement.event-types" ? (
<ContentCommandForm
commandId="content.engagement.event-type.change"
buttonLabel="Save event type"
input={{ action: "update" }}
fields={[
{
name: "id",
label: "Type ID",
type: "identifier",
required: true,
},
{ name: "name", label: "Name", type: "text", maxLength: 100 },
{ name: "slug", label: "Slug", type: "text", maxLength: 100 },
{
name: "description",
label: "Description",
type: "textarea",
maxLength: 500,
},
{ name: "color", label: "Color", type: "text", maxLength: 20 },
{ name: "icon", label: "Icon", type: "text", maxLength: 50 },
{
name: "isActive",
label: "Active",
type: "checkbox",
allowUnchanged: true,
},
{
name: "minRank",
label: "Minimum rank",
type: "number",
min: 0,
max: 7,
},
]}
/>
) : null}
{canEvents &&
routeId?.includes("event") &&
routeId !== "content.engagement.event-types" ? (
<>
<ContentCommandForm
commandId="content.engagement.event.change"
buttonLabel="Save event"
input={{ action: creatingEvent ? "create" : "update" }}
fields={eventFields}
/>
<ContentCommandForm
commandId="content.engagement.event-prize.change"
buttonLabel="Save prize"
input={{ action: "create" }}
fields={[
{
name: "eventId",
label: "Event ID",
type: "identifier",
required: true,
},
{
name: "position",
label: "Position",
type: "number",
min: 1,
defaultValue: 1,
},
{
name: "prizeType",
label: "Prize type",
type: "select",
options: [
{ value: "badge", label: "Badge" },
{ value: "credits", label: "Credits" },
{ value: "pixels", label: "Pixels" },
{ value: "points", label: "Points" },
{ value: "item", label: "Item" },
],
},
{
name: "badgeCode",
label: "Badge code",
type: "text",
maxLength: 50,
},
{
name: "credits",
label: "Credits",
type: "number",
min: 0,
defaultValue: 0,
},
{
name: "pixels",
label: "Pixels",
type: "number",
min: 0,
defaultValue: 0,
},
{
name: "points",
label: "Points",
type: "number",
min: 0,
defaultValue: 0,
},
{ name: "itemId", label: "Item ID", type: "identifier" },
{
name: "description",
label: "Description",
type: "text",
maxLength: 255,
},
]}
/>
<ContentCommandForm
commandId="content.engagement.event-winner.add"
buttonLabel="Add winner"
input={{}}
fields={[
{
name: "eventId",
label: "Event ID",
type: "identifier",
required: true,
},
{
name: "userId",
label: "User ID",
type: "identifier",
required: true,
},
{
name: "position",
label: "Position",
type: "number",
min: 1,
defaultValue: 1,
},
]}
/>
</>
) : null}
{canPolls && routeId?.includes("poll") ? (
<>
<ContentCommandForm
@@ -417,10 +238,11 @@ export async function renderContentEngagementPage(
) {
const routeId = input.match.routeId as ContentPageProps["routeId"];
const title = resolveHousekeepingRouteTitle(input, "Engagement");
const list = parseContentListInput(input.searchParams ?? {});
const result = await contentQuery.run(input.context, {
routeId: routeId ?? "content.engagement.events",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
list,
});
return (
<ContentEngagementPage
@@ -428,6 +250,10 @@ export async function renderContentEngagementPage(
context={input.context}
result={result}
routeId={routeId}
search={list.search}
status={list.status}
pageSize={list.pageSize}
offset={list.offset}
/>
);
}
@@ -0,0 +1,289 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
fail,
type HousekeepingCapabilityContext,
ok,
} from "../../../foundation/contracts";
import type {
ContentEventDetailPayload,
ContentEventTypePayload,
ContentQueryData,
} from "../queries/content-queries";
import { ContentEventWorkflow } from "./event-workflow";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
const eventType: ContentEventTypePayload & { readonly id: string } = {
id: "2",
kind: "event-type",
name: "Tournament",
slug: "tournament",
description: "Ranked competition",
color: "#ff9900",
icon: "trophy",
isActive: true,
minRank: 1,
};
const detail: ContentEventDetailPayload = {
kind: "event-detail",
description: "Complete event",
typeId: "2",
hostUserId: "5",
roomId: "99",
startsAt: "2026-09-02T18:00:00.000Z",
endsAt: "2026-09-02T19:00:00.000Z",
maxPlayers: 20,
isRecurring: true,
recurrenceRule: "FREQ=WEEKLY",
image: "/media/event.webp",
eventTypes: [eventType],
prizes: [
{
id: "4",
position: 1,
prizeType: "badge",
badgeCode: "WIN1",
credits: 0,
pixels: 0,
points: 0,
itemId: null,
description: "Winner badge",
},
],
winners: [
{
id: "6",
userId: "9",
position: 1,
username: "Alice",
createdAt: "2026-09-02T20:00:00.000Z",
},
],
registrations: [
{
id: "8",
userId: "10",
username: "Bob",
createdAt: "2026-09-01T12:00:00.000Z",
},
],
};
function data(
items: ContentQueryData["items"],
total = items.length,
): ContentQueryData {
return { kind: "engagement", items, total, partialDependencies: [] };
}
const editor = context([PERMS.EVENTS_VIEW, PERMS.EVENTS_EDIT]);
describe("Content Event workflow", () => {
it("renders explicit loading, forbidden, dependency-error, and empty states", () => {
const render = (
result?: Parameters<typeof ContentEventWorkflow>[0]["result"],
) =>
renderToStaticMarkup(
<ContentEventWorkflow
title="Events"
context={editor}
result={result}
routeId="content.engagement.events"
list={{ search: "", status: "", pageSize: 25, offset: 0 }}
/>,
);
expect(render()).toContain('data-housekeeping-state="loading"');
expect(
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "forbidden")),
).toContain('data-housekeeping-state="forbidden"');
expect(
render(
fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
"error",
),
),
).toContain('data-housekeeping-state="error"');
expect(render(ok(data([]), "empty"))).toContain(
'data-housekeeping-state="empty"',
);
});
it("renders an operational event list with filters, metadata, and pagination", () => {
const html = renderToStaticMarkup(
<ContentEventWorkflow
title="Events"
context={editor}
result={ok(
data(
[
{
id: "11",
title: "Battle Ball",
status: "published",
href: "/ase-next/content/engagement/events/11",
privatePayload: {
kind: "event-summary",
typeId: "2",
typeName: "Tournament",
typeColor: "#ff9900",
startsAt: "2026-09-02T18:00:00.000Z",
endsAt: "2026-09-02T19:00:00.000Z",
maxPlayers: 20,
registrationCount: 12,
},
},
],
80,
),
"list",
)}
routeId="content.engagement.events"
list={{ search: "ball", status: "published", pageSize: 25, offset: 25 }}
/>,
);
expect(html).toContain('data-housekeeping-state="ready"');
expect(html).toContain("/ase-next/content/engagement/events/create");
expect(html).toContain("/ase-next/content/engagement/events/types");
expect(html).toContain('name="search"');
expect(html).toContain('value="ball"');
expect(html).toContain('name="status"');
expect(html).toMatch(/value="published"[^>]*selected=""/u);
expect(html).toContain("Battle Ball");
expect(html).toContain("Tournament");
expect(html).toContain('dateTime="2026-09-02T18:00:00.000Z"');
expect(html).toContain("12 registrations");
expect(html).toContain("20 seats");
expect(html).toContain("Showing 1 of 80 events");
expect(html).toContain("offset=0");
expect(html).toContain("offset=50");
});
it("renders event creation with real type options and native schedules", () => {
const html = renderToStaticMarkup(
<ContentEventWorkflow
title="Create event"
context={editor}
result={ok(
data([
{
id: "2",
title: "Tournament",
privatePayload: eventType,
},
]),
"create",
)}
routeId="content.engagement.event-create"
list={{ search: "", status: "", pageSize: 25, offset: 0 }}
/>,
);
expect(html).toContain(
'data-housekeeping-command="content.engagement.event.change"',
);
expect(html).toMatch(/<option value="2" selected="">Tournament<\/option>/u);
expect(html).toContain('name="startsAt"');
expect(html).toContain('type="datetime-local"');
expect(html).not.toContain('name="id"');
});
it("renders a prefilled event detail with related operations and protected deletion", () => {
const html = renderToStaticMarkup(
<ContentEventWorkflow
title="Event detail"
context={editor}
result={ok(
data([
{
id: "11",
title: "Battle Ball",
status: "published",
privatePayload: detail,
},
]),
"detail",
)}
routeId="content.engagement.event-detail"
list={{ search: "", status: "", pageSize: 25, offset: 0 }}
/>,
);
expect(html).toContain('name="title" value="Battle Ball"');
expect(html).toContain("Complete event");
expect(html).toMatch(/<option value="2" selected="">Tournament<\/option>/u);
expect(html).toContain('name="roomId"');
expect(html).toContain('value="99"');
expect(html).toContain('name="startsAt"');
expect(html).toContain('value="2026-09-02T18:00"');
expect(html).toContain('name="recurrenceRule"');
expect(html).toContain('value="FREQ=WEEKLY"');
expect(html).toContain("Winner badge");
expect(html).toContain("Alice");
expect(html).toContain("Bob");
expect(html).not.toContain('name="eventId"');
expect(html).toContain(
'data-housekeeping-command="content.engagement.event.delete"',
);
expect(html).toContain('name="reason"');
});
it("renders event-type creation and prefilled maintenance without manual IDs", () => {
const html = renderToStaticMarkup(
<ContentEventWorkflow
title="Event types"
context={editor}
result={ok(
data(
[
{
id: "2",
title: "Tournament",
status: "active",
privatePayload: eventType,
},
],
80,
),
"types",
)}
routeId="content.engagement.event-types"
list={{ search: "", status: "", pageSize: 25, offset: 25 }}
/>,
);
expect(
html.match(
/data-housekeeping-command="content\.engagement\.event-type\.change"/gu,
),
).toHaveLength(2);
expect(html).toContain('name="name" value="Tournament"');
expect(html).toContain('name="slug" value="tournament"');
expect(html).not.toContain('name="id"');
expect(html).toContain(
'data-housekeeping-command="content.engagement.event-type.delete"',
);
expect(html).toContain('name="reason"');
expect(html).toContain("Showing 1 of 80 event types");
expect(html).toContain("offset=0");
expect(html).toContain("offset=50");
const ids = [...html.matchAll(/\sid="([^"]+)"/gu)].map((match) => match[1]);
expect(ids.length).toBeGreaterThan(0);
expect(new Set(ids).size).toBe(ids.length);
});
});
@@ -0,0 +1,770 @@
import { PERMS } from "@/lib/permission-slugs";
import type {
HousekeepingCapabilityContext,
HousekeepingResult,
} from "../../../foundation/contracts";
import {
type ContentEventDetailPayload,
type ContentEventTypePayload,
type ContentQueryData,
type ContentQueryItem,
isContentEventDetailPayload,
isContentEventSummaryPayload,
isContentEventTypePayload,
} from "../queries/content-queries";
import {
type ContentCommandField,
ContentCommandForm,
} from "./content-command-form";
type ContentEventRouteId =
| "content.engagement.events"
| "content.engagement.event-create"
| "content.engagement.event-detail"
| "content.engagement.event-types";
export interface ContentEventListState {
readonly search: string;
readonly status: string;
readonly pageSize: number;
readonly offset: number;
}
interface ContentEventWorkflowProps {
readonly title: string;
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<ContentQueryData>;
readonly routeId: ContentEventRouteId;
readonly list: ContentEventListState;
}
type EventTypeOption = ContentEventTypePayload & { readonly id: string };
function EventState({
title,
state,
message,
}: {
readonly title: string;
readonly state: "loading" | "forbidden" | "error" | "empty";
readonly message: string;
}) {
return (
<section
data-housekeeping-state={state}
role={state === "forbidden" || state === "error" ? "alert" : undefined}
className="space-y-2"
>
<h1>{title}</h1>
<p>{message}</p>
</section>
);
}
function eventTypes(
items: readonly ContentQueryItem[],
): readonly EventTypeOption[] {
return items.flatMap((item) =>
isContentEventTypePayload(item.privatePayload)
? [{ id: item.id, ...item.privatePayload }]
: [],
);
}
function displayDate(value: string): string {
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
}
function dateTimeLocal(value: string | null): string | undefined {
if (!value) return undefined;
const parsed = new Date(value);
return Number.isFinite(parsed.getTime())
? parsed.toISOString().slice(0, 16)
: undefined;
}
function eventHref(list: ContentEventListState, offset: number): string {
const params = new URLSearchParams();
if (list.search) params.set("search", list.search);
if (list.status) params.set("status", list.status);
params.set("pageSize", String(list.pageSize));
params.set("offset", String(offset));
return `/ase-next/content/engagement/events?${params.toString()}`;
}
function eventTypeHref(list: ContentEventListState, offset: number): string {
const params = new URLSearchParams();
if (list.search) params.set("search", list.search);
if (list.status) params.set("status", list.status);
params.set("pageSize", String(list.pageSize));
params.set("offset", String(offset));
return `/ase-next/content/engagement/events/types?${params.toString()}`;
}
function EventList({
title,
context,
result,
list,
}: Pick<ContentEventWorkflowProps, "title" | "context" | "list"> & {
readonly result: Extract<HousekeepingResult<ContentQueryData>, { ok: true }>;
}) {
const canEdit = context.has(PERMS.EVENTS_EDIT);
const summaries = result.data.items.filter((item) =>
isContentEventSummaryPayload(item.privatePayload),
);
const state = summaries.length === 0 ? "empty" : "ready";
const previousOffset = Math.max(0, list.offset - list.pageSize);
const nextOffset = list.offset + list.pageSize;
return (
<section data-housekeeping-state={state} className="space-y-5">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1>{title}</h1>
<p>Schedule, publish, and operate community events.</p>
</div>
{canEdit ? (
<nav aria-label="Event actions" className="flex flex-wrap gap-2">
<a href="/ase-next/content/engagement/events/types">
Manage event types
</a>
<a
href="/ase-next/content/engagement/events/create"
className="rounded bg-[var(--admin-accent)] px-3 py-2 text-sm font-semibold text-[var(--admin-accent-foreground)]"
>
New event
</a>
</nav>
) : null}
</header>
<search>
<form method="get" className="grid gap-2 md:grid-cols-[1fr_auto_auto]">
<label className="sr-only" htmlFor="event-search">
Search events
</label>
<input
id="event-search"
name="search"
type="search"
defaultValue={list.search}
placeholder="Search by title or event type"
maxLength={128}
/>
<label className="sr-only" htmlFor="event-status">
Event status
</label>
<select id="event-status" name="status" defaultValue={list.status}>
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="cancelled">Cancelled</option>
<option value="completed">Completed</option>
</select>
<button type="submit">Apply filters</button>
</form>
</search>
<p className="text-sm text-[var(--admin-text-muted)]">
Showing {summaries.length} of {result.data.total} events
</p>
{summaries.length === 0 ? (
<p>No events match the current filters.</p>
) : (
<ul className="grid gap-3 lg:grid-cols-2">
{summaries.map((item) => {
const payload = item.privatePayload;
if (!isContentEventSummaryPayload(payload)) return null;
return (
<li
key={item.id}
className="rounded border border-[var(--admin-border)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h2 className="font-semibold">
{item.href ? (
<a href={item.href}>{item.title}</a>
) : (
item.title
)}
</h2>
<p style={{ color: payload.typeColor }}>
{payload.typeName}
</p>
</div>
{item.status ? <span>{item.status}</span> : null}
</div>
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm">
<div>
<dt>Starts</dt>
<dd>
<time dateTime={payload.startsAt}>
{displayDate(payload.startsAt)}
</time>
</dd>
</div>
<div>
<dt>Capacity</dt>
<dd>
{payload.registrationCount} registrations ·{" "}
{payload.maxPlayers == null
? "unlimited seats"
: `${payload.maxPlayers} seats`}
</dd>
</div>
</dl>
</li>
);
})}
</ul>
)}
{list.offset > 0 || nextOffset < result.data.total ? (
<nav aria-label="Event pages" className="flex justify-between">
{list.offset > 0 ? (
<a href={eventHref(list, previousOffset)}>Previous</a>
) : (
<span />
)}
{nextOffset < result.data.total ? (
<a href={eventHref(list, nextOffset)}>Next</a>
) : null}
</nav>
) : null}
</section>
);
}
function eventFields(
types: readonly EventTypeOption[],
item?: ContentQueryItem,
detail?: ContentEventDetailPayload,
): readonly ContentCommandField[] {
return [
{
name: "title",
label: "Title",
type: "text",
required: true,
maxLength: 255,
defaultValue: item?.title,
},
{
name: "description",
label: "Description",
type: "textarea",
required: true,
maxLength: 20_000,
defaultValue: detail?.description,
},
{
name: "typeId",
label: "Event type",
type: "select",
required: true,
defaultValue: detail?.typeId ?? types[0]?.id,
options: types.map((type) => ({ value: type.id, label: type.name })),
},
{
name: "startsAt",
label: "Starts at (UTC)",
type: "datetime-local",
required: true,
defaultValue: dateTimeLocal(detail?.startsAt ?? null),
},
{
name: "endsAt",
label: "Ends at (UTC)",
type: "datetime-local",
emptyValue: null,
defaultValue: dateTimeLocal(detail?.endsAt ?? null),
},
{
name: "roomId",
label: "Room ID",
type: "number",
emptyValue: null,
min: 1,
defaultValue: detail?.roomId ?? undefined,
},
{
name: "maxPlayers",
label: "Maximum players",
type: "number",
emptyValue: null,
min: 1,
defaultValue: detail?.maxPlayers ?? undefined,
},
{
name: "isRecurring",
label: "Recurring",
type: "checkbox",
defaultValue: detail?.isRecurring ?? false,
},
{
name: "recurrenceRule",
label: "Recurrence rule",
type: "text",
emptyValue: null,
maxLength: 255,
defaultValue: detail?.recurrenceRule ?? undefined,
},
{
name: "status",
label: "Status",
type: "select",
required: true,
defaultValue: item?.status ?? "draft",
options: [
{ value: "draft", label: "Draft" },
{ value: "published", label: "Published" },
{ value: "cancelled", label: "Cancelled" },
{ value: "completed", label: "Completed" },
],
},
{
name: "image",
label: "Image URL",
type: "text",
emptyValue: null,
maxLength: 500,
defaultValue: detail?.image ?? undefined,
},
];
}
function EventCreate({
title,
context,
result,
}: Pick<ContentEventWorkflowProps, "title" | "context"> & {
readonly result: Extract<HousekeepingResult<ContentQueryData>, { ok: true }>;
}) {
if (!context.has(PERMS.EVENTS_EDIT)) {
return (
<EventState
title={title}
state="forbidden"
message="You do not have permission to create events."
/>
);
}
const types = eventTypes(result.data.items).filter((type) => type.isActive);
if (types.length === 0) {
return (
<section data-housekeeping-state="empty" className="space-y-2">
<h1>{title}</h1>
<p>Create an active event type before scheduling an event.</p>
<a href="/ase-next/content/engagement/events/types">
Manage event types
</a>
</section>
);
}
return (
<section data-housekeeping-state="ready" className="space-y-5">
<header>
<a href="/ase-next/content/engagement/events">Back to events</a>
<h1>{title}</h1>
<p>Define the schedule, audience, location, and publication state.</p>
</header>
<ContentCommandForm
commandId="content.engagement.event.change"
buttonLabel="Create event"
input={{ action: "create" }}
fields={eventFields(types)}
/>
</section>
);
}
const prizeFields: readonly ContentCommandField[] = [
{
name: "position",
label: "Position",
type: "number",
min: 1,
defaultValue: 1,
},
{
name: "prizeType",
label: "Prize type",
type: "select",
required: true,
defaultValue: "badge",
options: ["badge", "credits", "pixels", "points", "item"].map((value) => ({
value,
label: value,
})),
},
{ name: "badgeCode", label: "Badge code", type: "text", maxLength: 50 },
{
name: "credits",
label: "Credits",
type: "number",
min: 0,
defaultValue: 0,
},
{ name: "pixels", label: "Pixels", type: "number", min: 0, defaultValue: 0 },
{ name: "points", label: "Points", type: "number", min: 0, defaultValue: 0 },
{ name: "itemId", label: "Item ID", type: "number", min: 1 },
{ name: "description", label: "Description", type: "text", maxLength: 255 },
];
function EventDetail({
title,
context,
result,
}: Pick<ContentEventWorkflowProps, "title" | "context"> & {
readonly result: Extract<HousekeepingResult<ContentQueryData>, { ok: true }>;
}) {
if (!context.has(PERMS.EVENTS_EDIT)) {
return (
<EventState
title={title}
state="forbidden"
message="You do not have permission to administer events."
/>
);
}
const item = result.data.items[0];
if (!item) {
return (
<section data-housekeeping-state="empty" className="space-y-2">
<h1>{title}</h1>
<p>The requested event was not found.</p>
<a href="/ase-next/content/engagement/events">Back to events</a>
</section>
);
}
if (!isContentEventDetailPayload(item.privatePayload)) {
return (
<EventState
title={title}
state="error"
message="The event details are unavailable."
/>
);
}
const detail = item.privatePayload;
return (
<section data-housekeeping-state="ready" className="space-y-6">
<header>
<a href="/ase-next/content/engagement/events">Back to events</a>
<h1>{item.title}</h1>
<p>
{item.status ?? "draft"} · {displayDate(detail.startsAt)} ·{" "}
{detail.registrations.length} registrations
</p>
</header>
<ContentCommandForm
commandId="content.engagement.event.change"
buttonLabel="Save event"
input={{ action: "update", id: item.id }}
fields={eventFields(detail.eventTypes, item, detail)}
/>
<section className="space-y-3">
<h2>Prizes</h2>
{detail.prizes.length === 0 ? (
<p>No prizes configured.</p>
) : (
<ul className="divide-y divide-[var(--admin-border)]">
{detail.prizes.map((prize) => (
<li
key={prize.id}
className="grid gap-2 py-3 md:grid-cols-[1fr_auto]"
>
<p>
#{prize.position} · {prize.prizeType}
{prize.badgeCode ? ` · ${prize.badgeCode}` : ""}
{prize.description ? ` · ${prize.description}` : ""}
</p>
<ContentCommandForm
commandId="content.engagement.event-prize.change"
buttonLabel="Remove prize"
input={{ action: "delete", id: prize.id }}
/>
</li>
))}
</ul>
)}
<ContentCommandForm
commandId="content.engagement.event-prize.change"
buttonLabel="Add prize"
input={{ action: "create", eventId: item.id }}
fields={prizeFields}
/>
</section>
<section className="space-y-3">
<h2>Winners</h2>
{detail.winners.length === 0 ? (
<p>No winners recorded.</p>
) : (
<ol>
{detail.winners.map((winner) => (
<li key={winner.id}>
#{winner.position} {winner.username} (user {winner.userId})
</li>
))}
</ol>
)}
<ContentCommandForm
commandId="content.engagement.event-winner.add"
buttonLabel="Add winner"
input={{ eventId: item.id }}
fields={[
{
name: "userId",
label: "User ID",
type: "number",
min: 1,
required: true,
},
{
name: "position",
label: "Position",
type: "number",
min: 1,
defaultValue: detail.winners.length + 1,
},
]}
/>
</section>
<section className="space-y-3">
<h2>Registrations ({detail.registrations.length})</h2>
{detail.registrations.length === 0 ? (
<p>No registrations yet.</p>
) : (
<ul>
{detail.registrations.map((registration) => (
<li key={registration.id}>
{registration.username} (user {registration.userId}) ·{" "}
<time dateTime={registration.createdAt}>
{displayDate(registration.createdAt)}
</time>
</li>
))}
</ul>
)}
</section>
<section className="space-y-2 rounded border border-[var(--admin-error)] p-4">
<h2>Danger zone</h2>
<p>
Deleting an event also removes its prizes, winners, and registrations.
</p>
<ContentCommandForm
commandId="content.engagement.event.delete"
buttonLabel="Delete event"
input={{ action: "delete", id: item.id }}
requiresReason
/>
</section>
</section>
);
}
function eventTypeFields(
type?: EventTypeOption,
): readonly ContentCommandField[] {
return [
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 100,
defaultValue: type?.name,
},
{
name: "slug",
label: "Slug",
type: "text",
required: true,
maxLength: 100,
defaultValue: type?.slug,
},
{
name: "description",
label: "Description",
type: "textarea",
emptyValue: "",
maxLength: 500,
defaultValue: type?.description,
},
{
name: "color",
label: "Color",
type: "text",
maxLength: 20,
defaultValue: type?.color ?? "#3b82f6",
},
{
name: "icon",
label: "Icon",
type: "text",
maxLength: 50,
defaultValue: type?.icon ?? "calendar",
},
{
name: "isActive",
label: "Active",
type: "checkbox",
defaultValue: type?.isActive ?? true,
},
{
name: "minRank",
label: "Minimum rank",
type: "number",
min: 0,
max: 7,
defaultValue: type?.minRank ?? 0,
},
];
}
function EventTypes({
title,
context,
result,
list,
}: Pick<ContentEventWorkflowProps, "title" | "context" | "list"> & {
readonly result: Extract<HousekeepingResult<ContentQueryData>, { ok: true }>;
}) {
if (!context.has(PERMS.EVENTS_EDIT)) {
return (
<EventState
title={title}
state="forbidden"
message="You do not have permission to manage event types."
/>
);
}
const types = eventTypes(result.data.items);
const previousOffset = Math.max(0, list.offset - list.pageSize);
const nextOffset = list.offset + list.pageSize;
return (
<section
data-housekeeping-state={types.length === 0 ? "empty" : "ready"}
className="space-y-6"
>
<header>
<a href="/ase-next/content/engagement/events">Back to events</a>
<h1>{title}</h1>
<p>
Maintain reusable event categories, visibility, and rank requirements.
</p>
</header>
<section className="space-y-3">
<h2>New event type</h2>
<ContentCommandForm
commandId="content.engagement.event-type.change"
buttonLabel="Create event type"
input={{ action: "create" }}
fields={eventTypeFields()}
/>
</section>
<p className="text-sm text-[var(--admin-text-muted)]">
Showing {types.length} of {result.data.total} event types
</p>
{types.length === 0 ? (
<p>No event types exist yet.</p>
) : (
<ul className="grid gap-4 xl:grid-cols-2">
{types.map((type) => (
<li
key={type.id}
className="space-y-3 rounded border border-[var(--admin-border)] p-4"
>
<h2>{type.name}</h2>
<ContentCommandForm
commandId="content.engagement.event-type.change"
buttonLabel="Save event type"
input={{ action: "update", id: type.id }}
fields={eventTypeFields(type)}
/>
<ContentCommandForm
commandId="content.engagement.event-type.delete"
buttonLabel="Delete event type"
input={{ action: "delete", id: type.id }}
requiresReason
/>
</li>
))}
</ul>
)}
{list.offset > 0 || nextOffset < result.data.total ? (
<nav aria-label="Event type pages" className="flex justify-between">
{list.offset > 0 ? (
<a href={eventTypeHref(list, previousOffset)}>Previous</a>
) : (
<span />
)}
{nextOffset < result.data.total ? (
<a href={eventTypeHref(list, nextOffset)}>Next</a>
) : null}
</nav>
) : null}
</section>
);
}
export function ContentEventWorkflow(props: ContentEventWorkflowProps) {
if (!props.result) {
return (
<EventState
title={props.title}
state="loading"
message="Loading events…"
/>
);
}
if (!props.result.ok) {
return (
<EventState
title={props.title}
state={props.result.error.code === "FORBIDDEN" ? "forbidden" : "error"}
message={props.result.error.messageKey}
/>
);
}
if (props.routeId === "content.engagement.events") {
return <EventList {...props} result={props.result} />;
}
if (props.routeId === "content.engagement.event-create") {
return (
<EventCreate
title={props.title}
context={props.context}
result={props.result}
/>
);
}
if (props.routeId === "content.engagement.event-types") {
return (
<EventTypes
title={props.title}
context={props.context}
result={props.result}
list={props.list}
/>
);
}
return (
<EventDetail
title={props.title}
context={props.context}
result={props.result}
/>
);
}
@@ -10,6 +10,11 @@ import type {
type RawRow = Readonly<Record<string, unknown>>;
const EVENT_TYPE_LIMIT = 500;
const EVENT_PRIZE_LIMIT = 100;
const EVENT_WINNER_LIMIT = 500;
const EVENT_REGISTRATION_LIMIT = 1_000;
interface QueryDefinition {
readonly statement: string;
readonly href: string;
@@ -70,20 +75,16 @@ export const CONTENT_QUERY_DEFINITIONS = {
},
"content.engagement.events": {
statement:
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
href: "/ase-next/content/engagement/events/",
appendId: true,
},
"content.engagement.event-detail": {
statement:
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
"SELECT e.id, e.title, COALESCE(et.name, 'Unknown type') AS description, e.status, e.updated_at, e.type_id, COALESCE(et.name, 'Unknown type') AS type_name, COALESCE(et.color, '#6b7280') AS type_color, e.starts_at, e.ends_at, e.max_players, (SELECT COUNT(*) FROM website_event_registrations er WHERE er.event_id = e.id) AS registration_count FROM website_events e LEFT JOIN website_event_types et ON et.id = e.type_id ORDER BY e.starts_at DESC",
href: "/ase-next/content/engagement/events/",
appendId: true,
privatePayload: eventSummaryPayload,
},
"content.engagement.event-types": {
statement:
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name",
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at, name, slug, description, color, icon, is_active, min_rank FROM website_event_types ORDER BY name",
href: "/ase-next/content/engagement/events/types",
privatePayload: eventTypePayload,
},
"content.engagement.polls": {
statement:
@@ -129,7 +130,6 @@ export const CONTENT_QUERY_DEFINITIONS = {
const EMPTY_ROUTES = new Set<ContentRouteId>([
"content.editorial.article-create",
"content.media.ad-create",
"content.engagement.event-create",
"content.engagement.poll-create",
"content.help.question-create",
]);
@@ -141,6 +141,18 @@ function rows(result: unknown): readonly RawRow[] {
return result[0] as readonly RawRow[];
}
function limitedEventRows(
result: unknown,
limit: number,
label: string,
): readonly RawRow[] {
const loaded = rows(result);
if (loaded.length > limit) {
throw new Error(`invalid Content ${label} limit`);
}
return loaded;
}
function value(value: unknown, fallback = ""): string {
return typeof value === "string"
? value
@@ -155,6 +167,46 @@ function updatedAt(value: unknown): string | null {
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
}
function requiredDate(value: unknown): string {
const normalized = updatedAt(value);
if (!normalized) throw new Error("invalid Content date");
return normalized;
}
function integer(value: unknown, fallback = 0): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
}
function nullableIdentifier(value: unknown): string | null {
return value == null ? null : String(value);
}
function eventTypePayload(row: RawRow) {
return {
kind: "event-type" as const,
name: value(row.name ?? row.title),
slug: value(row.slug),
description: value(row.description),
color: value(row.color, "#3b82f6"),
icon: value(row.icon, "calendar"),
isActive: Number(row.is_active) === 1,
minRank: integer(row.min_rank),
};
}
function eventSummaryPayload(row: RawRow) {
return {
kind: "event-summary" as const,
typeId: value(row.type_id),
typeName: value(row.type_name, "Unknown type"),
typeColor: value(row.type_color, "#6b7280"),
startsAt: requiredDate(row.starts_at),
endsAt: updatedAt(row.ends_at),
maxPlayers: row.max_players == null ? null : integer(row.max_players),
registrationCount: integer(row.registration_count),
};
}
function response(
input: NormalizedContentQueryInput,
items: readonly ContentQueryItem[],
@@ -237,6 +289,9 @@ async function databaseRoute(
: sql`LOWER(COALESCE(title, '')) LIKE ${pattern}`,
);
}
if (input.list.status) {
filters.push(sql`LOWER(COALESCE(status, '')) = ${input.list.status}`);
}
if (input.params.id) filters.push(sql`CAST(id AS CHAR) = ${input.params.id}`);
const filtered = filters.length
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
@@ -263,6 +318,134 @@ async function databaseRoute(
partialDependencies: [],
};
}
async function eventCreate(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const rawRows = limitedEventRows(
await db.execute(
sql`SELECT id, name, slug, description, color, icon, is_active, min_rank FROM website_event_types WHERE is_active = 1 ORDER BY name LIMIT ${EVENT_TYPE_LIMIT + 1}`,
),
EVENT_TYPE_LIMIT,
"event type",
);
const items = rawRows.map((row) => ({
id: value(row.id),
title: value(row.name, "Untitled event type"),
status: "active",
href: "/ase-next/content/engagement/events/create",
privatePayload: eventTypePayload(row),
}));
return {
kind: contentRouteGroup(input.routeId),
items,
total: items.length,
partialDependencies: [],
};
}
async function eventDetail(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
const id = Number(input.params.id);
if (!Number.isSafeInteger(id) || id <= 0) return response(input, []);
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const [event] = rows(
await db.execute(
sql`SELECT id, title, description, type_id, host_user_id, room_id, starts_at, ends_at, max_players, is_recurring, recurrence_rule, status, image, updated_at FROM website_events WHERE id = ${id} LIMIT 1`,
),
);
if (!event) return response(input, []);
const typeRows = limitedEventRows(
await db.execute(
sql`SELECT id, name, slug, description, color, icon, is_active, min_rank FROM website_event_types ORDER BY is_active DESC, name LIMIT ${EVENT_TYPE_LIMIT + 1}`,
),
EVENT_TYPE_LIMIT,
"event type",
);
const prizeRows = limitedEventRows(
await db.execute(
sql`SELECT id, position, prize_type, badge_code, credits, pixels, points, item_id, description FROM website_event_prizes WHERE event_id = ${id} ORDER BY position, id LIMIT ${EVENT_PRIZE_LIMIT + 1}`,
),
EVENT_PRIZE_LIMIT,
"event prize",
);
const winnerRows = limitedEventRows(
await db.execute(
sql`SELECT ew.id, ew.user_id, ew.position, COALESCE(u.username, CONCAT('User #', ew.user_id)) AS username, ew.created_at FROM website_event_winners ew LEFT JOIN users u ON u.id = ew.user_id WHERE ew.event_id = ${id} ORDER BY ew.position, ew.id LIMIT ${EVENT_WINNER_LIMIT + 1}`,
),
EVENT_WINNER_LIMIT,
"event winner",
);
const registrationRows = limitedEventRows(
await db.execute(
sql`SELECT er.id, er.user_id, COALESCE(u.username, CONCAT('User #', er.user_id)) AS username, er.created_at FROM website_event_registrations er LEFT JOIN users u ON u.id = er.user_id WHERE er.event_id = ${id} ORDER BY er.created_at DESC LIMIT ${EVENT_REGISTRATION_LIMIT + 1}`,
),
EVENT_REGISTRATION_LIMIT,
"event registration",
);
const item: ContentQueryItem = {
id: value(event.id),
title: value(event.title, "Untitled event"),
status: value(event.status, "draft"),
updatedAt: updatedAt(event.updated_at),
href: `/ase-next/content/engagement/events/${value(event.id)}`,
privatePayload: {
kind: "event-detail",
description: value(event.description),
typeId: value(event.type_id),
hostUserId: value(event.host_user_id),
roomId: nullableIdentifier(event.room_id),
startsAt: requiredDate(event.starts_at),
endsAt: updatedAt(event.ends_at),
maxPlayers: event.max_players == null ? null : integer(event.max_players),
isRecurring: Number(event.is_recurring) === 1,
recurrenceRule:
event.recurrence_rule == null ? null : value(event.recurrence_rule),
image: event.image == null ? null : value(event.image),
eventTypes: typeRows.map((row) => ({
id: value(row.id),
...eventTypePayload(row),
})),
prizes: prizeRows.map((row) => ({
id: value(row.id),
position: integer(row.position),
prizeType: value(row.prize_type),
badgeCode: nullableIdentifier(row.badge_code),
credits: integer(row.credits),
pixels: integer(row.pixels),
points: integer(row.points),
itemId: nullableIdentifier(row.item_id),
description: value(row.description),
})),
winners: winnerRows.map((row) => ({
id: value(row.id),
userId: value(row.user_id),
position: integer(row.position),
username: value(row.username),
createdAt: requiredDate(row.created_at),
})),
registrations: registrationRows.map((row) => ({
id: value(row.id),
userId: value(row.user_id),
username: value(row.username),
createdAt: requiredDate(row.created_at),
})),
},
};
return {
kind: "engagement",
items: [item],
total: 1,
partialDependencies: [],
};
}
async function mediaLibrary(
input: NormalizedContentQueryInput,
@@ -409,6 +592,12 @@ export async function loadProductionContentQuery(
input: NormalizedContentQueryInput,
): Promise<ContentQueryData> {
if (EMPTY_ROUTES.has(input.routeId)) return response(input, []);
if (input.routeId === "content.engagement.event-create") {
return eventCreate(input);
}
if (input.routeId === "content.engagement.event-detail") {
return eventDetail(input);
}
if (input.routeId === "content.media.library") return mediaLibrary(input);
if (input.routeId.startsWith("content.brand.")) return brand(input);
if (
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { CONTENT_ROUTE_IDS } from "../routes";
@@ -58,6 +58,8 @@ const ready: ContentQueryData = {
};
describe("Content query", () => {
beforeEach(() => queryMocks.execute.mockReset());
it("reports totals beyond the former 500-row adapter cap", async () => {
const pageRows = Array.from({ length: 25 }, (_, index) => ({
id: index + 1,
@@ -98,7 +100,7 @@ describe("Content query", () => {
expect(serialized).toContain("14");
});
it("searches only projected aliases for routes without descriptions", async () => {
it("searches event titles and projected event-type descriptions", async () => {
queryMocks.execute
.mockResolvedValueOnce([[{ total: 0 }]])
.mockResolvedValueOnce([[]]);
@@ -109,7 +111,7 @@ describe("Content query", () => {
});
const serialized = JSON.stringify(queryMocks.execute.mock.calls.slice(-2));
expect(serialized).toContain("COALESCE(title");
expect(serialized).not.toContain("COALESCE(description");
expect(serialized).toContain("COALESCE(description");
});
it("never selects email template bodies into summary query results", () => {
@@ -153,6 +155,174 @@ describe("Content query", () => {
CONTENT_QUERY_DEFINITIONS["content.editorial.articles"].statement,
).not.toMatch(/short_story|full_story/iu);
});
it("loads event summaries with operational metadata and a bound status filter", async () => {
queryMocks.execute
.mockResolvedValueOnce([[{ total: 1 }]])
.mockResolvedValueOnce([
[
{
id: 11,
title: "Battle Ball",
status: "published",
updated_at: new Date("2026-09-01T08:00:00.000Z"),
type_id: 2,
type_name: "Tournament",
type_color: "#ff9900",
starts_at: new Date("2026-09-02T18:00:00.000Z"),
ends_at: new Date("2026-09-02T19:00:00.000Z"),
max_players: 20,
registration_count: 12,
},
],
]);
const result = await loadProductionContentQuery({
routeId: "content.engagement.events",
params: {},
list: { search: "", status: "published", pageSize: 25, offset: 0 },
});
expect(result.items[0]?.privatePayload).toEqual({
kind: "event-summary",
typeId: "2",
typeName: "Tournament",
typeColor: "#ff9900",
startsAt: "2026-09-02T18:00:00.000Z",
endsAt: "2026-09-02T19:00:00.000Z",
maxPlayers: 20,
registrationCount: 12,
});
expect(JSON.stringify(queryMocks.execute.mock.calls)).toContain(
"published",
);
});
it("loads active event types for event creation", async () => {
queryMocks.execute.mockResolvedValueOnce([
[
{
id: 2,
name: "Tournament",
slug: "tournament",
description: "Ranked competition",
color: "#ff9900",
icon: "trophy",
is_active: 1,
min_rank: 1,
},
],
]);
const result = await loadProductionContentQuery({
routeId: "content.engagement.event-create",
params: {},
list: { search: "", pageSize: 25, offset: 0 },
});
expect(result.items[0]).toMatchObject({
id: "2",
title: "Tournament",
privatePayload: {
kind: "event-type",
slug: "tournament",
isActive: true,
},
});
});
it("loads only the selected event with all related operator data", async () => {
queryMocks.execute
.mockResolvedValueOnce([
[
{
id: 11,
title: "Battle Ball",
status: "published",
updated_at: new Date("2026-09-01T08:00:00.000Z"),
description: "Complete event",
type_id: 2,
host_user_id: 5,
room_id: 99,
starts_at: new Date("2026-09-02T18:00:00.000Z"),
ends_at: null,
max_players: 20,
is_recurring: 0,
recurrence_rule: null,
image: "/media/event.webp",
},
],
])
.mockResolvedValueOnce([
[
{
id: 2,
name: "Tournament",
slug: "tournament",
description: "Ranked competition",
color: "#ff9900",
icon: "trophy",
is_active: 1,
min_rank: 1,
},
],
])
.mockResolvedValueOnce([
[
{
id: 4,
position: 1,
prize_type: "badge",
badge_code: "WIN1",
credits: 0,
pixels: 0,
points: 0,
item_id: null,
description: "Winner badge",
},
],
])
.mockResolvedValueOnce([
[
{
id: 6,
user_id: 9,
position: 1,
username: "Alice",
created_at: new Date("2026-09-02T20:00:00.000Z"),
},
],
])
.mockResolvedValueOnce([
[
{
id: 8,
user_id: 10,
username: "Bob",
created_at: new Date("2026-09-01T12:00:00.000Z"),
},
],
]);
const result = await loadProductionContentQuery({
routeId: "content.engagement.event-detail",
params: { id: "11" },
list: { search: "", pageSize: 25, offset: 0 },
});
expect(result.items).toHaveLength(1);
expect(result.items[0]?.privatePayload).toMatchObject({
kind: "event-detail",
description: "Complete event",
eventTypes: [{ id: "2", name: "Tournament" }],
prizes: [{ id: "4", prizeType: "badge" }],
winners: [{ userId: "9", username: "Alice" }],
registrations: [{ userId: "10", username: "Bob" }],
});
const sql = JSON.stringify(queryMocks.execute.mock.calls);
expect(sql).toContain("11");
expect(queryMocks.execute).toHaveBeenCalledTimes(5);
});
it("fails closed before production adapters are invoked", async () => {
const load = vi.fn(async () => ready);
const query = createContentQuery({ load });
@@ -223,6 +393,66 @@ describe("Content query", () => {
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
it("normalizes a bounded event status before invoking the adapter", async () => {
const load = vi.fn(async () => ({
kind: "engagement" as const,
items: [],
total: 0,
partialDependencies: [],
}));
const query = createContentQuery({ load });
await query.run(context([PERMS.EVENTS_VIEW]), {
routeId: "content.engagement.events",
list: {
status: ` PUBLISHED${"x".repeat(40)} `,
} as ContentQueryInput["list"] & { status: string },
});
expect(load).toHaveBeenCalledWith({
routeId: "content.engagement.events",
params: {},
list: {
search: "",
status: `published${"x".repeat(23)}`,
pageSize: 25,
offset: 0,
},
});
});
it.each([
{
label: "missing payload",
items: [{ id: "7", title: "Launch day", status: "published" }],
},
{
label: "more than one selected event",
items: [
{ id: "7", title: "Launch day", status: "published" },
{ id: "8", title: "After party", status: "draft" },
],
},
])("fails closed for an event detail with $label", async ({ items }) => {
const query = createContentQuery({
load: async () => ({
kind: "engagement",
items,
total: items.length,
partialDependencies: [],
}),
});
const result = await query.run(context([PERMS.EVENTS_EDIT]), {
routeId: "content.engagement.event-detail",
params: { id: "7" },
});
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
it("rejects a fake partial result and maps complete adapter failure", async () => {
const malformed = createContentQuery({
load: async () =>
@@ -268,9 +498,250 @@ describe("Content query", () => {
const all = context(Object.values(PERMS));
for (const routeId of CONTENT_ROUTE_IDS) {
const result = await query.run(all, { routeId });
const result = await query.run(all, {
routeId,
...(routeId === "content.engagement.event-detail"
? { params: { id: "1" } }
: {}),
});
expect(result.ok, routeId).toBe(true);
}
expect(load).toHaveBeenCalledTimes(CONTENT_ROUTE_IDS.length);
});
it("loads more than one page of active event types for event creation", async () => {
queryMocks.execute.mockResolvedValueOnce([
Array.from({ length: 30 }, (_, index) => ({
id: index + 1,
name: `Type ${index + 1}`,
slug: `type-${index + 1}`,
description: "Event category",
color: "#ff9900",
icon: "calendar",
is_active: 1,
min_rank: 1,
})),
]);
const result = await loadProductionContentQuery({
routeId: "content.engagement.event-create",
params: {},
list: { search: "", pageSize: 25, offset: 0 },
});
expect(result.items).toHaveLength(30);
expect(result.total).toBe(30);
expect(JSON.stringify(queryMocks.execute.mock.calls)).toContain("501");
});
it("bounds every event-detail dependency and fails closed on overflow", async () => {
queryMocks.execute
.mockResolvedValueOnce([
[
{
id: 11,
title: "Battle Ball",
type_id: 2,
host_user_id: 5,
starts_at: new Date("2026-09-02T18:00:00.000Z"),
},
],
])
.mockResolvedValueOnce([
Array.from({ length: 501 }, (_, index) => ({
id: index + 1,
name: `Type ${index + 1}`,
slug: `type-${index + 1}`,
description: "Event category",
color: "#ff9900",
icon: "calendar",
is_active: 1,
min_rank: 1,
})),
]);
await expect(
loadProductionContentQuery({
routeId: "content.engagement.event-detail",
params: { id: "11" },
list: { search: "", pageSize: 25, offset: 0 },
}),
).rejects.toThrow("event type limit");
const sql = JSON.stringify(queryMocks.execute.mock.calls);
expect(sql).toContain("501");
expect(queryMocks.execute).toHaveBeenCalledTimes(2);
});
it("applies explicit limits to all event-detail dependency queries", async () => {
queryMocks.execute
.mockResolvedValueOnce([
[
{
id: 11,
title: "Battle Ball",
type_id: 2,
host_user_id: 5,
starts_at: new Date("2026-09-02T18:00:00.000Z"),
},
],
])
.mockResolvedValueOnce([[]])
.mockResolvedValueOnce([[]])
.mockResolvedValueOnce([[]])
.mockResolvedValueOnce([[]]);
await loadProductionContentQuery({
routeId: "content.engagement.event-detail",
params: { id: "11" },
list: { search: "", pageSize: 25, offset: 0 },
});
const sql = JSON.stringify(queryMocks.execute.mock.calls);
expect(sql).toContain("101");
expect(sql).toContain("501");
expect(sql).toContain("1001");
});
it.each([
{
label: "an invalid requested identifier",
requestedId: "0",
itemId: "7",
total: 1,
startsAt: "2026-09-02T18:00:00.000Z",
},
{
label: "a mismatched selected identifier",
requestedId: "7",
itemId: "8",
total: 1,
startsAt: "2026-09-02T18:00:00.000Z",
},
{
label: "an invalid start date",
requestedId: "7",
itemId: "7",
total: 1,
startsAt: "tomorrow",
},
{
label: "an inconsistent total",
requestedId: "7",
itemId: "7",
total: 0,
startsAt: "2026-09-02T18:00:00.000Z",
},
])(
"fails closed for event detail data with $label",
async ({ requestedId, itemId, total, startsAt }) => {
const query = createContentQuery({
load: async () => ({
kind: "engagement",
items: [
{
id: itemId,
title: "Battle Ball",
privatePayload: {
kind: "event-detail",
description: "Complete event",
typeId: "2",
hostUserId: "5",
roomId: null,
startsAt,
endsAt: null,
maxPlayers: null,
isRecurring: false,
recurrenceRule: null,
image: null,
eventTypes: [],
prizes: [],
winners: [],
registrations: [],
},
},
],
total,
partialDependencies: [],
}),
});
const result = await query.run(context([PERMS.EVENTS_EDIT]), {
routeId: "content.engagement.event-detail",
params: { id: requestedId },
});
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
},
);
it("rejects malformed event summary and event type identifiers", async () => {
const summaryQuery = createContentQuery({
load: async () => ({
kind: "engagement",
items: [
{
id: "11",
title: "Battle Ball",
privatePayload: {
kind: "event-summary",
typeId: "0",
typeName: "Tournament",
typeColor: "#ff9900",
startsAt: "not-a-date",
endsAt: null,
maxPlayers: null,
registrationCount: 0,
},
},
],
total: 1,
partialDependencies: [],
}),
});
expect(
await summaryQuery.run(context([PERMS.EVENTS_VIEW]), {
routeId: "content.engagement.events",
}),
).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
const typeQuery = createContentQuery({
load: async () => ({
kind: "engagement",
items: [
{
id: "0",
title: "Tournament",
privatePayload: {
kind: "event-type",
name: "Tournament",
slug: "tournament",
description: "Ranked competition",
color: "#ff9900",
icon: "calendar",
isActive: true,
minRank: 1,
},
},
],
total: 1,
partialDependencies: [],
}),
});
expect(
await typeQuery.run(context([PERMS.EVENTS_EDIT]), {
routeId: "content.engagement.event-types",
}),
).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
});
@@ -14,6 +14,7 @@ import {
export interface ContentQueryListInput {
readonly search?: string;
readonly status?: string;
readonly pageSize?: number;
readonly offset?: number;
}
@@ -43,6 +44,210 @@ export function isContentArticlePrivatePayload(
typeof payload.fullStory === "string"
);
}
export interface ContentEventTypePayload {
readonly kind: "event-type";
readonly name: string;
readonly slug: string;
readonly description: string;
readonly color: string;
readonly icon: string;
readonly isActive: boolean;
readonly minRank: number;
}
export interface ContentEventSummaryPayload {
readonly kind: "event-summary";
readonly typeId: string;
readonly typeName: string;
readonly typeColor: string;
readonly startsAt: string;
readonly endsAt: string | null;
readonly maxPlayers: number | null;
readonly registrationCount: number;
}
export interface ContentEventPrizePayload {
readonly id: string;
readonly position: number;
readonly prizeType: string;
readonly badgeCode: string | null;
readonly credits: number;
readonly pixels: number;
readonly points: number;
readonly itemId: string | null;
readonly description: string;
}
export interface ContentEventWinnerPayload {
readonly id: string;
readonly userId: string;
readonly position: number;
readonly username: string;
readonly createdAt: string;
}
export interface ContentEventRegistrationPayload {
readonly id: string;
readonly userId: string;
readonly username: string;
readonly createdAt: string;
}
export interface ContentEventDetailPayload {
readonly kind: "event-detail";
readonly description: string;
readonly typeId: string;
readonly hostUserId: string;
readonly roomId: string | null;
readonly startsAt: string;
readonly endsAt: string | null;
readonly maxPlayers: number | null;
readonly isRecurring: boolean;
readonly recurrenceRule: string | null;
readonly image: string | null;
readonly eventTypes: readonly (ContentEventTypePayload & {
readonly id: string;
})[];
readonly prizes: readonly ContentEventPrizePayload[];
readonly winners: readonly ContentEventWinnerPayload[];
readonly registrations: readonly ContentEventRegistrationPayload[];
}
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object"
? (value as Record<string, unknown>)
: null;
}
function stringOrNull(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
function positiveIdentifier(value: unknown): value is string {
return typeof value === "string" && /^[1-9]\d*$/u.test(value);
}
function canonicalDate(value: unknown): value is string {
if (typeof value !== "string") return false;
const parsed = new Date(value);
return Number.isFinite(parsed.getTime()) && parsed.toISOString() === value;
}
function dateOrNull(value: unknown): value is string | null {
return value === null || canonicalDate(value);
}
function safeCount(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) >= 0;
}
function positiveCount(value: unknown): value is number {
return safeCount(value) && Number(value) > 0;
}
export function isContentEventTypePayload(
value: unknown,
): value is ContentEventTypePayload {
const payload = object(value);
return (
payload?.kind === "event-type" &&
typeof payload.name === "string" &&
typeof payload.slug === "string" &&
typeof payload.description === "string" &&
typeof payload.color === "string" &&
typeof payload.icon === "string" &&
typeof payload.isActive === "boolean" &&
safeCount(payload.minRank)
);
}
export function isContentEventSummaryPayload(
value: unknown,
): value is ContentEventSummaryPayload {
const payload = object(value);
return (
payload?.kind === "event-summary" &&
positiveIdentifier(payload.typeId) &&
typeof payload.typeName === "string" &&
typeof payload.typeColor === "string" &&
canonicalDate(payload.startsAt) &&
dateOrNull(payload.endsAt) &&
(payload.maxPlayers === null || positiveCount(payload.maxPlayers)) &&
safeCount(payload.registrationCount)
);
}
function isContentEventPrizePayload(
value: unknown,
): value is ContentEventPrizePayload {
const payload = object(value);
return (
positiveIdentifier(payload?.id) &&
positiveCount(payload.position) &&
typeof payload.prizeType === "string" &&
stringOrNull(payload.badgeCode) &&
safeCount(payload.credits) &&
safeCount(payload.pixels) &&
safeCount(payload.points) &&
(payload.itemId === null || positiveIdentifier(payload.itemId)) &&
typeof payload.description === "string"
);
}
function isContentEventWinnerPayload(
value: unknown,
): value is ContentEventWinnerPayload {
const payload = object(value);
return (
positiveIdentifier(payload?.id) &&
positiveIdentifier(payload.userId) &&
positiveCount(payload.position) &&
typeof payload.username === "string" &&
canonicalDate(payload.createdAt)
);
}
function isContentEventRegistrationPayload(
value: unknown,
): value is ContentEventRegistrationPayload {
const payload = object(value);
return (
positiveIdentifier(payload?.id) &&
positiveIdentifier(payload.userId) &&
typeof payload.username === "string" &&
canonicalDate(payload.createdAt)
);
}
export function isContentEventDetailPayload(
value: unknown,
): value is ContentEventDetailPayload {
const payload = object(value);
return (
payload?.kind === "event-detail" &&
typeof payload.description === "string" &&
positiveIdentifier(payload.typeId) &&
positiveIdentifier(payload.hostUserId) &&
(payload.roomId === null || positiveIdentifier(payload.roomId)) &&
canonicalDate(payload.startsAt) &&
dateOrNull(payload.endsAt) &&
(payload.maxPlayers === null || positiveCount(payload.maxPlayers)) &&
typeof payload.isRecurring === "boolean" &&
stringOrNull(payload.recurrenceRule) &&
stringOrNull(payload.image) &&
Array.isArray(payload.eventTypes) &&
payload.eventTypes.every(
(type) =>
positiveIdentifier(object(type)?.id) && isContentEventTypePayload(type),
) &&
Array.isArray(payload.prizes) &&
payload.prizes.every(isContentEventPrizePayload) &&
Array.isArray(payload.winners) &&
payload.winners.every(isContentEventWinnerPayload) &&
Array.isArray(payload.registrations) &&
payload.registrations.every(isContentEventRegistrationPayload)
);
}
export interface ContentQueryItem {
readonly id: string;
@@ -66,6 +271,7 @@ export interface NormalizedContentQueryInput {
readonly params: Readonly<Record<string, string>>;
readonly list: Readonly<{
search: string;
status?: string;
pageSize: number;
offset: number;
}>;
@@ -87,6 +293,11 @@ function boundedInteger(
}
function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
const status = String(input.list?.status ?? "")
.normalize("NFC")
.trim()
.toLocaleLowerCase()
.slice(0, 32);
return {
routeId: input.routeId,
params: Object.fromEntries(
@@ -100,6 +311,7 @@ function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
.normalize("NFC")
.trim()
.slice(0, 128),
...(status ? { status } : {}),
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
},
@@ -129,13 +341,56 @@ function isValidData(
(item.href === undefined || isSafeHousekeepingHref(item.href)),
);
if (!validItems) return false;
if (input.routeId !== "content.editorial.article-detail") return true;
return (
data.items.length <= 1 &&
data.items.every((item) =>
isContentArticlePrivatePayload(item.privatePayload),
)
);
if (input.routeId === "content.editorial.article-detail") {
return (
data.items.length <= 1 &&
data.items.every((item) =>
isContentArticlePrivatePayload(item.privatePayload),
)
);
}
if (input.routeId === "content.engagement.events") {
return (
data.items.length <= input.list.pageSize &&
data.items.length <= data.total &&
data.items.every(
(item) =>
positiveIdentifier(item.id) &&
isContentEventSummaryPayload(item.privatePayload),
)
);
}
if (
input.routeId === "content.engagement.event-create" ||
input.routeId === "content.engagement.event-types"
) {
return (
data.items.length <= data.total &&
(input.routeId === "content.engagement.event-create"
? data.items.length === data.total
: data.items.length <= input.list.pageSize) &&
data.items.every(
(item) =>
positiveIdentifier(item.id) &&
isContentEventTypePayload(item.privatePayload),
)
);
}
if (input.routeId === "content.engagement.event-detail") {
const requestedId = input.params.id;
return (
positiveIdentifier(requestedId) &&
data.items.length <= 1 &&
data.total === data.items.length &&
data.items.every(
(item) =>
item.id === requestedId &&
positiveIdentifier(item.id) &&
isContentEventDetailPayload(item.privatePayload),
)
);
}
return true;
}
export function createContentQuery(
@@ -16,23 +16,37 @@ const sqlMocks = vi.hoisted(() => ({
const database = vi.hoisted(() => {
let selected: Record<string, unknown> = { id: 7, title: "Existing" };
let selectedQueue: Record<string, unknown>[] = [];
const set = vi.fn((values: Record<string, unknown>) => ({
where: vi.fn(async () => undefined),
values,
}));
const update = vi.fn(() => ({ set }));
const limit = vi.fn(async () => [selected]);
const limit = vi.fn(async () => [selectedQueue.shift() ?? selected]);
const where = vi.fn(() => ({ limit }));
const from = vi.fn(() => ({ where }));
const select = vi.fn(() => ({ from }));
const execute = vi.fn(async () => undefined);
const deleteWhere = vi.fn(async () => undefined);
const remove = vi.fn((_table: unknown) => ({ where: deleteWhere }));
return {
execute,
set,
update,
select,
delete: remove,
deleteWhere,
removedTables() {
return remove.mock.calls.map(
([table]) => (table as { tableName?: string }).tableName,
);
},
selected(value: Record<string, unknown>) {
selected = value;
selectedQueue = [];
},
queueSelected(...values: Record<string, unknown>[]) {
selectedQueue = [...values];
},
};
});
@@ -46,8 +60,17 @@ vi.mock("drizzle-orm", () => ({
}));
vi.mock("@/lib/db", () => {
const table = new Proxy({}, { get: (_target, key) => String(key) });
const namedTable = (tableName: string) =>
new Proxy(
{ tableName },
{
get: (target, key) =>
key === "tableName" ? target.tableName : String(key),
},
);
return {
db: {
delete: database.delete,
execute: database.execute,
select: database.select,
update: database.update,
@@ -61,10 +84,11 @@ vi.mock("@/lib/db", () => {
WebsiteArticleReactions: table,
WebsiteArticles: table,
WebsiteBanner: table,
WebsiteEvent: table,
WebsiteEventPrize: table,
WebsiteEventType: table,
WebsiteEventWinner: table,
WebsiteEvent: namedTable("WebsiteEvent"),
WebsiteEventPrize: namedTable("WebsiteEventPrize"),
WebsiteEventRegistration: namedTable("WebsiteEventRegistration"),
WebsiteEventType: namedTable("WebsiteEventType"),
WebsiteEventWinner: namedTable("WebsiteEventWinner"),
WebsiteHelpCenterCategories: table,
WebsitePoll: table,
WebsitePollQuestion: table,
@@ -94,6 +118,41 @@ describe("Content database mutation runtime partial updates", () => {
database.selected({ id: 7, title: "Existing" });
});
it("deletes event registrations, prizes, winners, and the event", async () => {
database.selected({ id: 7, title: "Existing", status: "draft" });
await executeContentDatabaseMutation(
"event.change",
{ action: "delete", id: 7 },
context,
undefined,
);
expect(database.removedTables()).toEqual([
"WebsiteEventRegistration",
"WebsiteEventPrize",
"WebsiteEventWinner",
"WebsiteEvent",
]);
});
it("blocks deletion of an event type referenced by an event", async () => {
database.queueSelected({ id: 7, name: "Tournament" }, { id: 11 });
await expect(
executeContentDatabaseMutation(
"event-type.change",
{ action: "delete", id: 7 },
context,
undefined,
),
).rejects.toMatchObject({
code: "CONFLICT",
messageKey: "errors.housekeeping.conflict",
});
expect(database.removedTables()).toEqual([]);
});
it("does not reset omitted banner fields", async () => {
await executeContentDatabaseMutation(
"banner.change",
@@ -15,6 +15,7 @@ import {
WebsiteBanner,
WebsiteEvent,
WebsiteEventPrize,
WebsiteEventRegistration,
WebsiteEventType,
WebsiteEventWinner,
WebsiteHelpCenterCategories,
@@ -70,6 +71,9 @@ function notFound(): ContentMutationFailure {
"errors.housekeeping.notFound",
);
}
function conflict(): ContentMutationFailure {
return new ContentMutationFailure("CONFLICT", "errors.housekeeping.conflict");
}
function text(value: unknown, maximum: number, required = false): string {
const normalized = String(value ?? "")
@@ -408,6 +412,13 @@ async function eventTypeChange(
.limit(1);
if (!existing) throw notFound();
if (action === "delete") {
const [referencedEvent] = await connection
.select({ id: WebsiteEvent.id })
.from(WebsiteEvent)
.where(eq(WebsiteEvent.typeId, id))
.limit(1);
if (referencedEvent) throw conflict();
await connection
.delete(WebsiteEventType)
.where(eq(WebsiteEventType.id, id));
@@ -467,6 +478,15 @@ async function eventChange(
.limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection
.delete(WebsiteEventRegistration)
.where(eq(WebsiteEventRegistration.eventId, id));
await connection
.delete(WebsiteEventPrize)
.where(eq(WebsiteEventPrize.eventId, id));
await connection
.delete(WebsiteEventWinner)
.where(eq(WebsiteEventWinner.eventId, id));
await connection.delete(WebsiteEvent).where(eq(WebsiteEvent.id, id));
return { before: existing, after: null };
}
@@ -287,6 +287,13 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/pages/event-workflow.tsx",
new Set([
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
[
"src/features/housekeeping/domains/content/pages/brand.tsx",
new Set([
@@ -320,6 +327,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
new Set([
"src/features/housekeeping/domains/content/pages/content-command-form",
"src/features/housekeeping/domains/content/pages/content-page-frame",
"src/features/housekeeping/domains/content/pages/event-workflow",
"src/features/housekeeping/domains/content/queries/content-queries",
]),
],
+4 -2
View File
@@ -270,13 +270,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "نوع الحدث · حذف"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Event · Change"
"change": "Event · Change",
"delete": "الأحداث · حذف"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Тип събитие · Изтрий"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Събития · Изтрий"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Typ události · Smazat"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Události · Smazat"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Begivenhedstype · Slet"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Begivenheder · Slet"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3515,13 +3515,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Ereignistyp · Löschen"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Ereignisse · Löschen"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Τύπος εκδήλωσης · Διαγραφή"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Εκδηλώσεις · Διαγραφή"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3526,13 +3526,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Event type · Delete"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Events · Delete"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3515,13 +3515,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Tipo de evento · Eliminar"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Eventos · Eliminar"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -270,13 +270,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Tapahtumatyyppi · Poista"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Event · Change"
"change": "Event · Change",
"delete": "Tapahtumat · Poista"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3515,13 +3515,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Type d’événement · Supprimer"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Événements · Supprimer"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Vrsta događaja · Izbriši"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Događaji · Izbriši"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Eseménytípus · Törlés"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Események · Törlés"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3524,13 +3524,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Tipo di evento · Elimina"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Eventi · Change"
"change": "Eventi · Change",
"delete": "Eventi · Elimina"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -270,13 +270,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "イベントタイプ · 削除"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Event · Change"
"change": "Event · Change",
"delete": "イベント · 削除"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3429,13 +3429,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Evenementtype · Verwijderen"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Evenementen · Verwijderen"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Arrangementstype · Slett"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Arrangementer · Slett"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3515,13 +3515,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Typ wydarzenia · Usuń"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Wydarzenia · Usuń"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3515,13 +3515,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Tipo de evento · Eliminar"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Eventos · Eliminar"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Tip de eveniment · Șterge"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Evenimente · Șterge"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3513,13 +3513,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Тип события · Удалить"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "События · Удалить"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Typ udalosti · Odstrániť"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Udalosti · Odstrániť"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Vrsta događaja · Obriši"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Događaji · Obriši"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Evenemangstyp · Ta bort"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Evenemang · Ta bort"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3515,13 +3515,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Etkinlik türü · Sil"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Etkinlikler · Sil"
},
"poll-question": {
"change": "Poll Question change · Change"
+4 -2
View File
@@ -3514,13 +3514,15 @@
"change": "Event Prize change · Change"
},
"event-type": {
"change": "Event Type change · Change"
"change": "Event Type change · Change",
"delete": "Тип події · Видалити"
},
"event-winner": {
"add": "Event Winner add · Add"
},
"event": {
"change": "Events · Change"
"change": "Events · Change",
"delete": "Події · Видалити"
},
"poll-question": {
"change": "Poll Question change · Change"