managers_TextTicketManager.js

'use strict';

const { ChannelType, OverwriteType, PermissionFlagsBits } = require('discord-api-types/v10');
const { CategoryChannel, Collection, Guild, GuildMember, Role, User } = require('discord.js');
const BaseTicketManager = require('./BaseTicketManager');
const { DisGroupDevError, Messages } = require('../errors/DisGroupDevError');
const TextTicket = require('../structures/TextTicket');


/**
 * @typedef {Object} TextTicketManagerOptions
 * @property {String} channelTopic The topic of the ticket channel
 * @property {Snowflake} closedParentId The id of the closed tickte category (default: #parentId)
 * @property {Snowflake} parentId The id of the ticket category
 * @property {Array<Snowflake>} staffRoles The IDs of the staff roles
 * @property {String} storage The json file to save the tickets to
 */

/**
 * @typedef {Object} TicketDataRaw
 * @property {Snowflake} channelId The id of the ticket channel or thread
 * @property {Snowflake} guildId The id of the guild
 * @property {Number} number The number of the ticket
 * @property {Array<Snowflake>} participants The array with all ids of participants
 * @property {TicketDataStatus} status The status of the ticket
 * @property {TicketDataType} type The type of the ticket
 * @property {Snowflake} userId The id of the user
 */

/**
 * The text ticket manager class.
 * @extends {BaseTicketManager}
 * @class
 */
class TextTicketManager extends BaseTicketManager {
    /**
     * The constructor of the text ticket manager class.
     * @param {Client} client The client
     * @param {TextTicketManagerOptions} options The options of the text ticket manager
     */
    constructor(client, options) {
        super(client, options);

        /**
         * The cache with all text tickets
         * @type {Collection<Number, TextTicket>}
         */
        this.cache = new Collection();

        /**
         * The options of the text ticket manager
         * @type {TextTicketManagerOptions}
         * @public
         */
        this.options = options;

        this.options.closedParentId = this.options.closedParentId ?? this.options.parentId;

        this._init();
    }

    /**
     * Inits the manager
     * @private
     */
    async _init() {
        const loadedTickets = await this._getTickets();

        this._rawTickets = loadedTickets;

        for (const rawTicket of loadedTickets) {
            const textTicket = new TextTicket(this.client, this, rawTicket);

            this.cache.set(textTicket.number, textTicket);
        }

        this.isReady = true;
    }

    /**
     * Closes a ticket
     * @param {TextTicket} ticket The text ticket you want to close
     * @returns {Promise<TextTicket|DisGroupDevError>}
     * @public
     */
    closeTicket(ticket) {
        return new Promise(async (resolve, reject) => {
            if (!this.isReady) reject(new DisGroupDevError(Messages.NOT_READY));

            if (!ticket || !(ticket instanceof TextTicket)) reject(new DisGroupDevError(Messages.NOT_INSTANCE_OF(TextTicket, ticket)));
            if (ticket.status === 'CLOSED') reject(new DisGroupDevError(Messages.TICKET_CLOSED(ticket.number)));

            const resolvedClosedTicketCategory = ticket.guild.channels.resolve(this.options.closedParentId);

            if (!resolvedClosedTicketCategory || !(resolvedClosedTicketCategory instanceof CategoryChannel)) reject(new DisGroupDevError(Messages.UNRESOLVABLE_CATEGORY(ticket.guildId, this.options.closedParentId)));

            /** @type {OverwriteData[]} */
            const ticketPermissions = [];

            ticketPermissions.push({
                id: ticket.userId,
                deny: [
                    PermissionFlagsBits.AttachFiles,
                    PermissionFlagsBits.AddReactions,
                    PermissionFlagsBits.EmbedLinks,
                    PermissionFlagsBits.ReadMessageHistory,
                    PermissionFlagsBits.SendMessages,
                    PermissionFlagsBits.UseExternalEmojis,
                    PermissionFlagsBits.UseExternalStickers,
                    PermissionFlagsBits.ViewChannel,
                ],
                type: OverwriteType.Member,
            });
            ticketPermissions.push({
                id: ticket.guildId,
                deny: [PermissionFlagsBits.ViewChannel],
                type: OverwriteType.Role,
            });

            for (const participant of ticket.participants) {
                const resolvedParticipant = await ticket.guild.members.fetch(participant);

                if (!resolvedParticipant) return;

                ticketPermissions.push({
                    id: resolvedParticipant.id,
                    deny: [
                        PermissionFlagsBits.AttachFiles,
                        PermissionFlagsBits.AddReactions,
                        PermissionFlagsBits.EmbedLinks,
                        PermissionFlagsBits.ReadMessageHistory,
                        PermissionFlagsBits.SendMessages,
                        PermissionFlagsBits.UseExternalEmojis,
                        PermissionFlagsBits.UseExternalStickers,
                        PermissionFlagsBits.ViewChannel,
                    ],
                    type: OverwriteType.Member,
                });
            }

            for (const staffRole of this.options.staffRoles) {
                const resolvedStaffRole = ticket.guild.roles.resolve(staffRole);

                if (!resolvedStaffRole || !(resolvedStaffRole instanceof Role)) reject(new DisGroupDevError(Messages.UNRESOLVABLE_ROLE(ticket.guildId, staffRole)));

                ticketPermissions.push({
                    id: resolvedStaffRole.id,
                    deny: [
                        PermissionFlagsBits.AttachFiles,
                        PermissionFlagsBits.AddReactions,
                        PermissionFlagsBits.EmbedLinks,
                        PermissionFlagsBits.ReadMessageHistory,
                        PermissionFlagsBits.SendMessages,
                        PermissionFlagsBits.UseExternalEmojis,
                        PermissionFlagsBits.UseExternalStickers,
                        PermissionFlagsBits.ViewChannel,
                    ],
                     type: OverwriteType.Role,
                });
            }

            await ticket.channel.edit({
                name: `closed-${ticket.number}`,
                parent: resolvedClosedTicketCategory.id,
                permissionOverwrites: ticketPermissions,
            });

            ticket.data.status = 'CLOSED';

            const newTicket = new TextTicket(this.client, this, ticket.data);

            this._rawTickets[this._rawTickets.indexOf(ticket.data)] = ticket.data;

            await this.save();

            this.cache.set(ticket.number, newTicket);


            /**
             * Emitted when a ticket is closed
             * @event TextTicketManager#ticketClose
             * @param {TextTicket} ticket
             */
            this.emit('ticketClose', newTicket);

            resolve(newTicket);
        });
    }

    /**
     * Creates a ticket for a user
     * @param {GuildResolvable} guild The guild of the ticket
     * @param {UserResolvable} user The user who created the ticket
     * @returns {Promise<TextTicket|DisGroupDevError>}
     * @public
     */
    createTicket(guild, user) {
        return new Promise(async (resolve, reject) => {
            if (!this.isReady) reject(new DisGroupDevError(Messages.NOT_READY));

            const resolvedGuild = this.resolveGuild(guild);

            if (!resolvedGuild || !(resolvedGuild instanceof Guild)) reject(new DisGroupDevError(Messages.UNRESOLVABLE_GUILD(guild)));

            const resolvedUser = this.resolveUser(user);

            if (!resolvedUser || !(resolvedUser instanceof User)) reject(new DisGroupDevError(Messages.UNRESOLVABLE_USER(user)));

            const resolvedMember = await resolvedGuild.members.fetch(resolvedUser);

            if (!resolvedMember || !(resolvedMember instanceof GuildMember)) reject(new DisGroupDevError(Messages.UNRESOLVABLE_GUILD_MEMBER(resolvedGuild.id, resolvedUser.id)));

            const resolvedTicketCategory = resolvedGuild.channels.resolve(this.options.parentId);

            if (!resolvedTicketCategory || !(resolvedTicketCategory instanceof CategoryChannel)) reject(new DisGroupDevError(Messages.UNRESOLVABLE_CATEGORY(resolvedGuild.id, this.options.parentId)));

            const guildTickets = this._rawTickets.filter(rawTicket => rawTicket.guildId === resolvedGuild.id);
            let ticketNumber = 0;

            if (guildTickets.length > 0) ticketNumber = guildTickets.sort((a, b) => b.number - a.number)[0].number;

            const newTicketNumber = (parseInt(String(ticketNumber)) + 1).toString();
            /** @type {OverwriteData[]} */
            const ticketPermissions = [];

            ticketPermissions.push({
                id: resolvedUser.id,
                allow: [
                    PermissionFlagsBits.AttachFiles,
                    PermissionFlagsBits.AddReactions,
                    PermissionFlagsBits.EmbedLinks,
                    PermissionFlagsBits.ReadMessageHistory,
                    PermissionFlagsBits.SendMessages,
                    PermissionFlagsBits.UseExternalEmojis,
                    PermissionFlagsBits.UseExternalStickers,
                    PermissionFlagsBits.ViewChannel,
                ],
                type: OverwriteType.Member,
            });
            ticketPermissions.push({
                id: resolvedGuild.id,
                deny: [PermissionFlagsBits.ViewChannel],
                type: OverwriteType.Role,
            });

            for (const staffRole of this.options.staffRoles) {
                const resolvedStaffRole = resolvedGuild.roles.resolve(staffRole);

                if (!resolvedStaffRole || !(resolvedStaffRole instanceof Role)) reject(new DisGroupDevError(Messages.UNRESOLVABLE_ROLE(resolvedGuild.id, staffRole)));

                ticketPermissions.push({
                    id: resolvedStaffRole.id,
                    allow: [
                        PermissionFlagsBits.AttachFiles,
                        PermissionFlagsBits.AddReactions,
                        PermissionFlagsBits.EmbedLinks,
                        PermissionFlagsBits.ReadMessageHistory,
                        PermissionFlagsBits.SendMessages,
                        PermissionFlagsBits.UseExternalEmojis,
                        PermissionFlagsBits.UseExternalStickers,
                        PermissionFlagsBits.ViewChannel,
                    ],
                    type: OverwriteType.Role,
                });
            }

            const ticketChannel = await resolvedGuild.channels.create({
                name: `ticket-${newTicketNumber}`,
                parent: resolvedTicketCategory.id,
                permissionOverwrites: ticketPermissions,
                topic: this.options.channelTopic ?? null,
                type: ChannelType.GuildText,
            });

            const ticket = new TextTicket(this.client, this, {
                channelId: ticketChannel.id,
                guildId: resolvedGuild.id,
                number: newTicketNumber,
                participants: [resolvedUser.id],
                status: 'OPEN',
                userId: resolvedUser.id,
            });

            this._rawTickets.push(ticket.data);

            this.save();

            this.cache.set(ticket.number, ticket);

            /**
             * Emitted when a ticket is created
             * @event TextTicketManager#ticketCreate
             * @param {TextTicket} ticket
             */
            this.emit('ticketCreate', ticket);

            resolve(ticket);
        });
    }

    /**
     * Deletes a ticket
     * @param {TextTicket} ticket The ticket to delete
     * @returns {Promise<Boolean|DisGroupDevError>}
     * @public
     */
    deleteTicket(ticket) {
        return new Promise(async (resolve, reject) => {
            if (!this.isReady) reject(new DisGroupDevError(Messages.NOT_READY));

            if (!ticket || !(ticket instanceof TextTicket)) reject(new DisGroupDevError(Messages.NOT_INSTANCE_OF(TextTicket, ticket)));

            await ticket.channel.delete();

            ticket.data.status = 'DELETED';

            this._rawTickets = this._rawTickets.filter(rawTicket => rawTicket.channelId !== ticket.channelId);

            this.save();

            this.cache.delete(ticket.number);

            /**
             * Emitted when a ticket is deleted
             * @event TextTicketManager#ticketDelete
             * @param {TextTicket} ticket
             */
            this.emit('ticketDelete', ticket);

            resolve(true);
        });
    }

    /**
     * Renames a ticket
     * @param {TextTicket} ticket The ticket to rename
     * @param {String} name The new name of the ticket
     * @returns {Promise<TextTicket|DisGroupDevError>}
     * @public
     */
    renameTicket(ticket, name) {
        return new Promise(async (resolve, reject) => {
            if (!this.isReady) reject(new DisGroupDevError(Messages.NOT_READY));

            if (!ticket || !(ticket instanceof TextTicket)) reject(new DisGroupDevError(Messages.NOT_INSTANCE_OF(TextTicket, ticket)));

            await ticket.channel.edit({ name: `${name}-${ticket.number}` });

            /**
             * Emitted when a ticket is renamed
             * @event TextTicketManager#ticketRename
             * @param {TextTicket} ticket
             */
            this.emit('ticketRename', ticket);

            resolve(ticket);
        });
    }

    /**
     * Reopens a ticket
     * @param {TextTicket} ticket The ticket to reopen
     * @returns {Promise<TextTicket|DisGroupDevError>}
     * @public
     */
    reopenTicket(ticket) {
        return new Promise(async (resolve, reject) => {
            if (!this.isReady) reject(new DisGroupDevError(Messages.NOT_READY));

            if (!ticket || !(ticket instanceof TextTicket)) reject(new DisGroupDevError(Messages.NOT_INSTANCE_OF(TextTicket, ticket)));
            if (ticket.status === 'OPEN') reject(new DisGroupDevError(Messages.TICKET_OPEN(ticket.number)));

            const resolvedTicketCategory = ticket.guild.channels.resolve(this.options.parentId);

            if (!resolvedTicketCategory || !(resolvedTicketCategory instanceof CategoryChannel)) reject(new DisGroupDevError(Messages.UNRESOLVABLE_CATEGORY(ticket.guildId, this.options.parentId)));

            /** @type {OverwriteData[]} */
            const ticketPermissions = [];

            ticketPermissions.push({
                id: ticket.userId,
                allow: [
                    PermissionFlagsBits.AttachFiles,
                    PermissionFlagsBits.AddReactions,
                    PermissionFlagsBits.EmbedLinks,
                    PermissionFlagsBits.ReadMessageHistory,
                    PermissionFlagsBits.SendMessages,
                    PermissionFlagsBits.UseExternalEmojis,
                    PermissionFlagsBits.UseExternalStickers,
                    PermissionFlagsBits.ViewChannel,
                ],
                type: OverwriteType.Member,
            });
            ticketPermissions.push({
                id: ticket.guildId,
                deny: [PermissionFlagsBits.ViewChannel],
                type: OverwriteType.Role,
            });

            for (const participant of ticket.participants) {
                const resolvedParticipant = await ticket.guild.members.fetch(participant);

                if (!resolvedParticipant) return;

                ticketPermissions.push({
                    id: resolvedParticipant.id,
                    allow: [
                        PermissionFlagsBits.AttachFiles,
                        PermissionFlagsBits.AddReactions,
                        PermissionFlagsBits.EmbedLinks,
                        PermissionFlagsBits.ReadMessageHistory,
                        PermissionFlagsBits.SendMessages,
                        PermissionFlagsBits.UseExternalEmojis,
                        PermissionFlagsBits.UseExternalStickers,
                        PermissionFlagsBits.ViewChannel,
                    ],
                    type: OverwriteType.Member,
                });
            }

            for (const staffRole of this.options.staffRoles) {
                const resolvedStaffRole = ticket.guild.roles.resolve(staffRole);

                if (!resolvedStaffRole || !(resolvedStaffRole instanceof Role)) reject(new DisGroupDevError(Messages.UNRESOLVABLE_ROLE(ticket.guildId, staffRole)));

                ticketPermissions.push({
                    id: resolvedStaffRole.id,
                    allow: [
                        PermissionFlagsBits.AttachFiles,
                        PermissionFlagsBits.AddReactions,
                        PermissionFlagsBits.EmbedLinks,
                        PermissionFlagsBits.ReadMessageHistory,
                        PermissionFlagsBits.SendMessages,
                        PermissionFlagsBits.UseExternalEmojis,
                        PermissionFlagsBits.UseExternalStickers,
                        PermissionFlagsBits.ViewChannel,
                    ],
                    type: OverwriteType.Role,
                });
            }

            await ticket.channel.edit({
                name: `ticket-${ticket.number}`,
                parent: resolvedTicketCategory.id,
                permissionOverwrites: ticketPermissions,
            });

            ticket.data.status = 'OPEN';

            const newTicket = new TextTicket(this.client, this, ticket.data);

            this._rawTickets[this._rawTickets.indexOf(ticket.data)] = ticket.data;

            this.save();

            this.cache.set(ticket.number, newTicket);


            /**
             * Emitted when a ticket is reopened
             * @event TextTicketManager#ticketReopen
             * @param {TextTicket} ticket
             */
            this.emit('ticketReopen', newTicket);

            resolve(newTicket);
        });
    }
}

module.exports = TextTicketManager;