diff --git a/apps/webui/src/app/api/guilds/[guildId]/birthdays/route.ts b/apps/webui/src/app/api/guilds/[guildId]/birthdays/route.ts
new file mode 100644
index 0000000..c4fdeaf
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/birthdays/route.ts
@@ -0,0 +1,46 @@
+import { BirthdayConfigDashboardPatchSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { getBirthdayDashboard, updateBirthdayDashboard } from '@/lib/module-configs/birthdays';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const config = await getBirthdayDashboard(guildId);
+ return NextResponse.json(config);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const patch = BirthdayConfigDashboardPatchSchema.parse(body);
+
+ const before = await getBirthdayDashboard(guildId);
+ const after = await updateBirthdayDashboard(guildId, patch);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'birthdays.update',
+ path: `/dashboard/${guildId}/birthdays`,
+ before,
+ after
+ });
+
+ return NextResponse.json(after);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts
new file mode 100644
index 0000000..300ed0e
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/action/route.ts
@@ -0,0 +1,44 @@
+import { GiveawayActionSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { endGiveawayDashboard, setGiveawayPaused } from '@/lib/module-configs/giveaways';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string; giveawayId: string }>;
+}
+
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const { guildId, giveawayId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const action = GiveawayActionSchema.parse(body.action);
+
+ let giveaway;
+ if (action === 'end') {
+ giveaway = await endGiveawayDashboard(guildId, giveawayId);
+ } else if (action === 'pause') {
+ giveaway = await setGiveawayPaused(guildId, giveawayId, true);
+ } else {
+ giveaway = await setGiveawayPaused(guildId, giveawayId, false);
+ }
+
+ if (!giveaway) {
+ return NextResponse.json({ error: 'Giveaway not found or already ended' }, { status: 404 });
+ }
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: `giveaways.${action}`,
+ path: `/dashboard/${guildId}/giveaways`,
+ after: giveaway
+ });
+
+ return NextResponse.json(giveaway);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/route.ts
new file mode 100644
index 0000000..c4e552f
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/giveaways/[giveawayId]/route.ts
@@ -0,0 +1,32 @@
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { deleteGiveawayDashboard } from '@/lib/module-configs/giveaways';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string; giveawayId: string }>;
+}
+
+export async function DELETE(_request: NextRequest, { params }: RouteParams) {
+ const { guildId, giveawayId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const deleted = await deleteGiveawayDashboard(guildId, giveawayId);
+ if (!deleted) {
+ return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
+ }
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'giveaways.delete',
+ path: `/dashboard/${guildId}/giveaways`,
+ before: { giveawayId }
+ });
+
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/giveaways/route.ts b/apps/webui/src/app/api/guilds/[guildId]/giveaways/route.ts
new file mode 100644
index 0000000..a767c32
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/giveaways/route.ts
@@ -0,0 +1,47 @@
+import { GiveawayCreateDashboardSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { createGiveawayDashboard, GiveawayCreateTimeoutError, listGiveaways } from '@/lib/module-configs/giveaways';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const giveaways = await listGiveaways(guildId);
+ return NextResponse.json({ giveaways });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const input = GiveawayCreateDashboardSchema.parse(body);
+
+ const giveaway = await createGiveawayDashboard(guildId, session.user.id, input);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'giveaways.create',
+ path: `/dashboard/${guildId}/giveaways`,
+ after: giveaway
+ });
+
+ return NextResponse.json(giveaway, { status: 201 });
+ } catch (error) {
+ if (error instanceof GiveawayCreateTimeoutError) {
+ return NextResponse.json({ error: error.message }, { status: 504 });
+ }
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts
new file mode 100644
index 0000000..7d7fc52
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts
@@ -0,0 +1,59 @@
+import { SelfRolePanelDashboardUpdateSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { deleteSelfRolePanel, updateSelfRolePanel } from '@/lib/module-configs/selfroles';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string; panelId: string }>;
+}
+
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+ const { guildId, panelId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const patch = SelfRolePanelDashboardUpdateSchema.parse(body);
+
+ const updated = await updateSelfRolePanel(guildId, panelId, patch);
+ if (!updated) {
+ return NextResponse.json({ error: 'Panel not found' }, { status: 404 });
+ }
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'selfroles.update',
+ path: `/dashboard/${guildId}/selfroles`,
+ after: updated
+ });
+
+ return NextResponse.json(updated);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function DELETE(_request: NextRequest, { params }: RouteParams) {
+ const { guildId, panelId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const deleted = await deleteSelfRolePanel(guildId, panelId);
+ if (!deleted) {
+ return NextResponse.json({ error: 'Panel not found' }, { status: 404 });
+ }
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'selfroles.delete',
+ path: `/dashboard/${guildId}/selfroles`,
+ before: { panelId }
+ });
+
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts b/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts
new file mode 100644
index 0000000..62fdc23
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts
@@ -0,0 +1,44 @@
+import { SelfRolePanelDashboardCreateSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { createSelfRolePanel, listSelfRolePanels } from '@/lib/module-configs/selfroles';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const panels = await listSelfRolePanels(guildId);
+ return NextResponse.json({ panels });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const input = SelfRolePanelDashboardCreateSchema.parse(body);
+
+ const panel = await createSelfRolePanel(guildId, input);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'selfroles.create',
+ path: `/dashboard/${guildId}/selfroles`,
+ after: panel
+ });
+
+ return NextResponse.json(panel, { status: 201 });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/starboard/route.ts b/apps/webui/src/app/api/guilds/[guildId]/starboard/route.ts
new file mode 100644
index 0000000..3fa890a
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/starboard/route.ts
@@ -0,0 +1,46 @@
+import { StarboardConfigDashboardPatchSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { getStarboardDashboard, updateStarboardDashboard } from '@/lib/module-configs/starboard';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const config = await getStarboardDashboard(guildId);
+ return NextResponse.json(config);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const patch = StarboardConfigDashboardPatchSchema.parse(body);
+
+ const before = await getStarboardDashboard(guildId);
+ const after = await updateStarboardDashboard(guildId, patch);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'starboard.update',
+ path: `/dashboard/${guildId}/starboard`,
+ before,
+ after
+ });
+
+ return NextResponse.json(after);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/tags/[tagId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tags/[tagId]/route.ts
new file mode 100644
index 0000000..3872d57
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/tags/[tagId]/route.ts
@@ -0,0 +1,62 @@
+import { TagDashboardUpdateSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { deleteTag, TagConflictError, updateTag } from '@/lib/module-configs/tags';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string; tagId: string }>;
+}
+
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+ const { guildId, tagId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const patch = TagDashboardUpdateSchema.parse(body);
+
+ const updated = await updateTag(guildId, tagId, patch);
+ if (!updated) {
+ return NextResponse.json({ error: 'Tag not found' }, { status: 404 });
+ }
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'tags.update',
+ path: `/dashboard/${guildId}/tags`,
+ after: updated
+ });
+
+ return NextResponse.json(updated);
+ } catch (error) {
+ if (error instanceof TagConflictError) {
+ return NextResponse.json({ error: error.message }, { status: 409 });
+ }
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function DELETE(_request: NextRequest, { params }: RouteParams) {
+ const { guildId, tagId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const deleted = await deleteTag(guildId, tagId);
+ if (!deleted) {
+ return NextResponse.json({ error: 'Tag not found' }, { status: 404 });
+ }
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'tags.delete',
+ path: `/dashboard/${guildId}/tags`,
+ before: { tagId }
+ });
+
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts
new file mode 100644
index 0000000..d7b2dcf
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/tags/route.ts
@@ -0,0 +1,47 @@
+import { TagDashboardCreateSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { createTag, listTags, TagConflictError } from '@/lib/module-configs/tags';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const tags = await listTags(guildId);
+ return NextResponse.json({ tags });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const input = TagDashboardCreateSchema.parse(body);
+
+ const tag = await createTag(guildId, session.user.id, input);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'tags.create',
+ path: `/dashboard/${guildId}/tags`,
+ after: tag
+ });
+
+ return NextResponse.json(tag, { status: 201 });
+ } catch (error) {
+ if (error instanceof TagConflictError) {
+ return NextResponse.json({ error: error.message }, { status: 409 });
+ }
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/tempvoice/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tempvoice/route.ts
new file mode 100644
index 0000000..24d2ce0
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/tempvoice/route.ts
@@ -0,0 +1,46 @@
+import { TempVoiceConfigDashboardPatchSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { getTempVoiceDashboard, updateTempVoiceDashboard } from '@/lib/module-configs/tempvoice';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const config = await getTempVoiceDashboard(guildId);
+ return NextResponse.json(config);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const patch = TempVoiceConfigDashboardPatchSchema.parse(body);
+
+ const before = await getTempVoiceDashboard(guildId);
+ const after = await updateTempVoiceDashboard(guildId, patch);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'tempvoice.update',
+ path: `/dashboard/${guildId}/tempvoice`,
+ before,
+ after
+ });
+
+ return NextResponse.json(after);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts
new file mode 100644
index 0000000..059842a
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts
@@ -0,0 +1,66 @@
+import { TicketCategoryDashboardUpdateSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import {
+ deleteTicketCategory,
+ TicketCategoryConflictError,
+ updateTicketCategory
+} from '@/lib/module-configs/tickets';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string; categoryId: string }>;
+}
+
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+ const { guildId, categoryId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const patch = TicketCategoryDashboardUpdateSchema.parse(body);
+
+ const updated = await updateTicketCategory(guildId, categoryId, patch);
+ if (!updated) {
+ return NextResponse.json({ error: 'Category not found' }, { status: 404 });
+ }
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'tickets.category.update',
+ path: `/dashboard/${guildId}/tickets`,
+ after: updated
+ });
+
+ return NextResponse.json(updated);
+ } catch (error) {
+ if (error instanceof TicketCategoryConflictError) {
+ return NextResponse.json({ error: error.message }, { status: 409 });
+ }
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function DELETE(_request: NextRequest, { params }: RouteParams) {
+ const { guildId, categoryId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const deleted = await deleteTicketCategory(guildId, categoryId);
+ if (!deleted) {
+ return NextResponse.json({ error: 'Category not found' }, { status: 404 });
+ }
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'tickets.category.delete',
+ path: `/dashboard/${guildId}/tickets`,
+ before: { categoryId }
+ });
+
+ return NextResponse.json({ ok: true });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts
new file mode 100644
index 0000000..97d44f3
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts
@@ -0,0 +1,47 @@
+import { TicketCategoryDashboardCreateSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { createTicketCategory, listTicketCategories, TicketCategoryConflictError } from '@/lib/module-configs/tickets';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const categories = await listTicketCategories(guildId);
+ return NextResponse.json({ categories });
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const input = TicketCategoryDashboardCreateSchema.parse(body);
+
+ const category = await createTicketCategory(guildId, input);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'tickets.category.create',
+ path: `/dashboard/${guildId}/tickets`,
+ after: category
+ });
+
+ return NextResponse.json(category, { status: 201 });
+ } catch (error) {
+ if (error instanceof TicketCategoryConflictError) {
+ return NextResponse.json({ error: error.message }, { status: 409 });
+ }
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts
new file mode 100644
index 0000000..9c6a074
--- /dev/null
+++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts
@@ -0,0 +1,46 @@
+import { TicketConfigDashboardPatchSchema } from '@nexumi/shared';
+import { type NextRequest, NextResponse } from 'next/server';
+import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
+import { writeDashboardAudit } from '@/lib/audit';
+import { getTicketConfigDashboard, updateTicketConfigDashboard } from '@/lib/module-configs/tickets';
+import { prisma } from '@/lib/prisma';
+
+interface RouteParams {
+ params: Promise<{ guildId: string }>;
+}
+
+export async function GET(_request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ await requireGuildAccess(guildId);
+ const config = await getTicketConfigDashboard(guildId);
+ return NextResponse.json(config);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
+
+export async function PATCH(request: NextRequest, { params }: RouteParams) {
+ const { guildId } = await params;
+ try {
+ const session = await requireGuildAccess(guildId);
+ const body = await request.json();
+ const patch = TicketConfigDashboardPatchSchema.parse(body);
+
+ const before = await getTicketConfigDashboard(guildId);
+ const after = await updateTicketConfigDashboard(guildId, patch);
+
+ await writeDashboardAudit(prisma, {
+ guildId,
+ actorUserId: session.user.id,
+ action: 'tickets.update',
+ path: `/dashboard/${guildId}/tickets`,
+ before,
+ after
+ });
+
+ return NextResponse.json(after);
+ } catch (error) {
+ return toApiErrorResponse(error);
+ }
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/giveaways/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/giveaways/loading.tsx
new file mode 100644
index 0000000..00b1ad3
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/giveaways/loading.tsx
@@ -0,0 +1,15 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function GiveawaysLoading() {
+ return (
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/giveaways/page.tsx b/apps/webui/src/app/dashboard/[guildId]/giveaways/page.tsx
new file mode 100644
index 0000000..bfda909
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/giveaways/page.tsx
@@ -0,0 +1,22 @@
+import { GiveawaysManager } from '@/components/modules/giveaways-manager';
+import { getLocale, t } from '@/lib/i18n';
+import { listGiveaways } from '@/lib/module-configs/giveaways';
+
+interface GiveawaysPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function GiveawaysPage({ params }: GiveawaysPageProps) {
+ const { guildId } = await params;
+ const [locale, giveaways] = await Promise.all([getLocale(), listGiveaways(guildId)]);
+
+ return (
+
+
+
{t(locale, 'modules.giveaways.label')}
+
{t(locale, 'modules.giveaways.description')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/selfroles/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/selfroles/loading.tsx
new file mode 100644
index 0000000..66431d8
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/selfroles/loading.tsx
@@ -0,0 +1,15 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function SelfRolesLoading() {
+ return (
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/selfroles/page.tsx b/apps/webui/src/app/dashboard/[guildId]/selfroles/page.tsx
new file mode 100644
index 0000000..424e7da
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/selfroles/page.tsx
@@ -0,0 +1,22 @@
+import { SelfRolesManager } from '@/components/modules/selfroles-manager';
+import { getLocale, t } from '@/lib/i18n';
+import { listSelfRolePanels } from '@/lib/module-configs/selfroles';
+
+interface SelfRolesPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function SelfRolesPage({ params }: SelfRolesPageProps) {
+ const { guildId } = await params;
+ const [locale, panels] = await Promise.all([getLocale(), listSelfRolePanels(guildId)]);
+
+ return (
+
+
+
{t(locale, 'modules.selfroles.label')}
+
{t(locale, 'modules.selfroles.description')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/tags/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/tags/loading.tsx
new file mode 100644
index 0000000..8662b3f
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/tags/loading.tsx
@@ -0,0 +1,15 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function TagsLoading() {
+ return (
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/tags/page.tsx b/apps/webui/src/app/dashboard/[guildId]/tags/page.tsx
new file mode 100644
index 0000000..1341970
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/tags/page.tsx
@@ -0,0 +1,22 @@
+import { TagsManager } from '@/components/modules/tags-manager';
+import { getLocale, t } from '@/lib/i18n';
+import { listTags } from '@/lib/module-configs/tags';
+
+interface TagsPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function TagsPage({ params }: TagsPageProps) {
+ const { guildId } = await params;
+ const [locale, tags] = await Promise.all([getLocale(), listTags(guildId)]);
+
+ return (
+
+
+
{t(locale, 'modules.tags.label')}
+
{t(locale, 'modules.tags.description')}
+
+
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/tickets/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/tickets/loading.tsx
new file mode 100644
index 0000000..8025a52
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/tickets/loading.tsx
@@ -0,0 +1,15 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function TicketsLoading() {
+ return (
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx b/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx
new file mode 100644
index 0000000..ef785c1
--- /dev/null
+++ b/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx
@@ -0,0 +1,28 @@
+import { TicketCategoriesManager } from '@/components/modules/ticket-categories-manager';
+import { TicketConfigForm } from '@/components/modules/ticket-config-form';
+import { getLocale, t } from '@/lib/i18n';
+import { getTicketConfigDashboard, listTicketCategories } from '@/lib/module-configs/tickets';
+
+interface TicketsPageProps {
+ params: Promise<{ guildId: string }>;
+}
+
+export default async function TicketsPage({ params }: TicketsPageProps) {
+ const { guildId } = await params;
+ const [locale, config, categories] = await Promise.all([
+ getLocale(),
+ getTicketConfigDashboard(guildId),
+ listTicketCategories(guildId)
+ ]);
+
+ return (
+
+
+
{t(locale, 'modules.tickets.label')}
+
{t(locale, 'modules.tickets.description')}
+
+
+
+
+ );
+}
diff --git a/apps/webui/src/components/modules/giveaways-manager.tsx b/apps/webui/src/components/modules/giveaways-manager.tsx
new file mode 100644
index 0000000..f650d3a
--- /dev/null
+++ b/apps/webui/src/components/modules/giveaways-manager.tsx
@@ -0,0 +1,286 @@
+'use client';
+
+import type { GiveawayDashboard } from '@nexumi/shared';
+import { Pause, Play, Plus, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { useTranslations } from '@/components/locale-provider';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+const EMPTY_DRAFT = {
+ channelId: '',
+ prize: '',
+ winnerCount: '1',
+ endsAt: '',
+ requiredRoleId: '',
+ requiredLevel: '',
+ requiredMemberDays: ''
+};
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
+}
+
+interface GiveawaysManagerProps {
+ guildId: string;
+ initialGiveaways: GiveawayDashboard[];
+}
+
+export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManagerProps) {
+ const t = useTranslations();
+ const [giveaways, setGiveaways] = useState(initialGiveaways);
+ const [draft, setDraft] = useState(EMPTY_DRAFT);
+ const [creating, setCreating] = useState(false);
+ const [pendingId, setPendingId] = useState(null);
+
+ async function handleCreate() {
+ if (!draft.channelId.trim() || !draft.prize.trim() || !draft.endsAt) {
+ toast.error(t('modulePages.giveaways.formIncomplete'));
+ return;
+ }
+ setCreating(true);
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/giveaways`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ channelId: draft.channelId.trim(),
+ prize: draft.prize.trim(),
+ winnerCount: Number(draft.winnerCount) || 1,
+ endsAt: new Date(draft.endsAt).toISOString(),
+ requiredRoleId: draft.requiredRoleId.trim() || undefined,
+ requiredLevel: draft.requiredLevel.trim() ? Number(draft.requiredLevel) : undefined,
+ requiredMemberDays: draft.requiredMemberDays.trim() ? Number(draft.requiredMemberDays) : undefined
+ })
+ });
+ const body = (await response.json().catch(() => null)) as (GiveawayDashboard & { error?: string }) | null;
+ if (!response.ok || !body) {
+ toast.error(body?.error ?? t('common.saveError'));
+ return;
+ }
+ setGiveaways((prev) => [body, ...prev]);
+ setDraft(EMPTY_DRAFT);
+ toast.success(t('modulePages.giveaways.created'));
+ } catch {
+ toast.error(t('common.saveError'));
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function handleAction(giveaway: GiveawayDashboard, action: 'end' | 'pause' | 'unpause') {
+ setPendingId(giveaway.id);
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/giveaways/${giveaway.id}/action`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action })
+ });
+ const body = (await response.json().catch(() => null)) as (GiveawayDashboard & { error?: string }) | null;
+ if (!response.ok || !body) {
+ toast.error(body?.error ?? t('common.saveError'));
+ return;
+ }
+ setGiveaways((prev) => prev.map((entry) => (entry.id === giveaway.id ? body : entry)));
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ } finally {
+ setPendingId(null);
+ }
+ }
+
+ async function handleDelete(giveaway: GiveawayDashboard) {
+ if (!window.confirm(t('modulePages.giveaways.confirmDelete'))) {
+ return;
+ }
+ setPendingId(giveaway.id);
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/giveaways/${giveaway.id}`, { method: 'DELETE' });
+ if (!response.ok) {
+ toast.error(t('common.saveError'));
+ return;
+ }
+ setGiveaways((prev) => prev.filter((entry) => entry.id !== giveaway.id));
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ } finally {
+ setPendingId(null);
+ }
+ }
+
+ return (
+
+
+
+ {t('modulePages.giveaways.createTitle')}
+ {t('modulePages.giveaways.createDescription')}
+
+
+
+
+
+
+
+
+
+ {t('modulePages.giveaways.listTitle')}
+ {t('modulePages.giveaways.listDescription')}
+
+
+ {giveaways.length === 0 && (
+ {t('modulePages.giveaways.empty')}
+ )}
+ {giveaways.map((giveaway) => {
+ const ended = Boolean(giveaway.endedAt);
+ return (
+
+
+
+
{giveaway.prize}
+
+ #{giveaway.channelId} · {t('modulePages.giveaways.winnerCount')}: {giveaway.winnerCount} ·{' '}
+ {t('modulePages.giveaways.entrants')}: {giveaway.entrants.length}
+
+
+
+ {ended ? (
+ {t('modulePages.giveaways.statusEnded')}
+ ) : giveaway.paused ? (
+ {t('modulePages.giveaways.statusPaused')}
+ ) : (
+ {t('modulePages.giveaways.statusActive')}
+ )}
+
+
+
+ {ended
+ ? `${t('modulePages.giveaways.endedAt')}: ${formatDate(giveaway.endedAt!)}`
+ : `${t('modulePages.giveaways.endsAt')}: ${formatDate(giveaway.endsAt)}`}
+
+ {ended && giveaway.winners.length > 0 && (
+
+ {t('modulePages.giveaways.winners')}: {giveaway.winners.map((id) => `<@${id}>`).join(', ')}
+
+ )}
+ {!ended && (
+
+
+
+
+
+ )}
+ {ended && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/webui/src/components/modules/selfroles-manager.tsx b/apps/webui/src/components/modules/selfroles-manager.tsx
new file mode 100644
index 0000000..9fd5d53
--- /dev/null
+++ b/apps/webui/src/components/modules/selfroles-manager.tsx
@@ -0,0 +1,318 @@
+'use client';
+
+import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared';
+import { Plus, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { useTranslations } from '@/components/locale-provider';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+
+const MODES: SelfRoleMode[] = ['BUTTONS', 'DROPDOWN', 'REACTIONS'];
+const BEHAVIORS: SelfRoleBehavior[] = ['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY'];
+
+function rolesToText(roles: SelfRoleEntry[]): string {
+ return roles.map((role) => [role.roleId, role.label, role.emoji ?? '', role.description ?? ''].join(' | ')).join('\n');
+}
+
+function textToRoles(text: string): SelfRoleEntry[] {
+ return text
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ .map((line) => {
+ const [roleId, label, emoji, description] = line.split('|').map((part) => part.trim());
+ return {
+ roleId: roleId ?? '',
+ label: label || roleId || '',
+ emoji: emoji || undefined,
+ description: description || undefined
+ };
+ })
+ .filter((role) => role.roleId.length > 0);
+}
+
+interface LocalPanel extends SelfRolePanelDashboard {
+ clientKey: string;
+ rolesText: string;
+ saving?: boolean;
+}
+
+const EMPTY_DRAFT = {
+ channelId: '',
+ title: '',
+ description: '',
+ mode: 'BUTTONS' as SelfRoleMode,
+ behavior: 'NORMAL' as SelfRoleBehavior,
+ rolesText: ''
+};
+
+interface SelfRolesManagerProps {
+ guildId: string;
+ initialPanels: SelfRolePanelDashboard[];
+}
+
+export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerProps) {
+ const t = useTranslations();
+ const [panels, setPanels] = useState(
+ initialPanels.map((panel, index) => ({
+ ...panel,
+ clientKey: panel.id ?? `existing-${index}`,
+ rolesText: rolesToText(panel.roles)
+ }))
+ );
+ const [draft, setDraft] = useState(EMPTY_DRAFT);
+ const [creating, setCreating] = useState(false);
+
+ async function handleCreate() {
+ const roles = textToRoles(draft.rolesText);
+ if (!draft.channelId.trim() || !draft.title.trim() || roles.length === 0) {
+ toast.error(t('modulePages.selfroles.formIncomplete'));
+ return;
+ }
+ setCreating(true);
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/selfroles`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ channelId: draft.channelId.trim(),
+ title: draft.title.trim(),
+ description: draft.description.trim() || null,
+ mode: draft.mode,
+ behavior: draft.behavior,
+ roles
+ })
+ });
+ const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
+ if (!response.ok || !body) {
+ toast.error(body?.error ?? t('common.saveError'));
+ return;
+ }
+ setPanels((prev) => [
+ { ...body, clientKey: body.id ?? `new-${Date.now()}`, rolesText: rolesToText(body.roles) },
+ ...prev
+ ]);
+ setDraft(EMPTY_DRAFT);
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function handleSave(panel: LocalPanel) {
+ setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: true } : entry)));
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ channelId: panel.channelId,
+ title: panel.title,
+ description: panel.description,
+ mode: panel.mode,
+ behavior: panel.behavior,
+ roles: textToRoles(panel.rolesText)
+ })
+ });
+ const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
+ if (!response.ok || !body) {
+ toast.error(body?.error ?? t('common.saveError'));
+ return;
+ }
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ } finally {
+ setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry)));
+ }
+ }
+
+ async function handleDelete(panel: LocalPanel) {
+ if (!window.confirm(t('modulePages.selfroles.confirmDelete'))) {
+ return;
+ }
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, { method: 'DELETE' });
+ if (!response.ok) {
+ toast.error(t('common.saveError'));
+ return;
+ }
+ setPanels((prev) => prev.filter((entry) => entry.clientKey !== panel.clientKey));
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ }
+ }
+
+ return (
+
+
+
+ {t('modulePages.selfroles.createTitle')}
+ {t('modulePages.selfroles.createDescription')}
+
+
+
+
+
+ setDraft((prev) => ({ ...prev, channelId: event.target.value }))} placeholder="123456789012345678" />
+
+
+
+ setDraft((prev) => ({ ...prev, title: event.target.value }))} />
+
+
+
+
+
+
+
+
+
+
+
+
+ setDraft((prev) => ({ ...prev, description: event.target.value }))} />
+
+
+
+
+
+
+
+
+
+
+ {t('modulePages.selfroles.listTitle')}
+ {t('modulePages.selfroles.listDescription')}
+
+
+ {panels.length === 0 && {t('modulePages.selfroles.empty')}
}
+ {panels.map((panel) => (
+
+
+
+
+
+ setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId: event.target.value } : entry)))
+ }
+ />
+
+
+
+
+ setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, title: event.target.value } : entry)))
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/webui/src/components/modules/tags-manager.tsx b/apps/webui/src/components/modules/tags-manager.tsx
new file mode 100644
index 0000000..e4b92d7
--- /dev/null
+++ b/apps/webui/src/components/modules/tags-manager.tsx
@@ -0,0 +1,261 @@
+'use client';
+
+import type { TagDashboard, TagResponseType } from '@nexumi/shared';
+import { Plus, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { useTranslations } from '@/components/locale-provider';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+
+const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED'];
+
+function toCsv(ids: string[]): string {
+ return ids.join(', ');
+}
+function fromCsv(text: string): string[] {
+ return text.split(',').map((entry) => entry.trim()).filter(Boolean);
+}
+
+interface LocalTag extends TagDashboard {
+ clientKey: string;
+ allowedRoleIdsText: string;
+ allowedChannelIdsText: string;
+ saving?: boolean;
+}
+
+const EMPTY_DRAFT = {
+ name: '',
+ content: '',
+ responseType: 'TEXT' as TagResponseType,
+ triggerWord: '',
+ allowedRoleIdsText: '',
+ allowedChannelIdsText: ''
+};
+
+interface TagsManagerProps {
+ guildId: string;
+ initialTags: TagDashboard[];
+}
+
+export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
+ const t = useTranslations();
+ const [tags, setTags] = useState(
+ initialTags.map((tag, index) => ({
+ ...tag,
+ clientKey: tag.id ?? `existing-${index}`,
+ allowedRoleIdsText: toCsv(tag.allowedRoleIds),
+ allowedChannelIdsText: toCsv(tag.allowedChannelIds)
+ }))
+ );
+ const [draft, setDraft] = useState(EMPTY_DRAFT);
+ const [creating, setCreating] = useState(false);
+
+ async function handleCreate() {
+ if (!draft.name.trim()) {
+ toast.error(t('modulePages.tags.nameRequired'));
+ return;
+ }
+ setCreating(true);
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/tags`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: draft.name.trim(),
+ content: draft.content.trim() || null,
+ responseType: draft.responseType,
+ triggerWord: draft.triggerWord.trim() || null,
+ allowedRoleIds: fromCsv(draft.allowedRoleIdsText),
+ allowedChannelIds: fromCsv(draft.allowedChannelIdsText)
+ })
+ });
+ const body = (await response.json().catch(() => null)) as (TagDashboard & { error?: string }) | null;
+ if (!response.ok || !body) {
+ toast.error(body?.error ?? t('common.saveError'));
+ return;
+ }
+ setTags((prev) => [
+ {
+ ...body,
+ clientKey: body.id ?? `new-${Date.now()}`,
+ allowedRoleIdsText: toCsv(body.allowedRoleIds),
+ allowedChannelIdsText: toCsv(body.allowedChannelIds)
+ },
+ ...prev
+ ]);
+ setDraft(EMPTY_DRAFT);
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function handleSave(tag: LocalTag) {
+ setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: true } : entry)));
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/tags/${tag.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: tag.name,
+ content: tag.content,
+ responseType: tag.responseType,
+ triggerWord: tag.triggerWord,
+ allowedRoleIds: fromCsv(tag.allowedRoleIdsText),
+ allowedChannelIds: fromCsv(tag.allowedChannelIdsText)
+ })
+ });
+ const body = (await response.json().catch(() => null)) as (TagDashboard & { error?: string }) | null;
+ if (!response.ok || !body) {
+ toast.error(body?.error ?? t('common.saveError'));
+ return;
+ }
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ } finally {
+ setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: false } : entry)));
+ }
+ }
+
+ async function handleDelete(tag: LocalTag) {
+ if (!window.confirm(t('modulePages.tags.confirmDelete'))) {
+ return;
+ }
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/tags/${tag.id}`, { method: 'DELETE' });
+ if (!response.ok) {
+ toast.error(t('common.saveError'));
+ return;
+ }
+ setTags((prev) => prev.filter((entry) => entry.clientKey !== tag.clientKey));
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ }
+ }
+
+ return (
+
+
+
+ {t('modulePages.tags.createTitle')}
+ {t('modulePages.tags.createDescription')}
+
+
+
+
+
+ setDraft((prev) => ({ ...prev, name: event.target.value }))} />
+
+
+
+
+
+
+
+ setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} />
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('modulePages.tags.listTitle')}
+ {t('modulePages.tags.listDescription')}
+
+
+ {tags.length === 0 && {t('modulePages.tags.empty')}
}
+ {tags.map((tag) => (
+
+
+
+
+ setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, name: event.target.value } : entry)))} />
+
+
+
+
+
+
+
+ setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} />
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/webui/src/components/modules/ticket-categories-manager.tsx b/apps/webui/src/components/modules/ticket-categories-manager.tsx
new file mode 100644
index 0000000..8be7f97
--- /dev/null
+++ b/apps/webui/src/components/modules/ticket-categories-manager.tsx
@@ -0,0 +1,239 @@
+'use client';
+
+import type { TicketCategoryDashboard } from '@nexumi/shared';
+import { Plus, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { useTranslations } from '@/components/locale-provider';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+interface LocalCategory extends TicketCategoryDashboard {
+ clientKey: string;
+ saving?: boolean;
+}
+
+function toCsv(ids: string[]): string {
+ return ids.join(', ');
+}
+
+function fromCsv(text: string): string[] {
+ return text
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+}
+
+const EMPTY_DRAFT = { name: '', description: '', emoji: '', supportRoleIdsText: '' };
+
+interface TicketCategoriesManagerProps {
+ guildId: string;
+ initialCategories: TicketCategoryDashboard[];
+}
+
+export function TicketCategoriesManager({ guildId, initialCategories }: TicketCategoriesManagerProps) {
+ const t = useTranslations();
+ const [categories, setCategories] = useState(
+ initialCategories.map((category, index) => ({ ...category, clientKey: category.id ?? `existing-${index}` }))
+ );
+ const [draft, setDraft] = useState(EMPTY_DRAFT);
+ const [creating, setCreating] = useState(false);
+
+ async function handleCreate() {
+ if (!draft.name.trim()) {
+ toast.error(t('modulePages.tickets.nameRequired'));
+ return;
+ }
+ setCreating(true);
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/tickets/categories`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: draft.name.trim(),
+ description: draft.description.trim() || null,
+ emoji: draft.emoji.trim() || null,
+ supportRoleIds: fromCsv(draft.supportRoleIdsText)
+ })
+ });
+ const body = (await response.json().catch(() => null)) as (TicketCategoryDashboard & { error?: string }) | null;
+ if (!response.ok || !body) {
+ toast.error(body?.error ?? t('common.saveError'));
+ return;
+ }
+ setCategories((prev) => [...prev, { ...body, clientKey: body.id ?? `new-${Date.now()}` }]);
+ setDraft(EMPTY_DRAFT);
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function handleSaveRow(category: LocalCategory) {
+ setCategories((prev) =>
+ prev.map((entry) => (entry.clientKey === category.clientKey ? { ...entry, saving: true } : entry))
+ );
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/tickets/categories/${category.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: category.name,
+ description: category.description,
+ emoji: category.emoji,
+ supportRoleIds: category.supportRoleIds
+ })
+ });
+ const body = (await response.json().catch(() => null)) as (TicketCategoryDashboard & { error?: string }) | null;
+ if (!response.ok || !body) {
+ toast.error(body?.error ?? t('common.saveError'));
+ return;
+ }
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ } finally {
+ setCategories((prev) =>
+ prev.map((entry) => (entry.clientKey === category.clientKey ? { ...entry, saving: false } : entry))
+ );
+ }
+ }
+
+ async function handleDelete(category: LocalCategory) {
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/tickets/categories/${category.id}`, {
+ method: 'DELETE'
+ });
+ if (!response.ok) {
+ toast.error(t('common.saveError'));
+ return;
+ }
+ setCategories((prev) => prev.filter((entry) => entry.clientKey !== category.clientKey));
+ toast.success(t('common.saveSuccess'));
+ } catch {
+ toast.error(t('common.saveError'));
+ }
+ }
+
+ return (
+
+
+ {t('modulePages.tickets.categoriesTitle')}
+ {t('modulePages.tickets.categoriesDescription')}
+
+
+ {categories.length === 0 && (
+ {t('modulePages.tickets.categoriesEmpty')}
+ )}
+ {categories.map((category) => (
+
+
+
+
+
+ setCategories((prev) =>
+ prev.map((entry) =>
+ entry.clientKey === category.clientKey
+ ? { ...entry, supportRoleIds: fromCsv(event.target.value) }
+ : entry
+ )
+ )
+ }
+ placeholder={t('modulePages.tickets.idsPlaceholder')}
+ />
+
+
+
+
+
+
+ ))}
+
+
+
{t('modulePages.tickets.addCategory')}
+
+
+
+ setDraft((prev) => ({ ...prev, supportRoleIdsText: event.target.value }))}
+ placeholder={t('modulePages.tickets.idsPlaceholder')}
+ />
+
+
+
+
+
+ );
+}
diff --git a/apps/webui/src/components/modules/ticket-config-form.tsx b/apps/webui/src/components/modules/ticket-config-form.tsx
new file mode 100644
index 0000000..f301e58
--- /dev/null
+++ b/apps/webui/src/components/modules/ticket-config-form.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import type { TicketConfigDashboard } from '@nexumi/shared';
+import { useId } from 'react';
+import { useTranslations } from '@/components/locale-provider';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import type { SettingsSaveResult } from '@/components/settings/settings-form';
+import { SettingsForm } from '@/components/settings/settings-form';
+
+async function saveTicketConfig(guildId: string, value: TicketConfigDashboard): Promise {
+ try {
+ const response = await fetch(`/api/guilds/${guildId}/tickets`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(value)
+ });
+ if (!response.ok) {
+ const body = (await response.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
+ }
+ return { ok: true };
+ } catch {
+ return { ok: false, error: 'Network error' };
+ }
+}
+
+interface TicketConfigFormProps {
+ guildId: string;
+ initialValue: TicketConfigDashboard;
+}
+
+export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProps) {
+ const t = useTranslations();
+ const categoryId = useId();
+ const logChannelId = useId();
+ const inactivityId = useId();
+
+ return (
+
+ initialValue={initialValue}
+ onSave={(value) => saveTicketConfig(guildId, value)}
+ >
+ {({ value, setValue }) => (
+
+
+ {t('modulePages.tickets.title')}
+ {t('modulePages.tickets.description')}
+
+
+
+
+ setValue((prev) => ({ ...prev, enabled }))}
+ />
+
+
+
+
+
+
+
+
+
+
+ setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))}
+ placeholder="123456789012345678"
+ />
+
+
+
+
+ setValue((prev) => ({ ...prev, logChannelId: event.target.value.trim() }))}
+ placeholder="123456789012345678"
+ />
+
+
+
+
+
+ setValue((prev) => ({ ...prev, inactivityHours: Number(event.target.value) || 1 }))
+ }
+ />
+
+
+
+
+
+
+
{t('modulePages.tickets.transcriptDmHint')}
+
+
setValue((prev) => ({ ...prev, transcriptDm }))}
+ />
+
+
+
+
+ setValue((prev) => ({ ...prev, ratingEnabled }))}
+ />
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/webui/src/lib/module-configs/feeds.ts b/apps/webui/src/lib/module-configs/feeds.ts
new file mode 100644
index 0000000..39fc0f9
--- /dev/null
+++ b/apps/webui/src/lib/module-configs/feeds.ts
@@ -0,0 +1,65 @@
+import type { SocialFeedDashboard, SocialFeedDashboardCreate, SocialFeedDashboardUpdate } from '@nexumi/shared';
+import type { SocialFeed } from '@prisma/client';
+import { prisma } from '../prisma';
+
+function toDashboard(feed: SocialFeed): SocialFeedDashboard {
+ return {
+ id: feed.id,
+ type: feed.type as SocialFeedDashboard['type'],
+ sourceId: feed.sourceId,
+ channelId: feed.channelId,
+ rolePingId: feed.rolePingId ?? '',
+ template: feed.template,
+ enabled: feed.enabled
+ };
+}
+
+export async function listSocialFeeds(guildId: string): Promise {
+ const feeds = await prisma.socialFeed.findMany({ where: { guildId }, orderBy: { createdAt: 'asc' } });
+ return feeds.map(toDashboard);
+}
+
+export async function createSocialFeed(
+ guildId: string,
+ input: SocialFeedDashboardCreate
+): Promise {
+ await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
+ const feed = await prisma.socialFeed.create({
+ data: {
+ guildId,
+ type: input.type,
+ sourceId: input.sourceId,
+ channelId: input.channelId,
+ rolePingId: input.rolePingId ? input.rolePingId : null,
+ template: input.template ?? null,
+ enabled: input.enabled
+ }
+ });
+ return toDashboard(feed);
+}
+
+export async function updateSocialFeed(
+ guildId: string,
+ feedId: string,
+ patch: SocialFeedDashboardUpdate
+): Promise {
+ const existing = await prisma.socialFeed.findFirst({ where: { id: feedId, guildId } });
+ if (!existing) {
+ return null;
+ }
+
+ const { rolePingId, ...rest } = patch;
+ const updated = await prisma.socialFeed.update({
+ where: { id: feedId },
+ data: {
+ ...rest,
+ ...(rolePingId !== undefined ? { rolePingId: rolePingId === '' ? null : rolePingId } : {})
+ }
+ });
+ return toDashboard(updated);
+}
+
+export async function deleteSocialFeed(guildId: string, feedId: string): Promise {
+ const result = await prisma.socialFeed.deleteMany({ where: { id: feedId, guildId } });
+ return result.count > 0;
+}
diff --git a/apps/webui/src/lib/module-configs/giveaways.ts b/apps/webui/src/lib/module-configs/giveaways.ts
new file mode 100644
index 0000000..8b88678
--- /dev/null
+++ b/apps/webui/src/lib/module-configs/giveaways.ts
@@ -0,0 +1,141 @@
+import type { GiveawayCreateDashboard, GiveawayDashboard } from '@nexumi/shared';
+import type { Giveaway } from '@prisma/client';
+import { prisma } from '../prisma';
+import { addJobAndAwait, giveawayQueue, giveawayQueueEvents } from '../queues';
+
+export class GiveawayCreateTimeoutError extends Error {
+ constructor() {
+ super('The bot did not confirm the giveaway in time. It may still be starting - check the list shortly.');
+ }
+}
+
+function toDashboard(giveaway: Giveaway): GiveawayDashboard {
+ return {
+ id: giveaway.id,
+ guildId: giveaway.guildId,
+ channelId: giveaway.channelId,
+ messageId: giveaway.messageId,
+ prize: giveaway.prize,
+ winnerCount: giveaway.winnerCount,
+ endsAt: giveaway.endsAt.toISOString(),
+ requiredRoleId: giveaway.requiredRoleId,
+ requiredLevel: giveaway.requiredLevel,
+ requiredMemberDays: giveaway.requiredMemberDays,
+ paused: giveaway.paused,
+ endedAt: giveaway.endedAt?.toISOString() ?? null,
+ winners: giveaway.winners,
+ entrants: giveaway.entrants,
+ hostId: giveaway.hostId,
+ createdAt: giveaway.createdAt.toISOString()
+ };
+}
+
+export async function listGiveaways(guildId: string): Promise {
+ const giveaways = await prisma.giveaway.findMany({
+ where: { guildId },
+ orderBy: [{ endedAt: 'asc' }, { endsAt: 'asc' }],
+ take: 50
+ });
+ return giveaways.map(toDashboard);
+}
+
+/**
+ * The WebUI has no discord.js Client to post the giveaway announcement
+ * message, so creation is delegated to the bot via the same `giveaways`
+ * BullMQ queue used by `/giveaway start` (see
+ * `apps/bot/src/modules/giveaways/service.ts#runGiveawayCreateJob`). We wait
+ * (bounded) for the job to finish so the dashboard can show the created
+ * giveaway immediately.
+ */
+export async function createGiveawayDashboard(
+ guildId: string,
+ hostId: string,
+ input: GiveawayCreateDashboard
+): Promise {
+ await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
+
+ const durationMs = new Date(input.endsAt).getTime() - Date.now();
+ if (durationMs <= 0) {
+ throw new GiveawayCreateTimeoutError();
+ }
+
+ const { confirmed, result } = await addJobAndAwait<{ id: string }>(
+ giveawayQueue,
+ giveawayQueueEvents,
+ 'giveawayCreate',
+ {
+ guildId,
+ channelId: input.channelId,
+ hostId,
+ prize: input.prize,
+ winnerCount: input.winnerCount,
+ durationMs,
+ requiredRoleId: input.requiredRoleId || null,
+ requiredLevel: input.requiredLevel ?? null,
+ requiredMemberDays: input.requiredMemberDays ?? null
+ },
+ { removeOnComplete: true, removeOnFail: 25 }
+ );
+
+ if (!confirmed || !result) {
+ throw new GiveawayCreateTimeoutError();
+ }
+
+ const giveaway = await prisma.giveaway.findUnique({ where: { id: result.id } });
+ if (!giveaway) {
+ throw new GiveawayCreateTimeoutError();
+ }
+ return toDashboard(giveaway);
+}
+
+export async function setGiveawayPaused(
+ guildId: string,
+ giveawayId: string,
+ paused: boolean
+): Promise {
+ const existing = await prisma.giveaway.findFirst({ where: { id: giveawayId, guildId } });
+ if (!existing || existing.endedAt) {
+ return null;
+ }
+ const updated = await prisma.giveaway.update({ where: { id: giveawayId }, data: { paused } });
+ return toDashboard(updated);
+}
+
+/**
+ * Ends a giveaway immediately by re-scheduling its `giveawayEnd` job with no
+ * delay and waiting for the bot to draw winners, update the Discord message
+ * and DM them - see `apps/bot/src/modules/giveaways/service.ts#endGiveaway`.
+ */
+export async function endGiveawayDashboard(guildId: string, giveawayId: string): Promise {
+ const existing = await prisma.giveaway.findFirst({ where: { id: giveawayId, guildId } });
+ if (!existing || existing.endedAt) {
+ return null;
+ }
+
+ const existingJob = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
+ await existingJob?.remove();
+
+ await addJobAndAwait(
+ giveawayQueue,
+ giveawayQueueEvents,
+ 'giveawayEnd',
+ { giveawayId },
+ { jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 }
+ );
+
+ const updated = await prisma.giveaway.findUnique({ where: { id: giveawayId } });
+ return updated ? toDashboard(updated) : null;
+}
+
+export async function deleteGiveawayDashboard(guildId: string, giveawayId: string): Promise {
+ const existing = await prisma.giveaway.findFirst({ where: { id: giveawayId, guildId } });
+ if (!existing) {
+ return false;
+ }
+
+ const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
+ await job?.remove();
+
+ await prisma.giveaway.delete({ where: { id: giveawayId } });
+ return true;
+}
diff --git a/apps/webui/src/lib/module-configs/scheduler.ts b/apps/webui/src/lib/module-configs/scheduler.ts
new file mode 100644
index 0000000..71f0bd2
--- /dev/null
+++ b/apps/webui/src/lib/module-configs/scheduler.ts
@@ -0,0 +1,111 @@
+import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate } from '@nexumi/shared';
+import type { ScheduledMessage } from '@prisma/client';
+import { prisma } from '../prisma';
+import { scheduleQueue } from '../queues';
+
+export class SchedulerValidationError extends Error {}
+
+function isValidCron(pattern: string): boolean {
+ const parts = pattern.trim().split(/\s+/);
+ return parts.length >= 5 && parts.length <= 6;
+}
+
+function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
+ return {
+ id: schedule.id,
+ channelId: schedule.channelId,
+ creatorId: schedule.creatorId,
+ content: schedule.content,
+ rolePingId: schedule.rolePingId,
+ cron: schedule.cron,
+ runAt: schedule.runAt?.toISOString() ?? null,
+ enabled: schedule.enabled,
+ createdAt: schedule.createdAt.toISOString()
+ };
+}
+
+export async function listSchedules(guildId: string): Promise {
+ const schedules = await prisma.scheduledMessage.findMany({
+ where: { guildId, enabled: true },
+ orderBy: { createdAt: 'desc' }
+ });
+ return schedules.map(toDashboard);
+}
+
+/**
+ * Creates a scheduled message and enqueues the exact `scheduleSend` BullMQ
+ * job the bot's `/schedule create` command would enqueue (same queue, job
+ * name and delay/repeat semantics) - see
+ * `apps/bot/src/modules/scheduler/service.ts#enqueueScheduleJob`. The bot's
+ * existing `scheduleQueueName` worker sends the message, so no bot-side
+ * changes are required.
+ */
+export async function createSchedule(
+ guildId: string,
+ creatorId: string,
+ input: ScheduledMessageDashboardCreate
+): Promise {
+ await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
+
+ const cron = input.cron?.trim() || null;
+ if (cron && !isValidCron(cron)) {
+ throw new SchedulerValidationError('Invalid cron expression (expects 5-6 space separated fields)');
+ }
+
+ const runAt = !cron && input.runAt ? new Date(input.runAt) : null;
+ if (!cron && runAt && runAt.getTime() <= Date.now()) {
+ throw new SchedulerValidationError('runAt must be in the future');
+ }
+
+ const schedule = await prisma.scheduledMessage.create({
+ data: {
+ guildId,
+ channelId: input.channelId,
+ creatorId,
+ content: input.content?.trim() || null,
+ rolePingId: input.rolePingId ? input.rolePingId : null,
+ cron,
+ runAt
+ }
+ });
+
+ const jobOptions: {
+ jobId: string;
+ removeOnComplete: boolean;
+ removeOnFail: number;
+ delay?: number;
+ repeat?: { pattern: string };
+ } = {
+ jobId: `schedule-${schedule.id}`,
+ removeOnComplete: true,
+ removeOnFail: 50
+ };
+ if (cron) {
+ jobOptions.repeat = { pattern: cron };
+ } else if (runAt) {
+ jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
+ }
+
+ const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
+ const updated = await prisma.scheduledMessage.update({
+ where: { id: schedule.id },
+ data: { jobId: String(job.id) }
+ });
+
+ return toDashboard(updated);
+}
+
+export async function deleteSchedule(guildId: string, scheduleId: string): Promise {
+ const schedule = await prisma.scheduledMessage.findFirst({ where: { id: scheduleId, guildId } });
+ if (!schedule) {
+ return false;
+ }
+
+ if (schedule.jobId) {
+ const job = await scheduleQueue.getJob(schedule.jobId);
+ await job?.remove();
+ }
+
+ await prisma.scheduledMessage.delete({ where: { id: scheduleId } });
+ return true;
+}
diff --git a/apps/webui/src/lib/module-configs/selfroles.ts b/apps/webui/src/lib/module-configs/selfroles.ts
new file mode 100644
index 0000000..c912416
--- /dev/null
+++ b/apps/webui/src/lib/module-configs/selfroles.ts
@@ -0,0 +1,91 @@
+import type {
+ SelfRoleEntry,
+ SelfRolePanelDashboard,
+ SelfRolePanelDashboardCreate,
+ SelfRolePanelDashboardUpdate
+} from '@nexumi/shared';
+import type { Prisma, SelfRolePanel } from '@prisma/client';
+import { prisma } from '../prisma';
+
+function toRolesArray(raw: unknown): SelfRoleEntry[] {
+ if (!Array.isArray(raw)) {
+ return [];
+ }
+ return raw.filter(
+ (entry): entry is SelfRoleEntry =>
+ Boolean(entry) && typeof entry === 'object' && typeof (entry as SelfRoleEntry).roleId === 'string'
+ );
+}
+
+function toDashboard(panel: SelfRolePanel): SelfRolePanelDashboard {
+ return {
+ id: panel.id,
+ channelId: panel.channelId,
+ title: panel.title,
+ description: panel.description,
+ mode: panel.mode as SelfRolePanelDashboard['mode'],
+ behavior: panel.behavior as SelfRolePanelDashboard['behavior'],
+ roles: toRolesArray(panel.roles),
+ expiresAt: panel.expiresAt?.toISOString() ?? null
+ };
+}
+
+export async function listSelfRolePanels(guildId: string): Promise {
+ const panels = await prisma.selfRolePanel.findMany({
+ where: { guildId },
+ orderBy: { createdAt: 'desc' }
+ });
+ return panels.map(toDashboard);
+}
+
+export async function createSelfRolePanel(
+ guildId: string,
+ input: SelfRolePanelDashboardCreate
+): Promise {
+ await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
+ const panel = await prisma.selfRolePanel.create({
+ data: {
+ guildId,
+ channelId: input.channelId,
+ title: input.title,
+ description: input.description ?? null,
+ mode: input.mode,
+ behavior: input.behavior,
+ roles: input.roles as unknown as Prisma.InputJsonValue,
+ expiresAt: input.expiresAt ? new Date(input.expiresAt) : null
+ }
+ });
+ return toDashboard(panel);
+}
+
+export async function updateSelfRolePanel(
+ guildId: string,
+ panelId: string,
+ patch: SelfRolePanelDashboardUpdate
+): Promise {
+ const existing = await prisma.selfRolePanel.findFirst({ where: { id: panelId, guildId } });
+ if (!existing) {
+ return null;
+ }
+
+ const updated = await prisma.selfRolePanel.update({
+ where: { id: panelId },
+ data: {
+ ...(patch.channelId !== undefined ? { channelId: patch.channelId } : {}),
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
+ ...(patch.description !== undefined ? { description: patch.description } : {}),
+ ...(patch.mode !== undefined ? { mode: patch.mode } : {}),
+ ...(patch.behavior !== undefined ? { behavior: patch.behavior } : {}),
+ ...(patch.roles !== undefined ? { roles: patch.roles as unknown as Prisma.InputJsonValue } : {}),
+ ...(patch.expiresAt !== undefined
+ ? { expiresAt: patch.expiresAt ? new Date(patch.expiresAt) : null }
+ : {})
+ }
+ });
+ return toDashboard(updated);
+}
+
+export async function deleteSelfRolePanel(guildId: string, panelId: string): Promise {
+ const result = await prisma.selfRolePanel.deleteMany({ where: { id: panelId, guildId } });
+ return result.count > 0;
+}
diff --git a/apps/webui/src/lib/module-configs/suggestions.ts b/apps/webui/src/lib/module-configs/suggestions.ts
new file mode 100644
index 0000000..3d83ee1
--- /dev/null
+++ b/apps/webui/src/lib/module-configs/suggestions.ts
@@ -0,0 +1,99 @@
+import type {
+ SuggestionAction,
+ SuggestionConfigDashboard,
+ SuggestionConfigDashboardPatch,
+ SuggestionDashboard,
+ SuggestionListQuery
+} from '@nexumi/shared';
+import type { Suggestion } from '@prisma/client';
+import { prisma } from '../prisma';
+
+async function ensureSuggestionConfig(guildId: string) {
+ await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
+ return prisma.suggestionConfig.upsert({ where: { guildId }, update: {}, create: { guildId } });
+}
+
+function toConfigDashboard(
+ config: Awaited>
+): SuggestionConfigDashboard {
+ return {
+ enabled: config.enabled,
+ openChannelId: config.openChannelId ?? '',
+ approvedChannelId: config.approvedChannelId ?? '',
+ deniedChannelId: config.deniedChannelId ?? '',
+ createThread: config.createThread
+ };
+}
+
+export async function getSuggestionConfigDashboard(guildId: string): Promise {
+ const config = await ensureSuggestionConfig(guildId);
+ return toConfigDashboard(config);
+}
+
+export async function updateSuggestionConfigDashboard(
+ guildId: string,
+ patch: SuggestionConfigDashboardPatch
+): Promise {
+ await ensureSuggestionConfig(guildId);
+
+ const { openChannelId, approvedChannelId, deniedChannelId, ...rest } = patch;
+ const updated = await prisma.suggestionConfig.update({
+ where: { guildId },
+ data: {
+ ...rest,
+ ...(openChannelId !== undefined ? { openChannelId: openChannelId === '' ? null : openChannelId } : {}),
+ ...(approvedChannelId !== undefined
+ ? { approvedChannelId: approvedChannelId === '' ? null : approvedChannelId }
+ : {}),
+ ...(deniedChannelId !== undefined
+ ? { deniedChannelId: deniedChannelId === '' ? null : deniedChannelId }
+ : {})
+ }
+ });
+ return toConfigDashboard(updated);
+}
+
+function toSuggestionDashboard(suggestion: Suggestion): SuggestionDashboard {
+ return {
+ id: suggestion.id,
+ authorId: suggestion.authorId,
+ content: suggestion.content,
+ status: suggestion.status as SuggestionDashboard['status'],
+ upvotes: suggestion.upvotes,
+ downvotes: suggestion.downvotes,
+ staffReason: suggestion.staffReason,
+ createdAt: suggestion.createdAt.toISOString()
+ };
+}
+
+export async function listSuggestions(
+ guildId: string,
+ query: SuggestionListQuery
+): Promise {
+ const suggestions = await prisma.suggestion.findMany({
+ where: { guildId, ...(query.status ? { status: query.status } : {}) },
+ orderBy: { createdAt: 'desc' },
+ take: query.limit
+ });
+ return suggestions.map(toSuggestionDashboard);
+}
+
+export async function applySuggestionAction(
+ guildId: string,
+ suggestionId: string,
+ action: SuggestionAction
+): Promise {
+ const existing = await prisma.suggestion.findFirst({ where: { id: suggestionId, guildId } });
+ if (!existing) {
+ return null;
+ }
+
+ const updated = await prisma.suggestion.update({
+ where: { id: suggestionId },
+ data: {
+ status: action.status,
+ staffReason: action.staffReason ?? null
+ }
+ });
+ return toSuggestionDashboard(updated);
+}
diff --git a/apps/webui/src/lib/module-configs/tags.ts b/apps/webui/src/lib/module-configs/tags.ts
new file mode 100644
index 0000000..adb9d5d
--- /dev/null
+++ b/apps/webui/src/lib/module-configs/tags.ts
@@ -0,0 +1,90 @@
+import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate } from '@nexumi/shared';
+import type { Tag } from '@prisma/client';
+import { prisma } from '../prisma';
+
+export class TagConflictError extends Error {
+ constructor() {
+ super('A tag with this name already exists');
+ }
+}
+
+function toDashboard(tag: Tag): TagDashboard {
+ return {
+ id: tag.id,
+ name: tag.name,
+ content: tag.content,
+ responseType: tag.responseType as TagDashboard['responseType'],
+ triggerWord: tag.triggerWord,
+ allowedRoleIds: tag.allowedRoleIds,
+ allowedChannelIds: tag.allowedChannelIds
+ };
+}
+
+export async function listTags(guildId: string): Promise {
+ const tags = await prisma.tag.findMany({ where: { guildId }, orderBy: { name: 'asc' } });
+ return tags.map(toDashboard);
+}
+
+export async function createTag(
+ guildId: string,
+ createdById: string,
+ input: TagDashboardCreate
+): Promise {
+ await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
+ try {
+ const tag = await prisma.tag.create({
+ data: {
+ guildId,
+ name: input.name,
+ content: input.content ?? null,
+ responseType: input.responseType,
+ triggerWord: input.triggerWord ?? null,
+ allowedRoleIds: input.allowedRoleIds,
+ allowedChannelIds: input.allowedChannelIds,
+ createdById
+ }
+ });
+ return toDashboard(tag);
+ } catch (error) {
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
+ throw new TagConflictError();
+ }
+ throw error;
+ }
+}
+
+export async function updateTag(
+ guildId: string,
+ tagId: string,
+ patch: TagDashboardUpdate
+): Promise {
+ const existing = await prisma.tag.findFirst({ where: { id: tagId, guildId } });
+ if (!existing) {
+ return null;
+ }
+
+ try {
+ const updated = await prisma.tag.update({
+ where: { id: tagId },
+ data: {
+ ...(patch.name !== undefined ? { name: patch.name } : {}),
+ ...(patch.content !== undefined ? { content: patch.content } : {}),
+ ...(patch.responseType !== undefined ? { responseType: patch.responseType } : {}),
+ ...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}),
+ ...(patch.allowedRoleIds !== undefined ? { allowedRoleIds: patch.allowedRoleIds } : {}),
+ ...(patch.allowedChannelIds !== undefined ? { allowedChannelIds: patch.allowedChannelIds } : {})
+ }
+ });
+ return toDashboard(updated);
+ } catch (error) {
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
+ throw new TagConflictError();
+ }
+ throw error;
+ }
+}
+
+export async function deleteTag(guildId: string, tagId: string): Promise {
+ const result = await prisma.tag.deleteMany({ where: { id: tagId, guildId } });
+ return result.count > 0;
+}