From 26a942e8fb18287c5bf343ba73caf7e539c1ab3c Mon Sep 17 00:00:00 2001 From: RA341 Date: Mon, 4 Nov 2024 02:19:40 -0500 Subject: [PATCH 1/4] added webhooks --- devU-api/package.json | 2 +- .../entities/webhooks/webhooks.controller.ts | 71 +++++++++++++++++++ .../entities/webhooks/webhooks.middleware.ts | 67 +++++++++++++++++ .../src/entities/webhooks/webhooks.model.ts | 38 ++++++++++ .../src/entities/webhooks/webhooks.router.ts | 54 ++++++++++++++ .../entities/webhooks/webhooks.serializer.ts | 13 ++++ .../src/entities/webhooks/webhooks.service.ts | 36 ++++++++++ .../entities/webhooks/webhooks.validator.ts | 16 +++++ devU-api/src/index.ts | 3 + .../src/migration/1730698767895-webhooks.ts | 16 +++++ devU-api/src/router/courseData.router.ts | 4 ++ devU-shared/src/index.ts | 1 + devU-shared/src/types/webhooks.types.ts | 8 +++ 13 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 devU-api/src/entities/webhooks/webhooks.controller.ts create mode 100644 devU-api/src/entities/webhooks/webhooks.middleware.ts create mode 100644 devU-api/src/entities/webhooks/webhooks.model.ts create mode 100644 devU-api/src/entities/webhooks/webhooks.router.ts create mode 100644 devU-api/src/entities/webhooks/webhooks.serializer.ts create mode 100644 devU-api/src/entities/webhooks/webhooks.service.ts create mode 100644 devU-api/src/entities/webhooks/webhooks.validator.ts create mode 100644 devU-api/src/migration/1730698767895-webhooks.ts create mode 100644 devU-shared/src/types/webhooks.types.ts diff --git a/devU-api/package.json b/devU-api/package.json index e908de15..1c45a402 100644 --- a/devU-api/package.json +++ b/devU-api/package.json @@ -5,7 +5,7 @@ "scripts": { "start": "npm run migrate && ts-node-dev src/index.ts", "migrate": "npm run typeorm -- migration:run -d src/database.ts", - "update-shared": "cd ../devU-shared && npm run build-local && npm i", + "update-shared": "cd ../devU-shared && npm run build-local && cd ../devU-api && npm i", "typeorm": "typeorm-ts-node-commonjs", "test": "jest --passWithNoTests", "clean": "rimraf build/*", diff --git a/devU-api/src/entities/webhooks/webhooks.controller.ts b/devU-api/src/entities/webhooks/webhooks.controller.ts new file mode 100644 index 00000000..8bc455b5 --- /dev/null +++ b/devU-api/src/entities/webhooks/webhooks.controller.ts @@ -0,0 +1,71 @@ +import { NextFunction, Request, Response } from 'express' + +import { Updated } from '../../utils/apiResponse.utils' +import { serialize } from './webhooks.serializer' +import WebhooksService from './webhooks.service' + +export async function get(req: Request, res: Response, next: NextFunction) { + try { + const hooksModel = await WebhooksService.list() + const hooks = hooksModel.map(serialize) + res.status(200).json(hooks) + } catch (err) { + next(err) + } +} + +export async function getById(req: Request, res: Response, next: NextFunction) { + try { + const hooksModel = await WebhooksService.retrieveByUserId(req.currentUser!.userId!) + if (!hooksModel) { + return res.status(404).json({ 'error': 'Webhook for user not found not found' }) + } + const hooks = hooksModel.map(serialize) + + res.status(200).json(hooks) + } catch (err) { + next(err) + } +} + + +export async function post(req: Request, res: Response, next: NextFunction) { + try { + req.body['userId'] = req.currentUser!.userId! + + const created = await WebhooksService.create(req.body) + res.status(201).json(serialize(created)) + } catch (err: any) { + next(err) + } +} + +export async function put(req: Request, res: Response, next: NextFunction) { + try { + const webhookId = parseInt(req.params.id) + await WebhooksService.update(webhookId, req.body) + + res.status(201).json(Updated) + } catch (err: any) { + next(err) + } +} + +export async function _delete(req: Request, res: Response, next: NextFunction) { + try { + const webhookId = parseInt(req.params.id) + await WebhooksService._delete(webhookId) + + res.status(204).json('Deleted') + } catch (err: any) { + next(err) + } +} + +export default { + get, + put, + post, + _delete, + getById, +} diff --git a/devU-api/src/entities/webhooks/webhooks.middleware.ts b/devU-api/src/entities/webhooks/webhooks.middleware.ts new file mode 100644 index 00000000..97bf9fa5 --- /dev/null +++ b/devU-api/src/entities/webhooks/webhooks.middleware.ts @@ -0,0 +1,67 @@ +import { NextFunction, Request, Response } from 'express' +import WebhooksService from './webhooks.service' +import fetch from 'node-fetch' + + +export async function processWebhook(statusCode: number, userId: number, path: string, body: any) { + return new Promise(async (resolve, reject) => { + try { + // process path and check for match + const hooks = await WebhooksService.retrieveByUserId(userId) + if (hooks.length !== 0) { + for (let hook of hooks) { + // todo make matcher more generic that checks url patterns + if (hook.matcherUrl == path) { + try { + // todo add multiple ways to format a webhook message currently only formated for discord + const re = await fetch(hook.destinationUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ 'content': body }), + }) + + if (re.status >= 300) { + reject(`Failed to send webhook ${re.statusText}`) + } + } catch (e) { + reject(e) + } + } + } + // prepare request and send + } else { + reject('Url could not be matched') + } + + resolve('Goodbye and thanks for all the fish') + } catch (e) { + reject(e) + } + }) +} + +// adds a +export function responseInterceptor(req: Request, res: Response, next: NextFunction) { + const originalSend = res.send + + // Override function + // @ts-ignore + res.send = function(body) { + // send response to client + originalSend.call(this, body) + + // send body for processing in webhook + if (req.currentUser?.userId) { + processWebhook(res.statusCode, req.currentUser?.userId, req.originalUrl, body).then( + value => { + console.log('Sent webhook successfully') + }, + ).catch(err => { + console.error('Error sending webhook', err) + }) + } + } + next() +} diff --git a/devU-api/src/entities/webhooks/webhooks.model.ts b/devU-api/src/entities/webhooks/webhooks.model.ts new file mode 100644 index 00000000..bdeabb00 --- /dev/null +++ b/devU-api/src/entities/webhooks/webhooks.model.ts @@ -0,0 +1,38 @@ +import { + JoinColumn, + ManyToOne, + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm' + +import UserModel from '../user/user.model' + +@Entity('Webhooks') +export default class WebhooksModel { + @PrimaryGeneratedColumn() + id: number + + @Column({name: 'destination_url'}) + destinationUrl: string + + @Column({name: 'matcher_url'}) + matcherUrl: string + + @Column({ name: 'user_id' }) + @JoinColumn({ name: 'user_id' }) + @ManyToOne(() => UserModel) + userId: number + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt?: Date +} diff --git a/devU-api/src/entities/webhooks/webhooks.router.ts b/devU-api/src/entities/webhooks/webhooks.router.ts new file mode 100644 index 00000000..371c7fa4 --- /dev/null +++ b/devU-api/src/entities/webhooks/webhooks.router.ts @@ -0,0 +1,54 @@ +import express from 'express' + +import validator from './webhooks.validator' + +import WebhooksController from './webhooks.controller' +import { isAuthorized } from '../../authorization/authorization.middleware' + +const Router = express.Router({ mergeParams: true }) + + +/** + * @swagger + * /webhooks: + * get: + * summary: get all webhooks created by a user + */ +Router.get('/', isAuthorized('courseViewAll'), WebhooksController.getById) + +/** + * @swagger + * /webhooks: + * get: + * summary: get all webhooks, only for admins + */ +Router.get('/all', isAuthorized('admin'), WebhooksController.get) + + +/** + * @swagger + * /webhooks: + * get: + * summary: create a webhook + */ +Router.post('/', isAuthorized('courseViewAll'), validator, WebhooksController.post) + + +/** + * @swagger + * /webhooks: + * put: + * summary: Edit webhook urls + */ +Router.put('/:id', isAuthorized('courseViewAll'), validator, WebhooksController.put) + + +/** + * @swagger + * /webhooks: + * delete: + * summary: delete a webhook + */ +Router.delete('/:id', isAuthorized('courseViewAll'), WebhooksController._delete) + +export default Router diff --git a/devU-api/src/entities/webhooks/webhooks.serializer.ts b/devU-api/src/entities/webhooks/webhooks.serializer.ts new file mode 100644 index 00000000..96b61253 --- /dev/null +++ b/devU-api/src/entities/webhooks/webhooks.serializer.ts @@ -0,0 +1,13 @@ +import WebhooksModel from './webhooks.model' +import { Webhooks } from 'devu-shared-modules' + +export function serialize(webhooks: WebhooksModel): Webhooks { + return { + id: webhooks.id, + userId: webhooks.userId, + destinationUrl: webhooks.destinationUrl, + matcherUrl: webhooks.matcherUrl, + updatedAt: webhooks.updatedAt.toISOString(), + createdAt: webhooks.createdAt.toISOString(), + } +} diff --git a/devU-api/src/entities/webhooks/webhooks.service.ts b/devU-api/src/entities/webhooks/webhooks.service.ts new file mode 100644 index 00000000..7d8e041e --- /dev/null +++ b/devU-api/src/entities/webhooks/webhooks.service.ts @@ -0,0 +1,36 @@ +import { dataSource } from '../../database' +import WebhooksModel from './webhooks.model' +import { Webhooks } from 'devu-shared-modules' +import { IsNull } from 'typeorm' + +const connect = () => dataSource.getRepository(WebhooksModel) + +async function create(input: Webhooks) { + return await connect().save(input) +} + + + +async function retrieveByUserId(userId: number) { + return await connect().findBy({ userId: userId, deletedAt: IsNull() }) +} + +async function update(id: number, input: Webhooks) { + return await connect().update(id, { matcherUrl: input.matcherUrl, destinationUrl: input.destinationUrl }) +} + +async function list() { + return await connect().findBy({ deletedAt: IsNull() }) +} + +async function _delete(id: number) { + return await connect().softDelete({ id, deletedAt: IsNull() }) +} + +export default { + create, + list, + update, + retrieveByUserId, + _delete, +} diff --git a/devU-api/src/entities/webhooks/webhooks.validator.ts b/devU-api/src/entities/webhooks/webhooks.validator.ts new file mode 100644 index 00000000..146c79bd --- /dev/null +++ b/devU-api/src/entities/webhooks/webhooks.validator.ts @@ -0,0 +1,16 @@ +import { check } from 'express-validator' + +import validate from '../../middleware/validator/generic.validator' + + +const destinationUrl = check('destinationUrl').isString().trim() +const matcherUrl = check('matcherUrl').isString().trim() + + +const validator = [ + destinationUrl, + matcherUrl, + validate, +] + +export default validator diff --git a/devU-api/src/index.ts b/devU-api/src/index.ts index 75056335..92730683 100644 --- a/devU-api/src/index.ts +++ b/devU-api/src/index.ts @@ -19,6 +19,7 @@ import errorHandler from './middleware/errorHandler.middleware' // Authentication Handlers import './utils/passport.utils' +import { responseInterceptor } from './entities/webhooks/webhooks.middleware' const app = express() @@ -43,6 +44,8 @@ initializeMinio() console.log(`Api: ${environment.isDocker ? '' : 'not'} running in docker`) + app.use(responseInterceptor) + // Middleware; app.use('/', router) app.use(errorHandler) diff --git a/devU-api/src/migration/1730698767895-webhooks.ts b/devU-api/src/migration/1730698767895-webhooks.ts new file mode 100644 index 00000000..51e06be1 --- /dev/null +++ b/devU-api/src/migration/1730698767895-webhooks.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Webhooks1730698767895 implements MigrationInterface { + name = 'Webhooks1730698767895' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "Webhooks" ("id" SERIAL NOT NULL, "destination_url" character varying NOT NULL, "matcher_url" character varying NOT NULL, "user_id" integer NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP, CONSTRAINT "PK_ef540eaf209b4e5cb871ea34910" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "Webhooks" ADD CONSTRAINT "FK_0831572f37f912eed2756aef1af" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "Webhooks" DROP CONSTRAINT "FK_0831572f37f912eed2756aef1af"`); + await queryRunner.query(`DROP TABLE "Webhooks"`); + } + +} diff --git a/devU-api/src/router/courseData.router.ts b/devU-api/src/router/courseData.router.ts index 037e8005..174d0d70 100644 --- a/devU-api/src/router/courseData.router.ts +++ b/devU-api/src/router/courseData.router.ts @@ -18,6 +18,7 @@ import role from '../entities/role/role.router' import nonContainerAutoGraderRouter from '../entities/nonContainerAutoGrader/nonContainerAutoGrader.router' import { asInt } from '../middleware/validator/generic.validator' +import webhooksRouter from '../entities/webhooks/webhooks.router' const assignmentRouter = express.Router({ mergeParams: true }) assignmentRouter.use('/assignment-problems', assignmentProblem) @@ -41,4 +42,7 @@ Router.use('/file-upload', fileUpload) Router.use('/roles', role) Router.use('/user-courses', userCourse) +Router.use('/webhooks', webhooksRouter) + + export default Router diff --git a/devU-shared/src/index.ts b/devU-shared/src/index.ts index d83081ea..464b8ba2 100644 --- a/devU-shared/src/index.ts +++ b/devU-shared/src/index.ts @@ -21,6 +21,7 @@ export * from './types/nonContainerAutoGrader.types' export * from './types/deadlineExtensions.types' export * from './types/grader.types' export * from './types/role.types' +export * from './types/webhooks.types' export * from './utils/object.utils' export * from './utils/string.utils' diff --git a/devU-shared/src/types/webhooks.types.ts b/devU-shared/src/types/webhooks.types.ts new file mode 100644 index 00000000..185c07e8 --- /dev/null +++ b/devU-shared/src/types/webhooks.types.ts @@ -0,0 +1,8 @@ +export type Webhooks = { + id?: number + userId: number + destinationUrl: string + matcherUrl: string + createdAt?: string + updatedAt?: string +} From 47b7e6a189c2c656a0469ab8c89edc2e7a298033 Mon Sep 17 00:00:00 2001 From: Kevin Zhong Date: Mon, 4 Nov 2024 13:29:42 -0500 Subject: [PATCH 2/4] Added base webhookURLForm Added the base of the webhookURLForm that will eventually connect to the backend. --- .../src/components/authenticatedRouter.tsx | 3 ++ .../src/components/pages/webhookURLForm.scss | 5 +++ .../src/components/pages/webhookURLForm.tsx | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 devU-client/src/components/pages/webhookURLForm.scss create mode 100644 devU-client/src/components/pages/webhookURLForm.tsx diff --git a/devU-client/src/components/authenticatedRouter.tsx b/devU-client/src/components/authenticatedRouter.tsx index 50c9217f..a4d09cf7 100644 --- a/devU-client/src/components/authenticatedRouter.tsx +++ b/devU-client/src/components/authenticatedRouter.tsx @@ -21,6 +21,8 @@ import CoursesListPage from "./pages/listPages/courses/coursesListPage"; import AssignmentProblemFormPage from './pages/forms/assignments/assignmentProblemFormPage' import InstructorSubmissionspage from "./pages/submissions/InstructorSubmissionspage"; +import webhookURLForm from './pages/webhookURLForm' + const AuthenticatedRouter = () => ( @@ -50,6 +52,7 @@ const AuthenticatedRouter = () => ( component={InstructorSubmissionspage}/> + diff --git a/devU-client/src/components/pages/webhookURLForm.scss b/devU-client/src/components/pages/webhookURLForm.scss new file mode 100644 index 00000000..25c022e5 --- /dev/null +++ b/devU-client/src/components/pages/webhookURLForm.scss @@ -0,0 +1,5 @@ +@import 'variables'; + +.textField { + align-items: center; +} \ No newline at end of file diff --git a/devU-client/src/components/pages/webhookURLForm.tsx b/devU-client/src/components/pages/webhookURLForm.tsx new file mode 100644 index 00000000..4f82c1f3 --- /dev/null +++ b/devU-client/src/components/pages/webhookURLForm.tsx @@ -0,0 +1,38 @@ +import React, {useState} from 'react' +import TextField from 'components/shared/inputs/textField' +import Button from 'components/shared/inputs/button' +import PageWrapper from 'components/shared/layouts/pageWrapper' +import styles from './webhookURLForm.scss' + +const webhookURLForm = () => { + const [webhookURL, setWebhookURL] = useState() + const [webhookUrls, setWebhookUrls] = useState([]) + + const handleChange = (value: string) => { + setWebhookURL(value); + }; + + const handleAddURL = () => { + if (webhookURL) { + setWebhookUrls([...webhookUrls, webhookURL]) + setWebhookURL('') + } + } + + return ( + +
+ + +
+ +
    + {webhookUrls.map((url, index) => ( +
  • {url}
  • + ))} +
+
+ ) +} + +export default webhookURLForm \ No newline at end of file From 76157c09e016d95dea82bec9a2836e6cad59bc80 Mon Sep 17 00:00:00 2001 From: Kevin Zhong Date: Mon, 4 Nov 2024 13:32:36 -0500 Subject: [PATCH 3/4] Added comment to aid backend Added a comment of where to start writing the connection between frontend and backend --- devU-client/src/components/pages/webhookURLForm.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/devU-client/src/components/pages/webhookURLForm.tsx b/devU-client/src/components/pages/webhookURLForm.tsx index 4f82c1f3..8c3442bd 100644 --- a/devU-client/src/components/pages/webhookURLForm.tsx +++ b/devU-client/src/components/pages/webhookURLForm.tsx @@ -15,6 +15,8 @@ const webhookURLForm = () => { const handleAddURL = () => { if (webhookURL) { setWebhookUrls([...webhookUrls, webhookURL]) + //Handle adding webhook URL to backend here + setWebhookURL('') } } From 1345584e84ff203d6f41c3124d41d29ea936457f Mon Sep 17 00:00:00 2001 From: RA341 Date: Mon, 4 Nov 2024 20:36:22 -0500 Subject: [PATCH 4/4] added matcher url --- .../src/components/authenticatedRouter.tsx | 6 +- .../src/components/pages/webhookURLForm.tsx | 60 ++++++++++++++----- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/devU-client/src/components/authenticatedRouter.tsx b/devU-client/src/components/authenticatedRouter.tsx index a4d09cf7..a8016a49 100644 --- a/devU-client/src/components/authenticatedRouter.tsx +++ b/devU-client/src/components/authenticatedRouter.tsx @@ -21,7 +21,7 @@ import CoursesListPage from "./pages/listPages/courses/coursesListPage"; import AssignmentProblemFormPage from './pages/forms/assignments/assignmentProblemFormPage' import InstructorSubmissionspage from "./pages/submissions/InstructorSubmissionspage"; -import webhookURLForm from './pages/webhookURLForm' +import WebhookURLForm from './pages/webhookURLForm' const AuthenticatedRouter = () => ( @@ -45,6 +45,7 @@ const AuthenticatedRouter = () => ( component={NonContainerAutoGraderForm}/> + @@ -52,7 +53,8 @@ const AuthenticatedRouter = () => ( component={InstructorSubmissionspage}/> - + // TBD, undecided where webhooks should be placed + {/**/} diff --git a/devU-client/src/components/pages/webhookURLForm.tsx b/devU-client/src/components/pages/webhookURLForm.tsx index 8c3442bd..8b979cc1 100644 --- a/devU-client/src/components/pages/webhookURLForm.tsx +++ b/devU-client/src/components/pages/webhookURLForm.tsx @@ -3,37 +3,69 @@ import TextField from 'components/shared/inputs/textField' import Button from 'components/shared/inputs/button' import PageWrapper from 'components/shared/layouts/pageWrapper' import styles from './webhookURLForm.scss' +import RequestService from '../../services/request.service' +import { useParams } from 'react-router-dom' +import { SET_ALERT } from 'redux/types/active.types' +import { useActionless } from '../../redux/hooks' const webhookURLForm = () => { const [webhookURL, setWebhookURL] = useState() const [webhookUrls, setWebhookUrls] = useState([]) + const [watcherUrl, setWatcherUrl] = useState() + const { courseId } = useParams<{ courseId: string}>() + const [setAlert] = useActionless(SET_ALERT) - const handleChange = (value: string) => { + const handleUrlChange = (value: string) => { setWebhookURL(value); }; + const handleWatcherUrl = (value: string) => { + setWatcherUrl(value); + } + const handleAddURL = () => { if (webhookURL) { setWebhookUrls([...webhookUrls, webhookURL]) //Handle adding webhook URL to backend here - + RequestService.post(`/course/${courseId}/webhooks`, { + "destinationUrl": webhookURL, + "matcherUrl": watcherUrl + }).then( + _ => { + setAlert({ autoDelete: true, type: 'success', message: 'Added webhook' }) + } + ).catch(reason => { + setAlert({ autoDelete: true, type: 'error', message: `Failed to add webhook ${reason.toString()}` }) + }) + + // clear field setWebhookURL('') + setWatcherUrl('') } } return ( - -
- - -
- -
    - {webhookUrls.map((url, index) => ( -
  • {url}
  • - ))} -
-
+ +
+ + + +
+ +
    + {webhookUrls.map((url, index) => ( +
  • {url}
  • + ))} +
+
) }