← Back to lessons
DISCORD SYSTEMS

Lesson — QC System

Goal: Build a Quality Control system where designs are submitted, reviewed, and approved or denied.

Lesson Overview

Video Lesson

Notes — Understanding QC System

QC Channel

All submissions go to a private QC channel.

QC Role

Only people with QC role can approve or deny.

Approve / Deny Buttons

Buttons update the message and lock after decision.

Thread

A thread is created automatically for feedback.

Step-by-Step

Create QC Command

Users submit designs using /qc command with image and channel.

Send to QC Channel

Bot sends submission to QC channel with buttons.

QC Approve / Deny System


// Buttons: Approve or Deny
if (interaction.customId === "qc_approve") {
   // approve logic
}

if (interaction.customId === "qc_deny") {
   // deny logic
}

Lock Buttons After Decision

Disable buttons after approval/denial so it can't be changed.

Full QC System Code

QC Command — qc.js

Show Full File

const { ContainerBuilder, SeparatorSpacingSize, ButtonBuilder, ButtonStyle, ActionRowBuilder, MessageFlags, MessageActivityType, Emoji } = require("discord.js");
const { execute } = require("../modals/say");

const QC_ROLE_ID = "";

module.export = {
    customIDs: ['qc_approve', 'qc_deny'],

    async execute(interaction) {
        const member = interaction.member;

        if (!member.roles.cache.has(QC_ROLE_ID)) {
            return interaction.reply({
                components: [
                    new ContainerBuilder()
                        .addTextDisplayComponents((t) =>
                            t.setContent("You do not have permission.")
                        )
                ],
                flags: MessageFlags.IsComponentsV2 | MessageFlags.Ephemeral,
            });
        }

        const approved = interaction.customId === "qc_approve";

        const original = interaction.message;
        const attachment = original.components
            .flatMap((c) => c.components ?? [])
            .find((c) => c.type === "media_gallery" || c.data?.type === 11);
        
        const imageUrl = 
            original.attachments?.first()?.url ??
            original.embeds?.[0]?.image?.url ??
            null;
        
        const originalText = original.components?.[0]?.components
            ?.find((c) => c.data?.content?.includes("Designer"))
            ?.data?.content ?? "";
        
        const designerMatch = originalText.match(/\*\*Designer:\*\* <@(\d+)>/);
        const channelMatch = originalText.match(
            /\*\*Intended Channel:\*\* <#(\d+)>/
        );

        const color = approved ? 0x2ecc71 : 0xe74c3c;
        const statusText = approved ? 'Approved' : 'Denied';
        const emoji = approved ? "✅" : "❌";

        const updatedContainer = new ContainerBuilder()
            .addTextDisplayComponents(
                (t) =>
                    t.setContent(`## ${emoji} QC Submission`),
                (t) =>
                    t.setContent(
                        [
                            `**Designer:** <@${designerId}>`,
                            `**Intended Channel:** <#${channelId}>`,
                            `**Status:** ${statusText}`,
                            `**Actioned by:** <@${interaction.user.id}>`,
                        ].join("\n")
                    )
            )
            .addMediaGalleryComponents((g) =>
                g.addItems((i) =>
                    i.setURL(
                        original.components
                            .find((c) => c.data?.components?.find(
                                (i) => i.data?.type === 11
                            ))
                            ?.data?.components
                            ?.find((i) => i.data?.type === 11)
                            ?.data?.items?.[0]?.media.url ?? image
                    )
                )
            );
        
        const disabledRow = new ActionRowBuilder().addComponents(
            new ButtonBuilder()
                .setCustomId("qc_approve")
                .setLabel("approve")
                .setStyle(ButtonStyle.Success)
                .setDisabled(true),
            new ButtonBuilder()
                .setCustomId("qc_deny")
                .setLabel("Deny")
                .setStyle(ButtonStyle.Danger)
                .setDisabled(true)
        );

        return interaction.update({
            components: [updatedContainer, disabledRow],
            flags: MessageFlags.IsComponentsV2,
        });
    }
}

QC Buttons — qc-buttons.js

Show Full File

const { SlashCommandBuilder } = require("@discordjs/builders");
const {
    ContainerBuilder,
    SeparatorSpacingSize,
    ButtonBuilder,
    ButtonStyle,
    ActionRowBuilder,
    MessageFlags,
    ChannelType,
} = require("discord.js");

const QC_CHANNEL_ID = "";
const QC_ROLE_ID = ""; // who to ping, who can accept / deny
const DESIGNER_ROLE_ID = "";

module.exports = {
    dev: true, // visible only to guilds in config.json
    guilds: [''], // whitelist by guild ID
    roles: [''], // requires user to have listed role IDs
    users: [''], // whitelist by user ID
    cooldown: 0, // cooldown time in seconds
    userPerms: [''], // required user permissions
    clientPerms: [''], // required bot permissions
    alias: '', // Single Alias
	aliases: [''], // Mulitple Aliases
    defer: false, // defer without being ephemeral | true or false
	cache: true, // the next time you run this command it will jump straight to the bottom without delay. | true or false
    data: new SlashCommandBuilder()
        .setName('qc') // Name of your slash command
        .setDescription('quality control command')
        .addAttachmentOption((opt) =>
            opt
                .setName("image")
                .setDescription("the design image to submit")
                .setRequired(true)
        )
        .addChannelOption((opt) => 
            opt
                .setName("channel")
                .setDescription("order channel")
                .setRequired(true)
        ),
    async execute(interaction) {
        // Command logic here
        const image = interaction.options.getAttachment("image");
        const channel = interaction.options.getChannel("channel");
        const designer = interaction.user;

        const qcChannel = 
            await interaction.client.channels.fetch(QC_CHANNEL_ID);
        
        const container = new ContainerBuilder()
            .addTextDisplayComponents(
                (t) => t.setContent("## Quality Control"),
                (t) =>
                    t.setContent(
                        [
                            `**Designer** <@${designer.id}>`,
                            `**Intended Channel:** <#${channel.id}>`,
                            `**Status** Pending`,
                            `\n<@&${QC_ROLE_ID}>`,
                        ].join('\n')
                    )
            )
            .addSeparatorComponents((s) =>
                s.setSpacing(SeparatorSpacingSize.Small)
            )
            .addMediaGallery((g) =>
                g.addItems((i) => i.setUrl(image.url))
            );
        
        const row = new ActionRowBuilder().addComponents(
            new ButtonBuilder()
                .setCustomId("qc_approve")
                .setLabel("Approve")
                .setStyle(ButtonStyle.Success),
            new ButtonBuilder()
                .setCustomId("qc_deny")
                .setLabel("Deny")
                .setStyle(ButtonStyle.Danger)
        );


        const qcMessage = await qcChannel.send({
            components: [container, row],
            flags: MessageFlags.IsComponentsV2,
        });

        await qcMessage.startThread({
            name: `QC - ${designer.username}`,
            autoArchiveDuration: 1440,
            type: ChannelType.PublicThread,
        });

        return interaction.editReply({
            components: [
                new ContainerBuilder()
                    .addTextDisplayComponents((t) =>
                        t.setContent("Your design was submitted for QC.")
                    ),
            ],
            flags: MessageFlags.IsComponentsV2,
        })
    },
};

Challenge

← Last Lesson Next Lesson →