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,104 @@
|
||||
// /middlewares/activityLogger.js
|
||||
'use strict';
|
||||
|
||||
const activityLogService = require('../components/activityLogs/activityLogService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const SKIP_PREFIXES = [
|
||||
'/api/health',
|
||||
'/api/activity-logs'
|
||||
];
|
||||
|
||||
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
const resolveResource = (path = '') => {
|
||||
const clean = path.split('?')[0].replace(/^\/api\//, '');
|
||||
const [resource] = clean.split('/').filter(Boolean);
|
||||
return resource || 'unknown';
|
||||
};
|
||||
|
||||
const resolveResourceId = (req) => {
|
||||
if (req.params?.id) return String(req.params.id);
|
||||
if (req.params?.userId) return String(req.params.userId);
|
||||
if (req.body?._id) return String(req.body._id);
|
||||
if (req.body?.id) return String(req.body.id);
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveAction = (method, path = '') => {
|
||||
const p = path.toLowerCase();
|
||||
|
||||
if (p.includes('/auth/login')) return 'login';
|
||||
if (p.includes('/auth/logout')) return 'logout';
|
||||
if (p.includes('/auth/refresh')) return 'other';
|
||||
if (p.includes('enroll') || p.includes('register')) return 'enroll';
|
||||
if (p.includes('attendance')) return 'attendance';
|
||||
if (p.includes('upload')) return 'upload';
|
||||
if (p.includes('retry')) return 'retry';
|
||||
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
return 'create';
|
||||
case 'PUT':
|
||||
case 'PATCH':
|
||||
return 'update';
|
||||
case 'DELETE':
|
||||
return 'delete';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
};
|
||||
|
||||
const shouldSkip = (req) => {
|
||||
if (!MUTATING_METHODS.has(req.method)) return true;
|
||||
|
||||
const path = req.originalUrl || req.url || '';
|
||||
if (SKIP_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
|
||||
if (path.includes('/auth/refresh')) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const activityLogger = (req, res, next) => {
|
||||
if (shouldSkip(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.on('finish', () => {
|
||||
// Only persist completed mutating requests (success or business failure)
|
||||
if (res.statusCode < 200 || res.statusCode >= 600) return;
|
||||
|
||||
const path = (req.originalUrl || req.url || '').split('?')[0];
|
||||
const action = resolveAction(req.method, path);
|
||||
const actor = req.user || null;
|
||||
|
||||
const payload = {
|
||||
actor: actor?._id || null,
|
||||
actorUsername: actor?.username || req.body?.username || null,
|
||||
actorName: actor
|
||||
? `${actor.name || ''} ${actor.surname || ''}`.trim() || actor.username
|
||||
: req.body?.username || null,
|
||||
action,
|
||||
resource: resolveResource(path),
|
||||
resourceId: resolveResourceId(req),
|
||||
method: req.method,
|
||||
path,
|
||||
statusCode: res.statusCode,
|
||||
ip: req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || null,
|
||||
userAgent: req.headers['user-agent'] || null,
|
||||
metadata: {
|
||||
params: activityLogService.sanitizeValue(req.params || {}),
|
||||
query: activityLogService.sanitizeValue(req.query || {}),
|
||||
body: activityLogService.sanitizeValue(req.body || {})
|
||||
}
|
||||
};
|
||||
|
||||
activityLogService.createActivityLog(payload).catch((err) => {
|
||||
logger.error(`[ActivityLog] Failed to write log: ${err.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = activityLogger;
|
||||
@@ -0,0 +1,43 @@
|
||||
// /middlewares/authMiddleware.js
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config/config');
|
||||
const AppError = require('../utils/AppError');
|
||||
const User = require('../components/users/userModel');
|
||||
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
let token = null;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.split(' ')[1];
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return next(new AppError('UNAUTHORIZED'));
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, config.JWT_ACCESS_SECRET);
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return next(new AppError('TOKEN_EXPIRED'));
|
||||
}
|
||||
return next(new AppError('UNAUTHORIZED'));
|
||||
}
|
||||
|
||||
const user = await User.findById(decoded.id).populate('role');
|
||||
if (!user || !user.isActive) {
|
||||
return next(new AppError('UNAUTHORIZED'));
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = authMiddleware;
|
||||
@@ -0,0 +1,107 @@
|
||||
// /middlewares/globalErrorHandler.js
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/apiResponse');
|
||||
const { getLocalizedErrorMessage } = require('../utils/errorLocalization');
|
||||
|
||||
const formatDetailsMessage = (details) => {
|
||||
if (!details) return null;
|
||||
|
||||
if (typeof details === 'string') return details;
|
||||
|
||||
if (Array.isArray(details)) {
|
||||
const parts = details
|
||||
.map((item) => {
|
||||
if (!item) return null;
|
||||
if (typeof item === 'string') return item;
|
||||
return item.message || item.reason || null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return parts.length ? parts.join(' — ') : null;
|
||||
}
|
||||
|
||||
if (typeof details === 'object') {
|
||||
const parts = Object.entries(details).map(([key, value]) => {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') return `${key}: ${value}`;
|
||||
if (typeof value === 'object' && value.message) return `${key}: ${value.message}`;
|
||||
if (key === 'keyValue') return Object.entries(value).map(([k, v]) => `${k}=${v}`).join(', ');
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
return parts.length ? parts.join(' — ') : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const globalErrorHandler = (err, req, res, next) => {
|
||||
let statusCode = err.statusCode || 500;
|
||||
let errorCode = err.errorCode || 'INTERNAL_SERVER_ERROR';
|
||||
let details = err.details || null;
|
||||
|
||||
// Handle Mongoose CastError (e.g. invalid ObjectId)
|
||||
if (err.name === 'CastError') {
|
||||
statusCode = 400;
|
||||
errorCode = 'VALIDATION_FAILED';
|
||||
details = { field: err.path, value: err.value, reason: 'Invalid ObjectId format' };
|
||||
}
|
||||
|
||||
// Handle Mongoose Duplicate Key Error (E11000)
|
||||
if (err.code === 11000) {
|
||||
statusCode = 409;
|
||||
errorCode = 'DUPLICATE_KEY';
|
||||
details = { keyPattern: err.keyPattern, keyValue: err.keyValue };
|
||||
}
|
||||
|
||||
// Handle Mongoose ValidationError
|
||||
if (err.name === 'ValidationError') {
|
||||
statusCode = 400;
|
||||
errorCode = 'VALIDATION_FAILED';
|
||||
details = Object.keys(err.errors || {}).reduce((acc, key) => {
|
||||
acc[key] = err.errors[key].message;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Handle Joi validation errors if forwarded as standard Error
|
||||
if (err.isJoi) {
|
||||
statusCode = 400;
|
||||
errorCode = 'VALIDATION_FAILED';
|
||||
details = err.details
|
||||
? err.details.map((d) => ({ message: d.message, path: d.path }))
|
||||
: null;
|
||||
}
|
||||
|
||||
// Localized catalog message (Accept-Language / ?lang=)
|
||||
let localizedMessage = err.overrideMessage
|
||||
? err.overrideMessage
|
||||
: getLocalizedErrorMessage(errorCode, req);
|
||||
|
||||
const detailsMessage = formatDetailsMessage(details);
|
||||
if (detailsMessage && errorCode === 'VALIDATION_FAILED' && !err.overrideMessage) {
|
||||
localizedMessage = `${localizedMessage} ${detailsMessage}`;
|
||||
} else if (detailsMessage && errorCode === 'DUPLICATE_KEY') {
|
||||
localizedMessage = `${localizedMessage} (${detailsMessage})`;
|
||||
}
|
||||
|
||||
// Log error (stack trace for non-operational / 500 errors)
|
||||
if (!err.isOperational || statusCode >= 500) {
|
||||
logger.error(`[PROGRAMMING_ERROR] ${err.message}`, {
|
||||
stack: err.stack,
|
||||
path: req.originalUrl,
|
||||
method: req.method
|
||||
});
|
||||
} else {
|
||||
logger.warn(`[OPERATIONAL_ERROR] Code: ${errorCode} | Path: ${req.originalUrl} | Message: ${localizedMessage}`);
|
||||
}
|
||||
|
||||
return errorResponse(
|
||||
res,
|
||||
statusCode,
|
||||
errorCode,
|
||||
localizedMessage,
|
||||
details
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = globalErrorHandler;
|
||||
@@ -0,0 +1,9 @@
|
||||
// /middlewares/notFoundHandler.js
|
||||
|
||||
const AppError = require('../utils/AppError');
|
||||
|
||||
const notFoundHandler = (req, res, next) => {
|
||||
next(new AppError('NOT_FOUND', { path: req.originalUrl, method: req.method }));
|
||||
};
|
||||
|
||||
module.exports = notFoundHandler;
|
||||
@@ -0,0 +1,35 @@
|
||||
// /middlewares/permissionMiddleware.js
|
||||
|
||||
const AppError = require('../utils/AppError');
|
||||
|
||||
const requirePermission = (requiredPermission) => {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return next(new AppError('UNAUTHORIZED'));
|
||||
}
|
||||
|
||||
const role = req.user.role;
|
||||
if (!role) {
|
||||
return next(new AppError('FORBIDDEN'));
|
||||
}
|
||||
|
||||
// Check if user is superAdmin (by role name or system status)
|
||||
if (role.name === 'superAdmin' || (role.isSystem && role.permissions.includes('*'))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if required permission is held by role
|
||||
if (Array.isArray(role.permissions) && role.permissions.includes(requiredPermission)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return next(new AppError('FORBIDDEN'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = requirePermission;
|
||||
module.exports.requires = requirePermission;
|
||||
Reference in New Issue
Block a user