Refactor giveaway job processing and enhance environment documentation

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

View File

@@ -0,0 +1,65 @@
import type { SocialFeedDashboard, SocialFeedDashboardCreate, SocialFeedDashboardUpdate } from '@nexumi/shared';
import type { SocialFeed } from '@prisma/client';
import { prisma } from '../prisma';
function toDashboard(feed: SocialFeed): SocialFeedDashboard {
return {
id: feed.id,
type: feed.type as SocialFeedDashboard['type'],
sourceId: feed.sourceId,
channelId: feed.channelId,
rolePingId: feed.rolePingId ?? '',
template: feed.template,
enabled: feed.enabled
};
}
export async function listSocialFeeds(guildId: string): Promise<SocialFeedDashboard[]> {
const feeds = await prisma.socialFeed.findMany({ where: { guildId }, orderBy: { createdAt: 'asc' } });
return feeds.map(toDashboard);
}
export async function createSocialFeed(
guildId: string,
input: SocialFeedDashboardCreate
): Promise<SocialFeedDashboard> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
const feed = await prisma.socialFeed.create({
data: {
guildId,
type: input.type,
sourceId: input.sourceId,
channelId: input.channelId,
rolePingId: input.rolePingId ? input.rolePingId : null,
template: input.template ?? null,
enabled: input.enabled
}
});
return toDashboard(feed);
}
export async function updateSocialFeed(
guildId: string,
feedId: string,
patch: SocialFeedDashboardUpdate
): Promise<SocialFeedDashboard | null> {
const existing = await prisma.socialFeed.findFirst({ where: { id: feedId, guildId } });
if (!existing) {
return null;
}
const { rolePingId, ...rest } = patch;
const updated = await prisma.socialFeed.update({
where: { id: feedId },
data: {
...rest,
...(rolePingId !== undefined ? { rolePingId: rolePingId === '' ? null : rolePingId } : {})
}
});
return toDashboard(updated);
}
export async function deleteSocialFeed(guildId: string, feedId: string): Promise<boolean> {
const result = await prisma.socialFeed.deleteMany({ where: { id: feedId, guildId } });
return result.count > 0;
}

View File

@@ -0,0 +1,141 @@
import type { GiveawayCreateDashboard, GiveawayDashboard } from '@nexumi/shared';
import type { Giveaway } from '@prisma/client';
import { prisma } from '../prisma';
import { addJobAndAwait, giveawayQueue, giveawayQueueEvents } from '../queues';
export class GiveawayCreateTimeoutError extends Error {
constructor() {
super('The bot did not confirm the giveaway in time. It may still be starting - check the list shortly.');
}
}
function toDashboard(giveaway: Giveaway): GiveawayDashboard {
return {
id: giveaway.id,
guildId: giveaway.guildId,
channelId: giveaway.channelId,
messageId: giveaway.messageId,
prize: giveaway.prize,
winnerCount: giveaway.winnerCount,
endsAt: giveaway.endsAt.toISOString(),
requiredRoleId: giveaway.requiredRoleId,
requiredLevel: giveaway.requiredLevel,
requiredMemberDays: giveaway.requiredMemberDays,
paused: giveaway.paused,
endedAt: giveaway.endedAt?.toISOString() ?? null,
winners: giveaway.winners,
entrants: giveaway.entrants,
hostId: giveaway.hostId,
createdAt: giveaway.createdAt.toISOString()
};
}
export async function listGiveaways(guildId: string): Promise<GiveawayDashboard[]> {
const giveaways = await prisma.giveaway.findMany({
where: { guildId },
orderBy: [{ endedAt: 'asc' }, { endsAt: 'asc' }],
take: 50
});
return giveaways.map(toDashboard);
}
/**
* The WebUI has no discord.js Client to post the giveaway announcement
* message, so creation is delegated to the bot via the same `giveaways`
* BullMQ queue used by `/giveaway start` (see
* `apps/bot/src/modules/giveaways/service.ts#runGiveawayCreateJob`). We wait
* (bounded) for the job to finish so the dashboard can show the created
* giveaway immediately.
*/
export async function createGiveawayDashboard(
guildId: string,
hostId: string,
input: GiveawayCreateDashboard
): Promise<GiveawayDashboard> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
const durationMs = new Date(input.endsAt).getTime() - Date.now();
if (durationMs <= 0) {
throw new GiveawayCreateTimeoutError();
}
const { confirmed, result } = await addJobAndAwait<{ id: string }>(
giveawayQueue,
giveawayQueueEvents,
'giveawayCreate',
{
guildId,
channelId: input.channelId,
hostId,
prize: input.prize,
winnerCount: input.winnerCount,
durationMs,
requiredRoleId: input.requiredRoleId || null,
requiredLevel: input.requiredLevel ?? null,
requiredMemberDays: input.requiredMemberDays ?? null
},
{ removeOnComplete: true, removeOnFail: 25 }
);
if (!confirmed || !result) {
throw new GiveawayCreateTimeoutError();
}
const giveaway = await prisma.giveaway.findUnique({ where: { id: result.id } });
if (!giveaway) {
throw new GiveawayCreateTimeoutError();
}
return toDashboard(giveaway);
}
export async function setGiveawayPaused(
guildId: string,
giveawayId: string,
paused: boolean
): Promise<GiveawayDashboard | null> {
const existing = await prisma.giveaway.findFirst({ where: { id: giveawayId, guildId } });
if (!existing || existing.endedAt) {
return null;
}
const updated = await prisma.giveaway.update({ where: { id: giveawayId }, data: { paused } });
return toDashboard(updated);
}
/**
* Ends a giveaway immediately by re-scheduling its `giveawayEnd` job with no
* delay and waiting for the bot to draw winners, update the Discord message
* and DM them - see `apps/bot/src/modules/giveaways/service.ts#endGiveaway`.
*/
export async function endGiveawayDashboard(guildId: string, giveawayId: string): Promise<GiveawayDashboard | null> {
const existing = await prisma.giveaway.findFirst({ where: { id: giveawayId, guildId } });
if (!existing || existing.endedAt) {
return null;
}
const existingJob = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
await existingJob?.remove();
await addJobAndAwait(
giveawayQueue,
giveawayQueueEvents,
'giveawayEnd',
{ giveawayId },
{ jobId: `giveaway-end-${giveawayId}`, removeOnComplete: true, removeOnFail: 50, delay: 0 }
);
const updated = await prisma.giveaway.findUnique({ where: { id: giveawayId } });
return updated ? toDashboard(updated) : null;
}
export async function deleteGiveawayDashboard(guildId: string, giveawayId: string): Promise<boolean> {
const existing = await prisma.giveaway.findFirst({ where: { id: giveawayId, guildId } });
if (!existing) {
return false;
}
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
await job?.remove();
await prisma.giveaway.delete({ where: { id: giveawayId } });
return true;
}

View File

@@ -0,0 +1,111 @@
import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate } from '@nexumi/shared';
import type { ScheduledMessage } from '@prisma/client';
import { prisma } from '../prisma';
import { scheduleQueue } from '../queues';
export class SchedulerValidationError extends Error {}
function isValidCron(pattern: string): boolean {
const parts = pattern.trim().split(/\s+/);
return parts.length >= 5 && parts.length <= 6;
}
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
return {
id: schedule.id,
channelId: schedule.channelId,
creatorId: schedule.creatorId,
content: schedule.content,
rolePingId: schedule.rolePingId,
cron: schedule.cron,
runAt: schedule.runAt?.toISOString() ?? null,
enabled: schedule.enabled,
createdAt: schedule.createdAt.toISOString()
};
}
export async function listSchedules(guildId: string): Promise<ScheduledMessageDashboard[]> {
const schedules = await prisma.scheduledMessage.findMany({
where: { guildId, enabled: true },
orderBy: { createdAt: 'desc' }
});
return schedules.map(toDashboard);
}
/**
* Creates a scheduled message and enqueues the exact `scheduleSend` BullMQ
* job the bot's `/schedule create` command would enqueue (same queue, job
* name and delay/repeat semantics) - see
* `apps/bot/src/modules/scheduler/service.ts#enqueueScheduleJob`. The bot's
* existing `scheduleQueueName` worker sends the message, so no bot-side
* changes are required.
*/
export async function createSchedule(
guildId: string,
creatorId: string,
input: ScheduledMessageDashboardCreate
): Promise<ScheduledMessageDashboard> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
const cron = input.cron?.trim() || null;
if (cron && !isValidCron(cron)) {
throw new SchedulerValidationError('Invalid cron expression (expects 5-6 space separated fields)');
}
const runAt = !cron && input.runAt ? new Date(input.runAt) : null;
if (!cron && runAt && runAt.getTime() <= Date.now()) {
throw new SchedulerValidationError('runAt must be in the future');
}
const schedule = await prisma.scheduledMessage.create({
data: {
guildId,
channelId: input.channelId,
creatorId,
content: input.content?.trim() || null,
rolePingId: input.rolePingId ? input.rolePingId : null,
cron,
runAt
}
});
const jobOptions: {
jobId: string;
removeOnComplete: boolean;
removeOnFail: number;
delay?: number;
repeat?: { pattern: string };
} = {
jobId: `schedule-${schedule.id}`,
removeOnComplete: true,
removeOnFail: 50
};
if (cron) {
jobOptions.repeat = { pattern: cron };
} else if (runAt) {
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
}
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
const updated = await prisma.scheduledMessage.update({
where: { id: schedule.id },
data: { jobId: String(job.id) }
});
return toDashboard(updated);
}
export async function deleteSchedule(guildId: string, scheduleId: string): Promise<boolean> {
const schedule = await prisma.scheduledMessage.findFirst({ where: { id: scheduleId, guildId } });
if (!schedule) {
return false;
}
if (schedule.jobId) {
const job = await scheduleQueue.getJob(schedule.jobId);
await job?.remove();
}
await prisma.scheduledMessage.delete({ where: { id: scheduleId } });
return true;
}

View File

@@ -0,0 +1,91 @@
import type {
SelfRoleEntry,
SelfRolePanelDashboard,
SelfRolePanelDashboardCreate,
SelfRolePanelDashboardUpdate
} from '@nexumi/shared';
import type { Prisma, SelfRolePanel } from '@prisma/client';
import { prisma } from '../prisma';
function toRolesArray(raw: unknown): SelfRoleEntry[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.filter(
(entry): entry is SelfRoleEntry =>
Boolean(entry) && typeof entry === 'object' && typeof (entry as SelfRoleEntry).roleId === 'string'
);
}
function toDashboard(panel: SelfRolePanel): SelfRolePanelDashboard {
return {
id: panel.id,
channelId: panel.channelId,
title: panel.title,
description: panel.description,
mode: panel.mode as SelfRolePanelDashboard['mode'],
behavior: panel.behavior as SelfRolePanelDashboard['behavior'],
roles: toRolesArray(panel.roles),
expiresAt: panel.expiresAt?.toISOString() ?? null
};
}
export async function listSelfRolePanels(guildId: string): Promise<SelfRolePanelDashboard[]> {
const panels = await prisma.selfRolePanel.findMany({
where: { guildId },
orderBy: { createdAt: 'desc' }
});
return panels.map(toDashboard);
}
export async function createSelfRolePanel(
guildId: string,
input: SelfRolePanelDashboardCreate
): Promise<SelfRolePanelDashboard> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
const panel = await prisma.selfRolePanel.create({
data: {
guildId,
channelId: input.channelId,
title: input.title,
description: input.description ?? null,
mode: input.mode,
behavior: input.behavior,
roles: input.roles as unknown as Prisma.InputJsonValue,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null
}
});
return toDashboard(panel);
}
export async function updateSelfRolePanel(
guildId: string,
panelId: string,
patch: SelfRolePanelDashboardUpdate
): Promise<SelfRolePanelDashboard | null> {
const existing = await prisma.selfRolePanel.findFirst({ where: { id: panelId, guildId } });
if (!existing) {
return null;
}
const updated = await prisma.selfRolePanel.update({
where: { id: panelId },
data: {
...(patch.channelId !== undefined ? { channelId: patch.channelId } : {}),
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.mode !== undefined ? { mode: patch.mode } : {}),
...(patch.behavior !== undefined ? { behavior: patch.behavior } : {}),
...(patch.roles !== undefined ? { roles: patch.roles as unknown as Prisma.InputJsonValue } : {}),
...(patch.expiresAt !== undefined
? { expiresAt: patch.expiresAt ? new Date(patch.expiresAt) : null }
: {})
}
});
return toDashboard(updated);
}
export async function deleteSelfRolePanel(guildId: string, panelId: string): Promise<boolean> {
const result = await prisma.selfRolePanel.deleteMany({ where: { id: panelId, guildId } });
return result.count > 0;
}

View File

@@ -0,0 +1,99 @@
import type {
SuggestionAction,
SuggestionConfigDashboard,
SuggestionConfigDashboardPatch,
SuggestionDashboard,
SuggestionListQuery
} from '@nexumi/shared';
import type { Suggestion } from '@prisma/client';
import { prisma } from '../prisma';
async function ensureSuggestionConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.suggestionConfig.upsert({ where: { guildId }, update: {}, create: { guildId } });
}
function toConfigDashboard(
config: Awaited<ReturnType<typeof ensureSuggestionConfig>>
): SuggestionConfigDashboard {
return {
enabled: config.enabled,
openChannelId: config.openChannelId ?? '',
approvedChannelId: config.approvedChannelId ?? '',
deniedChannelId: config.deniedChannelId ?? '',
createThread: config.createThread
};
}
export async function getSuggestionConfigDashboard(guildId: string): Promise<SuggestionConfigDashboard> {
const config = await ensureSuggestionConfig(guildId);
return toConfigDashboard(config);
}
export async function updateSuggestionConfigDashboard(
guildId: string,
patch: SuggestionConfigDashboardPatch
): Promise<SuggestionConfigDashboard> {
await ensureSuggestionConfig(guildId);
const { openChannelId, approvedChannelId, deniedChannelId, ...rest } = patch;
const updated = await prisma.suggestionConfig.update({
where: { guildId },
data: {
...rest,
...(openChannelId !== undefined ? { openChannelId: openChannelId === '' ? null : openChannelId } : {}),
...(approvedChannelId !== undefined
? { approvedChannelId: approvedChannelId === '' ? null : approvedChannelId }
: {}),
...(deniedChannelId !== undefined
? { deniedChannelId: deniedChannelId === '' ? null : deniedChannelId }
: {})
}
});
return toConfigDashboard(updated);
}
function toSuggestionDashboard(suggestion: Suggestion): SuggestionDashboard {
return {
id: suggestion.id,
authorId: suggestion.authorId,
content: suggestion.content,
status: suggestion.status as SuggestionDashboard['status'],
upvotes: suggestion.upvotes,
downvotes: suggestion.downvotes,
staffReason: suggestion.staffReason,
createdAt: suggestion.createdAt.toISOString()
};
}
export async function listSuggestions(
guildId: string,
query: SuggestionListQuery
): Promise<SuggestionDashboard[]> {
const suggestions = await prisma.suggestion.findMany({
where: { guildId, ...(query.status ? { status: query.status } : {}) },
orderBy: { createdAt: 'desc' },
take: query.limit
});
return suggestions.map(toSuggestionDashboard);
}
export async function applySuggestionAction(
guildId: string,
suggestionId: string,
action: SuggestionAction
): Promise<SuggestionDashboard | null> {
const existing = await prisma.suggestion.findFirst({ where: { id: suggestionId, guildId } });
if (!existing) {
return null;
}
const updated = await prisma.suggestion.update({
where: { id: suggestionId },
data: {
status: action.status,
staffReason: action.staffReason ?? null
}
});
return toSuggestionDashboard(updated);
}

View File

@@ -0,0 +1,90 @@
import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate } from '@nexumi/shared';
import type { Tag } from '@prisma/client';
import { prisma } from '../prisma';
export class TagConflictError extends Error {
constructor() {
super('A tag with this name already exists');
}
}
function toDashboard(tag: Tag): TagDashboard {
return {
id: tag.id,
name: tag.name,
content: tag.content,
responseType: tag.responseType as TagDashboard['responseType'],
triggerWord: tag.triggerWord,
allowedRoleIds: tag.allowedRoleIds,
allowedChannelIds: tag.allowedChannelIds
};
}
export async function listTags(guildId: string): Promise<TagDashboard[]> {
const tags = await prisma.tag.findMany({ where: { guildId }, orderBy: { name: 'asc' } });
return tags.map(toDashboard);
}
export async function createTag(
guildId: string,
createdById: string,
input: TagDashboardCreate
): Promise<TagDashboard> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
try {
const tag = await prisma.tag.create({
data: {
guildId,
name: input.name,
content: input.content ?? null,
responseType: input.responseType,
triggerWord: input.triggerWord ?? null,
allowedRoleIds: input.allowedRoleIds,
allowedChannelIds: input.allowedChannelIds,
createdById
}
});
return toDashboard(tag);
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
throw new TagConflictError();
}
throw error;
}
}
export async function updateTag(
guildId: string,
tagId: string,
patch: TagDashboardUpdate
): Promise<TagDashboard | null> {
const existing = await prisma.tag.findFirst({ where: { id: tagId, guildId } });
if (!existing) {
return null;
}
try {
const updated = await prisma.tag.update({
where: { id: tagId },
data: {
...(patch.name !== undefined ? { name: patch.name } : {}),
...(patch.content !== undefined ? { content: patch.content } : {}),
...(patch.responseType !== undefined ? { responseType: patch.responseType } : {}),
...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}),
...(patch.allowedRoleIds !== undefined ? { allowedRoleIds: patch.allowedRoleIds } : {}),
...(patch.allowedChannelIds !== undefined ? { allowedChannelIds: patch.allowedChannelIds } : {})
}
});
return toDashboard(updated);
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
throw new TagConflictError();
}
throw error;
}
}
export async function deleteTag(guildId: string, tagId: string): Promise<boolean> {
const result = await prisma.tag.deleteMany({ where: { id: tagId, guildId } });
return result.count > 0;
}