import type { SuggestionAction, SuggestionConfigDashboard, SuggestionConfigDashboardPatch, SuggestionDashboard, SuggestionListQuery } from '@nexumi/shared'; import type { Suggestion } from '@prisma/client'; import { prisma } from '../prisma'; import { addJobAndAwait, getSuggestionsQueue, getSuggestionsQueueEvents } from '../queues'; export class SuggestionActionTimeoutError extends Error { constructor() { super('The bot did not confirm the suggestion update in time. Reload the list shortly to check the result.'); } } 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> ): SuggestionConfigDashboard { return { enabled: config.enabled, openChannelId: config.openChannelId ?? '', approvedChannelId: config.approvedChannelId ?? '', deniedChannelId: config.deniedChannelId ?? '', createThread: config.createThread }; } export async function getSuggestionConfigDashboard(guildId: string): Promise { const config = await ensureSuggestionConfig(guildId); return toConfigDashboard(config); } export async function updateSuggestionConfigDashboard( guildId: string, patch: SuggestionConfigDashboardPatch ): Promise { 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 toDashboard(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 { const suggestions = await prisma.suggestion.findMany({ where: { guildId, ...(query.status ? { status: query.status } : {}) }, orderBy: { createdAt: 'desc' }, take: query.limit }); return suggestions.map(toDashboard); } /** * Staff actions (approve/deny/consider/implement) need to move or edit the * suggestion's Discord embed and DM the author, which requires the bot's * discord.js Client. The WebUI enqueues a `suggestionStatusUpdate` job on * the same `suggestions` BullMQ queue the bot worker consumes (see * `apps/bot/src/modules/suggestions/service.ts#runSuggestionStatusUpdateJob`) * and waits (bounded) for it to finish. */ export async function applySuggestionAction( guildId: string, suggestionId: string, action: SuggestionAction, _actorUserId: string ): Promise { const existing = await prisma.suggestion.findFirst({ where: { id: suggestionId, guildId } }); if (!existing) { throw new SuggestionActionTimeoutError(); } const { confirmed } = await addJobAndAwait<{ id: string }>( getSuggestionsQueue(), getSuggestionsQueueEvents(), 'suggestionStatusUpdate', { suggestionId, guildId, status: action.status, reason: action.staffReason ?? '' }, { removeOnComplete: true, removeOnFail: 25 } ); if (!confirmed) { throw new SuggestionActionTimeoutError(); } const updated = await prisma.suggestion.findUnique({ where: { id: suggestionId } }); if (!updated) { throw new SuggestionActionTimeoutError(); } return toDashboard(updated); }