Initial commit: teaching institution management API.
Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
// /components/users/userController.js
|
||||
|
||||
const catchAsync = require('../../utils/catchAsync');
|
||||
const userService = require('./userService');
|
||||
const { successResponse, listResponse } = require('../../utils/apiResponse');
|
||||
|
||||
exports.signUp = catchAsync(async (req, res, next) => {
|
||||
const user = await userService.signUp(req.body);
|
||||
return successResponse(res, 201, 'Signed up successfully', user);
|
||||
});
|
||||
|
||||
exports.getSelf = catchAsync(async (req, res, next) => {
|
||||
const user = await userService.getUserById(req.user._id);
|
||||
return successResponse(res, 200, 'User profile retrieved', user);
|
||||
});
|
||||
|
||||
exports.updateSelf = catchAsync(async (req, res, next) => {
|
||||
delete req.body.role;
|
||||
delete req.body.isActive;
|
||||
|
||||
const user = await userService.updateUser(req.user._id, req.body);
|
||||
return successResponse(res, 200, 'Profile updated successfully', user);
|
||||
});
|
||||
|
||||
exports.createAdmin = catchAsync(async (req, res, next) => {
|
||||
const user = await userService.createUserAdmin(req.body);
|
||||
return successResponse(res, 201, 'User created successfully', user);
|
||||
});
|
||||
|
||||
exports.getOne = catchAsync(async (req, res, next) => {
|
||||
const user = await userService.getUserById(req.params.id);
|
||||
return successResponse(res, 200, 'User retrieved successfully', user);
|
||||
});
|
||||
|
||||
exports.getAll = catchAsync(async (req, res, next) => {
|
||||
const { data, meta } = await userService.getAllUsers(req.query);
|
||||
return listResponse(res, 200, data, meta);
|
||||
});
|
||||
|
||||
exports.update = catchAsync(async (req, res, next) => {
|
||||
const user = await userService.updateUser(req.params.id, req.body);
|
||||
return successResponse(res, 200, 'User updated successfully', user);
|
||||
});
|
||||
|
||||
exports.delete = catchAsync(async (req, res, next) => {
|
||||
await userService.deleteUser(req.params.id);
|
||||
return successResponse(res, 200, 'User deleted successfully');
|
||||
});
|
||||
|
||||
exports.search = catchAsync(async (req, res, next) => {
|
||||
const { data, meta } = await userService.searchUsers(req.query);
|
||||
return listResponse(res, 200, data, meta);
|
||||
});
|
||||
|
||||
exports.enroll = catchAsync(async (req, res, next) => {
|
||||
const { userId } = req.params;
|
||||
const { courseId } = req.body;
|
||||
const actorId = req.user._id;
|
||||
const result = await userService.enrollUserInCourse(userId, courseId, actorId);
|
||||
return successResponse(res, 200, 'User enrolled into course successfully', result);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// /components/users/userModel.js
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const refreshTokenSchema = new mongoose.Schema({
|
||||
token: { type: String, required: true },
|
||||
expiresAt: { type: Date, required: true },
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
});
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
nationalIdCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
index: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
surname: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
phoneNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
index: true
|
||||
},
|
||||
preferredMessenger: {
|
||||
type: [{
|
||||
type: String,
|
||||
enum: ['Bale', 'WhatsApp', 'Telegram', 'SMS', 'Email']
|
||||
}],
|
||||
default: ['SMS']
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
sparse: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Please fill a valid email address']
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
index: true
|
||||
},
|
||||
passwordHash: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
role: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Role',
|
||||
required: true
|
||||
},
|
||||
courses: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Course'
|
||||
}],
|
||||
certificates: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Certificate'
|
||||
}],
|
||||
refreshTokens: [refreshTokenSchema],
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('User', userSchema);
|
||||
@@ -0,0 +1,31 @@
|
||||
// /components/users/userRoutes.js
|
||||
|
||||
const express = require('express');
|
||||
const userController = require('./userController');
|
||||
const {
|
||||
validateSignUp,
|
||||
validateCreateUserAdmin,
|
||||
validateUpdateUser,
|
||||
validateEnroll
|
||||
} = require('./userValidator');
|
||||
const authMiddleware = require('../../middlewares/authMiddleware');
|
||||
const perm = require('../../middlewares/permissionMiddleware');
|
||||
const { PERMISSIONS } = require('../../constants/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// User Scope (Self-Service)
|
||||
router.post('/user/sign-up', validateSignUp, userController.signUp);
|
||||
router.get('/user/get-self', authMiddleware, userController.getSelf);
|
||||
router.put('/user/update-self', authMiddleware, validateUpdateUser, userController.updateSelf);
|
||||
|
||||
// Admin Scope
|
||||
router.post('/admin/create', authMiddleware, perm.requires(PERMISSIONS.USERS_CREATE), validateCreateUserAdmin, userController.createAdmin);
|
||||
router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getAll);
|
||||
router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.USERS_SEARCH), userController.search);
|
||||
router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getOne);
|
||||
router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), validateUpdateUser, userController.update);
|
||||
router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_DELETE), userController.delete);
|
||||
router.post('/admin/:userId/enroll', authMiddleware, perm.requires(PERMISSIONS.USERS_ENROLL), validateEnroll, userController.enroll);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,232 @@
|
||||
// /components/users/userService.js
|
||||
'use strict';
|
||||
|
||||
const User = require('./userModel');
|
||||
const Role = require('../roles/roleModel');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const AppError = require('../../utils/AppError');
|
||||
const { calculateMeta } = require('../../utils/pagination');
|
||||
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
|
||||
const { sendAccountCreatedSms } = require('../../utils/senders/smsMessages');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
const POPULATE_ROLE = { path: 'role', select: 'name permissions' };
|
||||
const SAFE_FIELDS = '-passwordHash -refreshTokens';
|
||||
const ALLOWED_MESSENGERS = ['Bale', 'WhatsApp', 'Telegram', 'SMS', 'Email'];
|
||||
|
||||
const normalizePreferredMessengers = (value) => {
|
||||
if (value == null || value === '') return undefined;
|
||||
const list = Array.isArray(value) ? value : [value];
|
||||
const cleaned = [...new Set(list.map(String).filter((v) => ALLOWED_MESSENGERS.includes(v)))];
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
const allocateUniqueUsername = async (preferred) => {
|
||||
let username = preferred && String(preferred).trim();
|
||||
if (username) {
|
||||
const exists = await User.exists({ username });
|
||||
if (exists) throw new AppError('USER_ALREADY_EXISTS', null, 'Username already exists');
|
||||
return username;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
username = generateUsername();
|
||||
const exists = await User.exists({ username });
|
||||
if (!exists) return username;
|
||||
}
|
||||
throw new AppError('INTERNAL_SERVER_ERROR', null, 'Could not generate a unique username');
|
||||
};
|
||||
|
||||
const signUp = async (body) => {
|
||||
const { name, surname, nationalId, nationalIdCode, phoneNumber, phone, username, password, email, address, preferredMessenger } = body;
|
||||
|
||||
const userRole = await Role.findOne({ name: 'User' });
|
||||
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await User.create({
|
||||
name,
|
||||
surname,
|
||||
nationalIdCode: nationalIdCode || nationalId,
|
||||
phoneNumber: phoneNumber || phone,
|
||||
username,
|
||||
passwordHash,
|
||||
email,
|
||||
address,
|
||||
preferredMessenger: normalizePreferredMessengers(preferredMessenger),
|
||||
role: userRole._id
|
||||
});
|
||||
|
||||
return user.populate(POPULATE_ROLE);
|
||||
};
|
||||
|
||||
const getUserById = async (id) => {
|
||||
const user = await User.findById(id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
|
||||
if (!user) throw new AppError('USER_NOT_FOUND');
|
||||
return user;
|
||||
};
|
||||
|
||||
const getAllUsers = async (query) => {
|
||||
const page = parseInt(query.page) || 1;
|
||||
const limit = Math.min(parseInt(query.limit) || 20, 200);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const filter = {};
|
||||
if (query.search) {
|
||||
filter.$or = [
|
||||
{ name: new RegExp(query.search, 'i') },
|
||||
{ surname: new RegExp(query.search, 'i') },
|
||||
{ username: new RegExp(query.search, 'i') }
|
||||
];
|
||||
}
|
||||
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
User.find(filter).select(SAFE_FIELDS).populate(POPULATE_ROLE).skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
||||
User.countDocuments(filter)
|
||||
]);
|
||||
|
||||
return { data: items, meta: calculateMeta(total, page, limit) };
|
||||
};
|
||||
|
||||
const searchUsers = async (query) => getAllUsers(query);
|
||||
|
||||
const createUserAdmin = async (body) => {
|
||||
const {
|
||||
name,
|
||||
surname,
|
||||
nationalIdCode,
|
||||
nationalId,
|
||||
phoneNumber,
|
||||
phone,
|
||||
username: requestedUsername,
|
||||
password: requestedPassword,
|
||||
email,
|
||||
roleId,
|
||||
role,
|
||||
address,
|
||||
preferredMessenger,
|
||||
} = body;
|
||||
|
||||
let roleObj = null;
|
||||
if (roleId || role) {
|
||||
roleObj = await Role.findById(roleId || role);
|
||||
}
|
||||
if (!roleObj) {
|
||||
roleObj = await Role.findOne({ name: 'User' });
|
||||
}
|
||||
if (!roleObj) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
|
||||
|
||||
const plainPassword = (requestedPassword && String(requestedPassword).trim()) || generateSimplePassword();
|
||||
const username = await allocateUniqueUsername(requestedUsername);
|
||||
const passwordHash = await bcrypt.hash(plainPassword, 10);
|
||||
const resolvedPhone = phoneNumber || phone;
|
||||
|
||||
const user = await User.create({
|
||||
name,
|
||||
surname,
|
||||
nationalIdCode: nationalIdCode || nationalId,
|
||||
phoneNumber: resolvedPhone,
|
||||
username,
|
||||
passwordHash,
|
||||
email,
|
||||
address,
|
||||
preferredMessenger: normalizePreferredMessengers(preferredMessenger),
|
||||
role: roleObj._id,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAccountCreatedSms(resolvedPhone, username, plainPassword);
|
||||
} catch (err) {
|
||||
logger.error(`[createUserAdmin] Account SMS failed for ${resolvedPhone}: ${err.message}`);
|
||||
}
|
||||
|
||||
const created = await User.findById(user._id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
|
||||
return {
|
||||
...created,
|
||||
generatedCredentials: {
|
||||
username,
|
||||
password: plainPassword,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const updateUser = async (id, body) => {
|
||||
const {
|
||||
password,
|
||||
nationalId,
|
||||
nationalIdCode,
|
||||
phone,
|
||||
phoneNumber,
|
||||
roleId,
|
||||
role,
|
||||
username,
|
||||
passwordHash,
|
||||
refreshTokens,
|
||||
preferredMessenger,
|
||||
_id,
|
||||
id: bodyId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
__v,
|
||||
...rest
|
||||
} = body;
|
||||
|
||||
const update = { ...rest };
|
||||
|
||||
// Map frontend field aliases to schema fields
|
||||
if (nationalIdCode || nationalId) {
|
||||
update.nationalIdCode = nationalIdCode || nationalId;
|
||||
}
|
||||
if (phoneNumber || phone) {
|
||||
update.phoneNumber = phoneNumber || phone;
|
||||
}
|
||||
if (roleId || role) {
|
||||
update.role = roleId || role;
|
||||
}
|
||||
if (preferredMessenger !== undefined) {
|
||||
update.preferredMessenger = normalizePreferredMessengers(preferredMessenger) || [];
|
||||
}
|
||||
|
||||
// Never overwrite username with an empty value on update
|
||||
if (typeof username === 'string' && username.trim()) {
|
||||
update.username = username.trim();
|
||||
}
|
||||
|
||||
// Strip empty strings so required validators are not tripped
|
||||
Object.keys(update).forEach((key) => {
|
||||
if (key === 'preferredMessenger') return;
|
||||
if (update[key] === '' || update[key] === null || update[key] === undefined) {
|
||||
delete update[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (password) {
|
||||
update.passwordHash = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const user = await User.findByIdAndUpdate(id, update, { new: true, runValidators: true })
|
||||
.select(SAFE_FIELDS)
|
||||
.populate(POPULATE_ROLE)
|
||||
.lean();
|
||||
if (!user) throw new AppError('USER_NOT_FOUND');
|
||||
return user;
|
||||
};
|
||||
|
||||
const deleteUser = async (id) => {
|
||||
const user = await User.findByIdAndDelete(id);
|
||||
if (!user) throw new AppError('USER_NOT_FOUND');
|
||||
};
|
||||
|
||||
const enrollUserInCourse = async (userId, courseId, actorId) => {
|
||||
const user = await User.findById(userId);
|
||||
if (!user) throw new AppError('USER_NOT_FOUND');
|
||||
|
||||
if (!user.courses.includes(courseId)) {
|
||||
user.courses.push(courseId);
|
||||
await user.save();
|
||||
}
|
||||
return User.findById(userId).select(SAFE_FIELDS).populate('courses').lean();
|
||||
};
|
||||
|
||||
module.exports = { signUp, getUserById, getAllUsers, searchUsers, createUserAdmin, updateUser, deleteUser, enrollUserInCourse };
|
||||
@@ -0,0 +1,6 @@
|
||||
// Stub validator — pass-through middleware (no validation yet)
|
||||
const passThrough = (req, res, next) => next();
|
||||
|
||||
module.exports = new Proxy({}, {
|
||||
get: () => passThrough
|
||||
});
|
||||
Reference in New Issue
Block a user