create-theta-code 1.0.1
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.
- package/bin/create.js +9 -0
- package/package.json +34 -0
- package/src/cli.js +21 -0
- package/src/generators/scaffoldProject.js +46 -0
- package/src/prompts/getProjectName.js +30 -0
- package/src/prompts/getTemplateChoice.js +39 -0
- package/src/utils/logger.js +29 -0
- package/templates/mongo-js/.env +12 -0
- package/templates/mongo-js/.env.example +13 -0
- package/templates/mongo-js/.eslintrc.json +24 -0
- package/templates/mongo-js/.prettierrc +9 -0
- package/templates/mongo-js/README.md +429 -0
- package/templates/mongo-js/_env.example +13 -0
- package/templates/mongo-js/_gitignore +22 -0
- package/templates/mongo-js/package-lock.json +4671 -0
- package/templates/mongo-js/package.json +48 -0
- package/templates/mongo-js/server.js +67 -0
- package/templates/mongo-js/src/config/app.config.js +72 -0
- package/templates/mongo-js/src/config/db.config.js +32 -0
- package/templates/mongo-js/src/config/env.config.js +49 -0
- package/templates/mongo-js/src/config/rateLimiter.config.js +32 -0
- package/templates/mongo-js/src/middlewares/auth.middleware.js +20 -0
- package/templates/mongo-js/src/middlewares/error.middleware.js +61 -0
- package/templates/mongo-js/src/middlewares/notFound.middleware.js +11 -0
- package/templates/mongo-js/src/middlewares/requestId.middleware.js +10 -0
- package/templates/mongo-js/src/middlewares/requireRole.middleware.js +13 -0
- package/templates/mongo-js/src/middlewares/validate.middleware.js +21 -0
- package/templates/mongo-js/src/modules/user/user.controller.js +88 -0
- package/templates/mongo-js/src/modules/user/user.model.js +45 -0
- package/templates/mongo-js/src/modules/user/user.repository.js +47 -0
- package/templates/mongo-js/src/modules/user/user.routes.js +32 -0
- package/templates/mongo-js/src/modules/user/user.service.js +87 -0
- package/templates/mongo-js/src/modules/user/user.validator.js +28 -0
- package/templates/mongo-js/src/utils/AppError.js +15 -0
- package/templates/mongo-js/src/utils/apiResponse.js +23 -0
- package/templates/mongo-js/src/utils/asyncHandler.js +7 -0
- package/templates/mongo-js/src/utils/constants.js +16 -0
- package/templates/mongo-js/src/utils/jwt.utils.js +40 -0
- package/templates/mongo-js/tests/integration/user.routes.test.js +111 -0
- package/templates/mongo-js/tests/unit/user.service.test.js +96 -0
- package/templates/pg-js/.eslintrc.json +24 -0
- package/templates/pg-js/.prettierrc +9 -0
- package/templates/pg-js/_env.example +7 -0
- package/templates/pg-js/_gitignore +20 -0
- package/templates/pg-js/package.json +50 -0
- package/templates/pg-js/prisma/schema.prisma +23 -0
- package/templates/pg-js/server.js +63 -0
- package/templates/pg-js/src/config/app.config.js +48 -0
- package/templates/pg-js/src/config/db.config.js +30 -0
- package/templates/pg-js/src/config/env.config.js +36 -0
- package/templates/pg-js/src/config/rateLimiter.config.js +22 -0
- package/templates/pg-js/src/middlewares/auth.middleware.js +32 -0
- package/templates/pg-js/src/middlewares/error.middleware.js +50 -0
- package/templates/pg-js/src/middlewares/notFound.middleware.js +11 -0
- package/templates/pg-js/src/middlewares/validate.middleware.js +21 -0
- package/templates/pg-js/src/modules/user/user.controller.js +57 -0
- package/templates/pg-js/src/modules/user/user.model.js +20 -0
- package/templates/pg-js/src/modules/user/user.repository.js +105 -0
- package/templates/pg-js/src/modules/user/user.routes.js +27 -0
- package/templates/pg-js/src/modules/user/user.service.js +81 -0
- package/templates/pg-js/src/modules/user/user.validator.js +22 -0
- package/templates/pg-js/src/utils/AppError.js +14 -0
- package/templates/pg-js/src/utils/apiResponse.js +23 -0
- package/templates/pg-js/src/utils/asyncHandler.js +7 -0
- package/templates/pg-js/src/utils/constants.js +24 -0
- package/templates/pg-js/src/utils/jwt.utils.js +39 -0
- package/templates/pg-js/tests/integration/user.routes.test.js +95 -0
- package/templates/pg-js/tests/unit/user.service.test.js +96 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-awesome-app",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Production-grade Node.js API built with create-theta-code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js",
|
|
9
|
+
"dev": "nodemon server.js",
|
|
10
|
+
"test": "vitest",
|
|
11
|
+
"test:coverage": "vitest --coverage",
|
|
12
|
+
"lint": "eslint src/",
|
|
13
|
+
"lint:fix": "eslint src/ --fix",
|
|
14
|
+
"format": "prettier --write \"src/**/*.js\"",
|
|
15
|
+
"migrate": "echo 'Run migrations here'"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"nodejs",
|
|
19
|
+
"express",
|
|
20
|
+
"mongodb",
|
|
21
|
+
"api"
|
|
22
|
+
],
|
|
23
|
+
"author": "Arpit Shukla <arpit@techvault.in>",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"bcryptjs": "^2.4.3",
|
|
27
|
+
"cookie-parser": "^1.4.7",
|
|
28
|
+
"cors": "^2.8.5",
|
|
29
|
+
"dotenv": "^16.3.1",
|
|
30
|
+
"express": "^4.18.2",
|
|
31
|
+
"express-rate-limit": "^7.1.5",
|
|
32
|
+
"helmet": "^7.1.0",
|
|
33
|
+
"jsonwebtoken": "^9.0.2",
|
|
34
|
+
"mongoose": "^8.0.0",
|
|
35
|
+
"zod": "^3.22.4"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"eslint": "^8.54.0",
|
|
39
|
+
"morgan": "^1.10.0",
|
|
40
|
+
"nodemon": "^3.0.1",
|
|
41
|
+
"prettier": "^3.1.0",
|
|
42
|
+
"supertest": "^6.3.3",
|
|
43
|
+
"vitest": "^0.34.6"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// server.js — HTTP server startup and graceful shutdown. Zero other logic.
|
|
2
|
+
|
|
3
|
+
import { createApp } from './src/config/app.config.js';
|
|
4
|
+
import { connectDB, disconnectDB } from './src/config/db.config.js';
|
|
5
|
+
import envConfig from './src/config/env.config.js';
|
|
6
|
+
|
|
7
|
+
let server;
|
|
8
|
+
|
|
9
|
+
async function startServer() {
|
|
10
|
+
try {
|
|
11
|
+
// Connect to database
|
|
12
|
+
await connectDB();
|
|
13
|
+
|
|
14
|
+
// Create Express app
|
|
15
|
+
const app = createApp();
|
|
16
|
+
|
|
17
|
+
// Start HTTP server
|
|
18
|
+
server = app.listen(envConfig.PORT, () => {
|
|
19
|
+
console.log(`✅ Server running on port ${envConfig.PORT}`);
|
|
20
|
+
});
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error('❌ Failed to start server:', err.message);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const shutdown = async (signal) => {
|
|
28
|
+
console.log(`\n${signal} received. Shutting down gracefully...`);
|
|
29
|
+
|
|
30
|
+
// Force exit if graceful shutdown takes too long
|
|
31
|
+
const forceExit = setTimeout(() => {
|
|
32
|
+
console.error('Forced shutdown: graceful shutdown timed out after 10s');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}, 10000);
|
|
35
|
+
forceExit.unref(); // Don't keep process alive just for this timer
|
|
36
|
+
|
|
37
|
+
if (server) {
|
|
38
|
+
server.close(async () => {
|
|
39
|
+
console.log('HTTP server closed.');
|
|
40
|
+
await disconnectDB();
|
|
41
|
+
clearTimeout(forceExit);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
await disconnectDB();
|
|
46
|
+
clearTimeout(forceExit);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
52
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
53
|
+
|
|
54
|
+
// Unhandled promise rejections
|
|
55
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
56
|
+
console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Uncaught exceptions
|
|
61
|
+
process.on('uncaughtException', (err) => {
|
|
62
|
+
console.error('❌ Uncaught Exception:', err);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Start server
|
|
67
|
+
startServer();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/config/app.config.js — Express app factory. Middleware registration. Middleware order sacred.
|
|
2
|
+
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import helmet from 'helmet';
|
|
5
|
+
import cors from 'cors';
|
|
6
|
+
import morgan from 'morgan';
|
|
7
|
+
import cookieParser from 'cookie-parser';
|
|
8
|
+
import envConfig from './env.config.js';
|
|
9
|
+
import { globalRateLimiter } from './rateLimiter.config.js';
|
|
10
|
+
import { notFoundMiddleware } from '../middlewares/notFound.middleware.js';
|
|
11
|
+
import { errorMiddleware } from '../middlewares/error.middleware.js';
|
|
12
|
+
import { requestIdMiddleware } from '../middlewares/requestId.middleware.js';
|
|
13
|
+
import { REQUEST_BODY_LIMIT } from '../utils/constants.js';
|
|
14
|
+
import userRoutes from '../modules/user/user.routes.js';
|
|
15
|
+
|
|
16
|
+
function createApp() {
|
|
17
|
+
const app = express();
|
|
18
|
+
|
|
19
|
+
// Request correlation ID middleware must be first for tracing
|
|
20
|
+
app.use(requestIdMiddleware);
|
|
21
|
+
|
|
22
|
+
// Security middleware
|
|
23
|
+
app.use(helmet());
|
|
24
|
+
app.use(cookieParser());
|
|
25
|
+
app.use(cors({
|
|
26
|
+
origin: (origin, callback) => {
|
|
27
|
+
if (!origin) return callback(null, true); // allow server-to-server
|
|
28
|
+
const allowed = envConfig.CORS_ORIGIN.split(',').map(o => o.trim());
|
|
29
|
+
if (allowed.includes('*') || allowed.includes(origin)) {
|
|
30
|
+
return callback(null, true);
|
|
31
|
+
}
|
|
32
|
+
return callback(new Error(`CORS: origin ${origin} not allowed`));
|
|
33
|
+
},
|
|
34
|
+
credentials: true,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// Body parser with 10kb limit
|
|
38
|
+
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
|
|
39
|
+
app.use(express.urlencoded({ limit: REQUEST_BODY_LIMIT }));
|
|
40
|
+
|
|
41
|
+
// Logging in development only
|
|
42
|
+
if (envConfig.NODE_ENV === 'development') {
|
|
43
|
+
app.use(morgan('dev'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Health check (before rate limiter)
|
|
47
|
+
app.get('/health', (_req, res) => {
|
|
48
|
+
res.status(200).json({
|
|
49
|
+
status: 'UP',
|
|
50
|
+
environment: envConfig.NODE_ENV,
|
|
51
|
+
uptime: process.uptime(),
|
|
52
|
+
timestamp: new Date().toISOString(),
|
|
53
|
+
memory: process.memoryUsage(),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Rate limiter on all /api routes
|
|
58
|
+
app.use('/api/', globalRateLimiter);
|
|
59
|
+
|
|
60
|
+
// API routes
|
|
61
|
+
app.use('/api/v1/users', userRoutes);
|
|
62
|
+
|
|
63
|
+
// 404 handler
|
|
64
|
+
app.use(notFoundMiddleware);
|
|
65
|
+
|
|
66
|
+
// Global error handler (must be last)
|
|
67
|
+
app.use(errorMiddleware);
|
|
68
|
+
|
|
69
|
+
return app;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { createApp };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/config/db.config.js — MongoDB connection. Mongoose initialization and connection pooling.
|
|
2
|
+
|
|
3
|
+
import mongoose from 'mongoose';
|
|
4
|
+
import envConfig from './env.config.js';
|
|
5
|
+
|
|
6
|
+
async function connectDB() {
|
|
7
|
+
try {
|
|
8
|
+
const mongooseInstance = await mongoose.connect(envConfig.MONGODB_URI, {
|
|
9
|
+
maxPoolSize: 10,
|
|
10
|
+
minPoolSize: 5,
|
|
11
|
+
serverSelectionTimeoutMS: 5000,
|
|
12
|
+
socketTimeoutMS: 45000,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
console.log('✅ MongoDB connected successfully');
|
|
16
|
+
return mongooseInstance;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
console.error('❌ MongoDB connection failed:', err.message);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function disconnectDB() {
|
|
24
|
+
try {
|
|
25
|
+
await mongoose.disconnect();
|
|
26
|
+
console.log('✅ MongoDB disconnected');
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error('❌ MongoDB disconnection failed:', err.message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { connectDB, disconnectDB };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/config/env.config.js — Validates ALL required env vars at startup. App refuses to boot if missing.
|
|
2
|
+
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
const envSchema = z.object({
|
|
7
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
8
|
+
PORT: z.string().transform(Number).default('3000'),
|
|
9
|
+
MONGODB_URI: z.string().url('Invalid MongoDB URI'),
|
|
10
|
+
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
|
|
11
|
+
JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
|
|
12
|
+
JWT_EXPIRES_IN: z.string().default('7d'),
|
|
13
|
+
JWT_REFRESH_EXPIRES_IN: z.string().default('30d'),
|
|
14
|
+
BCRYPT_SALT_ROUNDS: z.string().transform(Number).default('12'),
|
|
15
|
+
RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default('900000'),
|
|
16
|
+
RATE_LIMIT_MAX: z.string().transform(Number).default('100'),
|
|
17
|
+
AUTH_RATE_LIMIT_MAX: z.string().transform(Number).default('10'),
|
|
18
|
+
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
|
19
|
+
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let envConfig;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const rawEnv = {
|
|
26
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
27
|
+
PORT: process.env.PORT,
|
|
28
|
+
MONGODB_URI: process.env.MONGODB_URI,
|
|
29
|
+
JWT_SECRET: process.env.JWT_SECRET,
|
|
30
|
+
JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET,
|
|
31
|
+
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN,
|
|
32
|
+
JWT_REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN,
|
|
33
|
+
BCRYPT_SALT_ROUNDS: process.env.BCRYPT_SALT_ROUNDS,
|
|
34
|
+
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
|
|
35
|
+
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
|
|
36
|
+
AUTH_RATE_LIMIT_MAX: process.env.AUTH_RATE_LIMIT_MAX,
|
|
37
|
+
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
|
38
|
+
LOG_LEVEL: process.env.LOG_LEVEL,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
envConfig = envSchema.parse(rawEnv);
|
|
42
|
+
Object.freeze(envConfig);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('❌ Environment validation failed:');
|
|
45
|
+
console.error(err.errors || err.message);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default envConfig;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/config/rateLimiter.config.js — Global and auth-specific rate limiters. Non-negotiable.
|
|
2
|
+
|
|
3
|
+
import rateLimit from 'express-rate-limit';
|
|
4
|
+
import envConfig from './env.config.js';
|
|
5
|
+
|
|
6
|
+
const globalRateLimiter = rateLimit({
|
|
7
|
+
windowMs: envConfig.RATE_LIMIT_WINDOW_MS,
|
|
8
|
+
max: envConfig.RATE_LIMIT_MAX,
|
|
9
|
+
message: {
|
|
10
|
+
success: false,
|
|
11
|
+
message: 'Too many requests, please try again later.',
|
|
12
|
+
data: null,
|
|
13
|
+
timestamp: new Date().toISOString(),
|
|
14
|
+
},
|
|
15
|
+
standardHeaders: true,
|
|
16
|
+
legacyHeaders: false,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const authRateLimiter = rateLimit({
|
|
20
|
+
windowMs: envConfig.RATE_LIMIT_WINDOW_MS,
|
|
21
|
+
max: envConfig.AUTH_RATE_LIMIT_MAX,
|
|
22
|
+
message: {
|
|
23
|
+
success: false,
|
|
24
|
+
message: 'Too many login attempts, please try again later.',
|
|
25
|
+
data: null,
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
},
|
|
28
|
+
standardHeaders: true,
|
|
29
|
+
legacyHeaders: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export { globalRateLimiter, authRateLimiter };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/middlewares/auth.middleware.js — Verifies JWT from Authorization header. Attaches req.user.
|
|
2
|
+
|
|
3
|
+
import { HTTP_STATUS } from '../utils/constants.js';
|
|
4
|
+
import { AppError } from '../utils/AppError.js';
|
|
5
|
+
import { verifyToken } from '../utils/jwt.utils.js';
|
|
6
|
+
import { asyncHandler } from '../utils/asyncHandler.js';
|
|
7
|
+
|
|
8
|
+
const authMiddleware = asyncHandler((req, res, next) => {
|
|
9
|
+
const token = req.cookies?.accessToken || req.headers.authorization?.replace('Bearer ', '');
|
|
10
|
+
|
|
11
|
+
if (!token) {
|
|
12
|
+
throw new AppError('Authentication required', HTTP_STATUS.UNAUTHORIZED);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const decoded = verifyToken(token);
|
|
16
|
+
req.user = decoded;
|
|
17
|
+
next();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export { authMiddleware };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/middlewares/error.middleware.js — Global error handler. Operational vs programmer errors. No stack in prod.
|
|
2
|
+
|
|
3
|
+
import { HTTP_STATUS } from '../utils/constants.js';
|
|
4
|
+
import { sendError } from '../utils/apiResponse.js';
|
|
5
|
+
|
|
6
|
+
const errorMiddleware = (err, req, res, next) => {
|
|
7
|
+
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
8
|
+
|
|
9
|
+
if (req?.requestId) {
|
|
10
|
+
res.setHeader('X-Request-Id', req.requestId);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Mongoose CastError
|
|
14
|
+
if (err.name === 'CastError') {
|
|
15
|
+
return sendError(
|
|
16
|
+
res,
|
|
17
|
+
HTTP_STATUS.BAD_REQUEST,
|
|
18
|
+
`Invalid ${err.path}: ${err.value}`,
|
|
19
|
+
isDevelopment ? err : null
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Mongoose ValidationError
|
|
24
|
+
if (err.name === 'ValidationError') {
|
|
25
|
+
const messages = Object.values(err.errors).map((e) => e.message);
|
|
26
|
+
return sendError(res, HTTP_STATUS.BAD_REQUEST, messages.join(', '), isDevelopment ? err : null);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Mongoose duplicate key (11000)
|
|
30
|
+
if (err.code === 11000) {
|
|
31
|
+
const field = Object.keys(err.keyValue)[0];
|
|
32
|
+
const message = `${field} already in use`;
|
|
33
|
+
return sendError(res, HTTP_STATUS.CONFLICT, message, isDevelopment ? err : null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// JWT TokenExpiredError
|
|
37
|
+
if (err.name === 'TokenExpiredError') {
|
|
38
|
+
return sendError(res, HTTP_STATUS.UNAUTHORIZED, 'Token expired', isDevelopment ? err : null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// JWT JsonWebTokenError
|
|
42
|
+
if (err.name === 'JsonWebTokenError') {
|
|
43
|
+
return sendError(res, HTTP_STATUS.UNAUTHORIZED, 'Invalid token', isDevelopment ? err : null);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// AppError (operational)
|
|
47
|
+
if (err.isOperational) {
|
|
48
|
+
return sendError(res, err.statusCode, err.message, isDevelopment ? { stack: err.stack } : null);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Programmer error (unhandled)
|
|
52
|
+
console.error('UNHANDLED ERROR:', err);
|
|
53
|
+
return sendError(
|
|
54
|
+
res,
|
|
55
|
+
HTTP_STATUS.INTERNAL_SERVER_ERROR,
|
|
56
|
+
'Internal server error',
|
|
57
|
+
isDevelopment ? { error: err.message, stack: err.stack } : null
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { errorMiddleware };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/middlewares/notFound.middleware.js — Catches 404s. Creates AppError, passes to error handler.
|
|
2
|
+
|
|
3
|
+
import { HTTP_STATUS } from '../utils/constants.js';
|
|
4
|
+
import { AppError } from '../utils/AppError.js';
|
|
5
|
+
|
|
6
|
+
const notFoundMiddleware = (req, res, next) => {
|
|
7
|
+
const error = new AppError(`Route ${req.originalUrl} not found`, HTTP_STATUS.NOT_FOUND);
|
|
8
|
+
next(error);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export { notFoundMiddleware };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// src/middlewares/requestId.middleware.js — Adds request correlation ID for tracing.
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const requestIdMiddleware = (req, res, next) => {
|
|
6
|
+
const requestId = crypto.randomUUID();
|
|
7
|
+
req.requestId = requestId;
|
|
8
|
+
res.setHeader('X-Request-Id', requestId);
|
|
9
|
+
next();
|
|
10
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/middlewares/requireRole.middleware.js — Role-based access control guard.
|
|
2
|
+
// Responsibility: Verify req.user.role matches allowed roles.
|
|
3
|
+
// Must be used AFTER authMiddleware — never standalone.
|
|
4
|
+
|
|
5
|
+
import { AppError } from '../utils/AppError.js';
|
|
6
|
+
import { HTTP_STATUS } from '../utils/constants.js';
|
|
7
|
+
|
|
8
|
+
export const requireRole = (...roles) => (req, res, next) => {
|
|
9
|
+
if (!req.user || !roles.includes(req.user.role)) {
|
|
10
|
+
return next(new AppError('Access denied. Insufficient permissions.', HTTP_STATUS.FORBIDDEN));
|
|
11
|
+
}
|
|
12
|
+
next();
|
|
13
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/middlewares/validate.middleware.js — Validates req.body with Zod schema. 400 on fail.
|
|
2
|
+
|
|
3
|
+
import { HTTP_STATUS } from '../utils/constants.js';
|
|
4
|
+
import { sendError } from '../utils/apiResponse.js';
|
|
5
|
+
|
|
6
|
+
const validate = (schema) => (req, res, next) => {
|
|
7
|
+
const result = schema.safeParse(req.body);
|
|
8
|
+
|
|
9
|
+
if (!result.success) {
|
|
10
|
+
const errors = result.error.errors.map((err) => ({
|
|
11
|
+
field: err.path.join('.'),
|
|
12
|
+
message: err.message,
|
|
13
|
+
}));
|
|
14
|
+
return sendError(res, HTTP_STATUS.BAD_REQUEST, 'Validation failed', errors);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
req.body = result.data;
|
|
18
|
+
next();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export { validate };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/modules/user/user.controller.js — HTTP only. Read req, call service, send res. No business logic.
|
|
2
|
+
|
|
3
|
+
import { HTTP_STATUS } from '../../utils/constants.js';
|
|
4
|
+
import { sendSuccess, sendError } from '../../utils/apiResponse.js';
|
|
5
|
+
import { asyncHandler } from '../../utils/asyncHandler.js';
|
|
6
|
+
import envConfig from '../../config/env.config.js';
|
|
7
|
+
import * as userService from './user.service.js';
|
|
8
|
+
|
|
9
|
+
const register = asyncHandler(async (req, res) => {
|
|
10
|
+
const { email, firstName, lastName, password } = req.body;
|
|
11
|
+
|
|
12
|
+
const user = await userService.register(email, firstName, lastName, password);
|
|
13
|
+
|
|
14
|
+
return sendSuccess(res, HTTP_STATUS.CREATED, 'User registered successfully', {
|
|
15
|
+
user,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const login = asyncHandler(async (req, res) => {
|
|
20
|
+
const { email, password } = req.body;
|
|
21
|
+
|
|
22
|
+
const result = await userService.login(email, password);
|
|
23
|
+
|
|
24
|
+
res.cookie('accessToken', result.token, {
|
|
25
|
+
httpOnly: true,
|
|
26
|
+
secure: envConfig.NODE_ENV === 'production',
|
|
27
|
+
sameSite: 'strict',
|
|
28
|
+
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
res.cookie('refreshToken', result.refreshToken, {
|
|
32
|
+
httpOnly: true,
|
|
33
|
+
secure: envConfig.NODE_ENV === 'production',
|
|
34
|
+
sameSite: 'strict',
|
|
35
|
+
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'Login successful', {
|
|
39
|
+
user: result.user,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const getAllUsers = asyncHandler(async (req, res) => {
|
|
44
|
+
const rawLimit = Number(req.query.limit);
|
|
45
|
+
const rawSkip = Number(req.query.skip);
|
|
46
|
+
const limit = Math.min(isNaN(rawLimit) || rawLimit < 1 ? 10 : rawLimit, 100);
|
|
47
|
+
const skip = isNaN(rawSkip) || rawSkip < 0 ? 0 : rawSkip;
|
|
48
|
+
|
|
49
|
+
const users = await userService.getAllUsers(limit, skip);
|
|
50
|
+
|
|
51
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'Users retrieved', { users });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const logout = asyncHandler(async (req, res) => {
|
|
55
|
+
res.clearCookie('accessToken');
|
|
56
|
+
res.clearCookie('refreshToken');
|
|
57
|
+
|
|
58
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'Logged out successfully', null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const getProfile = asyncHandler(async (req, res) => {
|
|
62
|
+
const user = await userService.getUserById(req.user.id);
|
|
63
|
+
|
|
64
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'User profile retrieved', {
|
|
65
|
+
user,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const updateProfile = asyncHandler(async (req, res) => {
|
|
70
|
+
const { firstName, lastName } = req.body;
|
|
71
|
+
|
|
72
|
+
const user = await userService.updateUser(req.user.id, {
|
|
73
|
+
firstName,
|
|
74
|
+
lastName,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'User profile updated', {
|
|
78
|
+
user,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const deleteAccount = asyncHandler(async (req, res) => {
|
|
83
|
+
await userService.deleteUser(req.user.id);
|
|
84
|
+
|
|
85
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'Account deleted successfully', null);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export { register, login, logout, getAllUsers, getProfile, updateProfile, deleteAccount };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/modules/user/user.model.js — Mongoose schema. DB layer only. No business logic.
|
|
2
|
+
|
|
3
|
+
import mongoose from 'mongoose';
|
|
4
|
+
|
|
5
|
+
const userSchema = new mongoose.Schema(
|
|
6
|
+
{
|
|
7
|
+
email: {
|
|
8
|
+
type: String,
|
|
9
|
+
required: [true, 'Email is required'],
|
|
10
|
+
unique: true,
|
|
11
|
+
lowercase: true,
|
|
12
|
+
trim: true,
|
|
13
|
+
match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Invalid email format'],
|
|
14
|
+
},
|
|
15
|
+
firstName: {
|
|
16
|
+
type: String,
|
|
17
|
+
required: [true, 'First name is required'],
|
|
18
|
+
trim: true,
|
|
19
|
+
},
|
|
20
|
+
lastName: {
|
|
21
|
+
type: String,
|
|
22
|
+
required: [true, 'Last name is required'],
|
|
23
|
+
trim: true,
|
|
24
|
+
},
|
|
25
|
+
password: {
|
|
26
|
+
type: String,
|
|
27
|
+
required: [true, 'Password is required'],
|
|
28
|
+
select: false,
|
|
29
|
+
},
|
|
30
|
+
role: {
|
|
31
|
+
type: String,
|
|
32
|
+
enum: ['user', 'admin'],
|
|
33
|
+
default: 'user',
|
|
34
|
+
},
|
|
35
|
+
isActive: {
|
|
36
|
+
type: Boolean,
|
|
37
|
+
default: true,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{ timestamps: true, versionKey: false, collection: 'users' }
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const User = mongoose.model('User', userSchema);
|
|
44
|
+
|
|
45
|
+
export { User };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/modules/user/user.repository.js — DB queries only. Never business logic, never HTTP.
|
|
2
|
+
|
|
3
|
+
import { User } from './user.model.js';
|
|
4
|
+
|
|
5
|
+
async function findById(id) {
|
|
6
|
+
return User.findById(id).select('-password');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function findByEmail(email) {
|
|
10
|
+
return User.findOne({ email }).select('+password');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function findByEmailPublic(email) {
|
|
14
|
+
return User.findOne({ email }).select('-password');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function create(userData) {
|
|
18
|
+
const user = new User(userData);
|
|
19
|
+
await user.save();
|
|
20
|
+
return user.toObject({ transform: (doc, ret) => { delete ret.password; return ret; } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function updateById(id, updateData) {
|
|
24
|
+
return User.findByIdAndUpdate(id, updateData, { new: true, runValidators: true }).select('-password');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function deleteById(id) {
|
|
28
|
+
return User.findByIdAndDelete(id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function findAll(limit = 10, skip = 0) {
|
|
32
|
+
return User.find()
|
|
33
|
+
.select('-password')
|
|
34
|
+
.limit(limit)
|
|
35
|
+
.skip(skip)
|
|
36
|
+
.sort({ createdAt: -1 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
findById,
|
|
41
|
+
findByEmail,
|
|
42
|
+
findByEmailPublic,
|
|
43
|
+
create,
|
|
44
|
+
updateById,
|
|
45
|
+
deleteById,
|
|
46
|
+
findAll,
|
|
47
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/modules/user/user.routes.js — Route definitions. Middleware chain per route. Validation before controller.
|
|
2
|
+
|
|
3
|
+
import { Router } from 'express';
|
|
4
|
+
import { validate } from '../../middlewares/validate.middleware.js';
|
|
5
|
+
import { authMiddleware } from '../../middlewares/auth.middleware.js';
|
|
6
|
+
import { requireRole } from '../../middlewares/requireRole.middleware.js';
|
|
7
|
+
import { authRateLimiter } from '../../config/rateLimiter.config.js';
|
|
8
|
+
import { registerSchema, loginSchema, updateUserSchema } from './user.validator.js';
|
|
9
|
+
import {
|
|
10
|
+
register,
|
|
11
|
+
login,
|
|
12
|
+
logout,
|
|
13
|
+
getAllUsers,
|
|
14
|
+
getProfile,
|
|
15
|
+
updateProfile,
|
|
16
|
+
deleteAccount,
|
|
17
|
+
} from './user.controller.js';
|
|
18
|
+
|
|
19
|
+
const router = Router();
|
|
20
|
+
|
|
21
|
+
// Public routes
|
|
22
|
+
router.post('/register', authRateLimiter, validate(registerSchema), register);
|
|
23
|
+
router.post('/login', authRateLimiter, validate(loginSchema), login);
|
|
24
|
+
|
|
25
|
+
// Protected routes
|
|
26
|
+
router.get('/users', authMiddleware, requireRole('admin'), getAllUsers);
|
|
27
|
+
router.post('/logout', authMiddleware, logout);
|
|
28
|
+
router.get('/profile', authMiddleware, getProfile);
|
|
29
|
+
router.put('/profile', authMiddleware, validate(updateUserSchema), updateProfile);
|
|
30
|
+
router.delete('/account', authMiddleware, deleteAccount);
|
|
31
|
+
|
|
32
|
+
export default router;
|