tinfo-web-server / controllers /facultyController.js
Faridaqurr
initial backend
e8c33fa
Raw
History Blame Contribute Delete
11.7 kB
const Faculty = require('../models/Faculty');
const { saveOptimizedImage, removeStoredFile } = require('../services/mediaService');
const {
normalizeFacultyCreateInput,
normalizeFacultyUpdateInput,
getSingleAssignmentPositionKey,
getSingleAssignmentPositionLabel,
getSingleAssignmentPositionRegex,
} = require('../validation/facultyValidation');
const { isValidPersonName } = require('../validation/commonValidation');
const { ensureValidIdOrRespond, findByIdOrRespondNotFound } = require('../utils/controllerResponses');
const { asyncController } = require('../utils/asyncController');
const parseBoundedInt = (value, { min, max, fallback }) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
};
const escapeRegex = (value = '') => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const isMissingTextIndexError = (err) => {
const message = String(err?.message || '').toLowerCase();
return message.includes('text index') && (message.includes('required') || message.includes('not found'));
};
const MAX_NAME_LENGTH = 150;
const MAX_POSITION_LENGTH = 120;
const MAX_SPECIALIZATION_LENGTH = 180;
const MAX_EMAIL_LENGTH = 120;
const MAX_SCHOLAR_PROFILE_LENGTH = 500;
const ALLOWED_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/x-png']);
const stripHtml = (value) => String(value || '').replace(/<[^>]*>/g, '');
const isValidHttpUrl = (value = '') => {
if (!value) return true;
try {
const parsed = new URL(value);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
};
const findSingleAssignmentPositionConflict = async ({ position, excludeId } = {}) => {
const key = getSingleAssignmentPositionKey(position);
if (!key) return null;
const pattern = getSingleAssignmentPositionRegex(position);
if (!pattern) return null;
const filter = { position: { $regex: pattern } };
if (excludeId) {
filter._id = { $ne: excludeId };
}
const existing = await Faculty.findOne(filter).select('_id name position').lean();
if (!existing) return null;
return {
...existing,
key,
label: getSingleAssignmentPositionLabel(position) || existing.position,
};
};
const getFaculty = asyncController(async (req, res) => {
const hasPaginationQuery = req.query.page !== undefined || req.query.pageSize !== undefined;
const legacyLimit = parseBoundedInt(req.query.limit, { min: 1, max: 200, fallback: 120 });
const page = parseBoundedInt(req.query.page, { min: 1, max: 5000, fallback: 1 });
const pageSize = parseBoundedInt(req.query.pageSize, { min: 1, max: 100, fallback: 25 });
const rawSearch = String(req.query.search || '').trim();
const search = rawSearch.slice(0, 120);
const searchRegex = search ? new RegExp(escapeRegex(search), 'i') : null;
const fallbackFilter = search
? {
$or: [
{ name: { $regex: searchRegex } },
{ position: { $regex: searchRegex } },
{ specializations: { $regex: searchRegex } },
{ email: { $regex: searchRegex } },
],
}
: {};
const textFilter = search.length >= 2 ? { $text: { $search: search } } : null;
const findMany = (filter, { limit, skip = 0 } = {}) => {
const query = Faculty.find(filter)
.sort({ createdAt: 1 })
.skip(skip)
.limit(limit)
.lean();
return query;
};
if (!hasPaginationQuery) {
let faculty = [];
if (textFilter) {
try {
faculty = await findMany(textFilter, { limit: legacyLimit });
} catch (err) {
if (!isMissingTextIndexError(err)) throw err;
}
}
if (!faculty.length) {
faculty = await findMany(fallbackFilter, { limit: legacyLimit });
}
res.json(faculty);
return;
}
const skip = (page - 1) * pageSize;
let total = 0;
let items = [];
if (textFilter) {
try {
[total, items] = await Promise.all([
Faculty.countDocuments(textFilter),
findMany(textFilter, { skip, limit: pageSize }),
]);
} catch (err) {
if (!isMissingTextIndexError(err)) throw err;
}
}
if (!total) {
[total, items] = await Promise.all([
Faculty.countDocuments(fallbackFilter),
findMany(fallbackFilter, { skip, limit: pageSize }),
]);
}
res.json({
items,
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
});
});
const getFacultyById = asyncController(async (req, res) => {
if (!ensureValidIdOrRespond(res, req.params.id, 'faculty')) return;
const member = await findByIdOrRespondNotFound({
Model: Faculty,
id: req.params.id,
res,
notFoundMessage: 'Faculty member not found',
lean: true,
});
if (!member) return;
res.json(member);
});
const createFaculty = asyncController(async (req, res) => {
const payload = normalizeFacultyCreateInput(req.body);
// Strip HTML from free-text fields
payload.name = stripHtml(payload.name);
const errors = {};
if (!payload.name) errors.name = 'Name is required';
if (!payload.position) errors.position = 'Position is required';
if (payload.name && !isValidPersonName(payload.name)) errors.name = 'Name must contain letters only (no numbers)';
if (payload.name && payload.name.length > MAX_NAME_LENGTH) errors.name = `Name must be ${MAX_NAME_LENGTH} characters or less`;
if (payload.position && payload.position.length > MAX_POSITION_LENGTH) errors.position = `Position must be ${MAX_POSITION_LENGTH} characters or less`;
if (payload.specializations.some((value) => value.length > MAX_SPECIALIZATION_LENGTH)) errors.specializations = `Each specialization must be ${MAX_SPECIALIZATION_LENGTH} characters or less`;
if (payload.email.some((value) => value.length > MAX_EMAIL_LENGTH)) errors.email = `Each email must be ${MAX_EMAIL_LENGTH} characters or less`;
if (payload.scholarProfile.length > MAX_SCHOLAR_PROFILE_LENGTH) errors.scholarProfile = `Scholar profile URL must be ${MAX_SCHOLAR_PROFILE_LENGTH} characters or less`;
if (payload.scholarProfile && !isValidHttpUrl(payload.scholarProfile)) errors.scholarProfile = 'Scholar profile URL must start with http:// or https://';
if (Object.keys(errors).length > 0) {
return res.status(400).json({ error: 'Validation failed', fields: errors });
}
// Check for duplicate email before insert
if (payload.email.length > 0) {
const emailConflict = await Faculty.findOne({ email: { $in: payload.email } }).select('_id name').lean();
if (emailConflict) {
return res.status(409).json({
error: 'Validation failed',
fields: { email: 'A faculty member with this email already exists.' },
});
}
}
const conflict = await findSingleAssignmentPositionConflict({ position: payload.position });
if (conflict) {
return res.status(409).json({
message: `Only one faculty member can be assigned as ${conflict.label}. Current assignment: ${conflict.name}.`,
});
}
if (req.file && !ALLOWED_MIME.has(req.file.mimetype)) {
return res.status(415).json({ message: 'Unsupported file type. Use JPG, PNG, or WEBP.' });
}
const photo = req.file
? await saveOptimizedImage({
file: req.file,
subdir: 'faculty',
width: 1200,
height: 1200,
quality: 78,
})
: '';
const member = await Faculty.create({
name: payload.name,
position: payload.position,
specializations: payload.specializations,
email: payload.email,
photo,
scholarProfile: payload.scholarProfile,
});
res.status(201).json(member);
}, { defaultStatus: 400 });
const updateFaculty = asyncController(async (req, res) => {
if (!ensureValidIdOrRespond(res, req.params.id, 'faculty')) return;
const member = await findByIdOrRespondNotFound({
Model: Faculty,
id: req.params.id,
res,
notFoundMessage: 'Faculty member not found',
});
if (!member) return;
const payload = normalizeFacultyUpdateInput(req.body);
if (!Object.keys(payload).length && !req.file) {
return res.status(400).json({ message: 'No fields provided to update' });
}
// Strip HTML from free-text fields when present
if (payload.name !== undefined) payload.name = stripHtml(payload.name);
const errors = {};
if (payload.name !== undefined && !isValidPersonName(payload.name)) errors.name = 'Name must contain letters only (no numbers)';
if (payload.name !== undefined && payload.name.length > MAX_NAME_LENGTH) errors.name = `Name must be ${MAX_NAME_LENGTH} characters or less`;
if (payload.position !== undefined && payload.position.length > MAX_POSITION_LENGTH) errors.position = `Position must be ${MAX_POSITION_LENGTH} characters or less`;
if (payload.specializations !== undefined && payload.specializations.some((value) => value.length > MAX_SPECIALIZATION_LENGTH)) errors.specializations = `Each specialization must be ${MAX_SPECIALIZATION_LENGTH} characters or less`;
if (payload.email !== undefined && payload.email.some((value) => value.length > MAX_EMAIL_LENGTH)) errors.email = `Each email must be ${MAX_EMAIL_LENGTH} characters or less`;
if (payload.scholarProfile !== undefined && payload.scholarProfile.length > MAX_SCHOLAR_PROFILE_LENGTH) errors.scholarProfile = `Scholar profile URL must be ${MAX_SCHOLAR_PROFILE_LENGTH} characters or less`;
if (payload.scholarProfile !== undefined && payload.scholarProfile && !isValidHttpUrl(payload.scholarProfile)) errors.scholarProfile = 'Scholar profile URL must start with http:// or https://';
if (Object.keys(errors).length > 0) {
return res.status(400).json({ error: 'Validation failed', fields: errors });
}
// Check for duplicate email on update (exclude this member's own record)
if (payload.email !== undefined && payload.email.length > 0) {
const emailConflict = await Faculty.findOne({
email: { $in: payload.email },
_id: { $ne: member._id },
}).select('_id name').lean();
if (emailConflict) {
return res.status(409).json({
error: 'Validation failed',
fields: { email: 'A faculty member with this email already exists.' },
});
}
}
if (payload.position !== undefined) {
const conflict = await findSingleAssignmentPositionConflict({
position: payload.position,
excludeId: member._id,
});
if (conflict) {
return res.status(409).json({
message: `Only one faculty member can be assigned as ${conflict.label}. Current assignment: ${conflict.name}.`,
});
}
}
if (req.file && !ALLOWED_MIME.has(req.file.mimetype)) {
return res.status(415).json({ message: 'Unsupported file type. Use JPG, PNG, or WEBP.' });
}
if (req.file) {
const nextPhoto = await saveOptimizedImage({
file: req.file,
subdir: 'faculty',
width: 1200,
height: 1200,
quality: 78,
});
if (nextPhoto) {
await removeStoredFile(member.photo);
member.photo = nextPhoto;
}
}
Object.assign(member, payload);
const updated = await member.save();
res.json(updated);
}, { defaultStatus: 400 });
const deleteFaculty = asyncController(async (req, res) => {
if (!ensureValidIdOrRespond(res, req.params.id, 'faculty')) return;
const member = await findByIdOrRespondNotFound({
Model: Faculty,
id: req.params.id,
res,
notFoundMessage: 'Faculty member not found',
});
if (!member) return;
await removeStoredFile(member.photo);
await member.deleteOne();
res.json({ message: 'Faculty member removed' });
});
module.exports = { getFaculty, getFacultyById, createFaculty, updateFaculty, deleteFaculty };