diff --git a/package.json b/package.json index a97963a..d247c39 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "nanoid": "^3.3.3", "nodemailer": "^6.7.0", "redis": "^3.1.2", - "winston": "^3.7.2" + "winston": "^3.7.2", + "zod": "^3.14.4" }, "devDependencies": { "@types/chai": "^4.2.22", diff --git a/src/Controllers/common.ts b/src/Controllers/common.ts index a9a0caf..ad2dc49 100644 --- a/src/Controllers/common.ts +++ b/src/Controllers/common.ts @@ -10,6 +10,7 @@ export enum DataType { PERMISSION_LEVEL = "'ELEVATED' | 'STANDARD'", DATETIME = "ISOstring", UUID = "string", + RESULT_SCHEMA = "result_schema", } interface Body { @@ -64,6 +65,13 @@ export const genericError = { }, }; +export const NAME_ERROR = { + type: "error", + payload: { + message: "The name has to be at least 1 character long", + }, +}; + export function validateName(name: string) { return name.length > 0; } @@ -106,12 +114,7 @@ export async function handleCreateByName( } if (!validateName(name)) { - return res.status(400).json({ - type: "error", - payload: { - message: "The name has to be at least 1 character long", - }, - }); + return res.status(400).json(NAME_ERROR); } // Check if link object exsits diff --git a/src/Controllers/discipline.controller.ts b/src/Controllers/discipline.controller.ts new file mode 100644 index 0000000..93431ae --- /dev/null +++ b/src/Controllers/discipline.controller.ts @@ -0,0 +1,189 @@ +import { Prisma, Organisation, Admin, AdminLevel, Team } from "@prisma/client"; +import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; +import { Request, Response } from "express"; +import prisma from "../lib/prisma"; +import ForwardableError from "../Middleware/error/ForwardableError"; +import NotFoundError from "../Middleware/error/NotFoundError"; +import { + createInsufficientPermissionsError, + DataType, + generateError, + generateInvalidBodyError, + NAME_ERROR, + validateName, +} from "./common"; + +require("express-async-errors"); + +const basicDiscipline = { + pid: true, + name: true, + visual: { select: { pid: true, location: true } }, + maxTeamSize: true, + minTeamSize: true, + event: { select: { pid: true, name: true } }, + roles: { select: { pid: true, name: true } }, +} as const; + +const authenticatedDiscipline = { + ...basicDiscipline, + teams: { select: { pid: true, name: true } }, +} as const; + +const elevatedDiscipline: Prisma.DisciplineFindManyArgs["select"] = { + ...authenticatedDiscipline, + teams: { select: { pid: true, name: true, leaderEmail: true } }, +}; + +export const _getAllDisciplines = async ( + res: Response, + authenticated: boolean | undefined, + eventId: string | undefined +) => { + const disciplines = await prisma.discipline.findMany({ + where: { event: { pid: eventId } }, + select: !authenticated ? basicDiscipline : authenticatedDiscipline, + }); + + return res.status(200).json({ + type: "success", + payload: { + disciplines, + }, + }); +}; + +interface GetAllDisciplinesSearchParams { + eventPid?: string; +} + +export const getAllDisciplines = async (req: Request<{}, {}, {}, GetAllDisciplinesSearchParams>, res: Response) => { + return _getAllDisciplines(res, req.auth?.isAuthenticated, req.query.eventPid); +}; + +interface GetAllDisciplinesQueryParams { + eventPid: string; +} + +export const GetAllDisciplinesWithParam = async (req: Request, res: Response) => { + return _getAllDisciplines(res, req.auth?.isAuthenticated, req.params.eventPid); +}; + +interface GetDisciplineQueryParams { + pid: string; +} + +export const getDiscipline = async (req: Request, res: Response) => { + const { pid } = req.params; + + const discipline = await prisma.discipline.findUnique({ + where: { pid }, + select: !req.auth?.isAuthenticated + ? basicDiscipline + : req.auth.permission_level !== "ELEVATED" + ? authenticatedDiscipline + : elevatedDiscipline, + }); + + if (!discipline) { + throw new NotFoundError("discipline", pid); + } + + return res.status(200).json({ + type: "success", + payload: { + discipline: { + ...discipline, + event: { + ...discipline.event, + _links: [{ rel: "self", type: "GET", href: `/api/events/${discipline.event.pid}` }], + }, + roles: discipline.roles.map((role) => ({ + ...role, + _links: [{ rel: "self", type: "GET", href: `/api/role/${role.pid}` }], + })), + ...((discipline as any).teams + ? (discipline as any).teams.map((team: Team) => ({ + ...team, + _links: [{ rel: "self", type: "GET", href: `/api/teams/${team.pid}` }], + })) + : {}), + }, + }, + }); +}; + +interface CreateDisciplineBody { + name?: string; + minTeamSize?: number; + maxTeamSize?: number; +} + +// require: auth(ELEVATED) +// at: POST /event/:eventPid/discipliens +export const createDiscipline = async (req: Request<{ eventPid: string }, {}, CreateDisciplineBody>, res: Response) => { + if (req.auth?.permission_level !== "ELEVATED") { + return res.status(403).json(createInsufficientPermissionsError()); + } + + const { name, minTeamSize, maxTeamSize } = req.body; + + if (typeof name !== "string" || typeof minTeamSize !== "number" || typeof maxTeamSize !== "number") { + return res.status(400).json( + generateInvalidBodyError({ + name: DataType.STRING, + minTeamSize: DataType.NUMBER, + maxTeamSize: DataType.NUMBER, + }) + ); + } + + if (!validateName(name)) { + return res.status(400).json(NAME_ERROR); + } + + 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 } }, + }, + }); + + return res.status(201).json({ type: "success", payload: { discipline } }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + return res.status(404).json(generateError(`Could not link to event with ID '${req.params.eventPid}'`)); + } + throw e; + } +}; + +interface DeleteDisciplineQueryParams { + pid: string; +} + +// requires: auth(ELEVATED) +export const deleteDiscipline = async (req: Request, res: Response) => { + if (req.auth?.permission_level !== "ELEVATED") { + res.status(403).json(createInsufficientPermissionsError()); + } + + const { pid } = req.params; + + try { + await prisma.discipline.delete({ where: { pid } }); + + return res.status(204).end(); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("discipline", pid); + } + + throw e; + } +}; diff --git a/src/Controllers/group.controllers.ts b/src/Controllers/group.controllers.ts index 10f42c7..ee7fb5e 100644 --- a/src/Controllers/group.controllers.ts +++ b/src/Controllers/group.controllers.ts @@ -1,8 +1,8 @@ import { Admin } from "@prisma/client"; -import { PrismaClientUnknownRequestError } from "@prisma/client/runtime"; +import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; import { Request, Response } from "express"; import prisma from "../lib/prisma"; -import { generateError, genericError, handleCreateByName } from "./common"; +import { createInsufficientPermissionsError, generateError, genericError, handleCreateByName } from "./common"; const basicGroup = { pid: true, @@ -114,3 +114,27 @@ export const createGroup = async (req: Request<{ organisationPid: string }, {}, res ); }; + +interface DeleteGroupQueryParams { + pid: string; +} + +export const deleteGroup = async (req: Request, res: Response) => { + if (req.auth?.permission_level !== "ELEVATED") { + return res.status(403).json(createInsufficientPermissionsError()); + } + + const { pid } = req.params; + + try { + 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 e; + } +}; diff --git a/src/Controllers/role_schema.controller.ts b/src/Controllers/role_schema.controller.ts new file mode 100644 index 0000000..ca73554 --- /dev/null +++ b/src/Controllers/role_schema.controller.ts @@ -0,0 +1,125 @@ +import { Prisma } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; +import { Request, Response } from "express"; +import prisma from "../lib/prisma"; +import { DurationSchemaT, parseSchema, PointSchemaT } from "../lib/result_schema"; +import NotFoundError from "../Middleware/error/NotFoundError"; +import SchemaError from "../Middleware/error/SchemaError"; +import { + createInsufficientPermissionsError, + DataType, + generateInvalidBodyError, + NAME_ERROR, + validateName, +} from "./common"; + +const roleSchema = { + name: true, + schema: true, + discipline: { select: { pid: true, name: true } }, + visual: { select: { pid: true, location: true } }, +} as const; + +export const _getAllRoleSchemas = async (res: Response, disciplinePid: string | undefined) => { + const schemas = await prisma.roleSchema.findMany({ + where: { discipline: { pid: disciplinePid } }, + select: roleSchema, + }); + + return res.status(200).json({ + type: "success", + payload: { + roleSchemas: schemas, + }, + }); +}; + +interface GetAllRoleSchemasSearchParams { + disciplinePid?: string; +} + +export const getAllRoleSchemas = async (req: Request<{}, {}, {}, GetAllRoleSchemasSearchParams>, res: Response) => { + return _getAllRoleSchemas(res, req.query.disciplinePid); +}; + +interface GetAllRoleSchemasWithParamQueryParams { + organisationPid: string; +} + +export const getAllRoleSchemasWithParam = async ( + req: Request, + res: Response +) => { + return _getAllRoleSchemas(res, req.params.organisationPid); +}; + +interface GetRoleSchemaQueryParams { + pid: string; +} + +export const getRoleSchema = async (req: Request, res: Response) => { + const { pid } = req.params; + + const schema = await prisma.roleSchema.findUnique({ where: { pid }, select: roleSchema }); + + if (!schema) { + throw new NotFoundError("roleSchema", pid); + } + + return res.status(200).json({ + type: "success", + payload: { + roleSchema: { + ...schema, + discipline: { + ...schema.discipline, + _links: [{ rel: "self", type: "GET", href: `/api/disciplines/${schema.discipline.pid}` }], + }, + }, + }, + }); +}; + +interface CreateRoleSchemaBody { + name?: string; + schema?: any; +} + +// at: POST /discipline/:disciplinePid/role-schemas +// requires: auth(ELEVATED) +export const createRoleSchema = async ( + req: Request<{ disciplinePid: string }, {}, CreateRoleSchemaBody>, + res: Response +) => { + if (req.auth?.permission_level !== "ELEVATED") { + return res.status(403).json(createInsufficientPermissionsError()); + } + + const { name, schema: resultSchema } = req.body; + + if (typeof name !== "string") { + return res.status(400).json(generateInvalidBodyError({ name: DataType.STRING, schema: DataType.RESULT_SCHEMA })); + } + + if (!validateName(name)) { + return res.status(400).json(NAME_ERROR); + } + + // Validate the result schema (Errors should be handled by the default error handler) + const validatedSchema = parseSchema(resultSchema); + + try { + const schema = await prisma.roleSchema.create({ + data: { name, schema: validatedSchema, discipline: { connect: { pid: req.params.disciplinePid } } }, + select: roleSchema, + }); + + return res.status(201).json({ type: "success", payload: { roleSchema: schema } }); + } catch (e) { + if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") { + throw new NotFoundError("discipline", req.params.disciplinePid); + } + + throw e; + } +}; diff --git a/src/Middleware/error/ForwardableError.ts b/src/Middleware/error/ForwardableError.ts index 04bc5e1..bbae485 100644 --- a/src/Middleware/error/ForwardableError.ts +++ b/src/Middleware/error/ForwardableError.ts @@ -1,6 +1,7 @@ export default class ForwardableError extends Error { // If in different context private readonly __id = "CUSTOM_ERROR"; + protected readonly __oid?: string; public readonly status: number; diff --git a/src/Middleware/error/NotFoundError.ts b/src/Middleware/error/NotFoundError.ts new file mode 100644 index 0000000..382441f --- /dev/null +++ b/src/Middleware/error/NotFoundError.ts @@ -0,0 +1,13 @@ +import ForwardableError from "./ForwardableError"; + +export default class NotFoundError extends ForwardableError { + protected __oid = "NOT_FOUND_ERROR"; + + constructor(resource?: string, pid?: string) { + super(404, `The requested ${resource ?? "resource"}${pid ? ` with PID '${pid}'` : ""} could not be found!`); + } + + static isNotFoundError(err: any): err is NotFoundError { + return err.__oid === "NOT_FOUND_ERROR"; + } +} diff --git a/src/Middleware/error/SchemaError.ts b/src/Middleware/error/SchemaError.ts new file mode 100644 index 0000000..b880480 --- /dev/null +++ b/src/Middleware/error/SchemaError.ts @@ -0,0 +1,13 @@ +import ForwardableError from "./ForwardableError"; + +export default class SchemaError extends ForwardableError { + protected __oid = "SCHEMA_ERROR"; + + constructor(message: string) { + super(400, message); + } + + public static isSchemaError(err: any): err is SchemaError { + return err.__oid === "SCHEMA_ERROR"; + } +} diff --git a/src/Routes/discipline.routes.ts b/src/Routes/discipline.routes.ts new file mode 100644 index 0000000..08aec30 --- /dev/null +++ b/src/Routes/discipline.routes.ts @@ -0,0 +1,19 @@ +import express from "express"; +import eventRouter from "./event.routes"; +import { + createDiscipline, + deleteDiscipline, + getAllDisciplines, + getDiscipline, +} from "../Controllers/discipline.controller"; +import { requireAuthentication } from "../Middleware/auth/auth"; + +const router = express.Router(); + +router.get("/", getAllDisciplines); // TODO: Optional auth +router.get("/:pid", getDiscipline); +router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteDiscipline); + +eventRouter.post("/:eventPid/disciplines", requireAuthentication, createDiscipline); + +export default router; diff --git a/src/Routes/group.routes.ts b/src/Routes/group.routes.ts index 12ae59d..4ab9387 100644 --- a/src/Routes/group.routes.ts +++ b/src/Routes/group.routes.ts @@ -1,5 +1,11 @@ import express from "express"; -import { createGroup, getAllGroups, getAllGroupsWithParam, getGroup } from "../Controllers/group.controllers"; +import { + createGroup, + deleteGroup, + getAllGroups, + getAllGroupsWithParam, + getGroup, +} from "../Controllers/group.controllers"; import { requireAuthentication } from "../Middleware/auth/auth"; import organisationRouter from "./organisation.routes"; @@ -7,6 +13,7 @@ const router = express.Router(); router.get("/", getAllGroups); router.get("/:pid", getGroup); +router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteGroup); organisationRouter.get("/:organisationPid/groups", getAllGroupsWithParam); organisationRouter.post("/:organisationPid/groups", requireAuthentication, createGroup); diff --git a/src/Routes/organisation.routes.ts b/src/Routes/organisation.routes.ts index 7cff08d..9cda611 100644 --- a/src/Routes/organisation.routes.ts +++ b/src/Routes/organisation.routes.ts @@ -2,6 +2,7 @@ import express from "express"; import eventRouter from "./event.routes"; import { createOrganisation, + deleteOrganisation, getAllOrganisations, getAllOrganisationsWithParam, getOrganisation, @@ -14,6 +15,7 @@ const router = express.Router(); router.get("/", getAllOrganisations); router.get("/:pid", getOrganisation); router.put<"/:pid", { pid: string }>("/:pid", requireAuthentication, updateOrganisation); +router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteOrganisation); eventRouter.get("/:eventPid/organisations", getAllOrganisationsWithParam); eventRouter.post<"/:eventPid/organisations", { eventPid: string }>( diff --git a/src/Routes/role_schema.routes.ts b/src/Routes/role_schema.routes.ts new file mode 100644 index 0000000..ea3c938 --- /dev/null +++ b/src/Routes/role_schema.routes.ts @@ -0,0 +1,19 @@ +import express from "express"; +import disciplineRouter from "./discipline.routes"; +import { + createRoleSchema, + getAllRoleSchemas, + getAllRoleSchemasWithParam, + getRoleSchema, +} from "../Controllers/role_schema.controller"; +import { requireAuthentication } from "../Middleware/auth/auth"; + +const router = express.Router(); + +router.get("/", getAllRoleSchemas); +router.get("/:pid", getRoleSchema); + +disciplineRouter.get("/:disciplinePid/role-schemas", getAllRoleSchemasWithParam); +disciplineRouter.post("/:disciplinePid/role-schemas", requireAuthentication, createRoleSchema); + +export default router; diff --git a/src/app.ts b/src/app.ts index da3df2c..39dcc0f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,8 @@ import argon2 from "argon2"; import adminRouter from "./Routes/admin.routes"; import organisationRouter from "./Routes/organisation.routes"; import groupRouter from "./Routes/group.routes"; +import disciplineRouter from "./Routes/discipline.routes"; +import roleSchemaRouter from "./Routes/role_schema.routes"; import defaultErrorHandler from "./Middleware/error/handler"; import logger from "./Middleware/error/logger"; import debugLogger from "./Middleware/debug/logger"; @@ -53,8 +55,12 @@ async function main() { app.use("/api/groups", groupRouter); + app.use("/api/disciplines", disciplineRouter); + + app.use("/api/role-schemas", roleSchemaRouter); + // Error handling - app.use(defaultErrorHandler); + app.use(defaultErrorHandler); // Not working app.listen(process.env.PORT, () => { logger.info(`Listening on port ${process.env.PORT}`); diff --git a/src/lib/result_schema.ts b/src/lib/result_schema.ts new file mode 100644 index 0000000..7da23c9 --- /dev/null +++ b/src/lib/result_schema.ts @@ -0,0 +1,65 @@ +import { Schema, z, ZodError } from "zod"; +import SchemaError from "../Middleware/error/SchemaError"; + +// -- Schema definitions -- + +const SchemaVersion = z.enum(["1.0"]); + +const TimeUnit = z.enum(["days", "hours", "minutes", "seconds", "milliseconds"]); + +const DurationSchema = z + .object({ + type: z.literal("duration"), + min: z.number().int({ message: "min must be an integer (relative to smallestUnit)" }), + max: z.number().int({ message: "max must be an integer (relative to smallestUnit)" }), + smallestUnit: TimeUnit, + higherIsBetter: z.boolean(), + }) + .refine(({ min, max }) => min < max, { message: "min must be smaller than max" }); + +const PointSchema = z + .object({ + type: z.literal("points"), + min: z.number(), + max: z.number(), + step: z.number(), + start: z.number(), + + unit: z.string(), + unitSign: z.string(), + + higherIsBetter: z.boolean(), + }) + .refine(({ min, max }) => min < max, { message: "min must be smaller than max" }) + .refine(({ min, max, start }) => min <= start && max >= start, { + message: "start must be larger than or equal to min and smaller than or equal to max", + }); + +export type DurationSchemaT = z.infer; +export type PointSchemaT = z.infer; + +// -- Parser -- + +export function parseSchema(schema: any): DurationSchemaT | PointSchemaT { + try { + if (schema.type === "duration") { + return DurationSchema.parse(schema); + } else if (schema.type === "points") { + return PointSchema.parse(schema); + } + } catch (e) { + if (e instanceof ZodError) { + const issue = e.issues.at(0); + + if (issue) { + throw new SchemaError(`Error with the schema: ${issue.path ? `${issue.path}:` : ""} ${issue.message}`); + } + + throw new SchemaError("Unknown error occured while validating the schema"); + } + + throw e; + } + + throw new SchemaError("Error with the schema: type must be either 'duration' or 'points'"); +}