├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── __test__ └── users.signup.spec.js ├── index.js ├── jest.config.js ├── package-lock.json ├── package.json └── server ├── api └── v1 │ ├── auth.js │ ├── docs.js │ ├── groups │ ├── controller.js │ ├── model.js │ └── routes.js │ ├── index.js │ ├── tasks │ ├── controller.js │ ├── model.js │ └── routes.js │ └── users │ ├── controller.js │ ├── model.js │ └── routes.js ├── config ├── docs.js ├── index.js └── logger.js ├── database.js ├── index.js └── utils ├── index.js └── index.test.js /.env.example: -------------------------------------------------------------------------------- 1 | SERVER_PORT=3000 2 | DATABASE_PROTOCOL=mongodb 3 | DATABASE_URL=localhost/checklist 4 | DATABASE_USERNAME= 5 | DATABASE_PASSWORD= 6 | TOKEN_SECRET=rickandmorty 7 | TOKEN_EXPIRES=1h 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es2020": true, 5 | "node": true, 6 | "jest/globals": true 7 | }, 8 | "plugins": ["jest"], 9 | "extends": ["airbnb-base", "prettier", "plugin:jest/recommended"], 10 | "parserOptions": { 11 | "ecmaVersion": 11 12 | }, 13 | "rules": { 14 | "no-console": "off", 15 | "no-unused-vars": [ 16 | "error", 17 | { 18 | "argsIgnorePattern": "next" 19 | } 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | Thumbs.db 4 | .env 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "waderyan.nodejs-extension-pack", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Node: Attach to process", 8 | "processId": "${command:PickProcess}", 9 | "restart": true, 10 | "protocol": "inspector" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.renderWhitespace": "all", 4 | "editor.renderControlCharacters": true, 5 | "editor.trimAutoWhitespace": true, 6 | "editor.tabSize": 2, 7 | "files.insertFinalNewline": true, 8 | "editor.defaultFormatter": "esbenp.prettier-vscode", 9 | "prettier.singleQuote": true, 10 | "prettier.trailingComma": "es5" 11 | } 12 | -------------------------------------------------------------------------------- /__test__/users.signup.spec.js: -------------------------------------------------------------------------------- 1 | // __test__/users.signup.spec.js 2 | 3 | const request = require('supertest'); 4 | 5 | const server = require('../server'); 6 | const config = require('../server/config'); 7 | const database = require('../server/database'); 8 | 9 | let agent; 10 | 11 | beforeAll(() => { 12 | const url = `${config.database.url}-test`; 13 | database.connect({ url }, {}); 14 | 15 | agent = request(server); 16 | }); 17 | 18 | afterAll(() => { 19 | database.disconnect(); 20 | }); 21 | 22 | describe('Users', () => { 23 | test('Get the list of the users', async () => { 24 | const response = await agent.get('/api/users'); 25 | const { body = {} } = response; 26 | const { success = false } = body; 27 | expect(success).toBeTruthy(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // index.js 2 | 3 | const http = require('http'); 4 | 5 | const app = require('./server'); 6 | const config = require('./server/config'); 7 | const database = require('./server/database'); 8 | 9 | // Connect to database 10 | database.connect(config.database, {}); 11 | 12 | const { port } = config.server; 13 | 14 | const server = http.createServer(app); 15 | 16 | server.listen(port, () => { 17 | console.log(`Server running at port: ${port}`); 18 | }); 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "checklist-api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "cross-env NODE_ENV=development nodemon --inspect", 8 | "start": "cross-env NODE_ENV=production node index", 9 | "test": "cross-env NODE_ENV=test jest" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "eslint": "^6.8.0", 16 | "eslint-config-airbnb-base": "^14.1.0", 17 | "eslint-config-prettier": "^6.11.0", 18 | "eslint-plugin-import": "^2.20.2", 19 | "eslint-plugin-jest": "^23.20.0", 20 | "jest": "^26.4.2", 21 | "nodemon": "^2.0.4", 22 | "supertest": "^4.0.2" 23 | }, 24 | "dependencies": { 25 | "bcryptjs": "^2.4.3", 26 | "body-parser": "^1.19.0", 27 | "cors": "^2.8.5", 28 | "cross-env": "^7.0.2", 29 | "dotenv": "^8.2.0", 30 | "express": "^4.17.1", 31 | "express-request-id": "^1.4.1", 32 | "express-validator": "^6.6.1", 33 | "jsonwebtoken": "^8.5.1", 34 | "lodash": "^4.17.20", 35 | "mongoose": "^5.10.0", 36 | "morgan": "^1.10.0", 37 | "strip-final-newline": "^2.0.0", 38 | "swagger-jsdoc": "^4.0.0", 39 | "swagger-ui-express": "^4.1.4", 40 | "validator": "^13.1.1", 41 | "winston": "^3.2.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/api/v1/auth.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/auth.js 2 | 3 | const { sign, verify } = require('jsonwebtoken'); 4 | 5 | const config = require('../../config'); 6 | 7 | const { secret, expires } = config.token; 8 | 9 | const signToken = (payload, expiresIn = expires) => 10 | sign(payload, secret, { 11 | algorithm: 'HS256', 12 | expiresIn, 13 | }); 14 | 15 | const auth = (req, res, next) => { 16 | let token = req.headers.authorization || req.query.token || ''; 17 | if (token.startsWith('Bearer ')) { 18 | token = token.substring(7); 19 | } 20 | 21 | if (!token) { 22 | const message = 'Unauthorized'; 23 | 24 | next({ 25 | success: false, 26 | message, 27 | statusCode: 401, 28 | level: 'info', 29 | }); 30 | } else { 31 | verify(token, config.token.secret, (err, decoded) => { 32 | if (err) { 33 | const message = 'Unauthorized'; 34 | 35 | next({ 36 | success: false, 37 | message, 38 | statusCode: 401, 39 | level: 'info', 40 | }); 41 | } else { 42 | req.decoded = decoded; 43 | next(); 44 | } 45 | }); 46 | } 47 | }; 48 | 49 | const me = (req, res, next) => { 50 | const { decoded = {}, params = {} } = req; 51 | const { _id } = decoded; 52 | const { id } = params; 53 | if (_id !== id) { 54 | const message = 'Forbidden'; 55 | 56 | next({ 57 | success: false, 58 | message, 59 | statusCode: 403, 60 | type: 'warn', 61 | }); 62 | } else { 63 | next(); 64 | } 65 | }; 66 | 67 | const owner = (req, res, next) => { 68 | const { decoded = {}, doc = {} } = req; 69 | const { _id } = decoded; 70 | const { id } = doc.userId; 71 | if (_id !== id) { 72 | const message = 'Forbidden'; 73 | 74 | next({ 75 | success: false, 76 | message, 77 | statusCode: 403, 78 | type: 'warn', 79 | }); 80 | } else { 81 | next(); 82 | } 83 | }; 84 | 85 | module.exports = { 86 | signToken, 87 | auth, 88 | me, 89 | owner, 90 | }; 91 | -------------------------------------------------------------------------------- /server/api/v1/docs.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/docs.js 2 | 3 | const swaggerJSDoc = require('swagger-jsdoc'); 4 | const merge = require('lodash/merge'); 5 | 6 | const { swaggerDefinition } = require.main.require('./server/config/docs'); 7 | 8 | // Override default definition 9 | const localDefinition = { 10 | info: { 11 | version: '1.0.0', 12 | }, 13 | basePath: '/api/v1', 14 | }; 15 | 16 | const options = { 17 | swaggerDefinition: merge(swaggerDefinition, localDefinition), 18 | apis: ['./server/api/v1/tasks/routes.js'], 19 | }; 20 | 21 | module.exports = swaggerJSDoc(options); 22 | -------------------------------------------------------------------------------- /server/api/v1/groups/controller.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/groups/controller.js 2 | 3 | const { Model, fields, references, virtuals } = require('./model'); 4 | const { paginationParseParams } = require('../../../utils'); 5 | const { sortParseParams, sortCompactToStr } = require('../../../utils'); 6 | const { populateToObject } = require('../../../utils'); 7 | 8 | const referencesNames = [ 9 | ...Object.getOwnPropertyNames(references), 10 | ...Object.getOwnPropertyNames(virtuals), 11 | ]; 12 | 13 | exports.id = async (req, res, next, id) => { 14 | const populate = referencesNames.join(' '); 15 | try { 16 | const doc = await Model.findById(id).populate(populate).exec(); 17 | if (!doc) { 18 | const message = `${Model.modelName} not found`; 19 | 20 | next({ 21 | message, 22 | statusCode: 404, 23 | level: 'warn', 24 | }); 25 | } else { 26 | req.doc = doc; 27 | next(); 28 | } 29 | } catch (err) { 30 | next(new Error(err)); 31 | } 32 | }; 33 | 34 | exports.create = async (req, res, next) => { 35 | const { body = {}, decoded = {} } = req; 36 | const { _id = null } = decoded; 37 | if (_id) { 38 | body.userId = _id; 39 | } 40 | const document = new Model(body); 41 | 42 | try { 43 | const doc = await document.save(); 44 | res.status(201); 45 | res.json({ 46 | success: true, 47 | data: doc, 48 | }); 49 | } catch (err) { 50 | next(new Error(err)); 51 | } 52 | }; 53 | 54 | exports.all = async (req, res, next) => { 55 | const { query = {} } = req; 56 | const { limit, page, skip } = paginationParseParams(query); 57 | const { sortBy, direction } = sortParseParams(query, fields); 58 | const populate = populateToObject(referencesNames, virtuals); 59 | 60 | const all = Model.find({}) 61 | .sort(sortCompactToStr(sortBy, direction)) 62 | .skip(skip) 63 | .limit(limit) 64 | .populate(populate); 65 | const count = Model.countDocuments(); 66 | 67 | try { 68 | const data = await Promise.all([all.exec(), count.exec()]); 69 | const [docs, total] = data; 70 | const pages = Math.ceil(total / limit); 71 | 72 | res.json({ 73 | success: true, 74 | data: docs, 75 | meta: { 76 | limit, 77 | skip, 78 | total, 79 | page, 80 | pages, 81 | sortBy, 82 | direction, 83 | }, 84 | }); 85 | } catch (err) { 86 | next(new Error(err)); 87 | } 88 | }; 89 | 90 | exports.read = async (req, res, next) => { 91 | const { doc = {} } = req; 92 | 93 | res.json({ 94 | success: true, 95 | data: doc, 96 | }); 97 | }; 98 | 99 | exports.update = async (req, res, next) => { 100 | const { doc = {}, body = {} } = req; 101 | 102 | Object.assign(doc, body); 103 | 104 | try { 105 | const updated = await doc.save(); 106 | res.json({ 107 | success: true, 108 | data: updated, 109 | }); 110 | } catch (err) { 111 | next(new Error(err)); 112 | } 113 | }; 114 | 115 | exports.delete = async (req, res, next) => { 116 | const { doc = {} } = req; 117 | 118 | try { 119 | const removed = await doc.remove(); 120 | res.json({ 121 | success: true, 122 | data: removed, 123 | }); 124 | } catch (err) { 125 | next(new Error(err)); 126 | } 127 | }; 128 | -------------------------------------------------------------------------------- /server/api/v1/groups/model.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/groups/model.js 2 | 3 | const mongoose = require('mongoose'); 4 | const { body } = require('express-validator'); 5 | 6 | const { Schema } = mongoose; 7 | 8 | const fields = { 9 | title: { 10 | type: String, 11 | required: true, 12 | trim: true, 13 | maxlength: 128, 14 | }, 15 | }; 16 | 17 | const references = { 18 | userId: { 19 | type: Schema.Types.ObjectId, 20 | ref: 'user', 21 | required: true, 22 | }, 23 | }; 24 | 25 | const group = new Schema(Object.assign(fields, references), { 26 | timestamps: true, 27 | toJSON: { 28 | virtuals: true, 29 | }, 30 | }); 31 | 32 | const virtuals = { 33 | tasks: { 34 | ref: 'task', 35 | localField: '_id', 36 | foreignField: 'groupId', 37 | }, 38 | }; 39 | 40 | group.virtual('tasks', virtuals.tasks); 41 | 42 | const sanitizers = [body('title').escape()]; 43 | 44 | module.exports = { 45 | Model: mongoose.model('group', group), 46 | fields, 47 | references, 48 | virtuals, 49 | sanitizers, 50 | }; 51 | -------------------------------------------------------------------------------- /server/api/v1/groups/routes.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/groups/routes.js 2 | 3 | const router = require('express').Router(); 4 | const controller = require('./controller'); 5 | const { auth, owner } = require('../auth'); 6 | const { sanitizers } = require('./model'); 7 | 8 | /* 9 | * /api/groups/ POST - CREATE 10 | * /api/groups/ GET - READ ALL 11 | * /api/groups/:id GET - READ ONE 12 | * /api/groups/:id PUT - UPDATE 13 | * /api/groups/:id DELETE - DELETE 14 | */ 15 | 16 | router 17 | .route('/') 18 | .post(auth, sanitizers, controller.create) 19 | .get(auth, controller.all); 20 | 21 | router.param('id', controller.id); 22 | 23 | router 24 | .route('/:id') 25 | .get(auth, controller.read) 26 | .put(auth, owner, sanitizers, controller.update) 27 | .delete(auth, owner, controller.delete); 28 | 29 | module.exports = router; 30 | -------------------------------------------------------------------------------- /server/api/v1/index.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/index.js 2 | 3 | const router = require('express').Router(); 4 | 5 | const tasks = require('./tasks/routes'); 6 | const users = require('./users/routes'); 7 | const groups = require('./groups/routes'); 8 | 9 | router.use('/tasks', tasks); 10 | router.use('/users', users); 11 | router.use('/groups', groups); 12 | 13 | module.exports = router; 14 | -------------------------------------------------------------------------------- /server/api/v1/tasks/controller.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/tasks/controller.js 2 | 3 | const { Model, fields, references } = require('./model'); 4 | const { paginationParseParams } = require('../../../utils'); 5 | const { sortParseParams, sortCompactToStr } = require('../../../utils'); 6 | const { filterByNested } = require('../../../utils'); 7 | const { Model: User } = require('../users/model'); 8 | 9 | const referencesNames = Object.getOwnPropertyNames(references); 10 | 11 | exports.parentId = async (req, res, next) => { 12 | const { params = {} } = req; 13 | const { userId = null } = params; 14 | if (userId) { 15 | try { 16 | const doc = await User.findById(userId).exec(); 17 | if (doc) { 18 | next(); 19 | } else { 20 | const message = 'User not found'; 21 | 22 | next({ 23 | success: false, 24 | message, 25 | statusCode: 404, 26 | level: 'warn', 27 | }); 28 | } 29 | } catch (err) { 30 | next(new Error(err)); 31 | } 32 | } else { 33 | next(); 34 | } 35 | }; 36 | 37 | exports.id = async (req, res, next, id) => { 38 | const populate = referencesNames.join(' '); 39 | try { 40 | const doc = await Model.findById(id).populate(populate).exec(); 41 | if (!doc) { 42 | const message = `${Model.modelName} not found`; 43 | 44 | next({ 45 | message, 46 | statusCode: 404, 47 | level: 'warn', 48 | }); 49 | } else { 50 | req.doc = doc; 51 | next(); 52 | } 53 | } catch (err) { 54 | next(new Error(err)); 55 | } 56 | }; 57 | 58 | exports.create = async (req, res, next) => { 59 | const { body = {}, params = {}, decoded = {} } = req; 60 | const { _id = null } = decoded; 61 | if (_id) { 62 | body.userId = _id; 63 | } 64 | 65 | Object.assign(body, params); 66 | 67 | const document = new Model(body); 68 | 69 | try { 70 | const doc = await document.save(); 71 | res.status(201); 72 | res.json({ 73 | success: true, 74 | data: doc, 75 | }); 76 | } catch (err) { 77 | next(new Error(err)); 78 | } 79 | }; 80 | 81 | exports.all = async (req, res, next) => { 82 | const { query = {}, params = {} } = req; 83 | const { limit, page, skip } = paginationParseParams(query); 84 | const { sortBy, direction } = sortParseParams(query, fields); 85 | const { filters, populate } = filterByNested(params, referencesNames); 86 | 87 | const all = Model.find({}) 88 | .sort(sortCompactToStr(sortBy, direction)) 89 | .skip(skip) 90 | .limit(limit) 91 | .populate(populate); 92 | const count = Model.countDocuments(filters); 93 | 94 | try { 95 | const data = await Promise.all([all.exec(), count.exec()]); 96 | const [docs, total] = data; 97 | const pages = Math.ceil(total / limit); 98 | 99 | res.json({ 100 | success: true, 101 | data: docs, 102 | meta: { 103 | limit, 104 | skip, 105 | total, 106 | page, 107 | pages, 108 | sortBy, 109 | direction, 110 | }, 111 | }); 112 | } catch (err) { 113 | next(new Error(err)); 114 | } 115 | }; 116 | 117 | exports.read = async (req, res, next) => { 118 | const { doc = {} } = req; 119 | 120 | res.json({ 121 | success: true, 122 | data: doc, 123 | }); 124 | }; 125 | 126 | exports.update = async (req, res, next) => { 127 | const { doc = {}, body = {}, params = {} } = req; 128 | 129 | Object.assign(doc, body, params); 130 | 131 | try { 132 | const updated = await doc.save(); 133 | res.json({ 134 | success: true, 135 | data: updated, 136 | }); 137 | } catch (err) { 138 | next(new Error(err)); 139 | } 140 | }; 141 | 142 | exports.delete = async (req, res, next) => { 143 | const { doc = {} } = req; 144 | 145 | try { 146 | const removed = await doc.remove(); 147 | res.json({ 148 | success: true, 149 | data: removed, 150 | }); 151 | } catch (err) { 152 | next(new Error(err)); 153 | } 154 | }; 155 | -------------------------------------------------------------------------------- /server/api/v1/tasks/model.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/tasks/model.js 2 | 3 | const mongoose = require('mongoose'); 4 | const validator = require('validator'); 5 | const { body } = require('express-validator'); 6 | 7 | const { Schema } = mongoose; 8 | 9 | const fields = { 10 | title: { 11 | type: String, 12 | required: true, 13 | trim: true, 14 | maxlength: 128, 15 | }, 16 | completed: { 17 | type: Boolean, 18 | default: false, 19 | }, 20 | description: { 21 | type: String, 22 | default: '', 23 | trim: true, 24 | maxlength: 255, 25 | }, 26 | url: { 27 | type: String, 28 | default: '', 29 | trim: true, 30 | validate: { 31 | validator(value) { 32 | return value ? validator.isURL(value) : true; 33 | }, 34 | message: (props) => `${props.value} is not a valid url`, 35 | }, 36 | }, 37 | dueDate: { 38 | type: Date, 39 | default: null, 40 | }, 41 | }; 42 | 43 | const references = { 44 | userId: { 45 | type: Schema.Types.ObjectId, 46 | ref: 'user', 47 | required: true, 48 | }, 49 | groupId: { 50 | type: Schema.Types.ObjectId, 51 | ref: 'group', 52 | }, 53 | }; 54 | 55 | const task = new Schema(Object.assign(fields, references), { 56 | timestamps: true, 57 | }); 58 | 59 | const sanitizers = [ 60 | body('title').escape(), 61 | body('completed').toBoolean(), 62 | body('description').escape(), 63 | body('dueDate').toDate(), 64 | ]; 65 | 66 | module.exports = { 67 | Model: mongoose.model('task', task), 68 | fields, 69 | references, 70 | sanitizers, 71 | }; 72 | -------------------------------------------------------------------------------- /server/api/v1/tasks/routes.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/tasks/routes.js 2 | 3 | const router = require('express').Router({ 4 | mergeParams: true, 5 | }); 6 | const controller = require('./controller'); 7 | const { auth, owner } = require('../auth'); 8 | const { sanitizers } = require('./model'); 9 | 10 | /* 11 | * /api/tasks/ POST - CREATE 12 | * /api/tasks/ GET - READ ALL 13 | * /api/tasks/:id GET - READ ONE 14 | * /api/tasks/:id PUT - UPDATE 15 | * /api/tasks/:id DELETE - DELETE 16 | */ 17 | 18 | router.param('id', controller.id); 19 | 20 | /** 21 | * @swagger 22 | * definitions: 23 | * NewTask: 24 | * type: object 25 | * required: 26 | * - title 27 | * properties: 28 | * title: 29 | * type: string 30 | * completed: 31 | * type: boolean 32 | * description: 33 | * type: string 34 | * url: 35 | * type: string 36 | * dueDate: 37 | * type: string 38 | * format: date-time 39 | * Task: 40 | * allOf: 41 | * - $ref: '#/definitions/NewTask' 42 | * - properties: 43 | * id: 44 | * type: string 45 | * created_at: 46 | * type: string 47 | * format: date-time 48 | * update_at: 49 | * type: string 50 | * format: date-time 51 | */ 52 | 53 | router 54 | .route('/') 55 | /** 56 | * @swagger 57 | * /tasks/: 58 | * post: 59 | * tags: 60 | * - Tasks 61 | * description: New task 62 | * parameters: 63 | * - name: task 64 | * description: Task object 65 | * in: body 66 | * required: true 67 | * schema: 68 | * $ref: '#/definitions/NewTask' 69 | * consumes: 70 | * - application/json 71 | * produces: 72 | * - application/json 73 | * responses: 74 | * 200: 75 | * description: Task 76 | * schema: 77 | * $ref: '#/definitions/Task' 78 | */ 79 | .post(auth, controller.parentId, sanitizers, controller.create) 80 | /** 81 | * @swagger 82 | * /tasks/: 83 | * get: 84 | * tags: 85 | * - Tasks 86 | * description: Get all tasks 87 | * parameters: 88 | * - name: limit 89 | * description: Limit number of items 90 | * in: query 91 | * schema: 92 | * type: integer 93 | * - name: skip 94 | * description: Number of items to skip 95 | * in: query 96 | * schema: 97 | * type: integer 98 | * - name: page 99 | * description: Number of the page 100 | * in: query 101 | * schema: 102 | * type: integer 103 | * - name: sortBy 104 | * description: Sort by field name 105 | * in: query 106 | * schema: 107 | * type: string 108 | * - name: direction 109 | * description: Sort order 110 | * in: query 111 | * schema: 112 | * type: string 113 | * enum: [asc, desc] 114 | * produces: 115 | * - application/json 116 | * responses: 117 | * 200: 118 | * description: Task 119 | * schema: 120 | * type: object 121 | * properties: 122 | * success: 123 | * type: boolean 124 | * data: 125 | * type: array 126 | * items: 127 | * $ref: '#/definitions/Task' 128 | * meta: 129 | * type: object 130 | * properties: 131 | * limit: 132 | * type: integer 133 | * example: 10 134 | * skip: 135 | * type: integer 136 | * example: 0 137 | * total: 138 | * type: integer 139 | * example: 1 140 | * page: 141 | * type: integer 142 | * example: 1 143 | * pages: 144 | * type: integer 145 | * example: 1 146 | * sortyBy: 147 | * type: string 148 | * example: created_at 149 | * direction: 150 | * type: string 151 | * example: desc 152 | */ 153 | .get(auth, controller.parentId, controller.all); 154 | 155 | router 156 | .route('/:id') 157 | /** 158 | * @swagger 159 | * 160 | * /tasks/{id}: 161 | * get: 162 | * tags: 163 | * - Tasks 164 | * description: Get a task 165 | * parameters: 166 | * - name: id 167 | * in: path 168 | * description: Task id 169 | * required: true 170 | * type: string 171 | * produces: 172 | * - application/json 173 | * responses: 174 | * 200: 175 | * description: Task 176 | * schema: 177 | * $ref: '#/definitions/Task' 178 | */ 179 | .get(auth, controller.parentId, controller.read) 180 | /** 181 | * @swagger 182 | * /tasks/{id}: 183 | * put: 184 | * tags: 185 | * - Tasks 186 | * description: Update a Task 187 | * parameters: 188 | * - name: id 189 | * in: path 190 | * description: Task id 191 | * required: true 192 | * type: string 193 | * - name: task 194 | * description: Post object 195 | * in: body 196 | * required: true 197 | * schema: 198 | * $ref: '#/definitions/NewTask' 199 | * consumes: 200 | * - application/json 201 | * produces: 202 | * - application/json 203 | * responses: 204 | * 200: 205 | * description: Task 206 | * schema: 207 | * $ref: '#/definitions/Task' 208 | */ 209 | .put(auth, owner, controller.parentId, sanitizers, controller.update) 210 | /** 211 | * @swagger 212 | * /tasks/{id}: 213 | * delete: 214 | * tags: 215 | * - Tasks 216 | * description: Delete a task 217 | * parameters: 218 | * - name: id 219 | * in: path 220 | * description: Task id 221 | * required: true 222 | * type: string 223 | * produces: 224 | * - application/json 225 | * responses: 226 | * 200: 227 | * description: Task 228 | * schema: 229 | * $ref: '#/definitions/Task' 230 | */ 231 | .delete(auth, owner, controller.parentId, controller.delete); 232 | 233 | module.exports = router; 234 | -------------------------------------------------------------------------------- /server/api/v1/users/controller.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/users/controller.js 2 | 3 | const { Model, fields } = require('./model'); 4 | const { signToken } = require('../auth'); 5 | const { paginationParseParams } = require('../../../utils'); 6 | const { sortParseParams, sortCompactToStr } = require('../../../utils'); 7 | 8 | exports.id = async (req, res, next, id) => { 9 | try { 10 | const doc = await Model.findById(id).exec(); 11 | if (!doc) { 12 | const message = `${Model.modelName} not found`; 13 | 14 | next({ 15 | message, 16 | statusCode: 404, 17 | level: 'warn', 18 | }); 19 | } else { 20 | req.doc = doc; 21 | next(); 22 | } 23 | } catch (err) { 24 | next(new Error(err)); 25 | } 26 | }; 27 | 28 | exports.signup = async (req, res, next) => { 29 | const { body = {} } = req; 30 | const document = new Model(body); 31 | 32 | try { 33 | const doc = await document.save(); 34 | const { _id } = doc; 35 | const token = signToken({ _id }); 36 | 37 | res.status(201); 38 | res.json({ 39 | success: true, 40 | data: doc, 41 | meta: { 42 | token, 43 | }, 44 | }); 45 | } catch (err) { 46 | next(new Error(err)); 47 | } 48 | }; 49 | 50 | exports.signin = async (req, res, next) => { 51 | const { body = {} } = req; 52 | const { email = '', password = '' } = body; 53 | 54 | try { 55 | const user = await Model.findOne({ email }).exec(); 56 | if (!user) { 57 | const message = 'Email or password are invalid'; 58 | 59 | return next({ 60 | success: false, 61 | message, 62 | statusCode: 401, 63 | level: 'info', 64 | }); 65 | } 66 | 67 | const verified = await user.verifyPassword(password); 68 | if (!verified) { 69 | const message = 'Email or password are invalid'; 70 | 71 | return next({ 72 | success: false, 73 | message, 74 | statusCode: 401, 75 | level: 'info', 76 | }); 77 | } 78 | 79 | const { _id } = user; 80 | const token = signToken({ _id }); 81 | return res.json({ 82 | success: true, 83 | data: user, 84 | meta: { 85 | token, 86 | }, 87 | }); 88 | } catch (error) { 89 | return next(new Error(error)); 90 | } 91 | }; 92 | 93 | exports.all = async (req, res, next) => { 94 | const { query = {} } = req; 95 | const { limit, page, skip } = paginationParseParams(query); 96 | const { sortBy, direction } = sortParseParams(query, fields); 97 | 98 | const all = Model.find({}) 99 | .sort(sortCompactToStr(sortBy, direction)) 100 | .skip(skip) 101 | .limit(limit); 102 | const count = Model.countDocuments(); 103 | 104 | try { 105 | const data = await Promise.all([all.exec(), count.exec()]); 106 | const [docs, total] = data; 107 | const pages = Math.ceil(total / limit); 108 | 109 | res.json({ 110 | success: true, 111 | data: docs, 112 | meta: { 113 | limit, 114 | skip, 115 | total, 116 | page, 117 | pages, 118 | sortBy, 119 | direction, 120 | }, 121 | }); 122 | } catch (err) { 123 | next(new Error(err)); 124 | } 125 | }; 126 | 127 | exports.read = async (req, res, next) => { 128 | const { doc = {} } = req; 129 | 130 | res.json({ 131 | success: true, 132 | data: doc, 133 | }); 134 | }; 135 | 136 | exports.update = async (req, res, next) => { 137 | const { doc = {}, body = {} } = req; 138 | 139 | Object.assign(doc, body); 140 | 141 | try { 142 | const updated = await doc.save(); 143 | res.json({ 144 | success: true, 145 | data: updated, 146 | }); 147 | } catch (err) { 148 | next(new Error(err)); 149 | } 150 | }; 151 | 152 | exports.delete = async (req, res, next) => { 153 | const { doc = {} } = req; 154 | 155 | try { 156 | const removed = await doc.remove(); 157 | res.json({ 158 | success: true, 159 | data: removed, 160 | }); 161 | } catch (err) { 162 | next(new Error(err)); 163 | } 164 | }; 165 | -------------------------------------------------------------------------------- /server/api/v1/users/model.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/users/model.js 2 | 3 | const mongoose = require('mongoose'); 4 | const { hash, compare } = require('bcryptjs'); 5 | const validator = require('validator'); 6 | const { body } = require('express-validator'); 7 | 8 | const { Schema } = mongoose; 9 | 10 | const fields = { 11 | firstname: { 12 | type: String, 13 | required: true, 14 | trim: true, 15 | maxlength: 64, 16 | }, 17 | lastname: { 18 | type: String, 19 | required: true, 20 | trim: true, 21 | maxlength: 64, 22 | }, 23 | email: { 24 | type: String, 25 | required: true, 26 | unique: true, 27 | trim: true, 28 | lowercase: true, 29 | validate: { 30 | validator(value) { 31 | return validator.isEmail(value); 32 | }, 33 | message: (props) => `${props.value} is not a valid email`, 34 | }, 35 | }, 36 | password: { 37 | type: String, 38 | required: true, 39 | trim: true, 40 | }, 41 | }; 42 | 43 | const user = new Schema(fields, { 44 | timestamps: true, 45 | toJSON: { 46 | virtuals: true, 47 | }, 48 | toObject: { 49 | virtuals: true, 50 | }, 51 | }); 52 | 53 | user 54 | .virtual('name') 55 | .get(function getName() { 56 | return `${this.firstname} ${this.lastname}`; 57 | }) 58 | .set(function setName(name) { 59 | const [firstname = '', lastname = ''] = name.split(' '); 60 | this.firstname = firstname; 61 | this.lastname = lastname; 62 | }); 63 | 64 | const hiddenFields = ['password']; 65 | 66 | user.methods.toJSON = function toJSON() { 67 | const doc = this.toObject(); 68 | hiddenFields.forEach((field) => { 69 | if (Object.hasOwnProperty.call(doc, field)) { 70 | delete doc[field]; 71 | } 72 | }); 73 | return doc; 74 | }; 75 | 76 | user.pre('save', async function save(next) { 77 | if (this.isNew || this.isModified('password')) { 78 | this.password = await hash(this.password, 10); 79 | } 80 | next(); 81 | }); 82 | 83 | user.methods.verifyPassword = function verifyPassword(password) { 84 | return compare(password, this.password); 85 | }; 86 | 87 | const sanitizers = [body('email').isEmail().normalizeEmail()]; 88 | 89 | module.exports = { 90 | Model: mongoose.model('user', user), 91 | fields, 92 | sanitizers, 93 | }; 94 | -------------------------------------------------------------------------------- /server/api/v1/users/routes.js: -------------------------------------------------------------------------------- 1 | // server/api/v1/users/routes.js 2 | 3 | const router = require('express').Router(); 4 | const tasksRouter = require('../tasks/routes'); 5 | const controller = require('./controller'); 6 | const { auth, me } = require('../auth'); 7 | const { sanitizers } = require('./model'); 8 | 9 | /* 10 | * /api/users/ GET - READ ALL 11 | * /api/users/signup POST - CREATE 12 | * /api/users/signin POST - LOGIN 13 | * /api/users/:id GET - READ ONE 14 | * /api/users/:id PUT - UPDATE 15 | * /api/users/:id DELETE - DELETE 16 | */ 17 | 18 | router.param('id', controller.id); 19 | 20 | router.route('/').get(controller.all); 21 | 22 | router.route('/signup').post(sanitizers, controller.signup); 23 | router.route('/signin').post(sanitizers, controller.signin); 24 | 25 | router 26 | .route('/:id') 27 | .get(auth, me, controller.read) 28 | .put(auth, me, sanitizers, controller.update) 29 | .delete(auth, me, controller.delete); 30 | 31 | router.use('/:userId/tasks', tasksRouter); 32 | 33 | module.exports = router; 34 | -------------------------------------------------------------------------------- /server/config/docs.js: -------------------------------------------------------------------------------- 1 | // server/config/docs.js 2 | 3 | const swaggerDefinition = { 4 | info: { 5 | title: 'Checklist API', 6 | version: '1.0.0', 7 | description: 'Checklist API Documentation', 8 | }, 9 | basePath: '/api/', 10 | schemes: ['http'], 11 | consumes: ['application/json'], 12 | produces: ['application/json'], 13 | }; 14 | 15 | module.exports = { 16 | swaggerDefinition, 17 | }; 18 | -------------------------------------------------------------------------------- /server/config/index.js: -------------------------------------------------------------------------------- 1 | // server/config/index.js 2 | 3 | require('dotenv').config(''); 4 | 5 | const config = { 6 | server: { 7 | port: process.env.SERVER_PORT, 8 | }, 9 | database: { 10 | protocol: process.env.DATABASE_PROTOCOL, 11 | url: process.env.DATABASE_URL, 12 | username: process.env.DATABASE_USERNAME, 13 | password: process.env.DATABASE_PASSWORD, 14 | }, 15 | pagination: { 16 | limit: 10, 17 | skip: 0, 18 | page: 1, 19 | }, 20 | sort: { 21 | sortBy: { 22 | default: 'createdAt', 23 | fields: ['createdAt', 'updatedAt'], 24 | }, 25 | direction: { 26 | default: 'desc', 27 | options: ['asc', 'desc'], 28 | }, 29 | }, 30 | populate: { 31 | virtuals: { 32 | limit: 20, 33 | sort: 'createdAt', 34 | direction: 'desc', 35 | }, 36 | }, 37 | token: { 38 | secret: process.env.TOKEN_SECRET, 39 | expires: process.env.TOKEN_EXPIRES, 40 | }, 41 | }; 42 | 43 | module.exports = config; 44 | -------------------------------------------------------------------------------- /server/config/logger.js: -------------------------------------------------------------------------------- 1 | // server/config/logger.js 2 | 3 | const { createLogger, format, transports } = require('winston'); 4 | const morgan = require('morgan'); 5 | const stripFinalNewline = require('strip-final-newline'); 6 | 7 | // Setup logger 8 | const logger = createLogger({ 9 | format: format.simple(), 10 | transports: [new transports.Console()], 11 | }); 12 | 13 | // Setup requests logger 14 | morgan.token('id', (req) => req.id); 15 | 16 | const requestFormat = ':remote-addr [:date[iso]] :id ":method :url" :status'; 17 | const requests = morgan(requestFormat, { 18 | stream: { 19 | write: (message) => { 20 | // Remove all line breaks 21 | const log = stripFinalNewline(message); 22 | return logger.info(log); 23 | }, 24 | }, 25 | }); 26 | 27 | // Attach to logger object 28 | logger.requests = requests; 29 | 30 | // Format as request logger and attach to logger object 31 | logger.header = (req) => { 32 | const date = new Date().toISOString(); 33 | return `${req.ip} [${date}] ${req.id} "${req.method} ${req.originalUrl}"`; 34 | }; 35 | 36 | module.exports = logger; 37 | -------------------------------------------------------------------------------- /server/database.js: -------------------------------------------------------------------------------- 1 | // server/database.js 2 | 3 | const mongoose = require('mongoose'); 4 | 5 | const logger = require('./config/logger'); 6 | 7 | exports.connect = ( 8 | { protocol = 'mongodb', url, username = '', password = '' }, 9 | options = {} 10 | ) => { 11 | let dburl = ''; 12 | 13 | // Required auth 14 | if (username && password) { 15 | dburl = `${protocol}://${username}:${password}@${url}`; 16 | } else { 17 | dburl = `${protocol}://${url}`; 18 | } 19 | 20 | mongoose.connect(dburl, { 21 | ...options, 22 | useNewUrlParser: true, 23 | useCreateIndex: true, 24 | useUnifiedTopology: true, 25 | }); 26 | 27 | mongoose.connection.on('open', () => { 28 | logger.info('Database connected'); 29 | }); 30 | 31 | mongoose.connection.on('close', () => { 32 | logger.info('Database disconnected'); 33 | }); 34 | 35 | mongoose.connection.on('error', (err) => { 36 | logger.error(`Database connection error: ${err}`); 37 | }); 38 | 39 | process.on('SIGINT', () => { 40 | mongoose.connection.close(() => { 41 | logger.info('Database connection disconnected through app termination'); 42 | process.exit(0); 43 | }); 44 | }); 45 | }; 46 | 47 | exports.disconnect = () => { 48 | mongoose.connection.close(() => { 49 | logger.info('Database disconnected'); 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | // server/index.js 2 | 3 | const express = require('express'); 4 | const requestId = require('express-request-id')(); 5 | const bodyParser = require('body-parser'); 6 | const cors = require('cors'); 7 | const swaggerUI = require('swagger-ui-express'); 8 | 9 | const logger = require('./config/logger'); 10 | const api = require('./api/v1'); 11 | const docs = require('./api/v1/docs'); 12 | 13 | // Init app 14 | const app = express(); 15 | 16 | // Documentation 17 | app.use('/docs', swaggerUI.serve, swaggerUI.setup(docs)); 18 | 19 | // setup CORS 20 | app.use( 21 | cors({ 22 | origin: '*', 23 | methods: ['GET', 'PUT', 'POST', 'DELETE'], 24 | allowedHeaders: ['Accept', 'Content-Type', 'Authorization'], 25 | }) 26 | ); 27 | 28 | // Setup middleware 29 | app.use(requestId); 30 | app.use(logger.requests); 31 | 32 | // parse application/x-www-form-urlencoded 33 | app.use(bodyParser.urlencoded({ extended: false })); 34 | // parse application/json 35 | app.use(bodyParser.json()); 36 | 37 | // Setup router and routes 38 | app.use('/api', api); 39 | app.use('/api/v1', api); 40 | 41 | // No route found handler 42 | app.use((req, res, next) => { 43 | next({ 44 | message: 'Route not found', 45 | statusCode: 404, 46 | level: 'warn', 47 | }); 48 | }); 49 | 50 | // Error handler 51 | app.use((err, req, res, next) => { 52 | const { message, level = 'error' } = err; 53 | let { statusCode = 500 } = err; 54 | const log = `${logger.header(req)} ${statusCode} ${message}`; 55 | 56 | // Validation Errors 57 | if (err.message.startsWith('ValidationError')) { 58 | statusCode = 422; 59 | } 60 | 61 | logger[level](log); 62 | 63 | res.status(statusCode); 64 | res.json({ 65 | error: true, 66 | statusCode, 67 | message, 68 | }); 69 | }); 70 | 71 | module.exports = app; 72 | -------------------------------------------------------------------------------- /server/utils/index.js: -------------------------------------------------------------------------------- 1 | // server/utils/index.js 2 | 3 | const config = require('../config'); 4 | 5 | const { pagination, sort, populate } = config; 6 | 7 | const paginationParseParams = ({ 8 | limit = pagination.limit, 9 | page = pagination.page, 10 | skip = pagination.skip, 11 | }) => ({ 12 | limit: parseInt(limit, 10), 13 | page: parseInt(page, 10), 14 | skip: skip ? parseInt(skip, 10) : (page - 1) * limit, 15 | }); 16 | 17 | const sortParseParams = ( 18 | { sortBy = sort.sortBy.default, direction = sort.direction.default }, 19 | fields 20 | ) => { 21 | const safelist = { 22 | sortBy: [...Object.getOwnPropertyNames(fields), ...sort.sortBy.fields], 23 | direction: sort.direction.options, 24 | }; 25 | return { 26 | sortBy: safelist.sortBy.includes(sortBy) ? sortBy : sort.sortBy.default, 27 | direction: safelist.direction.includes(direction) 28 | ? direction 29 | : sort.direction.default, 30 | }; 31 | }; 32 | 33 | const sortCompactToStr = (sortBy, direction) => { 34 | const dir = direction === sort.direction.default ? '-' : ''; 35 | return `${dir}${sortBy}`; 36 | }; 37 | 38 | const filterByNested = (params, referencesNames) => { 39 | const paramsNames = Object.getOwnPropertyNames(params); 40 | const populateNames = referencesNames.filter( 41 | (item) => !paramsNames.includes(item) 42 | ); 43 | 44 | return { 45 | filters: params, 46 | populate: populateNames.join(' '), 47 | }; 48 | }; 49 | 50 | const populateToObject = (populateNames, virtuals = {}) => { 51 | const virtualsNames = Object.getOwnPropertyNames(virtuals); 52 | return populateNames.map((item) => { 53 | let options = {}; 54 | if (virtualsNames.includes(item)) { 55 | options = { 56 | limit: populate.virtuals.limit, 57 | sort: sortCompactToStr( 58 | populate.virtuals.sort, 59 | populate.virtuals.direction 60 | ), 61 | }; 62 | } 63 | return { 64 | path: item, 65 | options, 66 | }; 67 | }); 68 | }; 69 | 70 | module.exports = { 71 | paginationParseParams, 72 | sortParseParams, 73 | sortCompactToStr, 74 | filterByNested, 75 | populateToObject, 76 | }; 77 | -------------------------------------------------------------------------------- /server/utils/index.test.js: -------------------------------------------------------------------------------- 1 | // server/utils/index.test.js 2 | 3 | const { sortCompactToStr } = require('.'); 4 | 5 | describe('Utils', () => { 6 | describe('sortCompactStr', () => { 7 | test('Descendant', () => { 8 | const result = sortCompactToStr('createdAt', 'desc'); 9 | const expected = '-createdAt'; 10 | 11 | expect(result).toEqual(expected); 12 | }); 13 | test('Ascendant', () => { 14 | const result = sortCompactToStr('createdAt', 'asc'); 15 | const expected = 'createdAt'; 16 | 17 | expect(result).toEqual(expected); 18 | }); 19 | }); 20 | }); 21 | --------------------------------------------------------------------------------