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 };