diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..80ee357 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,4 @@ +DDB_HOST=localhost +DDB_PORT=5432 + +DDB_BOT_TOKEN= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 780c7fc..9b98040 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,6 @@ logs/ .DS_Store eslint-results.sarif -.env - # Yarn new stuff .yarn/* !.yarn/cache @@ -22,6 +20,7 @@ eslint-results.sarif !.yarn/versions .env* +!.env.local.example .flaskenv* !.env.project !.env.vault diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c0377e4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + postgres: + image: postgres:16-alpine + container_name: devden_db + environment: + POSTGRES_USER: root + POSTGRES_PASSWORD: password + POSTGRES_DB: database + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + +volumes: + postgres_data: diff --git a/src/Config.prod.ts b/src/Config.prod.ts index 9d930f1..ed41e6d 100644 --- a/src/Config.prod.ts +++ b/src/Config.prod.ts @@ -49,6 +49,12 @@ export const config: Config = { yesEmojiId: "997496973093502986", noEmojiId: "1012427085798723666", }, + suggest: { + suggestionsChannel: "", + archiveChannel: "", + yesEmojiId: "thumbsup", + noEmojiId: "thumbsdown", + }, pastebin: { url: "https://paste.developerden.org", threshold: 20, diff --git a/src/Config.ts b/src/Config.ts index 678e365..776c2db 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -37,6 +37,12 @@ export const config: Config = { yesEmojiId: "thumbsup", noEmojiId: "thumbsdown", }, + suggest: { + suggestionsChannel: "1407001821674868746", + archiveChannel: "1407001847239016550", + yesEmojiId: "👍", + noEmojiId: "👎", + }, pastebin: prodConfig.pastebin, branding: { color: "#ffffff", diff --git a/src/config.type.ts b/src/config.type.ts index 3ddc081..838781b 100644 --- a/src/config.type.ts +++ b/src/config.type.ts @@ -20,6 +20,12 @@ export interface Config { introductions?: string; general: string; }; + suggest: { + suggestionsChannel: string; + archiveChannel: string; + yesEmojiId: string; + noEmojiId: string; + }; commands: { daily: Snowflake; }; diff --git a/src/index.ts b/src/index.ts index 4514b21..44624f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { initStorage } from "./store/storage.js"; import { initSentry } from "./sentry.js"; import { logger } from "./logging.js"; import { startHealthCheck } from "./healthcheck.js"; +import SuggestModule from "./modules/suggest/suggest.module.js"; const client = new Client({ intents: [ @@ -55,6 +56,7 @@ export const moduleManager = new ModuleManager( ShowcaseModule, TokenScannerModule, XpModule, + SuggestModule, ], ); diff --git a/src/modules/suggest/suggest.command.ts b/src/modules/suggest/suggest.command.ts new file mode 100644 index 0000000..71c6f1c --- /dev/null +++ b/src/modules/suggest/suggest.command.ts @@ -0,0 +1,152 @@ +import { + ActionRowBuilder, + ApplicationCommandOptionType, + ApplicationCommandType, + Attachment, + ButtonBuilder, + ButtonStyle, + ChatInputCommandInteraction, + GuildMember, +} from "discord.js"; +import { Command } from "djs-slash-helper"; +import { config } from "../../Config.js"; +import { + createSuggestion, + createSuggestionEmbed, + SUGGESTION_MANAGE_ID, + SUGGESTION_NO_ID, + SUGGESTION_VIEW_VOTES_ID, + SUGGESTION_YES_ID, +} from "./suggest.js"; + +function isEmbeddableImage(attachment: Attachment): boolean { + if (!attachment.contentType) return false; + + // Check for standard image types and GIFs that can be embedded + const embeddableTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + ]; + + return embeddableTypes.includes(attachment.contentType.toLowerCase()); +} + +export const SuggestCommand: Command = { + name: "suggest", + description: "Create a suggestion", + type: ApplicationCommandType.ChatInput, + options: [ + { + type: ApplicationCommandOptionType.String, + name: "suggestion", + description: "The suggestion", + required: true, + }, + { + type: ApplicationCommandOptionType.Attachment, + name: "image", + description: "Image that is relevant to the suggestion", + required: false, + }, + ], + handle: async (interaction: ChatInputCommandInteraction) => { + if (!interaction.member || !interaction.inGuild()) { + await interaction.reply({ + flags: ["Ephemeral"], + content: "We are not in a guild?", + }); + } + + const member = interaction.member as GuildMember; + + await interaction.deferReply({ + flags: ["Ephemeral"], + }); + // Get the suggestion and optional image + const suggestionText = interaction.options.get("suggestion") + ?.value as string; + + const suggestionImage = interaction.options.getAttachment("image"); + + if (suggestionImage && !isEmbeddableImage(suggestionImage)) { + await interaction.followUp({ + content: "Your upload needs to be a image!", + }); + return; + } + + const suggestionChannel = await interaction.client.channels.fetch( + config.suggest.suggestionsChannel, + ); + + if (!suggestionChannel) { + await interaction.followUp({ + content: "There is no Suggestion channel!", + flags: ["Ephemeral"], + }); + return; + } + if (!suggestionChannel.isSendable() || !suggestionChannel.isTextBased()) { + await interaction.followUp({ + content: + "The suggestion channel is either not writeable or not a text channel!", + flags: ["Ephemeral"], + }); + return; + } + + const suggestionId = interaction.id; + + const embed = createSuggestionEmbed( + suggestionId, + member, + suggestionText, + suggestionImage?.proxyURL, + ); + + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(SUGGESTION_YES_ID) + .setStyle(ButtonStyle.Success) + .setEmoji(config.suggest.yesEmojiId), + new ButtonBuilder() + .setCustomId(SUGGESTION_NO_ID) + .setStyle(ButtonStyle.Danger) + .setEmoji(config.suggest.noEmojiId), + new ButtonBuilder() + .setCustomId(SUGGESTION_VIEW_VOTES_ID) + .setStyle(ButtonStyle.Secondary) + .setEmoji("👁") + .setLabel("View Votes"), + new ButtonBuilder() + .setStyle(ButtonStyle.Secondary) + .setCustomId(SUGGESTION_MANAGE_ID) + .setEmoji("🎛"), + ); + const response = await suggestionChannel.send({ + embeds: [embed], + components: [buttons], + }); + + await response.startThread({ + name: "Suggestion discussion thread", + reason: `User ${member.displayName} created a suggestion`, + }); + + await interaction.followUp({ + content: `Suggestion with the ID \`${suggestionId}\` successfully submitted! See [here](${response.url})`, + flags: ["Ephemeral"], + }); + + await createSuggestion( + BigInt(suggestionId), + BigInt(member.id), + BigInt(response.id), + suggestionText, + suggestionImage?.proxyURL, + ); + }, +}; diff --git a/src/modules/suggest/suggest.listener.ts b/src/modules/suggest/suggest.listener.ts new file mode 100644 index 0000000..26ce630 --- /dev/null +++ b/src/modules/suggest/suggest.listener.ts @@ -0,0 +1,273 @@ +import { EventListener } from "../module.js"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + GuildMember, + Interaction, +} from "discord.js"; +import { + createSuggestionEmbedFromEntity, + createSuggestionManageButtons, + createVotesEmbed, + getSuggestionByMessageId, + SUGGESTION_MANAGE_APPROVE_ID, + SUGGESTION_MANAGE_ID, + SUGGESTION_MANAGE_REJECT_ID, + SUGGESTION_NO_ID, + SUGGESTION_VIEW_VOTES_ID, + SUGGESTION_YES_ID, + SuggestionVoteType, + upsertVote, +} from "./suggest.js"; +import { SuggestionStatus } from "../../store/models/Suggestion.js"; +import { config } from "../../Config.js"; + +const SUGGESTION_BUTTON_MAP: { + [key: string]: SuggestionVoteType; +} = { + "suggestion-no": -1, + "suggestion-yes": 1, +}; + +export const SuggestionButtonListener: EventListener = { + async interactionCreate(client, interaction: Interaction) { + if ( + !interaction.isButton() || + !interaction.member || + !interaction.inGuild() + ) + return; + const member = interaction.member as GuildMember; + if ( + interaction.customId === SUGGESTION_NO_ID || + interaction.customId === SUGGESTION_YES_ID + ) { + if (!interaction.message.editable) { + await interaction.reply({ + content: "This suggestion is no longer editable!", + flags: ["Ephemeral"], + }); + return; + } + + await interaction.deferReply({ flags: ["Ephemeral"] }); + + const votingValue = SUGGESTION_BUTTON_MAP[ + interaction.customId as keyof typeof SUGGESTION_BUTTON_MAP + ] as SuggestionVoteType; + + const suggestion = await getSuggestionByMessageId( + BigInt(interaction.message.id), + ); + + if (suggestion == null) { + await interaction.followUp({ + content: "No Suggestion found for this message", + flags: ["Ephemeral"], + }); + return; + } + + const previousVoteValue = await upsertVote( + suggestion.id, + BigInt(member.id), + votingValue, + ); + + await suggestion.reload(); + await interaction.message.edit({ + embeds: [ + await createSuggestionEmbedFromEntity( + suggestion, + interaction.member as GuildMember, + ), + ], + }); + + let content = `You ${previousVoteValue && previousVoteValue === votingValue ? "already " : ""}voted ${votingValue === 1 ? "**Yes**" : "**No**"} on this suggestion`; + if (previousVoteValue && previousVoteValue !== votingValue) { + content = `You changed your vote from ${previousVoteValue === 1 ? "**Yes**" : "**No**"} to ${votingValue === 1 ? "**Yes**" : "**No**"} on this suggestion`; + } + await interaction.followUp({ + content: content, + flags: ["Ephemeral"], + }); + } else if (interaction.customId === SUGGESTION_VIEW_VOTES_ID) { + await interaction.deferReply({ flags: ["Ephemeral"] }); + const suggestion = await getSuggestionByMessageId( + BigInt(interaction.message.id), + ); + if (!suggestion) { + await interaction.followUp({ + content: "No Suggestion found for this message", + flags: ["Ephemeral"], + }); + return; + } + const yesVotes = + suggestion.votes?.filter((vote) => vote.vote === 1) || []; + const noVotes = + suggestion.votes?.filter((vote) => vote.vote === -1) || []; + + const embed = createVotesEmbed(member, yesVotes, noVotes); + + await interaction.followUp({ + embeds: [embed], + flags: ["Ephemeral"], + }); + } else if (interaction.customId === SUGGESTION_MANAGE_ID) { + const row = createSuggestionManageButtons(); + + await interaction.reply({ + content: "Manage Suggestion", + components: [row], + flags: ["Ephemeral"], + }); + } else if (interaction.customId === SUGGESTION_MANAGE_APPROVE_ID) { + await interaction.deferReply({ flags: ["Ephemeral"] }); + const initialMessage = await interaction.message.fetchReference(); + const suggestion = await getSuggestionByMessageId( + BigInt(initialMessage.id), + ); + if (!suggestion) { + await interaction.followUp({ + content: "No Suggestion found for this message", + flags: ["Ephemeral"], + }); + return; + } + + suggestion.status = SuggestionStatus.APPROVED; + suggestion.moderatorId = BigInt(member.id); + await suggestion.save(); + + const suggestionArchive = await client.channels.fetch( + config.suggest.archiveChannel, + ); + if (suggestionArchive) { + if ( + !suggestionArchive.isSendable() || + !suggestionArchive.isTextBased() + ) { + await interaction.followUp({ + content: + "The suggestion channel is either not writeable or not a text channel!", + flags: ["Ephemeral"], + }); + return; + } + + try { + const member = await interaction.guild!.members.fetch( + suggestion.memberId.toString(), + ); + + const embed = await createSuggestionEmbedFromEntity( + suggestion, + member, + ); + + const newMessage = await suggestionArchive.send({ + embeds: [embed], + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(SUGGESTION_VIEW_VOTES_ID) + .setStyle(ButtonStyle.Secondary) + .setEmoji("👁") + .setLabel("View Votes"), + ), + ], + }); + if (initialMessage.deletable) await initialMessage.delete(); + suggestion.messageId = BigInt(newMessage.id); + await suggestion.save(); + await interaction.followUp({ + content: "Suggestion approved!", + flags: ["Ephemeral"], + }); + } catch (e) { + console.error(e); + await interaction.followUp({ + content: + "Something went wrong while archiving the suggestion! Please try again later!", + flags: ["Ephemeral"], + }); + } + } + } else if (interaction.customId === SUGGESTION_MANAGE_REJECT_ID) { + const initialMessage = await interaction.message.fetchReference(); + await interaction.deferReply({ flags: ["Ephemeral"] }); + const suggestion = await getSuggestionByMessageId( + BigInt(initialMessage.id), + ); + if (!suggestion) { + await interaction.followUp({ + content: "No Suggestion found for this message", + flags: ["Ephemeral"], + }); + return; + } + + suggestion.status = SuggestionStatus.REJECTED; + suggestion.moderatorId = BigInt(member.id); + await suggestion.save(); + + const suggestionArchive = await client.channels.fetch( + config.suggest.archiveChannel, + ); + if (suggestionArchive) { + if ( + !suggestionArchive.isSendable() || + !suggestionArchive.isTextBased() + ) { + await interaction.followUp({ + content: + "The suggestion channel is either not writeable or not a text channel!", + flags: ["Ephemeral"], + }); + return; + } + + try { + const member = await interaction.guild!.members.fetch( + suggestion.memberId.toString(), + ); + + const embed = await createSuggestionEmbedFromEntity( + suggestion, + member, + ); + + const newMessage = await suggestionArchive.send({ + embeds: [embed], + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(SUGGESTION_VIEW_VOTES_ID) + .setStyle(ButtonStyle.Secondary) + .setEmoji("👁") + .setLabel("View Votes"), + ), + ], + }); + if (initialMessage.deletable) await initialMessage.delete(); + suggestion.messageId = BigInt(newMessage.id); + await suggestion.save(); + await interaction.followUp({ + content: "Suggestion rejected!", + flags: ["Ephemeral"], + }); + } catch (e) { + console.error(e); + await interaction.followUp({ + content: + "Something went wrong while archiving the suggestion! Please try again later!", + flags: ["Ephemeral"], + }); + } + } + } + }, +}; diff --git a/src/modules/suggest/suggest.module.ts b/src/modules/suggest/suggest.module.ts new file mode 100644 index 0000000..29f6d43 --- /dev/null +++ b/src/modules/suggest/suggest.module.ts @@ -0,0 +1,11 @@ +import Module from "../module.js"; +import { SuggestCommand } from "./suggest.command.js"; +import { SuggestionButtonListener } from "./suggest.listener.js"; + +export const SuggestModule: Module = { + name: "suggest", + commands: [SuggestCommand], + listeners: [SuggestionButtonListener], +}; + +export default SuggestModule; diff --git a/src/modules/suggest/suggest.ts b/src/modules/suggest/suggest.ts new file mode 100644 index 0000000..7a1bee8 --- /dev/null +++ b/src/modules/suggest/suggest.ts @@ -0,0 +1,246 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + GuildMember, +} from "discord.js"; +import { createStandardEmbed } from "../../util/embeds.js"; +import { actualMention, actualMentionById } from "../../util/users.js"; +import { Suggestion, SuggestionStatus } from "../../store/models/Suggestion.js"; +import { SuggestionVote } from "../../store/models/SuggestionVote.js"; +import { config } from "../../Config.js"; + +export const SUGGESTION_ID_FIELD_NAME = "Suggestion ID"; + +export const SUGGESTION_YES_ID = "suggestion-yes"; +export const SUGGESTION_NO_ID = "suggestion-no"; +export const SUGGESTION_MANAGE_ID = "suggestion-manage"; + +export const SUGGESTION_MANAGE_APPROVE_ID = "suggestion-manage-approve"; +export const SUGGESTION_MANAGE_REJECT_ID = "suggestion-manage-reject"; + +export const SUGGESTION_VIEW_VOTES_ID = "suggestion-view-votes"; + +export type SuggestionVoteType = 1 | -1; + +export const createSuggestionEmbed: ( + id: string, + member: GuildMember, + suggestionText: string, + suggestionImage?: string, + upVotes?: number, + downVotes?: number, + status?: SuggestionStatus, + moderatorId?: string, +) => EmbedBuilder = ( + id: string, + member, + suggestionText, + suggestionImage, + upvotes = 0, + downVotes = 0, + status, + moderatorId, +) => { + const builder = createStandardEmbed(member); + + if (status) { + builder.addFields({ + name: "Status", + value: `**${status}**`, + }); + builder.setColor(status === SuggestionStatus.REJECTED ? "Red" : "Green"); + if (moderatorId) { + builder.addFields({ + name: `${status === SuggestionStatus.REJECTED ? "**Denied**" : "**Approved**"} By`, + value: actualMentionById(BigInt(moderatorId)), + }); + } + } + + builder.addFields([ + { + name: "Submitter", + value: actualMention(member), + inline: true, + }, + { + name: SUGGESTION_ID_FIELD_NAME, + value: id, + }, + { + name: "Suggestion", + value: suggestionText, + }, + { + name: status === SuggestionStatus.PENDING ? "Current Votes" : "Results", + value: `------------- + :white_check_mark::\`${upvotes}\` + :x::\`${downVotes}\` + `, + }, + ]); + + if (suggestionImage) { + builder.setImage(suggestionImage); + } + + return builder; +}; + +export const createSuggestionEmbedFromEntity: ( + suggestion: Suggestion, + member: GuildMember, +) => Promise = async (suggestion: Suggestion, member) => { + const upvotes = suggestion.votes?.filter((vote) => vote.vote === 1).length; + const downvotes = suggestion.votes?.filter((vote) => vote.vote === -1).length; + + return createSuggestionEmbed( + suggestion.id.toString(), + member, + suggestion.suggestionText, + suggestion.suggestionImageUrl, + upvotes ?? 0, + downvotes ?? 0, + suggestion.status !== SuggestionStatus.PENDING + ? suggestion.status + : undefined, + suggestion.moderatorId ? suggestion.moderatorId.toString() : undefined, + ); +}; + +export const getSuggestionByMessageId: ( + messageId: bigint, +) => Promise = async (messageId: bigint) => { + return await Suggestion.findOne({ + where: { + messageId: messageId, + }, + include: [SuggestionVote], + }); +}; + +export const createSuggestion: ( + id: bigint, + userId: bigint, + messageId: bigint, + suggestionText: string, + suggestionImage: string | undefined, +) => Promise = async ( + id: bigint, + userId: bigint, + messageId: bigint, + suggestionText: string, + suggestionImageUrl: string | undefined, +) => { + return await Suggestion.create({ + id: id, + suggestionImageUrl: suggestionImageUrl, + memberId: userId, + suggestionText: suggestionText, + messageId: messageId, + status: SuggestionStatus.PENDING, + }); +}; + +export const getVoteForMemberAndSuggestion: ( + suggestionId: bigint, + memberId: bigint, +) => Promise = async ( + suggestionId: bigint, + memberId: bigint, +) => { + return await SuggestionVote.findOne({ + where: { + suggestionId: suggestionId, + memberId: memberId, + }, + }); +}; + +export const upsertVote: ( + suggestionId: bigint, + memberId: bigint, + vote: SuggestionVoteType, +) => Promise = async ( + suggestionId: bigint, + memberId: bigint, + vote: SuggestionVoteType, +) => { + // insert or update vote + const existingVote = await getVoteForMemberAndSuggestion( + suggestionId, + memberId, + ); + if (existingVote) { + const previousVote = existingVote.vote; + if (existingVote.vote === vote) { + return previousVote as SuggestionVoteType; + } + + existingVote.vote = vote; + await existingVote.save(); + return previousVote as SuggestionVoteType; + } else { + await createVote(suggestionId, memberId, vote); + return undefined; + } +}; + +export const createVote: ( + suggestionId: bigint, + memberId: bigint, + vote: SuggestionVoteType, +) => Promise = async ( + suggestionId: bigint, + memberId: bigint, + vote: SuggestionVoteType, +) => { + return await SuggestionVote.create({ + suggestionId: suggestionId, + memberId: memberId, + vote: vote, + }); +}; + +export const createVotesEmbed: ( + member: GuildMember, + upvotes: SuggestionVote[], + downvotes: SuggestionVote[], +) => EmbedBuilder = (member, upvotes, downvotes) => { + return createStandardEmbed(member) + .setTitle("Suggestion Votes") + .addFields([ + { + name: `${config.suggest.yesEmojiId} Upvotes`, + value: upvotes + .map((vote) => actualMentionById(vote.memberId)) + .join("\n"), + inline: true, + }, + { + name: `${config.suggest.noEmojiId} Downvotes`, + value: downvotes + .map((vote) => actualMentionById(vote.memberId)) + .join("\n"), + inline: true, + }, + ]); +}; + +export const createSuggestionManageButtons: () => ActionRowBuilder = + () => { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setStyle(ButtonStyle.Success) + .setCustomId(SUGGESTION_MANAGE_APPROVE_ID) + .setEmoji("✅") + .setLabel("Approve"), + new ButtonBuilder() + .setStyle(ButtonStyle.Danger) + .setCustomId(SUGGESTION_MANAGE_REJECT_ID) + .setEmoji("❌") + .setLabel("Reject/Close"), + ); + }; diff --git a/src/store/models/Suggestion.ts b/src/store/models/Suggestion.ts new file mode 100644 index 0000000..547276a --- /dev/null +++ b/src/store/models/Suggestion.ts @@ -0,0 +1,73 @@ +import { + DataTypes, + InferAttributes, + InferCreationAttributes, + Model, +} from "@sequelize/core"; +import { + AllowNull, + Attribute, + ColumnName, + Default, + HasMany, + NotNull, + PrimaryKey, + Table, + Unique, +} from "@sequelize/core/decorators-legacy"; +import { RealBigInt } from "../RealBigInt.js"; +import { SuggestionVote } from "./SuggestionVote.js"; + +export enum SuggestionStatus { + PENDING = "PENDING", + APPROVED = "APPROVED", + REJECTED = "REJECTED", +} + +@Table({ + tableName: "Suggestion", +}) +export class Suggestion extends Model< + InferAttributes, + InferCreationAttributes +> { + @Attribute(RealBigInt) + @PrimaryKey + @Unique + @NotNull + declare public id: bigint; + + @Attribute(RealBigInt) + @NotNull + @ColumnName("messageId") + public messageId!: bigint; + + @Attribute(RealBigInt) + @NotNull + @ColumnName("memberId") + public memberId!: bigint; + + @Attribute(DataTypes.STRING) + @NotNull + @ColumnName("suggestionText") + public suggestionText!: string; + + @Attribute(DataTypes.STRING) + @AllowNull + @ColumnName("suggestionImageUrl") + public suggestionImageUrl: string | undefined; + + @Attribute(DataTypes.ENUM(SuggestionStatus)) + @Default(SuggestionStatus.PENDING) + @NotNull + @ColumnName("status") + public status: SuggestionStatus = SuggestionStatus.PENDING; + + @Attribute(RealBigInt) + @AllowNull + @ColumnName("moderatorId") + declare public moderatorId: bigint | undefined; + + @HasMany(() => SuggestionVote, "suggestionId") + declare public votes?: SuggestionVote[]; +} diff --git a/src/store/models/SuggestionVote.ts b/src/store/models/SuggestionVote.ts new file mode 100644 index 0000000..92f6166 --- /dev/null +++ b/src/store/models/SuggestionVote.ts @@ -0,0 +1,45 @@ +import { + DataTypes, + InferAttributes, + InferCreationAttributes, + Model, +} from "@sequelize/core"; +import { + AllowNull, + Attribute, + BelongsTo, + ColumnName, + NotNull, + Table, + Unique, +} from "@sequelize/core/decorators-legacy"; +import { RealBigInt } from "../RealBigInt.js"; +import { Suggestion } from "./Suggestion.js"; + +@Table({ + tableName: "SuggestionVote", +}) +export class SuggestionVote extends Model< + InferAttributes, + InferCreationAttributes +> { + @Attribute(RealBigInt) + @NotNull + @ColumnName("suggestionId") + @Unique("unique_suggestion_member") + public suggestionId!: bigint; + + @Attribute(RealBigInt) + @NotNull + @ColumnName("memberId") + @Unique("unique_suggestion_member") + public memberId!: bigint; + + @Attribute(DataTypes.TINYINT) + @AllowNull + @ColumnName("vote") + public vote!: number; + + @BelongsTo(() => Suggestion, "suggestionId") + declare public suggestion?: Suggestion; +} diff --git a/src/store/storage.ts b/src/store/storage.ts index 230f5b8..64b0f1e 100644 --- a/src/store/storage.ts +++ b/src/store/storage.ts @@ -7,6 +7,8 @@ import { AbstractDialect, DialectName, Sequelize } from "@sequelize/core"; import { SqliteDialect } from "@sequelize/sqlite3"; import { ConnectionConfig } from "pg"; import { Bump } from "./models/Bump.js"; +import { Suggestion } from "./models/Suggestion.js"; +import { SuggestionVote } from "./models/SuggestionVote.js"; function sequelizeLog(sql: string, timing?: number) { if (timing) { @@ -54,7 +56,7 @@ export async function initStorage() { } await sequelize.authenticate(); - const models = [DDUser, ColourRoles, FAQ, Bump]; + const models = [DDUser, ColourRoles, FAQ, Bump, Suggestion, SuggestionVote]; sequelize.addModels(models); Bump.belongsTo(DDUser, { diff --git a/src/util/users.ts b/src/util/users.ts index 44fbae7..2adfd02 100644 --- a/src/util/users.ts +++ b/src/util/users.ts @@ -26,6 +26,8 @@ export const actualMention = ( user: GuildMember | User | PartialGuildMember, ): string => `<@${user.id}>`; +export const actualMentionById = (id: bigint): string => `<@${id}>`; + export const mentionWithNoPingMessage = (user: GuildMember): string => userShouldBePinged(user) ? `<@${user.id}> (Don't want to be pinged? )`