diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6842dffc..3e87344c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,6 +18,11 @@ "aaron-bond.better-comments", "streetsidesoftware.code-spell-checker", "coenraads.bracket-pair-colorizer-2", + "docsmsft.docs-markdown", + "oouo-diogo-perdigao.docthis", + "octref.vetur", + "benjaminadk.emojis4git", + "fabiospampinato.vscode-todo-plus" ] // Uncomment the next line if you want start specific services in your Docker Compose config. diff --git a/packages/backend/src/configuration/__mocks__/environment.ts b/packages/backend/src/configuration/__mocks__/environment.ts index f760cbd3..39d7c2ec 100644 --- a/packages/backend/src/configuration/__mocks__/environment.ts +++ b/packages/backend/src/configuration/__mocks__/environment.ts @@ -1,9 +1,7 @@ export const config = { - admin: { - id: '1234567890123456789', - name: 'testuser#00000', - }, + connectorLogLifetime: 1, discordToken: 'xxxxxxxxxxxxxx', + discordChannel: 'xxxxxxxxxxxxxx', env: 'test', jwt: { secret: 'secret', @@ -11,17 +9,18 @@ export const config = { }, mongo: { host: 'mongodb://localhost:27017/fmdb', - port: 27017 }, mongooseDebug: true, - port: 4040, moodle: { baseURL: 'https://moodle.example.com', + fetchInterval: '12', reminderTimeLeft: 86400, token: 'MOODLETOKEN123', useCourseShortname: true, userId: 123456 }, + port: 8080, + registrationTokenLifetime: 123, rp: { name: 'Unit Test', id: 'localhost', diff --git a/packages/backend/src/configuration/discord.ts b/packages/backend/src/configuration/discord.ts deleted file mode 100644 index 67290796..00000000 --- a/packages/backend/src/configuration/discord.ts +++ /dev/null @@ -1,11 +0,0 @@ -import Discord from 'discord.js'; -import { config } from './environment'; -import { loggerFile } from './logger'; - -export const client = new Discord.Client(); - -client.once('ready', () => { - loggerFile.info(`Logged in as ${client.user.tag}!`); - }); - -client.login(config.discordToken); diff --git a/packages/backend/src/configuration/environment.ts b/packages/backend/src/configuration/environment.ts index 4333ed0c..80e94b61 100644 --- a/packages/backend/src/configuration/environment.ts +++ b/packages/backend/src/configuration/environment.ts @@ -6,23 +6,9 @@ dotenvConfig(); // define validation for all the env vars const envVarsSchema = object({ - NODE_ENV: string() - .allow('development') - .allow('production') - .allow('test') - .allow('provision') - .default('production'), - PORT: number() - .default(4040), - MONGOOSE_DEBUG: boolean() - .when('NODE_ENV', { - is: string().equal('development'), - then: boolean().default(true), - otherwise: boolean().default(false) - }), - MONGO_HOST: string() - .required() - .description('Path to your mongodb instance.'), + CONNECTOR_LOG_LIFETIME: string() + .default('31d') + .description('Defines how long log entries/items will be stored'), DISCORD_TOKEN: string() .required() .description('Discord Token for bot'), @@ -35,6 +21,15 @@ const envVarsSchema = object({ JWT_EXPIRESIN: string() .default('10m') .description('Defines how long a user will be logged in'), + MONGOOSE_DEBUG: boolean() + .when('NODE_ENV', { + is: string().equal('development'), + then: boolean().default(true), + otherwise: boolean().default(false) + }), + MONGO_HOST: string() + .required() + .description('Path to your mongodb instance.'), MOODLE_BASE_URL: string() .required() .uri() @@ -54,7 +49,15 @@ const envVarsSchema = object({ MOODLE_USERID: number() .required() .description('Moodle user Id required to fetch course details'), - REGISTRATIONTOKEN_LIFETIME: string() + NODE_ENV: string() + .allow('development') + .allow('production') + .allow('test') + .allow('provision') + .default('production'), + PORT: number() + .default(4040), + REGISTRATION_TOKEN_LIFETIME: string() .default('15m') .description('Defines how long a registration token can be used until it expires'), RP_NAME: string() @@ -79,6 +82,7 @@ const envDescriptionLink = 'https://github.com/tjarbo/discord-moodle-bot/wiki/Wh if (error) throw new Error(`Config validation error: ${error.message} \nSee ${envDescriptionLink} for more information`); export const config = { + connectorLogLifetime: envVars.CONNECTOR_LOG_LIFETIME, discordToken: envVars.DISCORD_TOKEN, discordChannel: envVars.DISCORD_CHANNEL, env: envVars.NODE_ENV, @@ -99,7 +103,7 @@ export const config = { userId: envVars.MOODLE_USERID, }, port: envVars.PORT, - registrationTokenLifetime: envVars.REGISTRATIONTOKEN_LIFETIME, + registrationTokenLifetime: envVars.REGISTRATION_TOKEN_LIFETIME, rp: { name: envVars.RP_NAME, id: envVars.RP_ID, diff --git a/packages/backend/src/configuration/logger.ts b/packages/backend/src/configuration/logger.ts index de89408b..b659f9fe 100644 --- a/packages/backend/src/configuration/logger.ts +++ b/packages/backend/src/configuration/logger.ts @@ -1,5 +1,5 @@ import log4js from 'log4js'; -import {config} from './environment'; +import { config } from './environment'; const configLogger = { appenders: { diff --git a/packages/backend/src/controllers/authentication/index.ts b/packages/backend/src/controllers/authentication/index.ts index 0e4afb28..ec55707f 100644 --- a/packages/backend/src/controllers/authentication/index.ts +++ b/packages/backend/src/controllers/authentication/index.ts @@ -137,22 +137,6 @@ export async function authAttestationGetRequest(req: Request, res: Response, nex userName: userDoc.username.toString(), timeout: 60000, attestationType: 'indirect', - /** - * Passing in a user's list of already-registered authenticator IDs here prevents users from - * registering the same device multiple times. The authenticator will simply throw an error in - * the browser if it's asked to perform an attestation when one of these ID's already resides - * on it. - * - * excludeCredentials: [{ - * id: userDoc.device.credentialID, - * type: 'public-key', - * transports: userDoc.device.transports, - * }], - */ - /** - * The optional authenticatorSelection property allows for specifying more constraints around - * the types of authenticators that users can use for attestation - */ authenticatorSelection: { userVerification: 'preferred', requireResidentKey: false, diff --git a/packages/backend/src/controllers/connectors/index.ts b/packages/backend/src/controllers/connectors/index.ts new file mode 100644 index 00000000..42b8291c --- /dev/null +++ b/packages/backend/src/controllers/connectors/index.ts @@ -0,0 +1,5 @@ +export enum ConnectorLogType { + Info = 'info', + Warning = 'warning', + Error = 'error' +} diff --git a/packages/backend/src/controllers/connectors/logger.ts b/packages/backend/src/controllers/connectors/logger.ts new file mode 100644 index 00000000..e31aa990 --- /dev/null +++ b/packages/backend/src/controllers/connectors/logger.ts @@ -0,0 +1,75 @@ +import { ConnectorLogType } from '.'; +import { loggerFile } from '../../configuration/logger'; +import { ConnectorLogItem } from './schemas/connectorLogItem.schema'; + +class ConnectorLogger { + + /** + * Creates object with message and connector attribute for ConnectorLogItem + * + * @param message message that needs to be stored + * @param objectId objectId of the connector + * @returns object + */ + private createContent(message: string, objectId: string): { [key: string]: string } { + return { + connector: objectId, + message + }; + } + + /** + * Prints and stores an info message + * + * @param message message that needs to be stored + * @param objectId objectId of the connector + * @param skipSave default: true - skips adding the message to ConnectorLogs + * @returns void + */ + public info(message: string, objectId: string, skipSave: boolean = false): void { + loggerFile.info(message); + + if (skipSave) return; + + const content = this.createContent(message, objectId); + new ConnectorLogItem({ ...content, type: ConnectorLogType.Info }).save(); + } + + /** + * Prints and stores a warning message + * + * @param message message that needs to be stored + * @param objectId objectId of the connector + * @param skipSave default: true - skips adding the message to ConnectorLogs + * @returns void + */ + public warn(message: string, objectId: string, skipSave: boolean = false): void { + loggerFile.warn(message); + + if (skipSave) return; + + const content = this.createContent(message, objectId); + new ConnectorLogItem({ ...content, type: ConnectorLogType.Warning }).save(); + } + + /** + * Prints and stores an error message + * + * @param message message that needs to be stored + * @param objectId objectId of the connector + * @param skipSave default: true - skips adding the message to ConnectorLogs + * @returns void + */ + public error(message: string, objectId: string, skipSave: boolean = false): void { + loggerFile.error(message); + + if (skipSave) return; + + const content = this.createContent(message, objectId); + new ConnectorLogItem({ ...content, type: ConnectorLogType.Error }).save(); + } +} + +// This step is not required, because all functions are static +// But the usage should be similar to the loggerFile object +export const connectorLogger = new ConnectorLogger(); diff --git a/packages/backend/src/controllers/connectors/plugins/connectorPlugin.class.ts b/packages/backend/src/controllers/connectors/plugins/connectorPlugin.class.ts new file mode 100644 index 00000000..1b767776 --- /dev/null +++ b/packages/backend/src/controllers/connectors/plugins/connectorPlugin.class.ts @@ -0,0 +1,57 @@ +import { IConnectorDocument } from '../schemas/connector.schema'; +import { ConnectorLogItem, IConnectorLogItemDocument } from '../schemas/connectorLogItem.schema'; + +export abstract class ConnectorPlugin { + + protected abstract document: IConnectorDocument; + + public abstract send(message: string): void; + public abstract update(body: { [key: string]: any }): Promise; + + /** + * Returns an array of the newest log items of this connector. + * The amount of items can be limited by parameter. Default is 50. + * + * @param {number} [limit=50] Set a limit to the amount of items + * @return {Promise} Array of ConnectorLogItemDocuments + * @memberof ConnectorPlugin + */ + public async getLogs(limit: number = 50): Promise { + const query = { + connector: this.objectId + }; + return await ConnectorLogItem.find(query).sort({ createdAt: -1 }).limit(limit); + } + + /** + * Returns the mongoDB objectId, this connector is build on. + * + * @readonly + * @type {string} + * @memberof ConnectorPlugin + */ + public get objectId(): string { return this.document.id; } + + /** + * Returns all courses, that are assigned to this bot. + * + * @readonly + * @type {{ [key: string]: string; }[]} + * @memberof ConnectorPlugin + */ + public get courses(): number[] { + return this.document.courses; + } + + /** + * Returns true, if this plugin is an default handler for not + * assigned courses. + * + * @readonly + * @type {boolean} + * @memberof ConnectorPlugin + */ + public get isDefault(): boolean { + return this.document.default; + } +} diff --git a/packages/backend/src/controllers/connectors/plugins/discordBot.class.ts b/packages/backend/src/controllers/connectors/plugins/discordBot.class.ts new file mode 100644 index 00000000..40c48df7 --- /dev/null +++ b/packages/backend/src/controllers/connectors/plugins/discordBot.class.ts @@ -0,0 +1,87 @@ +import Discord, { TextChannel } from 'discord.js'; +import { config } from '../../../configuration/environment'; +import { IConnectorDocument } from '../schemas/connector.schema'; +import { connectorLogger } from '../logger'; +import { ConnectorPlugin } from './connectorPlugin.class'; +import { object, ObjectSchema, string } from '@hapi/joi'; +import { ApiError } from '../../../utils/api'; + +export class DiscordBotConnectorPlugin extends ConnectorPlugin { + private readonly client: Discord.Client = new Discord.Client(); + private readonly updateRequestSchema: ObjectSchema = object({ + channel: string().alphanum().length(18), + }).required(); + + /** + * Creates an instance of DiscordBotConnectorPlugin. + * + * @param {IConnectorDocument} document mongoose document + * @memberof DiscordBotConnectorPlugin + */ + constructor(protected document: IConnectorDocument) { + super(); + + this.setUpListeners(); + this.client.login(config.discordToken); + } + + /** + * Creates all listeners to provide better overview about the health of the bot. + * + * @private + * @memberof DiscordBotConnectorPlugin + */ + private setUpListeners(): void { + this.client.once('ready', () => { + connectorLogger.info(`Logged in as ${this.client.user.tag}!`, this.objectId); + }); + + this.client.on('warn', (info) => { + connectorLogger.warn(`discord.js: ${info}`, this.objectId); + }); + + this.client.on('disconnect', (info) => { + connectorLogger.error(`discord.js: ${info}`, this.objectId); + }); + } + + /** + * Sends the given message to the discord channel + * + * @param {string} message to send + */ + public send(message: string): void { + const discordChannel = this.client.channels.cache.get(this.document.socket.channel); + if (!discordChannel) return connectorLogger.error(`Channel not in discord cache. Send a small 'test' message to the channel and try again.`, this.objectId); + + (discordChannel as TextChannel).send(message) + .then(() => { + connectorLogger.info('Successfully sent message via Discord bot!', this.objectId); + }) + .catch((error) => { + connectorLogger.info(`Failed to send message via Discord bot! ${error.message}`, this.objectId); + }); + } + + /** + * Applies the given patch to the discord bot document. + * + * @param {{ [key: string]: any }} body + * @return {Promise} The updated document + * @memberof DiscordBotConnectorPlugin + */ + public async update(body: { [key: string]: any }): Promise { + // Validate user input + const updateRequest = this.updateRequestSchema.validate(body); + if (updateRequest.error) throw new ApiError(400, updateRequest.error.message); + + // Apply changes + this.document.socket.channel = updateRequest.value.channel; + const result = await this.document.save(); + + // Log update process + connectorLogger.info('New values have been applied', this.objectId); + + return result; + } +} diff --git a/packages/backend/src/controllers/connectors/plugins/index.ts b/packages/backend/src/controllers/connectors/plugins/index.ts new file mode 100644 index 00000000..fe04449b --- /dev/null +++ b/packages/backend/src/controllers/connectors/plugins/index.ts @@ -0,0 +1,6 @@ +export { DiscordBotConnectorPlugin } from './discordBot.class'; +export { ConnectorPlugin } from './connectorPlugin.class'; + +export enum ConnectorType { + Discord = 'discord', +} diff --git a/packages/backend/src/controllers/connectors/schemas/connector.schema.ts b/packages/backend/src/controllers/connectors/schemas/connector.schema.ts new file mode 100644 index 00000000..67733266 --- /dev/null +++ b/packages/backend/src/controllers/connectors/schemas/connector.schema.ts @@ -0,0 +1,26 @@ +/* tslint:disable:ban-types */ +import { Schema, model, Model, Document } from 'mongoose'; +import { ConnectorType } from '../plugins'; + +export interface IConnectorDocument extends Document { + [_id: string]: any; + active: boolean; + courses: number[]; + createdAt: Date; + default: boolean; + name: string; + socket: any; + type: ConnectorType; +} + +const connectorSchema = new Schema({ + active: { type: Boolean, default: true }, + courses: { type: Array, default: [] }, + createdAt: { type: Date, default: Date.now }, + default: { type: Boolean, default: false }, + name: { type: String, required: true }, + socket: { type: Object }, + type: { type: ConnectorType }, +}); + +export const Connector: Model = model('Connector', connectorSchema); diff --git a/packages/backend/src/controllers/connectors/schemas/connectorLogItem.schema.ts b/packages/backend/src/controllers/connectors/schemas/connectorLogItem.schema.ts new file mode 100644 index 00000000..3bef91a0 --- /dev/null +++ b/packages/backend/src/controllers/connectors/schemas/connectorLogItem.schema.ts @@ -0,0 +1,22 @@ +/* tslint:disable:ban-types */ +import { Schema, model, Model, Document } from 'mongoose'; +import { config } from '../../../configuration/environment'; +import { ConnectorLogType } from '..'; +import { ConnectorType } from '../plugins'; + +export interface IConnectorLogItemDocument extends Document { + [_id: string]: any; + connector: string; + createdAt: Date; + message: string; + type: ConnectorType; +} + +const connectorLogItemSchema = new Schema({ + connector: { type: Schema.Types.ObjectId, ref: 'Connector', required: true }, + createdAt: { type: Date, default: Date.now, expires: config.connectorLogLifetime, }, + message: { type: String, required: true }, + type: { type: ConnectorLogType, required: true }, +}); + +export const ConnectorLogItem: Model = model('connectorlog', connectorLogItemSchema); diff --git a/packages/backend/src/controllers/connectors/service.ts b/packages/backend/src/controllers/connectors/service.ts new file mode 100644 index 00000000..9e6aa5e3 --- /dev/null +++ b/packages/backend/src/controllers/connectors/service.ts @@ -0,0 +1,124 @@ +import { ApiError } from '../../utils/api'; +import { loggerFile } from '../../configuration/logger'; +import { ConnectorPlugin, ConnectorType, DiscordBotConnectorPlugin } from './plugins'; +import { Connector, IConnectorDocument } from './schemas/connector.schema'; +import { Message } from '../messages/templates'; +import { config } from '../../configuration/environment'; + +class ConnectorService { + + private connectors: ConnectorPlugin[] = []; + + /** + * Creates an instance of ConnectorService. + * Fetches all connectors from database and creates ConnectorPlugin instances + * @memberof ConnectorService + */ + constructor() { + loggerFile.info('ConnectorService has been started'); + // Get all connectors from database + Connector.find().then((connectorList: IConnectorDocument[]) => { + connectorList.forEach(connector => { + switch (connector.type) { + case ConnectorType.Discord: + loggerFile.debug(`Found connector of type Discord Bot (${connector._id})`); + this.connectors.push(new DiscordBotConnectorPlugin(connector)); + break; + + default: + loggerFile.error(`Found connector with unknown type ${connector.type} at ${connector._id}`); + break; + } + }); + + // If no connector has been found, start to create connectors based on .env + if (connectorList.length === 0) { + loggerFile.warn('No connector found within database'); + this.createConnectorsFromEnv(); + } + }); + } + + /** + * Creates new connectors based on environment variables + * + * @private + * @async + * @memberof ConnectorService + */ + private async createConnectorsFromEnv(): Promise { + loggerFile.debug('Start creating connectors from environment variables'); + + // Create optional discord Bot + if (!!config.discordChannel && !!config.discordToken) { + const options: IConnectorDocument = { + name: 'Discord Bot (Environment)', + type: ConnectorType.Discord, + default: true, + socket: { + channel: config.discordChannel, + token: config.discordToken, + }, + } as IConnectorDocument; + + const connector = await new Connector(options).save(); + this.connectors.push(new DiscordBotConnectorPlugin(connector)); + loggerFile.debug(`Connector of type Discord (${connector._id}) has been created`); + } + } + + /** + * Publish a message to connectors that have this course assigned + * If the course is not assigned to a connector, it will send the message to all + * connectors with the default flag. + * + * @param {number} courseId id of the moodle course + * @param {MessageTemplate} template message template that will be used + * @param {*} options content that will be applied on template + * @memberof ConnectorService + */ + public publish(courseId: number, template: Message, options: any): void { + const message = template.apply(options); + let messageWasSent: boolean = false; + + loggerFile.info('Got new message publish order'); + + this.connectors.forEach(connector => { + // See if course is assigned to this connector - skip if not + if (connector.courses.indexOf(courseId) === -1) return; + + messageWasSent = true; + + // Course is assigned to this connector, send message + connector.send(message); + }); + + // If the message was sent to a plugin, stop here + if (messageWasSent) return; + + loggerFile.info(`No connector for course ${courseId} found. Use default connectors!`); + + // If not, send the message to all default connectors + this.connectors.forEach(connector => { + if (connector.isDefault) connector.send(message); + }); + } + + /** + * Updates a selected connector + * + * @throws ApiError + * @param {string} connectorId objectId of the connector + * @param {[key: string]: any} body body of http request + * @returns {Promise} Updated document + * @memberof ConnectorService + */ + public async update(connectorId: string, body: { [key: string]: any }) : Promise { + const connector = this.connectors.find(element => element.objectId === connectorId); + if (!connector) throw new ApiError(404, `Connector with id ${connectorId} not found!`); + + return await connector.update(body); + } +} + +export const connectorService = new ConnectorService(); diff --git a/packages/backend/src/controllers/discord/index.ts b/packages/backend/src/controllers/discord/index.ts deleted file mode 100644 index 77176fb4..00000000 --- a/packages/backend/src/controllers/discord/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * discord/index.ts provides an interface to interact with the discord.js libary on an easy way. - */ -import { client } from '../../configuration/discord'; -import { FMDBMessageTemplate } from './message.interface'; -import { TextChannel } from 'discord.js'; -import { getDiscordChannel } from '../discordChannel/discordChannel'; -import { ApiError } from '../../utils/api'; - -/** - * Send a templated message to a discord user - * - * @export - * @param {string} userId Discord ID of the recipient - * @param {FMDBMessageTemplate} messageTemplate Message template - * @param {object} values - */ -export function sendTo(userId: string, messageTemplate: FMDBMessageTemplate, values: object) { - const discordUser = client.users.cache.get(userId); - if (!discordUser) throw new ApiError(409, `User not in discord cache. Send the bot a small 'test' message (via DM) and try again.`); - - const message = messageTemplate.apply(values); - if (!message) throw new Error('Internal server error'); - - discordUser.send(message); -} - -/** - * Publish a message to the set discord channel - * - * @export - * @param {FMDBMessageTemplate} messageTemplate Message template - * @param {object} value - */ -export async function publish(messageTemplate: FMDBMessageTemplate, values: object) { - const channelId = await getDiscordChannel(); - const discordChannel = await client.channels.cache.get(channelId); - if (!discordChannel) throw new Error(`Channel not in discord cache. Send a small 'test' message to the channel and try again.`); - - const message = messageTemplate.apply(values); - if (!message) throw new Error('Internal server error'); - - (discordChannel as TextChannel).send(message); -} diff --git a/packages/backend/src/controllers/discord/templates/index.ts b/packages/backend/src/controllers/discord/templates/index.ts deleted file mode 100644 index d632ae07..00000000 --- a/packages/backend/src/controllers/discord/templates/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { AssignmentMessage, AssignmentMessageOptions } from './assignmentMessage.class'; -export { AssignmentReminderMessage, AssignmentReminderMessageOptions } from './assignmentReminderMessage.class'; -export { ResourceMessage, ResourceMessageOptions } from './resourceMessage.class'; -export { TokenRequestMessage, TokenRequestMessageOptions } from './tokenRequestMessage.class'; diff --git a/packages/backend/src/controllers/discord/templates/tokenRequestMessage.class.ts b/packages/backend/src/controllers/discord/templates/tokenRequestMessage.class.ts deleted file mode 100644 index cbbad46d..00000000 --- a/packages/backend/src/controllers/discord/templates/tokenRequestMessage.class.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { MessageTemplate } from '../message.class'; - -export interface TokenRequestMessageOptions { - key: string; -} - -export class TokenRequestMessage extends MessageTemplate { - readonly template = `:key: **Es wurde ein Zugangstoken angefordert** - Zugangstoken lautet: {key} - Solltest du den Token nicht angefordert haben - Kein Problem, lösche diese Nachricht einfach!`; -} diff --git a/packages/backend/src/controllers/discordChannel/discordChannel.schema.ts b/packages/backend/src/controllers/discordChannel/discordChannel.schema.ts deleted file mode 100644 index 19b6b2d6..00000000 --- a/packages/backend/src/controllers/discordChannel/discordChannel.schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Schema, model, Model, Document } from 'mongoose'; - -interface IDiscordChannelSchema extends Document { - [_id: string]: any; - channel: string; -} - -const discordChannelSchema = new Schema({ - channel: {type: String}, -}); - -export const DiscordChannel: Model = model('DiscordChannel', discordChannelSchema); diff --git a/packages/backend/src/controllers/discordChannel/discordChannel.ts b/packages/backend/src/controllers/discordChannel/discordChannel.ts deleted file mode 100644 index cb08e297..00000000 --- a/packages/backend/src/controllers/discordChannel/discordChannel.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { DiscordChannel } from './discordChannel.schema'; -import { config } from '../../configuration/environment'; -import { loggerFile } from '../../configuration/logger'; -import { Request, Response, NextFunction } from 'express'; -import { object, string } from '@hapi/joi'; -import { ApiSuccess, ApiError } from '../../utils/api'; - -/** - * Writes the discord channel into the database. - * Uses the default config value if no value is given. - * Creates a new document if it doesn't exist before. - * @param channel {number} Discord channel. - * @export - */ -export async function setDiscordChannel(channelNr:string = config.discordChannel):Promise{ - await DiscordChannel.findOneAndUpdate({},{$set: {channel: channelNr}},{upsert: true}); -} - -/** - * Returns the discord channel value from the database. - * Creates a new document with the config value if it doesn't exist before. - * @export - * @returns {Promise} A promise to the discord channel loaded from the database as string. - */ -export async function getDiscordChannel():Promise{ - const result = await DiscordChannel.findOne(); - // Set default channel from config if theres nothing in the database - if (!result) await setDiscordChannel(); - return result ? result.channel : config.discordChannel; -} - -/** - * Handles GET /api/settings/discordChannel requests and responds - * with the current discord channel (as JSON Object). - * @param req Request - * @param res Response - * @param next NextFunction - */ -export async function getDiscordChannelRequest(req: Request, res: Response, next: NextFunction) { - - try { - const channelId = await getDiscordChannel(); - if (!channelId) throw new ApiError(503, 'Internal error while retrieving discord channel id'); - - const response = new ApiSuccess(200, {channelId}); - next(response); - } - catch (err) { - loggerFile.error(err.message); - next(err); - } - } - -// Schema for validating api input -const discordChannelRequestSchema = object({ - channelId: string().length(18).required() -}); - -/** - * Handles PUT /api/settings/discordChannel/ requests. - * Writes given string to the database. - * @param req Request: Contains discord channel id as string - * @param res Response - * @param next NextFunction - */ -export async function setDiscordChannelRequest(req: Request, res: Response, next: NextFunction) { - - try { - // Input checking - const request = discordChannelRequestSchema.validate(req.body); - if (request.error) throw new ApiError(400, request.error.message); - - // Method call and exit - await setDiscordChannel(request.value.channelId); - - const response = new ApiSuccess(); - next(response); - } - catch (err) { - loggerFile.error(err.message); - next(err); - } - } diff --git a/packages/backend/src/controllers/discord/message.class.ts b/packages/backend/src/controllers/messages/message.class.ts similarity index 100% rename from packages/backend/src/controllers/discord/message.class.ts rename to packages/backend/src/controllers/messages/message.class.ts diff --git a/packages/backend/src/controllers/discord/message.interface.ts b/packages/backend/src/controllers/messages/message.interface.ts similarity index 100% rename from packages/backend/src/controllers/discord/message.interface.ts rename to packages/backend/src/controllers/messages/message.interface.ts diff --git a/packages/backend/src/controllers/discord/templates/assignmentMessage.class.ts b/packages/backend/src/controllers/messages/templates/assignmentMessage.class.ts similarity index 100% rename from packages/backend/src/controllers/discord/templates/assignmentMessage.class.ts rename to packages/backend/src/controllers/messages/templates/assignmentMessage.class.ts diff --git a/packages/backend/src/controllers/discord/templates/assignmentReminderMessage.class.ts b/packages/backend/src/controllers/messages/templates/assignmentReminderMessage.class.ts similarity index 100% rename from packages/backend/src/controllers/discord/templates/assignmentReminderMessage.class.ts rename to packages/backend/src/controllers/messages/templates/assignmentReminderMessage.class.ts diff --git a/packages/backend/src/controllers/messages/templates/index.ts b/packages/backend/src/controllers/messages/templates/index.ts new file mode 100644 index 00000000..ee9a3920 --- /dev/null +++ b/packages/backend/src/controllers/messages/templates/index.ts @@ -0,0 +1,9 @@ +import { AssignmentMessage } from './assignmentMessage.class'; +import { AssignmentReminderMessage } from './assignmentReminderMessage.class'; +import { ResourceMessage } from './resourceMessage.class'; + +export { AssignmentMessage, AssignmentMessageOptions } from './assignmentMessage.class'; +export { AssignmentReminderMessage, AssignmentReminderMessageOptions } from './assignmentReminderMessage.class'; +export { ResourceMessage, ResourceMessageOptions } from './resourceMessage.class'; + +export type Message = AssignmentMessage | AssignmentReminderMessage | ResourceMessage; diff --git a/packages/backend/src/controllers/discord/templates/resourceMessage.class.ts b/packages/backend/src/controllers/messages/templates/resourceMessage.class.ts similarity index 100% rename from packages/backend/src/controllers/discord/templates/resourceMessage.class.ts rename to packages/backend/src/controllers/messages/templates/resourceMessage.class.ts diff --git a/packages/backend/src/controllers/moodle/handle.ts b/packages/backend/src/controllers/moodle/handle.ts index b3cfb763..f671a199 100644 --- a/packages/backend/src/controllers/moodle/handle.ts +++ b/packages/backend/src/controllers/moodle/handle.ts @@ -1,8 +1,8 @@ import { config } from '../../configuration/environment'; import { ICourse } from './interfaces/course.interface'; import { IResource } from './interfaces/resource.interface'; -import { publish } from '../discord'; -import { AssignmentMessage, AssignmentMessageOptions, AssignmentReminderMessage, AssignmentReminderMessageOptions, ResourceMessage, ResourceMessageOptions } from '../discord/templates'; +import { connectorService } from '../connectors/service'; +import { AssignmentMessage, AssignmentMessageOptions, AssignmentReminderMessage, AssignmentReminderMessageOptions, ResourceMessage, ResourceMessageOptions } from '../messages/templates'; import { Reminder } from './schemas/reminder.schema'; import { IContentfile } from './interfaces/contentfile.interface'; @@ -22,7 +22,7 @@ export async function handleAssignments(courses: ICourse[], lastFetch: number): day: 'numeric', hour: 'numeric', minute: 'numeric' - }; + } as Intl.DateTimeFormatOptions; for (const course of courses) { for (const assignment of course.assignments) { @@ -36,7 +36,7 @@ export async function handleAssignments(courses: ICourse[], lastFetch: number): dueDate: new Date(assignment.duedate * 1000).toLocaleString('de-DE', dateOptions) }; - await publish(new AssignmentMessage(), options); + connectorService.publish(course.id, new AssignmentMessage(), options); } // check if new deadline is incoming that hasn`t been notified about @@ -50,7 +50,7 @@ export async function handleAssignments(courses: ICourse[], lastFetch: number): title: assignment.name }; await new Reminder({assignment_id: assignment.id}).save(); - await publish(new AssignmentReminderMessage(), options); + connectorService.publish(course.id, new AssignmentReminderMessage(), options); } } } @@ -104,7 +104,7 @@ export async function handleContents(contents: any, courseName: string, lastFetc title: file.filename, link: file.fileurl.replace('/webservice', '') }; - await publish(new ResourceMessage(), options); + connectorService.publish(undefined, new ResourceMessage(), options); } } @@ -128,7 +128,7 @@ export async function handleResources(resources: IResource[], courseMap: Map { it('should return the correct jwt', () => { diff --git a/packages/backend/tests/connectors/logger.spec.ts b/packages/backend/tests/connectors/logger.spec.ts new file mode 100644 index 00000000..ee9918b1 --- /dev/null +++ b/packages/backend/tests/connectors/logger.spec.ts @@ -0,0 +1,81 @@ +import { loggerFile } from '../../src/configuration/logger'; +import { connectorLogger } from '../../src/controllers/connectors/logger'; +import { ConnectorLogItem } from '../../src/controllers/connectors/schemas/connectorLogItem.schema'; + +jest.mock('../../src/configuration/environment.ts'); +jest.mock('../../src/controllers/connectors/schemas/connectorLogItem.schema'); + +describe('connectors/logger.ts info()', () => { + let spyLogger: jest.SpyInstance; + const message = "Test"; + + beforeEach(() => { + spyLogger = jest.spyOn(loggerFile, 'info'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should save the message to logs', () => { + connectorLogger.info(message, "123"); + expect(spyLogger).toHaveBeenCalledWith(message); + expect(ConnectorLogItem).toHaveBeenCalled(); + }); + + it('should skip saving the message to logs', () => { + connectorLogger.info(message, "123", true); + expect(spyLogger).toHaveBeenCalledWith(message); + expect(ConnectorLogItem).not.toHaveBeenCalled(); + }); +}); + +describe('connectors/logger.ts warn()', () => { + let spyLogger: jest.SpyInstance; + const message = "Test"; + + beforeEach(() => { + spyLogger = jest.spyOn(loggerFile,'warn'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should save the message to logs', () => { + connectorLogger.warn(message, "123"); + expect(spyLogger).toHaveBeenCalledWith(message); + expect(ConnectorLogItem).toHaveBeenCalled(); + }); + + it('should skip saving the message to logs', () => { + connectorLogger.warn(message, "123", true); + expect(spyLogger).toHaveBeenCalledWith(message); + expect(ConnectorLogItem).not.toHaveBeenCalled(); + }); +}); + +describe('connectors/logger.ts error()', () => { + let spyLogger: jest.SpyInstance; + const message = "Test"; + + beforeEach(() => { + spyLogger = jest.spyOn(loggerFile,'error'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should save the message to logs', () => { + connectorLogger.error(message, "123"); + expect(spyLogger).toHaveBeenCalledWith(message); + expect(ConnectorLogItem).toHaveBeenCalled(); + }); + + it('should skip saving the message to logs', () => { + connectorLogger.error(message, "123", true); + expect(spyLogger).toHaveBeenCalledWith(message); + expect(ConnectorLogItem).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/tests/discord.spec.ts b/packages/backend/tests/discord.spec.ts deleted file mode 100644 index 8f63b8f6..00000000 --- a/packages/backend/tests/discord.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { client } from '../src/configuration/discord'; -import { TokenRequestMessage } from '../src/controllers/discord/templates'; -import { publish, sendTo } from '../src/controllers/discord'; -import { ApiError } from "../src/utils/api"; -import * as discordChannel from '../src/controllers/discordChannel/discordChannel'; - -jest.mock('../src/configuration/environment.ts'); -jest.mock('../src/configuration/discord.ts'); - -describe('discord.ts discordSendTo', () => { - let spyDiscordClientUsers: jest.SpyInstance; - - beforeEach(() => { - spyDiscordClientUsers = jest.spyOn(client.users.cache, 'get'); - }); - - it('should throw error if user is not in cache', async () => { - spyDiscordClientUsers.mockImplementation(() => null); - const compareError = new ApiError(409, `User not in discord cache. Send the bot a small 'test' message (via DM) and try again.`); - try { - await sendTo('1234657890', new TokenRequestMessage(), { key: 123123 }); - } catch (error) { - expect(error).toEqual(compareError); - } - }); - - it('should send message if everything is fine', async () => { - const mockDiscordUser = { send: jest.fn() }; - spyDiscordClientUsers.mockImplementation(() => mockDiscordUser); - - await sendTo('1234657890', new TokenRequestMessage(), { key: 123123 }); - expect(mockDiscordUser.send).toHaveBeenCalled(); - }); - -}); - -describe('discord.ts discordPublish', () => { - let spyDiscordClientChannels: jest.SpyInstance; - let spyDiscordChannel: jest.SpyInstance; - - beforeEach(() => { - spyDiscordClientChannels = jest.spyOn(client.channels.cache, 'get'); - spyDiscordChannel = jest.spyOn(discordChannel, 'getDiscordChannel'); - }); - - it('should throw error if channel is not in cache', async () => { - spyDiscordClientChannels.mockImplementation(() => null); - spyDiscordChannel.mockImplementation(() => null); - const compareError = new Error(`Channel not in discord cache. Send a small 'test' message to the channel and try again.`); - try { - await publish(new TokenRequestMessage(), { key: 123123 }); - } catch (error) { - expect(error).toEqual(compareError); - } - }); - - it('should send message if everything is fine', async () => { - const mockDiscordChannel = { send: jest.fn() }; - spyDiscordClientChannels.mockImplementation(() => mockDiscordChannel); - spyDiscordChannel.mockResolvedValue(() => 123123123); - - await publish(new TokenRequestMessage(), { key: 123123 }); - expect(mockDiscordChannel.send).toHaveBeenCalled(); - }); - -}); diff --git a/packages/backend/tests/moodle.fetch.spec.ts b/packages/backend/tests/moodle.fetch.spec.ts index b51fb48a..5bd4ce17 100644 --- a/packages/backend/tests/moodle.fetch.spec.ts +++ b/packages/backend/tests/moodle.fetch.spec.ts @@ -5,9 +5,6 @@ import { loggerFile } from '../src/configuration/logger'; jest.mock('node-fetch', () => jest.fn()); jest.mock('../src/configuration/environment.ts'); -jest.mock('../src/configuration/discord.ts'); -jest.mock('../src/controllers/discord/index.ts'); - const mockFetch = (res: any) => mocked(fetch).mockImplementationOnce((): Promise => Promise.resolve({ diff --git a/packages/backend/tests/moodle.handle.spec.ts b/packages/backend/tests/moodle.handle.spec.ts index b1a0945d..47093ade 100644 --- a/packages/backend/tests/moodle.handle.spec.ts +++ b/packages/backend/tests/moodle.handle.spec.ts @@ -1,25 +1,23 @@ -import * as discord from '../src/controllers/discord'; +import { connectorService } from '../src/controllers/connectors/service'; import mockingoose from 'mockingoose'; import { handleResources, handleAssignments } from '../src/controllers/moodle/handle'; import { IResource } from '../src/controllers/moodle/interfaces/resource.interface'; -import { ResourceMessage, AssignmentMessage } from '../src/controllers/discord/templates'; +import { ResourceMessage, AssignmentMessage } from '../src/controllers/messages/templates'; import { ICourse } from '../src/controllers/moodle/interfaces/course.interface'; import { Reminder } from '../src/controllers/moodle/schemas/reminder.schema'; -jest.mock('../src/configuration/discord.ts'); jest.mock('../src/configuration/environment.ts'); -jest.mock('../src/controllers/discord/index.ts'); jest.mock('../src/controllers/moodle/fetch.ts'); describe('moodle/handle.ts handleAssignments', () => { - let spyDiscordPublish: jest.SpyInstance; + let spyConnectorServicePublish: jest.SpyInstance; let spyReminderSave: jest.SpyInstance; let mockCourses: ICourse[]; const dateOptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }; beforeEach(() => { - spyDiscordPublish = jest.spyOn(discord, 'publish'); + spyConnectorServicePublish = jest.spyOn(connectorService, 'publish'); spyReminderSave = jest.spyOn(new Reminder(), 'save'); mockCourses = [ { fullname: "Course01", shortname: "C1", assignments: [{ id: 0, name: "As1", duedate: 0, timemodified: 999 }] }, @@ -34,6 +32,7 @@ describe('moodle/handle.ts handleAssignments', () => { it('should only print assignments newer than the last fetch timestamp', async () => { // expected are the Courses with course.assignments[0].timemodified > 1000 const expectedParameters = [ + undefined, new AssignmentMessage(), { "course": "C2", @@ -43,8 +42,8 @@ describe('moodle/handle.ts handleAssignments', () => { ] await handleAssignments(mockCourses, 1000); - expect(spyDiscordPublish).toBeCalledTimes(1); - expect(spyDiscordPublish).toBeCalledWith(...expectedParameters); + expect(spyConnectorServicePublish).toBeCalledTimes(1); + expect(spyConnectorServicePublish).toBeCalledWith(...expectedParameters); }); it('should write new reminders to the database', async () => { @@ -52,7 +51,7 @@ describe('moodle/handle.ts handleAssignments', () => { mockingoose(Reminder).toReturn(null, 'findOne'); await handleAssignments(mockCourses, 2000); - expect(spyDiscordPublish).toHaveBeenCalledTimes(1); + expect(spyConnectorServicePublish).toHaveBeenCalledTimes(1); expect(spyReminderSave).toHaveBeenCalledTimes(1); }); @@ -60,18 +59,18 @@ describe('moodle/handle.ts handleAssignments', () => { mockCourses[0].assignments[0].duedate = Math.floor(Date.now() / 1000) + 3000; mockingoose(Reminder).toReturn({ assignment_id: 0 }, 'findOne'); - expect(spyDiscordPublish).toHaveBeenCalledTimes(0); + expect(spyConnectorServicePublish).toHaveBeenCalledTimes(0); }); }); describe('moodle/handle.ts handleResources', () => { - let spyDiscordPublish: jest.SpyInstance; + let spyConnectorServicePublish: jest.SpyInstance; let mockResources: IResource[]; const courseMap = new Map().set(1, 'Course01').set(2, 'Course02'); beforeEach(() => { - spyDiscordPublish = jest.spyOn(discord, 'publish'); + spyConnectorServicePublish = jest.spyOn(connectorService, 'publish'); mockResources = [ { course: 1, contentfiles: [{ timemodified: 999 }] }, { course: 2, contentfiles: [{ timemodified: 1001, fileurl: 'test/webservice', filename: 'testname' }] } @@ -84,6 +83,7 @@ describe('moodle/handle.ts handleResources', () => { it('should only print resources newer than the last fetch timestamp', async () => { const expectedParameters = [ + undefined, new ResourceMessage(), { course: 'Course02', @@ -93,7 +93,7 @@ describe('moodle/handle.ts handleResources', () => { ] await handleResources(mockResources, courseMap, 1000); - expect(spyDiscordPublish).toBeCalledTimes(1); - expect(spyDiscordPublish).toBeCalledWith(...expectedParameters) + expect(spyConnectorServicePublish).toBeCalledTimes(1); + expect(spyConnectorServicePublish).toBeCalledWith(...expectedParameters) }); }); diff --git a/packages/backend/tests/moodle.spec.ts b/packages/backend/tests/moodle.spec.ts index af142d1f..0b203489 100644 --- a/packages/backend/tests/moodle.spec.ts +++ b/packages/backend/tests/moodle.spec.ts @@ -11,8 +11,6 @@ import { ICourseDetails } from '../src/controllers/moodle/interfaces/coursedetai jest.mock('../src/configuration/environment.ts'); jest.mock('../src/controllers/moodle/fetch.ts'); -jest.mock('../src/configuration/discord.ts'); -jest.mock('../src/controllers/discord/index.ts'); jest.useFakeTimers();