diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index 46dde9c..f4fc83a 100644 --- a/db/mysql/schema.prisma +++ b/db/mysql/schema.prisma @@ -20,15 +20,16 @@ model ArchivedChannel { } model ArchivedMessage { - author ArchivedUser @relation(fields: [ticketId, authorId], references: [ticketId, userId], onDelete: Cascade) - authorId String @db.VarChar(19) - content String @db.Text - createdAt DateTime @default(now()) - deleted Boolean @default(false) - edited Boolean @default(false) - id String @id @db.VarChar(19) - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) - ticketId String @db.VarChar(19) + author ArchivedUser @relation(fields: [ticketId, authorId], references: [ticketId, userId], onDelete: Cascade) + authorId String @db.VarChar(19) + content String @db.Text + createdAt DateTime @default(now()) + deleted Boolean @default(false) + edited Boolean @default(false) + id String @id @db.VarChar(19) + referencedBy Ticket[] @relation("MessageReferencedByTicket") + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + ticketId String @db.VarChar(19) @@map("archivedMessages") } @@ -189,11 +190,11 @@ model Ticket { archivedUsers ArchivedUser[] category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) categoryId Int? - claimedBy User @relation(name: "TicketsClaimedByUser", fields: [claimedById], references: [id]) - claimedById String @db.VarChar(19) + claimedBy User? @relation(name: "TicketsClaimedByUser", fields: [claimedById], references: [id]) + claimedById String? @db.VarChar(19) closedAt DateTime? - closedBy User @relation(name: "TicketsClosedByUser", fields: [closedById], references: [id]) - closedById String @db.VarChar(19) + closedBy User? @relation(name: "TicketsClosedByUser", fields: [closedById], references: [id]) + closedById String? @db.VarChar(19) closedReason String? @db.Text createdAt DateTime @default(now()) createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id]) @@ -213,7 +214,8 @@ model Ticket { pinnedMessages Json @default("[]") priority TicketPriority? referencedBy Ticket[] @relation("TicketsReferencedByTicket") - referencesMessageId String @db.VarChar(19) + referencesMessage ArchivedMessage? @relation(name: "MessageReferencedByTicket", fields: [referencesMessageId], references: [id], onDelete: SetNull) + referencesMessageId String? @db.VarChar(19) referencesTicket Ticket? @relation(name: "TicketsReferencedByTicket", fields: [referencesTicketId], references: [id], onDelete: SetNull) referencesTicketId String? @db.VarChar(19) topic String? @db.Text diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index d093f69..d33a429 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -20,15 +20,16 @@ model ArchivedChannel { } model ArchivedMessage { - author ArchivedUser @relation(fields: [ticketId, authorId], references: [ticketId, userId], onDelete: Cascade) - authorId String @db.VarChar(19) - content String @db.Text - createdAt DateTime @default(now()) - deleted Boolean @default(false) - edited Boolean @default(false) - id String @id @db.VarChar(19) - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) - ticketId String @db.VarChar(19) + author ArchivedUser @relation(fields: [ticketId, authorId], references: [ticketId, userId], onDelete: Cascade) + authorId String @db.VarChar(19) + content String @db.Text + createdAt DateTime @default(now()) + deleted Boolean @default(false) + edited Boolean @default(false) + id String @id @db.VarChar(19) + referencedBy Ticket[] @relation("MessageReferencedByTicket") + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + ticketId String @db.VarChar(19) @@map("archivedMessages") } @@ -189,11 +190,11 @@ model Ticket { archivedUsers ArchivedUser[] category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) categoryId Int? - claimedBy User @relation(name: "TicketsClaimedByUser", fields: [claimedById], references: [id]) - claimedById String @db.VarChar(19) + claimedBy User? @relation(name: "TicketsClaimedByUser", fields: [claimedById], references: [id]) + claimedById String? @db.VarChar(19) closedAt DateTime? - closedBy User @relation(name: "TicketsClosedByUser", fields: [closedById], references: [id]) - closedById String @db.VarChar(19) + closedBy User? @relation(name: "TicketsClosedByUser", fields: [closedById], references: [id]) + closedById String? @db.VarChar(19) closedReason String? @db.Text createdAt DateTime @default(now()) createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id]) @@ -213,7 +214,8 @@ model Ticket { pinnedMessages Json @default("[]") priority TicketPriority? referencedBy Ticket[] @relation("TicketsReferencedByTicket") - referencesMessageId String @db.VarChar(19) + referencesMessage ArchivedMessage? @relation(name: "MessageReferencedByTicket", fields: [referencesMessageId], references: [id], onDelete: SetNull) + referencesMessageId String? @db.VarChar(19) referencesTicket Ticket? @relation(name: "TicketsReferencedByTicket", fields: [referencesTicketId], references: [id], onDelete: SetNull) referencesTicketId String? @db.VarChar(19) topic String? @db.Text diff --git a/db/sqlite/schema.prisma b/db/sqlite/schema.prisma index cf3337b..eb4e180 100644 --- a/db/sqlite/schema.prisma +++ b/db/sqlite/schema.prisma @@ -20,15 +20,16 @@ model ArchivedChannel { } model ArchivedMessage { - author ArchivedUser @relation(fields: [ticketId, authorId], references: [ticketId, userId], onDelete: Cascade) - authorId String - content String - createdAt DateTime @default(now()) - deleted Boolean @default(false) - edited Boolean @default(false) - id String @id - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) - ticketId String + author ArchivedUser @relation(fields: [ticketId, authorId], references: [ticketId, userId], onDelete: Cascade) + authorId String + content String + createdAt DateTime @default(now()) + deleted Boolean @default(false) + edited Boolean @default(false) + id String @id + referencedBy Ticket[] @relation("MessageReferencedByTicket") + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + ticketId String @@map("archivedMessages") } @@ -189,11 +190,11 @@ model Ticket { archivedUsers ArchivedUser[] category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) categoryId Int? - claimedBy User @relation(name: "TicketsClaimedByUser", fields: [claimedById], references: [id]) - claimedById String + claimedBy User? @relation(name: "TicketsClaimedByUser", fields: [claimedById], references: [id]) + claimedById String? closedAt DateTime? - closedBy User @relation(name: "TicketsClosedByUser", fields: [closedById], references: [id]) - closedById String + closedBy User? @relation(name: "TicketsClosedByUser", fields: [closedById], references: [id]) + closedById String? closedReason String? createdAt DateTime @default(now()) createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id]) @@ -213,7 +214,8 @@ model Ticket { pinnedMessages String @default("[]") priority String? referencedBy Ticket[] @relation("TicketsReferencedByTicket") - referencesMessageId String + referencesMessage ArchivedMessage? @relation(name: "MessageReferencedByTicket", fields: [referencesMessageId], references: [id], onDelete: SetNull) + referencesMessageId String? referencesTicket Ticket? @relation(name: "TicketsReferencedByTicket", fields: [referencesTicketId], references: [id], onDelete: SetNull) referencesTicketId String? topic String? diff --git a/src/commands/message/create.js b/src/commands/message/create.js index 774e8fd..ba8f240 100644 --- a/src/commands/message/create.js +++ b/src/commands/message/create.js @@ -13,5 +13,7 @@ module.exports = class CreateMessageCommand extends MessageCommand { }); } - async run(interaction) { } + async run(interaction) { + // TODO: archive message + } }; \ No newline at end of file diff --git a/src/i18n/en-GB.yml b/src/i18n/en-GB.yml index 2836cec..435bfbf 100644 --- a/src/i18n/en-GB.yml +++ b/src/i18n/en-GB.yml @@ -1,6 +1,3 @@ -test: | - line 1 - line 2 buttons: confirm_open: emoji: ✅ @@ -165,7 +162,16 @@ misc: title: ❌ There are no ticket categories ratelimited: description: Try again in a few seconds. - title: 🐢 Slow down + title: 🐢 Please slow down unknown_category: description: Please try a different category. - title: ❌ That ticket category doesn't exist \ No newline at end of file + title: ❌ That ticket category doesn't exist +ticket: + answers: + no_value: '*No response*' + opening_message: + content: | + {staff} + {creator} has created a new ticket + fields: + topic: Topic \ No newline at end of file diff --git a/src/lib/tickets/manager.js b/src/lib/tickets/manager.js index bb16650..a8e802d 100644 --- a/src/lib/tickets/manager.js +++ b/src/lib/tickets/manager.js @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ const { ActionRowBuilder, ModalBuilder, @@ -10,6 +11,9 @@ const emoji = require('node-emoji'); const ms = require('ms'); const { EmbedBuilder } = require('discord.js'); +/** + * @typedef {import('@prisma/client').Category & {guild: import('@prisma/client').Guild} & {questions: import('@prisma/client').Question[]}} CategoryGuildQuestions + */ module.exports = class TicketManager { constructor(client) { /** @type {import("client")} */ @@ -18,15 +22,15 @@ module.exports = class TicketManager { /** * @param {object} data - * @param {string} data.category + * @param {string} data.categoryId * @param {import("discord.js").ButtonInteraction|import("discord.js").SelectMenuInteraction} data.interaction * @param {string?} [data.topic] */ async create({ - categoryId, interaction, topic, reference, + categoryId, interaction, topic, referencesMessage, referencesTicket, }) { const cacheKey = `cache/category+guild+questions:${categoryId}`; - /** @type {import('@prisma/client').Category} */ + /** @type {CategoryGuildQuestions} */ let category = await this.client.keyv.get(cacheKey); if (!category) { category = await this.client.prisma.category.findUnique({ @@ -104,7 +108,8 @@ module.exports = class TicketManager { .setCustomId(JSON.stringify({ action: 'questions', categoryId, - reference, + referencesMessage, + referencesTicket, })) .setTitle(category.name) .setComponents( @@ -154,7 +159,8 @@ module.exports = class TicketManager { .setCustomId(JSON.stringify({ action: 'topic', categoryId, - reference, + referencesMessage, + referencesTicket, })) .setTitle(category.name) .setComponents( @@ -183,20 +189,140 @@ module.exports = class TicketManager { * @param {string?} [data.topic] */ async postQuestions({ - categoryId, interaction, topic, reference, + categoryId, interaction, topic, referencesMessage, referencesTicket, }) { await interaction.deferReply({ ephemeral: true }); - console.log(require('util').inspect(interaction, { - colors: true, - depth: 10, - })); - if (interaction.isModalSubmit()) { + const cacheKey = `cache/category+guild+questions:${categoryId}`; + /** @type {CategoryGuildQuestions} */ + const category = await this.client.keyv.get(cacheKey); + + let answers; + if (interaction.isModalSubmit()) { + answers = category.questions.map(q => ({ + questionId: q.id, + userId: interaction.user.id, + value: interaction.fields.getTextInputValue(q.id), + })); + if (category.customTopic) topic = interaction.fields.getTextInputValue(category.customTopic); } + /** @type {import("discord.js").Guild} */ + const guild = this.client.guilds.cache.get(category.guild.id); + const getMessage = this.client.i18n.getLocale(category.guild.locale); + const creator = await guild.members.fetch(interaction.user.id); + const number = (await this.client.prisma.ticket.count({ where: { guildId: category.guild.id } })) + 1; + const channelName = category.channelName + .replace(/{+\s?(user)?name\s?}+/gi, creator.user.username) + .replace(/{+\s?(nick|display)(name)?\s?}+/gi, creator.displayName) + .replace(/{+\s?num(ber)?\s?}+/gi, number === 1488 ? '1487b' : number); + const allow = ['ViewChannel', 'ReadMessageHistory', 'SendMessages', 'EmbedLinks', 'AttachFiles']; + /** @type {import("discord.js").TextChannel} */ + const channel = await guild.channels.create({ + name: channelName, + parent: category.discordCategory, + permissionOverwrites: [ + { + deny: ['ViewChannel'], + id: guild.roles.everyone, + }, + { + allow: allow, + id: this.client.user.id, + }, + { + allow: allow, + id: creator.id, + }, + ...category.staffRoles.map(id => ({ + allow: allow, + id, + })), + ], + rateLimitPerUser: category.ratelimit, + reason: `${creator.user.tag} created a ticket`, + topic: `${creator}${topic?.length > 0 ? ` | ${topic}` : ''}`, + }); + + const embed = new EmbedBuilder() + .setColor(category.guild.primaryColour) + .setAuthor({ + iconURL: creator.displayAvatarURL(), + name: creator.displayName, + }) + .setDescription( + category.openingMessage + .replace(/{+\s?(user)?name\s?}+/gi, creator.user.toString()), + + ); + + if (answers) { + embed.setFields( + category.questions.map(q => ({ + name: q.label, + value: interaction.fields.getTextInputValue(q.id) || getMessage('ticket.answers.no_value'), + })), + ); + } else if (topic) { + embed.setFields({ + name: getMessage('ticket.opening_message.fields.topic'), + value: topic, + }); + } + + if (category.guild.footer) { + embed.setFooter({ + iconURL: guild.iconURL(), + text: category.guild.footer, + }); + } + + // TODO: add edit button (if topic or questions) + // TODO: add close and claim buttons if enabled + const pings = category.pingRoles.map(r => `<@&${r}>`).join(' '); + const sent = await channel.send({ + content: getMessage('ticket.opening_message.content', { + creator: interaction.user.toString(), + staff: pings ? pings + ',' : '', + }), + embeds: [embed], + }); + await sent.pin({ reason: 'Ticket opening message' }); + const pinned = channel.messages.cache.last(); + + if (pinned.system) { + pinned + .delete({ reason: 'Cleaning up system message' }) + .catch(() => this.client.log.warn('Failed to delete system pin message')); + } + + // TODO: referenced msg or ticket + + const data = { + category: { connect: { id: categoryId } }, + createdBy: { + connectOrCreate: { + create: { id: interaction.user.id }, + where: { id: interaction.user.id }, + }, + }, + guild: { connect: { id: category.guild.id } }, + id: channel.id, + number, + openingMessage: sent.id, + topic, + }; + if (referencesTicket) data.referencesTicket = { connect: { id: referencesTicket } }; + let message; + if (referencesMessage) message = this.client.prisma.archivedMessage.findUnique({ where: { id: referencesMessage } }); + if (message) data.referencesMessage = { connect: { id: referencesMessage } }; // only add if the message has been archived ^^ + if (answers) data.questionAnswers = { createMany: { data: answers } }; + const ticket = await this.client.prisma.ticket.create({ data }); + console.log(ticket); interaction.editReply({ components: [], embeds: [], }); + // TODO: log channel } }; \ No newline at end of file diff --git a/src/listeners/client/ready.js b/src/listeners/client/ready.js index 092bfb5..2313360 100644 --- a/src/listeners/client/ready.js +++ b/src/listeners/client/ready.js @@ -28,9 +28,10 @@ module.exports = class extends Listener { firstResponseAt: true, }, }); + const closedTickets = tickets.filter(t => t.closedAt); cached = { - avgResolutionTime: ms(tickets.reduce((total, ticket) => total + (ticket.closedAt - ticket.createdAt), 0) ?? 1 / tickets.length), - avgResponseTime: ms(tickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / tickets.length), + avgResolutionTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.closedAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), + avgResponseTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), openTickets: tickets.filter(t => t.open).length, totalTickets: tickets.length, }; diff --git a/src/modals/questions.js b/src/modals/questions.js index f3063f5..f7c6b8e 100644 --- a/src/modals/questions.js +++ b/src/modals/questions.js @@ -8,13 +8,15 @@ module.exports = class QuestionsModal extends Modal { }); } + /** + * + * @param {*} id + * @param {import("discord.js").ModalSubmitInteraction} interaction + */ async run(id, interaction) { - console.log(id); - console.log(require('util').inspect(interaction, { - colors: true, - depth: 10, - })); - - // TODO: custom topic + await this.client.tickets.postQuestions({ + ...id, + interaction, + }); } }; \ No newline at end of file diff --git a/src/routes/api/admin/guilds/[guild]/categories/index.js b/src/routes/api/admin/guilds/[guild]/categories/index.js index ac30fce..95d1f81 100644 --- a/src/routes/api/admin/guilds/[guild]/categories/index.js +++ b/src/routes/api/admin/guilds/[guild]/categories/index.js @@ -27,13 +27,13 @@ module.exports.get = fastify => ({ }, where: { id: req.params.guild }, }); - categories = categories.map(c => { + const closedTickets = c.tickets.filter(t => t.closedAt); c = { ...c, stats: { - avgResolutionTime: ms(c.tickets.reduce((total, ticket) => total + (ticket.closedAt - ticket.createdAt), 0) ?? 1 / c.tickets.length), - avgResponseTime: ms(c.tickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / c.tickets.length), + avgResolutionTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.closedAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), + avgResponseTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), }, }; delete c.tickets; diff --git a/src/routes/api/admin/guilds/[guild]/index.js b/src/routes/api/admin/guilds/[guild]/index.js index 2c10f98..c3b22a0 100644 --- a/src/routes/api/admin/guilds/[guild]/index.js +++ b/src/routes/api/admin/guilds/[guild]/index.js @@ -28,14 +28,15 @@ module.exports.get = fastify => ({ }, where: { guildId: id }, }); + const closedTickets = tickets.filter(t => t.closedAt); cached = { createdAt: settings.createdAt, id: guild.id, logo: guild.iconURL(), name: guild.name, stats: { - avgResolutionTime: ms(tickets.reduce((total, ticket) => total + (ticket.closedAt - ticket.createdAt), 0) ?? 1 / tickets.length), - avgResponseTime: ms(tickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / tickets.length), + avgResolutionTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.closedAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), + avgResponseTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), categories: categories.map(c => ({ id: c.id, name: c.name, diff --git a/src/routes/api/client.js b/src/routes/api/client.js index b3321a6..f2a7649 100644 --- a/src/routes/api/client.js +++ b/src/routes/api/client.js @@ -14,6 +14,7 @@ module.exports.get = () => ({ firstResponseAt: true, }, }); + const closedTickets = tickets.filter(t => t.closedAt); const users = await client.prisma.user.findMany({ select: { messageCount: true } }); cached = { avatar: client.user.avatarURL(), @@ -23,8 +24,8 @@ module.exports.get = () => ({ stats: { activatedUsers: users.length, archivedMessages: users.reduce((total, user) => total + user.messageCount, 0), // don't count archivedMessage table rows, they get deleted - avgResolutionTime: ms(tickets.reduce((total, ticket) => total + (ticket.closedAt - ticket.createdAt), 0) ?? 1 / tickets.length), - avgResponseTime: ms(tickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / tickets.length), + avgResolutionTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.closedAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), + avgResponseTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), categories: await client.prisma.category.count(), guilds: client.guilds.cache.size, members: client.guilds.cache.reduce((t, g) => t + g.memberCount, 0),