diff --git a/prisma/dev.db b/prisma/dev.db index a408f40..7ed0d18 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/prisma/dev.db-journal b/prisma/dev.db-journal new file mode 100644 index 0000000..76e7dee Binary files /dev/null and b/prisma/dev.db-journal differ diff --git a/prisma/migrations/20250414211601_config_model/migration.sql b/prisma/migrations/20250414211601_config_model/migration.sql new file mode 100644 index 0000000..9ce9c57 --- /dev/null +++ b/prisma/migrations/20250414211601_config_model/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "Config" ( + "guild_id" TEXT NOT NULL PRIMARY KEY, + "setting_type" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE INDEX "Config_guild_id_setting_type_idx" ON "Config"("guild_id", "setting_type"); diff --git a/prisma/migrations/20250414212408_update_config_id/migration.sql b/prisma/migrations/20250414212408_update_config_id/migration.sql new file mode 100644 index 0000000..26da000 --- /dev/null +++ b/prisma/migrations/20250414212408_update_config_id/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Config" ( + "guild_id" TEXT NOT NULL, + "setting_type" TEXT NOT NULL, + "value" TEXT NOT NULL, + + PRIMARY KEY ("guild_id", "setting_type") +); +INSERT INTO "new_Config" ("guild_id", "setting_type", "value") SELECT "guild_id", "setting_type", "value" FROM "Config"; +DROP TABLE "Config"; +ALTER TABLE "new_Config" RENAME TO "Config"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250414213749_update_config_casing/migration.sql b/prisma/migrations/20250414213749_update_config_casing/migration.sql new file mode 100644 index 0000000..cbe3f9f --- /dev/null +++ b/prisma/migrations/20250414213749_update_config_casing/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `guild_id` on the `Config` table. All the data in the column will be lost. + - You are about to drop the column `setting_type` on the `Config` table. All the data in the column will be lost. + - Added the required column `guildId` to the `Config` table without a default value. This is not possible if the table is not empty. + - Added the required column `settingType` to the `Config` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Config" ( + "guildId" TEXT NOT NULL, + "settingType" TEXT NOT NULL, + "value" TEXT NOT NULL, + + PRIMARY KEY ("guildId", "settingType") +); +INSERT INTO "new_Config" ("value") SELECT "value" FROM "Config"; +DROP TABLE "Config"; +ALTER TABLE "new_Config" RENAME TO "Config"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 95ecc6e..6182403 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,28 +13,28 @@ model DiscordUser { username String discriminator String? avatar String? - roles String // Stored as JSON string of role IDs + roles String // Stored as JSON string of role IDs joinedAt DateTime @default(now()) lastSeen DateTime @default(now()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations - teams TeamMember[] - events EventParticipant[] - teamsLed Team[] // Teams where this user is the leader - submissions Submission[] + teams TeamMember[] + events EventParticipant[] + teamsLed Team[] // Teams where this user is the leader + submissions Submission[] } model Event { - id String @id @default(uuid()) + id String @id @default(uuid()) name String description String? startDate DateTime endDate DateTime? - status String @default("PLANNED") // PLANNED, ONGOING, COMPLETED, CANCELLED - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + status String @default("PLANNED") // PLANNED, ONGOING, COMPLETED, CANCELLED + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations participants EventParticipant[] @@ -48,7 +48,7 @@ model Team { description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - leaderId String // ID of the team leader + leaderId String // ID of the team leader leader DiscordUser @relation(fields: [leaderId], references: [id]) // Relations @@ -59,46 +59,54 @@ model Team { // Junction tables for many-to-many relationships model TeamMember { - id String @id @default(uuid()) - userId String - teamId String - role String @default("MEMBER") // LEADER, MEMBER, etc. - joinedAt DateTime @default(now()) - user DiscordUser @relation(fields: [userId], references: [id]) - team Team @relation(fields: [teamId], references: [id]) + id String @id @default(uuid()) + userId String + teamId String + role String @default("MEMBER") // LEADER, MEMBER, etc. + joinedAt DateTime @default(now()) + user DiscordUser @relation(fields: [userId], references: [id]) + team Team @relation(fields: [teamId], references: [id]) @@unique([userId, teamId]) } model EventParticipant { - id String @id @default(uuid()) - userId String - eventId String - status String @default("REGISTERED") // REGISTERED, INTERESTED, UNPAID - user DiscordUser @relation(fields: [userId], references: [id]) - event Event @relation(fields: [eventId], references: [id]) + id String @id @default(uuid()) + userId String + eventId String + status String @default("REGISTERED") // REGISTERED, INTERESTED, UNPAID + user DiscordUser @relation(fields: [userId], references: [id]) + event Event @relation(fields: [eventId], references: [id]) @@unique([userId, eventId]) } model Submission { - id String @id @default(uuid()) - name String // Name of the submitted item - value String // Value of the submitted item - proofUrl String // URL to the proof image - status String @default("PENDING") // PENDING, APPROVED, REJECTED - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + name String // Name of the submitted item + value String // Value of the submitted item + proofUrl String // URL to the proof image + status String @default("PENDING") // PENDING, APPROVED, REJECTED + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - userId String // ID of the user who submitted - eventId String // ID of the event this submission is for - teamId String // ID of the team this submission belongs to - user DiscordUser @relation(fields: [userId], references: [id]) - event Event @relation(fields: [eventId], references: [id]) - team Team @relation(fields: [teamId], references: [id]) + userId String // ID of the user who submitted + eventId String // ID of the event this submission is for + teamId String // ID of the team this submission belongs to + user DiscordUser @relation(fields: [userId], references: [id]) + event Event @relation(fields: [eventId], references: [id]) + team Team @relation(fields: [teamId], references: [id]) @@index([userId]) @@index([eventId]) @@index([teamId]) } + +model Config { + guildId String + settingType String + value String + + @@id([guildId, settingType]) +} diff --git a/src/commands/admin/adminrole.js b/src/commands/admin/adminrole.js new file mode 100644 index 0000000..6ea8229 --- /dev/null +++ b/src/commands/admin/adminrole.js @@ -0,0 +1,34 @@ +import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { ConfigService } from '#services/configService.js'; +import logger from '#utils/logger.js'; + +export const data = new SlashCommandBuilder() + .setName('adminrole') + .setDescription('Get or set admin role') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addRoleOption((option) => + option.setName('role').setDescription('Role to be set as admin role').setRequired(false) + ); + +/** + * @param {ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + const role = interaction.options.getRole('role'); + + // if role doesn't exist, get role channel if it exists + if (!role) { + const currentRole = await ConfigService.getAdminRole(interaction.guildId); + if (currentRole) { + await interaction.reply(`Current admin role: <@&${currentRole}>`); + } else { + await interaction.reply('No admin role set.'); + } + return; + } + + // if role exists, set it as the admin role + await ConfigService.setAdminRole(interaction.guildId, role.id); + await interaction.reply(`Admin role set to <@&${role.id}>`); + logger.info(`Admin role set to ${role.id} for guild ${interaction.guildId}`); +} diff --git a/src/commands/admin/approvechannel.js b/src/commands/admin/approvechannel.js new file mode 100644 index 0000000..4986270 --- /dev/null +++ b/src/commands/admin/approvechannel.js @@ -0,0 +1,53 @@ +import { ChannelType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; +import { ConfigService } from '#services/configService.js'; +import logger from '#utils/logger.js'; + +export const data = new SlashCommandBuilder() + .setName('approvechannel') + .setDescription('Get or set the approval submission channel') + .addChannelOption((option) => + option + .setName('channel') + .setDescription('Moderated channel for submissions to be forwarded to') + .setRequired(false) + .addChannelTypes(ChannelType.GuildText) + ); + +/** + * @param {ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + const channel = interaction.options.getChannel('channel'); + + // if channel doesn't exist, get current channel if it exists + if (!channel) { + const currentChannel = await ConfigService.getApprovalChannel(interaction.guildId); + if (currentChannel) { + await interaction.reply(`Current approval channel: <#${currentChannel}>`); + } else { + await interaction.reply('No approval channel set.'); + } + return; + } + + // Check if user has admin permissions + // Check custom role first + if (!interaction.member.permissions.has('Administrator')) { + const customRole = await ConfigService.getAdminRole(interaction.guildId); + if (customRole) { + const member = await interaction.guild.members.fetch(interaction.user.id); + if (!member.roles.cache.has(customRole)) { + await interaction.reply('You do not have permission to set the approval channel.'); + return; + } + } else { + await interaction.reply('You do not have permission to set the approval channel.'); + return; + } + } + + // if channel exists, set it as the approval channel + await ConfigService.setApprovalChannel(interaction.guildId, channel.id); + await interaction.reply(`Approval channel set to <#${channel.id}>`); + logger.info(`Approval channel set to ${channel.id} for guild ${interaction.guildId}`); +} diff --git a/src/constants/settings.js b/src/constants/settings.js new file mode 100644 index 0000000..0532826 --- /dev/null +++ b/src/constants/settings.js @@ -0,0 +1,4 @@ +export const SettingTypes = { + APPROVAL_CHANNEL: 'approval_channel', + ADMIN_ROLE: 'admin_role', +}; diff --git a/src/services/configService.js b/src/services/configService.js new file mode 100644 index 0000000..6905e25 --- /dev/null +++ b/src/services/configService.js @@ -0,0 +1,135 @@ +import prisma from '#utils/prisma.js'; +import logger from '#utils/logger.js'; +import { SettingTypes } from '#constants/settings.js'; + +export class ConfigService { + /** + * Get the approval channel for a guild + * @param {String} guildId - A Discord guild ID + * @returns {Promise} The approval channel ID + */ + static async getApprovalChannel(guildId) { + try { + const config = await prisma.config.findUnique({ + where: { + guildId_settingType: { + guildId, + settingType: SettingTypes.APPROVAL_CHANNEL, + }, + }, + }); + + return config?.value; + } catch (error) { + logger.error(`Error getting approval channel for guild ${guildId}:`, error); + throw error; + } + } + + /** + * Set the approval channel for a guild + * @param {String} guildId - A Discord guild ID + * @param {String} channelId - The approval channel ID + * @returns {Promise} + */ + static async setApprovalChannel(guildId, channelId) { + try { + const existingConfig = await prisma.config.findUnique({ + where: { + guildId_settingType: { + guildId, + settingType: SettingTypes.APPROVAL_CHANNEL, + }, + }, + }); + + if (existingConfig) { + await prisma.config.update({ + where: { + guildId_settingType: { + guildId, + settingType: SettingTypes.APPROVAL_CHANNEL, + }, + }, + data: { value: channelId }, + }); + } else { + await prisma.config.create({ + data: { + guildId, + settingType: SettingTypes.APPROVAL_CHANNEL, + value: channelId, + }, + }); + } + } catch (error) { + logger.error(`Error setting approval channel for guild ${guildId}:`, error); + throw error; + } + } + + /** + * Get the admin role for a guild + * @param {String} guildId - A Discord guild ID + * @returns {Promise} The admin role ID + */ + static async getAdminRole(guildId) { + try { + const config = await prisma.config.findUnique({ + where: { + guildId_settingType: { + guildId, + settingType: SettingTypes.ADMIN_ROLE, + }, + }, + }); + + return config?.value; + } catch (error) { + logger.error(`Error getting admin role for guild ${guildId}:`, error); + throw error; + } + } + + /** + * Set the admin role for a guild + * @param {String} guildId - A Discord guild ID + * @param {String} roleId - The admin role ID + * @returns {Promise} + */ + static async setAdminRole(guildId, roleId) { + try { + const existingConfig = await prisma.config.findUnique({ + where: { + guildId_settingType: { + guildId, + settingType: SettingTypes.ADMIN_ROLE, + }, + }, + }); + + if (existingConfig) { + await prisma.config.update({ + where: { + guildId_settingType: { + guildId, + settingType: SettingTypes.ADMIN_ROLE, + }, + }, + data: { value: roleId }, + }); + } else { + await prisma.config.create({ + data: { + guildId, + settingType: SettingTypes.ADMIN_ROLE, + value: roleId, + }, + }); + } + } catch (error) { + logger.error(`Error setting admin role for guild ${guildId}:`, error); + throw error; + } + } +} diff --git a/src/services/userService.js b/src/services/userService.js index e4212d1..a6527e4 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -1,8 +1,6 @@ -import { PrismaClient } from '@prisma/client'; +import prisma from '#utils/prisma.js'; import logger from '#utils/logger.js'; -const prisma = new PrismaClient(); - export class UserService { /** * Sync a Discord member to the database diff --git a/src/utils/prisma.js b/src/utils/prisma.js new file mode 100644 index 0000000..4e54f7a --- /dev/null +++ b/src/utils/prisma.js @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export default prisma; diff --git a/utils/db.js b/utils/db.js deleted file mode 100644 index 0dbc802..0000000 --- a/utils/db.js +++ /dev/null @@ -1,5 +0,0 @@ -const { PrismaClient } = require('@prisma/client'); - -const prisma = new PrismaClient(); - -module.exports = prisma;