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:
46
apps/webui/src/app/api/guilds/[guildId]/birthdays/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/birthdays/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/webui/src/app/api/guilds/[guildId]/giveaways/route.ts
Normal file
47
apps/webui/src/app/api/guilds/[guildId]/giveaways/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts
Normal file
44
apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/starboard/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/starboard/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/webui/src/app/api/guilds/[guildId]/tags/route.ts
Normal file
47
apps/webui/src/app/api/guilds/[guildId]/tags/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/tempvoice/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/tempvoice/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts
Normal file
46
apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/giveaways/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/giveaways/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/giveaways/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/giveaways/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/selfroles/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/selfroles/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/selfroles/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/selfroles/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/tags/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/tags/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/webui/src/app/dashboard/[guildId]/tags/page.tsx
Normal file
22
apps/webui/src/app/dashboard/[guildId]/tags/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/webui/src/app/dashboard/[guildId]/tickets/loading.tsx
Normal file
15
apps/webui/src/app/dashboard/[guildId]/tickets/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx
Normal file
28
apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
apps/webui/src/components/modules/giveaways-manager.tsx
Normal file
286
apps/webui/src/components/modules/giveaways-manager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
318
apps/webui/src/components/modules/selfroles-manager.tsx
Normal file
318
apps/webui/src/components/modules/selfroles-manager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
apps/webui/src/components/modules/tags-manager.tsx
Normal file
261
apps/webui/src/components/modules/tags-manager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
apps/webui/src/components/modules/ticket-categories-manager.tsx
Normal file
239
apps/webui/src/components/modules/ticket-categories-manager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
apps/webui/src/components/modules/ticket-config-form.tsx
Normal file
133
apps/webui/src/components/modules/ticket-config-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/webui/src/lib/module-configs/feeds.ts
Normal file
65
apps/webui/src/lib/module-configs/feeds.ts
Normal 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;
|
||||||
|
}
|
||||||
141
apps/webui/src/lib/module-configs/giveaways.ts
Normal file
141
apps/webui/src/lib/module-configs/giveaways.ts
Normal 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;
|
||||||
|
}
|
||||||
111
apps/webui/src/lib/module-configs/scheduler.ts
Normal file
111
apps/webui/src/lib/module-configs/scheduler.ts
Normal 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;
|
||||||
|
}
|
||||||
91
apps/webui/src/lib/module-configs/selfroles.ts
Normal file
91
apps/webui/src/lib/module-configs/selfroles.ts
Normal 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;
|
||||||
|
}
|
||||||
99
apps/webui/src/lib/module-configs/suggestions.ts
Normal file
99
apps/webui/src/lib/module-configs/suggestions.ts
Normal 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);
|
||||||
|
}
|
||||||
90
apps/webui/src/lib/module-configs/tags.ts
Normal file
90
apps/webui/src/lib/module-configs/tags.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user