feat(housekeeping): complete content events workflow
This commit is contained in:
1 parent
f2e67ebace
commit
d6235c37a1
42 files changed
+2868
-307
No files matched your search
@@ -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(
|
||||
|
||||
+64
-5
@@ -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",
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user