Spaces:
Running
Running
| 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 }; | |