diff --git a/dbschema/default.esdl b/dbschema/default.esdl index 73e335e..b2cb0a1 100644 --- a/dbschema/default.esdl +++ b/dbschema/default.esdl @@ -2,10 +2,10 @@ module default { scalar type Authmethod extending enum; type User { - required username: str; - required displayname: str { + required username: str { constraint exclusive; }; + required displayname: str; required multi authmethod: Authmethod; multi tokens: RefreshToken { @@ -66,4 +66,26 @@ module default { readonly := true; }; } + + scalar type Status extending enum; + + type JoinRequest { + required wantsToJoin: Class { + readonly := true; + }; + required user: User { + readonly := true; + }; + required created: datetime { + default := datetime_current(); + readonly := true; + }; + + required status: Status { + default := 'Pending'; + }; + + reviewedAt: datetime; + reviewedBy: User; + } } diff --git a/dbschema/migrations/00008.edgeql b/dbschema/migrations/00008.edgeql new file mode 100644 index 0000000..fe18bac --- /dev/null +++ b/dbschema/migrations/00008.edgeql @@ -0,0 +1,20 @@ +CREATE MIGRATION m1n5yfow7yvygzyzix542fym6svcupjqrwdsj6zu54subohzbz52eq + ONTO m1psgxwipft63xhdvv24e4bboazf26v6doqseu3nyzyoa3oecmjp4q +{ + CREATE SCALAR TYPE default::Status EXTENDING enum; + CREATE TYPE default::JoinRequest { + CREATE REQUIRED LINK user: default::User { + SET readonly := true; + }; + CREATE REQUIRED LINK wantsToJoin: default::Class { + SET readonly := true; + }; + CREATE REQUIRED PROPERTY created: std::datetime { + SET default := (std::datetime_current()); + SET readonly := true; + }; + CREATE REQUIRED PROPERTY status: default::Status { + SET default := 'Pending'; + }; + }; +}; diff --git a/dbschema/migrations/00009.edgeql b/dbschema/migrations/00009.edgeql new file mode 100644 index 0000000..9115b03 --- /dev/null +++ b/dbschema/migrations/00009.edgeql @@ -0,0 +1,16 @@ +CREATE MIGRATION m1g3bsb7rj3ypvusgnkekg6g7zetyzd7xvl564gqjkdb4zwjfphyyq + ONTO m1n5yfow7yvygzyzix542fym6svcupjqrwdsj6zu54subohzbz52eq +{ + ALTER TYPE default::JoinRequest { + CREATE LINK reviewedBy: default::User; + CREATE PROPERTY reviewedAt: std::datetime; + }; + ALTER TYPE default::User { + ALTER PROPERTY displayname { + DROP CONSTRAINT std::exclusive; + }; + ALTER PROPERTY username { + CREATE CONSTRAINT std::exclusive; + }; + }; +}; diff --git a/src/index.ts b/src/index.ts index f072715..885a91c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { accessTokenRouter } from "routes/auth/accessToken"; import { refreshTokenRouter } from "routes/auth/refreshToken"; import { registerRouter } from "routes/auth/register"; import { classRouter } from "routes/classes"; +import { moderationRouter } from "routes/moderation"; import { schoolRouter } from "routes/school"; import { userInfoRouter } from "routes/user/info"; import { edgedb } from "../dbschema/edgeql-js/imports"; @@ -16,6 +17,7 @@ const app = new Elysia() .use(swagger(DOCUMENTATION_OPTIONS)) .use(schoolRouter) .use(classRouter) + .use(moderationRouter) .get( "/", () => ({ diff --git a/src/routes/auth/refreshToken.ts b/src/routes/auth/refreshToken.ts index da1cf09..827d6df 100644 --- a/src/routes/auth/refreshToken.ts +++ b/src/routes/auth/refreshToken.ts @@ -4,6 +4,7 @@ import { HttpStatusCode } from "elysia-http-status-code"; import { client } from "index"; import { passowrdAuthSecret } from "schemas/auth"; import { createToken } from "utils/auth/jwt"; +import { promiseResult } from "utils/errors"; import { randomNumber } from "utils/random"; import { responseBuilder } from "utils/response"; import { wait } from "utils/time"; @@ -55,23 +56,18 @@ export const refreshTokenRouter = new Elysia({ prefix: "/refresh-token" }) type: "refresh", }); - const query = e.update(e.User, () => ({ - filter: e.op(e.User.username, "=", body.username), + const query = e.update(e.User, (u) => ({ + filter_single: e.op(u.username, "=", body.username), set: { tokens: { "+=": e.insert(e.RefreshToken, { token: tokenRefresh }), }, }, })); + + const result = await promiseResult(() => query.run(client)); - const result = await query.run(client).catch(() => - responseBuilder("error", { - error: "An error occurred while updating the user", - }), - ); - - // a positive result is a array - if (!Array.isArray(result)) { + if (result.isError) { set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR; return result; } diff --git a/src/routes/classes/create.ts b/src/routes/classes/create.ts index 04266fe..8bb4611 100644 --- a/src/routes/classes/create.ts +++ b/src/routes/classes/create.ts @@ -66,6 +66,12 @@ export const createClass = new Elysia() set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR; return DATABASE_WRITE_FAILED; } + if (!createResult.data) { + set.status = httpStatus.HTTP_404_NOT_FOUND; + return responseBuilder("error", { + error: "School not found", + }); + } set.status = httpStatus.HTTP_201_CREATED; return responseBuilder("success", { diff --git a/src/routes/moderation/create.ts b/src/routes/moderation/create.ts new file mode 100644 index 0000000..d63b177 --- /dev/null +++ b/src/routes/moderation/create.ts @@ -0,0 +1,154 @@ +import e from "@edgedb"; +import { + DATABASE_READ_FAILED, + DATABASE_WRITE_FAILED, + UNAUTHORIZED, +} from "constants/responses"; +import Elysia, { t } from "elysia"; +import { HttpStatusCode } from "elysia-http-status-code"; +import { client } from "index"; +import { auth } from "plugins/auth"; +import { getAmountOfMembersOfClass } from "utils/db/classes"; +import { doesRequestAlreadyExist } from "utils/db/requests"; +import { promiseResult } from "utils/errors"; +import { responseBuilder } from "utils/response"; + +export const createJoinRequest = new Elysia() + .use(auth) + .use(HttpStatusCode()) + .post( + "/", + async ({ auth, body, httpStatus, set }) => { + if (!auth.isAuthorized) { + return UNAUTHORIZED; + } + + const countResult = await promiseResult(() => + getAmountOfMembersOfClass({ + className: body.class, + schoolName: body.school, + }), + ); + if (countResult.status === "error") { + set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR; + return DATABASE_READ_FAILED; + } + + if (countResult.data === 0) { + const joinClassquery = e.update(e.Class, (c) => { + const classNameMatches = e.op(c.name, "=", body.class); + const schoolNameMatches = e.op(c.school.name, "=", body.school); + + return { + filter_single: e.op(classNameMatches, "and", schoolNameMatches), + set: { + students: { + "+=": e.select(e.User, (u) => ({ + filter_single: e.op(u.username, "=", auth.username), + })), + }, + }, + }; + }); + const joinResult = await promiseResult(() => + joinClassquery.run(client), + ); + if (joinResult.status === "error") { + set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR; + return DATABASE_WRITE_FAILED; + } + if (!joinResult.data) { + set.status = httpStatus.HTTP_404_NOT_FOUND; + return responseBuilder("error", { + error: "Class or school not found", + }); + } + + return responseBuilder("success", { + message: "Joined class successfully!", + data: null, + }); + } + + const isUserAlreadyInClassQuery = e.count(e.select(e.Class, (c) => { + const classNameMatches = e.op(c.name, "=", body.class); + const schoolNameMatches = e.op(c.school.name, "=", body.school); + const userMatches = e.op(c.students.username, "=", auth.username); + + return { + filter_single: e.op( + e.op(classNameMatches, "and", schoolNameMatches), + "and", + userMatches, + ), + }; + })); + const isUserAlreadyInClassResult = await promiseResult(() => + isUserAlreadyInClassQuery.run(client), + ); + if (isUserAlreadyInClassResult.isError) { + set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR; + return DATABASE_READ_FAILED; + } + if (isUserAlreadyInClassResult.data > 0) { + set.status = httpStatus.HTTP_400_BAD_REQUEST; + return responseBuilder("error", { + error: "User is already in class", + }); + } + + const doesReqAlreadyExistResult = await promiseResult(() => + doesRequestAlreadyExist({ + username: auth.username, + class: body.class, + school: body.school, + }), + ); + if (doesReqAlreadyExistResult.status === "error") { + set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR; + return DATABASE_READ_FAILED; + } + if (doesReqAlreadyExistResult.data) { + return responseBuilder("error", { + error: "Request already exists", + }); + } + + const joinRequestQuery = e.insert(e.JoinRequest, { + user: e.select(e.User, (u) => ({ + filter_single: e.op(u.username, "=", auth.username), + })), + wantsToJoin: e.select(e.Class, (c) => { + const classNameMatches = e.op(c.name, "=", body.class); + const schoolNameMatches = e.op(c.school.name, "=", body.school); + return { + filter_single: e.op(classNameMatches, "and", schoolNameMatches), + }; + }), + }); + const joinRequestResult = await promiseResult(() => + joinRequestQuery.run(client), + ); + if (joinRequestResult.status === "error") { + set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR; + return DATABASE_WRITE_FAILED; + } + + return responseBuilder("success", { + message: "Join request created successfully!", + data: null, + }); + }, + { + body: t.Object({ + school: t.String({ + minLength: 1, + description: "The name of the school to join", + }), + class: t.String({ + minLength: 1, + description: "The name of the class to join", + }), + }), + }, + ); diff --git a/src/routes/moderation/index.ts b/src/routes/moderation/index.ts new file mode 100644 index 0000000..931be93 --- /dev/null +++ b/src/routes/moderation/index.ts @@ -0,0 +1,6 @@ +import Elysia from "elysia"; +import { createJoinRequest } from "./create"; + +export const moderationRouter = new Elysia({ prefix: "/mod" }).use( + createJoinRequest, +); diff --git a/src/utils/db/README.md b/src/utils/db/README.md new file mode 100644 index 0000000..6bcaa39 --- /dev/null +++ b/src/utils/db/README.md @@ -0,0 +1,13 @@ +# Database Utilities + +This is a collection of complexer database operations. +Only put your queries here if it is simpler to read and understand it as a function call instead of the direct query builder. + +## Rules + +- Always use the query builder to build your queries! +- Always document your function with a js-doc comment! + - The comment must specify if the database will be read or written to + - The comment must specify if an error might be thrown +- If the query fails, throw an error! + - You may want to use the `promiseResult` utility to abstract errors into values diff --git a/src/utils/db/classes.ts b/src/utils/db/classes.ts new file mode 100644 index 0000000..c5f2e98 --- /dev/null +++ b/src/utils/db/classes.ts @@ -0,0 +1,39 @@ +import e from "@edgedb"; +import { client } from "index"; +import { promiseResult } from "utils/errors"; + +/** + * Get the amount of members in a class + * This only gives the accepted members - requests are ignored + * It doesn't modify the database but reads from it + * @throws Error if the database query fails + */ +export async function getAmountOfMembersOfClass(props: { + className: string; + schoolName: string; +}) { + const selectClassMembersQuery = e.select(e.User, (u) => { + const classNameMatches = e.op( + u[" countClassMembersQuery.run(client)); + + if (result.status === "error") { + throw new Error("Failed to get amount of class members"); + } + + return result.data; +} diff --git a/src/utils/db/requests.ts b/src/utils/db/requests.ts new file mode 100644 index 0000000..6700943 --- /dev/null +++ b/src/utils/db/requests.ts @@ -0,0 +1,39 @@ +import e from "@edgedb"; +import { client } from "index"; +import { promiseResult } from "utils/errors"; + +interface DoesReqAlreadyExistProps { + username: string; + class: string; + school: string; +} + +/** + * Check if a join-request already exists for a given user, class and school + * It doesn't modify the database but reads from it + * @throws Error if the database query fails + * @readonly + */ +export async function doesRequestAlreadyExist(props: DoesReqAlreadyExistProps) { + const selectRequestQuery = e.select(e.JoinRequest, (jr) => { + const userMatches = e.op(jr.user.username, "=", props.username); + const classMatches = e.op(jr.wantsToJoin.name, "=", props.class); + const schoolMatches = e.op(jr.wantsToJoin.school.name, "=", props.school); + + return { + filter: e.op( + userMatches, + "and", + e.op(classMatches, "and", schoolMatches), + ), + }; + }); + const countRequestQuery = e.count(selectRequestQuery); + const result = await promiseResult(() => countRequestQuery.run(client)); + + if (result.status === "error") { + throw new Error("Failed to check if request already exists"); + } + + return result.data > 0; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index fe87b64..7ff44bc 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -13,12 +13,15 @@ export const promiseResult = async (callback: () => Promise) => { ({ status: "success", data, + isError: false, }) as const, ) .catch( - () => + (e) => ({ status: "error", + error: e, + isError: true, }) as const, ); };