diff --git a/dev.sh b/dev.sh index d6b8e5a..64d89c1 100755 --- a/dev.sh +++ b/dev.sh @@ -1,26 +1,56 @@ #!/bin/bash +D_SERVICES="mail postgres redis" -DATABASE_PASSWORD=server COMPOSE_PROJECT_NAME=detleph_server docker-compose up -d mail postgres redis +DATABASE_PASSWORD=server COMPOSE_PROJECT_NAME=detleph_server docker-compose up -d $D_SERVICES + +# Flag: Should the container be deleted and created again? +RECREATE=true +D_PORT="${PORT:-3000}" + +if [ "$(docker ps -a | grep detleph_server_dev)" ]; then + echo "The dev server is already on the system!" + + RECREATE=false + + # Use -s flag to skip any prompts + if [ "$1" != "-s" ]; then + echo "Do you want to recreate it? [y/n]" + read input + + if [ "$input" = "y" ]; then + RECREATE=true + fi + fi + + if [ "$RECREATE" = true ]; then + echo "Removing container" + docker rm detleph_server_dev + rm -f INITIALIZED # Flag has to be reset + + echo "Do you also want to reset the other services? [y/n]" + read input + + if [ "$input" = "y" ]; then + COMPOSE_PROJECT_NAME=detleph_server docker-compose down + DATABASE_PASSWORD=server COMPOSE_PROJECT_NAME=detleph_server docker-compose up -d $D_SERVICES + fi + + else + echo "Starting existing container" + docker start -ia detleph_server_dev + fi +fi + +if [ "$RECREATE" = true ]; then + echo "Creating the dev container" -if [ ! "$(docker ps -a | grep detleph_server_dev)" ]; then - echo "Creating the server container" - docker run -it \ --name detleph_server_dev \ --mount type=bind,source="$(pwd)",target=/app \ --network detleph_server_default \ - -p 3000:3000 -e PORT=3000 \ - -e DATABASE_URL="postgresql://server:server@postgres:5432/management?schema=public" \ - -e DATABASE_USER=server \ -e DATABASE_PASSWORD=server \ + -e DATABASE_URL="postgresql://server:server@postgres:5432/management?schema=public" \ --entrypoint "/app/scripts/docker-entrypoint.dev.sh" \ node -else - echo "The server container already exists; Starting..." - - docker start -ia detleph_server_dev fi - -# After container termination - COMPOSE_PROJECT_NAME=detleph_server docker-compose stop diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8025cd4..67e12ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -134,9 +134,9 @@ model Group { name String user_limit Int @default(40) level Int + organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) + organisationId Int - oragnisation Organisation @relation(fields: [oragnisationId], references: [id], onDelete: Cascade) - oragnisationId Int participants Participant[] link Link? admins Admin[] diff --git a/src/Controllers/admin.controller.ts b/src/Controllers/admin.controller.ts index 714d98c..cdbc2ed 100644 --- a/src/Controllers/admin.controller.ts +++ b/src/Controllers/admin.controller.ts @@ -2,7 +2,7 @@ import prisma from "../lib/prisma"; import { Request, Response } from "express"; import { AdminLevel } from "@prisma/client"; import argon2 from "argon2"; -import { DataType, generateInvalidBodyError } from "./common"; +import { AUTH_ERROR, createInsufficientPermissionsError, DataType, generateInvalidBodyError } from "./common"; import { authClient } from "../lib/redis"; export const regenerateRevision = async (pid: string) => { @@ -16,28 +16,6 @@ export const regenerateRevision = async (pid: string) => { await authClient.set(pid, revision.toISOString()); }; -const AUTH_ERROR = { - type: "failure", - payload: { - message: "The server was not able to validate your credentials; Please try again later", - }, -}; - -const createInsufficientPermissionsError = (required: AdminLevel = "ELEVATED") => ({ - type: "error", - payload: { - message: "You do not have sufficient permissions to use this feature", - required_level: required, - }, - _links: [ - { - rel: "authentication", - href: "/api/authentication", - type: "POST", - }, - ], -}); - // requires: auth(elevated) export const getAllAdmins = async (req: Request, res: Response) => { if (!req.auth?.isAuthenticated) { diff --git a/src/Controllers/common.ts b/src/Controllers/common.ts index f568c75..a9a0caf 100644 --- a/src/Controllers/common.ts +++ b/src/Controllers/common.ts @@ -1,3 +1,8 @@ +import { AdminLevel, Prisma } from "@prisma/client"; +import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime"; +import { Request, Response } from "express"; +import prisma from "../lib/prisma"; + export enum DataType { STRING = "string", NUMBER = "number", @@ -20,3 +25,126 @@ export function generateInvalidBodyError(body: Body) { }, }; } + +export const AUTH_ERROR = { + type: "failure", + payload: { + message: "The server was not able to validate your credentials; Please try again later", + }, +}; + +export const createInsufficientPermissionsError = (required: AdminLevel = "ELEVATED") => ({ + type: "error", + payload: { + message: "You do not have sufficient permissions to use this feature", + required_level: required, + }, + _links: [ + { + rel: "authentication", + href: "/api/authentication", + type: "POST", + }, + ], +}); + +export const generateError = (message: string) => { + return { + type: "error", + payload: { + message, + }, + }; +}; + +export const genericError = { + type: "error", + payload: { + message: "There was an error processing your request, please try again later", + }, +}; + +export function validateName(name: string) { + return name.length > 0; +} + +type Organisation = Partial; +type Group = Partial; + +export async function handleCreateByName( + create: { type: "organisation"; data: Organisation }, + link: { type: "event"; id: string }, + req: Request, + res: Response +): Promise; +export async function handleCreateByName( + create: { type: "group"; data: Group }, + link: { type: "organisation"; id: string }, + req: Request, + res: Response +): Promise; +export async function handleCreateByName( + create: { type: "organisation" | "group"; data: Organisation | Group }, + link: { type: "event" | "organisation"; id: string }, + req: Request, + res: Response +) { + if (!req.auth?.isAuthenticated) { + return res.status(500).json(AUTH_ERROR); + } + + if (req.auth.permission_level !== "ELEVATED") { + return res.status(403).json(createInsufficientPermissionsError()); + } + + const { name } = create.data; + + if (typeof name !== "string") { + return res + .status(400) + .json(generateInvalidBodyError({ name: DataType.STRING, [link.type + "Pid"]: DataType.STRING })); + } + + if (!validateName(name)) { + return res.status(400).json({ + type: "error", + payload: { + message: "The name has to be at least 1 character long", + }, + }); + } + + // Check if link object exsits + + try { + // @ts-ignore + const linked = await prisma[link.type].findUnique({ where: { pid: link.id }, select: { id: true } }); + + if (!linked) { + return res.status(404).json({ + type: "error", + payload: { + message: `Could not link to ${link.type} with ID '${link.id}'`, + }, + }); + } + } catch (e) { + // REVIEW: Check for valid UUID + if (e instanceof PrismaClientUnknownRequestError) { + return res.status(400).json({ + type: "error", + payload: { + message: "Unknown error occured. This could be due to malformed IDs", + }, + }); + } + } + + // @ts-ignore + const object = await prisma[create.type].create({ + data: { ...create.data, [link.type]: { connect: { pid: link.id } } }, + select: { pid: true, name: true }, + }); + + res.status(201).json({ type: "success", payload: { [create.type]: object } }); +} diff --git a/src/Controllers/event.controller.ts b/src/Controllers/event.controller.ts index 465b8bf..9f5afbf 100644 --- a/src/Controllers/event.controller.ts +++ b/src/Controllers/event.controller.ts @@ -1,7 +1,8 @@ -import { Prisma } from "@prisma/client"; +import { Prisma } from "@prisma/client" +import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { Request, Response } from "express"; import prisma from "../lib/prisma"; -import { DataType, generateInvalidBodyError } from "./common"; +import { createInsufficientPermissionsError, DataType, generateError, generateInvalidBodyError } from "./common"; export const getAllEvents = async (req: Request, res: Response) => { const events = await prisma.event.findMany({ @@ -13,8 +14,13 @@ export const getAllEvents = async (req: Request, res: Response) => { id: false, }, }); - if (events.length > 0) res.status(200).json(events); - else res.status(200).json([]); + if (events.length > 0) + res.status(200).json({ + type: "success", + payload: { + events, + }, + }); }; export const getEvent = async (req: Request, res: Response) => { @@ -42,7 +48,12 @@ export const getEvent = async (req: Request, res: Response) => { }, }); - res.status(200).json(event); + res.status(200).json({ + type: "success", + payload: { + event, + }, + }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { res.status(500).json({ @@ -68,20 +79,23 @@ export const getEvent = async (req: Request, res: Response) => { } }; + +// requires: auth(ELEVATED) 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.description !== "string" ) { - res.status(400).json( generateInvalidBodyError({ name: DataType.STRING, date: DataType.DATETIME, description: DataType.STRING, }) ); - return; } //TODO: Check if date is valid @@ -102,9 +116,34 @@ export const addEvent = async (req: Request, res: Response) => { }); res.status(201).json({ - type: "succes", + type: "success", payload: { event, }, }); }; + +interface DeleteEventQueryParams { + pid: string; +} + +// requires: auth(ELEVATED) +export const deleteEvent = (req: Request, res: Response) => { + if (req.auth?.permission_level !== "ELEVATED") { + res.status(403).json(createInsufficientPermissionsError()); + } + + const { pid } = req.params; + + try { + prisma.event.delete({ where: { pid } }); + + 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; + } +}; diff --git a/src/Controllers/group.controllers.ts b/src/Controllers/group.controllers.ts new file mode 100644 index 0000000..10f42c7 --- /dev/null +++ b/src/Controllers/group.controllers.ts @@ -0,0 +1,116 @@ +import { Admin } from "@prisma/client"; +import { PrismaClientUnknownRequestError } from "@prisma/client/runtime"; +import { Request, Response } from "express"; +import prisma from "../lib/prisma"; +import { generateError, genericError, handleCreateByName } from "./common"; + +const basicGroup = { + pid: true, + name: true, + organisation: { select: { pid: true, name: true } }, +} as const; + +export const _getAllGroups = async (res: Response, organisationId: string | undefined) => { + const groups = await prisma.group.findMany({ + where: { organisation: { pid: organisationId } }, + select: basicGroup, + }); + + return res.status(200).json({ + type: "success", + payload: { + groups, + }, + }); +}; + +interface GetAllGroupsSearchParams { + organisationPid?: string; +} + +export const getAllGroups = async (req: Request<{}, {}, {}, GetAllGroupsSearchParams>, res: Response) => { + return _getAllGroups(res, req.query.organisationPid); +}; + +interface getAllGroupsWithParamQueryParams { + organisationPid: string; +} + +export const getAllGroupsWithParam = async (req: Request, res: Response) => { + return _getAllGroups(res, req.params.organisationPid); +}; + +interface GetGroupQueryParams { + pid: string; +} + +export const getGroup = async (req: Request, res: Response) => { + const { pid } = req.params; + + try { + const group: { + pid: string; + name: string; + organisation: { pid: string; name: string }; + admins?: { pid: string; name: string }[]; + participants?: { pid: string }[]; + } | null = await prisma.group.findUnique({ + where: { pid }, + select: req.auth?.isAuthenticated + ? { + pid: true, + name: true, + organisation: { select: { pid: true, name: true } }, + admins: { select: { pid: true, name: true } }, + participants: { select: { pid: true } }, + } + : basicGroup, + }); + + if (!group) { + return res.status(404).json(generateError(`The group with ID '${pid}' could not be found`)); + } + + return res.status(200).json({ + type: "success", + payload: { + group: { + ...group, + organisation: { + ...group.organisation, + _links: [{ rel: "self", type: "GET", href: `/api/organisation/${group.organisation.pid}` }], + }, + ...(req.auth?.isAuthenticated + ? { + admins: group.admins?.map((admin) => ({ + ...admin, + _links: [{ rel: "self", type: "GET", href: `/api/admins/${admin.pid}` }], + })), + participants: group.participants?.map((participant) => ({ + ...participant, + _links: [{ rel: "self", type: "GET", href: `/api/participant/${participant.pid}` }], + })), + } + : {}), + }, + }, + }); + } catch (e) { + if (e instanceof PrismaClientUnknownRequestError) { + return res.status(400).json(generateError("Unknown error occured. This could be due to malformed IDs")); + } + } + + return res.status(500).json(genericError); +}; + +// at: POST /api/organisations/:eventPid/groups +// requires: auth(ELEVATED) +export const createGroup = async (req: Request<{ organisationPid: string }, {}, { name?: string }>, res: Response) => { + return handleCreateByName( + { type: "group", data: { name: req.body.name, level: 1 } }, + { type: "organisation", id: req.params.organisationPid }, + req, + res + ); +}; diff --git a/src/Controllers/organisation.controller.ts b/src/Controllers/organisation.controller.ts new file mode 100644 index 0000000..146594e --- /dev/null +++ b/src/Controllers/organisation.controller.ts @@ -0,0 +1,233 @@ +import { PrismaClientUnknownRequestError } from "@prisma/client/runtime"; +import { Request, Response } from "express"; +import prisma from "../lib/prisma"; +import { + AUTH_ERROR, + createInsufficientPermissionsError, + DataType, + generateError, + generateInvalidBodyError, + genericError, + handleCreateByName, +} from "./common"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; +import { Prisma } from "@prisma/client"; + +function validateOranisationName(name: string) { + return name.length > 0; +} + +const detailedOrganisation = { + pid: true, + name: true, + event: { + select: { + pid: true, + name: true, + date: true, + description: true, + }, + }, +} as const; + +const basicOrganisation = { + pid: true, + name: true, + event: { + select: { + pid: true, + name: true, + }, + }, +} as const; + +export const _getAllOrganisations = async (res: Response, eventId: string | undefined = undefined) => { + const organisations = await prisma.organisation.findMany({ + where: { event: { pid: eventId } }, + select: basicOrganisation, + }); + + return res.status(200).json({ + type: "success", + payload: { + organisations, + }, + }); +}; + +interface GetAllOrganisationsSearchParams { + eventId?: string; +} + +export const getAllOrganisations = async (req: Request<{}, {}, {}, GetAllOrganisationsSearchParams>, res: Response) => { + // TODO: Maybe rename to eventPid + return _getAllOrganisations(res, req.query.eventId); +}; + +interface GetAllOrganisationsWithParamQueryParams { + eventPid: string; +} + +export const getAllOrganisationsWithParam = async ( + req: Request, + res: Response +) => { + return _getAllOrganisations(res, req.params.eventPid); +}; + +interface GetOrganisationQueryParams { + pid: string; +} + +export const getOrganisation = async (req: Request, res: Response) => { + const { pid } = req.params; + + const organisation = await prisma.organisation.findUnique({ + where: { pid }, + select: detailedOrganisation, + }); + + if (!organisation) { + return res.status(404).json({ + type: "error", + payload: { + message: `The organisation with ID '${pid} could not be found'`, + }, + }); + } + + return res.status(200).json({ + type: "success", + payload: { + organisation: { + ...organisation, + event: { + ...organisation.event, + _links: [{ rel: "self", type: "GET", href: `/api/event/${organisation.event.pid}` }], + }, + }, + }, + }); +}; + +interface CreateOrganisationQueryParams { + eventPid: string; +} + +interface CreateOrganisationBody { + name?: string; +} + +// at: POST /api/events/:eventPid/organisations +// requires: auth(ELEVATED) +export const createOrganisation = async ( + req: Request, + res: Response +) => { + return handleCreateByName( + { type: "organisation", data: { name: req.body.name } }, + { type: "event", id: req.params.eventPid }, + req, + res + ); +}; + +interface UpdateOrganisationQueryParams { + pid: string; +} + +interface UpdateOrganisationBody { + name?: string; +} + +// requires: auth(ELEVATED) +export const updateOrganisation = async ( + req: Request, + res: Response +) => { + if (!req.auth?.isAuthenticated) { + return res.status(500).json(AUTH_ERROR); + } + + if (req.auth.permission_level !== "ELEVATED") { + return res.status(403).json(createInsufficientPermissionsError()); + } + + const { pid } = req.params; + const { name } = req.body; + + if (name !== undefined && typeof name !== "string") { + return res.status(400).json( + generateInvalidBodyError({ + name: DataType.STRING, + }) + ); + } + + if (name !== undefined && !validateOranisationName(name)) { + return res.status(400).json({ + type: "error", + payload: { + message: "The name has to be at least 1 character long", + }, + }); + } + + try { + const organisation = await prisma.organisation.update({ + where: { pid }, + data: { name }, + select: detailedOrganisation, + }); + + return res.status(200).json({ + type: "success", + payload: { + organisation, + }, + }); + } 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")); + } + } + + return res.status(500).json(genericError); +}; + +interface DeleteOrganisationQueryParams { + pid: string; +} + +// requires: auth(ELEVATED) +export const deleteOrganisation = async (req: Request, res: Response) => { + if (!req.auth?.isAuthenticated) { + return res.status(500).json(AUTH_ERROR); + } + + if (req.auth.permission_level !== "ELEVATED") { + return res.status(403).json(createInsufficientPermissionsError()); + } + + const { pid } = req.params; + + try { + prisma.organisation.delete({ where: { pid } }); + + res.status(204).end(); + } 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")); + } + } + + return res.status(500).json(genericError); +}; diff --git a/src/Routes/group.routes.ts b/src/Routes/group.routes.ts new file mode 100644 index 0000000..12ae59d --- /dev/null +++ b/src/Routes/group.routes.ts @@ -0,0 +1,14 @@ +import express from "express"; +import { createGroup, getAllGroups, getAllGroupsWithParam, getGroup } from "../Controllers/group.controllers"; +import { requireAuthentication } from "../Middleware/auth/auth"; +import organisationRouter from "./organisation.routes"; + +const router = express.Router(); + +router.get("/", getAllGroups); +router.get("/:pid", getGroup); + +organisationRouter.get("/:organisationPid/groups", getAllGroupsWithParam); +organisationRouter.post("/:organisationPid/groups", requireAuthentication, createGroup); + +export default router; diff --git a/src/Routes/organisation.routes.ts b/src/Routes/organisation.routes.ts new file mode 100644 index 0000000..7cff08d --- /dev/null +++ b/src/Routes/organisation.routes.ts @@ -0,0 +1,25 @@ +import express from "express"; +import eventRouter from "./event.routes"; +import { + createOrganisation, + getAllOrganisations, + getAllOrganisationsWithParam, + getOrganisation, + updateOrganisation, +} from "../Controllers/organisation.controller"; +import { requireAuthentication } from "../Middleware/auth/auth"; + +const router = express.Router(); + +router.get("/", getAllOrganisations); +router.get("/:pid", getOrganisation); +router.put<"/:pid", { pid: string }>("/:pid", requireAuthentication, updateOrganisation); + +eventRouter.get("/:eventPid/organisations", getAllOrganisationsWithParam); +eventRouter.post<"/:eventPid/organisations", { eventPid: string }>( + "/:eventPid/organisations", + requireAuthentication, + createOrganisation +); + +export default router; diff --git a/src/app.ts b/src/app.ts index 6af2048..5bbc7b7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,9 @@ import eventRouter from "./Routes/event.routes"; import adminAuthRouter from "./Routes/admin_auth.routes"; import argon2 from "argon2"; import adminRouter from "./Routes/admin.routes"; +import organisationRouter from "./Routes/organisation.routes"; +import groupRouter from "./Routes/group.routes"; + require("dotenv").config(); // Load dotenv config @@ -46,6 +49,9 @@ async function main() { app.use("/api/admins", adminRouter); + app.use("/api/organisations", organisationRouter); + + app.use("/api/groups", groupRouter); app.listen(process.env.PORT, () => { console.log(`Listening on Port: ${process.env.PORT}`); });