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,30 @@
|
|
|
1
|
+
// src/config/db.config.js — PostgreSQL/Prisma connection. Client initialization and lifecycle.
|
|
2
|
+
|
|
3
|
+
import { PrismaClient } from '@prisma/client';
|
|
4
|
+
import envConfig from './env.config.js';
|
|
5
|
+
|
|
6
|
+
const prisma = new PrismaClient({
|
|
7
|
+
log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'],
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
async function connectDB() {
|
|
11
|
+
try {
|
|
12
|
+
await prisma.$connect();
|
|
13
|
+
console.log('✅ PostgreSQL connected successfully');
|
|
14
|
+
return prisma;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error('❌ PostgreSQL connection failed:', err.message);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function disconnectDB() {
|
|
22
|
+
try {
|
|
23
|
+
await prisma.$disconnect();
|
|
24
|
+
console.log('✅ PostgreSQL disconnected');
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error('❌ PostgreSQL disconnection failed:', err.message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { connectDB, disconnectDB, prisma };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/config/env.config.js — Validates ALL required env vars at startup. App refuses to boot if missing.
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const envSchema = z.object({
|
|
6
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
7
|
+
PORT: z.string().transform(Number).default('3000'),
|
|
8
|
+
DATABASE_URL: z.string().url('Invalid DATABASE_URL'),
|
|
9
|
+
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
|
|
10
|
+
JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
|
|
11
|
+
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
|
12
|
+
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
let envConfig;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const rawEnv = {
|
|
19
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
20
|
+
PORT: process.env.PORT,
|
|
21
|
+
DATABASE_URL: process.env.DATABASE_URL,
|
|
22
|
+
JWT_SECRET: process.env.JWT_SECRET,
|
|
23
|
+
JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET,
|
|
24
|
+
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
|
25
|
+
LOG_LEVEL: process.env.LOG_LEVEL,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
envConfig = envSchema.parse(rawEnv);
|
|
29
|
+
Object.freeze(envConfig);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error('❌ Environment validation failed:');
|
|
32
|
+
console.error(err.errors || err.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default envConfig;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// src/config/rateLimiter.config.js — Global and auth-specific rate limiters. Non-negotiable.
|
|
2
|
+
|
|
3
|
+
import rateLimit from 'express-rate-limit';
|
|
4
|
+
import { APP_CONSTANTS } from '../utils/constants.js';
|
|
5
|
+
|
|
6
|
+
const globalRateLimiter = rateLimit({
|
|
7
|
+
windowMs: APP_CONSTANTS.RATE_LIMIT_WINDOW_MS,
|
|
8
|
+
max: APP_CONSTANTS.RATE_LIMIT_MAX_REQUESTS,
|
|
9
|
+
message: 'Too many requests, please try again later',
|
|
10
|
+
standardHeaders: true,
|
|
11
|
+
legacyHeaders: false,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const authRateLimiter = rateLimit({
|
|
15
|
+
windowMs: APP_CONSTANTS.RATE_LIMIT_WINDOW_MS,
|
|
16
|
+
max: APP_CONSTANTS.AUTH_RATE_LIMIT_MAX_REQUESTS,
|
|
17
|
+
message: 'Too many login attempts, please try again later',
|
|
18
|
+
standardHeaders: true,
|
|
19
|
+
legacyHeaders: false,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export { globalRateLimiter, authRateLimiter };
|
|
@@ -0,0 +1,32 @@
|
|
|
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 authHeader = req.headers.authorization;
|
|
10
|
+
|
|
11
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
12
|
+
throw new AppError('Missing or invalid Authorization header', HTTP_STATUS.UNAUTHORIZED);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const token = authHeader.slice(7);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const decoded = verifyToken(token);
|
|
19
|
+
req.user = decoded;
|
|
20
|
+
next();
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err.name === 'TokenExpiredError') {
|
|
23
|
+
throw new AppError('Token expired', HTTP_STATUS.UNAUTHORIZED);
|
|
24
|
+
}
|
|
25
|
+
if (err.name === 'JsonWebTokenError') {
|
|
26
|
+
throw new AppError('Invalid token', HTTP_STATUS.UNAUTHORIZED);
|
|
27
|
+
}
|
|
28
|
+
throw new AppError('Authentication failed', HTTP_STATUS.UNAUTHORIZED);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export { authMiddleware };
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
// Prisma UniqueConstraintError
|
|
10
|
+
if (err.code === 'P2002') {
|
|
11
|
+
const field = err.meta?.target?.[0] || 'field';
|
|
12
|
+
return sendError(res, HTTP_STATUS.CONFLICT, `${field} already in use`, isDevelopment ? err : null);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Prisma RecordNotFoundError
|
|
16
|
+
if (err.code === 'P2025') {
|
|
17
|
+
return sendError(res, HTTP_STATUS.NOT_FOUND, 'Record not found', isDevelopment ? err : null);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Prisma ValidationError
|
|
21
|
+
if (err.code === 'P2003' || err.code === 'P2014') {
|
|
22
|
+
return sendError(res, HTTP_STATUS.BAD_REQUEST, 'Invalid data provided', isDevelopment ? err : null);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// JWT TokenExpiredError
|
|
26
|
+
if (err.name === 'TokenExpiredError') {
|
|
27
|
+
return sendError(res, HTTP_STATUS.UNAUTHORIZED, 'Token expired', isDevelopment ? err : null);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// JWT JsonWebTokenError
|
|
31
|
+
if (err.name === 'JsonWebTokenError') {
|
|
32
|
+
return sendError(res, HTTP_STATUS.UNAUTHORIZED, 'Invalid token', isDevelopment ? err : null);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// AppError (operational)
|
|
36
|
+
if (err.isOperational) {
|
|
37
|
+
return sendError(res, err.statusCode, err.message, isDevelopment ? { stack: err.stack } : null);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Programmer error (unhandled)
|
|
41
|
+
console.error('UNHANDLED ERROR:', err);
|
|
42
|
+
return sendError(
|
|
43
|
+
res,
|
|
44
|
+
HTTP_STATUS.INTERNAL_SERVER_ERROR,
|
|
45
|
+
'Internal server error',
|
|
46
|
+
isDevelopment ? { error: err.message, stack: err.stack } : null
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
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,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,57 @@
|
|
|
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 * as userService from './user.service.js';
|
|
7
|
+
|
|
8
|
+
const register = asyncHandler(async (req, res) => {
|
|
9
|
+
const { email, firstName, lastName, password } = req.body;
|
|
10
|
+
|
|
11
|
+
const user = await userService.register(email, firstName, lastName, password);
|
|
12
|
+
|
|
13
|
+
return sendSuccess(res, HTTP_STATUS.CREATED, 'User registered successfully', {
|
|
14
|
+
user,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const login = asyncHandler(async (req, res) => {
|
|
19
|
+
const { email, password } = req.body;
|
|
20
|
+
|
|
21
|
+
const result = await userService.login(email, password);
|
|
22
|
+
|
|
23
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'Login successful', {
|
|
24
|
+
user: result.user,
|
|
25
|
+
token: result.token,
|
|
26
|
+
refreshToken: result.refreshToken,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const getProfile = asyncHandler(async (req, res) => {
|
|
31
|
+
const user = await userService.getUserById(req.user.id);
|
|
32
|
+
|
|
33
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'User profile retrieved', {
|
|
34
|
+
user,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const updateProfile = asyncHandler(async (req, res) => {
|
|
39
|
+
const { firstName, lastName } = req.body;
|
|
40
|
+
|
|
41
|
+
const user = await userService.updateUser(req.user.id, {
|
|
42
|
+
firstName,
|
|
43
|
+
lastName,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'User profile updated', {
|
|
47
|
+
user,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const deleteAccount = asyncHandler(async (req, res) => {
|
|
52
|
+
await userService.deleteUser(req.user.id);
|
|
53
|
+
|
|
54
|
+
return sendSuccess(res, HTTP_STATUS.OK, 'Account deleted successfully', null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export { register, login, getProfile, updateProfile, deleteAccount };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/modules/user/user.model.js — Reference to Prisma User model. See prisma/schema.prisma for definition.
|
|
2
|
+
|
|
3
|
+
// This module exists for reference. The actual User model is defined in prisma/schema.prisma
|
|
4
|
+
// Example schema:
|
|
5
|
+
/*
|
|
6
|
+
model User {
|
|
7
|
+
id Int @id @default(autoincrement())
|
|
8
|
+
email String @unique
|
|
9
|
+
firstName String
|
|
10
|
+
lastName String
|
|
11
|
+
password String
|
|
12
|
+
isActive Boolean @default(true)
|
|
13
|
+
createdAt DateTime @default(now())
|
|
14
|
+
updatedAt DateTime @updatedAt
|
|
15
|
+
|
|
16
|
+
@@map("users")
|
|
17
|
+
}
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// src/modules/user/user.repository.js — DB queries only. Never business logic, never HTTP.
|
|
2
|
+
|
|
3
|
+
import { prisma } from '../../config/db.config.js';
|
|
4
|
+
|
|
5
|
+
async function findById(id) {
|
|
6
|
+
return prisma.user.findUnique({
|
|
7
|
+
where: { id },
|
|
8
|
+
select: {
|
|
9
|
+
id: true,
|
|
10
|
+
email: true,
|
|
11
|
+
firstName: true,
|
|
12
|
+
lastName: true,
|
|
13
|
+
isActive: true,
|
|
14
|
+
createdAt: true,
|
|
15
|
+
updatedAt: true,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function findByEmail(email) {
|
|
21
|
+
return prisma.user.findUnique({
|
|
22
|
+
where: { email },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function findByEmailPublic(email) {
|
|
27
|
+
return prisma.user.findUnique({
|
|
28
|
+
where: { email },
|
|
29
|
+
select: {
|
|
30
|
+
id: true,
|
|
31
|
+
email: true,
|
|
32
|
+
firstName: true,
|
|
33
|
+
lastName: true,
|
|
34
|
+
isActive: true,
|
|
35
|
+
createdAt: true,
|
|
36
|
+
updatedAt: true,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function create(userData) {
|
|
42
|
+
return prisma.user.create({
|
|
43
|
+
data: userData,
|
|
44
|
+
select: {
|
|
45
|
+
id: true,
|
|
46
|
+
email: true,
|
|
47
|
+
firstName: true,
|
|
48
|
+
lastName: true,
|
|
49
|
+
isActive: true,
|
|
50
|
+
createdAt: true,
|
|
51
|
+
updatedAt: true,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function updateById(id, updateData) {
|
|
57
|
+
return prisma.user.update({
|
|
58
|
+
where: { id },
|
|
59
|
+
data: updateData,
|
|
60
|
+
select: {
|
|
61
|
+
id: true,
|
|
62
|
+
email: true,
|
|
63
|
+
firstName: true,
|
|
64
|
+
lastName: true,
|
|
65
|
+
isActive: true,
|
|
66
|
+
createdAt: true,
|
|
67
|
+
updatedAt: true,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function deleteById(id) {
|
|
73
|
+
return prisma.user.delete({
|
|
74
|
+
where: { id },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function findAll(limit = 10, skip = 0) {
|
|
79
|
+
return prisma.user.findMany({
|
|
80
|
+
skip,
|
|
81
|
+
take: limit,
|
|
82
|
+
select: {
|
|
83
|
+
id: true,
|
|
84
|
+
email: true,
|
|
85
|
+
firstName: true,
|
|
86
|
+
lastName: true,
|
|
87
|
+
isActive: true,
|
|
88
|
+
createdAt: true,
|
|
89
|
+
updatedAt: true,
|
|
90
|
+
},
|
|
91
|
+
orderBy: {
|
|
92
|
+
createdAt: 'desc',
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export {
|
|
98
|
+
findById,
|
|
99
|
+
findByEmail,
|
|
100
|
+
findByEmailPublic,
|
|
101
|
+
create,
|
|
102
|
+
updateById,
|
|
103
|
+
deleteById,
|
|
104
|
+
findAll,
|
|
105
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
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 { authRateLimiter } from '../../config/rateLimiter.config.js';
|
|
7
|
+
import { registerSchema, loginSchema, updateUserSchema } from './user.validator.js';
|
|
8
|
+
import {
|
|
9
|
+
register,
|
|
10
|
+
login,
|
|
11
|
+
getProfile,
|
|
12
|
+
updateProfile,
|
|
13
|
+
deleteAccount,
|
|
14
|
+
} from './user.controller.js';
|
|
15
|
+
|
|
16
|
+
const router = Router();
|
|
17
|
+
|
|
18
|
+
// Public routes
|
|
19
|
+
router.post('/register', authRateLimiter, validate(registerSchema), register);
|
|
20
|
+
router.post('/login', authRateLimiter, validate(loginSchema), login);
|
|
21
|
+
|
|
22
|
+
// Protected routes
|
|
23
|
+
router.get('/profile', authMiddleware, getProfile);
|
|
24
|
+
router.put('/profile', authMiddleware, validate(updateUserSchema), updateProfile);
|
|
25
|
+
router.delete('/account', authMiddleware, deleteAccount);
|
|
26
|
+
|
|
27
|
+
export default router;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// src/modules/user/user.service.js — Business logic only. Never DB queries, never HTTP.
|
|
2
|
+
|
|
3
|
+
import bcryptjs from 'bcryptjs';
|
|
4
|
+
import { AppError } from '../../utils/AppError.js';
|
|
5
|
+
import { HTTP_STATUS, APP_CONSTANTS } from '../../utils/constants.js';
|
|
6
|
+
import { signToken, signRefreshToken } from '../../utils/jwt.utils.js';
|
|
7
|
+
import * as userRepository from './user.repository.js';
|
|
8
|
+
|
|
9
|
+
async function register(email, firstName, lastName, password) {
|
|
10
|
+
// Check if user exists
|
|
11
|
+
const existingUser = await userRepository.findByEmailPublic(email);
|
|
12
|
+
if (existingUser) {
|
|
13
|
+
throw new AppError('Email already in use', HTTP_STATUS.CONFLICT);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Hash password
|
|
17
|
+
const hashedPassword = await bcryptjs.hash(password, APP_CONSTANTS.BCRYPT_SALT_ROUNDS);
|
|
18
|
+
|
|
19
|
+
// Create user
|
|
20
|
+
const user = await userRepository.create({
|
|
21
|
+
email,
|
|
22
|
+
firstName,
|
|
23
|
+
lastName,
|
|
24
|
+
password: hashedPassword,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return user;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function login(email, password) {
|
|
31
|
+
// Find user with password
|
|
32
|
+
const user = await userRepository.findByEmail(email);
|
|
33
|
+
if (!user) {
|
|
34
|
+
throw new AppError('Invalid email or password', HTTP_STATUS.UNAUTHORIZED);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Verify password
|
|
38
|
+
const isPasswordValid = await bcryptjs.compare(password, user.password);
|
|
39
|
+
if (!isPasswordValid) {
|
|
40
|
+
throw new AppError('Invalid email or password', HTTP_STATUS.UNAUTHORIZED);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Generate tokens
|
|
44
|
+
const token = signToken({ id: user.id, email: user.email });
|
|
45
|
+
const refreshToken = signRefreshToken({ id: user.id });
|
|
46
|
+
|
|
47
|
+
// Return without password
|
|
48
|
+
const { password: _, ...userWithoutPassword } = user;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
user: userWithoutPassword,
|
|
52
|
+
token,
|
|
53
|
+
refreshToken,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function getUserById(id) {
|
|
58
|
+
const user = await userRepository.findById(id);
|
|
59
|
+
if (!user) {
|
|
60
|
+
throw new AppError('User not found', HTTP_STATUS.NOT_FOUND);
|
|
61
|
+
}
|
|
62
|
+
return user;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function updateUser(id, updateData) {
|
|
66
|
+
const user = await userRepository.updateById(id, updateData);
|
|
67
|
+
if (!user) {
|
|
68
|
+
throw new AppError('User not found', HTTP_STATUS.NOT_FOUND);
|
|
69
|
+
}
|
|
70
|
+
return user;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function deleteUser(id) {
|
|
74
|
+
const user = await userRepository.deleteById(id);
|
|
75
|
+
if (!user) {
|
|
76
|
+
throw new AppError('User not found', HTTP_STATUS.NOT_FOUND);
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export { register, login, getUserById, updateUser, deleteUser };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// src/modules/user/user.validator.js — Zod validation schemas. Never validate in controller.
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const registerSchema = z.object({
|
|
6
|
+
email: z.string().email('Invalid email format'),
|
|
7
|
+
firstName: z.string().min(2, 'First name must be at least 2 characters').max(50),
|
|
8
|
+
lastName: z.string().min(2, 'Last name must be at least 2 characters').max(50),
|
|
9
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const loginSchema = z.object({
|
|
13
|
+
email: z.string().email('Invalid email format'),
|
|
14
|
+
password: z.string().min(1, 'Password is required'),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const updateUserSchema = z.object({
|
|
18
|
+
firstName: z.string().min(2).max(50).optional(),
|
|
19
|
+
lastName: z.string().min(2).max(50).optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export { registerSchema, loginSchema, updateUserSchema };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/utils/AppError.js — Custom Error class for operational errors. All errors inherit this.
|
|
2
|
+
|
|
3
|
+
class AppError extends Error {
|
|
4
|
+
constructor(message, statusCode, isOperational = true) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.statusCode = statusCode;
|
|
7
|
+
this.isOperational = isOperational;
|
|
8
|
+
this.timestamp = new Date().toISOString();
|
|
9
|
+
|
|
10
|
+
Error.captureStackTrace(this, this.constructor);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export { AppError };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/utils/apiResponse.js — Standardized success/error response formatting. Always use these.
|
|
2
|
+
|
|
3
|
+
import { HTTP_STATUS } from './constants.js';
|
|
4
|
+
|
|
5
|
+
function sendSuccess(res, statusCode = HTTP_STATUS.OK, message = 'Success', data = null) {
|
|
6
|
+
return res.status(statusCode).json({
|
|
7
|
+
success: true,
|
|
8
|
+
message,
|
|
9
|
+
data,
|
|
10
|
+
timestamp: new Date().toISOString(),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sendError(res, statusCode = HTTP_STATUS.INTERNAL_SERVER_ERROR, message = 'Internal Server Error', data = null) {
|
|
15
|
+
return res.status(statusCode).json({
|
|
16
|
+
success: false,
|
|
17
|
+
message,
|
|
18
|
+
data,
|
|
19
|
+
timestamp: new Date().toISOString(),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { sendSuccess, sendError };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// src/utils/constants.js — All app constants. No hardcoded strings anywhere.
|
|
2
|
+
|
|
3
|
+
const HTTP_STATUS = {
|
|
4
|
+
OK: 200,
|
|
5
|
+
CREATED: 201,
|
|
6
|
+
BAD_REQUEST: 400,
|
|
7
|
+
UNAUTHORIZED: 401,
|
|
8
|
+
FORBIDDEN: 403,
|
|
9
|
+
NOT_FOUND: 404,
|
|
10
|
+
CONFLICT: 409,
|
|
11
|
+
INTERNAL_SERVER_ERROR: 500,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const APP_CONSTANTS = {
|
|
15
|
+
JWT_EXPIRY: '7d',
|
|
16
|
+
JWT_REFRESH_EXPIRY: '30d',
|
|
17
|
+
BCRYPT_SALT_ROUNDS: 12,
|
|
18
|
+
RATE_LIMIT_WINDOW_MS: 15 * 60 * 1000,
|
|
19
|
+
RATE_LIMIT_MAX_REQUESTS: 100,
|
|
20
|
+
AUTH_RATE_LIMIT_MAX_REQUESTS: 5,
|
|
21
|
+
REQUEST_BODY_LIMIT: '10kb',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export { HTTP_STATUS, APP_CONSTANTS };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/utils/jwt.utils.js — JWT token operations only. Never inline JWT logic anywhere.
|
|
2
|
+
|
|
3
|
+
import jwt from 'jsonwebtoken';
|
|
4
|
+
import { AppError } from './AppError.js';
|
|
5
|
+
import { HTTP_STATUS, APP_CONSTANTS } from './constants.js';
|
|
6
|
+
|
|
7
|
+
function signToken(payload, expiresIn = APP_CONSTANTS.JWT_EXPIRY) {
|
|
8
|
+
const secret = process.env.JWT_SECRET;
|
|
9
|
+
if (!secret) {
|
|
10
|
+
throw new AppError('JWT_SECRET not configured', HTTP_STATUS.INTERNAL_SERVER_ERROR, false);
|
|
11
|
+
}
|
|
12
|
+
return jwt.sign(payload, secret, { expiresIn });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function signRefreshToken(payload, expiresIn = APP_CONSTANTS.JWT_REFRESH_EXPIRY) {
|
|
16
|
+
const secret = process.env.JWT_REFRESH_SECRET;
|
|
17
|
+
if (!secret) {
|
|
18
|
+
throw new AppError('JWT_REFRESH_SECRET not configured', HTTP_STATUS.INTERNAL_SERVER_ERROR, false);
|
|
19
|
+
}
|
|
20
|
+
return jwt.sign(payload, secret, { expiresIn });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function verifyToken(token) {
|
|
24
|
+
const secret = process.env.JWT_SECRET;
|
|
25
|
+
if (!secret) {
|
|
26
|
+
throw new AppError('JWT_SECRET not configured', HTTP_STATUS.INTERNAL_SERVER_ERROR, false);
|
|
27
|
+
}
|
|
28
|
+
return jwt.verify(token, secret);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function verifyRefreshToken(token) {
|
|
32
|
+
const secret = process.env.JWT_REFRESH_SECRET;
|
|
33
|
+
if (!secret) {
|
|
34
|
+
throw new AppError('JWT_REFRESH_SECRET not configured', HTTP_STATUS.INTERNAL_SERVER_ERROR, false);
|
|
35
|
+
}
|
|
36
|
+
return jwt.verify(token, secret);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { signToken, signRefreshToken, verifyToken, verifyRefreshToken };
|