import type { TicketCategoryDashboard, TicketCategoryDashboardCreate, TicketCategoryDashboardUpdate, TicketConfigDashboard, TicketConfigDashboardPatch, TicketDashboard, TicketDashboardAction, TicketPriority, TicketStatus } from '@nexumi/shared'; import type { Ticket, TicketCategory } from '@prisma/client'; import { prisma } from '../prisma'; import { addJobAndAwait, getTicketsQueue, getTicketsQueueEvents } from '../queues'; export class TicketPanelSyncError extends Error { constructor( message: string, public readonly code: 'timeout' | 'not_posted' | 'no_categories' = 'not_posted' ) { super(message); this.name = 'TicketPanelSyncError'; } } export class TicketActionSyncError extends Error { constructor(message: string) { super(message); this.name = 'TicketActionSyncError'; } } async function ensureTicketConfig(guildId: string) { await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); return prisma.ticketConfig.upsert({ where: { guildId }, update: {}, create: { guildId } }); } function toConfigDashboard(config: Awaited>): TicketConfigDashboard { return { enabled: config.enabled, mode: config.mode as TicketConfigDashboard['mode'], categoryId: config.categoryId ?? '', logChannelId: config.logChannelId ?? '', panelChannelId: config.panelChannelId ?? '', transcriptDm: config.transcriptDm, inactivityHours: config.inactivityHours, ratingEnabled: config.ratingEnabled }; } export async function getTicketConfigDashboard(guildId: string): Promise { const config = await ensureTicketConfig(guildId); return toConfigDashboard(config); } /** * Dashboard has no discord.js Client, so panel posting is delegated to the bot * via the `tickets` BullMQ queue (`ticketPanelSync`). */ async function syncTicketPanel(guildId: string): Promise { const categoryCount = await prisma.ticketCategory.count({ where: { guildId } }); if (categoryCount === 0) { throw new TicketPanelSyncError( 'Create at least one ticket category before posting the panel', 'no_categories' ); } const { confirmed, result } = await addJobAndAwait<{ panelChannelId: string; panelMessageId: string; }>( getTicketsQueue(), getTicketsQueueEvents(), 'ticketPanelSync', { guildId }, { jobId: `ticket-panel-${guildId}-${Date.now()}`, removeOnComplete: true, removeOnFail: 25 }, 30_000 ); if (!confirmed || !result?.panelMessageId) { throw new TicketPanelSyncError( confirmed ? 'Ticket panel job finished without posting a Discord message' : 'The bot did not confirm the ticket panel in time. It may still be posting — check the channel shortly.', confirmed ? 'not_posted' : 'timeout' ); } const config = await prisma.ticketConfig.findUnique({ where: { guildId } }); if (!config?.panelMessageId) { throw new TicketPanelSyncError( 'Ticket panel job finished without storing a Discord message id', 'not_posted' ); } } async function maybeSyncTicketPanel(guildId: string): Promise { const config = await prisma.ticketConfig.findUnique({ where: { guildId } }); if (!config?.enabled || !config.panelChannelId) { return; } // Category CRUD must succeed even if Discord panel sync fails — otherwise // support roles / category fields appear "unsaved" in the UI while already // persisted in Postgres. try { await syncTicketPanel(guildId); } catch (error) { console.warn('[tickets] panel sync after category change failed', { guildId, error }); } } export async function updateTicketConfigDashboard( guildId: string, patch: TicketConfigDashboardPatch ): Promise { await ensureTicketConfig(guildId); const { categoryId, logChannelId, panelChannelId, ...rest } = patch; const updated = await prisma.ticketConfig.update({ where: { guildId }, data: { ...rest, ...(categoryId !== undefined ? { categoryId: categoryId === '' ? null : categoryId } : {}), ...(logChannelId !== undefined ? { logChannelId: logChannelId === '' ? null : logChannelId } : {}), ...(panelChannelId !== undefined ? { panelChannelId: panelChannelId === '' ? null : panelChannelId } : {}) } }); const dashboard = toConfigDashboard(updated); if (dashboard.enabled && dashboard.panelChannelId) { await syncTicketPanel(guildId); } return dashboard; } function toCategoryDashboard(category: TicketCategory): TicketCategoryDashboard { return { id: category.id, name: category.name, description: category.description, emoji: category.emoji, supportRoleIds: category.supportRoleIds }; } export async function listTicketCategories(guildId: string): Promise { const categories = await prisma.ticketCategory.findMany({ where: { guildId }, orderBy: { createdAt: 'asc' } }); return categories.map(toCategoryDashboard); } export class TicketCategoryConflictError extends Error { constructor() { super('A ticket category with this name already exists'); } } export async function createTicketCategory( guildId: string, input: TicketCategoryDashboardCreate ): Promise { await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); try { const category = await prisma.ticketCategory.create({ data: { guildId, name: input.name, description: input.description ?? null, emoji: input.emoji ?? null, supportRoleIds: input.supportRoleIds } }); await maybeSyncTicketPanel(guildId); return toCategoryDashboard(category); } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') { throw new TicketCategoryConflictError(); } throw error; } } export async function updateTicketCategory( guildId: string, categoryId: string, patch: TicketCategoryDashboardUpdate ): Promise { const existing = await prisma.ticketCategory.findFirst({ where: { id: categoryId, guildId } }); if (!existing) { return null; } try { const updated = await prisma.ticketCategory.update({ where: { id: categoryId }, data: { ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.description !== undefined ? { description: patch.description } : {}), ...(patch.emoji !== undefined ? { emoji: patch.emoji } : {}), ...(patch.supportRoleIds !== undefined ? { supportRoleIds: patch.supportRoleIds } : {}) } }); await maybeSyncTicketPanel(guildId); return toCategoryDashboard(updated); } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') { throw new TicketCategoryConflictError(); } throw error; } } export async function deleteTicketCategory(guildId: string, categoryId: string): Promise { const result = await prisma.ticketCategory.deleteMany({ where: { id: categoryId, guildId } }); if (result.count === 0) { return false; } const remaining = await prisma.ticketCategory.count({ where: { guildId } }); if (remaining > 0) { await maybeSyncTicketPanel(guildId); } return true; } type TicketWithCategory = Ticket & { category: TicketCategory | null }; function toTicketDashboard(ticket: TicketWithCategory): TicketDashboard { return { id: ticket.id, openerId: ticket.openerId, claimedById: ticket.claimedById, channelId: ticket.channelId, threadId: ticket.threadId, status: ticket.status as TicketStatus, priority: ticket.priority as TicketPriority, subject: ticket.subject, categoryId: ticket.categoryId, categoryName: ticket.category?.name ?? null, lastActivityAt: ticket.lastActivityAt.toISOString(), createdAt: ticket.createdAt.toISOString() }; } export async function listOpenTickets(guildId: string): Promise { const tickets = await prisma.ticket.findMany({ where: { guildId, status: { in: ['OPEN', 'CLAIMED'] } }, include: { category: true }, orderBy: { lastActivityAt: 'desc' }, take: 100 }); return tickets.map(toTicketDashboard); } export async function getTicketDashboard( guildId: string, ticketId: string ): Promise { const ticket = await prisma.ticket.findFirst({ where: { id: ticketId, guildId }, include: { category: true } }); return ticket ? toTicketDashboard(ticket) : null; } /** * Close / claim / priority need Discord side effects → bot via BullMQ. */ export async function applyTicketDashboardAction( guildId: string, ticketId: string, action: TicketDashboardAction, actorUserId: string ): Promise { const existing = await prisma.ticket.findFirst({ where: { id: ticketId, guildId }, include: { category: true } }); if (!existing) { return null; } if (existing.status === 'CLOSED') { throw new TicketActionSyncError('This ticket is already closed'); } const { confirmed } = await addJobAndAwait( getTicketsQueue(), getTicketsQueueEvents(), 'ticketDashboardAction', { ticketId, guildId, actorUserId, action: action.action, reason: action.action === 'close' ? action.reason : undefined, priority: action.action === 'priority' ? action.priority : undefined }, { jobId: `ticket-action-${ticketId}-${action.action}-${Date.now()}`, removeOnComplete: true, removeOnFail: 25 }, 45_000 ); if (!confirmed) { throw new TicketActionSyncError( 'The bot did not confirm the ticket action in time. Reload the list shortly to check the result.' ); } if (action.action === 'close') { const closed = await getTicketDashboard(guildId, ticketId); return closed; } const updated = await getTicketDashboard(guildId, ticketId); if (!updated) { throw new TicketActionSyncError('Ticket action finished but the ticket could not be reloaded'); } return updated; }