From ba2d14403881540d577040b8cad90affb9103bd5 Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Sat, 25 Jul 2026 15:43:55 +0200 Subject: [PATCH] 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. --- apps/bot/src/jobs.ts | 63 +++++- apps/bot/src/modules/selfroles/index.ts | 6 +- apps/bot/src/modules/selfroles/service.ts | 186 ++++++++++++++---- apps/bot/src/queues.ts | 2 + .../[guildId]/selfroles/[panelId]/route.ts | 12 +- .../api/guilds/[guildId]/selfroles/route.ts | 9 +- .../webui/src/lib/module-configs/selfroles.ts | 177 ++++++++++++++--- apps/webui/src/lib/queues.ts | 17 ++ docs/PHASE-TRACKING.md | 18 ++ 9 files changed, 423 insertions(+), 67 deletions(-) diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 9b06a38..0e9842f 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -12,7 +12,13 @@ import { activateLockdown, deactivateLockdown } from './modules/automod/actions. import { completeVerification } from './modules/verification/handlers.js'; import { closePoll } from './modules/utility/poll.js'; import { sendReminder } from './modules/utility/reminders.js'; -import { expireSelfRole } from './modules/selfroles/service.js'; +import { + expireSelfRole, + runSelfRolePanelCreateJob, + runSelfRolePanelUpdateJob, + runSelfRolePanelDeleteJob +} from './modules/selfroles/index.js'; +import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode } from '@nexumi/shared'; import { endGiveaway, runGiveawayCreateJob, @@ -48,6 +54,7 @@ import { retentionQueue, retentionQueueName, scheduleQueueName, + selfrolesQueueName, suggestionsQueueName, ticketQueue, ticketQueueName, @@ -132,6 +139,36 @@ type SuggestionStatusUpdateJob = { reason: string; }; +type SelfRolePanelCreateJob = { + guildId: string; + channelId: string; + title: string; + description?: string | null; + mode: SelfRoleMode; + behavior: SelfRoleBehavior; + roles: SelfRoleEntry[]; + temporaryDurationMs?: number; + expiresAt?: string | null; +}; + +type SelfRolePanelUpdateJob = { + panelId: string; + guildId: string; + channelId?: string; + title?: string; + description?: string | null; + mode?: SelfRoleMode; + behavior?: SelfRoleBehavior; + roles?: SelfRoleEntry[]; + temporaryDurationMs?: number; + expiresAt?: string | null; +}; + +type SelfRolePanelDeleteJob = { + panelId: string; + guildId: string; +}; + export function startWorkers(context: BotContext): Worker[] { const moderationWorker = new Worker( moderationQueueName, @@ -316,6 +353,29 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Ticket queue job failed'); }); + const selfrolesWorker = new Worker< + SelfRolePanelCreateJob | SelfRolePanelUpdateJob | SelfRolePanelDeleteJob + >( + selfrolesQueueName, + async (job) => { + if (job.name === 'selfRolePanelCreate') { + return runSelfRolePanelCreateJob(context, job.data as SelfRolePanelCreateJob); + } + if (job.name === 'selfRolePanelUpdate') { + return runSelfRolePanelUpdateJob(context, job.data as SelfRolePanelUpdateJob); + } + if (job.name === 'selfRolePanelDelete') { + const data = job.data as SelfRolePanelDeleteJob; + await runSelfRolePanelDeleteJob(context, data.panelId, data.guildId); + } + }, + { connection: redis } + ); + + selfrolesWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Self-roles queue job failed'); + }); + const giveawayWorker = new Worker< GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob >( @@ -489,6 +549,7 @@ export function startWorkers(context: BotContext): Worker[] { verificationWorker, reminderWorker, ticketWorker, + selfrolesWorker, giveawayWorker, birthdayWorker, statsWorker, diff --git a/apps/bot/src/modules/selfroles/index.ts b/apps/bot/src/modules/selfroles/index.ts index e40783c..4e0ff87 100644 --- a/apps/bot/src/modules/selfroles/index.ts +++ b/apps/bot/src/modules/selfroles/index.ts @@ -4,5 +4,9 @@ export { registerSelfRoleEvents } from './reactions.js'; export { expireSelfRole, scheduleSelfRoleExpiry, - cancelSelfRoleExpiry + cancelSelfRoleExpiry, + runSelfRolePanelCreateJob, + runSelfRolePanelUpdateJob, + runSelfRolePanelDeleteJob, + SelfRoleError } from './service.js'; diff --git a/apps/bot/src/modules/selfroles/service.ts b/apps/bot/src/modules/selfroles/service.ts index 8eca792..b786b1e 100644 --- a/apps/bot/src/modules/selfroles/service.ts +++ b/apps/bot/src/modules/selfroles/service.ts @@ -21,6 +21,7 @@ import { } from '@nexumi/shared'; import type { SelfRolePanel } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; +import { getGuildLocale } from '../../i18n.js'; import { logger } from '../../logger.js'; import { safeRemoveBullJob } from '../../lib/safe-remove-job.js'; import { ticketQueue } from '../../queues.js'; @@ -422,6 +423,69 @@ export async function findPanelByMessageId( }); } +async function postOrEditPanelMessage( + context: BotContext, + panel: SelfRolePanel, + locale: 'de' | 'en', + previous?: { channelId: string; messageId: string | null } +): Promise { + const mode = SelfRoleModeSchema.parse(panel.mode); + const { entries } = parseRolesJson(panel.roles); + const channelChanged = + previous !== undefined && previous.channelId !== panel.channelId; + + if (panel.messageId && !channelChanged) { + try { + const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel; + const message = await channel.messages.fetch(panel.messageId); + await message.edit({ + embeds: [buildPanelEmbed(panel, locale)], + components: buildPanelComponents(panel) + }); + + if (mode === 'REACTIONS') { + for (const reaction of message.reactions.cache.values()) { + await reaction.remove().catch(() => undefined); + } + await applyReactionEmojis(message, entries); + } + + return panel; + } catch (error) { + logger.warn({ error, panelId: panel.id }, 'Failed to edit self-role panel message; recreating'); + } + } + + if (previous?.messageId && channelChanged) { + try { + const oldChannel = (await context.client.channels.fetch(previous.channelId)) as TextChannel; + const oldMessage = await oldChannel.messages.fetch(previous.messageId); + await oldMessage.delete(); + } catch { + // Previous message may already be gone + } + } + + const channel = await context.client.channels.fetch(panel.channelId); + if (!channel || !channel.isTextBased() || channel.isDMBased()) { + throw new SelfRoleError('invalid_channel'); + } + + const message = await channel.send({ + embeds: [buildPanelEmbed(panel, locale)], + components: buildPanelComponents(panel) + }); + + if (mode === 'REACTIONS') { + await applyReactionEmojis(message, entries); + } + + return context.prisma.selfRolePanel.update({ + where: { id: panel.id }, + data: { messageId: message.id } + }); +} + export async function createSelfRolePanel( context: BotContext, params: { @@ -433,6 +497,7 @@ export async function createSelfRolePanel( behavior: SelfRoleBehavior; roles: SelfRoleEntry[]; temporaryDurationMs?: number; + expiresAt?: Date | null; }, locale: 'de' | 'en' ): Promise { @@ -457,24 +522,12 @@ export async function createSelfRolePanel( description: params.description ?? null, mode: params.mode, behavior: params.behavior, - roles: serializeRolesJson(params.roles, params.temporaryDurationMs) + roles: serializeRolesJson(params.roles, params.temporaryDurationMs), + expiresAt: params.expiresAt ?? null } }); - const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel; - const message = await channel.send({ - embeds: [buildPanelEmbed(panel, locale)], - components: buildPanelComponents(panel) - }); - - if (params.mode === 'REACTIONS') { - await applyReactionEmojis(message, params.roles); - } - - return context.prisma.selfRolePanel.update({ - where: { id: panel.id }, - data: { messageId: message.id } - }); + return postOrEditPanelMessage(context, panel, locale); } export async function updateSelfRolePanel( @@ -482,12 +535,14 @@ export async function updateSelfRolePanel( panelId: string, guildId: string, updates: { + channelId?: string; title?: string; description?: string | null; mode?: SelfRoleMode; behavior?: SelfRoleBehavior; roles?: SelfRoleEntry[]; temporaryDurationMs?: number; + expiresAt?: Date | null; }, locale: 'de' | 'en' ): Promise { @@ -513,39 +568,100 @@ export async function updateSelfRolePanel( throw new SelfRoleError('reaction_emoji_required'); } + const previous = { channelId: existing.channelId, messageId: existing.messageId }; const panel = await context.prisma.selfRolePanel.update({ where: { id: panelId }, data: { + channelId: updates.channelId ?? existing.channelId, title: updates.title ?? existing.title, description: updates.description !== undefined ? updates.description : existing.description, mode, behavior, - roles: serializeRolesJson(roles, temporaryDurationMs) + roles: serializeRolesJson(roles, temporaryDurationMs), + ...(updates.expiresAt !== undefined ? { expiresAt: updates.expiresAt } : {}) } }); - if (panel.messageId) { - try { - const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel; - const message = await channel.messages.fetch(panel.messageId); - await message.edit({ - embeds: [buildPanelEmbed(panel, locale)], - components: buildPanelComponents(panel) - }); + return postOrEditPanelMessage(context, panel, locale, previous); +} - if (mode === 'REACTIONS') { - const existingReactions = message.reactions.cache; - for (const reaction of existingReactions.values()) { - await reaction.remove().catch(() => undefined); - } - await applyReactionEmojis(message, roles); - } - } catch (error) { - logger.warn({ error, panelId }, 'Failed to update self-role panel message'); - } +/** + * BullMQ entry points for dashboard create/update/delete. The WebUI has no + * discord.js Client, so panel posting is delegated here (same pattern as + * giveaways / dashboard messages). + */ +export async function runSelfRolePanelCreateJob( + context: BotContext, + params: { + guildId: string; + channelId: string; + title: string; + description?: string | null; + mode: SelfRoleMode; + behavior: SelfRoleBehavior; + roles: SelfRoleEntry[]; + temporaryDurationMs?: number; + expiresAt?: string | null; } +): Promise<{ id: string }> { + const locale = await getGuildLocale(context.prisma, params.guildId); + const panel = await createSelfRolePanel( + context, + { + ...params, + expiresAt: params.expiresAt ? new Date(params.expiresAt) : null + }, + locale + ); + return { id: panel.id }; +} - return panel; +export async function runSelfRolePanelUpdateJob( + context: BotContext, + params: { + panelId: string; + guildId: string; + channelId?: string; + title?: string; + description?: string | null; + mode?: SelfRoleMode; + behavior?: SelfRoleBehavior; + roles?: SelfRoleEntry[]; + temporaryDurationMs?: number; + expiresAt?: string | null; + } +): Promise<{ id: string }> { + const locale = await getGuildLocale(context.prisma, params.guildId); + const panel = await updateSelfRolePanel( + context, + params.panelId, + params.guildId, + { + channelId: params.channelId, + title: params.title, + description: params.description, + mode: params.mode, + behavior: params.behavior, + roles: params.roles, + temporaryDurationMs: params.temporaryDurationMs, + expiresAt: + params.expiresAt === undefined + ? undefined + : params.expiresAt + ? new Date(params.expiresAt) + : null + }, + locale + ); + return { id: panel.id }; +} + +export async function runSelfRolePanelDeleteJob( + context: BotContext, + panelId: string, + guildId: string +): Promise { + await deleteSelfRolePanel(context, panelId, guildId); } export async function deleteSelfRolePanel( diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index a2f1535..e08d0aa 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -15,6 +15,8 @@ export const giveawayQueueName = 'giveaways'; export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis }); export const ticketQueueName = 'tickets'; export const ticketQueue = new Queue(ticketQueueName, { connection: redis }); +export const selfrolesQueueName = 'selfroles'; +export const selfrolesQueue = new Queue(selfrolesQueueName, { connection: redis }); export const birthdayQueueName = 'birthdays'; export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis }); export const statsQueueName = 'stats'; diff --git a/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts index 7d7fc52..4a92a6b 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/selfroles/[panelId]/route.ts @@ -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); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts b/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts index 62fdc23..ceb28f7 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/selfroles/route.ts @@ -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); } } diff --git a/apps/webui/src/lib/module-configs/selfroles.ts b/apps/webui/src/lib/module-configs/selfroles.ts index c912416..69e08fb 100644 --- a/apps/webui/src/lib/module-configs/selfroles.ts +++ b/apps/webui/src/lib/module-configs/selfroles.ts @@ -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 { const panels = await prisma.selfRolePanel.findMany({ where: { guildId }, @@ -38,23 +59,57 @@ export async function listSelfRolePanels(guildId: string): Promise { 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 { - 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; } diff --git a/apps/webui/src/lib/queues.ts b/apps/webui/src/lib/queues.ts index 9eefce8..22eaa42 100644 --- a/apps/webui/src/lib/queues.ts +++ b/apps/webui/src/lib/queues.ts @@ -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; /** diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 0fe1f25..84f0154 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -426,6 +426,24 @@ - [ ] Cases: Grund editieren / löschen; Warnings entfernen - [ ] `/lock server:true` und `/unlock server:true` +## Post-Phase – Selfroles WebUI → Discord Sync (Status: implementiert) + +### Abgeschlossen (Code) + +- Root cause: Dashboard speicherte Selfrole-Panels nur in Postgres, ohne BullMQ-Job an den Bot +- Neue Queue `selfroles` mit Jobs `selfRolePanelCreate` / `Update` / `Delete` +- WebUI wartet (bounded) auf Bot-Bestätigung; Panel hat danach `messageId` +- Update zieht fehlende Discord-Nachricht nach (auch für Alt-Panels ohne `messageId`) +- Roles-JSON `{ entries }` wird im Dashboard korrekt gelesen + +### Manuell testen + +- [ ] WebUI: Panel erstellen → Embed/Buttons erscheinen im gewählten Channel +- [ ] Panel editieren (Titel/Rollen) → Discord-Nachricht aktualisiert sich +- [ ] Channel wechseln → alte Nachricht weg, neue im Zielkanal +- [ ] Panel löschen → Discord-Nachricht entfernt +- [ ] Alt-Panel ohne Message speichern → Nachricht wird nachgezogen + ## Nächster geplanter Schritt - Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).