create-simo-api 0.0.0-stage → 1.1.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 (40) hide show
  1. package/README.md +33 -2
  2. package/index.js +318 -0
  3. package/package.json +24 -4
  4. package/template/README.md +61 -0
  5. package/template/_env.example +15 -0
  6. package/template/_gitignore +4 -0
  7. package/template/app.js +29 -0
  8. package/template/config/db.js +6 -0
  9. package/template/config/env.js +27 -0
  10. package/template/config/permissions.js +15 -0
  11. package/template/controllers/authController.js +53 -0
  12. package/template/controllers/productController.js +34 -0
  13. package/template/controllers/userController.js +22 -0
  14. package/template/index.js +6 -0
  15. package/template/middlewares/authMiddleware.js +15 -0
  16. package/template/middlewares/errorMiddleware.js +33 -0
  17. package/template/middlewares/rateLimitMiddleware.js +14 -0
  18. package/template/middlewares/roleMiddleware.js +22 -0
  19. package/template/middlewares/validateMiddleware.js +18 -0
  20. package/template/migrations/1792673478177_create-users-table.sql +16 -0
  21. package/template/migrations/1792842956065_create-refresh-tokens-table.sql +17 -0
  22. package/template/migrations/1792842957555_create-products-table.sql +14 -0
  23. package/template/models/productModel.js +42 -0
  24. package/template/models/refreshTokenModel.js +25 -0
  25. package/template/models/userModel.js +39 -0
  26. package/template/package.json +33 -0
  27. package/template/routes/authRoutes.js +13 -0
  28. package/template/routes/index.js +15 -0
  29. package/template/routes/productRoutes.js +17 -0
  30. package/template/routes/userRoutes.js +15 -0
  31. package/template/seeds/seed.js +50 -0
  32. package/template/services/authService.js +94 -0
  33. package/template/services/productService.js +37 -0
  34. package/template/services/userService.js +24 -0
  35. package/template/tests/api.test.js +174 -0
  36. package/template/utils/AppError.js +10 -0
  37. package/template/utils/verifyToken.js +12 -0
  38. package/template/validations/authValidation.js +18 -0
  39. package/template/validations/productValidation.js +21 -0
  40. package/template/validations/userValidation.js +10 -0
@@ -0,0 +1,15 @@
1
+ import env from '../config/env.js';
2
+ import AppError from '../utils/AppError.js';
3
+ import verifyToken from '../utils/verifyToken.js';
4
+
5
+ const authenticate = (req, res, next) => {
6
+ const header = req.headers.authorization;
7
+ if (!header?.startsWith('Bearer ')) {
8
+ throw new AppError('No access token', 401);
9
+ }
10
+
11
+ req.user = verifyToken(header.split(' ')[1], env.ACCESS_TOKEN_SECRET, 'Invalid or expired access token');
12
+ next();
13
+ };
14
+
15
+ export default authenticate;
@@ -0,0 +1,33 @@
1
+ import AppError from '../utils/AppError.js';
2
+
3
+ export const notFound = (req, res, next) => {
4
+ next(new AppError(`Route not found: ${req.method} ${req.originalUrl}`, 404));
5
+ };
6
+
7
+ export const errorHandler = (err, req, res, next) => {
8
+ let error = err;
9
+
10
+ if (err.type === 'entity.parse.failed') {
11
+ error = new AppError('Invalid JSON body', 400);
12
+ }
13
+
14
+ if (err.code === '23505') {
15
+ const field = err.detail?.match(/Key \((.+?)\)/)?.[1] ?? 'value';
16
+ error = new AppError(`${field} already exists`, 409);
17
+ }
18
+
19
+ if (err.code === '22P02') {
20
+ error = new AppError('Invalid input value', 400);
21
+ }
22
+
23
+ const statusCode = error instanceof AppError ? error.statusCode : 500;
24
+
25
+ if (statusCode === 500) {
26
+ console.error(err);
27
+ }
28
+
29
+ res.status(statusCode).json({
30
+ message: statusCode === 500 ? 'Internal server error' : error.message,
31
+ ...(error.details && { errors: error.details }),
32
+ });
33
+ };
@@ -0,0 +1,14 @@
1
+ import rateLimit from 'express-rate-limit';
2
+ import env from '../config/env.js';
3
+ import AppError from '../utils/AppError.js';
4
+
5
+ export const apiLimiter = rateLimit({
6
+ windowMs: 15 * 60 * 1000,
7
+ limit: 100,
8
+ standardHeaders: 'draft-8',
9
+ legacyHeaders: false,
10
+ skip: () => env.NODE_ENV === 'test',
11
+ handler: (req, res, next) => {
12
+ next(new AppError('Too many requests, please try again in 15 minutes', 429));
13
+ },
14
+ });
@@ -0,0 +1,22 @@
1
+ import { PERMISSIONS } from '../config/permissions.js';
2
+ import AppError from '../utils/AppError.js';
3
+
4
+ export const authorize = (permission) => {
5
+ return (req, res, next) => {
6
+ if (!req.user?.role) {
7
+ return next(new AppError('Not authenticated', 401));
8
+ }
9
+
10
+ const allowedRoles = PERMISSIONS[permission];
11
+
12
+ if (!allowedRoles) {
13
+ return next(new AppError('Permission not defined', 403));
14
+ }
15
+
16
+ if (!allowedRoles.includes(req.user.role)) {
17
+ return next(new AppError('Access denied', 403));
18
+ }
19
+
20
+ next();
21
+ };
22
+ };
@@ -0,0 +1,18 @@
1
+ import AppError from '../utils/AppError.js';
2
+
3
+ export const validate = (schema, source = 'body') => {
4
+ return (req, res, next) => {
5
+ const result = schema.safeParse(req[source] ?? {});
6
+
7
+ if (!result.success) {
8
+ const errors = result.error.issues.map((issue) => ({
9
+ field: issue.path.join('.'),
10
+ message: issue.message,
11
+ }));
12
+ return next(new AppError('Validation failed', 400, errors));
13
+ }
14
+
15
+ req[source] = result.data;
16
+ next();
17
+ };
18
+ };
@@ -0,0 +1,16 @@
1
+ -- Up Migration
2
+ CREATE TYPE user_role AS ENUM ('user', 'admin');
3
+
4
+ CREATE TABLE users (
5
+ id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
6
+ name VARCHAR(255) NOT NULL,
7
+ email VARCHAR(255) NOT NULL UNIQUE,
8
+ password VARCHAR(255) NOT NULL,
9
+ refresh_token CHAR(64),
10
+ role user_role NOT NULL DEFAULT 'user',
11
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
12
+ );
13
+
14
+ -- Down Migration
15
+ DROP TABLE users;
16
+ DROP TYPE user_role;
@@ -0,0 +1,17 @@
1
+ -- Up Migration
2
+ CREATE TABLE refresh_tokens (
3
+ id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
4
+ user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5
+ token_hash CHAR(64) NOT NULL UNIQUE,
6
+ expires_at TIMESTAMPTZ NOT NULL,
7
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
8
+ );
9
+
10
+ CREATE INDEX refresh_tokens_user_id_idx ON refresh_tokens (user_id);
11
+
12
+ ALTER TABLE users DROP COLUMN refresh_token;
13
+
14
+ -- Down Migration
15
+ ALTER TABLE users ADD COLUMN refresh_token CHAR(64);
16
+
17
+ DROP TABLE refresh_tokens;
@@ -0,0 +1,14 @@
1
+ -- Up Migration
2
+ CREATE TABLE products (
3
+ id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
4
+ name VARCHAR(255) NOT NULL,
5
+ description TEXT,
6
+ price NUMERIC(10, 2) NOT NULL CHECK (price >= 0),
7
+ stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
8
+ image_url TEXT,
9
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
10
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
11
+ );
12
+
13
+ -- Down Migration
14
+ DROP TABLE products;
@@ -0,0 +1,42 @@
1
+ import db from '../config/db.js';
2
+
3
+ const getAll = async () => {
4
+ const { rows } = await db.query('SELECT * FROM products ORDER BY id');
5
+ return rows;
6
+ };
7
+
8
+ const findById = async (id) => {
9
+ const { rows } = await db.query('SELECT * FROM products WHERE id = $1', [id]);
10
+ return rows[0];
11
+ };
12
+
13
+ const create = async ({ name, description = null, price, stock = 0, image_url = null }) => {
14
+ const { rows } = await db.query(
15
+ 'INSERT INTO products (name, description, price, stock, image_url) VALUES ($1, $2, $3, $4, $5) RETURNING *',
16
+ [name, description, price, stock, image_url]
17
+ );
18
+ return rows[0];
19
+ };
20
+
21
+ const update = async (id, fields) => {
22
+ const columns = Object.keys(fields);
23
+ const setClause = columns.map((column, index) => `${column} = $${index + 1}`).join(', ');
24
+ const { rows } = await db.query(
25
+ `UPDATE products SET ${setClause}, updated_at = now() WHERE id = $${columns.length + 1} RETURNING *`,
26
+ [...Object.values(fields), id]
27
+ );
28
+ return rows[0];
29
+ };
30
+
31
+ const remove = async (id) => {
32
+ const { rowCount } = await db.query('DELETE FROM products WHERE id = $1', [id]);
33
+ return rowCount > 0;
34
+ };
35
+
36
+ export default {
37
+ getAll,
38
+ findById,
39
+ create,
40
+ update,
41
+ remove
42
+ };
@@ -0,0 +1,25 @@
1
+ import db from '../config/db.js';
2
+
3
+ const create = async (userId, tokenHash, expiresAt) => {
4
+ await db.query('INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', [userId, tokenHash, expiresAt]);
5
+ };
6
+
7
+ const findByHash = async (tokenHash) => {
8
+ const { rows } = await db.query('SELECT * FROM refresh_tokens WHERE token_hash = $1 AND expires_at > now()', [tokenHash]);
9
+ return rows[0];
10
+ };
11
+
12
+ const deleteByHash = async (tokenHash) => {
13
+ await db.query('DELETE FROM refresh_tokens WHERE token_hash = $1', [tokenHash]);
14
+ };
15
+
16
+ const deleteAllForUser = async (userId) => {
17
+ await db.query('DELETE FROM refresh_tokens WHERE user_id = $1', [userId]);
18
+ };
19
+
20
+ export default {
21
+ create,
22
+ findByHash,
23
+ deleteByHash,
24
+ deleteAllForUser
25
+ };
@@ -0,0 +1,39 @@
1
+ import db from '../config/db.js';
2
+ import bcrypt from 'bcrypt';
3
+
4
+ const PUBLIC_COLUMNS = 'id, name, email, role, created_at';
5
+
6
+ const getAllUsers = async () => {
7
+ const { rows } = await db.query(`SELECT ${PUBLIC_COLUMNS} FROM users`);
8
+ return rows;
9
+ };
10
+
11
+ const findOne = async (email) => {
12
+ const { rows } = await db.query('SELECT * FROM users WHERE email = $1', [email]);
13
+ return rows[0];
14
+ }
15
+
16
+ const findById = async (id) => {
17
+ const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [id]);
18
+ return rows[0];
19
+ };
20
+
21
+ const findPublicById = async (id) => {
22
+ const { rows } = await db.query(`SELECT ${PUBLIC_COLUMNS} FROM users WHERE id = $1`, [id]);
23
+ return rows[0];
24
+ };
25
+
26
+ const createUser = async (userData) => {
27
+ const { name, email, password, role = 'user' } = userData;
28
+ const hashedPassword = await bcrypt.hash(password, 10);
29
+ const { rows } = await db.query('INSERT INTO users (name, email, password, role) VALUES ($1, $2, $3, $4) RETURNING id', [name, email, hashedPassword, role]);
30
+ return rows[0].id;
31
+ };
32
+
33
+ export default {
34
+ getAllUsers,
35
+ createUser,
36
+ findOne,
37
+ findById,
38
+ findPublicById
39
+ };
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "simo-api",
3
+ "version": "1.0.0",
4
+ "main": "index.js",
5
+ "type": "module",
6
+ "scripts": {
7
+ "migrate": "node-pg-migrate -j sql",
8
+ "migrate:up": "node-pg-migrate up -j sql",
9
+ "migrate:down": "node-pg-migrate down -j sql",
10
+ "start": "node index.js",
11
+ "dev": "nodemon index.js",
12
+ "test": "NODE_ENV=test node --test 'tests/**/*.test.js'",
13
+ "seed": "node seeds/seed.js"
14
+ },
15
+ "dependencies": {
16
+ "bcrypt": "^6.0.0",
17
+ "cookie-parser": "^1.4.7",
18
+ "cors": "^2.8.6",
19
+ "dotenv": "^18.0.8",
20
+ "express": "^5.3.0",
21
+ "express-rate-limit": "^8.7.1",
22
+ "jsonwebtoken": "^9.0.3",
23
+ "morgan": "^1.12.1",
24
+ "pg": "^8.23.1",
25
+ "zod": "^4.6.7"
26
+ },
27
+ "devDependencies": {
28
+ "node-pg-migrate": "^9.0.0",
29
+ "nodemon": "^3.1.14",
30
+ "supertest": "^7.3.1"
31
+ },
32
+ "private": true
33
+ }
@@ -0,0 +1,13 @@
1
+ import express from 'express';
2
+ const router = express.Router();
3
+
4
+ import authController from '../controllers/authController.js';
5
+ import { validate } from '../middlewares/validateMiddleware.js';
6
+ import { loginSchema, registerSchema } from '../validations/authValidation.js';
7
+
8
+ router.post('/login', validate(loginSchema), authController.login);
9
+ router.post('/register', validate(registerSchema), authController.register);
10
+ router.post('/refresh', authController.refresh, authController.clearRefreshCookieOnError);
11
+ router.post('/logout', authController.logout);
12
+
13
+ export default router;
@@ -0,0 +1,15 @@
1
+ import express from 'express';
2
+ const router = express.Router();
3
+
4
+ import authRoutes from './authRoutes.js';
5
+ import userRoutes from './userRoutes.js';
6
+ import productRoutes from './productRoutes.js';
7
+
8
+
9
+
10
+ router.use('/auth', authRoutes);
11
+ router.use('/user', userRoutes);
12
+ router.use('/product', productRoutes);
13
+
14
+
15
+ export default router;
@@ -0,0 +1,17 @@
1
+ import express from 'express';
2
+ const router = express.Router();
3
+
4
+ import productController from '../controllers/productController.js';
5
+ import authenticate from '../middlewares/authMiddleware.js';
6
+ import { authorize } from '../middlewares/roleMiddleware.js';
7
+ import { validate } from '../middlewares/validateMiddleware.js';
8
+ import { createProductSchema, updateProductSchema, productIdSchema } from '../validations/productValidation.js';
9
+
10
+
11
+ router.get('/getAll', productController.getAllProducts);
12
+ router.get('/:id', validate(productIdSchema, 'params'), productController.getProductById);
13
+ router.post('/create', authenticate, authorize('PRODUCT_CREATE'), validate(createProductSchema), productController.createProduct);
14
+ router.patch('/update/:id', authenticate, authorize('PRODUCT_UPDATE'), validate(productIdSchema, 'params'), validate(updateProductSchema), productController.updateProduct);
15
+ router.delete('/delete/:id', authenticate, authorize('PRODUCT_DELETE'), validate(productIdSchema, 'params'), productController.deleteProduct);
16
+
17
+ export default router;
@@ -0,0 +1,15 @@
1
+ import express from 'express';
2
+ const router = express.Router();
3
+
4
+ import userController from '../controllers/userController.js';
5
+ import authenticate from '../middlewares/authMiddleware.js';
6
+ import { authorize } from '../middlewares/roleMiddleware.js';
7
+ import { validate } from '../middlewares/validateMiddleware.js';
8
+ import { createUserSchema } from '../validations/userValidation.js';
9
+
10
+
11
+ router.get('/me', authenticate, userController.getMe);
12
+ router.get('/getAll', authenticate, authorize('USER_GETALL'), userController.getAllUsers);
13
+ router.post('/create', authenticate, authorize('USER_CREATE'), validate(createUserSchema), userController.createUser);
14
+
15
+ export default router;
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ import db from '../config/db.js';
3
+ import userModel from '../models/userModel.js';
4
+ import { ROLES } from '../config/permissions.js';
5
+ import { name, email, password } from '../validations/authValidation.js';
6
+
7
+ const adminSchema = z.object({
8
+ ADMIN_NAME: name,
9
+ ADMIN_EMAIL: email,
10
+ ADMIN_PASSWORD: password,
11
+ });
12
+
13
+ const seedAdmin = async () => {
14
+ const result = adminSchema.safeParse(process.env);
15
+
16
+ if (!result.success) {
17
+ console.error('Invalid admin variables in .env:');
18
+ for (const issue of result.error.issues) {
19
+ console.error(` - ${issue.path.join('.')}: ${issue.message}`);
20
+ }
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+
25
+ const { ADMIN_NAME, ADMIN_EMAIL, ADMIN_PASSWORD } = result.data;
26
+
27
+ const existing = await userModel.findOne(ADMIN_EMAIL);
28
+ if (existing) {
29
+ console.log(`Admin already exists: ${ADMIN_EMAIL}`);
30
+ return;
31
+ }
32
+
33
+ const id = await userModel.createUser({
34
+ name: ADMIN_NAME,
35
+ email: ADMIN_EMAIL,
36
+ password: ADMIN_PASSWORD,
37
+ role: ROLES.ADMIN,
38
+ });
39
+
40
+ console.log(`Admin created: ${ADMIN_EMAIL} (id ${id})`);
41
+ };
42
+
43
+ try {
44
+ await seedAdmin();
45
+ } catch (error) {
46
+ console.error('Seed failed:', error);
47
+ process.exitCode = 1;
48
+ } finally {
49
+ await db.end();
50
+ }
@@ -0,0 +1,94 @@
1
+ import bcrypt from 'bcrypt';
2
+ import jwt from 'jsonwebtoken';
3
+ import crypto from 'crypto';
4
+ import env from '../config/env.js';
5
+ import { ROLES } from '../config/permissions.js';
6
+ import userModel from '../models/userModel.js';
7
+ import refreshTokenModel from '../models/refreshTokenModel.js';
8
+ import AppError from '../utils/AppError.js';
9
+ import verifyToken from '../utils/verifyToken.js';
10
+
11
+ export const REFRESH_TOKEN_MAX_AGE = env.REFRESH_TOKEN_EXPIRES_DAYS * 24 * 60 * 60 * 1000;
12
+
13
+ const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
14
+
15
+ const generateAccessToken = (user) => {
16
+ const payload = { id: user.id, email: user.email, role: user.role };
17
+ return jwt.sign(payload, env.ACCESS_TOKEN_SECRET, { expiresIn: env.ACCESS_TOKEN_EXPIRES_IN });
18
+ };
19
+
20
+ const generateRefreshToken = (user) => {
21
+ const payload = { id: user.id, jti: crypto.randomUUID() };
22
+ return jwt.sign(payload, env.REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_MAX_AGE / 1000 });
23
+ };
24
+
25
+ const createSession = async (user) => {
26
+ const accessToken = generateAccessToken(user);
27
+ const refreshToken = generateRefreshToken(user);
28
+ const expiresAt = new Date(Date.now() + REFRESH_TOKEN_MAX_AGE);
29
+
30
+ await refreshTokenModel.create(user.id, hashToken(refreshToken), expiresAt);
31
+
32
+ return { accessToken, refreshToken };
33
+ };
34
+
35
+ const login = async (email, password) => {
36
+ const user = await userModel.findOne(email);
37
+
38
+ if (!user || !(await bcrypt.compare(password, user.password))) {
39
+ throw new AppError('Invalid email or password', 401);
40
+ }
41
+
42
+ const tokens = await createSession(user);
43
+
44
+ return {
45
+ ...tokens,
46
+ user: { id: user.id, name: user.name, email: user.email, role: user.role },
47
+ };
48
+ };
49
+
50
+ const register = async ({ name, email, password }) => {
51
+ const existingUser = await userModel.findOne(email);
52
+
53
+ if (existingUser) {
54
+ throw new AppError('Email already exists', 409);
55
+ }
56
+
57
+ return userModel.createUser({ name, email, password, role: ROLES.USER });
58
+ };
59
+
60
+ const refresh = async (token) => {
61
+ if (!token) {
62
+ throw new AppError('No refresh token', 401);
63
+ }
64
+
65
+ const payload = verifyToken(token, env.REFRESH_TOKEN_SECRET, 'Invalid or expired refresh token');
66
+
67
+ const storedToken = await refreshTokenModel.findByHash(hashToken(token));
68
+ if (!storedToken || storedToken.user_id !== payload.id) {
69
+ await refreshTokenModel.deleteAllForUser(payload.id);
70
+ throw new AppError('Refresh token revoked', 401);
71
+ }
72
+
73
+ const user = await userModel.findById(payload.id);
74
+ if (!user) {
75
+ throw new AppError('User no longer exists', 401);
76
+ }
77
+
78
+ await refreshTokenModel.deleteByHash(hashToken(token));
79
+
80
+ return createSession(user);
81
+ };
82
+
83
+ const logout = async (token) => {
84
+ if (token) {
85
+ await refreshTokenModel.deleteByHash(hashToken(token));
86
+ }
87
+ };
88
+
89
+ export default {
90
+ login,
91
+ register,
92
+ refresh,
93
+ logout
94
+ };
@@ -0,0 +1,37 @@
1
+ import productModel from '../models/productModel.js';
2
+ import AppError from '../utils/AppError.js';
3
+
4
+ const getAll = () => productModel.getAll();
5
+
6
+ const getById = async (id) => {
7
+ const product = await productModel.findById(id);
8
+ if (!product) {
9
+ throw new AppError('Product not found', 404);
10
+ }
11
+ return product;
12
+ };
13
+
14
+ const create = (data) => productModel.create(data);
15
+
16
+ const update = async (id, data) => {
17
+ const product = await productModel.update(id, data);
18
+ if (!product) {
19
+ throw new AppError('Product not found', 404);
20
+ }
21
+ return product;
22
+ };
23
+
24
+ const remove = async (id) => {
25
+ const deleted = await productModel.remove(id);
26
+ if (!deleted) {
27
+ throw new AppError('Product not found', 404);
28
+ }
29
+ };
30
+
31
+ export default {
32
+ getAll,
33
+ getById,
34
+ create,
35
+ update,
36
+ remove
37
+ };
@@ -0,0 +1,24 @@
1
+ import { ROLES } from '../config/permissions.js';
2
+ import userModel from '../models/userModel.js';
3
+ import AppError from '../utils/AppError.js';
4
+
5
+ const getAll = () => userModel.getAllUsers();
6
+
7
+ const getById = async (id) => {
8
+ const user = await userModel.findPublicById(id);
9
+ if (!user) {
10
+ throw new AppError('User not found', 404);
11
+ }
12
+ return user;
13
+ };
14
+
15
+ const create = ({ name, email, password, role }, currentUser) => {
16
+ const finalRole = currentUser.role === ROLES.ADMIN ? role : ROLES.USER;
17
+ return userModel.createUser({ name, email, password, role: finalRole });
18
+ };
19
+
20
+ export default {
21
+ getAll,
22
+ getById,
23
+ create
24
+ };