From 331d5018572d6f7515fbe2acff9eeddb2ff5f751 Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:00:35 -0500 Subject: [PATCH 01/10] ironmic endpoint --- src/controllers/discord/discord.ts | 104 +++++++++++++++++++++++++++++ src/helpers/zau.ts | 1 + 2 files changed, 105 insertions(+) diff --git a/src/controllers/discord/discord.ts b/src/controllers/discord/discord.ts index 99bc70c..34444d6 100644 --- a/src/controllers/discord/discord.ts +++ b/src/controllers/discord/discord.ts @@ -6,8 +6,10 @@ import { throwInternalServerErrorException, throwUnauthorizedException, } from '../../helpers/errors.js'; +import zau from '../../helpers/zau.js'; import internalAuth from '../../middleware/internalAuth.js'; import getUser from '../../middleware/user.js'; +import { ControllerHoursModel } from '../../models/controllerHours.js'; import { ACTION_TYPE, DossierModel } from '../../models/dossier.js'; import { UserModel } from '../../models/user.js'; import status from '../../types/status.js'; @@ -127,6 +129,108 @@ router.delete('/user', getUser, async (req: Request, res: Response, next: NextFu } }); +router.get('/ironmic', internalAuth, async (_req: Request, res: Response, next: NextFunction) => { + try { + const results = await ControllerHoursModel.aggregate([ + { + $match: { + $and: [ + { + timeStart: { $gte: zau.activity.period.startOfCurrent }, + }, + { timeStart: { $lte: zau.activity.period.endOfCurrent } }, + ], + position: { $not: /OBS/ }, + }, + }, + { + $lookup: { + from: 'users', + localField: 'cid', + foreignField: 'cid', + as: 'userDetails', + }, + }, + { + $unwind: { + path: '$userDetails', + preserveNullAndEmptyArrays: false, + }, + }, + { + $match: { + 'userDetails.member': true, + 'userDetails.vis': false, + }, + }, + { + $group: { + _id: '$cid', + fname: { $first: '$userDetails.fname' }, + lname: { $first: '$userDetails.lname' }, + rating: { $first: '$userDetails.rating' }, + totalSeconds: { + $sum: { + $dateDiff: { + startDate: '$timeStart', + endDate: '$timeEnd', + unit: 'second', + }, + }, + }, + }, + }, + { + $project: { + _id: 0, + controller: '$_id', + totalSeconds: 1, + fname: 1, + lname: 1, + rating: 1, + }, + }, + ]) + .cache('5 minutes') + .exec(); + + const center = []; + const approach = []; + const tower = []; + const ground = []; + + for (const result of results) { + if (result.rating >= 5) { + center.push(result); + } else if (result.rating === 4) { + approach.push(result); + } else if (result.rating === 3) { + tower.push(result); + } else if (result.rating === 2) { + ground.push(result); + } + } + + center.sort((a, b) => b.totalSeconds - a.totalSeconds); + approach.sort((a, b) => b.totalSeconds - a.totalSeconds); + tower.sort((a, b) => b.totalSeconds - a.totalSeconds); + ground.sort((a, b) => b.totalSeconds - a.totalSeconds); + return res + .status(status.OK) + .json({ + results: { + center: center.slice(0, 3), + approach: approach.slice(0, 3), + tower: tower.slice(0, 3), + ground: ground.slice(0, 3), + }, + period: zau.activity.period, + }); + } catch (e) { + return next(e); + } +}); + interface DiscordOptions { clientId: string; clientSecret: string; diff --git a/src/helpers/zau.ts b/src/helpers/zau.ts index 6272341..786f442 100644 --- a/src/helpers/zau.ts +++ b/src/helpers/zau.ts @@ -39,6 +39,7 @@ const activity = { unit: periodUnitName, periodsInYear, periodLength, + currentPeriod: getPeriodFromDate(new Date()), startOfCurrent: getPeriodStartFromDate(), endOfCurrent: getPeriodEndFromDate(), periodStartFromDate: function (date: Date = new Date()) { From ff8876cfe1af0e259f07364d613a6e1844a935bd Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:35:47 -0400 Subject: [PATCH 02/10] discord bot configuration --- src/controllers/discord/bot.ts | 351 +++++++++++++++++++++++++++++ src/controllers/discord/discord.ts | 118 +--------- src/helpers/discord.ts | 90 ++++++++ src/models/discordConfig.ts | 81 +++++++ 4 files changed, 524 insertions(+), 116 deletions(-) create mode 100644 src/controllers/discord/bot.ts create mode 100644 src/models/discordConfig.ts diff --git a/src/controllers/discord/bot.ts b/src/controllers/discord/bot.ts new file mode 100644 index 0000000..d9cbae1 --- /dev/null +++ b/src/controllers/discord/bot.ts @@ -0,0 +1,351 @@ +import { Router, type NextFunction, type Request, type Response } from 'express'; +import { getCacheInstance } from '../../app.js'; +import discord from '../../helpers/discord.js'; +import { throwBadRequestException } from '../../helpers/errors.js'; +import zau from '../../helpers/zau.js'; +import { isSeniorStaff } from '../../middleware/auth.js'; +import internalAuth from '../../middleware/internalAuth.js'; +import getUser from '../../middleware/user.js'; +import { ControllerHoursModel } from '../../models/controllerHours.js'; +import { DiscordConfigModel } from '../../models/discordConfig.js'; +import { UserModel } from '../../models/user.js'; +import status from '../../types/status.js'; + +const router = Router(); + +router.get('/users', internalAuth, async (_req: Request, res: Response, next: NextFunction) => { + try { + const users = await UserModel.find({ discordInfo: { $ne: null } }) + .select('fname lname cid discordInfo roleCodes oi rating member vis') + .exec(); + + return res.status(status.OK).json(users); + } catch (e) { + return next(e); + } +}); + +router.get('/ironmic', internalAuth, async (_req: Request, res: Response, next: NextFunction) => { + try { + const results = await ControllerHoursModel.aggregate([ + { + $match: { + $and: [ + { + timeStart: { $gte: zau.activity.period.startOfCurrent }, + }, + { timeStart: { $lte: zau.activity.period.endOfCurrent } }, + ], + position: { $not: /OBS/ }, + }, + }, + { + $lookup: { + from: 'users', + localField: 'cid', + foreignField: 'cid', + as: 'userDetails', + }, + }, + { + $unwind: { + path: '$userDetails', + preserveNullAndEmptyArrays: false, + }, + }, + { + $match: { + 'userDetails.member': true, + 'userDetails.vis': false, + }, + }, + { + $group: { + _id: '$cid', + fname: { $first: '$userDetails.fname' }, + lname: { $first: '$userDetails.lname' }, + rating: { $first: '$userDetails.rating' }, + totalSeconds: { + $sum: { + $dateDiff: { + startDate: '$timeStart', + endDate: '$timeEnd', + unit: 'second', + }, + }, + }, + }, + }, + { + $project: { + _id: 0, + controller: '$_id', + totalSeconds: 1, + fname: 1, + lname: 1, + rating: 1, + }, + }, + ]) + .cache('5 minutes') + .exec(); + + const center = []; + const approach = []; + const tower = []; + const ground = []; + + for (const result of results) { + if (result.rating >= 5) { + center.push(result); + } else if (result.rating === 4) { + approach.push(result); + } else if (result.rating === 3) { + tower.push(result); + } else if (result.rating === 2) { + ground.push(result); + } + } + + center.sort((a, b) => b.totalSeconds - a.totalSeconds); + approach.sort((a, b) => b.totalSeconds - a.totalSeconds); + tower.sort((a, b) => b.totalSeconds - a.totalSeconds); + ground.sort((a, b) => b.totalSeconds - a.totalSeconds); + return res.status(status.OK).json({ + results: { + center: center.slice(0, 3), + approach: approach.slice(0, 3), + tower: tower.slice(0, 3), + ground: ground.slice(0, 3), + }, + period: zau.activity.period, + }); + } catch (e) { + return next(e); + } +}); + +router.get( + '/config', + getUser, + isSeniorStaff, + async (_req: Request, res: Response, next: NextFunction) => { + try { + const config = await DiscordConfigModel.findOne({ type: 'discord' }) + .cache('1 hour', 'discord-config') + .exec(); + if (!config) { + const doc = await DiscordConfigModel.create({ + id: '485491681903247361', + type: 'discord', + repostChannels: [ + { + id: '486966861632897034', + topic: 'ZAU Announcement', + }, + { + id: '544080116762935296', + topic: 'ZAU Promotion!', + }, + { + id: '878613881046593586', + topic: 'ZAU Training Announcement', + }, + ], + managedRoles: [ + { + key: 'OBS', + roleId: '826533958245285909', + }, + { + key: 'S1', + roleId: '907949973721743421', + }, + { + key: 'S2', + roleId: '907950813337501697', + }, + { + key: 'S3', + roleId: '925768951491883018', + }, + { + key: 'C1', + roleId: '1012096233738879087', + }, + { + key: 'C3', + roleId: '1012096533027631124', + }, + { + key: 'I1', + roleId: '1012096533392535664', + }, + { + key: 'I3', + roleId: '1012096687071821856', + }, + { + key: 'SUP', + roleId: '1012096738804387920', + }, + { + key: 'ADM', + roleId: '1015818173628547182', + }, + { + key: 'HOME', + roleId: '485492230774325260', + }, + { + key: 'VIS', + roleId: '485500102056607745', + }, + { + key: 'ins', + roleId: '1025487324915699752', + }, + { + key: 'mtr', + roleId: '1025487633754882098', + }, + { + key: 'fe', + roleId: '1146456088129061006', + }, + { + key: 'ec', + roleId: '1044866729764986920', + }, + { + key: 'wm', + roleId: '1036086110931132436', + }, + { + key: 'GUEST', + roleId: '1013191411413287023', + }, + ], + ironMic: { channelId: '1206360145383395368', messageId: '1206361986032472114' }, + onlineControllers: { channelId: '1095122861028548710', messageId: '1184635443761905825' }, + cleanupChannels: [ + { + channelId: '1059158001484841010', + messageId: '1438596525805801492', + }, + ], + }); + + return res.status(status.OK).json(doc); + } + + return res.status(status.OK).json(config); + } catch (e) { + return next(e); + } + }, +); + +router.put( + '/config', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { config } = req.body; + if (!config) { + throwBadRequestException('Invalid request'); + } + + for (const repostChannel of config.repostChannels) { + if (config.repostChannels.filter((r: any) => r.id === repostChannel.id).length > 1) { + throwBadRequestException('Duplicate repost channel id'); + } + } + + for (const cleanupChannels of config.cleanupChannels) { + if ( + config.cleanupChannels.filter((c: any) => c.channelId === cleanupChannels.channelId) + .length > 1 + ) { + throwBadRequestException('Duplicate cleanup channel id'); + } + } + + await DiscordConfigModel.findOneAndUpdate({ type: 'discord' }, config, { upsert: true }); + + await getCacheInstance().clear('discord-config'); + + return res.status(status.OK).json(); + } catch (e) { + return next(e); + } + }, +); +router.get( + '/all-channels', + getUser, + isSeniorStaff, + async (_req: Request, res: Response, next: NextFunction) => { + try { + const channels = await discord.getAllTextChannels(); + return res.status(status.OK).json(channels); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/all-roles', + getUser, + isSeniorStaff, + async (_req: Request, res: Response, next: NextFunction) => { + try { + const roles = await discord.getAllRoles(); + return res.status(status.OK).json(roles); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/message-content', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { channelId, messageId } = req.query; + if (!channelId || !messageId) { + throwBadRequestException('Invalid request'); + } + + const content = await discord.getMessageContent(channelId as string, messageId as string); + return res.status(status.OK).json(content); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/all-messages', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { channelId } = req.query; + if (!channelId) { + throwBadRequestException('Invalid request'); + } + + const messages = await discord.getAllMessages(channelId as string); + return res.status(status.OK).json(messages); + } catch (e) { + return next(e); + } + }, +); + +export default router; diff --git a/src/controllers/discord/discord.ts b/src/controllers/discord/discord.ts index 34444d6..80884eb 100644 --- a/src/controllers/discord/discord.ts +++ b/src/controllers/discord/discord.ts @@ -6,28 +6,16 @@ import { throwInternalServerErrorException, throwUnauthorizedException, } from '../../helpers/errors.js'; -import zau from '../../helpers/zau.js'; -import internalAuth from '../../middleware/internalAuth.js'; import getUser from '../../middleware/user.js'; -import { ControllerHoursModel } from '../../models/controllerHours.js'; import { ACTION_TYPE, DossierModel } from '../../models/dossier.js'; import { UserModel } from '../../models/user.js'; import status from '../../types/status.js'; import { clearUserCache } from '../controller/utils.js'; +import discordBotRouter from './bot.js'; const router = Router(); -router.get('/users', internalAuth, async (_req: Request, res: Response, next: NextFunction) => { - try { - const users = await UserModel.find({ discordInfo: { $ne: null } }) - .select('fname lname cid discordInfo roleCodes oi rating member vis') - .exec(); - - return res.status(status.OK).json(users); - } catch (e) { - return next(e); - } -}); +router.use('/bot', discordBotRouter); router.get('/user', getUser, async (req: Request, res: Response, next: NextFunction) => { try { @@ -129,108 +117,6 @@ router.delete('/user', getUser, async (req: Request, res: Response, next: NextFu } }); -router.get('/ironmic', internalAuth, async (_req: Request, res: Response, next: NextFunction) => { - try { - const results = await ControllerHoursModel.aggregate([ - { - $match: { - $and: [ - { - timeStart: { $gte: zau.activity.period.startOfCurrent }, - }, - { timeStart: { $lte: zau.activity.period.endOfCurrent } }, - ], - position: { $not: /OBS/ }, - }, - }, - { - $lookup: { - from: 'users', - localField: 'cid', - foreignField: 'cid', - as: 'userDetails', - }, - }, - { - $unwind: { - path: '$userDetails', - preserveNullAndEmptyArrays: false, - }, - }, - { - $match: { - 'userDetails.member': true, - 'userDetails.vis': false, - }, - }, - { - $group: { - _id: '$cid', - fname: { $first: '$userDetails.fname' }, - lname: { $first: '$userDetails.lname' }, - rating: { $first: '$userDetails.rating' }, - totalSeconds: { - $sum: { - $dateDiff: { - startDate: '$timeStart', - endDate: '$timeEnd', - unit: 'second', - }, - }, - }, - }, - }, - { - $project: { - _id: 0, - controller: '$_id', - totalSeconds: 1, - fname: 1, - lname: 1, - rating: 1, - }, - }, - ]) - .cache('5 minutes') - .exec(); - - const center = []; - const approach = []; - const tower = []; - const ground = []; - - for (const result of results) { - if (result.rating >= 5) { - center.push(result); - } else if (result.rating === 4) { - approach.push(result); - } else if (result.rating === 3) { - tower.push(result); - } else if (result.rating === 2) { - ground.push(result); - } - } - - center.sort((a, b) => b.totalSeconds - a.totalSeconds); - approach.sort((a, b) => b.totalSeconds - a.totalSeconds); - tower.sort((a, b) => b.totalSeconds - a.totalSeconds); - ground.sort((a, b) => b.totalSeconds - a.totalSeconds); - return res - .status(status.OK) - .json({ - results: { - center: center.slice(0, 3), - approach: approach.slice(0, 3), - tower: tower.slice(0, 3), - ground: ground.slice(0, 3), - }, - period: zau.activity.period, - }); - } catch (e) { - return next(e); - } -}); - interface DiscordOptions { clientId: string; clientSecret: string; diff --git a/src/helpers/discord.ts b/src/helpers/discord.ts index 44aa21f..b868d25 100644 --- a/src/helpers/discord.ts +++ b/src/helpers/discord.ts @@ -57,7 +57,97 @@ async function getCurrentUser(tokenType: string, accessToken: string) { } } +async function getAllTextChannels() { + try { + const channels = await discord.get('/guilds/485491681903247361/channels'); + + const textChannels = channels.data.filter( + (channel: any) => channel.type === 0 || channel.type === 5, + ); + + const categoryMap = new Map( + channels.data.filter((c: any) => c.type === 4).map((cat: any) => [cat.id, cat.name]), + ); + + return textChannels.map((c: any) => ({ + id: c.id, + name: c.name, + topic: c.topic, + parent: c.parent_id, + parentName: categoryMap.get(c.parent_id) || 'No Category', + lastMessage: c.last_message_id, + })); + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + +async function getAllRoles() { + try { + const roles = await discord.get('/guilds/485491681903247361/roles'); + + return roles.data.map((r: any) => ({ id: r.id, name: r.name })); + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + +async function getMessageContent(channelId: string, messageId: string) { + try { + const message = await discord.get(`/channels/${channelId}/messages/${messageId}`); + + return { + author: message.data.author, + content: message.data.content, + }; + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + +async function getAllMessages(channelId: string) { + try { + const messages = await discord.get(`/channels/${channelId}/messages?limit=100`); + + return messages.data.map((message: any) => ({ + id: message.id, + author: message.author, + content: message.content, + })); + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + export default { sendMessage, getCurrentUser, + getAllTextChannels, + getAllRoles, + getMessageContent, + getAllMessages, }; diff --git a/src/models/discordConfig.ts b/src/models/discordConfig.ts new file mode 100644 index 0000000..c98cf3c --- /dev/null +++ b/src/models/discordConfig.ts @@ -0,0 +1,81 @@ +import { Document, model, Schema } from 'mongoose'; + +interface IUpdateableMessage { + channelId: string; + messageId: string; +} + +interface IManagedrole { + key: string; + roleId: string; +} + +interface IRepostChannel { + id: string; + topic: string; +} + +interface IDiscordConfig extends Document { + id: string; + type: string; + repostChannels: IRepostChannel[]; + managedRoles: IManagedrole[]; + ironMic: IUpdateableMessage; + onlineControllers: IUpdateableMessage; + cleanupChannels: IUpdateableMessage[]; +} + +const CleanupChannelsSchema = new Schema( + { + channelId: { type: String, required: true }, + messageId: { type: String, required: true }, + }, + { _id: false }, +); + +const IronMicSchema = new Schema( + { + channelId: { type: String, required: true }, + messageId: { type: String, required: true }, + }, + { _id: false }, +); + +const OnlineControllersSchema = new Schema( + { + channelId: { type: String, required: true }, + messageId: { type: String, required: true }, + }, + { _id: false }, +); + +const ManagedRoleSchema = new Schema( + { + key: { type: String, required: true }, + roleId: { type: String, required: true }, + }, + { _id: false }, +); + +const RepostChannelSchema = new Schema( + { + id: { type: String, required: true }, + topic: { type: String, required: true }, + }, + { _id: false }, +); + +const DiscordConfigSchema = new Schema( + { + id: { type: String, required: true, default: '485491681903247361' }, + type: { type: String, required: true, default: 'discord' }, + repostChannels: [RepostChannelSchema], + managedRoles: [ManagedRoleSchema], + ironMic: IronMicSchema, + onlineControllers: OnlineControllersSchema, + cleanupChannels: [CleanupChannelsSchema], + }, + { collection: 'config' }, +); + +export const DiscordConfigModel = model('DiscordConfig', DiscordConfigSchema); From 91fdd03e8ef4af867838af4d345b171c1d057456 Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:09:13 -0400 Subject: [PATCH 03/10] new internal auth, testing on discord only --- src/controllers/discord/bot.ts | 415 +++++++++++++++++---------------- src/middleware/internalAuth.ts | 82 ++++++- src/types/CustomRequest.ts | 4 + src/types/global.d.ts | 3 +- 4 files changed, 296 insertions(+), 208 deletions(-) diff --git a/src/controllers/discord/bot.ts b/src/controllers/discord/bot.ts index d9cbae1..72fec14 100644 --- a/src/controllers/discord/bot.ts +++ b/src/controllers/discord/bot.ts @@ -1,10 +1,10 @@ import { Router, type NextFunction, type Request, type Response } from 'express'; import { getCacheInstance } from '../../app.js'; import discord from '../../helpers/discord.js'; -import { throwBadRequestException } from '../../helpers/errors.js'; +import { throwBadRequestException, throwForbiddenException } from '../../helpers/errors.js'; import zau from '../../helpers/zau.js'; -import { isSeniorStaff } from '../../middleware/auth.js'; -import internalAuth from '../../middleware/internalAuth.js'; +import { isSeniorStaff, userOrInternal } from '../../middleware/auth.js'; +import { jwtInternalAuth } from '../../middleware/internalAuth.js'; import getUser from '../../middleware/user.js'; import { ControllerHoursModel } from '../../models/controllerHours.js'; import { DiscordConfigModel } from '../../models/discordConfig.js'; @@ -13,7 +13,7 @@ import status from '../../types/status.js'; const router = Router(); -router.get('/users', internalAuth, async (_req: Request, res: Response, next: NextFunction) => { +router.get('/users', jwtInternalAuth, async (_req: Request, res: Response, next: NextFunction) => { try { const users = await UserModel.find({ discordInfo: { $ne: null } }) .select('fname lname cid discordInfo roleCodes oi rating member vis') @@ -25,227 +25,230 @@ router.get('/users', internalAuth, async (_req: Request, res: Response, next: Ne } }); -router.get('/ironmic', internalAuth, async (_req: Request, res: Response, next: NextFunction) => { - try { - const results = await ControllerHoursModel.aggregate([ - { - $match: { - $and: [ - { - timeStart: { $gte: zau.activity.period.startOfCurrent }, - }, - { timeStart: { $lte: zau.activity.period.endOfCurrent } }, - ], - position: { $not: /OBS/ }, +router.get( + '/ironmic', + jwtInternalAuth, + async (_req: Request, res: Response, next: NextFunction) => { + try { + const results = await ControllerHoursModel.aggregate([ + { + $match: { + $and: [ + { + timeStart: { $gte: zau.activity.period.startOfCurrent }, + }, + { timeStart: { $lte: zau.activity.period.endOfCurrent } }, + ], + position: { $not: /OBS/ }, + }, }, - }, - { - $lookup: { - from: 'users', - localField: 'cid', - foreignField: 'cid', - as: 'userDetails', + { + $lookup: { + from: 'users', + localField: 'cid', + foreignField: 'cid', + as: 'userDetails', + }, }, - }, - { - $unwind: { - path: '$userDetails', - preserveNullAndEmptyArrays: false, + { + $unwind: { + path: '$userDetails', + preserveNullAndEmptyArrays: false, + }, }, - }, - { - $match: { - 'userDetails.member': true, - 'userDetails.vis': false, + { + $match: { + 'userDetails.member': true, + 'userDetails.vis': false, + }, }, - }, - { - $group: { - _id: '$cid', - fname: { $first: '$userDetails.fname' }, - lname: { $first: '$userDetails.lname' }, - rating: { $first: '$userDetails.rating' }, - totalSeconds: { - $sum: { - $dateDiff: { - startDate: '$timeStart', - endDate: '$timeEnd', - unit: 'second', + { + $group: { + _id: '$cid', + fname: { $first: '$userDetails.fname' }, + lname: { $first: '$userDetails.lname' }, + rating: { $first: '$userDetails.rating' }, + totalSeconds: { + $sum: { + $dateDiff: { + startDate: '$timeStart', + endDate: '$timeEnd', + unit: 'second', + }, }, }, }, }, - }, - { - $project: { - _id: 0, - controller: '$_id', - totalSeconds: 1, - fname: 1, - lname: 1, - rating: 1, + { + $project: { + _id: 0, + controller: '$_id', + totalSeconds: 1, + fname: 1, + lname: 1, + rating: 1, + }, }, - }, - ]) - .cache('5 minutes') - .exec(); - - const center = []; - const approach = []; - const tower = []; - const ground = []; - - for (const result of results) { - if (result.rating >= 5) { - center.push(result); - } else if (result.rating === 4) { - approach.push(result); - } else if (result.rating === 3) { - tower.push(result); - } else if (result.rating === 2) { - ground.push(result); - } - } - - center.sort((a, b) => b.totalSeconds - a.totalSeconds); - approach.sort((a, b) => b.totalSeconds - a.totalSeconds); - tower.sort((a, b) => b.totalSeconds - a.totalSeconds); - ground.sort((a, b) => b.totalSeconds - a.totalSeconds); - return res.status(status.OK).json({ - results: { - center: center.slice(0, 3), - approach: approach.slice(0, 3), - tower: tower.slice(0, 3), - ground: ground.slice(0, 3), - }, - period: zau.activity.period, - }); - } catch (e) { - return next(e); - } -}); - -router.get( - '/config', - getUser, - isSeniorStaff, - async (_req: Request, res: Response, next: NextFunction) => { - try { - const config = await DiscordConfigModel.findOne({ type: 'discord' }) - .cache('1 hour', 'discord-config') + ]) + .cache('5 minutes') .exec(); - if (!config) { - const doc = await DiscordConfigModel.create({ - id: '485491681903247361', - type: 'discord', - repostChannels: [ - { - id: '486966861632897034', - topic: 'ZAU Announcement', - }, - { - id: '544080116762935296', - topic: 'ZAU Promotion!', - }, - { - id: '878613881046593586', - topic: 'ZAU Training Announcement', - }, - ], - managedRoles: [ - { - key: 'OBS', - roleId: '826533958245285909', - }, - { - key: 'S1', - roleId: '907949973721743421', - }, - { - key: 'S2', - roleId: '907950813337501697', - }, - { - key: 'S3', - roleId: '925768951491883018', - }, - { - key: 'C1', - roleId: '1012096233738879087', - }, - { - key: 'C3', - roleId: '1012096533027631124', - }, - { - key: 'I1', - roleId: '1012096533392535664', - }, - { - key: 'I3', - roleId: '1012096687071821856', - }, - { - key: 'SUP', - roleId: '1012096738804387920', - }, - { - key: 'ADM', - roleId: '1015818173628547182', - }, - { - key: 'HOME', - roleId: '485492230774325260', - }, - { - key: 'VIS', - roleId: '485500102056607745', - }, - { - key: 'ins', - roleId: '1025487324915699752', - }, - { - key: 'mtr', - roleId: '1025487633754882098', - }, - { - key: 'fe', - roleId: '1146456088129061006', - }, - { - key: 'ec', - roleId: '1044866729764986920', - }, - { - key: 'wm', - roleId: '1036086110931132436', - }, - { - key: 'GUEST', - roleId: '1013191411413287023', - }, - ], - ironMic: { channelId: '1206360145383395368', messageId: '1206361986032472114' }, - onlineControllers: { channelId: '1095122861028548710', messageId: '1184635443761905825' }, - cleanupChannels: [ - { - channelId: '1059158001484841010', - messageId: '1438596525805801492', - }, - ], - }); - return res.status(status.OK).json(doc); + const center = []; + const approach = []; + const tower = []; + const ground = []; + + for (const result of results) { + if (result.rating >= 5) { + center.push(result); + } else if (result.rating === 4) { + approach.push(result); + } else if (result.rating === 3) { + tower.push(result); + } else if (result.rating === 2) { + ground.push(result); + } } - return res.status(status.OK).json(config); + center.sort((a, b) => b.totalSeconds - a.totalSeconds); + approach.sort((a, b) => b.totalSeconds - a.totalSeconds); + tower.sort((a, b) => b.totalSeconds - a.totalSeconds); + ground.sort((a, b) => b.totalSeconds - a.totalSeconds); + return res.status(status.OK).json({ + results: { + center: center.slice(0, 3), + approach: approach.slice(0, 3), + tower: tower.slice(0, 3), + ground: ground.slice(0, 3), + }, + period: zau.activity.period, + }); } catch (e) { return next(e); } }, ); +router.get('/config', userOrInternal, async (req: Request, res: Response, next: NextFunction) => { + try { + if ((req.user && !req.user.isSeniorStaff) || req.internal === false) { + throwForbiddenException('Forbidden'); + } + + const config = await DiscordConfigModel.findOne({ type: 'discord' }) + .cache('1 hour', 'discord-config') + .exec(); + if (!config) { + const doc = await DiscordConfigModel.create({ + id: '485491681903247361', + type: 'discord', + repostChannels: [ + { + id: '486966861632897034', + topic: 'ZAU Announcement', + }, + { + id: '544080116762935296', + topic: 'ZAU Promotion!', + }, + { + id: '878613881046593586', + topic: 'ZAU Training Announcement', + }, + ], + managedRoles: [ + { + key: 'OBS', + roleId: '826533958245285909', + }, + { + key: 'S1', + roleId: '907949973721743421', + }, + { + key: 'S2', + roleId: '907950813337501697', + }, + { + key: 'S3', + roleId: '925768951491883018', + }, + { + key: 'C1', + roleId: '1012096233738879087', + }, + { + key: 'C3', + roleId: '1012096533027631124', + }, + { + key: 'I1', + roleId: '1012096533392535664', + }, + { + key: 'I3', + roleId: '1012096687071821856', + }, + { + key: 'SUP', + roleId: '1012096738804387920', + }, + { + key: 'ADM', + roleId: '1015818173628547182', + }, + { + key: 'HOME', + roleId: '485492230774325260', + }, + { + key: 'VIS', + roleId: '485500102056607745', + }, + { + key: 'ins', + roleId: '1025487324915699752', + }, + { + key: 'mtr', + roleId: '1025487633754882098', + }, + { + key: 'fe', + roleId: '1146456088129061006', + }, + { + key: 'ec', + roleId: '1044866729764986920', + }, + { + key: 'wm', + roleId: '1036086110931132436', + }, + { + key: 'GUEST', + roleId: '1013191411413287023', + }, + ], + ironMic: { channelId: '1206360145383395368', messageId: '1206361986032472114' }, + onlineControllers: { channelId: '1095122861028548710', messageId: '1184635443761905825' }, + cleanupChannels: [ + { + channelId: '1059158001484841010', + messageId: '1438596525805801492', + }, + ], + }); + + return res.status(status.OK).json(doc); + } + + return res.status(status.OK).json(config); + } catch (e) { + return next(e); + } +}); + router.put( '/config', getUser, diff --git a/src/middleware/internalAuth.ts b/src/middleware/internalAuth.ts index 012716f..77f241e 100644 --- a/src/middleware/internalAuth.ts +++ b/src/middleware/internalAuth.ts @@ -1,8 +1,16 @@ import * as Sentry from '@sentry/node'; import { captureMessage } from '@sentry/node'; import type { NextFunction, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import type { IApplication } from '../types/CustomRequest.js'; import status from '../types/status.js'; +interface InternalAuthPayload { + sub: string; + iat: number; + exp: number; +} + export default function (req: Request, res: Response, next: NextFunction) { setupSentry(req); @@ -13,6 +21,78 @@ export default function (req: Request, res: Response, next: NextFunction) { return res.status(status.FORBIDDEN).json(); } +export function jwtInternalAuth(req: Request, res: Response, next: NextFunction) { + if (!isJwtValid(req)) { + setupSentry(req); + + return res.status(status.FORBIDDEN).json(); + } + + setupSentry(req); + + return next(); +} + +export function isJwtValid(req: Request): boolean { + if (!process.env['MICRO_ACCESS_KEY']) { + captureMessage('MICRO_ACCESS_KEY not set.'); + + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } + + const key = req.headers.authorization?.replace('Bearer ', ''); + + if (!key) { + captureMessage('Attempted access to an internal protected route'); + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } + + try { + const decoded = jwt.verify(key, process.env['MICRO_ACCESS_KEY']) as InternalAuthPayload; + + if (decoded.exp && decoded.iat) { + const lifespan = decoded.exp - decoded.iat; + const age = Math.floor(Date.now() / 1000) - decoded.iat; + + if (lifespan > 60) { + captureMessage('Attempted access to an internal protected route with a short-lived key'); + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } + + if (age > 65) { + captureMessage( + 'Attempted access to an internal protected route with a key that is too old', + ); + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } + } + + req.application = { + name: decoded.sub, + } as unknown as IApplication; + req.internal = true; + + return true; + } catch (e) { + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } +} + export function isKeyValid(req: Request): boolean { if (!process.env['MICRO_ACCESS_KEY']) { captureMessage('MICRO_ACCESS_KEY not set.'); @@ -48,7 +128,7 @@ function setupSentry(req: Request) { if (req.user) { user.id = -1; - user.username = `Internal Application`; + user.username = req.application ? req.application.name : `Internal Application`; } Sentry.setUser(user); diff --git a/src/types/CustomRequest.ts b/src/types/CustomRequest.ts index 1a7f8b0..190edbf 100644 --- a/src/types/CustomRequest.ts +++ b/src/types/CustomRequest.ts @@ -5,3 +5,7 @@ export interface OauthRequest { refresh_token: string; scopes: string[]; } + +export interface IApplication { + name: string; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index c8d209c..fdcdb24 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,6 +1,6 @@ import { Redis } from 'ioredis'; import type { IUser } from 'models/user.ts'; -import type { OauthRequest } from 'types/CustomRequest.ts'; +import type { IApplication, OauthRequest } from 'types/CustomRequest.ts'; // Extend the Express Application interface declare global { @@ -20,6 +20,7 @@ declare global { user: IUser; oauth: OauthRequest; internal: boolean; + application: IApplication; } } } From 29e85b987af7acd915908a2be878270bc87208c1 Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:27:34 -0400 Subject: [PATCH 04/10] discord bot single user route --- src/controllers/discord/bot.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/controllers/discord/bot.ts b/src/controllers/discord/bot.ts index 72fec14..290ca21 100644 --- a/src/controllers/discord/bot.ts +++ b/src/controllers/discord/bot.ts @@ -25,6 +25,31 @@ router.get('/users', jwtInternalAuth, async (_req: Request, res: Response, next: } }); +router.get( + '/user/:id', + jwtInternalAuth, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + if (!id || id.trim() === '') { + throwBadRequestException('Invalid request'); + } + + const user = await UserModel.findOne({ discord: id }) + .select('fname lname cid discordInfo roleCodes oi rating member vis') + .exec(); + + if (!user) { + throwBadRequestException('User not found'); + } + + return res.status(status.OK).json(user); + } catch (e) { + return next(e); + } + }, +); + router.get( '/ironmic', jwtInternalAuth, From ba1aab0c98457c215d6077808b544439782a86f5 Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:02:24 -0400 Subject: [PATCH 05/10] require server id for config paths and fix types --- src/controllers/discord/bot.ts | 238 +++++++++++++++++---------------- src/models/discordConfig.ts | 47 ++----- 2 files changed, 134 insertions(+), 151 deletions(-) diff --git a/src/controllers/discord/bot.ts b/src/controllers/discord/bot.ts index 290ca21..3f1ddd4 100644 --- a/src/controllers/discord/bot.ts +++ b/src/controllers/discord/bot.ts @@ -154,132 +154,136 @@ router.get( }, ); -router.get('/config', userOrInternal, async (req: Request, res: Response, next: NextFunction) => { - try { - if ((req.user && !req.user.isSeniorStaff) || req.internal === false) { - throwForbiddenException('Forbidden'); - } +router.get( + '/config/:id', + userOrInternal, + async (req: Request, res: Response, next: NextFunction) => { + try { + if ((req.user && !req.user.isSeniorStaff) || req.internal === false) { + throwForbiddenException('Forbidden'); + } - const config = await DiscordConfigModel.findOne({ type: 'discord' }) - .cache('1 hour', 'discord-config') - .exec(); - if (!config) { - const doc = await DiscordConfigModel.create({ - id: '485491681903247361', - type: 'discord', - repostChannels: [ - { - id: '486966861632897034', - topic: 'ZAU Announcement', - }, - { - id: '544080116762935296', - topic: 'ZAU Promotion!', - }, - { - id: '878613881046593586', - topic: 'ZAU Training Announcement', - }, - ], - managedRoles: [ - { - key: 'OBS', - roleId: '826533958245285909', - }, - { - key: 'S1', - roleId: '907949973721743421', - }, - { - key: 'S2', - roleId: '907950813337501697', - }, - { - key: 'S3', - roleId: '925768951491883018', - }, - { - key: 'C1', - roleId: '1012096233738879087', - }, - { - key: 'C3', - roleId: '1012096533027631124', - }, - { - key: 'I1', - roleId: '1012096533392535664', - }, - { - key: 'I3', - roleId: '1012096687071821856', - }, - { - key: 'SUP', - roleId: '1012096738804387920', - }, - { - key: 'ADM', - roleId: '1015818173628547182', - }, - { - key: 'HOME', - roleId: '485492230774325260', - }, - { - key: 'VIS', - roleId: '485500102056607745', - }, - { - key: 'ins', - roleId: '1025487324915699752', - }, - { - key: 'mtr', - roleId: '1025487633754882098', - }, - { - key: 'fe', - roleId: '1146456088129061006', - }, - { - key: 'ec', - roleId: '1044866729764986920', - }, - { - key: 'wm', - roleId: '1036086110931132436', - }, - { - key: 'GUEST', - roleId: '1013191411413287023', - }, - ], - ironMic: { channelId: '1206360145383395368', messageId: '1206361986032472114' }, - onlineControllers: { channelId: '1095122861028548710', messageId: '1184635443761905825' }, - cleanupChannels: [ - { - channelId: '1059158001484841010', - messageId: '1438596525805801492', - }, - ], - }); + const { id } = req.params; + if (!id || id === 'undefined') { + throwBadRequestException('Invalid request'); + } - return res.status(status.OK).json(doc); - } + const repostChannels = {} as any; + repostChannels['486966861632897034'] = 'ZAU Announcement'; + repostChannels['544080116762935296'] = 'ZAU Promotion!'; + repostChannels['878613881046593586'] = 'ZAU Training Announcement'; - return res.status(status.OK).json(config); - } catch (e) { - return next(e); - } -}); + const cleanupChannels = {} as any; + cleanupChannels['1059158001484841010'] = '1438596525805801492'; + + const config = await DiscordConfigModel.findOne({ type: 'discord', id: id }) + .cache('1 hour', 'discord-config') + .exec(); + if (!config) { + const doc = await DiscordConfigModel.create({ + id: id, + type: 'discord', + repostChannels: repostChannels, + managedRoles: [ + { + key: 'OBS', + roleId: '826533958245285909', + }, + { + key: 'S1', + roleId: '907949973721743421', + }, + { + key: 'S2', + roleId: '907950813337501697', + }, + { + key: 'S3', + roleId: '925768951491883018', + }, + { + key: 'C1', + roleId: '1012096233738879087', + }, + { + key: 'C3', + roleId: '1012096533027631124', + }, + { + key: 'I1', + roleId: '1012096533392535664', + }, + { + key: 'I3', + roleId: '1012096687071821856', + }, + { + key: 'SUP', + roleId: '1012096738804387920', + }, + { + key: 'ADM', + roleId: '1015818173628547182', + }, + { + key: 'HOME', + roleId: '485492230774325260', + }, + { + key: 'VIS', + roleId: '485500102056607745', + }, + { + key: 'ins', + roleId: '1025487324915699752', + }, + { + key: 'mtr', + roleId: '1025487633754882098', + }, + { + key: 'fe', + roleId: '1146456088129061006', + }, + { + key: 'ec', + roleId: '1044866729764986920', + }, + { + key: 'wm', + roleId: '1036086110931132436', + }, + { + key: 'GUEST', + roleId: '1013191411413287023', + }, + ], + ironMic: { channelId: '1206360145383395368', messageId: '1206361986032472114' }, + onlineControllers: { channelId: '1095122861028548710', messageId: '1184635443761905825' }, + cleanupChannels: cleanupChannels, + }); + + return res.status(status.OK).json(doc); + } + + return res.status(status.OK).json(config); + } catch (e) { + return next(e); + } + }, +); router.put( - '/config', + '/config/:id', getUser, isSeniorStaff, async (req: Request, res: Response, next: NextFunction) => { try { + const { id } = req.params; + if (!id || id === 'undefined') { + throwBadRequestException('Invalid request'); + } + const { config } = req.body; if (!config) { throwBadRequestException('Invalid request'); diff --git a/src/models/discordConfig.ts b/src/models/discordConfig.ts index c98cf3c..8b53331 100644 --- a/src/models/discordConfig.ts +++ b/src/models/discordConfig.ts @@ -1,31 +1,26 @@ import { Document, model, Schema } from 'mongoose'; -interface IUpdateableMessage { +interface IUpdatableMessage { channelId: string; messageId: string; } -interface IManagedrole { +interface IManagedRole { key: string; roleId: string; } -interface IRepostChannel { - id: string; - topic: string; -} - interface IDiscordConfig extends Document { id: string; type: string; - repostChannels: IRepostChannel[]; - managedRoles: IManagedrole[]; - ironMic: IUpdateableMessage; - onlineControllers: IUpdateableMessage; - cleanupChannels: IUpdateableMessage[]; + repostChannels: object; + managedRoles: IManagedRole[]; + ironMic: IUpdatableMessage; + onlineControllers: IUpdatableMessage; + cleanupChannels: object; } -const CleanupChannelsSchema = new Schema( +const IronMicSchema = new Schema( { channelId: { type: String, required: true }, messageId: { type: String, required: true }, @@ -33,7 +28,7 @@ const CleanupChannelsSchema = new Schema( { _id: false }, ); -const IronMicSchema = new Schema( +const OnlineControllersSchema = new Schema( { channelId: { type: String, required: true }, messageId: { type: String, required: true }, @@ -41,15 +36,7 @@ const IronMicSchema = new Schema( { _id: false }, ); -const OnlineControllersSchema = new Schema( - { - channelId: { type: String, required: true }, - messageId: { type: String, required: true }, - }, - { _id: false }, -); - -const ManagedRoleSchema = new Schema( +const ManagedRoleSchema = new Schema( { key: { type: String, required: true }, roleId: { type: String, required: true }, @@ -57,23 +44,15 @@ const ManagedRoleSchema = new Schema( { _id: false }, ); -const RepostChannelSchema = new Schema( - { - id: { type: String, required: true }, - topic: { type: String, required: true }, - }, - { _id: false }, -); - const DiscordConfigSchema = new Schema( { - id: { type: String, required: true, default: '485491681903247361' }, + id: { type: String, required: true }, type: { type: String, required: true, default: 'discord' }, - repostChannels: [RepostChannelSchema], + repostChannels: { type: Object }, managedRoles: [ManagedRoleSchema], ironMic: IronMicSchema, onlineControllers: OnlineControllersSchema, - cleanupChannels: [CleanupChannelsSchema], + cleanupChannels: { type: Object }, }, { collection: 'config' }, ); From a685711963338e364f4ad41456d99dad13feb70c Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:42:42 -0400 Subject: [PATCH 06/10] fixes and guild specific routes --- src/controllers/discord/bot.ts | 184 +++++++++++++-------------------- src/helpers/discord.ts | 24 ++++- 2 files changed, 94 insertions(+), 114 deletions(-) diff --git a/src/controllers/discord/bot.ts b/src/controllers/discord/bot.ts index 3f1ddd4..ebb2815 100644 --- a/src/controllers/discord/bot.ts +++ b/src/controllers/discord/bot.ts @@ -168,102 +168,19 @@ router.get( throwBadRequestException('Invalid request'); } - const repostChannels = {} as any; - repostChannels['486966861632897034'] = 'ZAU Announcement'; - repostChannels['544080116762935296'] = 'ZAU Promotion!'; - repostChannels['878613881046593586'] = 'ZAU Training Announcement'; - - const cleanupChannels = {} as any; - cleanupChannels['1059158001484841010'] = '1438596525805801492'; - const config = await DiscordConfigModel.findOne({ type: 'discord', id: id }) - .cache('1 hour', 'discord-config') + .cache('6 hours', `discord-config-${id}`) .exec(); if (!config) { - const doc = await DiscordConfigModel.create({ - id: id, + return res.status(status.OK).json({ + id, type: 'discord', - repostChannels: repostChannels, - managedRoles: [ - { - key: 'OBS', - roleId: '826533958245285909', - }, - { - key: 'S1', - roleId: '907949973721743421', - }, - { - key: 'S2', - roleId: '907950813337501697', - }, - { - key: 'S3', - roleId: '925768951491883018', - }, - { - key: 'C1', - roleId: '1012096233738879087', - }, - { - key: 'C3', - roleId: '1012096533027631124', - }, - { - key: 'I1', - roleId: '1012096533392535664', - }, - { - key: 'I3', - roleId: '1012096687071821856', - }, - { - key: 'SUP', - roleId: '1012096738804387920', - }, - { - key: 'ADM', - roleId: '1015818173628547182', - }, - { - key: 'HOME', - roleId: '485492230774325260', - }, - { - key: 'VIS', - roleId: '485500102056607745', - }, - { - key: 'ins', - roleId: '1025487324915699752', - }, - { - key: 'mtr', - roleId: '1025487633754882098', - }, - { - key: 'fe', - roleId: '1146456088129061006', - }, - { - key: 'ec', - roleId: '1044866729764986920', - }, - { - key: 'wm', - roleId: '1036086110931132436', - }, - { - key: 'GUEST', - roleId: '1013191411413287023', - }, - ], - ironMic: { channelId: '1206360145383395368', messageId: '1206361986032472114' }, - onlineControllers: { channelId: '1095122861028548710', messageId: '1184635443761905825' }, - cleanupChannels: cleanupChannels, + repostChannels: {}, + managedRoles: [], + ironMic: { channelId: '', messageId: '' }, + onlineControllers: { channelId: '', messageId: '' }, + cleanupChannels: {}, }); - - return res.status(status.OK).json(doc); } return res.status(status.OK).json(config); @@ -284,43 +201,71 @@ router.put( throwBadRequestException('Invalid request'); } - const { config } = req.body; + const config = req.body; if (!config) { throwBadRequestException('Invalid request'); } - for (const repostChannel of config.repostChannels) { - if (config.repostChannels.filter((r: any) => r.id === repostChannel.id).length > 1) { - throwBadRequestException('Duplicate repost channel id'); - } + await DiscordConfigModel.findOneAndUpdate({ type: 'discord', id: id }, config, { + upsert: true, + }).exec(); + + await getCacheInstance().clear(`discord-config-${id}`); + + return res.status(status.OK).json(); + } catch (e) { + return next(e); + } + }, +); + +router.patch( + '/config/:id', + jwtInternalAuth, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + if (!id || id === 'undefined') { + throwBadRequestException('Invalid request'); } - for (const cleanupChannels of config.cleanupChannels) { - if ( - config.cleanupChannels.filter((c: any) => c.channelId === cleanupChannels.channelId) - .length > 1 - ) { - throwBadRequestException('Duplicate cleanup channel id'); - } + const { ironMic, onlineControllers } = req.body; + if (!ironMic || !onlineControllers) { + throwBadRequestException('Invalid request'); } - await DiscordConfigModel.findOneAndUpdate({ type: 'discord' }, config, { upsert: true }); + const config = await DiscordConfigModel.findOne({ type: 'discord', id: id }) + .cache('6 hours', `discord-config-${id}`) + .exec(); + if (!config) { + throwBadRequestException('Invalid request'); + } - await getCacheInstance().clear('discord-config'); + config.ironMic.messageId = ironMic; + config.onlineControllers.messageId = onlineControllers; + const updated = await config.save(); - return res.status(status.OK).json(); + await getCacheInstance().clear(`discord-config-${id}`); + + return res.status(status.OK).json(updated); } catch (e) { return next(e); } }, ); + router.get( '/all-channels', getUser, isSeniorStaff, - async (_req: Request, res: Response, next: NextFunction) => { + async (req: Request, res: Response, next: NextFunction) => { try { - const channels = await discord.getAllTextChannels(); + const { guildId } = req.query; + if (!guildId || guildId === 'undefined') { + throwBadRequestException('Invalid request'); + } + + const channels = await discord.getAllTextChannels(guildId as string); return res.status(status.OK).json(channels); } catch (e) { return next(e); @@ -332,9 +277,14 @@ router.get( '/all-roles', getUser, isSeniorStaff, - async (_req: Request, res: Response, next: NextFunction) => { + async (req: Request, res: Response, next: NextFunction) => { try { - const roles = await discord.getAllRoles(); + const { guildId } = req.query; + if (!guildId || guildId === 'undefined') { + throwBadRequestException('Invalid request'); + } + + const roles = await discord.getAllRoles(guildId as string); return res.status(status.OK).json(roles); } catch (e) { return next(e); @@ -380,4 +330,18 @@ router.get( }, ); +router.get( + '/all-guilds', + getUser, + isSeniorStaff, + async (_req: Request, res: Response, next: NextFunction) => { + try { + const guilds = await discord.getAllGuilds(); + return res.status(status.OK).json(guilds); + } catch (e) { + return next(e); + } + }, +); + export default router; diff --git a/src/helpers/discord.ts b/src/helpers/discord.ts index b868d25..a20e9ce 100644 --- a/src/helpers/discord.ts +++ b/src/helpers/discord.ts @@ -57,9 +57,9 @@ async function getCurrentUser(tokenType: string, accessToken: string) { } } -async function getAllTextChannels() { +async function getAllTextChannels(guildId: string) { try { - const channels = await discord.get('/guilds/485491681903247361/channels'); + const channels = await discord.get(`/guilds/${guildId}/channels`); const textChannels = channels.data.filter( (channel: any) => channel.type === 0 || channel.type === 5, @@ -88,9 +88,9 @@ async function getAllTextChannels() { } } -async function getAllRoles() { +async function getAllRoles(guildId: string) { try { - const roles = await discord.get('/guilds/485491681903247361/roles'); + const roles = await discord.get(`/guilds/${guildId}/roles`); return roles.data.map((r: any) => ({ id: r.id, name: r.name })); } catch (e) { @@ -143,6 +143,21 @@ async function getAllMessages(channelId: string) { } } +async function getAllGuilds() { + try { + const guilds = await discord.get('/users/@me/guilds'); + + return guilds.data.map((g: any) => ({ id: g.id, name: g.name })); + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + export default { sendMessage, getCurrentUser, @@ -150,4 +165,5 @@ export default { getAllRoles, getMessageContent, getAllMessages, + getAllGuilds, }; From fedadd9e1f939248950db35a83b44eb3f9968234 Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:33:58 -0400 Subject: [PATCH 07/10] hotfix: logout cookie deletion --- src/controllers/user/user.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/controllers/user/user.ts b/src/controllers/user/user.ts index b39726c..de7a8ca 100644 --- a/src/controllers/user/user.ts +++ b/src/controllers/user/user.ts @@ -256,7 +256,9 @@ router.post('/login', oAuth, async (req: Request, res: Response, next: NextFunct router.get('/logout', getUser, async (req: Request, res: Response, next: NextFunction) => { try { - if (!req.cookies['token']) { + const cookie = zau.isProd ? 'token' : 'dev-token'; + + if (!req.cookies[cookie]) { throwUnauthorizedException('Not Logged In'); } From 7a3c6acbf8c9737bea8e3a9a78bb1b8c970d3a07 Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:36:59 -0400 Subject: [PATCH 08/10] use same auth for all bot called endpoints --- src/controllers/discord/bot.ts | 23 +++++++++++++++++++++-- src/middleware/auth.ts | 14 +++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/controllers/discord/bot.ts b/src/controllers/discord/bot.ts index ebb2815..34a3c91 100644 --- a/src/controllers/discord/bot.ts +++ b/src/controllers/discord/bot.ts @@ -3,7 +3,7 @@ import { getCacheInstance } from '../../app.js'; import discord from '../../helpers/discord.js'; import { throwBadRequestException, throwForbiddenException } from '../../helpers/errors.js'; import zau from '../../helpers/zau.js'; -import { isSeniorStaff, userOrInternal } from '../../middleware/auth.js'; +import { isSeniorStaff, userOrInternalJwt } from '../../middleware/auth.js'; import { jwtInternalAuth } from '../../middleware/internalAuth.js'; import getUser from '../../middleware/user.js'; import { ControllerHoursModel } from '../../models/controllerHours.js'; @@ -154,9 +154,28 @@ router.get( }, ); +router.get( + '/configs', + userOrInternalJwt, + async (req: Request, res: Response, next: NextFunction) => { + try { + if ((req.user && !req.user.isSeniorStaff) || req.internal === false) { + throwForbiddenException('Forbidden'); + } + + const configs = await DiscordConfigModel.find({ type: 'discord' }) + .cache('6 hours', 'discord-configs') + .exec(); + return res.status(status.OK).json(configs); + } catch (e) { + return next(e); + } + }, +); + router.get( '/config/:id', - userOrInternal, + userOrInternalJwt, async (req: Request, res: Response, next: NextFunction) => { try { if ((req.user && !req.user.isSeniorStaff) || req.internal === false) { diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 8b09c99..c923620 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,7 +1,7 @@ import { captureMessage } from '@sentry/node'; import type { NextFunction, Request, Response } from 'express'; import status from '../types/status.js'; -import { isKeyValid } from './internalAuth.js'; +import { isJwtValid, isKeyValid } from './internalAuth.js'; import { isUserValid } from './user.js'; export async function userOrInternal(req: Request, res: Response, next: NextFunction) { @@ -16,6 +16,18 @@ export async function userOrInternal(req: Request, res: Response, next: NextFunc return res.status(status.FORBIDDEN).json(); } +export async function userOrInternalJwt(req: Request, res: Response, next: NextFunction) { + if (await isUserValid(req)) { + return next(); + } + + if (isJwtValid(req)) { + return next(); + } + + return res.status(status.FORBIDDEN).json(); +} + export function hasRole(roles: string[]) { return function (req: Request, res: Response, next: NextFunction) { if (!req.user) { From f046870fdaf52124cfde5e8ed9a6265c4e559199 Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:15:44 -0400 Subject: [PATCH 09/10] allow sending messages as bot --- src/controllers/discord/bot.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/controllers/discord/bot.ts b/src/controllers/discord/bot.ts index 34a3c91..6f0bcf7 100644 --- a/src/controllers/discord/bot.ts +++ b/src/controllers/discord/bot.ts @@ -363,4 +363,23 @@ router.get( }, ); +router.post( + '/send-message', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { channelId, content } = req.body; + if (!channelId || !content) { + throwBadRequestException('Invalid request'); + } + + await discord.sendMessage(channelId, content); + return res.status(status.OK).json(); + } catch (e) { + return next(e); + } + }, +); + export default router; From dbf3d7d6fb48785a4dc45b36d34b029677421a31 Mon Sep 17 00:00:00 2001 From: Ryan Savara <32147285+ryansavara@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:28:00 -0400 Subject: [PATCH 10/10] add logging for discord actions --- src/controllers/controller/controller.ts | 2 ++ src/controllers/discord/bot.ts | 27 +++++++++++++++++++++++- src/models/dossier.ts | 2 ++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/controllers/controller/controller.ts b/src/controllers/controller/controller.ts index 22a34a3..45b28ce 100644 --- a/src/controllers/controller/controller.ts +++ b/src/controllers/controller/controller.ts @@ -277,6 +277,8 @@ router.get( 'Deleted Exam', 'Assigned Exam', 'Deleted Exam Attempt', + 'Updated Discord Configuration', + 'Sent Discord Message', ]); } catch (e) { return next(e); diff --git a/src/controllers/discord/bot.ts b/src/controllers/discord/bot.ts index 6f0bcf7..72dec2c 100644 --- a/src/controllers/discord/bot.ts +++ b/src/controllers/discord/bot.ts @@ -8,6 +8,7 @@ import { jwtInternalAuth } from '../../middleware/internalAuth.js'; import getUser from '../../middleware/user.js'; import { ControllerHoursModel } from '../../models/controllerHours.js'; import { DiscordConfigModel } from '../../models/discordConfig.js'; +import { ACTION_TYPE, DossierModel } from '../../models/dossier.js'; import { UserModel } from '../../models/user.js'; import status from '../../types/status.js'; @@ -231,6 +232,13 @@ router.put( await getCacheInstance().clear(`discord-config-${id}`); + await DossierModel.create({ + by: req.user.cid, + affected: -1, + action: `Updated Discord Configuration for server ${id}`, + actionType: ACTION_TYPE.UPDATE_DISCORD_CONFIG, + }); + return res.status(status.OK).json(); } catch (e) { return next(e); @@ -374,7 +382,24 @@ router.post( throwBadRequestException('Invalid request'); } - await discord.sendMessage(channelId, content); + await discord.sendMessage(channelId, { content }); + + DossierModel.create({ + by: req.user.cid, + affected: -1, + action: `Sent a Discord Message to ${channelId}`, + actionType: ACTION_TYPE.SEND_DISCORD_MESSAGE, + }); + + try { + // Post to the logs channel + await discord.sendMessage('1234367676240105493', { + content: `**${req.user.name}** sent a message in <#${channelId}> with the following content:\n\n${content.length > 1500 ? content.slice(0, 1500) + '...' : content}`, + }); + } catch (e) { + // Do nothing + } + return res.status(status.OK).json(); } catch (e) { return next(e); diff --git a/src/models/dossier.ts b/src/models/dossier.ts index 2ae567a..12b1c92 100644 --- a/src/models/dossier.ts +++ b/src/models/dossier.ts @@ -56,6 +56,8 @@ export const ACTION_TYPE = { DELETE_EXAM: 50, ASSIGN_EXAM: 51, DELETE_EXAM_ATTEMPT: 52, + UPDATE_DISCORD_CONFIG: 53, + SEND_DISCORD_MESSAGE: 54, } as const; interface IDossier extends Document, ITimestamps {