Implement self-role panel management with BullMQ integration
- Introduced a new `selfroles` queue to handle self-role panel creation, updates, and deletions via BullMQ jobs. - Added types for self-role panel jobs, enhancing type safety and clarity in job handling. - Implemented functions to create, update, and delete self-role panels, ensuring synchronization between the WebUI and Discord. - Enhanced error handling for self-role panel operations, providing better feedback in case of failures. - Updated the WebUI API to support self-role panel management, including error responses for synchronization issues.
This commit is contained in:
@@ -2,7 +2,11 @@ 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 {
|
||||
deleteSelfRolePanel,
|
||||
SelfRolePanelSyncError,
|
||||
updateSelfRolePanel
|
||||
} from '@/lib/module-configs/selfroles';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -31,6 +35,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof SelfRolePanelSyncError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +61,9 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof SelfRolePanelSyncError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ 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 {
|
||||
createSelfRolePanel,
|
||||
listSelfRolePanels,
|
||||
SelfRolePanelSyncError
|
||||
} from '@/lib/module-configs/selfroles';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -39,6 +43,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json(panel, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof SelfRolePanelSyncError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,30 @@ import type {
|
||||
SelfRolePanelDashboardCreate,
|
||||
SelfRolePanelDashboardUpdate
|
||||
} from '@nexumi/shared';
|
||||
import type { Prisma, SelfRolePanel } from '@prisma/client';
|
||||
import type { SelfRolePanel } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
import { addJobAndAwait, getSelfrolesQueue, getSelfrolesQueueEvents } from '../queues';
|
||||
|
||||
export class SelfRolePanelSyncError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SelfRolePanelSyncError';
|
||||
}
|
||||
}
|
||||
|
||||
function toRolesArray(raw: unknown): SelfRoleEntry[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter(
|
||||
(entry): entry is SelfRoleEntry =>
|
||||
Boolean(entry) && typeof entry === 'object' && typeof (entry as SelfRoleEntry).roleId === 'string'
|
||||
);
|
||||
}
|
||||
return raw.filter(
|
||||
(entry): entry is SelfRoleEntry =>
|
||||
Boolean(entry) && typeof entry === 'object' && typeof (entry as SelfRoleEntry).roleId === 'string'
|
||||
);
|
||||
|
||||
if (raw && typeof raw === 'object' && Array.isArray((raw as { entries?: unknown }).entries)) {
|
||||
return toRolesArray((raw as { entries: unknown }).entries);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function toDashboard(panel: SelfRolePanel): SelfRolePanelDashboard {
|
||||
@@ -30,6 +43,14 @@ function toDashboard(panel: SelfRolePanel): SelfRolePanelDashboard {
|
||||
};
|
||||
}
|
||||
|
||||
function temporaryDurationFromExpiresAt(expiresAt: string | null | undefined): number | undefined {
|
||||
if (!expiresAt) {
|
||||
return undefined;
|
||||
}
|
||||
const ms = new Date(expiresAt).getTime() - Date.now();
|
||||
return ms > 0 ? ms : undefined;
|
||||
}
|
||||
|
||||
export async function listSelfRolePanels(guildId: string): Promise<SelfRolePanelDashboard[]> {
|
||||
const panels = await prisma.selfRolePanel.findMany({
|
||||
where: { guildId },
|
||||
@@ -38,23 +59,57 @@ export async function listSelfRolePanels(guildId: string): Promise<SelfRolePanel
|
||||
return panels.map(toDashboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard create has no discord.js Client, so panel posting is delegated to
|
||||
* the bot via the `selfroles` BullMQ queue (`selfRolePanelCreate`).
|
||||
*/
|
||||
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: {
|
||||
|
||||
const temporaryDurationMs =
|
||||
input.behavior === 'TEMPORARY' ? temporaryDurationFromExpiresAt(input.expiresAt) : undefined;
|
||||
if (input.behavior === 'TEMPORARY' && (!temporaryDurationMs || temporaryDurationMs < 60_000)) {
|
||||
throw new SelfRolePanelSyncError(
|
||||
'Temporary panels require expiresAt at least 1 minute in the future (role duration).'
|
||||
);
|
||||
}
|
||||
|
||||
const { confirmed, result } = await addJobAndAwait<{ id: string }>(
|
||||
getSelfrolesQueue(),
|
||||
getSelfrolesQueueEvents(),
|
||||
'selfRolePanelCreate',
|
||||
{
|
||||
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
|
||||
}
|
||||
});
|
||||
roles: input.roles,
|
||||
temporaryDurationMs,
|
||||
expiresAt: input.expiresAt ?? null
|
||||
},
|
||||
{ removeOnComplete: true, removeOnFail: 25 },
|
||||
30_000
|
||||
);
|
||||
|
||||
if (!confirmed || !result) {
|
||||
throw new SelfRolePanelSyncError(
|
||||
'The bot did not confirm the self-role panel in time. It may still be posting — check the channel shortly.'
|
||||
);
|
||||
}
|
||||
|
||||
const panel = await prisma.selfRolePanel.findUnique({ where: { id: result.id } });
|
||||
if (!panel?.messageId) {
|
||||
throw new SelfRolePanelSyncError(
|
||||
confirmed
|
||||
? 'Self-role panel job finished without posting a Discord message'
|
||||
: 'Self-role panel create timed out – the bot may still be processing; refresh and retry'
|
||||
);
|
||||
}
|
||||
return toDashboard(panel);
|
||||
}
|
||||
|
||||
@@ -68,24 +123,90 @@ export async function updateSelfRolePanel(
|
||||
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 }
|
||||
: {})
|
||||
const nextBehavior = patch.behavior ?? (existing.behavior as SelfRolePanelDashboard['behavior']);
|
||||
const temporaryDurationMs =
|
||||
nextBehavior === 'TEMPORARY'
|
||||
? temporaryDurationFromExpiresAt(
|
||||
patch.expiresAt !== undefined ? patch.expiresAt : existing.expiresAt?.toISOString()
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (nextBehavior === 'TEMPORARY' && patch.behavior === 'TEMPORARY') {
|
||||
const storedDuration =
|
||||
existing.roles &&
|
||||
typeof existing.roles === 'object' &&
|
||||
!Array.isArray(existing.roles) &&
|
||||
typeof (existing.roles as { temporaryDurationMs?: unknown }).temporaryDurationMs === 'number'
|
||||
? (existing.roles as { temporaryDurationMs: number }).temporaryDurationMs
|
||||
: undefined;
|
||||
if (!temporaryDurationMs && !storedDuration) {
|
||||
throw new SelfRolePanelSyncError(
|
||||
'Temporary panels require expiresAt at least 1 minute in the future (role duration).'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { confirmed } = await addJobAndAwait(
|
||||
getSelfrolesQueue(),
|
||||
getSelfrolesQueueEvents(),
|
||||
'selfRolePanelUpdate',
|
||||
{
|
||||
panelId,
|
||||
guildId,
|
||||
channelId: patch.channelId,
|
||||
title: patch.title,
|
||||
description: patch.description,
|
||||
mode: patch.mode,
|
||||
behavior: patch.behavior,
|
||||
roles: patch.roles,
|
||||
temporaryDurationMs,
|
||||
expiresAt: patch.expiresAt
|
||||
},
|
||||
{
|
||||
jobId: `selfrole-update-${panelId}-${Date.now()}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 25
|
||||
},
|
||||
30_000
|
||||
);
|
||||
|
||||
const updated = await prisma.selfRolePanel.findUnique({ where: { id: panelId } });
|
||||
if (!updated?.messageId) {
|
||||
throw new SelfRolePanelSyncError(
|
||||
confirmed
|
||||
? 'Self-role panel update finished without a Discord message'
|
||||
: 'Self-role panel update timed out – the bot may still be processing; refresh and retry'
|
||||
);
|
||||
}
|
||||
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;
|
||||
const existing = await prisma.selfRolePanel.findFirst({ where: { id: panelId, guildId } });
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { confirmed } = await addJobAndAwait(
|
||||
getSelfrolesQueue(),
|
||||
getSelfrolesQueueEvents(),
|
||||
'selfRolePanelDelete',
|
||||
{ panelId, guildId },
|
||||
{
|
||||
jobId: `selfrole-delete-${panelId}-${Date.now()}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 25
|
||||
},
|
||||
30_000
|
||||
);
|
||||
|
||||
const remaining = await prisma.selfRolePanel.findUnique({ where: { id: panelId } });
|
||||
if (remaining) {
|
||||
throw new SelfRolePanelSyncError(
|
||||
confirmed
|
||||
? 'Self-role panel delete finished without deleting the panel'
|
||||
: 'Self-role panel delete timed out – the bot may still be processing; refresh and retry'
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const messagesQueueName = 'messages';
|
||||
export const verificationQueueName = 'verification';
|
||||
export const automodQueueName = 'automod';
|
||||
export const presenceQueueName = 'presence';
|
||||
export const selfrolesQueueName = 'selfroles';
|
||||
|
||||
const globalForQueues = globalThis as unknown as {
|
||||
__nexumiGiveawayQueue?: Queue;
|
||||
@@ -32,10 +33,12 @@ const globalForQueues = globalThis as unknown as {
|
||||
__nexumiVerificationQueue?: Queue;
|
||||
__nexumiAutomodQueue?: Queue;
|
||||
__nexumiPresenceQueue?: Queue;
|
||||
__nexumiSelfrolesQueue?: Queue;
|
||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||
__nexumiMessagesQueueEvents?: QueueEvents;
|
||||
__nexumiSelfrolesQueueEvents?: QueueEvents;
|
||||
};
|
||||
|
||||
function queueConnection() {
|
||||
@@ -103,6 +106,13 @@ export function getPresenceQueue(): Queue {
|
||||
return globalForQueues.__nexumiPresenceQueue;
|
||||
}
|
||||
|
||||
export function getSelfrolesQueue(): Queue {
|
||||
globalForQueues.__nexumiSelfrolesQueue ??= new Queue(selfrolesQueueName, {
|
||||
connection: queueConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiSelfrolesQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||
@@ -136,6 +146,13 @@ export function getMessagesQueueEvents(): QueueEvents {
|
||||
return globalForQueues.__nexumiMessagesQueueEvents;
|
||||
}
|
||||
|
||||
export function getSelfrolesQueueEvents(): QueueEvents {
|
||||
globalForQueues.__nexumiSelfrolesQueueEvents ??= new QueueEvents(selfrolesQueueName, {
|
||||
connection: queueEventsConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiSelfrolesQueueEvents;
|
||||
}
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user