import { BaseEntity, Brackets, Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; import { infoEmbed } from "../bot/Util"; import { CDN_URL, getDate } from "../config/Constants"; import { Guild } from "./Guild"; @Entity({ name: "appeals" }) export class Appeal extends BaseEntity { @PrimaryGeneratedColumn("uuid") id: string; // user data @Column() userTag: string; @Column({ nullable: true }) userAvatar?: string; @Column() userID: string; @ManyToOne(() => Guild, (guild) => guild.id, { eager: true, }) @JoinColumn() guild: Guild; @Column() punishmentDate: string; @Column() banReason: string; @Column() appealReason: string; @Column() additionalInfo: string; // Processing -> in_questioning -> questioning_rejoined -> accepted OR rejected // Processing -> rejected // Processing -> accepted @Column() status: | "processing" | "in_questioning" | "questioning_rejoined" | "accepted" | "rejected"; @Column() message: string; @Column({ nullable: true }) rejectionExpiry?: Date; // If the user is rejected, the member is unable to apply again until this date passes. @Column({ nullable: true }) decisionReason?: string; @Column({ nullable: true }) decisionBy?: string; @CreateDateColumn() created: Date; @UpdateDateColumn() lastUpdate: Date; constructor( userID?: string, userTag?: string, userAvatar?: string, guild?: Guild, punishDate?: string, banReason?: string, appealReason?: string, additionalInfo?: string ) { super(); if (!(userID && userTag && punishDate && banReason && appealReason)) { return; } this.userID = userID; this.userTag = userTag; this.userAvatar = userAvatar; this.guild = guild; this.punishmentDate = punishDate.trim(); this.banReason = banReason.trim(); this.appealReason = appealReason.trim(); this.additionalInfo = additionalInfo?.trim(); this.status = "processing"; } accept(by: string, reason?: string) { this.decisionBy = by; this.status = "accepted"; this.decisionReason = reason; this.save(); } reject(by: string, reason?: string) { this.decisionBy = by; this.status = "rejected"; this.decisionReason = reason; if (this.guild.defaultExpiry) { this.rejectionExpiry = getDate(this.guild.defaultExpiry); } this.save(); } validationMessage(): string { if (this.punishmentDate === "") return "Punishment date required."; if (this.banReason === "") return "Ban Reason Required."; if (this.appealReason === "") return "Appeal Reason required."; if (this.punishmentDate.length > 128) return "Punishment date length must be < 128."; if (this.banReason.length > 256) return "Ban reason length must be < 256 characters."; if (this.appealReason.length > 1000) return "Appeal reason length must be < 1000."; if (this.additionalInfo.length > 1000) return "Additional info length must be < 1000."; return null; } embed(full?: boolean) { const embed = infoEmbed(`Appeal - ${this.status}`) .setThumbnail(this.avatar()) .addField("\u200B", "\u200B") .setFooter("Appeal ID: " + this.id); if (this.lastUpdate) { embed.addField("Last Updated", this.lastUpdate.toLocaleString()); } if (full) { embed .addField("Punishment Date", this.punishmentDate) .addField("Ban Reason", this.banReason) .addField("Appeal Reason", this.appealReason) .addField("Additional Info", this.additionalInfo || "*None provided*"); } else { embed.addField("Full Message", `[Link](${this.link()})`); } if (["accepted", "rejected"].includes(this.status)) { embed.fields[0] = { name: "Decision By", value: `<@${this.decisionBy}>`, inline: false, }; } if (this.decisionReason) { let decision = this.decisionReason.substr(0, full ? 1000 : 400); if (decision !== this.decisionReason) decision += "\n\n*Message trimmed*"; embed.addField("Decision Note", decision); } let desc = `User: ${this.userTag} User ID: ${this.userID} Status: ${this.status}\n`; if (this.status === "rejected") { desc += `User re-appeal date: ${ this.rejectionExpiry?.toLocaleString() || "Never" }\n`; } embed.setDescription(`${"```fix\n"}${desc}${"```"}`); return embed; } link() { return `https://discord.com/channels/${this.guild.guild}/${this.guild.channel}/${this.message}`; } avatar() { const discrim = this.userTag.split("#")[1]; if (!this.userAvatar) { return `${CDN_URL}/embed/avatars/${Number(discrim) % 5}.png`; } return `${CDN_URL}/avatars/${this.userID}/${this.userAvatar}.png`; } /** * activeAppeals queries the database for all appeals that are currently * active for a specifc userID in a guild. * * @param {string} userID The userID to query for. * @param {string} guildID The guildID to query for. */ static async activeAppeals(userID: string, guild: string) { return await Appeal.createQueryBuilder("appeals") // where limits the appeals guild and user .where("appeals.userID = :userID", { userID }) .andWhere("appeals.guildId = :guildID", { guildID: guild }) .andWhere( new Brackets((qb) => qb // if it's accepted or rejected, it's not active anymore .where("appeals.status NOT IN (:...statuses)", { statuses: ["accepted", "rejected"], }) .orWhere( new Brackets((qb) => qb // however, if the appeal is rejected but the rejection hasn't expired, we still consider the appeal to be active .where(`appeals.status = "rejected"`) .andWhere( "appeals.rejectionExpiry IS NULL OR appeals.rejectionExpiry > datetime(:now)", { now: new Date().toISOString() } ) ) ) ) ) .orderBy("appeals.created") .getMany(); } /** * allAppeals gets all appeals for a specific userID in a guild. * * @param {string} userID The userID to query for. * @param {string} guildID The guildID to query for. If not provided, will query for all appeals. */ static async allAppeals(userID: string, guild?: string) { const query = Appeal.createQueryBuilder("appeals") .where("appeals.userID = :userID", { userID }) .leftJoinAndSelect("appeals.guild", "guilds") .orderBy("appeals.created", "DESC"); // Conditionally add guild filter if (guild) query.andWhere("appeals.guildId = :guild", { guild }); return await query.getMany(); } /** * appeal gets a specific appeal, and will return a rejected promise if the appeal is not found. * While the appeal ID is not a unique identifier, also specifying the * guildID is done so staff can't accidentally query appeals from other * guilds. */ static async appeal(id: string, guild?: string) { const query = Appeal.createQueryBuilder("appeals") .where("appeals.id = :id", { id }) .leftJoinAndSelect("appeals.guild", "guilds"); if (guild) query.andWhere("guilds.guild = :guild", { guild }); return await query.getOne(); } }