├── .env.public ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── LICENSE ├── README.md ├── apidoc.json ├── docs ├── assets │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ ├── glyphicons-halflings-regular.woff2 │ ├── main.bundle.js │ ├── main.css │ ├── prism-diff-highlight.css │ ├── prism-toolbar.css │ └── prism.css └── index.html ├── index.js ├── package.json ├── pnpm-lock.yaml ├── src ├── constant │ ├── constants.json │ └── constants.ts ├── database │ ├── classes │ │ ├── class.ts │ │ ├── createClass.ts │ │ ├── findClass.ts │ │ └── update.ts │ ├── database.ts │ ├── events │ │ ├── createEvent.ts │ │ └── event.ts │ ├── homework │ │ ├── createHomework.ts │ │ ├── deleteHomework.ts │ │ ├── findHomework.ts │ │ ├── getFrontendUrlForHomework.ts │ │ ├── homework.ts │ │ └── updateHomework.ts │ ├── notes │ │ ├── createNote.ts │ │ └── notes.ts │ ├── requests │ │ ├── addToClassRequests.ts │ │ ├── changeAddToClassRequestStatus.ts │ │ ├── createAddToClassRequest.ts │ │ └── findAddToClassRequests.ts │ ├── school │ │ ├── createSchool.ts │ │ ├── findSchool.ts │ │ ├── school.ts │ │ └── update.ts │ ├── user │ │ ├── changeData.ts │ │ ├── checkPassword.ts │ │ ├── createUser.ts │ │ ├── deleteUser.ts │ │ ├── doesUserExist.ts │ │ ├── findUser.ts │ │ └── user.ts │ └── utils │ │ └── getPaginatedData.ts ├── docs.ts ├── index.ts ├── middleware │ ├── auth.ts │ ├── isFormat.ts │ └── pagination.ts ├── migrations │ ├── addContributors.ts │ └── addEMailToUser.ts ├── routes │ ├── auth │ │ ├── login.ts │ │ ├── me │ │ │ ├── changeData.ts │ │ │ ├── delete.ts │ │ │ ├── getData.ts │ │ │ └── router.ts │ │ ├── register.ts │ │ ├── requests │ │ │ ├── getSpecificRequest.ts │ │ │ ├── getSpecificRequestSSE.ts │ │ │ ├── listRequest.ts │ │ │ ├── processRequest.ts │ │ │ └── router.ts │ │ ├── router.ts │ │ └── userDetails.ts │ ├── classes │ │ ├── createClass.ts │ │ ├── getClass.ts │ │ ├── getSpecificClass.ts │ │ ├── getSpecificClassById.ts │ │ └── router.ts │ ├── events │ │ ├── createEvent.ts │ │ ├── deleteEvent.ts │ │ ├── get │ │ │ ├── csv.ts │ │ │ ├── ical.ts │ │ │ ├── json.ts │ │ │ └── xml │ │ │ │ ├── generateXml.ts │ │ │ │ ├── humanReadable.ts │ │ │ │ └── minified.ts │ │ ├── getEvents.ts │ │ └── router.ts │ ├── homework │ │ ├── calendar │ │ │ ├── calendar.ts │ │ │ └── generateIcal.ts │ │ ├── createHomework.ts │ │ ├── csv │ │ │ └── generateCSV.ts │ │ ├── deleteHomework.ts │ │ ├── getAllHomework.ts │ │ ├── getPaginatedHomework.ts │ │ ├── router.ts │ │ ├── todo │ │ │ └── todo.ts │ │ └── updateHomework.ts │ ├── notes │ │ ├── createNote.ts │ │ ├── deleteNote.ts │ │ ├── getNotes.ts │ │ ├── router.ts │ │ └── updateNote.ts │ └── schools │ │ ├── createSchool.ts │ │ ├── getSchools.ts │ │ ├── getSpecificSchool.ts │ │ └── router.ts ├── types │ └── date.ts └── utils │ ├── date.ts │ ├── isDatatype.ts │ ├── isShorterThan.ts │ ├── jwt.ts │ └── strings.ts ├── tests ├── awaitTrue.test.ts ├── dlool.test.ts └── utils │ ├── adminDatabase.ts │ ├── awaitTrue.ts │ └── startServer.ts └── tsconfig.json /.env.public: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | PORT_DOC=3001 3 | ENVIROMENT=dev 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | .env 4 | .vercel 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm format 5 | pnpm test run 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 4 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dlool - Digital Homework Book 2 | 3 | **This is currently undergoing a complete rewrite, please see [this repository](https://github.com/Dlurak/dlool_backend_v2)** 4 | 5 | Dlool is the digital version of a homework book. 6 | 7 | --- 8 | 9 | Classical homework books have many problems. 10 | A lot of people who use tablets in school do not have notebooks but a homework book is still needed. 11 | Here are some problems they have and how Dlool solves them: 12 | 13 | | Problem | Solution | 14 | | ------------------------------------------ | ------------------------------------------------------------------------------ | 15 | | Everyone in a class writes down the same | Dlool is collaborative, all entries from one class are available in one class. | 16 | | They are not very good for the environment | Dlool is digital, no paper is needed. | 17 | | They are not very practical | Dlool is available on all devices. | 18 | | Entries are not very structured | Dlool has a structured entry system. | 19 | | When you lose your homework book | Dlool is digital, you can't lose it. | 20 | | Every year you need a new homework book | Dlool can be used for multiple years. | 21 | 22 | --- 23 | 24 | ## Installation 25 | 26 | 1. Clone this repository 27 | ```bash 28 | git clone git@github.com:Dlurak/dloolBackend.git 29 | ``` 30 | 2. Install the dependencies 31 | ```bash 32 | npm install 33 | ``` 34 | 3. Create a `.env` file in the root directory of the project 35 | ```bash 36 | touch .env 37 | ``` 38 | 4. Add the following variables to the `.env` file 39 | 40 | 1. `Mongo_URI`=String to the Cluster ``, `` and `` can be replaced automatically, e.g. `mongodb+srv://:@.ljdmejo.mongodb.net/?retryWrites=true&w=majority` 41 | 2. `MONGO_PASSWORD`=Password for the database 42 | 3. `MONGO_USERNAME`=Username for the database 43 | 4. `MONGO_DBNAME`=Name of the database 44 | 5. `JWT_SECRET`=Secret for the JWT, just a random string 45 | 46 | If you want to run tests, the test database needs to be droppable, so you need to add a new user on MongoDB with permission to drop the database. On Atlas it is the highest permission level. You will need to add the following variables to the `.env` file: 47 | 48 | 1. `MONGO_USERNAME_TEST`=Username for the newly created user 49 | 2. `MONGO_PASSWORD_TEST`=Password for the new user 50 | 51 | ## Usage 52 | 53 | 1. Compile the TypeScript code 54 | ```bash 55 | npm run build 56 | ``` 57 | 2. Start the server 58 | ```bash 59 | npm run start 60 | ``` 61 | 3. The server is now running on [localhost port 3000](http://localhost:3000/) 62 | 63 | ### Documentation 64 | 65 | 1. Compile the TypeScript code 66 | ```bash 67 | npm run build 68 | ``` 69 | 2. Create the static documentation HTML files 70 | ```bash 71 | npm run document 72 | ``` 73 | 3. Start the documentation server 74 | ```bash 75 | npm run serve:docs 76 | ``` 77 | 4. The server is now running on [localhost port 3001](http://localhost:3001) 78 | 79 | ## Thanks 80 | 81 | I want to thank [the contributors of that repo](https://github.com/dmfilipenko/timezones.json/blob/master/timezones.json). 82 | -------------------------------------------------------------------------------- /apidoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dlool", 3 | "title": "Dlool", 4 | "url": "https://www.dlool-backend.onrender.com", 5 | "version": "1.0.0", 6 | "desciption": "Dlool is a digital and collaborative homework book", 7 | "author": "Dlurak", 8 | "license": "GPL-2", 9 | 10 | "template": { 11 | "showRequiredLabels": true 12 | } 13 | } -------------------------------------------------------------------------------- /docs/assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/favicon-16x16.png -------------------------------------------------------------------------------- /docs/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/favicon-32x32.png -------------------------------------------------------------------------------- /docs/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/favicon.ico -------------------------------------------------------------------------------- /docs/assets/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /docs/assets/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /docs/assets/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /docs/assets/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dlurak/dloolBackend/f5df42439def5eb0bd0a1e0f7870570c82e82b6f/docs/assets/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /docs/assets/prism-diff-highlight.css: -------------------------------------------------------------------------------- 1 | pre.diff-highlight > code .token.deleted:not(.prefix), 2 | pre > code.diff-highlight .token.deleted:not(.prefix) { 3 | background-color: rgba(255, 0, 0, .1); 4 | color: inherit; 5 | display: block; 6 | } 7 | 8 | pre.diff-highlight > code .token.inserted:not(.prefix), 9 | pre > code.diff-highlight .token.inserted:not(.prefix) { 10 | background-color: rgba(0, 255, 128, .1); 11 | color: inherit; 12 | display: block; 13 | } 14 | -------------------------------------------------------------------------------- /docs/assets/prism-toolbar.css: -------------------------------------------------------------------------------- 1 | div.code-toolbar { 2 | position: relative; 3 | } 4 | 5 | div.code-toolbar > .toolbar { 6 | position: absolute; 7 | z-index: 10; 8 | top: .3em; 9 | right: .2em; 10 | transition: opacity 0.3s ease-in-out; 11 | opacity: 0; 12 | } 13 | 14 | div.code-toolbar:hover > .toolbar { 15 | opacity: 1; 16 | } 17 | 18 | /* Separate line b/c rules are thrown out if selector is invalid. 19 | IE11 and old Edge versions don't support :focus-within. */ 20 | div.code-toolbar:focus-within > .toolbar { 21 | opacity: 1; 22 | } 23 | 24 | div.code-toolbar > .toolbar > .toolbar-item { 25 | display: inline-block; 26 | } 27 | 28 | div.code-toolbar > .toolbar > .toolbar-item > a { 29 | cursor: pointer; 30 | } 31 | 32 | div.code-toolbar > .toolbar > .toolbar-item > button { 33 | background: none; 34 | border: 0; 35 | color: inherit; 36 | font: inherit; 37 | line-height: normal; 38 | overflow: visible; 39 | padding: 0; 40 | -webkit-user-select: none; /* for button */ 41 | -moz-user-select: none; 42 | -ms-user-select: none; 43 | } 44 | 45 | div.code-toolbar > .toolbar > .toolbar-item > a, 46 | div.code-toolbar > .toolbar > .toolbar-item > button, 47 | div.code-toolbar > .toolbar > .toolbar-item > span { 48 | color: #bbb; 49 | font-size: .8em; 50 | padding: 0 .5em; 51 | background: #f5f2f0; 52 | background: rgba(224, 224, 224, 0.2); 53 | box-shadow: 0 2px 0 0 rgba(0,0,0,0.2); 54 | border-radius: .5em; 55 | } 56 | 57 | div.code-toolbar > .toolbar > .toolbar-item > a:hover, 58 | div.code-toolbar > .toolbar > .toolbar-item > a:focus, 59 | div.code-toolbar > .toolbar > .toolbar-item > button:hover, 60 | div.code-toolbar > .toolbar > .toolbar-item > button:focus, 61 | div.code-toolbar > .toolbar > .toolbar-item > span:hover, 62 | div.code-toolbar > .toolbar > .toolbar-item > span:focus { 63 | color: inherit; 64 | text-decoration: none; 65 | } 66 | -------------------------------------------------------------------------------- /docs/assets/prism.css: -------------------------------------------------------------------------------- 1 | /** 2 | * prism.js tomorrow night eighties for JavaScript, CoffeeScript, CSS and HTML 3 | * Based on https://github.com/chriskempson/tomorrow-theme 4 | * @author Rose Pritchard 5 | */ 6 | 7 | code[class*="language-"], 8 | pre[class*="language-"] { 9 | color: #ccc; 10 | background: none; 11 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 12 | font-size: 1em; 13 | text-align: left; 14 | white-space: pre; 15 | word-spacing: normal; 16 | word-break: normal; 17 | word-wrap: normal; 18 | line-height: 1.5; 19 | 20 | -moz-tab-size: 4; 21 | -o-tab-size: 4; 22 | tab-size: 4; 23 | 24 | -webkit-hyphens: none; 25 | -moz-hyphens: none; 26 | -ms-hyphens: none; 27 | hyphens: none; 28 | 29 | } 30 | 31 | /* Code blocks */ 32 | pre[class*="language-"] { 33 | padding: 1em; 34 | margin: .5em 0; 35 | overflow: auto; 36 | } 37 | 38 | :not(pre) > code[class*="language-"], 39 | pre[class*="language-"] { 40 | background: #2d2d2d; 41 | } 42 | 43 | /* Inline code */ 44 | :not(pre) > code[class*="language-"] { 45 | padding: .1em; 46 | border-radius: .3em; 47 | white-space: normal; 48 | } 49 | 50 | .token.comment, 51 | .token.block-comment, 52 | .token.prolog, 53 | .token.doctype, 54 | .token.cdata { 55 | color: #999; 56 | } 57 | 58 | .token.punctuation { 59 | color: #ccc; 60 | } 61 | 62 | .token.tag, 63 | .token.attr-name, 64 | .token.namespace, 65 | .token.deleted { 66 | color: #e2777a; 67 | } 68 | 69 | .token.function-name { 70 | color: #6196cc; 71 | } 72 | 73 | .token.boolean, 74 | .token.number, 75 | .token.function { 76 | color: #f08d49; 77 | } 78 | 79 | .token.property, 80 | .token.class-name, 81 | .token.constant, 82 | .token.symbol { 83 | color: #f8c555; 84 | } 85 | 86 | .token.selector, 87 | .token.important, 88 | .token.atrule, 89 | .token.keyword, 90 | .token.builtin { 91 | color: #cc99cd; 92 | } 93 | 94 | .token.string, 95 | .token.char, 96 | .token.attr-value, 97 | .token.regex, 98 | .token.variable { 99 | color: #7ec699; 100 | } 101 | 102 | .token.operator, 103 | .token.entity, 104 | .token.url { 105 | color: #67cdcc; 106 | } 107 | 108 | .token.important, 109 | .token.bold { 110 | font-weight: bold; 111 | } 112 | .token.italic { 113 | font-style: italic; 114 | } 115 | 116 | .token.entity { 117 | cursor: help; 118 | } 119 | 120 | .token.inserted { 121 | color: green; 122 | } 123 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const jwt = require('./dist/utils/jwt') 2 | 3 | console.log(jwt.generateToken('Anzie')) 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "digital-homework-book-backend", 3 | "version": "0.0.1", 4 | "description": "The backend for a digital and collaborative homework book", 5 | "main": "index.ts", 6 | "scripts": { 7 | "start": "node dist/index.js", 8 | "build": "tsc", 9 | "dev": "nodemon dist/index.js", 10 | "build:watch": "tsc -w", 11 | "format": "prettier --write \"src/**/*.ts\"", 12 | "document": "apidoc -i src/ -o docs/ -c apidoc.json -e node_modules/", 13 | "prebuild": "npm run format && npm run document", 14 | "serve:docs": "node dist/docs.js", 15 | "test": "vitest", 16 | "prepare": "husky install" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/Dlurak/dloolBackend" 21 | }, 22 | "keywords": [ 23 | "digital", 24 | "backend", 25 | "mongodb", 26 | "express", 27 | "homework", 28 | "colloborative", 29 | "decentralized" 30 | ], 31 | "author": "Dlurak", 32 | "license": "GPL-2.0-only", 33 | "dependencies": { 34 | "bcrypt": "^5.1.0", 35 | "cors": "^2.8.5", 36 | "dotenv": "^16.3.1", 37 | "express": "^4.18.2", 38 | "ical-generator": "^5.0.0", 39 | "jsonwebtoken": "^9.0.1", 40 | "mongodb": "^5.8.0", 41 | "todo.txt": "^0.0.2", 42 | "xml-formatter": "^3.5.0", 43 | "zod": "^3.22.4" 44 | }, 45 | "devDependencies": { 46 | "@types/bcrypt": "^5.0.0", 47 | "@types/cors": "^2.8.13", 48 | "@types/express": "^4.17.17", 49 | "@types/jsonwebtoken": "^9.0.2", 50 | "@types/luxon": "^3.3.1", 51 | "@types/node": "^20.4.10", 52 | "apidoc": "^1.1.0", 53 | "dayjs": "^1.11.9", 54 | "moment": "^2.29.4", 55 | "moment-timezone": "^0.5.43", 56 | "nodemon": "^3.0.1", 57 | "prettier": "^3.0.0", 58 | "rrule": "^2.7.2", 59 | "typescript": "^5.1.6", 60 | "vitest": "^0.34.6", 61 | "husky": "^8.0.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/constant/constants.json: -------------------------------------------------------------------------------- 1 | { 2 | "timezones": [ 3 | -12, 4 | -11, 5 | -10, 6 | -8, 7 | -7, 8 | -6, 9 | -5, 10 | -4.5, 11 | -4, 12 | -3, 13 | -2.5, 14 | -2, 15 | -1, 16 | 0, 17 | 1, 18 | 2, 19 | 3, 20 | 4, 21 | 4.5, 22 | 5, 23 | 5.5, 24 | 5.75, 25 | 6, 26 | 6.5, 27 | 7, 28 | 8, 29 | 9, 30 | 9.5, 31 | 10, 32 | 11, 33 | 12, 34 | 13 35 | ] 36 | } -------------------------------------------------------------------------------- /src/constant/constants.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | 3 | export const timezoneOffsets: number[] = JSON.parse( 4 | readFileSync('src/constant/constants.json', 'utf-8'), 5 | ).timezones; 6 | -------------------------------------------------------------------------------- /src/database/classes/class.ts: -------------------------------------------------------------------------------- 1 | import { Collection, ObjectId } from 'mongodb'; 2 | import { db } from '../database'; 3 | 4 | export const classesCollection = db.collection('classes') as Collection; 5 | 6 | classesCollection.createIndex({ name: 1, school: 1 }, { unique: true }); 7 | 8 | export interface Class { 9 | name: string; 10 | school: ObjectId; 11 | members: ObjectId[]; 12 | } 13 | 14 | export interface ClassWithId extends Class { 15 | _id: ObjectId; 16 | } 17 | -------------------------------------------------------------------------------- /src/database/classes/createClass.ts: -------------------------------------------------------------------------------- 1 | import { addClassToSchool } from '../school/update'; 2 | import { Class, classesCollection } from './class'; 3 | 4 | export function createClass(classObj: Class): Promise { 5 | // 1. add the class to the db 6 | 7 | return classesCollection 8 | .insertOne(classObj) 9 | .then((newClass) => { 10 | const newClassId = newClass.insertedId; 11 | const schoolId = classObj.school; 12 | 13 | // 2. add the class to the school 14 | addClassToSchool(schoolId, newClassId); 15 | return true; 16 | }) 17 | .catch(() => { 18 | return false; 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/database/classes/findClass.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { SchoolWithId } from '../school/school'; 3 | import { classesCollection } from './class'; 4 | import { findUniqueSchool, findUniqueSchoolById } from '../school/findSchool'; 5 | 6 | function findClass(school: SchoolWithId, className: string) { 7 | const schoolId = school._id; 8 | 9 | return classesCollection.findOne({ name: className, school: schoolId }); 10 | } 11 | 12 | function getClassesFromSchool(school: SchoolWithId) { 13 | const ids = school.classes; 14 | 15 | const classes = classesCollection.find({ _id: { $in: ids } }).toArray(); 16 | 17 | return classes; 18 | } 19 | 20 | function getUniqueClassById(id: ObjectId) { 21 | return classesCollection.findOne({ _id: id }).then((class_) => { 22 | return class_; 23 | }); 24 | } 25 | 26 | async function findClassBySchoolNameAndClassName( 27 | schoolName: string, 28 | className: string, 29 | ) { 30 | const schoolObj = await findUniqueSchool(schoolName); 31 | if (!schoolObj) { 32 | return null; 33 | } 34 | 35 | const schoolId = schoolObj._id; 36 | return classesCollection.findOne({ name: className, school: schoolId }); 37 | } 38 | 39 | async function getClassAndSchoolNameById(id: ObjectId) { 40 | const classCollection = await classesCollection.findOne({ _id: id }); 41 | 42 | if (!classCollection) { 43 | return null; 44 | } 45 | 46 | const schoolId = classCollection.school; 47 | const school = await findUniqueSchoolById(schoolId); 48 | 49 | if (!school) { 50 | return null; 51 | } 52 | 53 | return { 54 | schoolName: school.name, 55 | className: classCollection.name, 56 | }; 57 | } 58 | 59 | export { 60 | findClass, 61 | getClassesFromSchool, 62 | getUniqueClassById, 63 | findClassBySchoolNameAndClassName, 64 | getClassAndSchoolNameById, 65 | }; 66 | -------------------------------------------------------------------------------- /src/database/classes/update.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { classesCollection } from './class'; 3 | 4 | function addMember(classId: ObjectId, memberId: ObjectId) { 5 | classesCollection.findOneAndUpdate( 6 | { _id: classId }, 7 | { 8 | $push: { members: memberId }, 9 | }, 10 | ); 11 | } 12 | 13 | export { addMember as addMemberToClass }; 14 | -------------------------------------------------------------------------------- /src/database/database.ts: -------------------------------------------------------------------------------- 1 | import { Db } from 'mongodb'; 2 | import * as mongodb from 'mongodb'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config({ path: '.env.public' }); 6 | dotenv.config(); 7 | 8 | /** 9 | * The database connection 10 | */ 11 | const client = new mongodb.MongoClient( 12 | (process.env.MONGO_URI as string) 13 | .replace('', process.env.MONGO_PASSWORD as string) 14 | .replace('', process.env.MONGO_USERNAME || 'root') 15 | .replace('', process.env.MONGO_DBNAME || 'test'), 16 | ); 17 | 18 | export let dbIsConnected = false; 19 | 20 | client 21 | .connect() 22 | .then(() => { 23 | dbIsConnected = true; 24 | console.log('MongoDB connection established'); 25 | }) 26 | .catch((err) => { 27 | console.error(err); 28 | process.exit(1); 29 | }); 30 | 31 | /** 32 | * The right database 33 | */ 34 | export let db = client.db( 35 | { 36 | dev: 'dev', 37 | test: 'test', 38 | prod: 'prod', 39 | }[process.env.ENVIROMENT || 'dev'], 40 | ); 41 | 42 | export const setDb = (newDbName: 'dev' | 'test' | 'prod') => { 43 | db = client.db(newDbName); 44 | }; 45 | 46 | process.on('SIGINT', () => { 47 | client.close().then(() => { 48 | console.log('MongoDB connection closed'); 49 | process.exit(0); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/database/events/createEvent.ts: -------------------------------------------------------------------------------- 1 | import { CalEvent, eventsCollection } from './event'; 2 | 3 | export function createEvent(event: CalEvent) { 4 | event.editedAt = [Date.now()]; 5 | 6 | return eventsCollection.insertOne(event); 7 | } 8 | -------------------------------------------------------------------------------- /src/database/events/event.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { DateTime } from '../../types/date'; 3 | import { db } from '../database'; 4 | 5 | export const eventsCollection = db.collection('events'); 6 | 7 | export interface CalEvent { 8 | title: string; 9 | description: string; 10 | 11 | date: DateTime; 12 | 13 | duration: number; 14 | location: string | null; 15 | subject: string; 16 | 17 | editors: ObjectId[]; 18 | editedAt: number[]; 19 | 20 | class: ObjectId; 21 | } 22 | -------------------------------------------------------------------------------- /src/database/homework/createHomework.ts: -------------------------------------------------------------------------------- 1 | import { WithId } from 'mongodb'; 2 | import { Homework, homeworkCollection } from './homework'; 3 | 4 | export function createHomework(homework: Homework) { 5 | homework.createdAt = Date.now(); 6 | 7 | homeworkCollection 8 | .find({ class: homework.class, from: homework.from }) 9 | .toArray() 10 | .then((list) => { 11 | if (list.length !== 0) { 12 | return null; 13 | } 14 | }); 15 | 16 | return homeworkCollection 17 | .insertOne(homework) 18 | .then((value) => { 19 | return { ...homework, _id: value.insertedId } as WithId; 20 | }) 21 | .catch(() => { 22 | return null; 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/database/homework/deleteHomework.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { homeworkCollection } from './homework'; 3 | 4 | export async function deleteHomework(id: ObjectId) { 5 | return (await homeworkCollection.findOneAndDelete({ _id: id })).value; 6 | } 7 | -------------------------------------------------------------------------------- /src/database/homework/findHomework.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { homeworkCollection } from './homework'; 3 | import findUsername from '../user/findUser'; 4 | import { classesCollection } from '../classes/class'; 5 | import { findUniqueSchool } from '../school/findSchool'; 6 | 7 | export function getHomeworkByClass(classId: ObjectId) { 8 | return homeworkCollection.find({ class: classId }).toArray(); 9 | } 10 | 11 | function getHomeworkByCreator(creatorId: ObjectId) { 12 | return homeworkCollection.find({ creator: creatorId }).toArray(); 13 | } 14 | 15 | export async function getNewestHomeworkFromClass(classId: ObjectId) { 16 | const objects = homeworkCollection 17 | .aggregate([ 18 | { 19 | $addFields: { 20 | date: { 21 | $dateFromParts: { 22 | year: '$from.year', 23 | month: '$from.month', 24 | day: '$from.day', 25 | }, 26 | }, 27 | }, 28 | }, 29 | { 30 | $match: { 31 | class: classId, 32 | }, 33 | }, 34 | { 35 | $sort: { 36 | date: -1, 37 | }, 38 | }, 39 | ]) 40 | .toArray(); 41 | 42 | return objects.then((value) => { 43 | if (value.length === 0) { 44 | return null; 45 | } else { 46 | return value[0]; 47 | } 48 | }); 49 | } 50 | 51 | export async function getHomeworkForUser(username: string) { 52 | const user = await findUsername(username); 53 | 54 | if (!user) { 55 | return null; 56 | } 57 | 58 | const classes = user.classes; 59 | 60 | return homeworkCollection.find({ class: { $in: classes } }).toArray(); 61 | } 62 | 63 | export async function getHomeworkForMultipleClasses(classes: ObjectId[]) { 64 | return homeworkCollection.find({ class: { $in: classes } }).toArray(); 65 | } 66 | -------------------------------------------------------------------------------- /src/database/homework/getFrontendUrlForHomework.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId, Sort } from 'mongodb'; 2 | import { getUniqueClassById } from '../classes/findClass'; 3 | import { homeworkCollection } from './homework'; 4 | import { findUniqueSchoolById } from '../school/findSchool'; 5 | 6 | export const getFrontendUrlForHomework = async (homeworkId: ObjectId) => { 7 | const id = homeworkId; 8 | const homeworkObj = await homeworkCollection.findOne({ _id: id }); 9 | const classId = homeworkObj?.class; 10 | if (!classId) return null; 11 | const classObj = await getUniqueClassById(classId); 12 | const className = classObj?.name; 13 | const schoolId = classObj?.school; 14 | if (!schoolId) return null; 15 | const schoolObj = await findUniqueSchoolById(schoolId); 16 | const school = schoolObj?.name; 17 | const pageSize = 15; 18 | const filter = { class: classId }; 19 | const sort: Sort = { 20 | 'from.year': -1, 21 | 'from.month': -1, 22 | 'from.day': -1, 23 | }; 24 | let pageNumber: number | null = null; 25 | const cursor = homeworkCollection.find(filter).sort(sort); 26 | const totalDocs = await homeworkCollection.countDocuments(filter); 27 | const totalPages = Math.ceil(totalDocs / pageSize); 28 | for (let page = 1; page <= totalPages; page++) { 29 | const docsOfPage = await homeworkCollection 30 | .find(filter) 31 | .sort(sort) 32 | .skip((page - 1) * pageSize) 33 | .limit(pageSize) 34 | .toArray(); 35 | 36 | const idsOfPage = docsOfPage.map((doc) => doc._id.toString()); 37 | 38 | if (idsOfPage.includes(id.toString())) { 39 | pageNumber = page; 40 | break; 41 | } 42 | } 43 | 44 | return `https://dlool-frontend.vercel.app/homework?page=${pageNumber}&school=${school}&class=${className}#${id}`; 45 | }; 46 | -------------------------------------------------------------------------------- /src/database/homework/homework.ts: -------------------------------------------------------------------------------- 1 | import { Collection, ObjectId } from 'mongodb'; 2 | import { db } from '../database'; 3 | 4 | export const homeworkCollection = db.collection( 5 | 'homework', 6 | ) as Collection; 7 | 8 | homeworkCollection.createIndex({ class: 1, from: 1 }, { unique: true }); 9 | 10 | export interface Homework { 11 | creator: ObjectId; 12 | contributors: ObjectId[]; 13 | class: ObjectId; 14 | 15 | createdAt: number; // timestamp 16 | 17 | from: { 18 | year: number; 19 | month: number; 20 | day: number; 21 | }; 22 | 23 | assignments: { 24 | subject: string; 25 | description: string; 26 | due: { 27 | year: number; 28 | month: number; 29 | day: number; 30 | }; 31 | }[]; 32 | } 33 | -------------------------------------------------------------------------------- /src/database/homework/updateHomework.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { Homework, homeworkCollection } from './homework'; 3 | 4 | export async function updateHomework(id: ObjectId, options: Homework) { 5 | let newHomework = options; 6 | 7 | await homeworkCollection.findOneAndReplace({ _id: id }, newHomework); 8 | return newHomework; 9 | } 10 | -------------------------------------------------------------------------------- /src/database/notes/createNote.ts: -------------------------------------------------------------------------------- 1 | import { WithId } from 'mongodb'; 2 | import { Note, noteCollection } from './notes'; 3 | 4 | export function createNote(note: Note) { 5 | note.createdAt = Date.now(); 6 | 7 | return noteCollection 8 | .insertOne(note) 9 | .then((value) => { 10 | return { ...note, _id: value.insertedId } as WithId; 11 | }) 12 | .catch(() => null); 13 | } 14 | -------------------------------------------------------------------------------- /src/database/notes/notes.ts: -------------------------------------------------------------------------------- 1 | import { Collection, ObjectId } from 'mongodb'; 2 | import { db } from '../database'; 3 | 4 | export const noteCollection = db.collection('notes') as Collection; 5 | 6 | export type Visibility = 'public' | 'private'; 7 | export interface Note { 8 | creator: ObjectId; 9 | 10 | createdAt: number; // timestamp 11 | 12 | title: string; 13 | content: string; 14 | due: { 15 | year: number; 16 | month: number; 17 | day: number; 18 | }; 19 | 20 | visibility: Visibility; 21 | class?: ObjectId | null; 22 | } 23 | -------------------------------------------------------------------------------- /src/database/requests/addToClassRequests.ts: -------------------------------------------------------------------------------- 1 | import { Collection, ObjectId } from 'mongodb'; 2 | import { db } from '../database'; 3 | 4 | export const addToClassRequestsCollection = db.collection( 5 | 'addToClassRequests', 6 | ) as Collection; 7 | 8 | export type AddToClassRequestStatus = 'pending' | 'accepted' | 'rejected'; 9 | 10 | export interface AddToClassRequest { 11 | userDetails: { 12 | name: string; 13 | username: string; 14 | createdAt: number; 15 | school: ObjectId; 16 | password: string; 17 | 18 | email: null | string; 19 | 20 | acceptedClasses: ObjectId[]; 21 | }; 22 | classId: ObjectId; 23 | createdAt: number; 24 | status: AddToClassRequestStatus; 25 | processedBy: ObjectId | null; 26 | } 27 | -------------------------------------------------------------------------------- /src/database/requests/changeAddToClassRequestStatus.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { findSpecificRequestById } from './findAddToClassRequests'; 3 | import { createUser } from '../user/createUser'; 4 | import findUsername from '../user/findUser'; 5 | import { addMemberToClass } from '../classes/update'; 6 | import { addToClassRequestsCollection } from './addToClassRequests'; 7 | 8 | export async function acceptRequest(id: ObjectId, processedBy: ObjectId) { 9 | const request = await findSpecificRequestById(id); 10 | 11 | if (!request) return false; 12 | if (request.status !== 'pending') return false; 13 | 14 | await createUser( 15 | { 16 | name: request.userDetails.name, 17 | username: request.userDetails.username, 18 | password: request.userDetails.password, 19 | school: request.userDetails.school, 20 | classes: [request.classId], 21 | 22 | email: request.userDetails.email, 23 | }, 24 | true, 25 | ); 26 | const newUser = await findUsername(request.userDetails.username); 27 | 28 | if (!newUser) return false; 29 | 30 | addMemberToClass(request.classId, newUser._id); 31 | 32 | // update the request 33 | 34 | await addToClassRequestsCollection.findOneAndUpdate( 35 | { _id: id }, 36 | { $set: { status: 'accepted', processedBy } }, 37 | ); 38 | 39 | return true; 40 | } 41 | 42 | export async function rejectRequest( 43 | id: ObjectId, 44 | processedBy: ObjectId, 45 | ): Promise { 46 | const thing = await addToClassRequestsCollection.findOneAndUpdate( 47 | { _id: id }, 48 | { $set: { status: 'rejected', processedBy } }, 49 | ); 50 | 51 | if (!thing.value) return false; 52 | return true; 53 | } 54 | -------------------------------------------------------------------------------- /src/database/requests/createAddToClassRequest.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId, WithId } from 'mongodb'; 2 | import findUsername from '../user/findUser'; 3 | import { 4 | AddToClassRequest, 5 | addToClassRequestsCollection, 6 | } from './addToClassRequests'; 7 | import { hashSync } from 'bcrypt'; 8 | 9 | export async function createAddToClassRequest(request: AddToClassRequest) { 10 | const username = request.userDetails.username; 11 | 12 | addToClassRequestsCollection 13 | .find({ 'userDetails.username': username }) 14 | .toArray() 15 | .then((list) => { 16 | if (list.length !== 0) { 17 | return null; 18 | } 19 | }); 20 | 21 | const userReal = await findUsername(username); 22 | if (userReal) return null; 23 | 24 | request.createdAt = Date.now(); 25 | request.status = 'pending'; 26 | request.processedBy = null; 27 | const clearPassword = request.userDetails.password; 28 | const hashedPassword = hashSync(clearPassword, 10); 29 | request.userDetails.password = hashedPassword; 30 | 31 | return addToClassRequestsCollection 32 | .insertOne(request) 33 | .then((value) => { 34 | return { 35 | ...request, 36 | _id: value.insertedId, 37 | } as WithId; 38 | }) 39 | .catch(() => { 40 | return null; 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/database/requests/findAddToClassRequests.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { 3 | AddToClassRequestStatus, 4 | addToClassRequestsCollection, 5 | } from './addToClassRequests'; 6 | 7 | export function findSpecificRequestById(id: ObjectId) { 8 | const document = addToClassRequestsCollection.findOne({ _id: id }); 9 | 10 | return document; 11 | } 12 | 13 | export function findRequestsByClassId( 14 | classId: ObjectId, 15 | status?: AddToClassRequestStatus, 16 | ) { 17 | const filter = status ? { classId, status } : { classId }; 18 | const documents = addToClassRequestsCollection.find(filter).toArray(); 19 | 20 | return documents; 21 | } 22 | 23 | export const doesRequestExist = (username: string): Promise => { 24 | const doc = addToClassRequestsCollection.findOne({ 25 | 'userDetails.username': username, 26 | }); 27 | return doc.then((d) => !!d); 28 | }; 29 | -------------------------------------------------------------------------------- /src/database/school/createSchool.ts: -------------------------------------------------------------------------------- 1 | import { School, schoolsCollection } from './school'; 2 | 3 | /** 4 | * Creates a school in the database 5 | * @param school The school to create 6 | * @returns A promise that resolves to true if the school was created successfully, false otherwise 7 | */ 8 | export function createSchool(school: School) { 9 | return schoolsCollection 10 | .insertOne(school) 11 | .then(() => { 12 | return true; 13 | }) 14 | .catch(() => { 15 | return false; 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/database/school/findSchool.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { SchoolWithId, schoolsCollection } from './school'; 3 | 4 | /** 5 | * Finds one specific school with the given unique name 6 | * @param uniqueName The unique name of the school to find 7 | * @returns Either the school with the given unique name or null if no school was found 8 | */ 9 | function findUniqueSchool(uniqueName: string) { 10 | type Return = T extends true ? SchoolWithId : SchoolWithId | null; 11 | return schoolsCollection.findOne({ uniqueName }).then((school) => { 12 | return school as Return; 13 | }); 14 | } 15 | 16 | function findUniqueSchoolById(id: ObjectId) { 17 | return schoolsCollection.findOne({ _id: id }).then((school) => { 18 | return school as SchoolWithId | null; 19 | }); 20 | } 21 | 22 | /** 23 | * 24 | * @param name The name of the school to find, case sensitive and must match exactly, I plan to support RegEx in the future 25 | * @param timezoneOffsetHours The timezone offset of the school to find in hours 26 | * @returns An array of schools with the given name and timezone offset 27 | */ 28 | function findSchools(name: string, timezoneOffsetHours: number) { 29 | return schoolsCollection 30 | .find({ name, timezoneOffset: timezoneOffsetHours }) 31 | .toArray() 32 | .then((schools) => { 33 | return schools as SchoolWithId[]; 34 | }); 35 | } 36 | 37 | const doesSchoolExist = (uniqueName: string): Promise => 38 | schoolsCollection.findOne({ uniqueName }).then((sch) => !!sch); 39 | 40 | export { findUniqueSchool, findSchools, findUniqueSchoolById, doesSchoolExist }; 41 | -------------------------------------------------------------------------------- /src/database/school/school.ts: -------------------------------------------------------------------------------- 1 | import { Collection, ObjectId } from 'mongodb'; 2 | import { db } from '../database'; 3 | 4 | export const schoolsCollection = db.collection('schools') as Collection; 5 | 6 | schoolsCollection.createIndex({ name: 1 }); 7 | schoolsCollection.createIndex({ uniqueName: 1 }, { unique: true }); 8 | schoolsCollection.createIndex({ timezoneOffset: 1 }); 9 | schoolsCollection.createIndex({ 10 | name: 'text', 11 | description: 'text', 12 | uniqueName: 'text', 13 | }); 14 | 15 | export interface School { 16 | name: string; 17 | description: string; 18 | uniqueName: string; 19 | timezoneOffset: number; 20 | classes: ObjectId[]; 21 | } 22 | 23 | export interface SchoolWithId extends School { 24 | _id: ObjectId; 25 | } 26 | -------------------------------------------------------------------------------- /src/database/school/update.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { schoolsCollection } from './school'; 3 | 4 | function addClass(schoolId: ObjectId, classId: ObjectId) { 5 | schoolsCollection.findOneAndUpdate( 6 | { _id: schoolId }, 7 | { 8 | $push: { classes: classId }, 9 | }, 10 | ); 11 | } 12 | 13 | export { addClass as addClassToSchool }; 14 | -------------------------------------------------------------------------------- /src/database/user/changeData.ts: -------------------------------------------------------------------------------- 1 | import { hashSync } from 'bcrypt'; 2 | import { ObjectId } from 'mongodb'; 3 | import { usersCollection } from './user'; 4 | 5 | export async function changeData( 6 | id: ObjectId, 7 | options: { 8 | username?: string; 9 | name?: string; 10 | password?: string; 11 | }, 12 | ) { 13 | const update: { $set?: any } = {}; 14 | 15 | if (options.username) { 16 | // check that username is not taken 17 | const user = await usersCollection.findOne({ 18 | username: options.username, 19 | }); 20 | if (user) return; 21 | 22 | update.$set = { ...update.$set, username: options.username }; 23 | } 24 | if (options.name) update.$set = { ...update.$set, name: options.name }; 25 | if (options.password) { 26 | update.$set = { 27 | ...update.$set, 28 | password: hashSync(options.password, 10), 29 | }; 30 | } 31 | 32 | usersCollection.findOneAndUpdate({ _id: id }, update); 33 | } 34 | -------------------------------------------------------------------------------- /src/database/user/checkPassword.ts: -------------------------------------------------------------------------------- 1 | import findUsername from './findUser'; 2 | import { compare } from 'bcrypt'; 3 | 4 | function checkUsernamePassword(username: string, passwordClear: string) { 5 | return findUsername(username).then((user) => { 6 | if (user) { 7 | // use bcrypt to compare passwordClear with user.password 8 | return compare(passwordClear, user.password); 9 | } else { 10 | // User does not exist 11 | // TODO: wait a few milliseconds to prevent timing attacks 12 | return false; 13 | } 14 | }); 15 | } 16 | 17 | export default checkUsernamePassword; 18 | -------------------------------------------------------------------------------- /src/database/user/createUser.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcrypt'; 2 | import { User, usersCollection } from './user'; 3 | import { findUniqueSchoolById } from '../school/findSchool'; 4 | import { getUniqueClassById } from '../classes/findClass'; 5 | 6 | /** 7 | * This function creates a user in the database and hashes the password 8 | * @param user The user to create the password should be in clear text 9 | * @param hashedPassword Whether the password is already hashed 10 | */ 11 | export function createUser(user: User, hashedPassword = false) { 12 | // check that a school with the given id exists 13 | findUniqueSchoolById(user.school).then((school) => { 14 | if (school === null) { 15 | throw new Error('School does not exist'); 16 | } 17 | }); 18 | if (user.classes.length <= 0) { 19 | throw new Error('User must be in at least one class'); 20 | } 21 | user.classes.forEach((classId) => { 22 | getUniqueClassById(classId).then((class_) => { 23 | if (class_ === null) { 24 | throw new Error('Class does not exist'); 25 | } 26 | }); 27 | }); 28 | 29 | const passwordClear = user.password; 30 | const passwordHash = hashedPassword 31 | ? user.password 32 | : bcrypt.hashSync(passwordClear, 10); 33 | 34 | user.password = passwordHash; 35 | 36 | return usersCollection 37 | .insertOne(user) 38 | .then(() => { 39 | return true; 40 | }) 41 | .catch(() => { 42 | return false; 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/database/user/deleteUser.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { usersCollection } from './user'; 3 | import { Class, classesCollection } from '../classes/class'; 4 | 5 | export function deleteUser(id: ObjectId) { 6 | usersCollection.findOne({ _id: id }).then((user) => { 7 | if (!user) return; 8 | user.classes.forEach((c: Class) => { 9 | classesCollection.findOneAndUpdate( 10 | { _id: c }, 11 | { 12 | $pull: { members: id }, 13 | }, 14 | ); 15 | }); 16 | }); 17 | 18 | usersCollection.findOneAndDelete({ _id: id }); 19 | } 20 | -------------------------------------------------------------------------------- /src/database/user/doesUserExist.ts: -------------------------------------------------------------------------------- 1 | import { classesCollection } from '../classes/class'; 2 | import { usersCollection } from './user'; 3 | 4 | /** 5 | * A function to check if a user exists 6 | * @param options The search options to find a user 7 | * @returns A boolean indicating whether the user exists 8 | */ 9 | async function doesUsernameExist(username: string): Promise { 10 | const exists = usersCollection.findOne({ username }).then((user) => { 11 | return user !== null; 12 | }); 13 | 14 | return exists; 15 | } 16 | 17 | export async function isUserMemberOfClass( 18 | username: string, 19 | className: string, 20 | ): Promise { 21 | const userIdPromise = usersCollection.findOne({ username }).then((user) => { 22 | return user?._id; 23 | }); 24 | const classMembersPromise = classesCollection 25 | .findOne({ name: className }) 26 | .then((classObj) => { 27 | return classObj?.members; 28 | }); 29 | 30 | const userId = await userIdPromise; 31 | const classMembers = await classMembersPromise; 32 | 33 | if (!userId || !classMembers) { 34 | return false; 35 | } 36 | 37 | const classmemberStrings = classMembers.map((member) => member.toString()); 38 | const userIsMember = classmemberStrings.includes(userId.toString()); 39 | return userIsMember; 40 | } 41 | 42 | export default doesUsernameExist; 43 | -------------------------------------------------------------------------------- /src/database/user/findUser.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { UserWithId, usersCollection } from './user'; 3 | 4 | function findUsername(username: string) { 5 | return usersCollection.findOne({ username: username }).then((user) => { 6 | return user as UserWithId | null; 7 | }); 8 | } 9 | 10 | export function findUserById(id: ObjectId) { 11 | return usersCollection.findOne({ _id: id }).then((user) => { 12 | return user as UserWithId | null; 13 | }); 14 | } 15 | 16 | export default findUsername; 17 | -------------------------------------------------------------------------------- /src/database/user/user.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { db } from '../database'; 3 | 4 | export const usersCollection = db.collection('users'); 5 | 6 | usersCollection.createIndex({ username: 1 }, { unique: true }); 7 | usersCollection.createIndex({ name: 1, school: 1, class: 1 }); 8 | usersCollection.createIndex({ password: 1 }); 9 | usersCollection.createIndex({ email: 1 }); 10 | 11 | export interface User { 12 | username: string; 13 | name: string; 14 | school: ObjectId; 15 | classes: ObjectId[]; 16 | password: string; 17 | 18 | email: null | string; 19 | } 20 | 21 | export interface UserWithId extends User { 22 | _id: ObjectId; 23 | } 24 | -------------------------------------------------------------------------------- /src/database/utils/getPaginatedData.ts: -------------------------------------------------------------------------------- 1 | import { Collection, Sort, Document } from 'mongodb'; 2 | 3 | /** 4 | * A function to get paginated data from a collection 5 | * @param collection The collection to get the data from 6 | * @param pageNumber Which page to get 7 | * @param pageSize How many items per page 8 | * @param sortKey The optional key to sort by, defaults to _id 9 | * @param sortOrder The optional sort order, defaults to 1 10 | * @param filter The optional filter to apply to the query 11 | * @returns A list of items from the collection 12 | */ 13 | export function getPaginatedData( 14 | collection: Collection, 15 | pageNumber: number, 16 | pageSize: number, 17 | sort?: Sort, 18 | filter?: any, 19 | ) { 20 | if (pageNumber < 1) throw new Error('Page number must be greater than 0'); 21 | if (pageSize < 1) throw new Error('Page size must be greater than 0'); 22 | 23 | const skip = (pageNumber - 1) * pageSize; 24 | 25 | return collection 26 | .find(filter || {}) 27 | .sort(sort || { _id: 1 }) 28 | .skip(skip) 29 | .limit(pageSize) 30 | .toArray(); 31 | } 32 | 33 | export function getPaginationPageCount( 34 | collection: Collection, 35 | pageSize: number, 36 | filter?: any, 37 | ) { 38 | return collection.countDocuments(filter || {}).then((count) => { 39 | return Math.ceil(count / pageSize); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/docs.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import dotenv from 'dotenv'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | 6 | dotenv.config({ path: '.env.public' }); 7 | 8 | const app = express(); 9 | 10 | app.use(express.static(path.join(__dirname, '../docs'))); 11 | 12 | app.use((req, res, next) => { 13 | res.status(404).send('Sorry cant find that!'); 14 | }); 15 | 16 | const port = process.env.PORT_DOC || 3001; 17 | 18 | app.listen(port, () => { 19 | console.log(`Documentation server listening at http://localhost:${port}`); 20 | }); 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import express, { NextFunction, Request, Response } from 'express'; 2 | import dotenv from 'dotenv'; 3 | import cors from 'cors'; 4 | 5 | import userRouter from './routes/auth/router'; 6 | import schoolRouter from './routes/schools/router'; 7 | import classRouter from './routes/classes/router'; 8 | import homeworkRouter from './routes/homework/router'; 9 | import notesRouter from './routes/notes/router'; 10 | import eventRouter from './routes/events/router'; 11 | 12 | dotenv.config({ path: '.env.public' }); 13 | 14 | const app = express(); 15 | 16 | app.use(express.json()); 17 | app.use(cors()); 18 | 19 | app.use((err: Error, req: Request, res: Response, next: NextFunction) => { 20 | if (err instanceof SyntaxError && 'body' in err) { 21 | return res.status(400).json({ 22 | status: 'error', 23 | message: 'Invalid JSON', 24 | }); 25 | } else { 26 | next(); 27 | } 28 | }); 29 | 30 | const port = +(process.env.PORT as string) || 3000; 31 | 32 | export let serverIsRunning = false; 33 | export const server = app.listen(port, '0.0.0.0', () => { 34 | serverIsRunning = true; 35 | console.log(`Server is running on port ${process.env.PORT}`); 36 | }); 37 | 38 | /** 39 | * @api {get} / Get API info 40 | * @apiName GetAPIInfo 41 | * @apiGroup General 42 | * @apiDescription This endpoint is used to get information about the API, the TS-SDK uses this endpoint to check that the url is a Dlool API. 43 | * 44 | * @apiSuccess (200) {String} name Name of the API, when you want to deploy a own instance this is where you can name your deployment. 45 | * @apiSuccess (200) {Boolean} isDlool This value will always be true, it's just a way to check that you are using a Dlool API. 46 | * 47 | * @apiSuccessExample {json} Official Response: 48 | * HTTP/1.1 200 Success 49 | * { 50 | * "name": "Dlool", 51 | * "isDlool": true 52 | * } 53 | */ 54 | app.all('/', (req, res) => { 55 | res.status(200).json({ 56 | name: 'Dlool', 57 | isDlool: true, 58 | }); 59 | }); 60 | 61 | app.use('/auth', userRouter); 62 | app.use('/schools?', schoolRouter); 63 | app.use('/class(es)?', classRouter); 64 | app.use('/homework', homeworkRouter); 65 | app.use('/notes?', notesRouter); 66 | app.use('/events?', eventRouter); 67 | -------------------------------------------------------------------------------- /src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import jwt from 'jsonwebtoken'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config(); 6 | 7 | const authenticate = (req: Request, res: Response, next: NextFunction) => { 8 | const authHeader = req.headers.authorization; 9 | 10 | if (!authHeader) { 11 | res.status(401).json({ 12 | status: 'error', 13 | error: 'Missing authorization header', 14 | }); 15 | return; 16 | } 17 | 18 | const token = authHeader.split(' ')[1]; 19 | 20 | jwt.verify(token, process.env.JWT_SECRET as string, (err, decoded) => { 21 | if (err) { 22 | res.status(401).json({ 23 | status: 'error', 24 | error: 'Invalid token', 25 | }); 26 | return; 27 | } 28 | 29 | res.locals.jwtPayload = decoded; 30 | next(); 31 | }); 32 | }; 33 | 34 | /** 35 | * A middleware that optionally authenticates a user using JWT 36 | * The JWT payload is stored in res.locals.jwtPayload 37 | * The authentication status is stored in the boolean res.locals.authenticated 38 | */ 39 | const authenticateOptional = ( 40 | req: Request, 41 | res: Response, 42 | next: NextFunction, 43 | ) => { 44 | const authHeader = req.headers.authorization; 45 | 46 | if (!authHeader) { 47 | res.locals.authenticated = false; 48 | next(); 49 | return; 50 | } 51 | 52 | const token = authHeader.split(' ')[1]; 53 | 54 | jwt.verify(token, process.env.JWT_SECRET as string, (err, decoded) => { 55 | if (err) { 56 | res.locals.authenticated = false; 57 | next(); 58 | return; 59 | } 60 | 61 | res.locals.jwtPayload = decoded; 62 | res.locals.authenticated = true; 63 | next(); 64 | }); 65 | }; 66 | 67 | export default authenticate; 68 | export { authenticateOptional }; 69 | 70 | /** 71 | * @apiDefine jwtAuth User A route that requires authentication using JWT 72 | * @apiHeader {String} authehorization A JSON-Web-Token a Bearer should stand in front of it 73 | * @apiHeaderExample {json} Request-Example: 74 | * { 75 | * "authorization": Bearer xxxxx.yyyyy.zzzzz 76 | * } 77 | * 78 | * @apiError (401) {String} status The status of the request 79 | * @apiError (401) {String} error A short explaination of the error 80 | * 81 | * @apiErrorExample {json} 401 - Missing authorization header: 82 | * HTTP/1.1 401 Unauthorized 83 | * { 84 | * "status": "error", 85 | * "error": "Missing authorization header" 86 | * } 87 | * @apiErrorExample {json} 401 - Invalid token: 88 | * HTTP/1.1 401 Unauthorized 89 | * { 90 | * "status": "error", 91 | * "error": "Invalid token" 92 | * } 93 | * 94 | * @apiPermission User 95 | */ 96 | 97 | /** 98 | * @apiDefine jwtAuthOptional User A route that optionally requires authentication using JWT 99 | * 100 | * @apiHeader {String} [authehorization] A JSON-Web-Token, prefixed with Bearer 101 | */ 102 | -------------------------------------------------------------------------------- /src/middleware/isFormat.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | 3 | export const jsonAccepter = ( 4 | req: Request, 5 | res: Response, 6 | next: NextFunction, 7 | ) => { 8 | console.log('JSON'); 9 | 10 | return !!req.accepts('application/json') ? next() : next('route'); 11 | }; 12 | 13 | export const htmlAccepter = ( 14 | req: Request, 15 | res: Response, 16 | next: NextFunction, 17 | ) => { 18 | return !!req.accepts('text/html') ? next() : next('route'); 19 | }; 20 | 21 | export const icalAccepter = ( 22 | req: Request, 23 | res: Response, 24 | next: NextFunction, 25 | ) => { 26 | console.log('ICAL'); 27 | 28 | return !!req.accepts('text/calendar') ? next() : null; 29 | }; 30 | -------------------------------------------------------------------------------- /src/middleware/pagination.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | /** 4 | * A middleware that checks if the request has pagination query parameters. 5 | * If it does, it will add the pagination object to res.locals.pagination. 6 | * If it does not, it will return a 400 error. 7 | * This middleware should be documented using apiUse pagination 8 | */ 9 | const pagination = (req: Request, res: Response, next: NextFunction) => { 10 | const requiredQueryParams = ['page', 'pageSize']; 11 | 12 | for (const param of requiredQueryParams) { 13 | if (!req.query[param]) { 14 | res.status(400).json({ 15 | status: 'error', 16 | message: `Missing query parameter ${param}`, 17 | }); 18 | return; 19 | } 20 | } 21 | 22 | const pageNumber = Number(req.query.page); 23 | const pageSize = Number(req.query.pageSize); 24 | 25 | if (pageNumber < 1) { 26 | return res.status(400).json({ 27 | status: 'error', 28 | message: 'Page number must be greater than 0', 29 | }); 30 | } else if (pageSize < 1) { 31 | return res.status(400).json({ 32 | status: 'error', 33 | message: 'Page size must be greater than 0', 34 | }); 35 | } else if (isNaN(pageNumber)) { 36 | return res.status(400).json({ 37 | status: 'error', 38 | message: 'Page number must be a number', 39 | }); 40 | } else if (isNaN(pageSize)) { 41 | return res.status(400).json({ 42 | status: 'error', 43 | message: 'Page size must be a number', 44 | }); 45 | } 46 | 47 | res.locals.pagination = { 48 | page: pageNumber, 49 | pageSize, 50 | }; 51 | 52 | next(); 53 | }; 54 | 55 | /** 56 | * @apiDefine pagination Pagination A route that requires pagination 57 | * @apiQuery {Number} page The page number 58 | * @apiQuery {Number} pageSize The number of items per page 59 | * 60 | * @apiError (400) {String="error"} status The status of the request (error) 61 | * @apiError (400) {String="Missing query parameter pageSize" "Missing query parameter pageNumber" "Page number must be greater than 0" "Page size must be greater than 0" "Page number must be a number" "Page size must be a number"} message A short explaination of the error 62 | */ 63 | 64 | export default pagination; 65 | -------------------------------------------------------------------------------- /src/migrations/addContributors.ts: -------------------------------------------------------------------------------- 1 | import { homeworkCollection } from '../database/homework/homework'; 2 | 3 | (async () => { 4 | const allHomework = await homeworkCollection.find().toArray(); 5 | 6 | for (const hw of allHomework) { 7 | homeworkCollection.updateOne( 8 | { _id: hw._id }, 9 | { $set: { contributors: [hw.creator] } }, 10 | ); 11 | } 12 | })(); 13 | -------------------------------------------------------------------------------- /src/migrations/addEMailToUser.ts: -------------------------------------------------------------------------------- 1 | import { usersCollection } from '../database/user/user'; 2 | 3 | usersCollection.updateMany({}, { $set: { email: null } }); 4 | usersCollection.createIndex({ email: 1 }); 5 | -------------------------------------------------------------------------------- /src/routes/auth/login.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { generateToken } from '../../utils/jwt'; 3 | import checkUsernamePassword from '../../database/user/checkPassword'; 4 | import { z } from 'zod'; 5 | 6 | const router = express.Router(); 7 | 8 | const bodySchema = z.object({ 9 | username: z 10 | .string({ 11 | required_error: 'Missing username in request body', 12 | invalid_type_error: 'Username must be of type string', 13 | }) 14 | .min(1, 'Missing username in request body'), 15 | password: z.string({ 16 | required_error: 'Missing password in request body', 17 | invalid_type_error: 'Password must be of type string', 18 | }), 19 | }); 20 | 21 | /** 22 | * @api {post} /auth/login Login 23 | * @apiName Login 24 | * @apiGroup Auth 25 | * @apiVersion 1.0.0 26 | * @apiDescription Retrieve a JWT token to use for authentication 27 | * 28 | * @apiBody {String} username The username of the user 29 | * @apiBody {String} password The password of the user 30 | * 31 | * @apiSuccess (200) {String} status Status of the request (success). 32 | * @apiSuccess (200) {String} message Message of the request (Login successful). 33 | * @apiSuccess (200) {String} token The JWT token to use for authentication, it will expire after 1 hour. 34 | * @apiSuccessExample {json} Success-Response: 35 | * HTTP/1.1 200 OK 36 | * { 37 | * "status": "success", 38 | * "message": "Login successful", 39 | * } 40 | * 41 | * @apiExample {curl} Curl example: 42 | * curl -X POST -H "Content-Type: application/json" -d '{"username": "test", "password": "test"}' http://localhost:3000/auth/login 43 | * 44 | * @apiPermission none 45 | * 46 | * @apiError (400) {String} status Status of the request (error). 47 | * @apiError (400) {String} error Error message. 48 | * 49 | * @apiError (401) {String} status Status of the request (error). 50 | * @apiError (401) {String} error Error message. 51 | * 52 | * @apiErrorExample {json} 400: 53 | * HTTP/1.1 400 Bad Request 54 | * { 55 | * "status": "error", 56 | * "error": "Missing username in request body" 57 | * } 58 | * 59 | * @apiErrorExample {json} 401: 60 | * HTTP/1.1 401 Unauthorized 61 | * { 62 | * "status": "error", 63 | * "error": "Incorrect username or password" 64 | * } 65 | */ 66 | router.post('/', async (req, res) => { 67 | const rawBody = req.body; 68 | 69 | const parsedBody = bodySchema.safeParse(rawBody); 70 | if (!parsedBody.success) { 71 | const errors = Object.values( 72 | parsedBody.error.flatten().fieldErrors, 73 | ).flat(); 74 | 75 | return res.status(400).json({ 76 | status: 'error', 77 | message: errors[0], 78 | errors, 79 | }); 80 | } 81 | 82 | const body = parsedBody.data; 83 | 84 | const correct = await checkUsernamePassword(body.username, body.password); 85 | 86 | if (correct) { 87 | const token = generateToken(body.username); 88 | res.status(200).json({ 89 | status: 'success', 90 | message: 'Login successful', 91 | token: token, 92 | }); 93 | return; 94 | } else { 95 | res.status(401).json({ 96 | status: 'error', 97 | message: 'Incorrect username or password', 98 | }); 99 | return; 100 | } 101 | }); 102 | 103 | router.all('/', (req, res) => { 104 | res.status(405).json({ 105 | status: 'error', 106 | message: 'Method not allowed', 107 | }); 108 | }); 109 | 110 | export default router; 111 | -------------------------------------------------------------------------------- /src/routes/auth/me/changeData.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import authenticate from '../../../middleware/auth'; 3 | import { changeData } from '../../../database/user/changeData'; 4 | import findUsername from '../../../database/user/findUser'; 5 | 6 | const router = express.Router(); 7 | 8 | /** 9 | * @api {patch} /auth/me Change user data 10 | * @apiName ChangeUserData 11 | * @apiGroup Auth 12 | * @apiVersion 1.0.0 13 | * @apiDescription Change the currently logged in user's data. After changing the username, 14 | * the user will need to create a new token or a lot of endpoints will have a 500 error. 15 | * This can be done using the /auth/login endpoint. 16 | * 17 | * @apiBody (Body) {String} [username] The new username 18 | * @apiBody (Body) {String} [name] The new name 19 | * @apiBody (Body) {String} [password] The new password 20 | * 21 | * @apiExample {curl} Example usage - curl: 22 | * curl -X PATCH -H "Content-Type: application/json" -d '{"username": "dlurak", "name": "Dlurak"}' http://localhost:3000/auth/me 23 | * @apiExample {JavaScript} Example usage - JavaScript: 24 | * const response = await fetch('http://localhost:3000/auth/me', { 25 | * method: 'PATCH', 26 | * headers: new Headers({ 27 | * 'Content-Type': 'application/json', 28 | * 'Authorization': 'Bearer ' + token, 29 | * }) 30 | * }); 31 | * console.log(await response.json()); 32 | * 33 | * @apiSuccess (200) {String} status A short status of the request (success) 34 | * @apiSuccess (200) {String} message A short explaination of the response 35 | * 36 | * @apiSuccessExample {json} Success-Response: 37 | * HTTP/1.1 200 OK 38 | * X-Powered-By: Express 39 | * Access-Control-Allow-Origin: * 40 | * Content-Type: application/json; charset=utf-8 41 | * Content-Length: 63 42 | * ETag: W/"3f-Ag5/LYYmO55gKyeWLD86Spb1/sw" 43 | * Date: Fri, 04 Aug 2023 21:10:14 GMT 44 | * Connection: close 45 | * 46 | * { 47 | * "status": "success", 48 | * "message": "Successfully changed user data" 49 | * } 50 | * 51 | * @apiError (400) {String} status A short status of the request (error) 52 | * @apiError (400) {String} error A short explaination of the error 53 | * 54 | * @apiError (500) {String} status A short status of the request (error) 55 | * @apiError (500) {String} error A short explaination of the error 56 | * @apiError (500) {String} hint A hint to what the user can try to fix the error 57 | * 58 | * @apiErrorExample {json} 500 - User not found: 59 | * HTTP/1.1 500 Can't find user 60 | * { 61 | * "status": "error", 62 | * "message": "User not found", 63 | * "hint": "Maybe you changed your username? In that case you need to login again!" 64 | * } 65 | * 66 | * @apiErrorExample {json} 400 - No data to change: 67 | * HTTP/1.1 400 Bad Request 68 | * { 69 | * "status": "error", 70 | * "message": "No data to change" 71 | * } 72 | * 73 | * @apiUse jwtAuth 74 | */ 75 | router.patch('/', authenticate, async (req, res) => { 76 | const body = req.body; 77 | const username = res.locals.jwtPayload.username; 78 | 79 | const options = { 80 | username: body.username, 81 | name: body.name, 82 | password: body.password, 83 | }; 84 | 85 | const userId = (await findUsername(username))?._id; 86 | 87 | if (!userId) { 88 | return res.status(500).json({ 89 | status: 'error', 90 | error: 'User not found', 91 | hint: 'Maybe you changed your username? In that case you need to login again!', 92 | }); 93 | } 94 | 95 | if (!options.username && !options.name && !options.password) { 96 | return res.status(400).json({ 97 | status: 'error', 98 | error: 'No data to change', 99 | }); 100 | } 101 | 102 | await changeData(userId, options); 103 | 104 | res.status(200).json({ 105 | status: 'success', 106 | message: 'Successfully changed user data', 107 | }); 108 | }); 109 | 110 | export default router; 111 | -------------------------------------------------------------------------------- /src/routes/auth/me/delete.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import authenticate from '../../../middleware/auth'; 3 | import findUsername from '../../../database/user/findUser'; 4 | import { deleteUser } from '../../../database/user/deleteUser'; 5 | 6 | const router = express.Router(); 7 | 8 | /** 9 | * @api {delete} /auth/me Delete the current user 10 | * @apiname DeleteOwnUser 11 | * @apiGroup Auth 12 | * @apiVersion 1.0.0 13 | * @apiDescription Delete the currently logged in user 14 | * 15 | * @apiSuccess (200) {String} status A short status of the request (success) 16 | * @apiSuccess (200) {String} message A short explaination of the response 17 | * @apiSuccess (200) {String} data The username of the deleted user 18 | * 19 | * @apiSuccessExample {json} Success-Response: 20 | * HTTP/1.1 200 OK 21 | * { 22 | * "status": "success", 23 | * "message": "Successfully deleted user", 24 | * "data": "dlurak" 25 | * } 26 | * 27 | * @apiUse jwtAuth 28 | */ 29 | router.delete('/', authenticate, async (req, res) => { 30 | const username = res.locals.jwtPayload.username; 31 | const user = await findUsername(username); 32 | 33 | if (!user) { 34 | return res.status(500).json({ 35 | status: 'error', 36 | error: 'User not found', 37 | }); 38 | } 39 | 40 | deleteUser(user._id); 41 | 42 | res.status(200).json({ 43 | status: 'success', 44 | message: 'Successfully deleted user', 45 | data: res.locals.jwtPayload.username, 46 | }); 47 | }); 48 | 49 | export default router; 50 | -------------------------------------------------------------------------------- /src/routes/auth/me/getData.ts: -------------------------------------------------------------------------------- 1 | import { findUniqueSchoolById } from '../../../database/school/findSchool'; 2 | import findUsername from '../../../database/user/findUser'; 3 | import { getUniqueClassById } from '../../../database/classes/findClass'; 4 | import express from 'express'; 5 | import authenticate from '../../../middleware/auth'; 6 | 7 | const router = express.Router(); 8 | 9 | /** 10 | * @api {get} /auth/me Get information about the current user 11 | * @apiname GetOwnData 12 | * @apiGroup Auth 13 | * @apiVersion 1.0.0 14 | * @apiDescription Get information about the currently logged in user 15 | * 16 | * @apiSuccess (200) {String} status A short status of the request (success) 17 | * @apiSuccess (200) {String} message A short explaination of the response 18 | * @apiSuccess (200) {Object} data An object containing the data about the user 19 | * @apiSuccess (200) {String} data.id The MongoDB id of the user 20 | * @apiSuccess (200) {String} data.username The unique username of the user 21 | * @apiSuccess (200) {String} data.name The show name of the user 22 | * @apiSuccess (200) {String|Null} data.email The email of the user 23 | * @apiSuccess (200) {Object} data.school The school the user is in 24 | * @apiSuccess (200) {String} data.school._id The MongoDB id of the school 25 | * @apiSuccess (200) {String} data.school.name The name of the school 26 | * @apiSuccess (200) {String} data.school.description The short description of the school 27 | * @apiSuccess (200) {String} data.school.uniqueName The unique name of the school 28 | * @apiSuccess (200) {Number} data.school.timezoneOffset This is a value that doesn't have any use but still exists 29 | * @apiSuccess (200) {String[]} data.school.classes All the MongoDB IDs of the classes that are part of the school 30 | * @apiSuccess (200) {Object[]} data.classes A list of all the classes the user is in 31 | * @apiSuccess (200) {String} data.classes._id The MongoDB Id of the class 32 | * @apiSuccess (200) {String} data.classes.name The name of the class 33 | * @apiSuccess (200) {String} data.classes.school The MongoDB ID of the school 34 | * @apiSuccess (200) {String[]} data.classes.members A list of all the MongoDB IDs of the members of a class including the ID of the requestor self 35 | * 36 | * @apiSuccessExample {json} Success-Response: 37 | * HTTP/1.1 200 OK 38 | * X-Powered-By: Express 39 | * Access-Control-Allow-Origin: * 40 | * Content-Type: application/json; charset=utf-8 41 | * Content-Length: 538 42 | * ETag: W/"21a-yV9CzJWdoBJXHCwymV4bFKpCjJY" 43 | * Date: Thu, 03 Aug 2023 14:05:38 GMT 44 | * Connection: close 45 | * 46 | * { 47 | * "status": "success", 48 | * "message": "successfully send data", 49 | * "data": { 50 | * "id": "64bfc7ae8e3c2ae28caf9662", 51 | * "username": "dlurak", 52 | * "name": "Dlurak", 53 | * "email": null, 54 | * "school": { 55 | * }, 56 | * "classes": [ 57 | * { 58 | * "_id": "64bfc63195f139281cec6c75", 59 | * "name": "class", 60 | * "school": "64bfc62295f139281cec6c74", 61 | * "members": [ 62 | * "64bfc7ae8e3c2ae28caf9662", 63 | * "64c01ccabc888e23b18f9f59", 64 | * "64c80fa8e0c4a1648da54339" 65 | * ] 66 | * } 67 | * ] 68 | * } 69 | * } 70 | * 71 | * @apiError (500) {String} status The status of the request (error) 72 | * @apiError (500) {String} error Could not find user 73 | * 74 | * @apiErrorExample {json} 500 - User could not be found: 75 | * HTTP/1.1 500 Internal Server Error 76 | * { 77 | * "status": "error", 78 | * "error": "Could not find user" 79 | * } 80 | * 81 | * @apiUse jwtAuth 82 | */ 83 | router.get('/', authenticate, async (req, res) => { 84 | const rawData = await findUsername(res.locals.jwtPayload.username); 85 | 86 | if (rawData === null) { 87 | return res.status(500).json({ 88 | status: 'error', 89 | error: 'Could not find user', 90 | }); 91 | } 92 | 93 | const school = await findUniqueSchoolById(rawData.school); 94 | const classes = await Promise.all( 95 | rawData.classes.map(async (c) => await getUniqueClassById(c)), 96 | ); 97 | 98 | const data = { 99 | id: rawData._id, 100 | username: rawData.username, 101 | name: rawData.name, 102 | email: rawData.email, 103 | school, 104 | classes, 105 | }; 106 | 107 | res.status(200).json({ 108 | status: 'success', 109 | message: 'successfully send data', 110 | data, 111 | }); 112 | }); 113 | 114 | export default router; 115 | -------------------------------------------------------------------------------- /src/routes/auth/me/router.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import getDataRouter from './getData'; 4 | import deleteRouter from './delete'; 5 | import patchDataRouter from './changeData'; 6 | 7 | const router = express.Router(); 8 | 9 | router.use('/', getDataRouter); 10 | router.use('/', deleteRouter); 11 | router.use('/', patchDataRouter); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /src/routes/auth/register.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { User } from '../../database/user/user'; 3 | import { createUser } from '../../database/user/createUser'; 4 | import doesUsernameExist from '../../database/user/doesUserExist'; 5 | import { 6 | doesSchoolExist, 7 | findUniqueSchool, 8 | } from '../../database/school/findSchool'; 9 | import { findClass } from '../../database/classes/findClass'; 10 | import findUsername from '../../database/user/findUser'; 11 | import { addMemberToClass } from '../../database/classes/update'; 12 | import { createAddToClassRequest } from '../../database/requests/createAddToClassRequest'; 13 | import { AddToClassRequest } from '../../database/requests/addToClassRequests'; 14 | 15 | import { z } from 'zod'; 16 | import { 17 | hasLowercaseLetter, 18 | hasNumber, 19 | hasSpecialCharacter, 20 | hasUppercaseLetter, 21 | specialCharacters, 22 | } from '../../utils/strings'; 23 | import { SchoolWithId } from '../../database/school/school'; 24 | import { doesRequestExist } from '../../database/requests/findAddToClassRequests'; 25 | 26 | const router = express.Router(); 27 | 28 | /** 29 | * @api {post} /auth/register Register a new user 30 | * @apiName Register 31 | * @apiGroup Auth 32 | * @apiVersion 1.0.0 33 | * @apiDescription Register a new user 34 | * 35 | * @apiBody {String} username The unique username of the user 36 | * @apiBody {String} name The name of the user it doesn't have to be unique 37 | * @apiBody {String} password The password of the user it shouldn't be hashed before sending as it is hashed on the server 38 | * @apiBody {String} school The unique name of the school the user is in 39 | * @apiBody {String} class The name of the class the user is in 40 | * 41 | * @apiBody {String} [email] The email of the user 42 | * 43 | * 44 | * @apiSuccess (201) {String} status Status of the request (success). 45 | * @apiSuccess (201) {String} message Message of the request (User created). 46 | * @apiSuccess (201) {Object} [data] Data of the request. 47 | * @apiSuccess (201) {String} data.id The id of the request. 48 | * @apiSuccessExample {json} Register success: 49 | * HTTP/1.1 201 Created 50 | * { 51 | * "status": "success", 52 | * "message": "User created" 53 | * } 54 | * 55 | * @apiSuccessExample {json} Signup-request success: 56 | * HTTP/1.1 201 Created 57 | * { 58 | * "status": "success", 59 | * "message": "Successfully created request", 60 | * "data": { 61 | * id: "5f9a3b3b9f6b3b1b3c9f6b3b" 62 | * } 63 | * } 64 | * 65 | * @apiExample {javascript} JavaScript example: 66 | * let headersList = { 67 | * "Content-Type": "application/json" 68 | * } 69 | * 70 | * let bodyContent = JSON.stringify({ 71 | * "username": "HappyNoName", 72 | * "name": "NoName", 73 | * "password": "lowercaseandUPPERCASEand1numberand8charsLong!", 74 | * "school": "Hogwarts", 75 | * "class": "1a", 76 | * 77 | * "email": "noreply@noreply.com" 78 | * }); 79 | * 80 | * let response = await fetch("http://localhost:3000/auth/register", { 81 | * method: "POST", 82 | * body: bodyContent, 83 | * headers: headersList 84 | * }); 85 | * 86 | * let data = await response.json(); 87 | * console.log(data); 88 | * 89 | * @apiPermission none 90 | * 91 | * @apiError (400) {String} status Status of the request (error). 92 | * @apiError (400) {String} message Error message. 93 | * @apiError (400) {Object} [errors] Errors of the request. 94 | * 95 | * @apiErrorExample {json} 400: 96 | * HTTP/1.1 400 Bad Request 97 | * { 98 | * "status": "error", 99 | * "error": "Missing username in request body" 100 | * } 101 | * 102 | * @apiError (500) {String} status Status of the request (error). 103 | * @apiError (500) {String} error Error message (Internal server error). 104 | * 105 | * @apiErrorExample {json} 500: 106 | * HTTP/1.1 500 Internal Server Error 107 | * { 108 | * "status": "error", 109 | * "error": "Internal server error" 110 | * } 111 | */ 112 | router.post('/', async (req, res) => { 113 | const genTypeErrorMessage = (key: string, type: string) => 114 | `Expected ${key} to be of type ${type}`; 115 | const genMissingErrorMessage = (key: string) => 116 | `Missing ${key} in request body`; 117 | const genErrorMessages = (key: string, type: string) => ({ 118 | invalid_type_error: genTypeErrorMessage(key, type), 119 | required_error: genMissingErrorMessage(key), 120 | }); 121 | const genEmptyErrorMessage = (key: string) => ({ 122 | message: genMissingErrorMessage(key), 123 | }); 124 | 125 | const schema = z.object({ 126 | username: z 127 | .string(genErrorMessages('username', 'string')) 128 | .min(1, genEmptyErrorMessage('username')) 129 | .refine( 130 | async (username) => { 131 | const registeredUsernameExistsPromise = 132 | doesUsernameExist(username); 133 | const signupRequestUsernameExistsPromise = 134 | doesRequestExist(username); 135 | const promiseList = [ 136 | registeredUsernameExistsPromise, 137 | signupRequestUsernameExistsPromise, 138 | ]; 139 | const existList = await Promise.all(promiseList); 140 | const valid = existList.every((exists) => !exists); 141 | return valid; 142 | }, 143 | (username) => ({ 144 | message: `User ${username} already exists`, 145 | }), 146 | ), 147 | name: z 148 | .string(genErrorMessages('name', 'string')) 149 | .min(1, genEmptyErrorMessage('name')), 150 | password: z 151 | .string(genErrorMessages('password', 'string')) 152 | .min(8, { message: 'Password must be at least 8 characters long' }) 153 | .refine(hasLowercaseLetter, { 154 | message: 'Password must contain at least one lowercase letter', 155 | }) 156 | .refine(hasUppercaseLetter, { 157 | message: 'Password must contain at least one uppercase letter', 158 | }) 159 | .refine(hasNumber, { 160 | message: 'Password must contain at least one number', 161 | }) 162 | .refine(hasSpecialCharacter, { 163 | message: `Password must contain at least one of those characters: ${specialCharacters}`, 164 | }), 165 | school: z 166 | .string(genErrorMessages('school', 'string')) 167 | .min(1, genEmptyErrorMessage('school')) 168 | .refine(doesSchoolExist, (school) => ({ 169 | message: `School ${school} does not exist`, 170 | })), 171 | class: z 172 | .string(genErrorMessages('class', 'string')) 173 | .min(1, genEmptyErrorMessage('class')), 174 | 175 | email: z 176 | .string({ 177 | invalid_type_error: genTypeErrorMessage('email', 'string'), 178 | }) 179 | .email({ message: 'Invalid email' }) 180 | .optional(), 181 | }); 182 | 183 | const result = await schema.safeParseAsync(req.body); 184 | 185 | if (!result.success) { 186 | const fieldErrors = result.error.flatten().fieldErrors; 187 | 188 | const errors = Object.values(fieldErrors); 189 | const flatErrors = errors.reduce((acc, val) => acc.concat(val), []); 190 | 191 | res.status(400).json({ 192 | status: 'error', 193 | message: flatErrors[0], 194 | errors: fieldErrors, 195 | }); 196 | return; 197 | } 198 | 199 | // body.school is the unique name so we need to find the id 200 | const school = (await findUniqueSchool(result.data.school)) as SchoolWithId; 201 | const schoolId = school._id; 202 | 203 | const classObject = await findClass(school, result.data.class); 204 | if (classObject === null) { 205 | res.status(400).json({ 206 | status: 'error', 207 | message: `Class ${result.data.class} does not exist`, 208 | }); 209 | return; 210 | } 211 | 212 | const { username, name, password } = result.data; 213 | const email = result.data.email ?? null; 214 | 215 | const user: User = { 216 | username, 217 | name, 218 | password, 219 | 220 | school: schoolId, 221 | classes: [classObject._id], 222 | 223 | email, 224 | }; 225 | 226 | // check if the class already has a user 227 | const users = classObject.members; 228 | if (users.length > 0) { 229 | // create a request 230 | const addToClassRequest: AddToClassRequest = { 231 | userDetails: { 232 | name, 233 | username, 234 | createdAt: Date.now(), 235 | school: schoolId, 236 | password, 237 | 238 | email, 239 | 240 | acceptedClasses: [], 241 | }, 242 | classId: classObject._id, 243 | createdAt: Date.now(), 244 | status: 'pending', 245 | processedBy: null, 246 | }; 247 | 248 | const newRequest = await createAddToClassRequest(addToClassRequest); 249 | 250 | if (!newRequest) { 251 | res.status(500).json({ 252 | status: 'error', 253 | error: 'Internal server error', 254 | }); 255 | return; 256 | } 257 | 258 | return res.status(201).json({ 259 | status: 'success', 260 | message: 'Successfully created request', 261 | data: { 262 | id: newRequest._id, 263 | }, 264 | }); 265 | } 266 | 267 | if (await createUser(user)) { 268 | const newUser = await findUsername(username); 269 | if (newUser === null) 270 | return res.status(500).json({ 271 | status: 'error', 272 | error: 'Internal server error', 273 | }); 274 | 275 | const userId = newUser._id; 276 | addMemberToClass(classObject._id, userId); 277 | 278 | res.status(201).json({ 279 | status: 'success', 280 | message: 'User created', 281 | }); 282 | return; 283 | } else { 284 | res.status(500).json({ 285 | status: 'error', 286 | error: 'Internal server error', 287 | }); 288 | return; 289 | } 290 | }); 291 | 292 | router.all('/', (req, res) => { 293 | res.status(405).json({ 294 | status: 'error', 295 | message: 'Method not allowed', 296 | }); 297 | }); 298 | 299 | export default router; 300 | -------------------------------------------------------------------------------- /src/routes/auth/requests/getSpecificRequest.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { findSpecificRequestById } from '../../../database/requests/findAddToClassRequests'; 3 | import { ObjectId } from 'mongodb'; 4 | 5 | const router = express.Router(); 6 | 7 | /** 8 | * @api {get} /auth/requests/:id Get specific request 9 | * @apiName GetSpecificRequest 10 | * @apiGroup Requests 11 | * @apiDescription Get specific request by id 12 | * @apiParam {String} id Request id 13 | * @apiParamExample {json} URL-Example: 14 | * /auth/requests/64e5c4994b9074d2afbba014 15 | * 16 | * @apiSuccess (200) {String} status Status of the request. 17 | * @apiSuccess (200) {String} message Message of the request. 18 | * @apiSuccess (200) {Object} data Data of the request. 19 | * @apiSuccess (200) {Object} data.userDetails Details of the user who requested to join the class. 20 | * @apiSuccess (200) {String} data.userDetails.name The name of the user- 21 | * @apiSuccess (200) {String} data.userDetails.username The username of the user. 22 | * @apiSuccess (200) {Number} data.userDetails.createdAt The timestamp of when the user was created. 23 | * @apiSuccess (200) {String} data.userDetails.school The MongoDB ObjectId of the school the user is in. 24 | * @apiSuccess (200) {String[]} data.userDetails.acceptedClasses The MongoDB ObjectIds of the classes the user is in. 25 | * @apiSuccess (200) {String} data.classId The MongoDB ObjectId of the class the user wants to join. 26 | * @apiSuccess (200) {Number} data.createdAt The timestamp of when the request was created. 27 | * @apiSuccess (200) {pending|rejected|accepted} data.status The status of the request. 28 | * 29 | * @apiError (400) {String} status Status of the request. 30 | * @apiError (400) {String} message A short error message. 31 | * 32 | * @apiError (404) {String} status Status of the request. 33 | * @apiError (404) {String} message A short error message. 34 | * 35 | * @apiSuccessExample {json} Success-Response: 36 | * { 37 | * "status": "success", 38 | * "message": "Request found", 39 | * "data": { 40 | * "userDetails": { 41 | * "name": "dlurak", 42 | * "username": "dlurak", 43 | * "createdAt": 1692779673405, 44 | * "school": "64e5c2099bd19b99be83bf7e", 45 | * "acceptedClasses": [] 46 | * }, 47 | * "classId": "64e5c4514b9074d2afbba012", 48 | * "createdAt": 1692779673432, 49 | * "status": "pending", 50 | * "processedBy": null 51 | * } 52 | * } 53 | * 54 | * @apiErrorExample {json} 400 Invalid id: 55 | * { 56 | * "status": "error", 57 | * "message": "Invalid id" 58 | * } 59 | * @apiErrorExample {json} 404 Request not found: 60 | * { 61 | * "status": "error", 62 | * "message": "Request not found" 63 | * } 64 | */ 65 | router.get('/:id', async (req, res) => { 66 | // check if the id is valid 67 | if (!ObjectId.isValid(req.params.id)) { 68 | return res.status(400).json({ status: 'error', message: 'Invalid id' }); 69 | } 70 | 71 | const document = await findSpecificRequestById(new ObjectId(req.params.id)); 72 | 73 | if (!document) { 74 | return res.status(404).json({ 75 | status: 'error', 76 | message: 'Request not found', 77 | }); 78 | } 79 | 80 | const userDetails = document.userDetails; 81 | 82 | return res.status(200).json({ 83 | status: 'success', 84 | message: 'Request found', 85 | data: { 86 | userDetails: { 87 | name: userDetails.name, 88 | username: userDetails.username, 89 | createdAt: userDetails.createdAt, 90 | school: userDetails.school, 91 | acceptedClasses: userDetails.acceptedClasses, 92 | }, 93 | classId: document.classId, 94 | createdAt: document.createdAt, 95 | status: document.status, 96 | processedBy: document.processedBy, 97 | }, 98 | }); 99 | }); 100 | 101 | export default router; 102 | -------------------------------------------------------------------------------- /src/routes/auth/requests/getSpecificRequestSSE.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { ObjectId, WithId } from 'mongodb'; 3 | import { findSpecificRequestById } from '../../../database/requests/findAddToClassRequests'; 4 | import { 5 | AddToClassRequest, 6 | addToClassRequestsCollection, 7 | } from '../../../database/requests/addToClassRequests'; 8 | 9 | const parseSignupRequest = (document: WithId) => ({ 10 | userDetails: { 11 | name: document.userDetails.name, 12 | username: document.userDetails.username, 13 | createdAt: document.userDetails.createdAt, 14 | school: document.userDetails.school, 15 | acceptedClasses: document.userDetails.acceptedClasses, 16 | }, 17 | classId: document.classId, 18 | createdAt: document.createdAt, 19 | status: document.status, 20 | processedBy: document.processedBy, 21 | }); 22 | 23 | const router = express.Router(); 24 | 25 | router.get('/:id/sse', async (req, res) => { 26 | res.setHeader('Content-Type', 'text/event-stream'); 27 | res.setHeader('Cache-Control', 'no-cache'); 28 | res.setHeader('Connection', 'keep-alive'); 29 | 30 | if (!ObjectId.isValid(req.params.id)) { 31 | res.write('event: error\n'); 32 | res.write('data: Invalid id\n\n'); 33 | res.end(); 34 | return; 35 | } 36 | 37 | const document = await findSpecificRequestById(new ObjectId(req.params.id)); 38 | 39 | if (!document) { 40 | res.write('event: error\n'); 41 | res.write('data: Request not found\n\n'); 42 | res.end(); 43 | return; 44 | } 45 | 46 | res.write(`data: ${JSON.stringify(parseSignupRequest(document))}\n\n`); 47 | 48 | const changeStream = addToClassRequestsCollection.watch([ 49 | { $match: { 'documentKey._id': document._id } }, 50 | ]); 51 | 52 | changeStream.on('change', async () => { 53 | const document = (await findSpecificRequestById( 54 | new ObjectId(req.params.id), 55 | )) as WithId; 56 | res.write(`data: ${JSON.stringify(parseSignupRequest(document))}\n\n`); 57 | 58 | if (document.status === 'accepted' || document.status === 'rejected') { 59 | res.end(); 60 | changeStream.close(); 61 | return; 62 | } 63 | }); 64 | }); 65 | 66 | export default router; 67 | -------------------------------------------------------------------------------- /src/routes/auth/requests/listRequest.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import authenticate from '../../../middleware/auth'; 3 | import findUsername from '../../../database/user/findUser'; 4 | import { findRequestsByClassId } from '../../../database/requests/findAddToClassRequests'; 5 | import { 6 | AddToClassRequest, 7 | AddToClassRequestStatus, 8 | } from '../../../database/requests/addToClassRequests'; 9 | import { WithId } from 'mongodb'; 10 | 11 | const router = express.Router(); 12 | 13 | /** 14 | * @api {get} /auth/requests Get requests 15 | * @apiName GetRequests 16 | * @apiGroup Requests 17 | * @apiDescription Get requests 18 | * @apiQuery {pending|accepted|rejected|p|a|r} [status] Filter requests by status. When a not supported or none value is given, all requests are returned. 19 | * @apiParamExample {url} URI-Example: 20 | * /auth/requests?status=pending 21 | * 22 | * @apiSuccess (200) {String} status Status of the request. 23 | * @apiSuccess (200) {String} message Message of the request. 24 | * @apiSuccess (200) {Object[]} data Data of the request. 25 | * @apiSuccess (200) {Object} data.userDetails Details of the user who requested to join the class. 26 | * @apiSuccess (200) {String} data.userDetails.name The name of the user- 27 | * @apiSuccess (200) {String} data.userDetails.username The username of the user. 28 | * @apiSuccess (200) {Number} data.userDetails.createdAt The timestamp of when the user was created. 29 | * @apiSuccess (200) {String} data.userDetails.school The MongoDB ObjectId of the school the user is in. 30 | * @apiSuccess (200) {String[]} data.userDetails.acceptedClasses The MongoDB ObjectIds of the classes the user is in. 31 | * @apiSuccess (200) {String} data.classId The MongoDB ObjectId of the class the user wants to join. 32 | * @apiSuccess (200) {Number} data.createdAt The timestamp of when the request was created. 33 | * @apiSuccess (200) {pending|rejected|accepted} data.status The status of the request. 34 | * @apiSuccess (200) {String|null} data.processedBy The MongoDB ObjectId of the user who processed the request. 35 | * 36 | * @apiError (500) {String} status Status of the request. 37 | * @apiError (500) {String} message A short error message. 38 | * 39 | * @apiSuccessExample {json} Success-Response: 40 | * HTTP/1.1 200 OK 41 | * { 42 | * "status": "success", 43 | * "message": "Requests found", 44 | * "data": [ 45 | * { 46 | * "_id": "64e61ed936475a58be01f54b", 47 | * "userDetails": { 48 | * "name": "test", 49 | * "username": "test", 50 | * "createdAt": 1692802777021, 51 | * "school": "64e61ea736475a58be01f548", 52 | * "acceptedClasses": [] 53 | * }, 54 | * "classId": "64e61eb136475a58be01f549", 55 | * "createdAt": 1692802777050, 56 | * "status": "pending", 57 | * "processedBy": null 58 | * } 59 | * ] 60 | * } 61 | * 62 | * @apiErrorExample {json} 500 User not found: 63 | * HTTP/1.1 500 Internal Server Error 64 | * { 65 | * "status": "error", 66 | * "message": "User not found" 67 | * } 68 | * 69 | * @apiUse jwtAuth 70 | */ 71 | router.get('/', authenticate, async (req, res) => { 72 | const username = res.locals.jwtPayload.username; 73 | const statusDict: { 74 | [index: string]: AddToClassRequestStatus; 75 | } = { 76 | pending: 'pending', 77 | accepted: 'accepted', 78 | rejected: 'rejected', 79 | p: 'pending', 80 | a: 'accepted', 81 | r: 'rejected', 82 | }; 83 | const status: AddToClassRequestStatus | undefined = 84 | statusDict[req.query.status as string]; 85 | 86 | const userObj = await findUsername(username); 87 | 88 | if (!userObj) { 89 | return res.status(500).json({ 90 | status: 'error', 91 | message: 'User not found', 92 | }); 93 | } 94 | 95 | const userClasses = userObj.classes; 96 | 97 | let requests: WithId[] = []; 98 | 99 | for (const classId of userClasses) { 100 | const classRequests = await findRequestsByClassId(classId, status); 101 | requests = [...requests, ...classRequests]; 102 | } 103 | 104 | const cleanedRequests = requests.map((request) => ({ 105 | ...request, 106 | userDetails: { 107 | name: request.userDetails.name, 108 | username: request.userDetails.username, 109 | createdAt: request.userDetails.createdAt, 110 | school: request.userDetails.school, 111 | acceptedClasses: request.userDetails.acceptedClasses, 112 | }, 113 | })); 114 | return res.status(200).json({ 115 | status: 'success', 116 | message: 'Requests found', 117 | data: cleanedRequests, 118 | }); 119 | }); 120 | 121 | export default router; 122 | -------------------------------------------------------------------------------- /src/routes/auth/requests/processRequest.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { ObjectId } from 'mongodb'; 3 | import { findSpecificRequestById } from '../../../database/requests/findAddToClassRequests'; 4 | import authenticate from '../../../middleware/auth'; 5 | import findUsername from '../../../database/user/findUser'; 6 | import { 7 | acceptRequest, 8 | rejectRequest, 9 | } from '../../../database/requests/changeAddToClassRequestStatus'; 10 | 11 | const router = express.Router(); 12 | 13 | /** 14 | * @api {patch} /auth/requests/:id/:operator Process a request 15 | * @apiName ProcessRequest 16 | * @apiGroup Requests 17 | * @apiDescription Process a request to join a class. The request must be pending. It can be accepted or rejected. 18 | * @apiPermission The user must be a member of the class that the request is for 19 | * 20 | * @apiParam {String} id The ID of the request 21 | * @apiParam {String} operator The operator to use. Must be either `accept` or `reject` 22 | * 23 | * @apiSuccess (200) {String} status The status of the request 24 | * @apiSuccess (200) {String} message A message indicating the status of the request 25 | * 26 | * @apiError (400) {String} status The status of the request 27 | * @apiError (400) {String} message A message indicating the status of the request 28 | * 29 | * @apiError (403) {String} status The status of the request 30 | * @apiError (403) {String} message A message indicating the status of the request 31 | * 32 | * @apiError (404) {String} status The status of the request 33 | * @apiError (404) {String} message A message indicating the status of the request 34 | * 35 | * @apiError (500) {String} status The status of the request 36 | * @apiError (500) {String} message A message indicating the status of the request 37 | * 38 | * @apiSuccessExample {json} 200 - Success: 39 | * HTTP/1.1 200 OK 40 | * { 41 | * "status": "Success", 42 | * "message": "Request processed" 43 | * } 44 | * 45 | * @apiErrorExample {json} 400 - Invalid ID: 46 | * HTTP/1.1 400 Bad Request 47 | * { 48 | * "status": "error", 49 | * "message": "Invalid ID" 50 | * } 51 | * 52 | * @apiErrorExample {json} 400 - Request a already processed: 53 | * HTTP/1.1 400 Bad Request 54 | * { 55 | * "status": "error", 56 | * "message": "Request is already processed" 57 | * } 58 | * 59 | * @apiErrorExample {json} 403 - User doesn't have access to class: 60 | * HTTP/1.1 403 Forbidden 61 | * { 62 | * "status": "error", 63 | * "message": "You don't have access to this class yourself" 64 | * } 65 | * 66 | * @apiErrorExample {json} 404 - Request not found: 67 | * HTTP/1.1 404 Not Found 68 | * { 69 | * "status": "error", 70 | * "message": "Request not found" 71 | * } 72 | * 73 | * @apiErrorExample {json} 500 - User not found: 74 | * HTTP/1.1 500 Internal Server Error 75 | * { 76 | * "status": "error", 77 | * "message": "User not found" 78 | * } 79 | * 80 | * @apiUse jwtAuth 81 | */ 82 | router.patch( 83 | '/:id/:operator(accept|reject)', 84 | authenticate, 85 | async (req, res) => { 86 | const rawId = req.params.id; 87 | const operator = req.params.operator as 'accept' | 'reject'; 88 | const username = res.locals.jwtPayload.username as string; 89 | const userObjPromise = findUsername(username); 90 | 91 | if (!ObjectId.isValid(rawId)) { 92 | return res.status(400).json({ 93 | status: 'error', 94 | message: 'Invalid ID', 95 | }); 96 | } 97 | 98 | const document = await findSpecificRequestById(new ObjectId(rawId)); 99 | if (!document) { 100 | return res.status(404).json({ 101 | status: 'error', 102 | message: 'Request not found', 103 | }); 104 | } 105 | 106 | if (document.status !== 'pending') { 107 | return res.status(400).json({ 108 | status: 'error', 109 | message: 'Request is already processed', 110 | }); 111 | } 112 | 113 | const userObj = await userObjPromise; 114 | if (!userObj) { 115 | return res.status(500).json({ 116 | status: 'error', 117 | message: 'User not found', 118 | }); 119 | } 120 | const userId = userObj._id; 121 | const userClasses = userObj.classes; 122 | 123 | if (!userClasses.map(String).includes(String(document.classId))) { 124 | return res.status(403).json({ 125 | status: 'error', 126 | message: "You don't have access to this class yourself", 127 | }); 128 | } 129 | 130 | // VALIDATION PASSED // 131 | 132 | if (operator === 'accept') await acceptRequest(document._id, userId); 133 | else if (operator === 'reject') 134 | await rejectRequest(document._id, userId); 135 | 136 | return res.status(200).json({ 137 | status: 'success', 138 | message: 'Request processed', 139 | }); 140 | }, 141 | ); 142 | 143 | export default router; 144 | -------------------------------------------------------------------------------- /src/routes/auth/requests/router.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import getSpecificRequestRouter from './getSpecificRequest'; 4 | import getSpecificRequestSSERouter from './getSpecificRequestSSE'; 5 | import getListOfRequestsRouter from './listRequest'; 6 | import processRequestRouter from './processRequest'; 7 | 8 | const router = express.Router(); 9 | 10 | router.use('/', getSpecificRequestRouter); 11 | router.use('/', getSpecificRequestSSERouter); 12 | router.use('/', getListOfRequestsRouter); 13 | router.use('/', processRequestRouter); 14 | 15 | export default router; 16 | -------------------------------------------------------------------------------- /src/routes/auth/router.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import registerRouter from './register'; 4 | import loginRouter from './login'; 5 | import meRouter from './me/router'; 6 | import requestsRouter from './requests/router'; 7 | import userDetailsRouter from './userDetails'; 8 | 9 | const router = express.Router(); 10 | 11 | router.use('/register', registerRouter); 12 | router.use('/login', loginRouter); 13 | router.use('/me', meRouter); 14 | router.use('/requests?', requestsRouter); 15 | router.use('/', userDetailsRouter); 16 | 17 | export default router; 18 | -------------------------------------------------------------------------------- /src/routes/auth/userDetails.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { ObjectId, WithId } from 'mongodb'; 3 | import { findUserById } from '../../database/user/findUser'; 4 | import { getUniqueClassById } from '../../database/classes/findClass'; 5 | import { Class } from '../../database/classes/class'; 6 | import { findUniqueSchoolById } from '../../database/school/findSchool'; 7 | import { SchoolWithId } from '../../database/school/school'; 8 | 9 | const router = express.Router(); 10 | 11 | /** 12 | * @api {get} /auth/user/:id Get user details 13 | * @apiName GetUserDetails 14 | * @apiDescription Get the details of a user. The user will be identified by the MongoDB ID. 15 | * @apiGroup Auth 16 | * 17 | * @apiParam {String} id The MongoDB ID of the user 18 | * 19 | * @apiSuccess (200) {String=Success} status Status of the request. 20 | * @apiSuccess (200) {String="User details"} message Message of the request. 21 | * @apiSuccess (200) {Object} data Data of the request. 22 | * @apiSuccess (200) {Object} data.user The user object. 23 | * @apiSuccess (200) {String} data.user._id The MongoDB ID of the user. 24 | * @apiSuccess (200) {String} data.user.name The name of the user. 25 | * @apiSuccess (200) {String} data.user.school The name of the school the user is in. 26 | * @apiSuccess (200) {String[]} data.user.classes The names of the classes the user is in. 27 | * 28 | * 29 | * @apiError (400) {String=error} status Status of the request. 30 | * @apiError (400) {String="Invalid user ID"} message Error message. 31 | * 32 | * @apiError (404) {String=error} status Status of the request. 33 | * @apiError (404) {String="User not found"} message Error message. 34 | */ 35 | router.get('/:id', async (req, res) => { 36 | const id = req.params.id; 37 | 38 | if (!ObjectId.isValid(id)) { 39 | res.status(400).json({ 40 | status: 'error', 41 | message: 'Invalid user ID', 42 | }); 43 | return; 44 | } 45 | 46 | const user = await findUserById(new ObjectId(id)); 47 | 48 | if (!user) { 49 | res.status(404).json({ 50 | status: 'error', 51 | message: 'User not found', 52 | }); 53 | return; 54 | } 55 | 56 | const { _id, name, school, classes } = user; 57 | 58 | const schoolId = new ObjectId(school); 59 | const classIds = classes.map((classId) => new ObjectId(classId)); 60 | 61 | const classNames = await Promise.all( 62 | classIds.map(async (classId) => { 63 | const class_ = (await getUniqueClassById(classId)) as WithId; 64 | return class_.name; 65 | }), 66 | ); 67 | const schoolName = (await findUniqueSchoolById(schoolId)) as SchoolWithId; 68 | 69 | const resUser = { 70 | _id, 71 | name, 72 | school: schoolName.name, 73 | classes: classNames, 74 | }; 75 | 76 | res.status(200).json({ 77 | status: 'success', 78 | message: 'User details', 79 | data: { 80 | user: resUser, 81 | }, 82 | }); 83 | }); 84 | 85 | export default router; 86 | -------------------------------------------------------------------------------- /src/routes/classes/createClass.ts: -------------------------------------------------------------------------------- 1 | import { WithId } from 'mongodb'; 2 | import { findUniqueSchool } from '../../database/school/findSchool'; 3 | import { getUniqueClassById } from '../../database/classes/findClass'; 4 | import express from 'express'; 5 | import { School } from '../../database/school/school'; 6 | import { Class } from '../../database/classes/class'; 7 | import { createClass } from '../../database/classes/createClass'; 8 | 9 | const router = express.Router(); 10 | 11 | /** 12 | * @api {post} /classes/ Create a new class 13 | * @apiName CreateClass 14 | * @apiGroup Classes 15 | * @apiVersion 1.0.0 16 | * @apiDescription Create a new class 17 | * 18 | * @apiBody {String} name The name of the class 19 | * @apiBody {String} school The uniquename of the school the class is in 20 | * 21 | * @apiSuccess (201) {String} status Status of the request (success). 22 | * @apiSuccess (201) {String} message Message of the request (New class created). 23 | * 24 | * @apiSuccessExample {json} Success-Response: 25 | * HTTP/1.1 201 Created 26 | * { 27 | * "status": "success", 28 | * "message": "New class created" 29 | * } 30 | * 31 | * @apiExample {curl} Curl example: 32 | * curl -X POST -H "Content-Type: application/json" -d '{"name": "name", "school": "school"}' http://localhost:3000/classes/ 33 | * 34 | * @apiError (400) {String} status Status of the request (error). 35 | * @apiError (400) {String} error Error message. 36 | * 37 | * @apiErrorExample {json} 400 - missing key: 38 | * HTTP/1.1 400 Bad Request 39 | * { 40 | * "status": "error", 41 | * "error": "Missing required field: name of type string" 42 | * } 43 | * 44 | * @apiErrorExample {json} 400 - empty name: 45 | * HTTP/1.1 400 Bad Request 46 | * { 47 | * "status": "error", 48 | * "error": "Name can't be empty" 49 | * } 50 | * 51 | * @apiErrorExample {json} 400 - school doesn't exist: 52 | * HTTP/1.1 400 Bad Request 53 | * { 54 | * "status": "error", 55 | * "error": "School school doesn't exist" 56 | * } 57 | * 58 | * @apiErrorExample {json} 400 - class already exists: 59 | * HTTP/1.1 400 Bad Request 60 | * { 61 | * "status": "error", 62 | * "error": "Class name already exists in school school" 63 | * } 64 | * 65 | * @apiError (500) {String} status Status of the request (error). 66 | * @apiError (500) {String} error Error message. 67 | * 68 | * @apiErrorExample {json} 500: 69 | * HTTP/1.1 500 Internal Server Error 70 | * { 71 | * "status": "error", 72 | * "error": "Failed to create class" 73 | * } 74 | * 75 | * @apiPermission none 76 | */ 77 | router.post('/', async (req, res) => { 78 | const body = req.body; 79 | 80 | const requiredFields = { 81 | name: 'string', 82 | school: 'string', 83 | }; 84 | 85 | for (const entry of Object.entries(requiredFields)) { 86 | const key = entry[0]; 87 | const value = entry[1]; 88 | 89 | if (typeof body[key] !== value) { 90 | return res.status(400).json({ 91 | status: 'error', 92 | error: `Missing required field: ${key} of type ${value}`, 93 | }); 94 | } 95 | } 96 | 97 | // check that body.name is not empty 98 | if (body.name.trim() === '') { 99 | res.status(400).json({ 100 | status: 'error', 101 | error: "Name can't be empty", 102 | }); 103 | return; 104 | } 105 | 106 | const school = await findUniqueSchool(body.school); 107 | if (!school) { 108 | res.status(400).json({ 109 | status: 'error', 110 | error: `School ${body.school} doesn't exist`, 111 | }); 112 | return; 113 | } 114 | 115 | const schoolClasses = (school as WithId).classes; 116 | for (const classId of schoolClasses) { 117 | const class_ = await getUniqueClassById(classId); 118 | if (class_?.name === body.name) { 119 | res.status(400).json({ 120 | status: 'error', 121 | error: `Class ${body.name} already exists in school ${body.school}`, 122 | }); 123 | return; 124 | } 125 | } 126 | 127 | const schoolId = (school as WithId)._id; 128 | 129 | const newClass: Class = { 130 | name: body.name, 131 | school: schoolId, 132 | members: [], 133 | }; 134 | 135 | const success = await createClass(newClass); 136 | if (success) { 137 | res.status(201).json({ 138 | status: 'success', 139 | message: `New class ${body.name} created`, 140 | data: newClass, 141 | }); 142 | return; 143 | } else { 144 | res.status(500).json({ 145 | status: 'error', 146 | error: 'Failed to create class', 147 | }); 148 | return; 149 | } 150 | }); 151 | 152 | export default router; 153 | -------------------------------------------------------------------------------- /src/routes/classes/getClass.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { getClassesFromSchool } from '../../database/classes/findClass'; 3 | import { findUniqueSchool } from '../../database/school/findSchool'; 4 | import { z } from 'zod'; 5 | 6 | const router = express.Router(); 7 | 8 | /** 9 | * @api {GET} /classes?school=:school Get classes 10 | * @apiName Get classes 11 | * @apiGroup Classes 12 | * @apiVersion 1.0.0 13 | * 14 | * @apiQuery {String} :school The name of the school to get the classes from 15 | * 16 | * @apiExample {curl} Example usage - curl: 17 | * curl http://localhost:3000/classes?school=School 18 | * @apiExample {python} Example usage - python: 19 | * import requests 20 | * school = input('School: ') 21 | * response = requests.get(f'http://localhost:3000/classes?school={school}') 22 | * print(response.json()) 23 | * @apiExample {javascript} Example usage - javascript: 24 | * const school = 'School'; 25 | * const response = await fetch(`http://localhost:3000/classes?school=${school}`); 26 | * console.log(await response.json()); 27 | * @apiExample {v} Example usage - v: 28 | * import net.http 29 | * school := 'School' 30 | * resp := http.get('http://localhost:3000/classes?school=${school}')! 31 | * println(resp.body) 32 | * @apiExample {kotlin} Example usage - kotlin: 33 | * val client = OkHttpClient() 34 | * val request = Request.Builder() 35 | * .url("http://localhost:3000/classes?school=Hogwarts") 36 | * .get() 37 | * .build() 38 | * 39 | * val response = client.newCall(request).execute() 40 | * 41 | * @apiSuccess (200) {String=success} status The status of the request (success) 42 | * @apiSuccess (200) {String="Class found"} message A short message about the status of the request 43 | * @apiSuccess (200) {Object[]} data The data returned by the request 44 | * @apiSuccess (200) {String} data._id The MongoDB ID of the class 45 | * @apiSuccess (200) {String} data.name The name of the class 46 | * @apiSuccess (200) {String} data.school The MongoDB ID of the school the class is in 47 | * @apiSuccess (200) {String[]} data.members The MongoDB IDs of the members of the class 48 | * 49 | * @apiSuccessExample {json} Success-Response: 50 | * HTTP/1.1 200 OK 51 | * { 52 | * "status": "success", 53 | * "message": "Class found", 54 | * "data": [ 55 | * { 56 | * "_id": "64bfc63195f139281cec6c75", 57 | * "name": "class", 58 | * "school": "64bfc62295f139281cec6c74", 59 | * "members": [ 60 | * "64bfc7ae8e3c2ae28caf9662", 61 | * "64c01ccabc888e23b18f9f59", 62 | * "64c80fa8e0c4a1648da54339" 63 | * ] 64 | * } 65 | * ] 66 | * } 67 | * 68 | * @apiError (400) {String} status The status of the request (error) 69 | * @apiError (400) {String} error A short message about the error 70 | * 71 | * @apiError (404) {String} status The status of the request (error) 72 | * @apiError (404) {String} error A short message about the error 73 | * 74 | * @apiErrorExample {json} 400 - Missing required query paramter: 75 | * HTTP/1.1 400 Bad Request 76 | * { 77 | * "status": "error", 78 | * "message": "Missing required parameter school" 79 | * } 80 | * @apiErrorExample {json} 404 - School not found: 81 | * HTTP/1.1 404 Not Found 82 | * { 83 | * "status": "error", 84 | * "message": "School not found" 85 | * } 86 | */ 87 | router.get('/', async (req, res) => { 88 | const schema = z.object({ 89 | school: z.string({ 90 | required_error: 'Missing required parameter school', 91 | }), 92 | }); 93 | const zRes = schema.safeParse(req.query); 94 | 95 | if (!zRes.success) 96 | return res.status(400).json({ 97 | status: 'error', 98 | error: zRes.error.issues[0].message, 99 | }); 100 | 101 | const schoolName = zRes.data.school; 102 | const schoolObj = await findUniqueSchool(schoolName); 103 | 104 | if (!schoolObj) { 105 | return res.status(404).json({ 106 | status: 'error', 107 | error: 'School not found', 108 | }); 109 | } 110 | 111 | const classes = await getClassesFromSchool(schoolObj); 112 | 113 | res.status(200).json({ 114 | status: 'success', 115 | message: 'Class found', 116 | data: classes, 117 | }); 118 | }); 119 | 120 | export default router; 121 | -------------------------------------------------------------------------------- /src/routes/classes/getSpecificClass.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { findClass } from '../../database/classes/findClass'; 3 | import { findUniqueSchool } from '../../database/school/findSchool'; 4 | 5 | const router = express.Router(); 6 | 7 | /** 8 | * @api {GET} /classes/:schoolName/:className Get a specific class 9 | * @apiName Get specific class 10 | * @apiGroup Classes 11 | * @apiVersion 1.0.0 12 | * 13 | * @apiParam {String} schoolName The uniquename of the school of the class, 14 | * this is case sensitive. 15 | * @apiParam {String} className The name of the class. All classes are lowercase, 16 | * when you use a capital letter it won't find the class. 17 | * 18 | * @apiExample {curl} Example usage - curl: 19 | * curl http://localhost:3000/Hogwarts/1a 20 | * @apiExample {python} Example usage - python: 21 | * import requests 22 | * response = requests.get('http://localhost:3000/Hogwarts/1a') 23 | * print(response.json()) 24 | * @apiExample {javascript} Example usage - javascript: 25 | * const response = await fetch('http://localhost:3000/Hogwarts/1a'); 26 | * console.log(await response.json()); 27 | * @apiExample {v} Example usage - v: 28 | * import net.http 29 | * resp := http.get('http://localhost:3000/Hogwarts/1a')! 30 | * println(resp.body) 31 | * 32 | * @apiSuccess (200) {String} status The status of the request (success) 33 | * @apiSuccess (200) {String} message A short message about the status of the request 34 | * @apiSuccess (200) {Object} data The data returned by the request 35 | * @apiSuccess (200) {String} data._id The MongoDB ID of the class 36 | * @apiSuccess (200) {String} data.name The name of the class 37 | * @apiSuccess (200) {String} data.school The MongoDB ID of the school the class is in 38 | * @apiSuccess (200) {String[]} data.members The MongoDB IDs of the members of the class 39 | * 40 | * @apiError (404) {String} status The status of the request (error) 41 | * @apiError (404) {String} error A short message about the error 42 | * 43 | * @apiSuccessExample {json} Success-Response: 44 | * HTTP/1.1 200 OK 45 | * { 46 | * "status": "success", 47 | * "message": "Class found", 48 | * "data": { 49 | * "_id": "64cd3d8b27b7e06ad1d90741", 50 | * "name": "class", 51 | * "school": "64cd3d2427b7e06ad1d90740", 52 | * "members": [ 53 | * "64cd3d9e27b7e06ad1d90742" 54 | * ] 55 | * } 56 | * } 57 | * @apiErrorExample {json} 404 - School not found: 58 | * HTTP/1.1 404 Not Found 59 | * { 60 | * "status": "error", 61 | * "error": "School not found" 62 | * } 63 | * @apiErrorExample {json} 404 - Class not found: 64 | * HTTP/1.1 404 Not Found 65 | * { 66 | * "status": "error", 67 | * "error": "Class not found" 68 | * } 69 | */ 70 | router.get('/:schoolname/:classname', async (req, res) => { 71 | const school = await findUniqueSchool(req.params.schoolname); 72 | 73 | if (!school) { 74 | return res.status(404).json({ 75 | status: 'error', 76 | error: 'School not found', 77 | }); 78 | } 79 | 80 | const classObj = await findClass(school, req.params.classname); 81 | 82 | if (!classObj) { 83 | return res.status(404).json({ 84 | status: 'error', 85 | error: 'Class not found', 86 | }); 87 | } 88 | 89 | return res.status(200).json({ 90 | status: 'success', 91 | message: 'Class found', 92 | data: classObj, 93 | }); 94 | }); 95 | 96 | export default router; 97 | -------------------------------------------------------------------------------- /src/routes/classes/getSpecificClassById.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { ObjectId } from 'mongodb'; 3 | import { getUniqueClassById } from '../../database/classes/findClass'; 4 | 5 | const router = express.Router(); 6 | 7 | /** 8 | * @api {GET} /classes/:id Get specific class 9 | * @apiName Get a specific class by id 10 | * @apiGroup Classes 11 | * @apiVersion 1.0.0 12 | * @apiDescription Get a specific class by its MongoDB ID 13 | * This endpoint really needs better documentation, 14 | * so if you are willing to do it please open a pull request 15 | * - if I read this in the future, please document it finally :) 16 | * 17 | * @apiParam {String} id The MongoDB ID of the class 18 | * 19 | * @apiExample {curl} Example usage - curl: 20 | * curl http://localhost:3000/classes/64bfc63195f139281cec6c75 21 | * 22 | * @apiSuccess (200) {String} status The status of the request (success) 23 | * @apiSuccess (200) {String} message A short message about the status of the request 24 | * @apiSuccess (200) {Object} data The data returned by the request 25 | * @apiSuccess (200) {String} data._id The MongoDB ID of the class 26 | * @apiSuccess (200) {String} data.name The name of the class 27 | * @apiSuccess (200) {String} data.school The MongoDB ID of the school the class is in 28 | * @apiSuccess (200) {String[]} data.members The MongoDB IDs of the members of the class 29 | * 30 | * @apiError (400) {String} status The status of the request (error) 31 | * @apiError (400) {String} message A short message about the error 32 | * 33 | * @apiError (404) {String} status The status of the request (error) 34 | * @apiError (404) {String} message A short message about the error 35 | */ 36 | router.get('/:id', async (req, res) => { 37 | // VALIDATION // 38 | if (!ObjectId.isValid(req.params.id)) { 39 | return res.status(400).json({ 40 | status: 'error', 41 | message: 'Invalid class id', 42 | }); 43 | } 44 | const classId = new ObjectId(req.params.id); 45 | const classObj = await getUniqueClassById(classId); 46 | if (!classObj) { 47 | return res.status(404).json({ 48 | status: 'error', 49 | message: 'Class not found', 50 | }); 51 | } 52 | // VALIDATION COMPLETE // 53 | 54 | return res.status(200).json({ 55 | status: 'success', 56 | message: 'Class found', 57 | data: classObj, 58 | }); 59 | }); 60 | 61 | export default router; 62 | -------------------------------------------------------------------------------- /src/routes/classes/router.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import createClassRouter from './createClass'; 4 | import getClassRouter from './getClass'; 5 | import getspecificClassRouter from './getSpecificClass'; 6 | import getSpecificClassByIdRouter from './getSpecificClassById'; 7 | 8 | const router = express.Router(); 9 | 10 | router.use('/', createClassRouter); 11 | router.use('/', getClassRouter); 12 | router.use('/', getspecificClassRouter); 13 | router.use('/', getSpecificClassByIdRouter); 14 | router.all('/', (req, res) => { 15 | res.status(405).json({ 16 | status: 'error', 17 | message: 'Method not allowed', 18 | }); 19 | }); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /src/routes/events/createEvent.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import authenticate from '../../middleware/auth'; 3 | import { isDateTimeValid } from '../../utils/date'; 4 | import { findClassBySchoolNameAndClassName } from '../../database/classes/findClass'; 5 | import { WithId } from 'mongodb'; 6 | import { Class } from '../../database/classes/class'; 7 | import findUsername from '../../database/user/findUser'; 8 | import { CalEvent } from '../../database/events/event'; 9 | import { createEvent } from '../../database/events/createEvent'; 10 | import { isUserMemberOfClass } from '../../database/user/doesUserExist'; 11 | 12 | const router = express.Router(); 13 | 14 | type positiveValidateSuccess = [true, WithId]; 15 | type negativeValidateSuccess = [string, number]; 16 | 17 | const validate = async ( 18 | body: Record, 19 | ): Promise => { 20 | const keyTypes: Record = { 21 | title: 'string', 22 | description: 'string', 23 | date: 'object', 24 | duration: 'number', 25 | subject: 'string', 26 | school: 'string', 27 | class: 'string', 28 | }; 29 | 30 | for (const key in keyTypes) { 31 | if (typeof body[key] !== keyTypes[key]) { 32 | return [`${key} is not a ${keyTypes[key]}`, 400]; 33 | } 34 | } 35 | 36 | const dateTimeIsValid = isDateTimeValid(body.date as any); 37 | if (!dateTimeIsValid) { 38 | return ['date is invalid', 400]; 39 | } 40 | 41 | const duration = body.duration as number; 42 | if (duration <= 0) { 43 | return ['duration must not be negative', 400]; 44 | } 45 | 46 | const classObj = await findClassBySchoolNameAndClassName( 47 | body.school as string, 48 | body.class as string, 49 | ); 50 | if (!classObj) { 51 | return ['class does not exist', 404]; 52 | } 53 | 54 | return [true, classObj]; 55 | }; 56 | 57 | /** 58 | * @api {post} /events Create an event 59 | * @apiName CreateEvent 60 | * @apiGroup Events 61 | * @apiDescription Create an event 62 | * 63 | * @apiBody {String} title Title of the event 64 | * @apiBody {String} description Description of the event 65 | * @apiBody {Object} date Date of the event 66 | * @apiBody {Number} date.year Year of the event 67 | * @apiBody {Number{1-12}} date.month Month of the event 68 | * @apiBody {Number{1-31}} date.day Day of the event 69 | * @apiBody {Number{0-23}} date.hour Hour of the event 70 | * @apiBody {Number{0-59}} date.minute Minute of the event 71 | * @apiBody {Number{0..}} duration Duration of the event in seconds 72 | * @apiBody {String} subject Subject of the event 73 | * @apiBody {String} school School of the event 74 | * @apiBody {String} class Class of the event 75 | * @apiBody {String} [location] Location of the event 76 | * 77 | * @apiError (400) {String="error"} status Status of the response 78 | * @apiError (400) {String="title is not a string" "description is not a string" "date is not a object" "duraton is not a number" "subject is not a string" "school is not a string" "class is not a string" "date is invalid" "duration must not be negative"} message Error message 79 | * 80 | * @apiError (403) {String="error"} status Status of the response 81 | * @apiError (403) {String="user is not a member of the class"} message Error message 82 | * 83 | * @apiError (404) {String="error"} status Status of the response 84 | * @apiError (404) {String="class does not exist"} message Error message 85 | * 86 | * @apiError (500) {String="error"} status Status of the response 87 | * @apiError (500) {String="internal server error" "user does not exist"} message Error message 88 | * 89 | * @apiSuccess (201) {String="success"} status Status of the response 90 | * @apiSuccess (201) {String="event created"} message Success message 91 | * @apiSuccess (201) {Object} data Data of the response 92 | * @apiSuccess (201) {String} data.id ID of the created event 93 | * 94 | * @apiUse jwtAuth 95 | */ 96 | router.post('/', authenticate, async (req, res) => { 97 | const body = req.body; 98 | const username = res.locals.jwtPayload.username as string; 99 | 100 | const validationStatus = await validate(body); 101 | if (validationStatus[0] !== true) { 102 | res.status(validationStatus[1]).json({ 103 | status: 'error', 104 | message: validationStatus[0], 105 | }); 106 | return; 107 | } 108 | 109 | const userObj = await findUsername(username); 110 | if (!userObj) { 111 | res.status(500).json({ 112 | status: 'error', 113 | message: 'user does not exist', 114 | }); 115 | return; 116 | } 117 | const userIsMember = await isUserMemberOfClass( 118 | username, 119 | body.class as string, 120 | ); 121 | if (!userIsMember) { 122 | res.status(403).json({ 123 | status: 'error', 124 | message: 'user is not a member of the class', 125 | }); 126 | return; 127 | } 128 | 129 | const classObj = validationStatus[1]; 130 | 131 | const editors = [userObj._id]; 132 | const classId = classObj._id; 133 | const bodyLocation = body.location; 134 | const location = bodyLocation ? bodyLocation + '' : null; 135 | 136 | const event: CalEvent = { 137 | title: body.title as string, 138 | description: body.description as string, 139 | date: body.date as any, 140 | duration: body.duration as number, 141 | subject: body.subject as string, 142 | editors, 143 | location, 144 | editedAt: [new Date().getTime()], 145 | class: classId, 146 | }; 147 | 148 | createEvent(event) 149 | .then((result) => { 150 | res.status(201).json({ 151 | status: 'success', 152 | message: 'event created', 153 | data: { 154 | id: result.insertedId, 155 | }, 156 | }); 157 | }) 158 | .catch(() => { 159 | res.status(500).json({ 160 | status: 'error', 161 | message: 'internal server error', 162 | }); 163 | }); 164 | }); 165 | 166 | export default router; 167 | -------------------------------------------------------------------------------- /src/routes/events/deleteEvent.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { ObjectId } from 'mongodb'; 3 | import { z } from 'zod'; 4 | import { eventsCollection } from '../../database/events/event'; 5 | import authenticate from '../../middleware/auth'; 6 | 7 | const router = express.Router(); 8 | 9 | /** 10 | * @api {delete} /events/:id Delete event 11 | * @apiName DeleteEvent 12 | * @apiGroup Events 13 | * @apiDescription Delete event 14 | * 15 | * @apiParam {String} id Event id 16 | * 17 | * @apiSuccess (200) {String=success} status success 18 | * @apiSuccess (200) {String="Event deleted successfully"} message Event deleted successfully 19 | * 20 | * @apiError (400) {String=error} status error 21 | * @apiError (400) {String="Invalid id"} message A short message describing the error 22 | * @apiError (400) {String[]} errors An array of all validation errors 23 | * 24 | * @apiError (404) {String=error} status error 25 | * @apiError (404) {String="Event not found"} message Event not found 26 | * 27 | * @apiUse jwtAuth 28 | */ 29 | router.delete('/:id', authenticate, async (req, res) => { 30 | const idParam = req.params.id; 31 | 32 | const idZ = z 33 | .string() 34 | .refine((val) => ObjectId.isValid(val), { message: 'Invalid id' }); 35 | 36 | const zRes = idZ.safeParse(idParam); 37 | 38 | if (!zRes.success) { 39 | const errorMessages = zRes.error.issues.map((issue) => issue.message); 40 | return res.status(400).json({ 41 | status: 'error', 42 | message: zRes.error.issues[0].message, 43 | errors: errorMessages, 44 | }); 45 | } 46 | 47 | const data = ( 48 | await eventsCollection.findOneAndDelete({ 49 | _id: new ObjectId(zRes.data), 50 | }) 51 | ).value; 52 | 53 | if (!data) { 54 | return res.status(404).json({ 55 | status: 'error', 56 | message: 'Event not found', 57 | }); 58 | } 59 | 60 | res.status(200).json({ 61 | status: 'success', 62 | message: 'Event deleted successfully', 63 | }); 64 | }); 65 | 66 | export default router; 67 | -------------------------------------------------------------------------------- /src/routes/events/get/csv.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { eventsCollection } from '../../../database/events/event'; 3 | 4 | const generateCsvRow = (cols: string[], delimiter = '|') => 5 | cols.join(delimiter); 6 | 7 | const generateCsv = (rows: string[][], delimiter = '|') => 8 | rows.map((row) => generateCsvRow(row, delimiter)).join('\n'); 9 | 10 | const router = express.Router(); 11 | 12 | router.get('/', async (req, res) => { 13 | const csvCols = [ 14 | 'id', 15 | 'title', 16 | 'description', 17 | 'date:year', 18 | 'date:month', 19 | 'date:day', 20 | 'date:hour', 21 | 'date:minute', 22 | 'duration', 23 | 'location', 24 | 'subject', 25 | 'editors', 26 | 'editedAt', 27 | 'classId', 28 | ] as const; 29 | const csvRows: string[][] = [[...csvCols]]; 30 | 31 | const events = (await eventsCollection.find({}).toArray()).map((e) => { 32 | let { _id, date, class: classId } = e; 33 | let { year, month, day, hour, minute } = date; 34 | 35 | return { 36 | ...e, 37 | id: _id, 38 | 'date:year': year, 39 | 'date:month': month, 40 | 'date:day': day, 41 | 'date:hour': hour, 42 | 'date:minute': minute, 43 | classId, 44 | }; 45 | }); 46 | 47 | events.forEach((e) => { 48 | let csvRowCols: string[] = []; 49 | csvCols.forEach((col) => { 50 | let string = ''; 51 | const base = e[col]; 52 | if (Array.isArray(base)) string = base.join(','); 53 | else if (base === null || base === undefined) string = ''; 54 | else string = base + ''; 55 | 56 | csvRowCols.push(string); 57 | }); 58 | csvRows.push(csvRowCols); 59 | }); 60 | 61 | res.setHeader('Content-Type', 'text/csv'); 62 | res.send(generateCsv(csvRows, (req.query.delimiter as string) || '|')); 63 | }); 64 | 65 | export default router; 66 | -------------------------------------------------------------------------------- /src/routes/events/get/ical.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { findClassBySchoolNameAndClassName } from '../../../database/classes/findClass'; 3 | import { eventsCollection } from '../../../database/events/event'; 4 | import ical, { ICalCategory } from 'ical-generator'; 5 | import { dateTimeToDate } from '../../../utils/date'; 6 | 7 | const generateTimezoneString = (timezoneOffsetMin: number): string => { 8 | const timezoneOffsetHours = timezoneOffsetMin / 60; 9 | 10 | const offsetIsPositive = timezoneOffsetHours > 0; 11 | const prefix = offsetIsPositive ? '+' : '-'; 12 | const offsetHours = Math.floor(Math.abs(timezoneOffsetHours)); 13 | 14 | return `Etc/GMT${prefix}${offsetHours}`; 15 | }; 16 | 17 | const router = express.Router(); 18 | 19 | router.get('/', async (req, res) => { 20 | const schoolName = req.query.school as string; 21 | const className = req.query.class as string; 22 | 23 | const ifModifiedSince = req.headers['if-modified-since']; 24 | const ifModifiedSinceExists = ifModifiedSince !== undefined; 25 | 26 | const ifModifiedDate = new Date( 27 | ifModifiedSinceExists ? ifModifiedSince : 0, 28 | ); 29 | const timezoneOffsetMin = ifModifiedDate.getTimezoneOffset(); 30 | const timezoneString = generateTimezoneString(timezoneOffsetMin); 31 | 32 | let filter = {}; 33 | 34 | if (typeof schoolName === 'string' && typeof className === 'string') { 35 | const classObj = await findClassBySchoolNameAndClassName( 36 | schoolName, 37 | className, 38 | ); 39 | 40 | if (!classObj) { 41 | res.status(404).json({ 42 | status: 'error', 43 | message: 'Class not found', 44 | }); 45 | return; 46 | } 47 | filter = { class: classObj._id }; 48 | } 49 | 50 | const data = await eventsCollection.find(filter).toArray(); 51 | 52 | const cal = ical({ 53 | name: 'Dlool - Events', 54 | timezone: timezoneString, 55 | }); 56 | 57 | data.forEach((event) => { 58 | const startDate = dateTimeToDate(event.date); 59 | const endDate = new Date(startDate.getTime() + event.duration * 1000); 60 | const description = `${event.description}\n\nDlool - Your colloborative homework manager`; 61 | 62 | cal.createEvent({ 63 | start: startDate, 64 | end: endDate, 65 | summary: `${event.subject} - ${event.title}`, 66 | description: description, 67 | location: event.location, 68 | allDay: false, 69 | categories: [new ICalCategory({ name: event.subject })], 70 | created: new Date(event.editedAt[0]), 71 | lastModified: new Date(event.editedAt[event.editedAt.length - 1]), 72 | }); 73 | }); 74 | 75 | res.set('Content-Type', 'text/calendar'); 76 | res.status(200).send(cal.toString()); 77 | }); 78 | 79 | export default router; 80 | -------------------------------------------------------------------------------- /src/routes/events/get/json.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import pagination from '../../../middleware/pagination'; 3 | import { 4 | findClassBySchoolNameAndClassName, 5 | getClassAndSchoolNameById, 6 | } from '../../../database/classes/findClass'; 7 | import { getPaginationPageCount } from '../../../database/utils/getPaginatedData'; 8 | import { eventsCollection } from '../../../database/events/event'; 9 | import { findUserById } from '../../../database/user/findUser'; 10 | import { UserWithId } from '../../../database/user/user'; 11 | 12 | const router = express.Router(); 13 | 14 | router.get('/', pagination, async (req, res) => { 15 | const schoolName = req.query.school as string; 16 | const className = req.query.class as string; 17 | 18 | let filter = {}; 19 | 20 | if (typeof schoolName === 'string' && typeof className === 'string') { 21 | const classObj = await findClassBySchoolNameAndClassName( 22 | schoolName, 23 | className, 24 | ); 25 | 26 | if (!classObj) { 27 | res.status(404).json({ 28 | status: 'error', 29 | message: 'Class not found', 30 | }); 31 | return; 32 | } 33 | filter = { class: classObj._id }; 34 | } 35 | 36 | const currentDateTime = new Date(); 37 | const currentTimestamp = currentDateTime.getTime(); 38 | const currentPage = res.locals.pagination.page as number; 39 | const pageSize = res.locals.pagination.pageSize as number; 40 | 41 | const data = await eventsCollection 42 | .aggregate([ 43 | { 44 | $match: filter, 45 | }, 46 | 47 | { 48 | $addFields: { 49 | jsDate: { 50 | $dateFromParts: { 51 | year: '$date.year', 52 | month: '$date.month', 53 | day: '$date.day', 54 | hour: '$date.hour', 55 | minute: '$date.minute', 56 | }, 57 | }, 58 | }, 59 | }, 60 | { 61 | $addFields: { 62 | timestamp: { $toLong: '$jsDate' }, 63 | }, 64 | }, 65 | { 66 | $addFields: { 67 | timeDifference: { 68 | $abs: { $subtract: ['$timestamp', currentTimestamp] }, 69 | }, 70 | }, 71 | }, 72 | { 73 | $sort: { 74 | timeDifference: 1, 75 | }, 76 | }, 77 | 78 | // remove the new fields 79 | { 80 | $project: { 81 | jsDate: 0, 82 | timestamp: 0, 83 | timeDifference: 0, 84 | }, 85 | }, 86 | 87 | { 88 | $skip: (currentPage - 1) * pageSize, 89 | }, 90 | { 91 | $limit: pageSize, 92 | }, 93 | ]) 94 | .toArray(); 95 | 96 | const mappedDataPromises = data.map(async (event) => { 97 | const classId = event.class; 98 | const schoolAndClassData = await getClassAndSchoolNameById(classId); 99 | if (!schoolAndClassData) { 100 | return event; 101 | } 102 | const { schoolName, className } = schoolAndClassData; 103 | 104 | const editorObjs = await Promise.all(event.editors.map(findUserById)); 105 | const editors = editorObjs.map((editor) => (editor as UserWithId).name); 106 | 107 | return { 108 | ...event, 109 | school: schoolName, 110 | class: className, 111 | editors, 112 | }; 113 | }); 114 | 115 | const mappedData = await Promise.all(mappedDataPromises); 116 | const pageCount = await getPaginationPageCount( 117 | eventsCollection, 118 | pageSize, 119 | filter, 120 | ); 121 | 122 | res.status(200).json({ 123 | status: 'success', 124 | message: 'Events found', 125 | data: { 126 | events: mappedData, 127 | pageCount, 128 | }, 129 | }); 130 | }); 131 | 132 | export default router; 133 | -------------------------------------------------------------------------------- /src/routes/events/get/xml/generateXml.ts: -------------------------------------------------------------------------------- 1 | import { eventsCollection } from '../../../../database/events/event'; 2 | 3 | export const generateXml = async () => { 4 | const events = await eventsCollection.find({}).toArray(); 5 | 6 | const xmlItems = events.map( 7 | (e) => 8 | ` 9 | ${e.title} 10 | ${e.description} 11 | 12 | ${e.date.year} 13 | ${e.date.month} 14 | ${e.date.day} 15 | ${e.date.hour} 16 | ${e.date.minute} 17 | 18 | ${e.duration} 19 | ${e.location} 20 | ${e.subject} 21 | ${e.class} 22 | ${e.editors.join(',')} 23 | ${e.editedAt.join(',')} 24 | `, 25 | ); 26 | 27 | return ` 28 | 29 | ${xmlItems.join('\n')} 30 | `; 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/events/get/xml/humanReadable.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import formatXml from 'xml-formatter'; 3 | import { generateXml } from './generateXml'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/', async (req, res) => { 8 | const xml = await generateXml(); 9 | 10 | res.set('Content-Type', 'text/xml'); 11 | res.send(formatXml(xml)); 12 | }); 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /src/routes/events/get/xml/minified.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import formatXml from 'xml-formatter'; 3 | import { generateXml } from './generateXml'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/', async (req, res) => { 8 | const xml = await generateXml(); 9 | 10 | res.set('Content-Type', 'text/xml'); 11 | res.send(formatXml.minify(xml)); 12 | }); 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /src/routes/events/getEvents.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import getAsJsonRouter from './get/json'; 4 | import getAsIcalRouter from './get/ical'; 5 | import getAsCsvRouter from './get/csv'; 6 | import getAsMinifiedXmlRouter from './get/xml/minified'; 7 | import getAsHumanReadableXmlRouter from './get/xml/humanReadable'; 8 | 9 | const router = express.Router(); 10 | 11 | /** 12 | * @api {get} /events Get events 13 | * @apiName GetEvents 14 | * @apiGroup Events 15 | * @apiDescription Get events sorted by newest to oldest. Can be filtered by school and class and is paginated. 16 | * Currently only supports JSON format but with the Accept header, it will support more formats in the future. 17 | * Please note that the only documented format is JSON. If you use any other format, it will work but it wont be documented. All of the errors will be in JSON format. 18 | * 19 | * @apiQuery {String} [school] Filter events by school. If not both school and class are provided, all events will be returned. 20 | * @apiQuery {String} [class] Filter events by class. If not both school and class are provided, all events will be returned. 21 | * 22 | * @apiHeader {String="application/json" "text/calendar"} [Accept="application/json"] The requested format. Currently only JSON is supported. I plan to support iCal very very soon and maybe RSS, CSV and XML in the future. 23 | * Please note that the only documented format is JSON. If you use any other format, it will work but it wont be documented. All of the errors will be in JSON format. 24 | * If you have an idea how to document other formats, please open an issue on the backend repo on GitHub. ;) 25 | * 26 | * @apiError (404) {String="error"} status The status of the request 27 | * @apiError (404) {String="Class not found"} message The error message. The request must specify both a school and class for this error to occur. 28 | * 29 | * @apiSuccess (200) {String="success"} status The status of the request 30 | * @apiSuccess (200) {Number} pageCount The number of pages 31 | * @apiSuccess (200) {Object} data The events 32 | * @apiSuccess (200) {Object[]} data.events The actual events 33 | * @apiSuccess (200) {String} data.events.title The title of the event 34 | * @apiSuccess (200) {String} data.events.description The description of the event 35 | * @apiSuccess (200) {Object} data.events.date The start date of the event 36 | * @apiSuccess (200) {Number} data.events.date.year The year of the event 37 | * @apiSuccess (200) {Number{1-12}} data.events.date.month The month of the event 38 | * @apiSuccess (200) {Number{1-31}} data.events.date.day The day of the event 39 | * @apiSuccess (200) {Number{0-23}} data.events.date.hour The hour of the event 40 | * @apiSuccess (200) {Number{0-59}} data.events.date.minute The minute of the event 41 | * @apiSuccess (200) {Number{0..}} data.events.duration The duration of the event in seconds 42 | * @apiSuccess (200) {String|Null} data.events.location The location of the event 43 | * @apiSuccess (200) {String} data.events.subject The subject of the event 44 | * @apiSuccess (200) {String} data.events.school The unique school name of the event 45 | * @apiSuccess (200) {String} data.events.class The class name of the event 46 | * @apiSuccess (200) {String[]} data.events.editors The usernames of the editors of the event 47 | * @apiSuccess (200) {String[]} data.events.editedAt The timestamps of the edits of the event 48 | * 49 | * @apiError (406) {String="error"} status The status of the request 50 | * @apiError (406) {String="Unsupported Accept header"} message The error message 51 | * 52 | * @apiUse pagination 53 | */ 54 | router.get('/', (req, res) => { 55 | res.format({ 56 | 'application/json': getAsJsonRouter, 57 | 'text/calendar': getAsIcalRouter, 58 | 'text/csv': getAsCsvRouter, 59 | 'application/xml': getAsMinifiedXmlRouter, 60 | 'text/xml': getAsHumanReadableXmlRouter, 61 | 62 | default: () => { 63 | res.status(406).json({ 64 | status: 'error', 65 | message: 'Unsupported Accept header', 66 | }); 67 | }, 68 | }); 69 | }); 70 | 71 | export default router; 72 | -------------------------------------------------------------------------------- /src/routes/events/router.ts: -------------------------------------------------------------------------------- 1 | import expess from 'express'; 2 | 3 | import createEventRouter from './createEvent'; 4 | import getEventsRouter from './getEvents'; 5 | import deleteEventRouter from './deleteEvent'; 6 | 7 | const router = expess.Router(); 8 | 9 | router.use('/', createEventRouter); 10 | router.use('/', getEventsRouter); 11 | router.use('/', deleteEventRouter); 12 | router.all('/', (req, res) => { 13 | res.status(405).json({ 14 | status: 'error', 15 | message: 'Method not allowed', 16 | }); 17 | }); 18 | 19 | export default router; 20 | -------------------------------------------------------------------------------- /src/routes/homework/calendar/calendar.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | getHomeworkForMultipleClasses, 4 | getHomeworkForUser, 5 | } from '../../../database/homework/findHomework'; 6 | 7 | import ical, { ICalCalendar, ICalEventTransparency } from 'ical-generator'; 8 | import { findUniqueSchool } from '../../../database/school/findSchool'; 9 | import { classesCollection } from '../../../database/classes/class'; 10 | import { ObjectId, Sort } from 'mongodb'; 11 | import { getFrontendUrlForHomework } from '../../../database/homework/getFrontendUrlForHomework'; 12 | import { generateIcal } from './generateIcal'; 13 | 14 | const router = express.Router(); 15 | 16 | /** 17 | * @api {get} /homework/calendar/:school Get homework calendar for a school 18 | * @apiName GetHomeworkCalendar 19 | * @apiGroup Homework 20 | * @apiDescription Get a calendar with all homework for a school in iCal format. IT IS NOT JSON!!! 21 | * @apiVersion 1.1.0 22 | * 23 | * @apiParam {String} school The unique name of the school to get the calendar for 24 | * @apiQuery {String[]} classes A comma separated list of classes to get the calendar for 25 | * 26 | * @apiSuccess (200) {String} The iCal calendar, in text/calendar format NOTE THAT THIS IS NOT JSON!!! 27 | * 28 | * @apiError (404) {String} status The status of the response 29 | * @apiError (404) {String} message The error message 30 | * 31 | * @apiErrorExample {json} 404 - School not found: 32 | * HTTP/1.1 404 Not Found 33 | * { 34 | * "status": "error", 35 | * "message": "School not found" 36 | * } 37 | * @apiErrorExample {json} 404 - No classes found: 38 | * HTTP/1.1 404 Not Found 39 | * { 40 | * "status": "error", 41 | * "message": "No classes found" 42 | * } 43 | */ 44 | router.get('/calendar/:school', async (req, res) => { 45 | const school = req.params.school; 46 | const rawClasses = req.query.classes; 47 | const classes = rawClasses ? (rawClasses as string).split(',') : []; 48 | 49 | const schoolObj = await findUniqueSchool(school); 50 | 51 | if (!schoolObj) { 52 | return res.status(404).json({ 53 | status: 'error', 54 | message: 'School not found', 55 | }); 56 | } 57 | 58 | const classIdsPromises = classes.map((className: string) => { 59 | const class_ = classesCollection 60 | .findOne({ name: className, school: schoolObj._id }) 61 | .then((class_) => class_?._id ?? null); 62 | return class_; 63 | }); 64 | 65 | const classIds = (await Promise.all(classIdsPromises)).filter( 66 | (id) => id !== null, 67 | ); 68 | 69 | if (classIds.length === 0) { 70 | return res.status(404).json({ 71 | status: 'error', 72 | message: 'No classes found', 73 | }); 74 | } 75 | 76 | const cal = generateIcal(school, classes) as Promise; 77 | 78 | res.set('Content-Type', 'text/calendar'); 79 | res.status(200).send(cal.toString()); 80 | }); 81 | 82 | export default router; 83 | -------------------------------------------------------------------------------- /src/routes/homework/calendar/generateIcal.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { classesCollection } from '../../../database/classes/class'; 3 | import { getHomeworkForMultipleClasses } from '../../../database/homework/findHomework'; 4 | import { findUniqueSchool } from '../../../database/school/findSchool'; 5 | import ical, { ICalEventTransparency } from 'ical-generator'; 6 | import { getFrontendUrlForHomework } from '../../../database/homework/getFrontendUrlForHomework'; 7 | 8 | export const generateIcal = async (school: string, classes: string[]) => { 9 | const schoolObj = await findUniqueSchool(school); 10 | if (!schoolObj) return null; 11 | 12 | const classIdsPromises = classes.map((className: string) => { 13 | const class_ = classesCollection 14 | .findOne({ name: className, school: schoolObj._id }) 15 | .then((class_) => class_?._id ?? null); 16 | return class_; 17 | }); 18 | 19 | const classIds = (await Promise.all(classIdsPromises)).filter( 20 | (id) => id !== null, 21 | ); 22 | 23 | if (classIds.length === 0) return null; 24 | 25 | const homework = await getHomeworkForMultipleClasses( 26 | classIds as ObjectId[], 27 | ); 28 | 29 | const cal = ical({ 30 | name: 'Homework', 31 | }); 32 | 33 | const homeworkIds = homework.map((hw) => hw._id); 34 | const frontendUrlsPromises = homeworkIds.map((id) => 35 | getFrontendUrlForHomework(id), 36 | ); 37 | const frontendUrls = await Promise.all(frontendUrlsPromises); 38 | 39 | let i = 0; 40 | homework.forEach((hw) => { 41 | const url = frontendUrls[i]; 42 | i++; 43 | hw.assignments.forEach((assignment) => { 44 | const dueDate = new Date( 45 | assignment.due.year, 46 | assignment.due.month - 1, 47 | assignment.due.day, 48 | ); 49 | 50 | const description = `${assignment.description}\n\nDlool - Your colloborative homework manager`; 51 | 52 | cal.createEvent({ 53 | start: dueDate, 54 | end: dueDate, 55 | allDay: true, 56 | 57 | summary: assignment.subject, 58 | description: description, 59 | 60 | url: url, 61 | 62 | transparency: ICalEventTransparency.TRANSPARENT, 63 | }); 64 | }); 65 | }); 66 | 67 | return cal; 68 | }; 69 | -------------------------------------------------------------------------------- /src/routes/homework/csv/generateCSV.ts: -------------------------------------------------------------------------------- 1 | import { WithId } from 'mongodb'; 2 | import { Homework } from '../../../database/homework/homework'; 3 | 4 | export const generateFullCsv = (homework: WithId[]) => { 5 | const csvCols = [ 6 | 'Parent Id', 7 | 'class', 8 | 'creator', 9 | 'createdAt', 10 | 11 | 'from:year', 12 | 'from:month', 13 | 'from:day', 14 | 15 | 'subject', 16 | 'description', 17 | 'due:year', 18 | 'due:month', 19 | 'due:day', 20 | ] as const; 21 | 22 | const csvRows: string[][] = [[...csvCols]]; 23 | 24 | const assignmentList = homework 25 | .map((hw) => { 26 | const { 27 | _id, 28 | assignments, 29 | class: classId, 30 | createdAt, 31 | creator, 32 | from, 33 | } = hw; 34 | 35 | return assignments.map((assignment) => ({ 36 | 'Parent Id': _id, 37 | class: classId, 38 | creator, 39 | createdAt, 40 | 41 | 'from:year': from.year, 42 | 'from:month': from.month, 43 | 'from:day': from.day, 44 | 45 | subject: assignment.subject, 46 | description: assignment.description, 47 | 'due:year': assignment.due.year, 48 | 'due:month': assignment.due.month, 49 | 'due:day': assignment.due.day, 50 | })); 51 | }) 52 | .flat(); 53 | 54 | assignmentList.forEach((assignment) => { 55 | const csvRowCols: string[] = []; 56 | csvCols.forEach((col) => csvRowCols.push(assignment[col] + '')); 57 | csvRows.push(csvRowCols); 58 | }); 59 | 60 | return csvRows 61 | .map((row) => row.map((col) => col.replace(/\|/g, ' ')).join('|')) 62 | .join('\n'); 63 | }; 64 | -------------------------------------------------------------------------------- /src/routes/homework/deleteHomework.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { deleteHomework } from '../../database/homework/deleteHomework'; 3 | import { ObjectId } from 'mongodb'; 4 | import authenticate from '../../middleware/auth'; 5 | 6 | const router = express.Router(); 7 | 8 | /** 9 | * @api {delete} /homework/:id Delete homework 10 | * @apiName DeleteHomework 11 | * @apiGroup Homework 12 | * @apiDescription Deletes a homework 13 | * 14 | * @apiParam {String} id Homework id 15 | * 16 | * @apiSuccess (200) {String} status success 17 | * @apiSuccess (200) {String} message Homework deleted successfully 18 | * 19 | * @apiError (400) {String} status error 20 | * @apiError (400) {String} message A short message describing the error 21 | * 22 | * @apiError (404) {String} status error 23 | * @apiError (404) {String} message Homework not found 24 | * 25 | * @apiUse jwtAuth 26 | */ 27 | router.delete('/:id', authenticate, async (req, res) => { 28 | const id = req.params.id; 29 | 30 | if (!id) { 31 | return res.status(400).json({ 32 | status: 'error', 33 | message: 'Missing homework id', 34 | }); 35 | } 36 | 37 | if (!ObjectId.isValid(id)) { 38 | return res.status(400).json({ 39 | status: 'error', 40 | message: 'Invalid homework id', 41 | }); 42 | } 43 | 44 | const deletedHomework = await deleteHomework(new ObjectId(id)); 45 | 46 | if (!deletedHomework) { 47 | return res.status(404).json({ 48 | status: 'error', 49 | message: 'Homework not found', 50 | }); 51 | } 52 | 53 | return res.status(200).json({ 54 | status: 'success', 55 | message: 'Homework deleted successfully', 56 | }); 57 | }); 58 | 59 | export default router; 60 | -------------------------------------------------------------------------------- /src/routes/homework/getAllHomework.ts: -------------------------------------------------------------------------------- 1 | import { findUniqueSchool } from '../../database/school/findSchool'; 2 | import { findClass } from '../../database/classes/findClass'; 3 | import express from 'express'; 4 | import { getHomeworkByClass } from '../../database/homework/findHomework'; 5 | import { z } from 'zod'; 6 | import { generateIcal } from './calendar/generateIcal'; 7 | import { generateFullCsv } from './csv/generateCSV'; 8 | 9 | const router = express.Router(); 10 | 11 | /** 12 | * @api {GET} /homework/all?class=:class&school=:school Get all homework 13 | * @apiName Get all homework 14 | * @apiGroup Homework 15 | * @apiVersion 1.0.0 16 | * 17 | * @apiQuery {String} :class The name of the class 18 | * @apiQuery {String} :school The name of the school 19 | * 20 | * @apiError (400) {String} status The status of the request (error) 21 | * @apiError (400) {String} message A short explaination of the error 22 | * 23 | * @apiErrorExample {json} 400 - Missing query parameter: 24 | * HTTP/1.1 400 Bad Request 25 | * { 26 | * "status": "error", 27 | * "message": "No class was given" 28 | * } 29 | * @apiErrorExample {json} 400 - The school doesn't exist: 30 | * HTTP/1.1 400 Bad Request 31 | * { 32 | * "status": "error", 33 | * "message": "The school Hogwarts does not exist" 34 | * } 35 | * @apiErrorExample {json} 400 - The class doesn't exist: 36 | * HTTP/1.1 400 Bad Request 37 | * { 38 | * "status": "error", 39 | * "message": "The class 1a does not exist in the school Hogwarts" 40 | * } 41 | * 42 | * 43 | * @apiSuccess (200) {String} status A status (success) 44 | * @apiSuccess (200) {String} message A short explaination (Homework found) 45 | * @apiSuccess (200) {Object[]} data The actual data 46 | * @apiSuccess (200) {String} data.creator The MongoDB ID of the creator of that homework 47 | * @apiSuccess (200) {String} data.class The MongoDB ID of the class that homework is for 48 | * @apiSuccess (200) {Number} data.createdAt The UNIX timestamp in milliseconds at which the homework was added to the system 49 | * @apiSuccess (200) {Object} data.from The date the homework is from 50 | * @apiSuccess (200) {Number} data.from.year The year the homework is from 51 | * @apiSuccess (200) {Number} data.from.month 52 | * @apiSuccess (200) {Number} data.from.day 53 | * @apiSuccess (200) {Object[]} data.assignments The assignments for the given date 54 | * @apiSuccess (200) {String} data.assignments.subject The subject of the assignment, e.g. math 55 | * @apiSuccess (200) {String} data.assignments.description A short explanation what the task is 56 | * @apiSuccess (200) {Object} data.assignments.due The date the assignment is due to 57 | * @apiSuccess (200) {Number} data.assignments.due.year 58 | * @apiSuccess (200) {Number} data.assignments.due.month 59 | * @apiSuccess (200) {Number} data.assignments.due.day 60 | * @apiSuccessExample {json} Success-Response: 61 | * HTTP/1.1 200 Success 62 | * { 63 | * "status": "success", 64 | * "message": "Homework found", 65 | * "data": [ 66 | * { 67 | * "creator": "MongoDB ObjectID", 68 | * "class": "MongoDB ObjectID", 69 | * "createdAt": 0, 70 | * "from": { 71 | * "year": 2023, 72 | * "month": 6, 73 | * "day": 28 74 | * }, 75 | * "assignments": [ 76 | * "subject": "Math", 77 | * "descirption": "Book page 2", 78 | * "due": { 79 | * "year": 2023, 80 | * "month": 7, 81 | * "day": 28 82 | * } 83 | * ] 84 | * } 85 | * ] 86 | * } 87 | */ 88 | router.get('/', async (req, res) => { 89 | const genErrorMessages = (keyname: string, type = 'string') => ({ 90 | invalid_type_error: `Expected ${keyname} to be of type ${type}`, 91 | required_error: `No ${keyname} was given`, 92 | }); 93 | 94 | const schema = z.object({ 95 | class: z.string(genErrorMessages('class')), 96 | school: z.string(genErrorMessages('school')), 97 | }); 98 | 99 | const result = schema.safeParse(req.query); 100 | 101 | if (!result.success) { 102 | const fieldErrors = result.error.flatten().fieldErrors; 103 | 104 | const errors = Object.values(fieldErrors); 105 | const flatErrors = errors.reduce((acc, val) => acc.concat(val), []); 106 | 107 | res.status(400).json({ 108 | status: 'error', 109 | message: flatErrors[0], 110 | errors: fieldErrors, 111 | }); 112 | return; 113 | } 114 | 115 | const className = result.data.class; 116 | const schoolName = result.data.school; 117 | 118 | const school = await findUniqueSchool(schoolName); 119 | if (!school) { 120 | res.status(400).json({ 121 | status: 'error', 122 | message: `The school ${schoolName} does not exist`, 123 | }); 124 | return; 125 | } 126 | 127 | const classObj = await findClass(school, className); 128 | 129 | if (!classObj) { 130 | res.status(400).json({ 131 | status: 'error', 132 | message: `The class ${className} does not exist in the school ${schoolName}`, 133 | }); 134 | return; 135 | } 136 | 137 | const homework = await getHomeworkByClass(classObj._id); 138 | 139 | res.format({ 140 | 'application/json': () => { 141 | res.status(200).json({ 142 | status: 'success', 143 | message: 'Homework found', 144 | data: homework, 145 | }); 146 | return; 147 | }, 148 | 'text/calendar': async () => { 149 | const cal = await generateIcal(schoolName, [className]); 150 | if (!cal) { 151 | res.status(500).json({ 152 | status: 'error', 153 | message: 'Internal server error', 154 | }); 155 | return; 156 | } 157 | res.status(200) 158 | .setHeader('Content-Type', 'text/calendar') 159 | .send(cal.toString()); 160 | }, 161 | // csv 162 | 'text/csv': () => 163 | res 164 | .status(200) 165 | .setHeader('Content-Type', 'text/csv') 166 | .send(generateFullCsv(homework)), 167 | 168 | default: () => { 169 | res.status(200).json({ 170 | status: 'success', 171 | message: 'Homework found', 172 | data: homework, 173 | }); 174 | return; 175 | }, 176 | }); 177 | }); 178 | 179 | export default router; 180 | -------------------------------------------------------------------------------- /src/routes/homework/getPaginatedHomework.ts: -------------------------------------------------------------------------------- 1 | import { findUniqueSchool } from '../../database/school/findSchool'; 2 | import { findClass } from '../../database/classes/findClass'; 3 | import express from 'express'; 4 | import { getPaginatedData } from '../../database/utils/getPaginatedData'; 5 | import { homeworkCollection } from '../../database/homework/homework'; 6 | import pagination from '../../middleware/pagination'; 7 | import { z } from 'zod'; 8 | 9 | const router = express.Router(); 10 | 11 | /** 12 | * @api {GET} /homework?class=:class&school=:school&page=:page&pageSize=:pageSize Get homework 13 | * @apiName Get homework 14 | * @apiGroup Homework 15 | * @apiVersion 1.0.0 16 | * 17 | * @apiQuery {String} :class The name of the class 18 | * @apiQuery {String} :school The name of the school 19 | * 20 | * @apiExample {curl} Example usage - curl: 21 | * curl http://localhost:3000/homework?class=1a&school=Hogwarts&page=1&pageSize=10 22 | * @apiExample {python} Example usage - python: 23 | * import requests 24 | * school = 'Hogwarts' 25 | * className = '1a' 26 | * page = 1 27 | * page_size = 10 28 | * response = requests.get(f'http://localhost:3000/homework?class={className}&school={school}&page={page}&pageSize={page_size}') 29 | * print(response.json()) 30 | * @apiExample {javascript} Example usage - javascript: 31 | * const response = await fetch('http://localhost:3000/homework?class=1a&school=Hogwarts&page=1&pageSize=10'); 32 | * const data = await response.json(); 33 | * console.log(data); 34 | * @apiExample {v} Example usage - v: 35 | * import net.http 36 | * school := 'Hogwarts' 37 | * class_name := '1a' 38 | * page := 1 39 | * page_size := 10 40 | * resp := http.get('http://localhost:3000/homework?class=${class_name}&school=${school}&page=${page}&pageSize=${page_size}')! 41 | * println(resp.body) 42 | * 43 | * @apiError (400) {String} status The status of the request (error) 44 | * @apiError (400) {String} error A short explaination of the error 45 | * 46 | * @apiErrorExample {json} 400 - Missing query parameter 47 | * HTTP/1.1 400 Bad Request 48 | * { 49 | * "status": "error", 50 | * "error": "Missing query parameter class" 51 | * } 52 | * 53 | * @apiErrorExample {json} 400 - The school doesn't exist 54 | * HTTP/1.1 400 Bad Request 55 | * { 56 | * "status": "error", 57 | * "error": "The school Hogwarts does not exist" 58 | * } 59 | * 60 | * @apiErrorExample {json} 400 - The class doesn't exist 61 | * HTTP/1.1 400 Bad Request 62 | * { 63 | * "status": "error", 64 | * "error": "The class 1a does not exist in the school Hogwarts" 65 | * } 66 | * 67 | * 68 | * 69 | * @apiSuccess (200) {String} status A status (success) 70 | * @apiSuccess (200) {String} message A short explaination (Homework found) 71 | * 72 | * @apiSuccess (200) {Object[]} data The actual data 73 | * @apiSuccess (200) {Number} data.totalPageCount The total amount of pages with the given page size 74 | * 75 | * @apiSuccess (200) {String} data.homework.creator The MongoDB ID of the creator of that homework 76 | * @apiSuccess (200) {String} data.homework.class The MongoDB ID of the class that homework is for 77 | * @apiSuccess (200) {Number} data.homework.createdAt The UNIX timestamp in milliseconds at which the homework was added to the system 78 | * @apiSuccess (200) {Object} data.homework.from The date the homework is from 79 | * @apiSuccess (200) {Number} data.homework.from.year The year the homework is from 80 | * @apiSuccess (200) {Number} data.homework.from.month 81 | * @apiSuccess (200) {Number} data.homework.from.day 82 | * @apiSuccess (200) {Object[]} data.homework.assignments The assignments for the given date 83 | * @apiSuccess (200) {String} data.homework.assignments.subject The subject of the assignment, e.g. math 84 | * @apiSuccess (200) {String} data.homework.assignments.description A short explanation what the task is 85 | * @apiSuccess (200) {Object} data.homework.assignments.due The date the assignment is due to 86 | * @apiSuccess (200) {Number} data.homework.assignments.due.year 87 | * @apiSuccess (200) {Number} data.homework.assignments.due.month 88 | * @apiSuccess (200) {Number} data.homework.assignments.due.day 89 | * 90 | * @apiUse pagination 91 | */ 92 | router.get('/', pagination, async (req, res) => { 93 | const schoolError = 'Missing query parameter school'; 94 | const classError = 'Missing query parameter class'; 95 | 96 | const schema = z.object({ 97 | class: z 98 | .string({ required_error: classError }) 99 | .min(1, { message: classError }), 100 | school: z 101 | .string({ required_error: schoolError }) 102 | .min(1, { message: schoolError }) 103 | .refine( 104 | async (sch) => await findUniqueSchool(sch), 105 | (sch) => ({ message: `The school ${sch} does not exist` }), 106 | ), 107 | }); 108 | 109 | const result = await schema.safeParseAsync(req.query); 110 | 111 | if (!result.success) { 112 | res.status(400).json({ 113 | status: 'error', 114 | message: result.error.issues[0].message, 115 | }); 116 | return; 117 | } 118 | 119 | const className = result.data.class; 120 | const schoolName = result.data.school; 121 | 122 | const school = await findUniqueSchool(schoolName); 123 | const classObj = await findClass(school, className); 124 | 125 | if (!classObj) { 126 | res.status(400).json({ 127 | status: 'error', 128 | message: `The class ${className} does not exist in the school ${schoolName}`, 129 | }); 130 | return; 131 | } 132 | 133 | const { page, pageSize } = res.locals.pagination; 134 | 135 | const homework = await getPaginatedData( 136 | homeworkCollection, 137 | page, 138 | pageSize, 139 | { 'from.year': -1, 'from.month': -1, 'from.day': -1 }, 140 | { 141 | class: classObj._id, 142 | }, 143 | ); 144 | 145 | res.status(200).json({ 146 | status: 'success', 147 | message: 'Homework found', 148 | data: { 149 | homework, 150 | totalPageCount: Math.ceil( 151 | (await homeworkCollection.countDocuments({ 152 | class: classObj._id, 153 | })) / pageSize, 154 | ), 155 | }, 156 | }); 157 | }); 158 | 159 | export default router; 160 | -------------------------------------------------------------------------------- /src/routes/homework/router.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import createHomeworkRouter from './createHomework'; 3 | import getAllHomeworkRouter from './getAllHomework'; 4 | import getPaginatedHomeworkRouter from './getPaginatedHomework'; 5 | import calendarRouter from './calendar/calendar'; 6 | import todoRouter from './todo/todo'; 7 | import updateHomeworkRouter from './updateHomework'; 8 | import deleteHomeworkRouter from './deleteHomework'; 9 | 10 | const router = express.Router(); 11 | 12 | router.use('/', createHomeworkRouter); 13 | router.use('/all', getAllHomeworkRouter); 14 | router.use('/', getPaginatedHomeworkRouter); 15 | router.use('/', calendarRouter); 16 | router.use('/', todoRouter); 17 | router.use('/', updateHomeworkRouter); 18 | router.use('/', deleteHomeworkRouter); 19 | router.all('/', (req, res) => { 20 | res.status(405).json({ 21 | status: 'error', 22 | message: 'Method not allowed', 23 | }); 24 | }); 25 | 26 | export default router; 27 | -------------------------------------------------------------------------------- /src/routes/homework/todo/todo.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { classesCollection } from '../../../database/classes/class'; 3 | import { findUniqueSchool } from '../../../database/school/findSchool'; 4 | import { getHomeworkForMultipleClasses } from '../../../database/homework/findHomework'; 5 | import { ObjectId } from 'mongodb'; 6 | import { getUniqueClassById } from '../../../database/classes/findClass'; 7 | 8 | const router = express.Router(); 9 | 10 | /** 11 | * @api {get} /homework/todo/:school Get homework todo for a school 12 | * @apiName GetHomeworkTodo 13 | * @apiGroup Homework 14 | * @apiDescription Get a todo list with all homework for a school in todo.txt format. IT IS NOT JSON!!! 15 | * 16 | * @apiParam {String} school The unique name of the school to get the todo list for 17 | * @apiQuery {String[]} classes A comma separated list of classes to get the todo list for 18 | * 19 | * @apiSuccess (200) {String} text The todo list, in text/plain format NOTE THAT THIS IS NOT JSON!!! 20 | * 21 | * @apiError (404) {String} status The status of the response 22 | * @apiError (404) {String} message The error message 23 | * 24 | * @apiErrorExample {json} 404 - School not found: 25 | * HTTP/1.1 404 Not Found 26 | * { 27 | * "status": "error", 28 | * "message": "School not found" 29 | * } 30 | * @apiErrorExample {json} 404 - No classes found: 31 | * HTTP/1.1 404 Not Found 32 | * { 33 | * "status": "error", 34 | * "message": "No classes found" 35 | * } 36 | */ 37 | router.get('/todo/:school', async (req, res) => { 38 | const school = req.params.school; 39 | const rawClasses = req.query.classes; 40 | const classes = rawClasses ? (rawClasses as string).split(',') : []; 41 | 42 | const schoolObj = await findUniqueSchool(school); 43 | 44 | if (!schoolObj) { 45 | return res.status(404).json({ 46 | status: 'error', 47 | message: 'School not found', 48 | }); 49 | } 50 | 51 | const classIdsPromises = classes.map((className: string) => { 52 | const class_ = classesCollection 53 | .findOne({ name: className, school: schoolObj._id }) 54 | .then((class_) => class_?._id ?? null); 55 | return class_; 56 | }); 57 | 58 | const classIds = (await Promise.all(classIdsPromises)).filter( 59 | (id) => id !== null, 60 | ); 61 | 62 | if (classIds.length === 0) { 63 | return res.status(404).json({ 64 | status: 'error', 65 | message: 'No classes found', 66 | }); 67 | } 68 | 69 | const homework = await getHomeworkForMultipleClasses( 70 | classIds as ObjectId[], 71 | ); 72 | 73 | const todos: any[] = homework.map(async (hw) => { 74 | let todo: string[] = []; 75 | const creationDate = `${hw.from.year}-${hw.from.month}-${hw.from.day}`; 76 | const className = (await getUniqueClassById(hw.class))?.name; 77 | hw.assignments.forEach((assignment) => { 78 | const dueDate = `${assignment.due.year}-${assignment.due.month}-${assignment.due.day}`; 79 | const project = `+${className} +${assignment.subject}`; 80 | const description = assignment.description; 81 | const todoAssignment = `${creationDate} ${description} ${project} due:${dueDate}`; 82 | todo.push(todoAssignment); 83 | }); 84 | return todo; 85 | }); 86 | const todosResolved = await Promise.all(todos); 87 | const todosFlattened = todosResolved.flat(); 88 | const todosString = todosFlattened.join('\n'); 89 | 90 | res.set('Content-Type', 'text/plain'); 91 | res.status(200); 92 | res.send(todosString); 93 | }); 94 | 95 | export default router; 96 | -------------------------------------------------------------------------------- /src/routes/notes/createNote.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import authenticate from '../../middleware/auth'; 3 | import { isDateValid, sortDate } from '../../utils/date'; 4 | import findUsername from '../../database/user/findUser'; 5 | import { getUniqueClassById } from '../../database/classes/findClass'; 6 | import { Note } from '../../database/notes/notes'; 7 | import { createNote } from '../../database/notes/createNote'; 8 | import { ObjectId } from 'mongodb'; 9 | 10 | const router = express.Router(); 11 | 12 | /** 13 | * @api {post} /notes Create a note 14 | * @apiName CreateNote 15 | * @apiGroup Notes 16 | * @apiDescription Create a note 17 | * @apiPermission authenticated 18 | * 19 | * @apiBody {String{64}} title Title of the note 20 | * @apiBody {String{512}} content Content of the note 21 | * @apiBody {Object} due Due date of the note 22 | * @apiBody {Number} due.year Year of the due date 23 | * @apiBody {Number} due.month Month of the due date 24 | * @apiBody {Number} due.day Day of the due date 25 | * @apiBody {String=private public} [visibility=public] Visibility of the note 26 | * @apiBody {String} [class] Class of the note 27 | * 28 | * @apiSuccess (201) {String=success} status success The status of the request 29 | * @apiSuccess (201) {String="Note created"} message A short message about the result 30 | * @apiSuccess (201) {Object} data The data of the result 31 | * @apiSuccess (201) {Object} data.note The note that was created 32 | * @apiSuccess (201) {String} data.note._id The ID of the note 33 | * @apiSuccess (201) {String} data.note.creator The ID of the creator of the note 34 | * @apiSuccess (201) {Number} data.note.createdAt The timestamp of when the note was created 35 | * @apiSuccess (201) {String{64}} data.note.title The title of the note 36 | * @apiSuccess (201) {String{512}} data.note.content The content of the note 37 | * @apiSuccess (201) {Object} data.note.due The due date of the note 38 | * @apiSuccess (201) {Number} data.note.due.year The year of the due date 39 | * @apiSuccess (201) {Number{1-12}} data.note.due.month The month of the due date 40 | * @apiSuccess (201) {Number{1-31}} data.note.due.day The day of the due date 41 | * @apiSuccess (201) {String=private public} data.note.visibility The visibility of the note 42 | * @apiSuccess (201) {String} [data.note.class] The ID of the class the note is associated with 43 | * 44 | * @apiError (400) {String=error} status error The status of the request 45 | * @apiError (400) {String="Invalid title or content" "Title is too long" "Content is too long" "Invalid visibility" "Invalid due date"} message A short message about the error 46 | * 47 | * @apiError (404) {String=error} status error The status of the request 48 | * @apiError (404) {String="Invalid class"} message A short message about the error 49 | * 50 | * @apiError (500) {String=error} status error The status of the request 51 | * @apiError (500) {String="Could not create note" "Could not find user"} message A short message about the error 52 | */ 53 | router.post('/', authenticate, async (req, res) => { 54 | const body = req.body; 55 | 56 | if (typeof body.title !== 'string' || typeof body.content !== 'string') { 57 | return res.status(400).json({ 58 | status: 'error', 59 | message: 'Invalid title or content', 60 | }); 61 | } else if (body.title.length > 64) { 62 | return res.status(400).json({ 63 | status: 'error', 64 | message: 'Title is too long', 65 | }); 66 | } else if (body.content.length > 512) { 67 | return res.status(400).json({ 68 | status: 'error', 69 | message: 'Content is too long', 70 | }); 71 | } else if (![undefined, 'public', 'private'].includes(body.visibility)) { 72 | return res.status(400).json({ 73 | status: 'error', 74 | message: 'Invalid visibility', 75 | }); 76 | } else if (!isDateValid(body.due)) { 77 | return res.status(400).json({ 78 | status: 'error', 79 | message: 'Invalid due date', 80 | }); 81 | } 82 | 83 | const username = res.locals.jwtPayload.username; 84 | const userPromise = findUsername(username); 85 | const userObj = await userPromise; 86 | if (!userObj) { 87 | return res.status(500).json({ 88 | status: 'error', 89 | message: 'Could not find user', 90 | }); 91 | } 92 | const userId = userObj._id; 93 | 94 | const classId = body.class as string | undefined; 95 | const isClassValid = !!classId && ObjectId.isValid(classId); 96 | if (isClassValid) { 97 | const classObjId = new ObjectId(classId); 98 | const classes = await getUniqueClassById(classObjId); 99 | if (!classes) { 100 | return res.status(404).json({ 101 | status: 'error', 102 | message: 'Invalid class', 103 | }); 104 | } 105 | } 106 | 107 | const note: Note = { 108 | creator: userId, 109 | createdAt: Date.now(), 110 | 111 | title: body.title, 112 | content: body.content, 113 | due: sortDate(body.due), 114 | visibility: body.visibility || 'public', 115 | class: isClassValid ? body.class : null, 116 | }; 117 | 118 | const newNote = await createNote(note); 119 | 120 | if (newNote) { 121 | return res.status(201).json({ 122 | status: 'success', 123 | message: 'Note created', 124 | data: { 125 | note: newNote, 126 | }, 127 | }); 128 | } else { 129 | return res.status(500).json({ 130 | status: 'error', 131 | message: 'Could not create note', 132 | }); 133 | } 134 | }); 135 | 136 | export default router; 137 | -------------------------------------------------------------------------------- /src/routes/notes/deleteNote.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { ObjectId } from 'mongodb'; 3 | import { noteCollection } from '../../database/notes/notes'; 4 | import authenticate from '../../middleware/auth'; 5 | import findUsername from '../../database/user/findUser'; 6 | 7 | const router = express.Router(); 8 | 9 | /** 10 | * @api {delete} /notes/:id Delete note 11 | * @apiName DeleteNote 12 | * @apiGroup Notes 13 | * @apiDescription Delete a note 14 | * When a note is deleted, it is deleted permanently. There is no way to recover it. 15 | * The note can only be deleted by the user who created it. 16 | * 17 | * @apiParam {String} id The ID of the note 18 | * 19 | * @apiSuccess {String="success"} status The status of the request 20 | * @apiSuccess {String="Note deleted"} message A message about the request status 21 | * 22 | * @apiError (400) {String=error} status The status of the request 23 | * @apiError (400) {String="Invalid ID"} message A message about the request status 24 | * 25 | * @apiError (404) {String=error} status The status of the request 26 | * @apiError (404) {String="Note not found"} message This can also occur if the note exists, but the user is not the creator of the note 27 | * 28 | * @apiError (500) {String=error} status The status of the request 29 | * @apiError (500) {String="User not found" "Internal server error"} message A message about the request status 30 | * 31 | * @apiUse jwtAuth 32 | */ 33 | router.delete('/:id', authenticate, async (req, res) => { 34 | //--//--// VALIDATION //--//--// 35 | const id = req.params.id; 36 | if (!ObjectId.isValid(id)) 37 | return res.sendStatus(400).json({ 38 | status: 'error', 39 | message: 'Invalid ID', 40 | }); 41 | 42 | const username = res.locals.jwtPayload?.username as string; 43 | const userObj = await findUsername(username); 44 | const userId = userObj?._id; 45 | if (!userId) { 46 | return res.status(500).json({ 47 | status: 'error', 48 | message: 'User not found', 49 | }); 50 | } 51 | 52 | const validNotes = await noteCollection.countDocuments({ 53 | _id: new ObjectId(id), 54 | creator: userId, 55 | }); 56 | if (validNotes !== 1) { 57 | return res.status(404).json({ 58 | status: 'error', 59 | message: 'Note not found', 60 | }); 61 | } 62 | 63 | //--//--// DELETE NOTE //--//--// 64 | noteCollection 65 | .deleteOne({ _id: new ObjectId(id) }) 66 | .then(() => { 67 | return res.status(200).json({ 68 | status: 'success', 69 | message: 'Note deleted', 70 | }); 71 | }) 72 | .catch(() => { 73 | return res.status(500).json({ 74 | status: 'error', 75 | message: 'Internal server error', 76 | }); 77 | }); 78 | }); 79 | 80 | export default router; 81 | -------------------------------------------------------------------------------- /src/routes/notes/getNotes.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import pagination from '../../middleware/pagination'; 3 | import { 4 | getPaginatedData, 5 | getPaginationPageCount, 6 | } from '../../database/utils/getPaginatedData'; 7 | import { noteCollection } from '../../database/notes/notes'; 8 | import { authenticateOptional } from '../../middleware/auth'; 9 | import findUsername from '../../database/user/findUser'; 10 | import { findClassBySchoolNameAndClassName } from '../../database/classes/findClass'; 11 | 12 | const router = express.Router(); 13 | 14 | /** 15 | * @api {get} /notes Get notes 16 | * @apiName GetNotes 17 | * @apiGroup Notes 18 | * @apiDescription Get a paginated list of notes 19 | * 20 | * @apiSuccess {String="success"} status The status of the request 21 | * @apiSuccess {String="Notes retrieved"} message A message about the request status 22 | * @apiSuccess {Object} data The data returned by the request 23 | * @apiSuccess {Number} data.pageCount The number of pages of notes 24 | * @apiSuccess {Object[]} data.notes The notes returned by the request 25 | * @apiSuccess {String} data.notes._id The ID of the note 26 | * @apiSuccess {String} data.notes.creator The ID of the note's creator 27 | * @apiSuccess {Number} data.notes.createdAt The timestamp of when the note was created 28 | * @apiSuccess {String{64}} data.notes.title The title of the note 29 | * @apiSuccess {String{512}} data.notes.content The content of the note 30 | * @apiSuccess {Object} data.notes.due The due date of the note 31 | * @apiSuccess {Number} data.notes.due.year The year of the due date 32 | * @apiSuccess {Number{1-12}} data.notes.due.month The month of the due date 33 | * @apiSuccess {Number{1-31}} data.notes.due.day The day of the due date 34 | * @apiSuccess {String="private" "public"} data.notes.visibility The visibility of the note 35 | * @apiSuccess {String|null} data.notes.class The ID of the class the note is for. Notes can be individual, so this can be null. 36 | * 37 | * @apiError (500) {String=error} status The status of the request 38 | * @apiError (500) {String="User not found"} message A message about the request status 39 | * 40 | * @apiUse pagination 41 | * @apiUse jwtAuthOptional 42 | */ 43 | router.get('/', pagination, authenticateOptional, async (req, res) => { 44 | const { page, pageSize } = res.locals.pagination; 45 | const { school, class: className } = req.query as { 46 | school: undefined | string; 47 | class: undefined | string; 48 | }; 49 | 50 | let query = { visibility: 'public', class: null } as any; 51 | 52 | if (school && className) { 53 | const classId = await findClassBySchoolNameAndClassName( 54 | school, 55 | className, 56 | ).then((c) => c?._id); 57 | 58 | if (!classId) 59 | return res 60 | .status(404) 61 | .json({ status: 'error', message: 'Class not found' }); 62 | 63 | query = { visibility: 'public', class: classId }; 64 | } 65 | 66 | const isAuthedResLocal = res.locals.authenticated as boolean; 67 | const username = res.locals.jwtPayload?.username as string | undefined; 68 | 69 | if (isAuthedResLocal && username) { 70 | const userObj = await findUsername(username); 71 | if (!userObj) 72 | return res 73 | .status(500) 74 | .json({ status: 'error', message: 'User not found' }); 75 | 76 | const userId = userObj._id; 77 | query = { 78 | $or: [query, { visibility: 'private', creator: userId }], 79 | }; 80 | } 81 | 82 | const notes = getPaginatedData( 83 | noteCollection, 84 | page, 85 | pageSize, 86 | { 'due.year': -1, 'due.month': -1, 'due.day': -1 }, 87 | query, 88 | ); 89 | const pageCount = getPaginationPageCount(noteCollection, pageSize, query); 90 | 91 | res.status(200).json({ 92 | status: 'success', 93 | message: 'Notes retrieved', 94 | data: { 95 | notes: await notes, 96 | pageCount: await pageCount, 97 | }, 98 | }); 99 | }); 100 | 101 | export default router; 102 | -------------------------------------------------------------------------------- /src/routes/notes/router.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import createNoteRouter from './createNote'; 4 | import getNoteRouter from './getNotes'; 5 | import deleteNoteRouter from './deleteNote'; 6 | import updateRouter from './updateNote'; 7 | 8 | const router = express.Router(); 9 | 10 | router.use('/', createNoteRouter); 11 | router.use('/', getNoteRouter); 12 | router.use('/', deleteNoteRouter); 13 | router.use('/', updateRouter); 14 | router.all('/', (req, res) => { 15 | res.status(405).json({ 16 | status: 'error', 17 | message: 'Method not allowed', 18 | }); 19 | }); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /src/routes/notes/updateNote.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { ObjectId, UpdateFilter } from 'mongodb'; 3 | import authenticate from '../../middleware/auth'; 4 | import findUsername from '../../database/user/findUser'; 5 | import { noteCollection } from '../../database/notes/notes'; 6 | import { isString, isUndefined } from '../../utils/isDatatype'; 7 | import { isShorterThan } from '../../utils/isShorterThan'; 8 | 9 | const router = express.Router(); 10 | 11 | /** 12 | * @api {patch} /notes/:id Update note 13 | * @apiName UpdateNote 14 | * @apiGroup Notes 15 | * @apiDescription Update a note 16 | * The note can only be updated by the user who created it. 17 | * 18 | * @apiParam {String} id The ID of the note 19 | * 20 | * @apiBody {String{64}} [title] The title of the note 21 | * @apiBody {String{512}} [content] The content of the note 22 | * 23 | * @apiSuccess {String="success"} status The status of the request 24 | * @apiSuccess {String="Note updated"} message A message about the request status 25 | * 26 | * @apiError (400) {String=error} status The status of the request 27 | * @apiError (400) {String="Invalid ID" "Missing title or content" "Title must be a string" "Content must be a string" "Title must be shorter than 64 characters" "Content must be shorter than 512 characters"} message A message about the request status 28 | * 29 | * @apiError (404) {String=error} status The status of the request 30 | * @apiError (404) {String="Note not found"} message A message about the request status. This error can also occur if the note exists, but the user is not the creator of the note. 31 | * 32 | * @apiError (500) {String=error} status The status of the request 33 | * @apiError (500) {String="User not found" "Internal server error"} message A message about the request status 34 | * 35 | * @apiUse jwtAuth 36 | */ 37 | router.patch('/:id', authenticate, async (req, res) => { 38 | //--//--// VALIDATION //--//--// 39 | const idString = req.params.id; 40 | if (!ObjectId.isValid(idString)) { 41 | return res.sendStatus(400).json({ 42 | status: 'error', 43 | message: 'Invalid ID', 44 | }); 45 | } 46 | const username = res.locals.jwtPayload?.username as string; 47 | const userObj = await findUsername(username); 48 | const userId = userObj?._id; 49 | if (!userId) { 50 | return res.status(500).json({ 51 | status: 'error', 52 | message: 'User not found', 53 | }); 54 | } 55 | 56 | const { title, content } = req.body; 57 | const titleIsString = isString(title); 58 | const titleIsUndefined = isUndefined(title); 59 | const contentIsString = isString(content); 60 | const contentIsUndefined = isUndefined(content); 61 | 62 | if (titleIsUndefined && contentIsUndefined) { 63 | return res.status(400).json({ 64 | status: 'error', 65 | message: 'Missing title or content', 66 | }); 67 | } else if (!titleIsUndefined && !titleIsString) { 68 | // if title is given but not a string 69 | return res.status(400).json({ 70 | status: 'error', 71 | message: 'Title must be a string', 72 | }); 73 | } else if (!contentIsUndefined && !contentIsString) { 74 | // if content is given but not a string 75 | return res.status(400).json({ 76 | status: 'error', 77 | message: 'Content must be a string', 78 | }); 79 | } else if (!titleIsUndefined && !isShorterThan(title, 64)) { 80 | return res.status(400).json({ 81 | status: 'error', 82 | message: 'Title must be shorter than 64 characters', 83 | }); 84 | } else if (!contentIsUndefined && !isShorterThan(content, 512)) { 85 | return res.status(400).json({ 86 | status: 'error', 87 | message: 'Content must be shorter than 512 characters', 88 | }); 89 | } 90 | 91 | //--//--// UPDATE NOTE //--//--// 92 | const id = new ObjectId(idString); 93 | const updateFilter: any = { $set: {} }; 94 | if (title !== undefined) updateFilter.$set.title = title; 95 | if (content !== undefined) updateFilter.$set.content = content; 96 | 97 | noteCollection 98 | .findOneAndUpdate( 99 | { 100 | _id: id, 101 | creator: userId, 102 | }, 103 | updateFilter, 104 | ) 105 | .then((result) => { 106 | if (result.value === null) { 107 | return res.status(404).json({ 108 | status: 'error', 109 | message: 'Note not found', 110 | }); 111 | } 112 | return res.status(200).json({ 113 | status: 'success', 114 | message: 'Note updated', 115 | }); 116 | }); 117 | }); 118 | 119 | export default router; 120 | -------------------------------------------------------------------------------- /src/routes/schools/createSchool.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { timezoneOffsets } from '../../constant/constants'; 3 | import { findUniqueSchool } from '../../database/school/findSchool'; 4 | import { createSchool } from '../../database/school/createSchool'; 5 | 6 | const router = express.Router(); 7 | 8 | /** 9 | * 10 | * @api {POST} /school Create School 11 | * @apiName createSchool 12 | * @apiGroup Schools 13 | * @apiVersion 1.0.0 14 | * @apiDescription Create a new School 15 | * 16 | * @apiBody {String} name The name of the new school 17 | * @apiBody {String} description A short description of the school so it is clear to others which exact school it is. So things like the city would be good. 18 | * @apiBody {String} uniqueName A unique name for the school no other school can already have this name 19 | * @apiBody {Number} timezoneOffset The timezone offset isn't actually used. So it will be probably be optional in a future version. 20 | * 21 | * @apiExample {json} Example-Body: 22 | * { 23 | * "name": "Hogwarts" 24 | * "description": "This is the real Hogwarts" 25 | * "uniqueName": "hogwarts" 26 | * "timezoneOffset": 0 27 | * } 28 | * 29 | * @apiSuccess (201) status The status of the request 30 | * @apiSuccess (201) message A short explaination 31 | * 32 | * @apiSuccessExample {json} Success-Response: 33 | * HTTP/1.1 201 Created 34 | * { 35 | * "status": "Success", 36 | * "message": "School created successfully" 37 | * } 38 | * 39 | * @apiError (400) {String} status The status of the request 40 | * @apiError (400) {String} message A short explaination of the error 41 | * 42 | * @apiError (500) {String} status The status of the request 43 | * @apiError (500) {String} message A short explaination of the error 44 | * 45 | * @apiErrorExample {json} 400 - Missing key: 46 | * HTTP/1.1 400 Bad Request 47 | * { 48 | * "status": "error", 49 | * "message": "Missing required field: name of type string" 50 | * } 51 | * @apiErrorExample {json} 400 - Invalid timezone: 52 | * HTTP/1.1 400 Bad Request 53 | * { 54 | * "status": "error", 55 | * "message": "Invalid timezone offset: 42" 56 | * } 57 | * @apiErrorExample {json} 400 - School already exists: 58 | * HTTP/1.1 400 Bad Request 59 | * { 60 | * "status": "error", 61 | * "message": "School with unique name Hogwarts already exists" 62 | * } 63 | * @apiErrorExample {json} 500 - Failed to create school: 64 | * HTTP/1.1 500 Internal Server Error 65 | * { 66 | * "status": "error", 67 | * "message": "Failed to create school" 68 | * } 69 | * 70 | * @apiPermission None 71 | */ 72 | router.post('/', async (req, res) => { 73 | const body = req.body; 74 | 75 | const requiredFields = { 76 | name: 'string', 77 | description: 'string', 78 | uniqueName: 'string', 79 | timezoneOffset: 'number', 80 | }; 81 | 82 | for (const entry of Object.entries(requiredFields)) { 83 | const key = entry[0]; 84 | const value = entry[1]; 85 | 86 | if (typeof body[key] !== value) { 87 | return res.status(400).json({ 88 | status: 'error', 89 | message: `Missing required field: ${key} of type ${value}`, 90 | }); 91 | } 92 | } 93 | 94 | // check if the timezone offset is valid 95 | 96 | if (!timezoneOffsets.includes(body.timezoneOffset)) { 97 | return res.status(400).json({ 98 | status: 'error', 99 | message: `Invalid timezone offset: ${body.timezoneOffset}`, 100 | }); 101 | } 102 | 103 | // check if the uniqueName is really unique 104 | let uniqueSchool = await findUniqueSchool(body.uniqueName); 105 | 106 | if (uniqueSchool) { 107 | return res.status(400).json({ 108 | status: 'error', 109 | message: `School with unique name ${body.uniqueName} already exists`, 110 | }); 111 | } 112 | 113 | createSchool({ 114 | name: body.name, 115 | description: body.description, 116 | uniqueName: body.uniqueName, 117 | timezoneOffset: body.timezoneOffset, 118 | classes: [], 119 | }) 120 | .then((success) => { 121 | if (success) { 122 | return res.status(201).json({ 123 | status: 'success', 124 | message: 'School created successfully', 125 | }); 126 | } else { 127 | return res.status(500).json({ 128 | status: 'error', 129 | message: 'Failed to create school', 130 | }); 131 | } 132 | }) 133 | .catch(() => { 134 | return res.status(500).json({ 135 | status: 'error', 136 | message: 'Failed to create school', 137 | }); 138 | }); 139 | }); 140 | 141 | export default router; 142 | -------------------------------------------------------------------------------- /src/routes/schools/getSchools.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { School, schoolsCollection } from '../../database/school/school'; 3 | import pagination from '../../middleware/pagination'; 4 | import { homeworkCollection } from '../../database/homework/homework'; 5 | import { ObjectId, WithId } from 'mongodb'; 6 | 7 | const router = express.Router(); 8 | 9 | /** 10 | * @api {GET} /schools?page=:page&pageSize=:pageSize&q=:q Get schools 11 | * @apiName Get schools 12 | * @apiGroup Schools 13 | * @apiVersion 1.0.0 14 | * 15 | * @apiQuery {String} [:q] The search term to search for, it will search in the name, uniqueName and description fields, a bad value can result in no results. 16 | * When not provided it will return all schools from the given page. 17 | * 18 | * @apiExample {curl} Example usage - curl: 19 | * curl http://localhost:3000/schools?page=1&pageSize=10 20 | * @apiExample {python} Example usage - python: 21 | * import requests 22 | * page = 1 23 | * page_size = 10 24 | * search_term = input('Search term: ') 25 | * response = requests.get(f'http://localhost:3000/schools?page={page}&pageSize={page_size}&q={search_term}') 26 | * print(response.json()) 27 | * @apiExample {javascript} Example usage - javascript: 28 | * const page = 1; 29 | * const pageSize = 10; 30 | * const response = await fetch(`http://localhost:3000/schools?page=${page}&pageSize=${pageSize}`); 31 | * console.log(await response.json()); 32 | * @apiExample {v} Example usage - v: 33 | * import net.http 34 | * page := 1 35 | * page_size := 10 36 | * resp := http.get('http://localhost:3000/schools?page=${page}&pageSize=${page_size}')! 37 | * println(resp.body) 38 | * 39 | * @apiSuccess (200) {String} status The status of the request (success) 40 | * @apiSuccess (200) {String} message A short message about the status of the request 41 | * @apiSuccess (200) {Object[]} data The data returned by the request 42 | * @apiSuccess (200) {Object[]} data.schools The schools 43 | * @apiSuccess (200) {String} data.schools._id The MongoDB ID of the school 44 | * @apiSuccess (200) {String} data.schools.name The name of the school 45 | * @apiSuccess (200) {String} data.schools.description The description of the school 46 | * @apiSuccess (200) {String} data.schools.uniqueName The unique name of the school 47 | * @apiSuccess (200) {String} data.schools.timezoneOffset The timezone offset of the school, this value isn't used but still mendatory 48 | * @apiSuccess (200) {String[]} data.schools.classes The MongoDB IDs of the classes in the school 49 | * @apiSuccess (200) {Number} data.totalPageCount The total amount of pages 50 | * 51 | * @apiSuccessExample {json} Success-Response: 52 | * HTTP/1.1 201 Created 53 | * { 54 | * "status": "success", 55 | * "message": "Schools found", 56 | * "data": { 57 | * "scshools": [ 58 | * { 59 | * "_id": "64bfc62295f139281cec6c74", 60 | * "name": "School", 61 | * "description": "This school does not exist it is only for testing purposes", 62 | * "uniqueName": "school", 63 | * "timezoneOffset": 0, 64 | * "classes": [ 65 | * "64bfc63195f139281cec6c75" 66 | * ] 67 | * } 68 | * ], 69 | * "totalPageCount": 1 70 | * } 71 | * } 72 | * 73 | * @apiUse pagination 74 | */ 75 | router.get('/', pagination, async (req, res) => { 76 | const { page, pageSize } = res.locals.pagination; 77 | 78 | const searchTerm = req.query.q; 79 | 80 | let searchFilter = {}; 81 | 82 | if (searchTerm) { 83 | searchFilter = { 84 | $text: { 85 | $search: searchTerm, 86 | $caseSensitive: false, 87 | $diacriticSensitive: false, 88 | }, 89 | }; 90 | } 91 | 92 | // ! This scales horribly 93 | 94 | const homeworksPerSchool: Record = {}; 95 | 96 | const allFilteredSchools = await schoolsCollection 97 | .find(searchFilter) 98 | .toArray(); 99 | 100 | for (const school of allFilteredSchools) { 101 | const classIds = school.classes; 102 | const homeworksForSchool = await homeworkCollection.countDocuments({ 103 | class: { 104 | $in: classIds, 105 | }, 106 | }); 107 | homeworksPerSchool[school._id.toHexString()] = homeworksForSchool; 108 | } 109 | 110 | const schoolIds = Object.entries(homeworksPerSchool) 111 | .sort((a, b) => b[1] - a[1]) 112 | .map((i) => new ObjectId(i[0])); 113 | 114 | const pagedSchoolIds = schoolIds.slice( 115 | (page - 1) * pageSize, 116 | page * pageSize, 117 | ); 118 | 119 | const schools = (await Promise.all( 120 | pagedSchoolIds.map( 121 | async (id) => await schoolsCollection.findOne({ _id: id }), 122 | ), 123 | )) as WithId[]; 124 | 125 | return res.status(200).json({ 126 | status: 'success', 127 | message: 'Schools found', 128 | data: { 129 | schools, 130 | totalPageCount: Math.ceil( 131 | (await schoolsCollection.countDocuments(searchFilter || {})) / 132 | pageSize, 133 | ), 134 | }, 135 | }); 136 | }); 137 | 138 | export default router; 139 | -------------------------------------------------------------------------------- /src/routes/schools/getSpecificSchool.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { findUniqueSchool } from '../../database/school/findSchool'; 3 | 4 | const router = express.Router(); 5 | 6 | /** 7 | * @api {GET} /schools/:schoolname Get a specific school 8 | * @apiName Get a specific school 9 | * @apiGroup Schools 10 | * @apiVersion 1.0.0 11 | * @apiDescription Returns details of a specific school 12 | * 13 | * @apiParam {String} schoolname The unique name of the school to get 14 | * 15 | * @apiExample {curl} Example usage - curl: 16 | * curl http://localhost:3000/schools/school 17 | * @apiExample {python} Example usage - python: 18 | * import requests 19 | * school_name = input('School name: ') 20 | * response = requests.get(f'http://localhost:3000/schools/{school_name}') 21 | * print(response.json()) 22 | * @apiExample {javascript} Example usage - javascript: 23 | * const schoolName = 'school'; 24 | * const res = await fetch(`http://localhost:3000/schools/${schoolName}`); 25 | * console.log(await res.json()); 26 | * @apiExample {v} Example usage - v: 27 | * import net.http 28 | * school_name := 'school' 29 | * resp := http.get('http://localhost:3000/schools/${school_name}')! 30 | * println(resp.body) 31 | * 32 | * @apiSuccess (200) {String} status The status of the request (success) 33 | * @apiSuccess (200) {String} message A short message about the status of the request 34 | * @apiSuccess (200) {Object} data The data returned by the request 35 | * @apiSuccess (200) {String} data._id The MongoDB ID of the school 36 | * @apiSuccess (200) {String} data.name The name of the school 37 | * @apiSuccess (200) {String} data.description The description of the school 38 | * @apiSuccess (200) {String} data.uniqueName The unique name of the school 39 | * @apiSuccess (200) {Number} data.timezoneOffset The offset of the school's timezone in hours. This value isn't used anywhere but is still mendatory 40 | * @apiSuccess (200) {String[]} data.classes The MongoDB IDs of the classes in the school 41 | * 42 | * @apiError (404) {String} status The status of the request (error) 43 | * @apiError (404) {String} message A short message about the status of the request 44 | * 45 | * @apiSuccessExample {json} Success-Response: 46 | * HTTP/1.1 201 Created 47 | * { 48 | * "status": "success", 49 | * "message": "School found", 50 | * "data": { 51 | * "_id": "64cd3d2427b7e06ad1d90740", 52 | * "name": "School", 53 | * "description": "This school does not exist it is only for testing purposes", 54 | * "uniqueName": "school", 55 | * "timezoneOffset": 0, 56 | * "classes": [ 57 | * "64cd3d8b27b7e06ad1d90741" 58 | * ] 59 | * } 60 | * } 61 | * @apiErrorExample {json} 404 - School not found: 62 | * HTTP/1.1 404 Not Found 63 | * { 64 | * "status": "error", 65 | * "error": "School not found" 66 | * } 67 | */ 68 | router.get('/:schoolname', async (req, res) => { 69 | const schoolName = req.params.schoolname; 70 | 71 | const school = await findUniqueSchool(schoolName); 72 | 73 | if (!school) { 74 | return res.status(404).json({ 75 | status: 'error', 76 | error: 'School not found', 77 | }); 78 | } 79 | 80 | res.status(200).json({ 81 | status: 'success', 82 | message: 'School found', 83 | data: school, 84 | }); 85 | }); 86 | 87 | export default router; 88 | -------------------------------------------------------------------------------- /src/routes/schools/router.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import getSchoolRouter from './getSchools'; 4 | import createSchoolRouter from './createSchool'; 5 | import getSpecificSchoolRouter from './getSpecificSchool'; 6 | 7 | const router = express.Router(); 8 | 9 | router.use('/', createSchoolRouter); 10 | router.use('/', getSchoolRouter); 11 | router.use('/', getSpecificSchoolRouter); 12 | router.all('/', (req, res) => { 13 | res.status(405).json({ 14 | status: 'error', 15 | message: 'Method not allowed', 16 | }); 17 | }); 18 | 19 | export default router; 20 | -------------------------------------------------------------------------------- /src/types/date.ts: -------------------------------------------------------------------------------- 1 | export interface Date { 2 | year: number; 3 | month: number; 4 | day: number; 5 | } 6 | 7 | export interface Time { 8 | hour: number; 9 | minute: number; 10 | } 11 | 12 | export interface DateTime extends Date, Time {} 13 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { Date, DateTime, Time } from '../types/date'; 2 | 3 | export function isDateValid(date: Date): boolean { 4 | if (!date) return false; 5 | const { year, month, day } = date; 6 | 7 | if ( 8 | typeof year !== 'number' || 9 | typeof month !== 'number' || 10 | typeof day !== 'number' 11 | ) { 12 | return false; 13 | } 14 | 15 | if (month < 1 || month > 12) { 16 | return false; 17 | } 18 | 19 | if (day < 1 || day > 31) { 20 | return false; 21 | } 22 | 23 | return true; 24 | } 25 | 26 | export function isTimeValid(time: Time): boolean { 27 | if (!time) return false; 28 | const { hour, minute } = time; 29 | 30 | if (typeof hour !== 'number' || typeof minute !== 'number') { 31 | return false; 32 | } 33 | 34 | if (hour < 0 || hour > 23) { 35 | return false; 36 | } 37 | 38 | if (minute < 0 || minute > 59) { 39 | return false; 40 | } 41 | 42 | return true; 43 | } 44 | 45 | export function isDateTimeValid(dateTime: DateTime): boolean { 46 | if (!dateTime) return false; 47 | 48 | const dateIsValid = isDateValid(dateTime); 49 | const timeIsValid = isTimeValid(dateTime); 50 | 51 | return dateIsValid && timeIsValid; 52 | } 53 | 54 | export function sortDate(date: Date) { 55 | const { year, month, day } = date; 56 | 57 | return { 58 | year, 59 | month, 60 | day, 61 | }; 62 | } 63 | 64 | export function sortDateTime(dateTime: DateTime) { 65 | const { year, month, day, hour, minute } = dateTime; 66 | 67 | return { 68 | year, 69 | month, 70 | day, 71 | hour, 72 | minute, 73 | }; 74 | } 75 | 76 | export const dateTimeToDate = (dateTime: DateTime) => { 77 | const { year, month, day, hour, minute } = dateTime; 78 | 79 | return new Date(year, month - 1, day, hour, minute); 80 | }; 81 | -------------------------------------------------------------------------------- /src/utils/isDatatype.ts: -------------------------------------------------------------------------------- 1 | export const isString = (value: any) => { 2 | return typeof value === 'string' || value instanceof String; 3 | }; 4 | export const isUndefined = (value: any) => { 5 | return typeof value === 'undefined'; 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/isShorterThan.ts: -------------------------------------------------------------------------------- 1 | import { isString } from './isDatatype'; 2 | 3 | export const isShorterThan = (str: string, length: number) => { 4 | if (!isString(str)) return false; 5 | return str.length < length; 6 | }; 7 | 8 | export const isLongerThan = (str: string, length: number) => { 9 | if (!isString(str)) return false; 10 | return str.length > length; 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from 'jsonwebtoken'; 2 | import dotenv from 'dotenv'; 3 | 4 | dotenv.config(); 5 | 6 | /** 7 | * A function to generate a JWT token 8 | * @param username The username to generate a token for 9 | * @returns A JWT token 10 | */ 11 | export function generateToken(username: string): string { 12 | const payload = { 13 | username, 14 | }; 15 | 16 | const options: jwt.SignOptions = { 17 | expiresIn: '1h', 18 | }; 19 | 20 | const token = jwt.sign(payload, process.env.JWT_SECRET as string, options); 21 | 22 | return token; 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/strings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A function to test if a string contains a lowercase letter 3 | * @param input The string to test 4 | * @returns A boolean true if the string contains a lowercase letter, false otherwise 5 | */ 6 | export const hasLowercaseLetter = (input: string): boolean => 7 | /[a-z]/.test(input); 8 | 9 | /** 10 | * A function to test if a string contains a uppercase letter 11 | * @param input The string to test 12 | * @returns A boolean indicating whether the string contains a uppercase letter 13 | */ 14 | export const hasUppercaseLetter = (input: string): boolean => 15 | /[A-Z]/.test(input); 16 | 17 | /** 18 | * A function to test if a string contains a number 19 | * @param input The string to test 20 | * @returns A boolean indicating whether the string contains a number 21 | */ 22 | export const hasNumber = (input: string): boolean => /[0-9]/.test(input); 23 | 24 | export const specialCharacters = '!@#$%^&*()_-[]{}?/\\|,.<>~`\'"'; 25 | 26 | export const hasSpecialCharacter = ( 27 | input: string, 28 | chars = specialCharacters, 29 | ): boolean => { 30 | for (const char of chars) if (input.includes(char)) return true; 31 | 32 | return false; 33 | }; 34 | -------------------------------------------------------------------------------- /tests/awaitTrue.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest'; 2 | import { awaitTrue } from './utils/awaitTrue'; 3 | 4 | describe.concurrent('awaitTrue', async () => { 5 | it('instantly resolves', async ({ expect }) => { 6 | const start = Date.now(); 7 | await awaitTrue(() => true); 8 | expect(Date.now() - start).toBeLessThan(100); 9 | }); 10 | 11 | it('resolves after ~100ms', async ({ expect }) => { 12 | const start = Date.now(); 13 | await awaitTrue(() => Date.now() - start > 100); 14 | const end = Date.now(); 15 | 16 | expect(end - start).toBeGreaterThan(100); 17 | expect(end - start).toBeLessThan(300); 18 | }); 19 | 20 | it('resolves after ~200ms', async ({ expect }) => { 21 | const start = Date.now(); 22 | await awaitTrue(() => Date.now() - start > 200); 23 | const end = Date.now(); 24 | 25 | expect(end - start).toBeGreaterThan(200); 26 | expect(end - start).toBeLessThan(400); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/dlool.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, describe, it, expect, afterEach, beforeEach } from 'vitest'; 2 | 3 | import { awaitTrue } from './utils/awaitTrue'; 4 | 5 | import { db, dbIsConnected, setDb } from '../src/database/database'; 6 | import { server, serverIsRunning } from '../src/index'; 7 | import { startServer } from './utils/startServer'; 8 | import { dropTestDb } from './utils/adminDatabase'; 9 | 10 | import dotenv from 'dotenv'; 11 | dotenv.config({ path: '.env.public' }); 12 | 13 | describe('the basic server', () => { 14 | it('runs', async () => { 15 | const index = await fetch( 16 | `http://localhost:${process.env.PORT || 3000}`, 17 | ); 18 | expect(index.status).toBe(200); 19 | expect(await index.json()).toEqual({ 20 | name: 'Dlool', 21 | isDlool: true, 22 | }); 23 | }); 24 | 25 | it('drops the right db', async () => { 26 | const dropped = await dropTestDb(); 27 | expect(dropped).toBe(true); 28 | }); 29 | }); 30 | 31 | beforeEach(async () => { 32 | await startServer(); 33 | await awaitTrue(() => serverIsRunning); 34 | await awaitTrue(() => dbIsConnected); 35 | expect(serverIsRunning).toBe(true); 36 | expect(dbIsConnected).toBe(true); 37 | 38 | setDb('test'); 39 | expect(db.databaseName).toBe('test'); 40 | }); 41 | 42 | afterEach(async () => { 43 | await server.close(); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/utils/adminDatabase.ts: -------------------------------------------------------------------------------- 1 | import { Db } from 'mongodb'; 2 | import * as mongodb from 'mongodb'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config({ path: '.env.public' }); 6 | dotenv.config(); 7 | 8 | /** 9 | * The database connection 10 | */ 11 | const client = new mongodb.MongoClient( 12 | (process.env.MONGO_URI as string) 13 | .replace('', process.env.MONGO_PASSWORD_TEST as string) 14 | .replace('', process.env.MONGO_USERNAME_TEST || 'root') 15 | .replace('', process.env.MONGO_DBNAME || 'test'), 16 | ); 17 | 18 | export const getTestDbWithAdmin = () => 19 | client.connect().then(() => client.db('test')); 20 | 21 | export const closeTestDbWithAdmin = () => client.close(); 22 | 23 | export const dropTestDb = async () => { 24 | const db = await getTestDbWithAdmin(); 25 | const dropped = await db.dropDatabase(); 26 | await closeTestDbWithAdmin(); 27 | return dropped; 28 | }; 29 | -------------------------------------------------------------------------------- /tests/utils/awaitTrue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A function that returns a promise that resolves when the given boolean is true 3 | * If needed, the function will check the boolean every 100ms 4 | * @param boolean The boolean to await 5 | */ 6 | export const awaitTrue = async (boolean: () => boolean): Promise => { 7 | return new Promise((resolve) => { 8 | if (boolean()) { 9 | resolve(); 10 | } else { 11 | setTimeout(() => { 12 | resolve(awaitTrue(boolean)); 13 | }, 50); 14 | } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /tests/utils/startServer.ts: -------------------------------------------------------------------------------- 1 | import { serverIsRunning } from '../../src/index'; 2 | import { awaitTrue } from './awaitTrue'; 3 | 4 | export const startServer = async () => { 5 | await import('../../src/index'); 6 | await awaitTrue(() => serverIsRunning); 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noImplicitAny": true, 11 | "removeComments": true 12 | }, 13 | "include": [ 14 | "./src" 15 | ], 16 | "exclude": [ 17 | "./node_modules" 18 | ] 19 | } --------------------------------------------------------------------------------