Refactor giveaway job processing and enhance environment documentation

- Improved handling of the `giveawayCreate` job in the bot's job processing for better consistency.
- Updated `.env.example` to clarify the usage of `BOT_TOKEN` for both the bot and WebUI.
- Added `bullmq` dependency to the WebUI package for enhanced job management capabilities.
This commit is contained in:
smueller
2026-07-22 15:31:03 +02:00
parent 429f974bfc
commit 387fbf11da
32 changed files with 2620 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function GiveawaysLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-40 rounded-lg" />
))}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.giveaways.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.giveaways.description')}</p>
</div>
<GiveawaysManager guildId={guildId} initialGiveaways={giveaways} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function SelfRolesLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-40 rounded-lg" />
))}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.selfroles.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.selfroles.description')}</p>
</div>
<SelfRolesManager guildId={guildId} initialPanels={panels} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function TagsLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-40 rounded-lg" />
))}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.tags.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.tags.description')}</p>
</div>
<TagsManager guildId={guildId} initialTags={tags} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function TicketsLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-40 rounded-lg" />
))}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.tickets.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.tickets.description')}</p>
</div>
<TicketConfigForm guildId={guildId} initialValue={config} />
<TicketCategoriesManager guildId={guildId} initialCategories={categories} />
</div>
);
}

View File

@@ -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<GiveawayDashboard[]>(initialGiveaways);
const [draft, setDraft] = useState(EMPTY_DRAFT);
const [creating, setCreating] = useState(false);
const [pendingId, setPendingId] = useState<string | null>(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 (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.giveaways.createTitle')}</CardTitle>
<CardDescription>{t('modulePages.giveaways.createDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.giveaways.channelId')}</Label>
<Input
value={draft.channelId}
onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.giveaways.prize')}</Label>
<Input value={draft.prize} onChange={(event) => setDraft((prev) => ({ ...prev, prize: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.giveaways.winnerCount')}</Label>
<Input
type="number"
min={1}
value={draft.winnerCount}
onChange={(event) => setDraft((prev) => ({ ...prev, winnerCount: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.giveaways.endsAt')}</Label>
<Input
type="datetime-local"
value={draft.endsAt}
onChange={(event) => setDraft((prev) => ({ ...prev, endsAt: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.giveaways.requiredRoleId')}</Label>
<Input
value={draft.requiredRoleId}
onChange={(event) => setDraft((prev) => ({ ...prev, requiredRoleId: event.target.value }))}
placeholder={t('common.optional')}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.giveaways.requiredLevel')}</Label>
<Input
type="number"
min={0}
value={draft.requiredLevel}
onChange={(event) => setDraft((prev) => ({ ...prev, requiredLevel: event.target.value }))}
placeholder={t('common.optional')}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.giveaways.requiredMemberDays')}</Label>
<Input
type="number"
min={0}
value={draft.requiredMemberDays}
onChange={(event) => setDraft((prev) => ({ ...prev, requiredMemberDays: event.target.value }))}
placeholder={t('common.optional')}
/>
</div>
</div>
<Button type="button" onClick={handleCreate} disabled={creating}>
<Plus className="size-4" />
{creating ? t('common.saving') : t('modulePages.giveaways.create')}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.giveaways.listTitle')}</CardTitle>
<CardDescription>{t('modulePages.giveaways.listDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{giveaways.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.giveaways.empty')}</p>
)}
{giveaways.map((giveaway) => {
const ended = Boolean(giveaway.endedAt);
return (
<div key={giveaway.id} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="font-medium">{giveaway.prize}</p>
<p className="text-xs text-muted-foreground">
#{giveaway.channelId} · {t('modulePages.giveaways.winnerCount')}: {giveaway.winnerCount} ·{' '}
{t('modulePages.giveaways.entrants')}: {giveaway.entrants.length}
</p>
</div>
<div className="flex items-center gap-2">
{ended ? (
<Badge variant="secondary">{t('modulePages.giveaways.statusEnded')}</Badge>
) : giveaway.paused ? (
<Badge variant="secondary">{t('modulePages.giveaways.statusPaused')}</Badge>
) : (
<Badge variant="success">{t('modulePages.giveaways.statusActive')}</Badge>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
{ended
? `${t('modulePages.giveaways.endedAt')}: ${formatDate(giveaway.endedAt!)}`
: `${t('modulePages.giveaways.endsAt')}: ${formatDate(giveaway.endsAt)}`}
</p>
{ended && giveaway.winners.length > 0 && (
<p className="text-xs text-muted-foreground">
{t('modulePages.giveaways.winners')}: {giveaway.winners.map((id) => `<@${id}>`).join(', ')}
</p>
)}
{!ended && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={pendingId === giveaway.id}
onClick={() => handleAction(giveaway, giveaway.paused ? 'unpause' : 'pause')}
>
{giveaway.paused ? <Play className="size-4" /> : <Pause className="size-4" />}
{giveaway.paused ? t('modulePages.giveaways.unpause') : t('modulePages.giveaways.pause')}
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={pendingId === giveaway.id}
onClick={() => handleAction(giveaway, 'end')}
>
{t('modulePages.giveaways.end')}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={pendingId === giveaway.id}
onClick={() => handleDelete(giveaway)}
>
<Trash2 className="size-4" />
{t('common.remove')}
</Button>
</div>
)}
{ended && (
<div>
<Button
type="button"
size="sm"
variant="ghost"
disabled={pendingId === giveaway.id}
onClick={() => handleDelete(giveaway)}
>
<Trash2 className="size-4" />
{t('common.remove')}
</Button>
</div>
)}
</div>
);
})}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<LocalPanel[]>(
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 (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.selfroles.createTitle')}</CardTitle>
<CardDescription>{t('modulePages.selfroles.createDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.selfroles.channelId')}</Label>
<Input value={draft.channelId} onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))} placeholder="123456789012345678" />
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
<Input value={draft.title} onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.mode')}</Label>
<Select value={draft.mode} onValueChange={(mode) => setDraft((prev) => ({ ...prev, mode: mode as SelfRoleMode }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{t(`modulePages.selfroles.modes.${mode}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.behavior')}</Label>
<Select value={draft.behavior} onValueChange={(behavior) => setDraft((prev) => ({ ...prev, behavior: behavior as SelfRoleBehavior }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BEHAVIORS.map((behavior) => (
<SelectItem key={behavior} value={behavior}>
{t(`modulePages.selfroles.behaviors.${behavior}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.description')}</Label>
<Input value={draft.description} onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.roles')}</Label>
<Textarea
rows={5}
value={draft.rolesText}
onChange={(event) => setDraft((prev) => ({ ...prev, rolesText: event.target.value }))}
placeholder={t('modulePages.selfroles.rolesPlaceholder')}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.selfroles.rolesHint')}</p>
</div>
<Button type="button" onClick={handleCreate} disabled={creating}>
<Plus className="size-4" />
{creating ? t('common.saving') : t('modulePages.selfroles.create')}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.selfroles.listTitle')}</CardTitle>
<CardDescription>{t('modulePages.selfroles.listDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{panels.length === 0 && <p className="text-sm text-muted-foreground">{t('modulePages.selfroles.empty')}</p>}
{panels.map((panel) => (
<div key={panel.clientKey} className="space-y-3 rounded-md border border-border p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.selfroles.channelId')}</Label>
<Input
value={panel.channelId}
onChange={(event) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId: event.target.value } : entry)))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
<Input
value={panel.title}
onChange={(event) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, title: event.target.value } : entry)))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.mode')}</Label>
<Select
value={panel.mode}
onValueChange={(mode) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, mode: mode as SelfRoleMode } : entry)))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{t(`modulePages.selfroles.modes.${mode}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.behavior')}</Label>
<Select
value={panel.behavior}
onValueChange={(behavior) =>
setPanels((prev) =>
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, behavior: behavior as SelfRoleBehavior } : entry))
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BEHAVIORS.map((behavior) => (
<SelectItem key={behavior} value={behavior}>
{t(`modulePages.selfroles.behaviors.${behavior}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.roles')}</Label>
<Textarea
rows={4}
value={panel.rolesText}
onChange={(event) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, rolesText: event.target.value } : entry)))
}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" disabled={panel.saving} onClick={() => handleSave(panel)}>
{panel.saving ? t('common.saving') : t('common.save')}
</Button>
<Button type="button" variant="ghost" size="icon" aria-label={t('common.remove')} onClick={() => handleDelete(panel)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<LocalTag[]>(
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 (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.tags.createTitle')}</CardTitle>
<CardDescription>{t('modulePages.tags.createDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.tags.name')}</Label>
<Input value={draft.name} onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.responseType')}</Label>
<Select value={draft.responseType} onValueChange={(value) => setDraft((prev) => ({ ...prev, responseType: value as TagResponseType }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RESPONSE_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(`modulePages.tags.responseTypes.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.triggerWord')}</Label>
<Input value={draft.triggerWord} onChange={(event) => setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} />
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.content')}</Label>
<Textarea rows={3} value={draft.content} onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.tags.allowedRoleIds')}</Label>
<Input value={draft.allowedRoleIdsText} onChange={(event) => setDraft((prev) => ({ ...prev, allowedRoleIdsText: event.target.value }))} placeholder={t('modulePages.tags.idsPlaceholder')} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.allowedChannelIds')}</Label>
<Input value={draft.allowedChannelIdsText} onChange={(event) => setDraft((prev) => ({ ...prev, allowedChannelIdsText: event.target.value }))} placeholder={t('modulePages.tags.idsPlaceholder')} />
</div>
</div>
<Button type="button" onClick={handleCreate} disabled={creating}>
<Plus className="size-4" />
{creating ? t('common.saving') : t('modulePages.tags.create')}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.tags.listTitle')}</CardTitle>
<CardDescription>{t('modulePages.tags.listDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{tags.length === 0 && <p className="text-sm text-muted-foreground">{t('modulePages.tags.empty')}</p>}
{tags.map((tag) => (
<div key={tag.clientKey} className="space-y-3 rounded-md border border-border p-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-2">
<Label>{t('modulePages.tags.name')}</Label>
<Input value={tag.name} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, name: event.target.value } : entry)))} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.responseType')}</Label>
<Select value={tag.responseType} onValueChange={(value) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, responseType: value as TagResponseType } : entry)))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RESPONSE_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(`modulePages.tags.responseTypes.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.triggerWord')}</Label>
<Input value={tag.triggerWord ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} />
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.content')}</Label>
<Textarea rows={3} value={tag.content ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content: event.target.value } : entry)))} />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.tags.allowedRoleIds')}</Label>
<Input value={tag.allowedRoleIdsText} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, allowedRoleIdsText: event.target.value } : entry)))} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.allowedChannelIds')}</Label>
<Input value={tag.allowedChannelIdsText} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, allowedChannelIdsText: event.target.value } : entry)))} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" disabled={tag.saving} onClick={() => handleSave(tag)}>
{tag.saving ? t('common.saving') : t('common.save')}
</Button>
<Button type="button" variant="ghost" size="icon" aria-label={t('common.remove')} onClick={() => handleDelete(tag)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<LocalCategory[]>(
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 (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.tickets.categoriesTitle')}</CardTitle>
<CardDescription>{t('modulePages.tickets.categoriesDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{categories.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.tickets.categoriesEmpty')}</p>
)}
{categories.map((category) => (
<div key={category.clientKey} className="space-y-3 rounded-md border border-border p-4">
<div className="grid gap-3 sm:grid-cols-4">
<div className="space-y-2">
<Label>{t('modulePages.tickets.categoryName')}</Label>
<Input
value={category.name}
onChange={(event) =>
setCategories((prev) =>
prev.map((entry) =>
entry.clientKey === category.clientKey ? { ...entry, name: event.target.value } : entry
)
)
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.tickets.categoryEmoji')}</Label>
<Input
value={category.emoji ?? ''}
onChange={(event) =>
setCategories((prev) =>
prev.map((entry) =>
entry.clientKey === category.clientKey ? { ...entry, emoji: event.target.value } : entry
)
)
}
/>
</div>
<div className="col-span-2 space-y-2">
<Label>{t('modulePages.tickets.categoryDescription')}</Label>
<Input
value={category.description ?? ''}
onChange={(event) =>
setCategories((prev) =>
prev.map((entry) =>
entry.clientKey === category.clientKey
? { ...entry, description: event.target.value }
: entry
)
)
}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.tickets.supportRoleIds')}</Label>
<Input
value={toCsv(category.supportRoleIds)}
onChange={(event) =>
setCategories((prev) =>
prev.map((entry) =>
entry.clientKey === category.clientKey
? { ...entry, supportRoleIds: fromCsv(event.target.value) }
: entry
)
)
}
placeholder={t('modulePages.tickets.idsPlaceholder')}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handleSaveRow(category)} disabled={category.saving}>
{category.saving ? t('common.saving') : t('common.save')}
</Button>
<Button type="button" variant="ghost" size="icon" aria-label={t('common.remove')} onClick={() => handleDelete(category)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
<div className="space-y-3 rounded-md border border-dashed border-border p-4">
<p className="text-sm font-medium">{t('modulePages.tickets.addCategory')}</p>
<div className="grid gap-3 sm:grid-cols-4">
<div className="space-y-2">
<Label>{t('modulePages.tickets.categoryName')}</Label>
<Input value={draft.name} onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.tickets.categoryEmoji')}</Label>
<Input value={draft.emoji} onChange={(event) => setDraft((prev) => ({ ...prev, emoji: event.target.value }))} />
</div>
<div className="col-span-2 space-y-2">
<Label>{t('modulePages.tickets.categoryDescription')}</Label>
<Input
value={draft.description}
onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.tickets.supportRoleIds')}</Label>
<Input
value={draft.supportRoleIdsText}
onChange={(event) => setDraft((prev) => ({ ...prev, supportRoleIdsText: event.target.value }))}
placeholder={t('modulePages.tickets.idsPlaceholder')}
/>
</div>
<Button type="button" variant="outline" onClick={handleCreate} disabled={creating}>
<Plus className="size-4" />
{t('modulePages.tickets.addCategory')}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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<SettingsSaveResult> {
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 (
<SettingsForm<TicketConfigDashboard>
initialValue={initialValue}
onSave={(value) => saveTicketConfig(guildId, value)}
>
{({ value, setValue }) => (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.tickets.title')}</CardTitle>
<CardDescription>{t('modulePages.tickets.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.tickets.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.tickets.mode')}</Label>
<Select value={value.mode} onValueChange={(mode) => setValue((prev) => ({ ...prev, mode: mode as TicketConfigDashboard['mode'] }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CHANNEL">{t('modulePages.tickets.mode.CHANNEL')}</SelectItem>
<SelectItem value="THREAD">{t('modulePages.tickets.mode.THREAD')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={categoryId}>{t('modulePages.tickets.categoryId')}</Label>
<Input
id={categoryId}
value={value.categoryId}
onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-2">
<Label htmlFor={logChannelId}>{t('modulePages.tickets.logChannelId')}</Label>
<Input
id={logChannelId}
value={value.logChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, logChannelId: event.target.value.trim() }))}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-2">
<Label htmlFor={inactivityId}>{t('modulePages.tickets.inactivityHours')}</Label>
<Input
id={inactivityId}
type="number"
min={1}
value={value.inactivityHours}
onChange={(event) =>
setValue((prev) => ({ ...prev, inactivityHours: Number(event.target.value) || 1 }))
}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5">
<Label>{t('modulePages.tickets.transcriptDm')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.tickets.transcriptDmHint')}</p>
</div>
<Switch
checked={value.transcriptDm}
onCheckedChange={(transcriptDm) => setValue((prev) => ({ ...prev, transcriptDm }))}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.tickets.ratingEnabled')}</Label>
<Switch
checked={value.ratingEnabled}
onCheckedChange={(ratingEnabled) => setValue((prev) => ({ ...prev, ratingEnabled }))}
/>
</div>
</CardContent>
</Card>
)}
</SettingsForm>
);
}

View File

@@ -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<SocialFeedDashboard[]> {
const feeds = await prisma.socialFeed.findMany({ where: { guildId }, orderBy: { createdAt: 'asc' } });
return feeds.map(toDashboard);
}
export async function createSocialFeed(
guildId: string,
input: SocialFeedDashboardCreate
): Promise<SocialFeedDashboard> {
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<SocialFeedDashboard | null> {
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<boolean> {
const result = await prisma.socialFeed.deleteMany({ where: { id: feedId, guildId } });
return result.count > 0;
}

View File

@@ -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<GiveawayDashboard[]> {
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<GiveawayDashboard> {
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<GiveawayDashboard | null> {
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<GiveawayDashboard | null> {
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<boolean> {
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;
}

View File

@@ -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<ScheduledMessageDashboard[]> {
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<ScheduledMessageDashboard> {
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<boolean> {
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;
}

View File

@@ -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<SelfRolePanelDashboard[]> {
const panels = await prisma.selfRolePanel.findMany({
where: { guildId },
orderBy: { createdAt: 'desc' }
});
return panels.map(toDashboard);
}
export async function createSelfRolePanel(
guildId: string,
input: SelfRolePanelDashboardCreate
): Promise<SelfRolePanelDashboard> {
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<SelfRolePanelDashboard | null> {
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<boolean> {
const result = await prisma.selfRolePanel.deleteMany({ where: { id: panelId, guildId } });
return result.count > 0;
}

View File

@@ -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<ReturnType<typeof ensureSuggestionConfig>>
): SuggestionConfigDashboard {
return {
enabled: config.enabled,
openChannelId: config.openChannelId ?? '',
approvedChannelId: config.approvedChannelId ?? '',
deniedChannelId: config.deniedChannelId ?? '',
createThread: config.createThread
};
}
export async function getSuggestionConfigDashboard(guildId: string): Promise<SuggestionConfigDashboard> {
const config = await ensureSuggestionConfig(guildId);
return toConfigDashboard(config);
}
export async function updateSuggestionConfigDashboard(
guildId: string,
patch: SuggestionConfigDashboardPatch
): Promise<SuggestionConfigDashboard> {
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<SuggestionDashboard[]> {
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<SuggestionDashboard | null> {
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);
}

View File

@@ -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<TagDashboard[]> {
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<TagDashboard> {
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<TagDashboard | null> {
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<boolean> {
const result = await prisma.tag.deleteMany({ where: { id: tagId, guildId } });
return result.count > 0;
}