create-nodejs-express-starter 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.dockerignore +3 -0
  2. package/.editorconfig +9 -0
  3. package/.env.example +22 -0
  4. package/.eslintignore +2 -0
  5. package/.eslintrc.json +32 -0
  6. package/.gitignore +14 -0
  7. package/.prettierignore +3 -0
  8. package/.prettierrc.json +4 -0
  9. package/Dockerfile +15 -0
  10. package/LICENSE +21 -0
  11. package/README.md +440 -0
  12. package/bin/createNodejsApp.js +106 -0
  13. package/docker-compose.dev.yml +4 -0
  14. package/docker-compose.prod.yml +4 -0
  15. package/docker-compose.test.yml +4 -0
  16. package/docker-compose.yml +30 -0
  17. package/jest.config.js +9 -0
  18. package/package.json +117 -0
  19. package/src/app.js +82 -0
  20. package/src/config/config.js +64 -0
  21. package/src/config/logger.js +26 -0
  22. package/src/config/morgan.js +25 -0
  23. package/src/config/passport.js +30 -0
  24. package/src/config/roles.js +12 -0
  25. package/src/config/tokens.js +10 -0
  26. package/src/controllers/auth.controller.js +59 -0
  27. package/src/controllers/index.js +2 -0
  28. package/src/controllers/user.controller.js +43 -0
  29. package/src/docs/components.yml +92 -0
  30. package/src/docs/swaggerDef.js +21 -0
  31. package/src/index.js +57 -0
  32. package/src/middlewares/auth.js +33 -0
  33. package/src/middlewares/error.js +47 -0
  34. package/src/middlewares/rateLimiter.js +11 -0
  35. package/src/middlewares/requestId.js +14 -0
  36. package/src/middlewares/validate.js +21 -0
  37. package/src/models/index.js +2 -0
  38. package/src/models/plugins/index.js +2 -0
  39. package/src/models/plugins/paginate.plugin.js +70 -0
  40. package/src/models/plugins/toJSON.plugin.js +43 -0
  41. package/src/models/token.model.js +44 -0
  42. package/src/models/user.model.js +91 -0
  43. package/src/routes/v1/auth.route.js +291 -0
  44. package/src/routes/v1/docs.route.js +21 -0
  45. package/src/routes/v1/health.route.js +43 -0
  46. package/src/routes/v1/index.js +39 -0
  47. package/src/routes/v1/user.route.js +252 -0
  48. package/src/services/auth.service.js +99 -0
  49. package/src/services/email.service.js +63 -0
  50. package/src/services/index.js +4 -0
  51. package/src/services/token.service.js +123 -0
  52. package/src/services/user.service.js +89 -0
  53. package/src/utils/ApiError.js +14 -0
  54. package/src/utils/catchAsync.js +5 -0
  55. package/src/utils/pick.js +17 -0
  56. package/src/validations/auth.validation.js +60 -0
  57. package/src/validations/custom.validation.js +21 -0
  58. package/src/validations/index.js +2 -0
  59. package/src/validations/user.validation.js +54 -0
  60. package/tests/fixtures/token.fixture.js +14 -0
  61. package/tests/fixtures/user.fixture.js +46 -0
  62. package/tests/integration/auth.test.js +587 -0
  63. package/tests/integration/docs.test.js +14 -0
  64. package/tests/integration/health.test.js +32 -0
  65. package/tests/integration/user.test.js +625 -0
  66. package/tests/unit/middlewares/error.test.js +168 -0
  67. package/tests/unit/models/plugins/paginate.plugin.test.js +61 -0
  68. package/tests/unit/models/plugins/toJSON.plugin.test.js +89 -0
  69. package/tests/unit/models/user.model.test.js +57 -0
  70. package/tests/utils/setupTestDB.js +18 -0
package/src/index.js ADDED
@@ -0,0 +1,57 @@
1
+ const mongoose = require('mongoose');
2
+ const app = require('./app');
3
+ const config = require('./config/config');
4
+ const logger = require('./config/logger');
5
+
6
+ let server;
7
+
8
+ mongoose
9
+ .connect(config.mongoose.url, config.mongoose.options)
10
+ .then(() => {
11
+ logger.info('Connected to MongoDB');
12
+ server = app.listen(config.port, () => {
13
+ logger.info(`Listening to port ${config.port}`);
14
+ });
15
+ })
16
+ .catch((err) => {
17
+ logger.error('MongoDB connection failed:', err.message);
18
+ process.exit(1);
19
+ });
20
+
21
+ const exitHandler = () => {
22
+ if (server) {
23
+ server.close(() => {
24
+ logger.info('Server closed');
25
+ mongoose.connection.close(false).then(() => {
26
+ logger.info('MongoDB connection closed');
27
+ process.exit(1);
28
+ });
29
+ });
30
+ } else {
31
+ mongoose.connection.close(false).then(() => {
32
+ process.exit(1);
33
+ });
34
+ }
35
+ };
36
+
37
+ const unexpectedErrorHandler = (error) => {
38
+ logger.error(error);
39
+ exitHandler();
40
+ };
41
+
42
+ process.on('uncaughtException', unexpectedErrorHandler);
43
+ process.on('unhandledRejection', unexpectedErrorHandler);
44
+
45
+ process.on('SIGTERM', () => {
46
+ logger.info('SIGTERM received');
47
+ if (server) {
48
+ server.close(() => {
49
+ mongoose.connection.close(false).then(() => {
50
+ logger.info('Graceful shutdown complete');
51
+ process.exit(0);
52
+ });
53
+ });
54
+ } else {
55
+ mongoose.connection.close(false).then(() => process.exit(0));
56
+ }
57
+ });
@@ -0,0 +1,33 @@
1
+ const passport = require('passport');
2
+ const httpStatus = require('http-status');
3
+ const ApiError = require('../utils/ApiError');
4
+ const { roleRights } = require('../config/roles');
5
+
6
+ const verifyCallback = (req, resolve, reject, requiredRights) => async (err, user, info) => {
7
+ if (err || info || !user) {
8
+ return reject(new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'));
9
+ }
10
+ req.user = user;
11
+
12
+ if (requiredRights.length) {
13
+ const userRights = roleRights.get(user.role);
14
+ const hasRequiredRights = requiredRights.every((requiredRight) => userRights.includes(requiredRight));
15
+ if (!hasRequiredRights && req.params.userId !== user.id) {
16
+ return reject(new ApiError(httpStatus.FORBIDDEN, 'Forbidden'));
17
+ }
18
+ }
19
+
20
+ resolve();
21
+ };
22
+
23
+ const auth =
24
+ (...requiredRights) =>
25
+ async (req, res, next) => {
26
+ return new Promise((resolve, reject) => {
27
+ passport.authenticate('jwt', { session: false }, verifyCallback(req, resolve, reject, requiredRights))(req, res, next);
28
+ })
29
+ .then(() => next())
30
+ .catch((err) => next(err));
31
+ };
32
+
33
+ module.exports = auth;
@@ -0,0 +1,47 @@
1
+ const mongoose = require('mongoose');
2
+ const httpStatus = require('http-status');
3
+ const config = require('../config/config');
4
+ const logger = require('../config/logger');
5
+ const ApiError = require('../utils/ApiError');
6
+
7
+ const errorConverter = (err, req, res, next) => {
8
+ let error = err;
9
+ if (!(error instanceof ApiError)) {
10
+ const statusCode =
11
+ error.statusCode || error instanceof mongoose.Error ? httpStatus.BAD_REQUEST : httpStatus.INTERNAL_SERVER_ERROR;
12
+ const message = error.message || httpStatus[statusCode];
13
+ error = new ApiError(statusCode, message, false, err.stack);
14
+ }
15
+ next(error);
16
+ };
17
+
18
+ // eslint-disable-next-line no-unused-vars
19
+ const errorHandler = (err, req, res, next) => {
20
+ let { statusCode, message } = err;
21
+ if (config.env === 'production' && !err.isOperational) {
22
+ statusCode = httpStatus.INTERNAL_SERVER_ERROR;
23
+ message = httpStatus[httpStatus.INTERNAL_SERVER_ERROR];
24
+ }
25
+
26
+ res.locals.errorMessage = err.message;
27
+
28
+ const response = {
29
+ code: statusCode,
30
+ message,
31
+ ...(req.id && { requestId: req.id }),
32
+ ...(config.env === 'development' && { stack: err.stack }),
33
+ };
34
+
35
+ if (config.env === 'development') {
36
+ logger.error(err);
37
+ } else if (config.env !== 'test') {
38
+ logger.error(`[${req.id || 'unknown'}] ${statusCode} - ${message}`);
39
+ }
40
+
41
+ res.status(statusCode).send(response);
42
+ };
43
+
44
+ module.exports = {
45
+ errorConverter,
46
+ errorHandler,
47
+ };
@@ -0,0 +1,11 @@
1
+ const rateLimit = require('express-rate-limit');
2
+
3
+ const authLimiter = rateLimit({
4
+ windowMs: 15 * 60 * 1000,
5
+ max: 20,
6
+ skipSuccessfulRequests: true,
7
+ });
8
+
9
+ module.exports = {
10
+ authLimiter,
11
+ };
@@ -0,0 +1,14 @@
1
+ const { randomUUID } = require('crypto');
2
+
3
+ /**
4
+ * Attach a unique request ID to each request for tracing
5
+ * Uses X-Request-Id header if present, otherwise generates a UUID
6
+ */
7
+ const requestId = (req, res, next) => {
8
+ const id = req.get('X-Request-Id') || randomUUID();
9
+ req.id = id;
10
+ res.setHeader('X-Request-Id', id);
11
+ next();
12
+ };
13
+
14
+ module.exports = requestId;
@@ -0,0 +1,21 @@
1
+ const Joi = require('joi');
2
+ const httpStatus = require('http-status');
3
+ const pick = require('../utils/pick');
4
+ const ApiError = require('../utils/ApiError');
5
+
6
+ const validate = (schema) => (req, res, next) => {
7
+ const validSchema = pick(schema, ['params', 'query', 'body']);
8
+ const object = pick(req, Object.keys(validSchema));
9
+ const { value, error } = Joi.compile(validSchema)
10
+ .prefs({ errors: { label: 'key' }, abortEarly: false })
11
+ .validate(object);
12
+
13
+ if (error) {
14
+ const errorMessage = error.details.map((details) => details.message).join(', ');
15
+ return next(new ApiError(httpStatus.BAD_REQUEST, errorMessage));
16
+ }
17
+ Object.assign(req, value);
18
+ return next();
19
+ };
20
+
21
+ module.exports = validate;
@@ -0,0 +1,2 @@
1
+ module.exports.Token = require('./token.model');
2
+ module.exports.User = require('./user.model');
@@ -0,0 +1,2 @@
1
+ module.exports.toJSON = require('./toJSON.plugin');
2
+ module.exports.paginate = require('./paginate.plugin');
@@ -0,0 +1,70 @@
1
+ /* eslint-disable no-param-reassign */
2
+
3
+ const paginate = (schema) => {
4
+ /**
5
+ * @typedef {Object} QueryResult
6
+ * @property {Document[]} results - Results found
7
+ * @property {number} page - Current page
8
+ * @property {number} limit - Maximum number of results per page
9
+ * @property {number} totalPages - Total number of pages
10
+ * @property {number} totalResults - Total number of documents
11
+ */
12
+ /**
13
+ * Query for documents with pagination
14
+ * @param {Object} [filter] - Mongo filter
15
+ * @param {Object} [options] - Query options
16
+ * @param {string} [options.sortBy] - Sorting criteria using the format: sortField:(desc|asc). Multiple sorting criteria should be separated by commas (,)
17
+ * @param {string} [options.populate] - Populate data fields. Hierarchy of fields should be separated by (.). Multiple populating criteria should be separated by commas (,)
18
+ * @param {number} [options.limit] - Maximum number of results per page (default = 10)
19
+ * @param {number} [options.page] - Current page (default = 1)
20
+ * @returns {Promise<QueryResult>}
21
+ */
22
+ schema.statics.paginate = async function (filter, options) {
23
+ let sort = '';
24
+ if (options.sortBy) {
25
+ const sortingCriteria = [];
26
+ options.sortBy.split(',').forEach((sortOption) => {
27
+ const [key, order] = sortOption.split(':');
28
+ sortingCriteria.push((order === 'desc' ? '-' : '') + key);
29
+ });
30
+ sort = sortingCriteria.join(' ');
31
+ } else {
32
+ sort = 'createdAt';
33
+ }
34
+
35
+ const limit = options.limit && parseInt(options.limit, 10) > 0 ? parseInt(options.limit, 10) : 10;
36
+ const page = options.page && parseInt(options.page, 10) > 0 ? parseInt(options.page, 10) : 1;
37
+ const skip = (page - 1) * limit;
38
+
39
+ const countPromise = this.countDocuments(filter).exec();
40
+ let docsPromise = this.find(filter).sort(sort).skip(skip).limit(limit);
41
+
42
+ if (options.populate) {
43
+ options.populate.split(',').forEach((populateOption) => {
44
+ docsPromise = docsPromise.populate(
45
+ populateOption
46
+ .split('.')
47
+ .reverse()
48
+ .reduce((a, b) => ({ path: b, populate: a })),
49
+ );
50
+ });
51
+ }
52
+
53
+ docsPromise = docsPromise.exec();
54
+
55
+ return Promise.all([countPromise, docsPromise]).then((values) => {
56
+ const [totalResults, results] = values;
57
+ const totalPages = Math.ceil(totalResults / limit);
58
+ const result = {
59
+ results,
60
+ page,
61
+ limit,
62
+ totalPages,
63
+ totalResults,
64
+ };
65
+ return Promise.resolve(result);
66
+ });
67
+ };
68
+ };
69
+
70
+ module.exports = paginate;
@@ -0,0 +1,43 @@
1
+ /* eslint-disable no-param-reassign */
2
+
3
+ /**
4
+ * A mongoose schema plugin which applies the following in the toJSON transform call:
5
+ * - removes __v, createdAt, updatedAt, and any path that has private: true
6
+ * - replaces _id with id
7
+ */
8
+
9
+ const deleteAtPath = (obj, path, index) => {
10
+ if (index === path.length - 1) {
11
+ delete obj[path[index]];
12
+ return;
13
+ }
14
+ deleteAtPath(obj[path[index]], path, index + 1);
15
+ };
16
+
17
+ const toJSON = (schema) => {
18
+ let transform;
19
+ if (schema.options.toJSON && schema.options.toJSON.transform) {
20
+ transform = schema.options.toJSON.transform;
21
+ }
22
+
23
+ schema.options.toJSON = Object.assign(schema.options.toJSON || {}, {
24
+ transform(doc, ret, options) {
25
+ Object.keys(schema.paths).forEach((path) => {
26
+ if (schema.paths[path].options && schema.paths[path].options.private) {
27
+ deleteAtPath(ret, path.split('.'), 0);
28
+ }
29
+ });
30
+
31
+ ret.id = ret._id.toString();
32
+ delete ret._id;
33
+ delete ret.__v;
34
+ delete ret.createdAt;
35
+ delete ret.updatedAt;
36
+ if (transform) {
37
+ return transform(doc, ret, options);
38
+ }
39
+ },
40
+ });
41
+ };
42
+
43
+ module.exports = toJSON;
@@ -0,0 +1,44 @@
1
+ const mongoose = require('mongoose');
2
+ const { toJSON } = require('./plugins');
3
+ const { tokenTypes } = require('../config/tokens');
4
+
5
+ const tokenSchema = mongoose.Schema(
6
+ {
7
+ token: {
8
+ type: String,
9
+ required: true,
10
+ index: true,
11
+ },
12
+ user: {
13
+ type: mongoose.SchemaTypes.ObjectId,
14
+ ref: 'User',
15
+ required: true,
16
+ },
17
+ type: {
18
+ type: String,
19
+ enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD, tokenTypes.VERIFY_EMAIL],
20
+ required: true,
21
+ },
22
+ expires: {
23
+ type: Date,
24
+ required: true,
25
+ },
26
+ blacklisted: {
27
+ type: Boolean,
28
+ default: false,
29
+ },
30
+ },
31
+ {
32
+ timestamps: true,
33
+ },
34
+ );
35
+
36
+ // add plugin that converts mongoose to json
37
+ tokenSchema.plugin(toJSON);
38
+
39
+ /**
40
+ * @typedef Token
41
+ */
42
+ const Token = mongoose.model('Token', tokenSchema);
43
+
44
+ module.exports = Token;
@@ -0,0 +1,91 @@
1
+ const mongoose = require('mongoose');
2
+ const validator = require('validator');
3
+ const bcrypt = require('bcryptjs');
4
+ const { toJSON, paginate } = require('./plugins');
5
+ const { roles } = require('../config/roles');
6
+
7
+ const userSchema = mongoose.Schema(
8
+ {
9
+ name: {
10
+ type: String,
11
+ required: true,
12
+ trim: true,
13
+ },
14
+ email: {
15
+ type: String,
16
+ required: true,
17
+ unique: true,
18
+ trim: true,
19
+ lowercase: true,
20
+ validate(value) {
21
+ if (!validator.isEmail(value)) {
22
+ throw new Error('Invalid email');
23
+ }
24
+ },
25
+ },
26
+ password: {
27
+ type: String,
28
+ required: true,
29
+ trim: true,
30
+ minlength: 8,
31
+ validate(value) {
32
+ if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) {
33
+ throw new Error('Password must contain at least one letter and one number');
34
+ }
35
+ },
36
+ private: true, // used by the toJSON plugin
37
+ },
38
+ role: {
39
+ type: String,
40
+ enum: roles,
41
+ default: 'user',
42
+ },
43
+ isEmailVerified: {
44
+ type: Boolean,
45
+ default: false,
46
+ },
47
+ },
48
+ {
49
+ timestamps: true,
50
+ },
51
+ );
52
+
53
+ // add plugin that converts mongoose to json
54
+ userSchema.plugin(toJSON);
55
+ userSchema.plugin(paginate);
56
+
57
+ /**
58
+ * Check if email is taken
59
+ * @param {string} email - The user's email
60
+ * @param {ObjectId} [excludeUserId] - The id of the user to be excluded
61
+ * @returns {Promise<boolean>}
62
+ */
63
+ userSchema.statics.isEmailTaken = async function (email, excludeUserId) {
64
+ const user = await this.findOne({ email, _id: { $ne: excludeUserId } });
65
+ return !!user;
66
+ };
67
+
68
+ /**
69
+ * Check if password matches the user's password
70
+ * @param {string} password
71
+ * @returns {Promise<boolean>}
72
+ */
73
+ userSchema.methods.isPasswordMatch = async function (password) {
74
+ const user = this;
75
+ return bcrypt.compare(password, user.password);
76
+ };
77
+
78
+ userSchema.pre('save', async function (next) {
79
+ const user = this;
80
+ if (user.isModified('password')) {
81
+ user.password = await bcrypt.hash(user.password, 8);
82
+ }
83
+ next();
84
+ });
85
+
86
+ /**
87
+ * @typedef User
88
+ */
89
+ const User = mongoose.model('User', userSchema);
90
+
91
+ module.exports = User;