diff --git a/server/.eslintignore b/server/.eslintignore new file mode 100644 index 0000000..5556eed --- /dev/null +++ b/server/.eslintignore @@ -0,0 +1,4 @@ +node_modules +dist +.space +index.js \ No newline at end of file diff --git a/server/.eslintrc b/server/.eslintrc new file mode 100644 index 0000000..d361362 --- /dev/null +++ b/server/.eslintrc @@ -0,0 +1,34 @@ +{ + "root": true, + "env": { + "node": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:json/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "prettier"], + "rules": { + "no-unused-vars": ["error"], + "indent": ["error", 2], + "linebreak-style": ["error", "unix"], + "quotes": ["error", "double"], + "semi": ["error", "always"], + "no-console": ["error"], + "prettier/prettier": ["error"] + }, + "settings": { + "import/resolver": { + "typescript": {} + } + } +} diff --git a/server/.eslintrc.json b/server/.eslintrc.json new file mode 100644 index 0000000..3c052dc --- /dev/null +++ b/server/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "env": { + "browser": true, + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended" + ], + "overrides": [], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "indent": ["error", 2], + "linebreak-style": ["error", "unix"], + "prettier/prettier": ["error", {}], + "quotes": ["error", "double"], + "semi": ["error", "always"] + } +} \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..8faafa2 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,15 @@ +# Deta files +.deta/ +.space + + +# Compiled javascript files +dist/ + +# Node +node_modules/ +yarn.lock +package-lock.json + +# env variables +.env \ No newline at end of file diff --git a/server/.prettierrc b/server/.prettierrc new file mode 100644 index 0000000..5e45ccc --- /dev/null +++ b/server/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "tabWidth": 2, + "printWidth": 100, + "singleQuote": false, + "trailingComma": "none", + "bracketSpacing": true +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..717d13e --- /dev/null +++ b/server/index.js @@ -0,0 +1,5 @@ +// Import the compiled dist js +const app = require("./dist/index.js"); + +// Export it to Deta +module.exports = app; diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..a3252c5 --- /dev/null +++ b/server/package.json @@ -0,0 +1,35 @@ +{ + "name": "deta-typescript-express-starter", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "dev": "npx nodemon --watch src src/index.ts", + "build": "npx tsc", + "deploy": "npx tsc && deta deploy", + "lint-fix": "eslint --fix . --ext .ts" + }, + "dependencies": { + "argon2": "^0.31.2", + "cors": "^2.8.5", + "deta": "^1.1.0", + "dotenv": "^16.0.3", + "express": "^4.18.1", + "nodemailer": "^6.9.7", + "outlook-nodemailer-transport": "^1.4.1" + }, + "devDependencies": { + "@types/cors": "^2.8.13", + "@types/express": "^4.17.13", + "@types/node": "14", + "@types/nodemailer": "^6.4.14", + "@typescript-eslint/eslint-plugin": "^5.47.0", + "@typescript-eslint/parser": "^5.47.0", + "eslint": "^8.30.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-json": "^3.1.0", + "eslint-plugin-prettier": "^4.2.1", + "ts-node": "^10.9.1", + "typescript": "^4.8.3" + } +} diff --git a/server/placeholder.txt b/server/placeholder.txt deleted file mode 100644 index e69de29..0000000 diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..37ca531 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,28 @@ +import express from "express"; +import cors from "cors"; +import auth from "./routes/auth"; +import task from "./routes/task"; +import board from "./routes/board"; + +const app = express(); +app.use(express.json()); +app.use(cors()); +app.disable("etag"); + +app.get("/", (req, res) => + res.status(200).json({ + msg: "This is the API of the following repository on GitHub: https://github.com/janlehner/taskhub" + }) +); + +// routes +app.use("/auth", auth); +app.use("/task", task); +app.use("/board", board); + +const port = parseInt(process.env.PORT) || 8080; +app.listen(port, () => { + console.log(`listening on port ${port}`); +}); + +module.exports = app; diff --git a/server/src/interfaces/interfaces.ts b/server/src/interfaces/interfaces.ts new file mode 100644 index 0000000..8f86b93 --- /dev/null +++ b/server/src/interfaces/interfaces.ts @@ -0,0 +1,26 @@ +export interface ILoginForm { + username: string; + password: string; +} + +export interface IRegisterForm { + username: string; + password: string; +} + +export interface IJWTData { + username: string; +} + +export interface ITask { + title: string; + definition: string; + owner: string; + board: string; +} + +export interface IBoard { + owner: string; + title: string; + tasks: ITask[]; +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..b1b6e73 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,78 @@ +import express from "express"; +import * as dotenv from "dotenv"; +import path from "path"; +import { Deta } from "deta"; +import argon2 from "argon2"; +import { ILoginForm, IRegisterForm } from "../interfaces/interfaces"; +import jwt from "jsonwebtoken"; +dotenv.config({ path: path.resolve(__dirname, "../.env") }); + +// deta setup +const projectKey: string = process.env.DETA_PROJECT_KEY; +const deta = Deta(projectKey); +const auth = deta.Base("users"); + +const jwtSecret: string = process.env.JWT_SECRET; + +const router = express.Router(); + + +router.post("/register", async (req, res) => { + try { + const registrationFormData: IRegisterForm = req.body as IRegisterForm; + + if (registrationFormData.username == null || registrationFormData.password == null || registrationFormData.username == "" || registrationFormData.password == "") { + throw new Error("Invalid Request"); + } + if (!(await auth.get(registrationFormData.username))) { + const passwordHash = await argon2.hash(registrationFormData.password); + const auhtFormDataJson = { + key: registrationFormData.username, + password: passwordHash + }; + const newUser = await auth.insert(auhtFormDataJson); + + res.status(201).json({ + username: newUser.key, + success: true + }); + } else { + throw new Error("Failed to register user!"); + } + } catch (err) { + res.status(503).json({ error: err.message }); + } +}); + +router.post("/login", async (req, res) => { + try { + const loginFormData: ILoginForm = req.body as ILoginForm; + + if (loginFormData.username == null || loginFormData.password == null || loginFormData.username == "" || loginFormData.password == "") { + throw new Error("Invalid Request"); + } + + const user = await auth.get(loginFormData.username); + if (user === null) { + throw new Error("Wrong credentials! Please try again."); + } + + const password = user.password as string; + + if (await argon2.verify(password, loginFormData.password)) { + const token = jwt.sign({ username: user.key }, jwtSecret, { expiresIn: "21600s" }); + res.status(200).json({ token, success: true }); + } else { + res.status(401).json({ + error: "Wrong credentials! Please try again.", + success: false + }); + } + } catch (err) { + res.status(503).json({ error: err.message }); + } +}); + + + +export default router; \ No newline at end of file diff --git a/server/src/routes/board.ts b/server/src/routes/board.ts new file mode 100644 index 0000000..a5eb2d2 --- /dev/null +++ b/server/src/routes/board.ts @@ -0,0 +1,50 @@ +import express from "express"; +import * as dotenv from "dotenv"; +import path from "path"; +import { Deta } from "deta"; +import { ITask, IBoard } from "../interfaces/interfaces"; +dotenv.config({ path: path.resolve(__dirname, "../.env") }); + +// deta setup +const projectKey: string = process.env.DETA_PROJECT_KEY; +const deta = Deta(projectKey); +const boardSets = deta.Base("board"); + +const router = express.Router(); + +router.post("/create", async (req, res) => { + try { + const boardDataSet: IBoard = req.body as IBoard; + if (typeof boardDataSet.title !== "string" || typeof boardDataSet.owner !== "string") { + throw new Error("Invalid 'title' or 'owner' in the request."); + } + + const key = boardDataSet.title.trim() + boardDataSet.owner.trim(); + + if (await boardSets.get(boardDataSet.title)) { + throw new Error("This board name exists already. Please try to edit this!"); + } else if (await boardSets.get(key)) { + throw new Error("This task name exists already. Please try to edit this!"); + } + + const tasks: ITask[] = []; + + const boardDataSetJSON = { + key: key, + title: boardDataSet.title, + owner: boardDataSet.owner, + tasks: tasks + }; + + const newtaskDataSet = await boardSets.insert(JSON.parse(JSON.stringify(boardDataSetJSON))); + + res.status(201).json({ + title: boardDataSet.title, + owner: boardDataSet.owner + }); + } catch (err) { + res.status(503).json({ error: err.message }); + } +}); + +export default router; diff --git a/server/src/routes/task.ts b/server/src/routes/task.ts new file mode 100644 index 0000000..dfc4798 --- /dev/null +++ b/server/src/routes/task.ts @@ -0,0 +1,141 @@ +import express from "express"; +import * as dotenv from "dotenv"; +import path from "path"; +import { Deta } from "deta"; +import { ITask, IBoard } from "../interfaces/interfaces"; +dotenv.config({ path: path.resolve(__dirname, "../.env") }); + +// deta setup +const projectKey: string = process.env.DETA_PROJECT_KEY; +const deta = Deta(projectKey); +const taskSets = deta.Base("tasks"); +const boardSets = deta.Base("board"); + +const router = express.Router(); + +router.post("/create/:board", async (req, res) => { + try { + const taskDataSet: ITask = req.body as ITask; + const key = taskDataSet.title.trim() + taskDataSet.owner.trim(); + const boardTitle = req.params.board; + if (!taskDataSet.title || !taskDataSet.owner) { + throw new Error("Both 'title' and 'owner' are required fields."); + } + + const existingTask = await taskSets.get(key); + const existingBoard = await taskSets.get(boardTitle); + + if (existingTask) { + throw new Error("This task name exists already. Please try to edit this!"); + } else if (existingBoard) { + throw new Error("The board of the to-be-created task does not exist."); + } + + const taskDataSetJSON = { + key: key, + title: taskDataSet.title, + definition: taskDataSet.definition + }; + + const newTaskDataSet = await taskSets.insert(taskDataSetJSON); + + const taskBoardArray = await boardSets.get(req.params.board); + var updatedArray = []; + updatedArray = Array.isArray(taskBoardArray) ? [taskDataSet, ...taskBoardArray] : []; + const boardKey = await boardSets.get(key); + const stringifiedKey = JSON.stringify(boardKey); + boardSets.update(JSON.parse(JSON.stringify(updatedArray)), stringifiedKey); + + res.status(201).json({ + title: taskDataSet.title, + definition: taskDataSet.definition + }); + } catch (err) { + res.status(503).json({ error: err.message }); + } +}); + +router.get("/get/:board/:id", async (req, res) => { + try { + const { id, board } = req.params; + + if (!id || !board) { + throw new Error("Both 'id' and 'board' parameters are required."); + } + + const fetchedtaskSets = await taskSets.fetch({ id, board }); + if (fetchedtaskSets == null) { + res.status(409).json({ + error: "This task does not exist." + }); + return false; + } else { + res.status(201).json({ fetchedtaskSets }); + } + } catch (err) { + res.status(503).json({ error: err.message }); + } +}); + +router.get("/getTaskAll/:board", async (req, res) => { + try { + const fetchedtaskSets = await taskSets.get(req.params.board); + if (fetchedtaskSets == null) { + res.status(409).json({ + error: "This task does not exist." + }); + return false; + } else { + res.status(201).json({ fetchedtaskSets }); + } + } catch (err) { + res.status(503).json({ error: err.message }); + } +}); + +router.delete("/deleteTask/:board/:id", async (req, res) => { + try { + const { id, board } = req.params; + if (!id || !board) { + throw new Error("Both 'id' and 'board' parameters are required."); + } + const fetchedtaskSets = await taskSets.fetch({ id, board }); + if (fetchedtaskSets != null) { + await taskSets.delete(id); + res.status(200).json({ message: "Deleted task", id: req.params.id, sucess: true }); + } else { + res.status(409).json({ + error: "This task does not exist." + }); + return false; + } + } catch (err) { + res.status(503).json({ error: err.message }); + } +}); + +router.post("/updateTask/:board/:id", async (req, res) => { + try { + const taskSetsData: ITask = req.body as ITask; + const { id, board } = req.params; + if (!(await taskSets.fetch({ id, board }))) { + throw new Error("This task does not exist."); + } + + const taskSetsDataJson = { + title: taskSetsData.title, + definition: taskSetsData.definition + }; + + await taskSets.insert(taskSetsDataJson); + + res.status(201).json({ + title: taskSetsData.title, + definition: taskSetsData.definition + }); + } catch (err) { + res.status(503).json({ error: err.message }); + } +}); + +export default router; diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..88eae0e --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "commonjs", + "esModuleInterop": true, + "noImplicitAny": true, + "removeComments": true, + "strict": false, + "outDir": "dist" + }, + "include": ["src/**/*"] +}