diff --git a/README.md b/README.md index b4b469e..8afa06d 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,5 @@ Before Runningthis on you local machine some things have to be setup MAILPASSWORD: THE PASSWORD FOR THE MAIL ACCOUNT DEV: SWITCH FOR DEV MODE AFFECTS EMAIL SERVER ALLOW_ORIGIN: ORIGIN OF THE PRODUCTION CLIENT (FOR CORS) + FRONTEND_MAIL_ENDPOINT: THE ENDPOINT THE FRONTEND DOES EMAIL-VERIFICATION ``` diff --git a/dev.sh b/dev.sh index 7067090..dcd55c1 100755 --- a/dev.sh +++ b/dev.sh @@ -52,6 +52,7 @@ if [ "$RECREATE" = true ]; then -e DATABASE_URL="postgresql://server:server@postgres:5432/management?schema=public" \ -e NODE_ENV="development" \ -e PORT="${D_PORT}" \ + -e JWT_SECRET="not_for_production" \ -p "${D_PORT}":"${D_PORT}" \ --entrypoint "/app/scripts/docker-entrypoint.dev.sh" \ node diff --git a/package-lock.json b/package-lock.json index fb8ed8f..ec2cb13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@prisma/client": "^3.3.0", + "@types/cors": "^2.8.12", "@types/express-fileupload": "^1.2.1", "@types/handlebars": "^4.1.0", "@types/jsonwebtoken": "^8.5.5", @@ -18,6 +19,7 @@ "@types/nodemailer": "^6.4.4", "@types/redis": "^2.8.32", "argon2": "^0.28.2", + "cors": "^2.8.5", "dotenv": "^10.0.0", "express": "^4.17.1", "express-async-errors": "^3.1.1", @@ -242,6 +244,11 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "node_modules/@types/cors": { + "version": "2.8.12", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", + "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" + }, "node_modules/@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -989,6 +996,18 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -4134,6 +4153,11 @@ "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", "dev": true }, + "@types/cors": { + "version": "2.8.12", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", + "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" + }, "@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -4757,6 +4781,15 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9557922..79ea5f2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,11 +34,13 @@ model Admin { } model Discipline { - id Int @id @default(autoincrement()) - pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid - name String - minTeamSize Int - maxTeamSize Int + id Int @id @default(autoincrement()) + pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + briefDescription String + fullDescription String? + minTeamSize Int + maxTeamSize Int roles RoleSchema[] teams Team[] @@ -64,8 +66,10 @@ model Team { pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String leaderEmail String + verified Boolean @default(false) roles Role[] @relation(name: "participants") + participants Participant[] discipline Discipline @relation(fields: [disciplineId], references: [id], onDelete: Cascade) disciplineId Int } @@ -79,6 +83,8 @@ model Participant { group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) groupId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int roles Role[] } @@ -121,7 +127,7 @@ model Group { model Media { id Int @id @default(autoincrement()) pid String @unique - description String + description String @default("visual") events Event[] disciplines Discipline[] diff --git a/src/Controllers/admin.controller.ts b/src/Controllers/admin.controller.ts index cdbc2ed..cca123f 100644 --- a/src/Controllers/admin.controller.ts +++ b/src/Controllers/admin.controller.ts @@ -5,6 +5,8 @@ import argon2 from "argon2"; import { AUTH_ERROR, createInsufficientPermissionsError, DataType, generateInvalidBodyError } from "./common"; import { authClient } from "../lib/redis"; +require("express-async-errors"); + export const regenerateRevision = async (pid: string) => { // TOOO: Add error handling const { revision } = await prisma.admin.update({ @@ -27,7 +29,9 @@ export const getAllAdmins = async (req: Request, res: Response) => { } // TODO: Add exception handling - const users = await prisma.admin.findMany({ select: { pid: true, name: true, permission_level: true } }); + const users = await prisma.admin.findMany({ + select: { pid: true, name: true, permission_level: true, groups: { select: { pid: true } } }, + }); res.status(200).json({ type: "success", diff --git a/src/Controllers/admin_auth.controller.ts b/src/Controllers/admin_auth.controller.ts index 760e85d..bb9ed32 100644 --- a/src/Controllers/admin_auth.controller.ts +++ b/src/Controllers/admin_auth.controller.ts @@ -6,7 +6,7 @@ import argon2 from "argon2"; import jwt from "jsonwebtoken"; import { DataType, generateInvalidBodyError } from "./common"; -const JWT_SECRET = process.env.JWT_SECRET || "secret"; +const JWT_SECRET = process.env.JWT_SECRET; const TOKEN_EXPIRY = "4 days"; export interface AuthJWTPayload { @@ -18,6 +18,10 @@ export interface AuthJWTPayload { } function createAdminJWT(admin: Admin & { groups: Group[] }) { + if (!JWT_SECRET) { + throw new Error("JWT_SECRET not set"); + } + const payload: AuthJWTPayload = { pid: admin.pid, name: admin.name, diff --git a/src/Controllers/common.ts b/src/Controllers/common.ts index 0b97e88..0f76edc 100644 --- a/src/Controllers/common.ts +++ b/src/Controllers/common.ts @@ -1,6 +1,7 @@ import { AdminLevel, Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; import { Request, Response } from "express"; +import { ZodError } from "zod"; import prisma from "../lib/prisma"; interface StringIndexedObject { @@ -23,6 +24,7 @@ export enum DataType { NUMBER = "number", INTEGER = "integer", PERMISSION_LEVEL = "'ELEVATED' | 'STANDARD'", + JOB = "'TEAMLEADER' | 'MEMBER'", DATETIME = "ISOstring", UUID = "string", RESULT_SCHEMA = "result_schema", @@ -32,7 +34,18 @@ interface Body { [k: string]: DataType; } -export function generateInvalidBodyError(body: Body) { +export function generateInvalidBodyError(body: Body, zodError?: ZodError) { + if (zodError instanceof ZodError) { + return { + type: "error", + payload: { + message: "The body of your request did not conform to the requirements", + errors: { body: zodError.format() }, + schema: { body }, + }, + }; + } + return { type: "error", payload: { diff --git a/src/Controllers/discipline.controller.ts b/src/Controllers/discipline.controller.ts index de8abbd..7caafe0 100644 --- a/src/Controllers/discipline.controller.ts +++ b/src/Controllers/discipline.controller.ts @@ -4,6 +4,7 @@ import { Request, Response } from "express"; import prisma from "../lib/prisma"; import ForwardableError from "../Middleware/error/ForwardableError"; import NotFoundError from "../Middleware/error/NotFoundError"; +import { number, z } from "zod"; import { createInsufficientPermissionsError, DataType, @@ -15,12 +16,30 @@ import { require("express-async-errors"); +const InitialDisciplineBody = z.object({ + name: z.string().min(1), + minTeamSize: z.number(), + maxTeamSize: z.number(), + briefDescription: z.string().min(1), + fullDescription: z.string(), +}); + +const disciplineRefiner = [ + (args: any) => (args.minTeamSize && args.maxTeamSize ? args.minTeamSize <= args.maxTeamSize : true), + { message: "The minTeamSize must be smaller or equal to the maxTeamSize" }, +] as const; + +const DisciplineBody = InitialDisciplineBody.partial({ fullDescription: true }).refine(...disciplineRefiner); +const updateDisciplineBody = InitialDisciplineBody.partial().refine(...disciplineRefiner); + const basicDiscipline = { pid: true, name: true, visual: { select: { pid: true } }, maxTeamSize: true, minTeamSize: true, + briefDescription: true, + fullDescription: true, event: { select: { pid: true, name: true } }, roles: { select: { pid: true, name: true } }, } as const; @@ -117,6 +136,8 @@ interface CreateDisciplineBody { name?: string; minTeamSize?: number; maxTeamSize?: number; + briefDescription?: string; + fullDescription?: string; } // require: auth(ELEVATED) @@ -126,32 +147,36 @@ export const createDiscipline = async (req: Request<{ eventPid: string }, {}, Cr return res.status(403).json(createInsufficientPermissionsError()); } - const { name, minTeamSize, maxTeamSize } = req.body; + const result = DisciplineBody.safeParse(req.body); - if (typeof name !== "string" || typeof minTeamSize !== "number" || typeof maxTeamSize !== "number") { + if (result.success === false) { return res.status(400).json( - generateInvalidBodyError({ - name: DataType.STRING, - minTeamSize: DataType.NUMBER, - maxTeamSize: DataType.NUMBER, - }) + generateInvalidBodyError( + { + name: DataType.STRING, + minTeamSize: DataType.NUMBER, + maxTeamSize: DataType.NUMBER, + briefDescription: DataType.STRING, + ["fullDescription?"]: DataType.STRING, + }, + result.error + ) ); } - if (!validateName(name)) { - return res.status(400).json(NAME_ERROR); - } + const { name, minTeamSize, maxTeamSize, briefDescription, fullDescription } = result.data; try { const discipline = await prisma.discipline.create({ - data: { name, minTeamSize, maxTeamSize, event: { connect: { pid: req.params.eventPid } } }, - select: { - pid: true, - name: true, - minTeamSize: true, - maxTeamSize: true, - event: { select: { pid: true, name: true } }, + data: { + name, + minTeamSize, + maxTeamSize, + briefDescription, + fullDescription, + event: { connect: { pid: req.params.eventPid } }, }, + select: basicDiscipline, }); return res.status(201).json({ type: "success", payload: { discipline } }); @@ -163,12 +188,60 @@ export const createDiscipline = async (req: Request<{ eventPid: string }, {}, Cr } }; -interface DeleteDisciplineQueryParams { - pid: string; -} +// requires: auth(ELEVATED) +export const updateDiscipline = async (req: Request<{ pid: string }>, res: Response) => { + if (req.auth?.permission_level !== "ELEVATED") { + res.status(403).json(createInsufficientPermissionsError()); + } + + const result = updateDisciplineBody.safeParse(req.body); // FIXME: Useres can currently use two requests to forgo min/max team size checking altogether + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + name: DataType.STRING, + minTeamSize: DataType.NUMBER, + maxTeamSize: DataType.NUMBER, + briefDescription: DataType.STRING, + ["fullDescription?"]: DataType.STRING, + }, + result.error + ) + ); + } + + const body = result.data; + const { pid } = req.params; + + try { + const discipline = await prisma.discipline.update({ + where: { pid }, + data: { + name: body.name, + minTeamSize: body.minTeamSize, + maxTeamSize: body.maxTeamSize, + briefDescription: body.briefDescription, + fullDescription: body.fullDescription, + }, + select: basicDiscipline, + }); + + res.status(200).json({ + type: "success", + payload: { discipline }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("discipline", pid); + } + + throw e; + } +}; // requires: auth(ELEVATED) -export const deleteDiscipline = async (req: Request, res: Response) => { +export const deleteDiscipline = async (req: Request<{ pid: string }>, res: Response) => { if (req.auth?.permission_level !== "ELEVATED") { res.status(403).json(createInsufficientPermissionsError()); } @@ -187,61 +260,3 @@ export const deleteDiscipline = async (req: Request throw e; } }; - -interface visualParams { - disciplinePid: string; -} - -interface visualBody { - mediaPid: string; -} - -export const addVisual = async (req: Request, res: Response) => { - if (req.auth?.permission_level != "ELEVATED") { - res.status(403).json(createInsufficientPermissionsError()); - } - - const { disciplinePid } = req.params; - - const discipline = await prisma.discipline.update({ - where: { pid: disciplinePid }, - data: { - visual: { connect: { pid: req.body.mediaPid } }, - }, - }); - - if (!discipline) { - throw new NotFoundError("discipline", disciplinePid); - } - - return res.status(200).json({ - type: "success", - payload: {}, - }); -}; - -export const deleteVisual = async (req: Request, res: Response) => { - if (req.auth?.permission_level != "ELEVATED") { - res.status(403).json(createInsufficientPermissionsError()); - } - - const { disciplinePid, pid } = req.params; - - try { - await prisma.discipline.update({ - where: { - pid: disciplinePid, - }, - data: { - visual: { disconnect: { pid } }, - }, - }); - return res.status(204).end(); - } catch (e) { - if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { - throw new NotFoundError("discipline", disciplinePid); - } - - throw e; - } -}; diff --git a/src/Controllers/event.controller.ts b/src/Controllers/event.controller.ts index 3193c37..03fb8eb 100644 --- a/src/Controllers/event.controller.ts +++ b/src/Controllers/event.controller.ts @@ -1,6 +1,6 @@ import { Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; -import { Request, Response } from "express"; +import e, { Request, Response } from "express"; import { z } from "zod"; import prisma from "../lib/prisma"; import NotFoundError from "../Middleware/error/NotFoundError"; @@ -8,17 +8,49 @@ import { createInsufficientPermissionsError, DataType, generateError, generateIn require("express-async-errors"); +export const dateSchema = z.preprocess((arg) => { + if (typeof arg == "string" || arg instanceof Date) return new Date(arg); +}, z.date()); + +const EventBody = z.object({ + name: z.string().min(1), + date: dateSchema, + briefDescription: z.string(), + fullDescription: z.string(), +}); + +const UpdateBody = EventBody.partial(); + +const CreateEventBody = EventBody.partial({ + fullDescription: true, +}); + +const basicEvent = { + pid: true, + name: true, + date: true, + briefDescription: true, + fullDescription: true, +} as const; + +const detailedEvent = { + pid: true, + name: true, + date: true, + briefDescription: true, + fullDescription: true, + visual: { select: { pid: true, description: true } }, + disciplines: { + select: { + pid: true, + name: true, + }, + }, +} as const; + export const getAllEvents = async (req: Request, res: Response) => { const events = await prisma.event.findMany({ - select: { - name: true, - briefDescription: true, - fullDescription: true, - visual: { select: { pid: true, description: true } }, - date: true, - pid: true, - id: false, - }, + select: detailedEvent, }); res.status(200).json({ @@ -45,15 +77,7 @@ export const getEvent = async (req: Request, res: Response) => { where: { pid: eventId, }, - select: { - name: true, - briefDescription: true, - fullDescription: true, - date: true, - pid: true, - id: false, - visual: { select: { pid: true, description: true } }, - }, + select: detailedEvent, }); if (!event) { @@ -96,39 +120,33 @@ export const addEvent = async (req: Request, res: Response) => { if (req.auth?.permission_level !== "ELEVATED") { res.status(403).json(createInsufficientPermissionsError()); } - if ( - typeof req.body.name !== "string" || - typeof req.body.date !== "string" || - typeof req.body.briefDescription !== "string" || - (req.body.fullDescription && typeof req.body.fullDescription !== "string") - ) { + + const result = CreateEventBody.safeParse(req.body); + + if (result.success === false) { return res.status(400).json( - generateInvalidBodyError({ - name: DataType.STRING, - date: DataType.DATETIME, - briefDescription: DataType.STRING, - ["fullDescription?"]: DataType.STRING, - }) + generateInvalidBodyError( + { + name: DataType.STRING, + date: DataType.DATETIME, + briefDescription: DataType.STRING, + ["fullDescription?"]: DataType.STRING, + }, + result.error + ) ); } - //TODO: Check if date is valid + const body = result.data; const event = await prisma.event.create({ data: { - name: req.body.name, - date: req.body.date, - briefDescription: req.body.briefDescription, - fullDescription: req.body.fullDescription, - }, - select: { - name: true, - date: true, - pid: true, - id: false, - briefDescription: true, - fullDescription: true, + name: body.name, + date: body.date, + briefDescription: body.briefDescription, + fullDescription: body.fullDescription, }, + select: basicEvent, }); res.status(201).json({ @@ -139,15 +157,6 @@ export const addEvent = async (req: Request, res: Response) => { }); }; -const EventBody = z.object({ - name: z.string(), - date: z.string(), - briefDescription: z.string(), - fullDescription: z.string(), -}); - -const UpdateBody = EventBody.partial(); - export const updateEvent = async (req: Request<{ pid: string }>, res: Response) => { if (req.auth?.permission_level !== "ELEVATED") { res.status(403).json(createInsufficientPermissionsError()); @@ -159,12 +168,15 @@ export const updateEvent = async (req: Request<{ pid: string }>, res: Response) if (result.success === false) { return res.status(400).json( - generateInvalidBodyError({ - name: DataType.STRING, - date: DataType.DATETIME, - briefDescription: DataType.STRING, - ["fullDescription?"]: DataType.STRING, - }) + generateInvalidBodyError( + { + name: DataType.STRING, + date: DataType.DATETIME, + briefDescription: DataType.STRING, + ["fullDescription?"]: DataType.STRING, + }, + result.error + ) ); } @@ -188,10 +200,6 @@ export const updateEvent = async (req: Request<{ pid: string }>, res: Response) }, }); - if (!event) { - throw new NotFoundError("event", pid); - } - res.status(200).json({ type: "success", payload: { @@ -199,24 +207,8 @@ export const updateEvent = async (req: Request<{ pid: string }>, res: Response) }, }); } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - return res.status(500).json({ - type: "error", - payload: { - message: `Internal Server error occured. Try again later`, - }, - }); - } - if (e instanceof Prisma.PrismaClientUnknownRequestError) { - return res.status(500).json({ - type: "error", - payload: { - message: "Unknown error occurred with your request. Check if your parameters are correct", - schema: { - eventId: DataType.UUID, - }, - }, - }); + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("event", pid); } throw e; @@ -241,71 +233,7 @@ export const deleteEvent = async (req: Request, res: Res return res.status(204).end(); } catch (e) { if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { - return res.status(404).json(generateError(`The organisation with the ID ${pid} could not be found`)); - } - - throw e; - } -}; - -// REVIEW: This code **will** need to be de-duplicated - -interface visualParams { - eventPid: string; -} - -interface visualBody { - mediaPid: string; -} - -export const addVisual = async (req: Request, res: Response) => { - if (req.auth?.permission_level != "ELEVATED") { - res.status(403).json(createInsufficientPermissionsError()); - } - - const { eventPid } = req.params; - - if (typeof req.body.mediaPid !== "string") { - res.status(400).json(generateInvalidBodyError({ mediaPid: DataType.STRING })); - } - - const event = await prisma.event.update({ - where: { pid: eventPid }, - data: { - visual: { connect: { pid: req.body.mediaPid } }, - }, - }); - - if (!event) { - throw new NotFoundError("event", eventPid); - } - - return res.status(200).json({ - type: "success", - payload: {}, - }); -}; - -export const deleteVisual = async (req: Request, res: Response) => { - if (req.auth?.permission_level != "ELEVATED") { - res.status(403).json(createInsufficientPermissionsError()); - } - - const { eventPid, pid } = req.params; - - try { - await prisma.event.update({ - where: { - pid: eventPid, - }, - data: { - visual: { disconnect: { pid } }, - }, - }); - return res.status(204).end(); - } catch (e) { - if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { - throw new NotFoundError("discipline", eventPid); + throw new NotFoundError("event", pid); } throw e; diff --git a/src/Controllers/group.controllers.ts b/src/Controllers/group.controllers.ts index ee7fb5e..c77ac6b 100644 --- a/src/Controllers/group.controllers.ts +++ b/src/Controllers/group.controllers.ts @@ -1,8 +1,28 @@ -import { Admin } from "@prisma/client"; +import { Admin, Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; import { Request, Response } from "express"; +import { z } from "zod"; import prisma from "../lib/prisma"; -import { createInsufficientPermissionsError, generateError, genericError, handleCreateByName } from "./common"; +import { requireResponsibleForGroups } from "../Middleware/auth/auth"; +import NotFoundError from "../Middleware/error/NotFoundError"; +import { + createInsufficientPermissionsError, + DataType, + generateError, + generateInvalidBodyError, + genericError, + handleCreateByName, +} from "./common"; + +require("express-async-errors"); + +const updateGroupBody = z + .object({ + name: z.string().min(1), + user_limit: z.number().int().positive(), + level: z.number().int().nonnegative(), + }) + .partial(); const basicGroup = { pid: true, @@ -10,6 +30,16 @@ const basicGroup = { organisation: { select: { pid: true, name: true } }, } as const; +const detailedGroup = { + pid: true, + name: true, + level: true, + user_limit: true, + organisation: { select: { pid: true, name: true } }, + participants: { select: { pid: true, firstName: true, lastName: true } }, + admins: { select: { pid: true, name: true } }, +}; + export const _getAllGroups = async (res: Response, organisationId: string | undefined) => { const groups = await prisma.group.findMany({ where: { organisation: { pid: organisationId } }, @@ -115,6 +145,52 @@ export const createGroup = async (req: Request<{ organisationPid: string }, {}, ); }; +// requires: auth(STANDARD with GROUP permission) +export const updateGroup = async (req: Request<{ pid: string }>, res: Response) => { + const result = updateGroupBody.safeParse(req.body); + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + name: DataType.STRING, + user_limit: DataType.NUMBER, + level: DataType.NUMBER, + }, + result.error + ) + ); + } + + const body = result.data; + const { pid } = req.params; + + requireResponsibleForGroups(req.auth, pid); + + try { + const group = await prisma.group.update({ + where: { pid }, + data: { + name: body.name, + user_limit: body.user_limit, + level: body.level, + }, + select: detailedGroup, + }); + + res.status(200).json({ + type: "success", + payload: { group }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("group", pid); + } + + throw e; + } +}; + interface DeleteGroupQueryParams { pid: string; } @@ -127,12 +203,12 @@ export const deleteGroup = async (req: Request, res: Res const { pid } = req.params; try { - prisma.group.delete({ where: { pid } }); + await prisma.group.delete({ where: { pid } }); return res.status(204).end(); } catch (e) { if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { - return res.status(404).json(generateError(`The group with the ID ${pid} could not be found`)); + throw new NotFoundError("group", pid); } throw e; diff --git a/src/Controllers/media.controller.ts b/src/Controllers/media.controller.ts index eb78bc3..57f231e 100644 --- a/src/Controllers/media.controller.ts +++ b/src/Controllers/media.controller.ts @@ -1,4 +1,4 @@ -import { Request, response, Response } from "express"; +import { Request, Response } from "express"; import fs from "fs"; import isSvg from "is-svg"; import { fromBuffer as fileTypeFromBuffer } from "file-type"; @@ -8,10 +8,9 @@ import prisma from "../lib/prisma"; import NotFoundError from "../Middleware/error/NotFoundError"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { generateInvalidBodyError, DataType } from "./common"; -import { type } from "os"; import { unlink } from "fs/promises"; import ForwardableError from "../Middleware/error/ForwardableError"; -import SchemaError from "../Middleware/error/SchemaError"; +import { table } from "console"; require("express-async-errors"); @@ -99,7 +98,6 @@ export const uploadImage = async (req: Request, res: Response) => { const fileName = file.md5 + (fileIsSvg ? ".svg" : "." + fileType?.ext); try { - //generate record const media = await prisma.media.create({ data: { pid: fileName, @@ -189,3 +187,75 @@ export const deleteMedia = async (req: Request, res: Response) => { throw e; } }; + +export const linkMedia = async (req: Request<{ pid: string }, {}, { mediaPid: string }>, res: Response) => { + if (req.auth?.permission_level != "ELEVATED") { + res.status(403).json(createInsufficientPermissionsError()); + } + + const { pid } = req.params; + const { mediaPid } = req.body; + const tableToUpdate = req.originalUrl.split("/"); + + if (typeof mediaPid !== "string") { + return res.status(400).json( + generateInvalidBodyError({ + mediaPid: DataType.UUID, + }) + ); + } + + try { + const updatedRec = await getPrismaUpdateFKT(tableToUpdate[2])({ + where: { pid }, + data: { + visual: { connect: { pid: mediaPid } }, + }, + }); + + return res.status(200).json({ + type: "success", + payload: { message: "Linking with the visual was successful" }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError(tableToUpdate[2], pid); + } + + throw e; + } +}; + +export const unlinkMedia = async (req: Request<{ pid: string; mediaPid: string }>, res: Response) => { + if (req.auth?.permission_level != "ELEVATED") { + res.status(403).json(createInsufficientPermissionsError()); + } + + const { pid, mediaPid } = req.params; + const tableToUpdate = req.originalUrl.split("/"); + + try { + await getPrismaUpdateFKT(tableToUpdate[2])({ + where: { pid }, + data: { visual: { disconnect: { pid: mediaPid } } }, + }); + return res.status(204).end(); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError(tableToUpdate[2], pid); + } + + throw e; + } +}; + +function getPrismaUpdateFKT(tableToUpdate: string): Function { + switch (tableToUpdate) { + case "events": + return prisma.event.update; + case "disciplines": + return prisma.discipline.update; + default: + return prisma.roleSchema.update; + } +} diff --git a/src/Controllers/organisation.controller.ts b/src/Controllers/organisation.controller.ts index 146594e..e79d7a3 100644 --- a/src/Controllers/organisation.controller.ts +++ b/src/Controllers/organisation.controller.ts @@ -12,6 +12,9 @@ import { } from "./common"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { Prisma } from "@prisma/client"; +import NotFoundError from "../Middleware/error/NotFoundError"; + +require("express-async-errors"); function validateOranisationName(name: string) { return name.length > 0; @@ -25,7 +28,7 @@ const detailedOrganisation = { pid: true, name: true, date: true, - description: true, + briefDescription: true, }, }, } as const; @@ -187,16 +190,12 @@ export const updateOrganisation = async ( }, }); } catch (e) { - if (e instanceof PrismaClientKnownRequestError) { - if (e.code === "P2025") { - return res.status(404).json(generateError(`The organisation with the ID ${pid} could not be found`)); - } - } else if (e instanceof PrismaClientUnknownRequestError) { - return res.status(400).send(generateError("Unkonwn error occured. This could be due to malformed IDs")); + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("organisation", pid); } - } - return res.status(500).json(genericError); + throw e; + } }; interface DeleteOrganisationQueryParams { @@ -216,7 +215,7 @@ export const deleteOrganisation = async (req: Request, res: Response) => { + const { teamPid } = req.params; + + const result = InitialParticipant.safeParse(req.body); + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + firstname: DataType.STRING, + lastName: DataType.STRING, + groupPid: DataType.UUID, + }, + result.error + ) + ); + } + const body = result.data; + + if (req.teamleader?.isAuthenticated) { + await requireLeaderOfTeam(req.teamleader, teamPid); + } else if (req.auth?.permission_level == "STANDARD") { + requireResponsibleForGroups(req.auth, body.groupPid); + } + + try { + const discipline = await prisma.team.findUnique({ + where: { pid: teamPid }, + select: { discipline: true }, + }); + + const maxteamsize = discipline?.discipline.maxTeamSize; + + const userCount = await prisma.participant.count({ + where: { team: { pid: teamPid } }, + }); + + if (maxteamsize == userCount) { + return res.status(418).json({ type: "error", payload: "The team has reached the limit of participants!" }); + } + + const participant = await prisma.participant.create({ + data: { + firstName: body.firstName, + lastName: body.lastName, + relevance: "MEMBER", + group: { connect: { pid: body.groupPid } }, + team: { connect: { pid: teamPid } }, + }, + select: returnedParticipant, + }); + + return res.status(201).json({ type: "success", payload: { participant } }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + return res + .status(404) + .json(generateError(`Could not link to team with ID '${teamPid}, or group with ID ${body.groupPid}'`)); + } + throw e; + } +}; + +// at: PATCH api/participants/:pid/ +export const updateParticipant = async (req: Request<{ pid: string }>, res: Response) => { + const { pid } = req.params; + + if (req.teamleader?.isAuthenticated) { + await requireResponsibleForParticipant(req.teamleader, pid); + } else if (req.auth?.permission_level == "STANDARD") { + requireResponsibleForGroups(req.auth, await getGroupByParticipantPid(pid)); + } + + const result = InitialParticipant.partial().safeParse(req.body); + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + firstname: DataType.STRING, + lastName: DataType.STRING, + groupPid: DataType.UUID, + }, + result.error + ) + ); + } + + const body = result.data; + + try { + const participant = await prisma.participant.update({ + where: { pid }, + data: { + firstName: body.firstName, + lastName: body.lastName, + ...(body.groupPid ? { group: { connect: { pid: body.groupPid } } } : {}), + }, + select: returnedParticipant, + }); + + res.status(200).json({ + type: "success", + payload: { participant }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + return res + .status(404) + .json(generateError(`Could not find participant '${pid}, or link to group with ID ${body.groupPid}.'`)); + } + + throw e; + } +}; + +// at: DELETE api/participants/:pid/ +export const deleteParticipant = async (req: Request<{ pid: string }>, res: Response) => { + const { pid } = req.params; + + if (req.teamleader?.isAuthenticated) { + await requireResponsibleForParticipant(req.teamleader, pid); + } else if (req.auth?.permission_level == "STANDARD") { + requireResponsibleForGroups(req.auth, await getGroupByParticipantPid(pid)); + } + + try { + await prisma.participant.delete({ where: { pid } }); + + return res.status(204).end(); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("participant", pid); + } + + throw e; + } +}; + +export async function getGroupByParticipantPid(partPid: string) { + const parti = (await prisma.participant.findUnique({ where: { pid: partPid }, select: { group: true } }))?.group.pid; + + if (!parti) { + throw new NotFoundError("participant", partPid); + } + + return parti; +} diff --git a/src/Controllers/role.controller.ts b/src/Controllers/role.controller.ts new file mode 100644 index 0000000..8860f52 --- /dev/null +++ b/src/Controllers/role.controller.ts @@ -0,0 +1,134 @@ +import { Prisma } from "@prisma/client"; +import { Request, Response } from "express"; +import { z } from "zod"; +import prisma from "../lib/prisma"; +import { requireResponsibleForGroups } from "../Middleware/auth/auth"; +import { requireLeaderOfTeam, requireResponsibleForParticipant } from "../Middleware/auth/teamleaderAuth"; +import NotFoundError from "../Middleware/error/NotFoundError"; +import { DataType, generateInvalidBodyError } from "./common"; +import { getGroupByParticipantPid } from "./participant.controller"; +import { getGroupsByTeamPid } from "./team.controller"; + +require("express-async-errors"); + +const detailedRole = { + pid: true, + score: true, + schema: { + select: { + pid: true, + name: true, + }, + }, + participant: { + select: { + pid: true, + firstName: true, + lastName: true, + }, + }, + team: { + select: { + pid: true, + name: true, + }, + }, +}; + +/** + * + * @param teamPid: Pid of the team to add the roles to + * @returns Count of roles added (As roles are helper objects, there should be no need for more) + */ +export async function createRolesForTeam(teamPid: string) { + const schemas = await prisma.roleSchema.findMany({ where: { discipline: { teams: { some: { pid: teamPid } } } } }); + + const teamId = (await prisma.team.findUnique({ where: { pid: teamPid } }))?.id; + + if (!teamId) { + throw new NotFoundError("team", teamPid); + } + + const roles = await prisma.role.createMany({ + data: schemas.map((schema) => ({ schemaId: schema.id, score: "", teamId })), // TODO: Use default score from schema? + }); + + return roles.count; +} + +export async function getRolesForTeam(req: Request<{ pid: string }>, res: Response) { + const pid = req.params.pid; + + if (req.teamleader?.isAuthenticated) { + await requireLeaderOfTeam(req.teamleader, pid); + } else { + requireResponsibleForGroups(req.auth, await getGroupsByTeamPid(pid)); + } + + const roles = await prisma.role.findMany({ + where: { team: { pid } }, + select: { + pid: true, + score: true, + schema: { select: { pid: true } }, + participant: { select: { pid: true, firstName: true, lastName: true } }, + }, + }); + + return res.status(200).json({ + type: "success", + payload: { + roles, + }, + }); +} + +const AssignParticipantToRoleBody = z.object({ + participantPid: z.string().uuid(), +}); + +// requires: auth(leader of the team) +export async function assignParticipantToRole(req: Request<{ pid: string }>, res: Response) { + const { pid } = req.params; + + const zBody = AssignParticipantToRoleBody.safeParse(req.body); + + if (zBody.success === false) { + return res.status(400).json(generateInvalidBodyError({ participantPid: DataType.UUID }, zBody.error)); + } + + const { participantPid } = zBody.data; + + if (req.teamleader?.isAuthenticated) { + requireResponsibleForParticipant(req.teamleader, participantPid); + } else { + requireResponsibleForGroups(req.auth, await getGroupByParticipantPid(participantPid)); + } + + try { + const schema = await prisma.role.update({ + where: { pid }, + data: { participant: { connect: { pid: participantPid } } }, + select: detailedRole, + }); + + return res.status(200).json({ + type: "success", + payload: { + message: `The participant with ID '${participantPid}' was successfully assigned to the role with ID '${pid}'`, + ...(schema.participant ? { unassigned: schema.participant } : {}), + }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + return res.status(404).json({ + type: "error", + payload: { + message: `No role with the ID '${pid}' could be found in the scope of the participant with the ID '${participantPid}'`, + }, + }); + } + + throw e; + } +} diff --git a/src/Controllers/role_schema.controller.ts b/src/Controllers/role_schema.controller.ts index 36265de..99c5369 100644 --- a/src/Controllers/role_schema.controller.ts +++ b/src/Controllers/role_schema.controller.ts @@ -1,19 +1,31 @@ import { Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { Request, Response } from "express"; +import { z } from "zod"; import prisma from "../lib/prisma"; -import { DurationSchemaT, parseSchema, PointSchemaT } from "../lib/result_schema"; +import { DurationSchema, parseSchema, PointSchema } from "../lib/result_schema"; import NotFoundError from "../Middleware/error/NotFoundError"; import SchemaError from "../Middleware/error/SchemaError"; import { createInsufficientPermissionsError, DataType, + generateError, generateInvalidBodyError, NAME_ERROR, validateName, } from "./common"; +require("express-async-errors"); + +const RoleSchemaBody = z.object({ + name: z.string().min(1), + schema: z.string(PointSchema).or(z.string(DurationSchema)), +}); + +const UpdateBody = RoleSchemaBody.partial(); + const roleSchema = { + pid: true, name: true, schema: true, discipline: { select: { pid: true, name: true } }, @@ -124,58 +136,48 @@ export const createRoleSchema = async ( } }; -interface visualParams { - schemaPid: string; -} - -interface visualBody { - mediaPid: string; -} - -export const addVisual = async (req: Request, res: Response) => { - if (req.auth?.permission_level != "ELEVATED") { - res.status(403).json(createInsufficientPermissionsError()); +export const UpdateRoleSchema = async (req: Request<{ pid: string }>, res: Response) => { + if (req.auth?.permission_level !== "ELEVATED") { + return res.status(403).json(createInsufficientPermissionsError()); } - const { schemaPid } = req.params; + const { pid } = req.params; - const schema = await prisma.roleSchema.update({ - where: { pid: schemaPid }, - data: { - visual: { connect: { pid: req.body.mediaPid } }, - }, - }); + const result = UpdateBody.safeParse(req.body); - if (!schema) { - throw new NotFoundError("role_schema", schemaPid); + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + name: DataType.STRING, + schema: DataType.RESULT_SCHEMA, + }, + result.error + ) + ); } - return res.status(200).json({ - type: "success", - payload: {}, - }); -}; - -export const deleteVisual = async (req: Request, res: Response) => { - if (req.auth?.permission_level != "ELEVATED") { - res.status(403).json(createInsufficientPermissionsError()); - } - - const { schemaPid, pid } = req.params; + const body = result.data; try { - await prisma.roleSchema.update({ - where: { - pid: schemaPid, - }, + const schema = await prisma.roleSchema.update({ + where: { pid }, data: { - visual: { disconnect: { pid } }, + name: body.name, + schema: body.schema, + }, + select: roleSchema, + }); + + res.status(200).json({ + type: "success", + payload: { + schema, }, }); - return res.status(204).end(); } catch (e) { - if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { - throw new NotFoundError("role_schema", schemaPid); + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("discipline", pid); } throw e; diff --git a/src/Controllers/team.controller.ts b/src/Controllers/team.controller.ts new file mode 100644 index 0000000..7b685a0 --- /dev/null +++ b/src/Controllers/team.controller.ts @@ -0,0 +1,160 @@ +import { Request, Response } from "express"; +import prisma from "../lib/prisma"; +import { DataType, generateInvalidBodyError } from "./common"; +import { requireLeaderOfTeam } from "../Middleware/auth/teamleaderAuth"; +import { TeamBody } from "./user_auth.controller"; +import { Prisma } from "@prisma/client"; +import NotFoundError from "../Middleware/error/NotFoundError"; +import { requireResponsibleForGroups } from "../Middleware/auth/auth"; +import AuthError from "../Middleware/error/AuthError"; +import { runInNewContext } from "vm"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; + +require("express-async-errors"); + +export const basicTeam = { + pid: true, + name: true, + discipline: { select: { pid: true } }, + roles: { + select: { + pid: true, + schema: { select: { name: true } }, + participant: { select: { pid: true } }, + }, + }, + participants: { + select: { + pid: true, + firstName: true, + lastName: true, + }, + }, +}; + +export const getTeams = async (req: Request, res: Response) => { + if (req.auth?.permission_level == "STANDARD") { + throw new AuthError("A STANDARD Admin is not allowed to get all teams!"); + } + const teams = await prisma.team.findMany({ select: basicTeam }); + + res.status(200).json({ type: "success", payload: { teams } }); +}; + +export const getTeam = async (req: Request<{ pid: string }>, res: Response) => { + const { pid } = req.params; + + if (req.teamleader?.isAuthenticated) { + await requireLeaderOfTeam(req.teamleader, pid); + } else if (req.auth?.permission_level == "STANDARD") { + requireResponsibleForGroups(req.auth, await getGroupsByTeamPid(pid)); + } + + const team = await prisma.team.findUnique({ + where: { pid }, + select: basicTeam, + }); + + if (!team) { + throw new NotFoundError("team", pid); + } + + res.status(200).json({ type: "success", payload: { team } }); +}; + +export const updateTeam = async (req: Request, res: Response) => { + const { pid } = req.params; + + if (req.teamleader?.isAuthenticated) { + await requireLeaderOfTeam(req.teamleader, pid); + } else if (req.auth?.permission_level == "STANDARD") { + requireResponsibleForGroups(req.auth, await getGroupsByTeamPid(pid)); + } + + const result = TeamBody.omit({ partGroupId: true, partFirstName: true, partLastName: true }).safeParse(req.body); + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + teamName: DataType.STRING, + leaderEmail: DataType.STRING, + disciplineId: DataType.UUID, + }, + result.error + ) + ); + } + + const body = result.data; + + try { + const team = await prisma.team.update({ + where: { + pid: pid, + }, + data: { + name: body.teamName, + discipline: { connect: { pid: body.disciplineId } }, + leaderEmail: body.leaderEmail, + }, + select: basicTeam, + }); + + res.status(200).json({ type: "success", payload: { team } }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("team", pid); + } + + throw e; + } +}; + +export const deleteTeam = async (req: Request, res: Response) => { + const { pid } = req.params; + + if (req.teamleader?.isAuthenticated) { + await requireLeaderOfTeam(req.teamleader, pid); + } + + if (req.auth?.permission_level == "STANDARD") { + throw new AuthError("STANDARD Admins are not allowed to delete Teams!"); + } + + try { + await prisma.team.delete({ where: { pid } }); + + res.status(204).json({ type: "success", payload: { message: "Sucesfully deleted team" } }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("team", pid); + } + + throw e; + } +}; + +export async function checkTeamExistence(teamPid: string) { + const teamCount = await prisma.team.count({ + where: { pid: teamPid }, + }); + if (teamCount == 0) { + throw new NotFoundError("team", teamPid); + } +} + +export async function getGroupsByTeamPid(teamPid: string) { + const team = await prisma.team.findUnique({ + where: { pid: teamPid }, + select: { participants: { select: { group: true } } }, + }); + + let groups: string[] = []; + + team?.participants.forEach((participant) => { + groups.push(participant.group.pid); + }); + + return groups; +} diff --git a/src/Controllers/user_auth.controller.ts b/src/Controllers/user_auth.controller.ts index cd8c94a..c2f02ef 100644 --- a/src/Controllers/user_auth.controller.ts +++ b/src/Controllers/user_auth.controller.ts @@ -3,27 +3,173 @@ import prisma from "../lib/prisma"; import { mailClient } from "../lib/redis"; import { nanoid } from "nanoid"; import { verificationMail } from "../lib/mail"; +import { DataType, generateError, generateInvalidBodyError } from "./common"; +import { generateTeamleaderJWT } from "../Middleware/auth/teamleaderAuth"; +import { createRolesForTeam } from "./role.controller"; +import { z } from "zod"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; +import NotFoundError from "../Middleware/error/NotFoundError"; -export const register = async (req: Request, res: Response) => { - //TODO: Implemnt user endpoint and use following code to send verification mail +require("express-async-errors"); - const user = { - //Supposed to come from database - id: "10", - email: "test@test.com", - }; +export const TeamBody = z.object({ + teamName: z.string().min(1), + leaderEmail: z.string().email(), + disciplineId: z.string().uuid(), + partFirstName: z.string().min(1), + partLastName: z.string().min(1), + partGroupId: z.string().uuid(), +}); + +interface CreateTeamBody { + teamName: string; + leaderEmail: string; + disciplineId: string; + partFirstName: string; + partLastName: string; + partGroupId: string; +} + +export const register = async (req: Request<{}, {}, CreateTeamBody>, res: Response) => { + const result = TeamBody.safeParse(req.body); + + if (result.success === false) { + return res.status(400).json( + generateInvalidBodyError( + { + teamName: DataType.STRING, + leaderEmail: DataType.STRING, + disciplineId: DataType.UUID, + partFirstName: DataType.STRING, + partLastName: DataType.STRING, + partGroupId: DataType.UUID, + }, + result.error + ) + ); + } + + const body = result.data; + + try { + const team = await prisma.team.create({ + data: { + leaderEmail: body.leaderEmail, + name: body.teamName, + roles: undefined, + discipline: { connect: { pid: body.disciplineId } }, + participants: { + create: { + firstName: body.partFirstName, + lastName: body.partLastName, + relevance: "TEAMLEADER", + group: { connect: { pid: body.partGroupId } }, + }, + }, + }, + select: { + pid: true, + name: true, + discipline: { select: { pid: true, name: true } }, + }, + }); + + await createRolesForTeam(team.pid); + + const usid = nanoid(); + + (await mailClient).set(usid, team.pid); + + verificationMail(req.body.leaderEmail, team.discipline.name, usid); + return res.status(201).json({ type: "success", payload: { team } }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + return res.status(404).json({ + type: "error", + payload: { + message: `Could not connect to discipline with the ID '${body.disciplineId}' or could not connect participant to group with the ID '${body.partGroupId}'`, + }, + }); + } + + throw e; + } +}; + +export const requestToken = async (req: Request, res: Response) => { + const data = z.object({ teamId: z.string().min(1) }).safeParse(req); + + if (data.success == false) { + return res.status(400).json(generateInvalidBodyError({ teamId: DataType.STRING }, data.error)); + } + + const { teamId } = data.data; + + const team = await prisma.team.findUnique({ + where: { + pid: teamId, + }, + select: { + discipline: { select: { name: true } }, + pid: true, + leaderEmail: true, + }, + }); + + if (!team) { + return res.status(404).json(generateError("Team does not exist!")); + } const usid = nanoid(); - (await mailClient).set(usid, user.id); + (await mailClient).set(usid, team.pid); - verificationMail(user.email, "eventname", usid); + verificationMail(team.leaderEmail, team.discipline.name, usid); - //Send status code + res.status(200).json({ type: "sucess", payload: { message: "Email sent!" } }); +}; + +export const requestTokenEmail = async (req: Request, res: Response) => { + const data = z.object({ email: z.string().min(1) }).safeParse(req); + + if (data.success == false) { + return res.status(400).json(generateInvalidBodyError({ email: DataType.STRING }, data.error)); + } + + const { email } = data.data; + + const teams = await prisma.team.findMany({ + where: { + leaderEmail: email, + }, + select: { + discipline: { select: { name: true } }, + pid: true, + leaderEmail: true, + }, + }); + + if (!teams) { + return res.status(404).json(generateError("Team does not exist!")); + } + + const team = teams[0]; //REVIEW: maybe a email should be only able to be responsible for one team + + if (!team) { + return res.status(404).json(generateError("Team does not exist!")); + } + + const usid = nanoid(); + + (await mailClient).set(usid, team.pid); + + verificationMail(team.leaderEmail, team.discipline.name, usid); + + res.status(200).json({ type: "sucess", payload: { message: "Email sent!" } }); }; export const verifyEmail = async (req: Request, res: Response) => { - const { code } = req.body || {}; + const { code } = req.params || {}; if (!(typeof code === "string")) { return res.status(400).json({ @@ -45,12 +191,26 @@ export const verifyEmail = async (req: Request, res: Response) => { }); } - prisma.participant.update({ - where: { - id: parseInt(acc), - }, - data: { - verified: true, - }, - }); + try { + const team = await prisma.team.update({ + where: { + pid: acc, + }, + data: { + verified: true, + }, + }); + + mailClient.set(code, ""); + + const token = generateTeamleaderJWT(team); + + res.status(200).json({ type: "succes", payload: { token } }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("discipline", acc); + } + + throw e; + } }; diff --git a/src/Middleware/auth/auth.ts b/src/Middleware/auth/auth.ts index 0b66952..3ef66a9 100644 --- a/src/Middleware/auth/auth.ts +++ b/src/Middleware/auth/auth.ts @@ -1,45 +1,99 @@ /// import { NextFunction, Request, Response } from "express"; -import { AuthJWTPayload } from "../../Controllers/admin_auth.controller"; +import { authenticateUser, AuthJWTPayload } from "../../Controllers/admin_auth.controller"; import { authClient } from "../../lib/redis"; import jwt, { JsonWebTokenError, JwtPayload } from "jsonwebtoken"; import prisma from "../../lib/prisma"; +import AuthError from "../error/AuthError"; +import { TeamleaderJWTPayload, _requireTeamleaderAuthentication } from "./teamleaderAuth"; -const JWT_SECRET = process.env.JWT_SECRET || "secret"; +require("express-async-errors"); -const verifyAuthorizationFormat = (authorization: string) => /^Bearer .+$/.test(authorization); +const JWT_SECRET = process.env.JWT_SECRET; -const getBearerToken = (authorization: string) => authorization.slice(7); +export const verifyAuthorizationFormat = (authorization: string) => /^Bearer .+$/.test(authorization); -export const requireAuthentication = async (req: Request, res: Response, next: NextFunction) => { - const { authorization } = req.headers; +export const getBearerToken = (authorization: string) => authorization.slice(7); - if (!authorization) { - return res.status(403).send({ - type: "error", - payload: { - message: "The requeset did not include the Authorization header", - }, - }); - } +const _requireAdminAuthentication = + (config: { optional?: Boolean; controlled?: Boolean } = { optional: false, controlled: false }) => + async (req: Request, res: Response, next: NextFunction) => { + if (!JWT_SECRET) { + throw new Error("JWT_SECRET not set"); + } - if (!verifyAuthorizationFormat(authorization)) { - return res.status(400).send({ - type: "error", - payload: { - message: "Malformed Authorization header", - format: "Bearer ", - }, - }); - } + const { authorization } = req.headers; - let token_payload_: string | JwtPayload; + if (!authorization) { + if (config.optional) { + return false; + } - try { - token_payload_ = jwt.verify(getBearerToken(authorization), JWT_SECRET); - } catch (e) { - if (e instanceof JsonWebTokenError) { + return res.status(403).send({ + type: "error", + payload: { + message: "The requeset did not include the Authorization header", + }, + }); + } + + if (!verifyAuthorizationFormat(authorization)) { + return res.status(400).send({ + type: "error", + payload: { + message: "Malformed Authorization header", + format: "Bearer ", + }, + }); + } + + let token_payload_: string | JwtPayload; + + try { + token_payload_ = jwt.verify(getBearerToken(authorization), JWT_SECRET); + } catch (e) { + if (e instanceof JsonWebTokenError) { + return res.status(403).json({ + type: "error", + payload: { + message: "Token could not be verified; It might be expired", + }, + }); + } + + throw e; + } + + const token_payload = token_payload_ as AuthJWTPayload; + + if (!token_payload.permission_level || !token_payload.pid || !token_payload.revision) { + if (typeof (token_payload as unknown as TeamleaderJWTPayload).team === "string") { + if (config.controlled) { + return false; + } + throw new AuthError("Teamleader authentication is not supported for this operation!"); + } + + throw new AuthError("The token did not include the required information!"); + } + + const { pid, revision } = token_payload; + + let db_revision = await authClient.get(pid); + + if (db_revision === null) { + // Load the revision ID from the main DB and cache it in redis + const user = await prisma.admin.findUnique({ where: { pid }, select: { revision: true } }); + + if (user) { + db_revision = user.revision.toISOString(); + + await authClient.set(pid, db_revision); + } + } + + if (revision !== db_revision || !revision || !db_revision) { return res.status(403).json({ type: "error", payload: { @@ -48,43 +102,96 @@ export const requireAuthentication = async (req: Request, res: Response, next: N }); } - throw e; - } + req.auth = { + isAuthenticated: true, + pid: token_payload.pid, + name: token_payload.name, + permission_level: token_payload.permission_level, + groups: token_payload.groups, + revision: token_payload.revision, + }; - const token_payload = token_payload_ as AuthJWTPayload; - - const { pid, revision } = token_payload; - - let db_revision = await authClient.get(pid); - - if (db_revision === null) { - // Load the revision ID from the main DB and cache it in redis - const user = await prisma.admin.findUnique({ where: { pid }, select: { revision: true } }); - - if (user) { - db_revision = user.revision.toISOString(); - - await authClient.set(pid, db_revision); + if (!config.controlled) { + next(); } - } - if (revision !== db_revision || !revision || !db_revision) { - return res.status(403).json({ - type: "error", - payload: { - message: "Token could not be verified; It might be expired", - }, - }); - } - - req.auth = { - isAuthenticated: true, - pid: token_payload.pid, - name: token_payload.name, - permission_level: token_payload.permission_level, - groups: token_payload.groups, - revision: token_payload.revision, + return true; }; - next(); -}; +export const requireAuthentication = _requireAdminAuthentication({ optional: false, controlled: false }); + +type AuthType = "admin" | "teamleader"; +interface AuthTypeConfig { + admin?: Boolean; + teamleader?: Boolean; +} + +interface AuthConfiguration { + type: AuthType | AuthTypeConfig; + + optional: Boolean; +} + +function getAuthTypes(type: AuthType | AuthTypeConfig): AuthType[] { + if (typeof type === "string") { + return [type]; + } + + return Object.entries(type) + .filter(([_, value]) => value) + .map(([key, _]) => key as AuthType); +} + +export const requireConfiguredAuthentication = + (config: AuthConfiguration = { optional: false, type: "admin" }) => + async (req: Request, res: Response, next: NextFunction) => { + const types = getAuthTypes(config.type); + const optional = config.optional; + + let adminFinished = false; + let teamleaderFinished = false; + + if (types.includes("admin")) { + adminFinished = Boolean(await _requireAdminAuthentication({ optional: true, controlled: true })(req, res, next)); + + if (adminFinished) { + return next(); + } + } + + if (types.includes("teamleader")) { + teamleaderFinished = Boolean( + _requireTeamleaderAuthentication({ optional: true, controlled: true })(req, res, next) + ); + + if (teamleaderFinished) { + return next(); + } + } + + if (!config.optional) { + throw new AuthError("No sufficient authorization was provided for this operation"); + } + + next(); + }; + +export function requireResponsibleForGroups(auth: AuthJWTPayload | undefined, groupPids: string[] | string) { + if (auth?.permission_level === "ELEVATED") { + return; + } + + if (Array.isArray(groupPids)) { + groupPids.forEach((gr) => { + if (auth?.groups.includes(gr)) { + return; + } + + throw new AuthError("The provided authorization is not valid for the requested operation!"); + }); + } else { + if (!auth?.groups.includes(groupPids)) { + throw new AuthError("The provided authorization is not valid for the requested operation!"); + } + } +} diff --git a/src/Middleware/auth/teamleaderAuth.ts b/src/Middleware/auth/teamleaderAuth.ts new file mode 100644 index 0000000..0af4e3a --- /dev/null +++ b/src/Middleware/auth/teamleaderAuth.ts @@ -0,0 +1,108 @@ +import { Team } from "@prisma/client"; +import e, { NextFunction, Request, Response } from "express"; +import jwt, { JsonWebTokenError } from "jsonwebtoken"; +import AuthError from "../error/AuthError"; +import { getBearerToken, verifyAuthorizationFormat } from "./auth"; +import prisma from "../../lib/prisma"; +import { checkTeamExistence } from "../../Controllers/team.controller"; + +export interface TeamleaderJWTPayload { + team: string; +} + +const JWT_SECRET = process.env.JWT_SECRET; + +export function generateTeamleaderJWT(teamleader: Team) { + if (!JWT_SECRET) { + throw new Error("JWT_SECRET not set"); + } + + const payload: TeamleaderJWTPayload = { + team: teamleader.pid, + }; + + return jwt.sign(payload, JWT_SECRET, { expiresIn: "4 days" }); +} + +export const _requireTeamleaderAuthentication = + (config: { optional: Boolean; controlled: Boolean } = { optional: false, controlled: false }) => + (req: Request, res: Response, next: NextFunction) => { + if (!JWT_SECRET) { + throw new Error("JWT_SECRET not set"); + } + + const { authorization } = req.headers; + + if (!authorization) { + if (config.optional) { + return false; + } + + return res.status(403).send({ + type: "error", + payload: { + message: + "The request did not include the Authorization header (Only the team leader can perform this operation)", + }, + }); + } + + if (!verifyAuthorizationFormat(authorization)) { + return res.status(400).send({ + type: "error", + payload: { + message: "Malformed Authorization header", + format: "Bearer ", + }, + }); + } + + try { + const token_payload = jwt.verify(getBearerToken(authorization), JWT_SECRET) as TeamleaderJWTPayload; + + req.teamleader = { + isAuthenticated: true, + team: token_payload.team, + }; + + if (!config.controlled) { + next(); + } + + return true; + } catch (e) { + if (e instanceof JsonWebTokenError) { + return res.status(403).json({ + type: "error", + payload: { + message: "Token could not be verified; It might be expired", + }, + }); + } + + throw e; + } + }; + +export const requireTeamleaderAuthentication = _requireTeamleaderAuthentication({ optional: false, controlled: false }); + +export async function requireLeaderOfTeam(auth: TeamleaderJWTPayload | undefined, teamPid: string) { + await checkTeamExistence(teamPid); + if (auth?.team !== teamPid) { + throw new AuthError("The provided authorization is not valid for the requested team"); + } +} + +export async function requireResponsibleForParticipant(auth: TeamleaderJWTPayload | undefined, participantPid: string) { + if (!auth) { + throw new AuthError("There was an error with your authorization"); + } + + const teamPid = ( + await prisma.participant.findUnique({ where: { pid: participantPid }, select: { team: { select: { pid: true } } } }) + )?.team.pid; + + if (teamPid !== auth.team) { + throw new AuthError("The provided authorization is not valid for the requested participant"); + } +} diff --git a/src/Middleware/error/AuthError.ts b/src/Middleware/error/AuthError.ts new file mode 100644 index 0000000..0faae4c --- /dev/null +++ b/src/Middleware/error/AuthError.ts @@ -0,0 +1,13 @@ +import ForwardableError from "./ForwardableError"; + +export default class AuthError extends ForwardableError { + protected __oid = "AUTH_ERROR"; + + constructor(message?: string) { + super(403, message ?? "The request did not provide sufficient authentication"); + } + + static isAuthError(err: any): err is AuthError { + return err.__oid === "AUTH_ERROR"; + } +} diff --git a/src/Middleware/error/defaultRoutes.ts b/src/Middleware/error/defaultRoutes.ts index 4a15997..0050727 100644 --- a/src/Middleware/error/defaultRoutes.ts +++ b/src/Middleware/error/defaultRoutes.ts @@ -5,7 +5,7 @@ export function notFoundHandler(req: Request, res: Response) { return res.status(404).json({ type: "error", payload: { - message: `The ${req.method} HTTP method is implemented for '${req.path}'`, + message: `The ${req.method} HTTP method is not implemented for '${req.path}'`, _links: [ { rel: "root", diff --git a/src/Routes/discipline.routes.ts b/src/Routes/discipline.routes.ts index 2d97a8e..6111c64 100644 --- a/src/Routes/discipline.routes.ts +++ b/src/Routes/discipline.routes.ts @@ -1,12 +1,11 @@ import express from "express"; import eventRouter from "./event.routes"; import { - addVisual, createDiscipline, deleteDiscipline, - deleteVisual, getAllDisciplines, getDiscipline, + updateDiscipline, } from "../Controllers/discipline.controller"; import { requireAuthentication } from "../Middleware/auth/auth"; @@ -16,20 +15,9 @@ router.get("/", getAllDisciplines); // TODO: Optional auth router.get("/:pid", getDiscipline); +router.patch("/:pid", requireAuthentication, updateDiscipline); router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteDiscipline); -router.post<"/:disciplinePid/images", { disciplinePid: string }>( - "/:disciplinePid/images", - requireAuthentication, - addVisual -); - -router.delete<"/:disciplinePid/images/:pid", { disciplinePid: string; pid: string }>( - "/:disciplinePid/images/:pid", - requireAuthentication, - deleteVisual -); - eventRouter.post("/:eventPid/disciplines", requireAuthentication, createDiscipline); export default router; diff --git a/src/Routes/event.routes.ts b/src/Routes/event.routes.ts index 5e68bd8..ef6e6e4 100644 --- a/src/Routes/event.routes.ts +++ b/src/Routes/event.routes.ts @@ -1,14 +1,6 @@ import Express from "express"; import { string } from "zod"; -import { - addEvent, - addVisual, - deleteEvent, - deleteVisual, - getAllEvents, - getEvent, - updateEvent, -} from "../Controllers/event.controller"; +import { addEvent, deleteEvent, getAllEvents, getEvent, updateEvent } from "../Controllers/event.controller"; import { requireAuthentication } from "../Middleware/auth/auth"; const router = Express.Router(); @@ -22,12 +14,4 @@ router.patch<"/:pid/", { pid: string }>("/:pid/", requireAuthentication, updateE router.delete<"/:pid/", { pid: string }>("/:pid/", requireAuthentication, deleteEvent); -router.post<"/:eventPid/media", { eventPid: string }>("/:eventPid/media", requireAuthentication, addVisual); - -router.delete<"/:eventPid/media/:pid", { eventPid: string; pid: string }>( - "/:eventPid/media/:pid", - requireAuthentication, - deleteVisual -); - export default router; diff --git a/src/Routes/group.routes.ts b/src/Routes/group.routes.ts index 4ab9387..ae7e470 100644 --- a/src/Routes/group.routes.ts +++ b/src/Routes/group.routes.ts @@ -5,6 +5,7 @@ import { getAllGroups, getAllGroupsWithParam, getGroup, + updateGroup, } from "../Controllers/group.controllers"; import { requireAuthentication } from "../Middleware/auth/auth"; import organisationRouter from "./organisation.routes"; @@ -14,6 +15,7 @@ const router = express.Router(); router.get("/", getAllGroups); router.get("/:pid", getGroup); router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteGroup); +router.patch("/:pid", requireAuthentication, updateGroup); organisationRouter.get("/:organisationPid/groups", getAllGroupsWithParam); organisationRouter.post("/:organisationPid/groups", requireAuthentication, createGroup); diff --git a/src/Routes/media.routes.ts b/src/Routes/media.routes.ts index f340de1..6785f7b 100644 --- a/src/Routes/media.routes.ts +++ b/src/Routes/media.routes.ts @@ -1,7 +1,14 @@ import express from "express"; import fileUpload from "express-fileupload"; import { requireAuthentication } from "../Middleware/auth/auth"; -import { deleteMedia, getAllMedia, getMediaMeta, uploadImage } from "../Controllers/media.controller"; +import { + deleteMedia, + getAllMedia, + getMediaMeta, + linkMedia, + unlinkMedia, + uploadImage, +} from "../Controllers/media.controller"; import eventRouter from "./event.routes"; import disciplineRouter from "./discipline.routes"; import roleSchemaRouter from "./role_schema.routes"; @@ -19,4 +26,28 @@ router.get("/:pid/meta", getMediaMeta); router.delete("/:pid", requireAuthentication, deleteMedia); +eventRouter.post<"/:pid/media", { pid: string }>("/:pid/media", requireAuthentication, linkMedia); + +eventRouter.delete<"/:pid/media/:mediaPid", { pid: string; mediaPid: string }>( + "/:pid/media/:mediaPid", + requireAuthentication, + unlinkMedia +); + +disciplineRouter.post<"/:pid/media", { pid: string }>("/:pid/media", requireAuthentication, linkMedia); + +disciplineRouter.delete<"/:pid/media/:mediaPid", { pid: string; mediaPid: string }>( + "/:pid/media/:mediaPid", + requireAuthentication, + unlinkMedia +); + +roleSchemaRouter.post<"/:pid/media", { pid: string }>("/:pid/media", requireAuthentication, linkMedia); + +roleSchemaRouter.delete<"/:pid/media/:mediaPid", { pid: string; mediaPid: string }>( + "/:pid/media/:mediaPid", + requireAuthentication, + unlinkMedia +); + export default router; diff --git a/src/Routes/participant.routes.ts b/src/Routes/participant.routes.ts new file mode 100644 index 0000000..2e76a2e --- /dev/null +++ b/src/Routes/participant.routes.ts @@ -0,0 +1,26 @@ +import express from "express"; +import { createParticipant, deleteParticipant, updateParticipant } from "../Controllers/participant.controller"; +import { requireConfiguredAuthentication } from "../Middleware/auth/auth"; +import teamRouter from "./team.routes"; + +const router = express.Router(); + +teamRouter.post<"/:teamPid/participants", { teamPid: string }>( + "/:teamPid/participants", + requireConfiguredAuthentication({ optional: false, type: { admin: true, teamleader: true } }), + createParticipant +); + +router.patch<"/:pid/", { pid: string }>( + "/:pid/", + requireConfiguredAuthentication({ optional: false, type: { admin: true, teamleader: true } }), + updateParticipant +); + +router.delete<"/:pid/", { pid: string }>( + "/:pid/", + requireConfiguredAuthentication({ optional: false, type: { admin: true, teamleader: true } }), + deleteParticipant +); + +export default router; diff --git a/src/Routes/role.routes.ts b/src/Routes/role.routes.ts new file mode 100644 index 0000000..f5df428 --- /dev/null +++ b/src/Routes/role.routes.ts @@ -0,0 +1,20 @@ +import Express from "express"; +import { assignParticipantToRole, getRolesForTeam } from "../Controllers/role.controller"; +import { requireConfiguredAuthentication } from "../Middleware/auth/auth"; +import teamRouter from "./team.routes"; + +const router = Express.Router(); + +router.put<"/:pid/participant", { pid: string }>( + "/:pid/participant", + requireConfiguredAuthentication({ optional: false, type: { admin: true, teamleader: true } }), + assignParticipantToRole +); + +teamRouter.get<"/:pid/roles", { pid: string }>( + "/:pid/roles", + requireConfiguredAuthentication({ optional: false, type: { admin: true, teamleader: true } }), + getRolesForTeam +); + +export default router; diff --git a/src/Routes/role_schema.routes.ts b/src/Routes/role_schema.routes.ts index f6ada5f..688dafc 100644 --- a/src/Routes/role_schema.routes.ts +++ b/src/Routes/role_schema.routes.ts @@ -1,9 +1,7 @@ import express from "express"; import disciplineRouter from "./discipline.routes"; import { - addVisual, createRoleSchema, - deleteVisual, getAllRoleSchemas, getAllRoleSchemasWithParam, getRoleSchema, @@ -20,12 +18,4 @@ disciplineRouter.get("/:disciplinePid/role-schemas", getAllRoleSchemasWithParam) disciplineRouter.post("/:disciplinePid/role-schemas", requireAuthentication, createRoleSchema); -router.post<"/:schemaPid/images", { schemaPid: string }>("/:schemaPid/images", requireAuthentication, addVisual); - -router.delete<"/:schemaPid/images/:pid", { schemaPid: string; pid: string }>( - "/:schemaPid/images/:pid", - requireAuthentication, - deleteVisual -); - export default router; diff --git a/src/Routes/team.routes.ts b/src/Routes/team.routes.ts new file mode 100644 index 0000000..7a79ea1 --- /dev/null +++ b/src/Routes/team.routes.ts @@ -0,0 +1,26 @@ +import express from "express"; +import { requireConfiguredAuthentication } from "../Middleware/auth/auth"; +import { deleteTeam, getTeam, getTeams, updateTeam } from "../Controllers/team.controller"; +import { getRolesForTeam } from "../Controllers/role.controller"; + +const router = express.Router(); + +router.get("/", requireConfiguredAuthentication({ type: "admin", optional: false }), getTeams); +router.get<"/:pid/", { pid: string }>( + "/:pid/", + requireConfiguredAuthentication({ optional: false, type: { admin: true, teamleader: true } }), + getTeam +); + +router.put<"/:pid/", { pid: string }>( + "/:pid/", + requireConfiguredAuthentication({ optional: false, type: { admin: true, teamleader: true } }), + updateTeam +); +router.delete<"/:pid/", { pid: string }>( + "/:pid/", + requireConfiguredAuthentication({ optional: false, type: { admin: true, teamleader: true } }), + deleteTeam +); + +export default router; diff --git a/src/Routes/user_auth.routes.ts b/src/Routes/user_auth.routes.ts new file mode 100644 index 0000000..568b764 --- /dev/null +++ b/src/Routes/user_auth.routes.ts @@ -0,0 +1,10 @@ +import express from "express"; +import { register, requestToken, verifyEmail } from "../Controllers/user_auth.controller"; + +const router = express.Router(); + +router.post("/", register); +router.get("/verify/:code", verifyEmail); +router.get("/token", requestToken); + +export default router; diff --git a/src/app.ts b/src/app.ts index 5b39331..d41ffba 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,10 @@ import defaultErrorHandler from "./Middleware/error/handler"; import logger from "./Middleware/error/logger"; import debugLogger from "./Middleware/debug/logger"; import mediaRouter from "./Routes/media.routes"; +import userRouter from "./Routes/user_auth.routes"; +import teamRouter from "./Routes/team.routes"; +import roleRouter from "./Routes/role.routes"; +import participantRouter from "./Routes/participant.routes"; import { notFoundHandler, rootHandler } from "./Middleware/error/defaultRoutes"; // Set up async error handling @@ -82,16 +86,21 @@ async function main() { app.use("/api/role-schemas", roleSchemaRouter); app.use("/api/media", mediaRouter); - + + app.use("/api/users", userRouter); + + app.use("/api/teams", teamRouter); + + app.use("/api/roles", roleRouter); + + app.use("/api/participants", participantRouter); + app.get("/", rootHandler); app.get("/api", rootHandler); // Error handling app.use(defaultErrorHandler); // This has to be the LAST ROUTE - // Disable the media router for now - // app.use("/api/media", mediaRouter); - app.use(notFoundHandler); app.listen(process.env.PORT, () => { diff --git a/src/custom.d.ts b/src/custom.d.ts index bb6296a..86509ba 100644 --- a/src/custom.d.ts +++ b/src/custom.d.ts @@ -1,7 +1,9 @@ import { AuthJWTPayload } from "./Controllers/admin_auth.controller"; +import { TeamleaderJWTPayload } from "./Middleware/auth/teamleaderAuth"; declare module "express-serve-static-core" { interface Request { auth?: AuthJWTPayload & { isAuthenticated: boolean }; + teamleader?: TeamleaderJWTPayload & { isAuthenticated: boolean }; } } diff --git a/src/lib/mail.ts b/src/lib/mail.ts index 4572b1e..9b1cf56 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -5,14 +5,21 @@ import nodemailer from "nodemailer"; import SMTPTransport from "nodemailer/lib/smtp-transport"; import mjml from "./mjml"; -import { randomUUID } from "crypto"; +import logger from "../Middleware/error/logger"; export let mailAccount = { user: process.env.MAILUSER + "@mail." + process.env.DOMAIN, pass: process.env.MAILPASSWORD }; let transporter = - process.env.DEV == "true" || process.env.DOMAIN == undefined + process.env.NODE_ENV == "development" || process.env.DOMAIN == undefined ? (async () => { - mailAccount = await nodemailer.createTestAccount(); + if (process.env.ETHEREAL_EMAIL == undefined || process.env.ETHEREAL_PASSWORD == undefined) { + mailAccount = await nodemailer.createTestAccount(); + } else { + mailAccount = { + user: process.env.ETHEREAL_EMAIL, + pass: process.env.ETHEREAL_PASSWORD, + }; + } if (process.env.NODE_ENV != "test") { console.log(mailAccount); } @@ -43,6 +50,8 @@ let transporter = ); const sendMail = async (from: string, to: string, subject: string, text?: string, html?: string) => { + logger.debug(`Sent email to: ${to}`); + return await ( await transporter ).sendMail({ @@ -57,7 +66,8 @@ const sendMail = async (from: string, to: string, subject: string, text?: string export const verificationMail = async (to: string, eventName: string, verificationLink: string) => { const raw = mjml.getTemplate("emailVerification"); - //TODO: Replace other handlebars with final values + // TODO: the process.env.DOMAIN is undefined in Development mode !! + verificationLink = (process.env.FRONTEND_MAIL_ENDPOINT ?? "localhost:3000/api/users/verify/") + verificationLink; const message = Handlebars.compile(raw); const data = { eventName, verificationLink }; diff --git a/src/lib/result_schema.ts b/src/lib/result_schema.ts index 7da23c9..fdb6b62 100644 --- a/src/lib/result_schema.ts +++ b/src/lib/result_schema.ts @@ -7,7 +7,7 @@ const SchemaVersion = z.enum(["1.0"]); const TimeUnit = z.enum(["days", "hours", "minutes", "seconds", "milliseconds"]); -const DurationSchema = z +export const DurationSchema = z .object({ type: z.literal("duration"), min: z.number().int({ message: "min must be an integer (relative to smallestUnit)" }), @@ -17,7 +17,7 @@ const DurationSchema = z }) .refine(({ min, max }) => min < max, { message: "min must be smaller than max" }); -const PointSchema = z +export const PointSchema = z .object({ type: z.literal("points"), min: z.number(),