const Announcement = require('../models/Announcement'); const { saveOptimizedImage, removeStoredFile } = require('../services/mediaService'); const { sanitizeText, isValidDateInput } = require('../validation/commonValidation'); const { isValidAnnouncementCategory, normalizeAnnouncementInput, parseBooleanInput } = require('../validation/announcementValidation'); const { ensureValidIdOrRespond, findByIdOrRespondNotFound } = require('../utils/controllerResponses'); const { asyncController } = require('../utils/asyncController'); const { emitAnnouncementUpdated } = require('../socket/socketServer'); const MAX_TITLE_LENGTH = 180; const MAX_DESCRIPTION_LENGTH = 2200; const ALLOWED_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/x-png']); const stripHtml = (value) => String(value || '').replace(/<[^>]*>/g, ''); const publishedOrLegacyFilter = { $or: [{ isPublished: true }, { isPublished: { $exists: false } }], }; const getAnnouncements = asyncController(async (req, res) => { const category = sanitizeText(req.query.category); const parsedLimit = Number.parseInt(String(req.query.limit ?? ''), 10); const includeUnpublished = req.user ? parseBooleanInput(req.query.includeUnpublished) === true : false; const published = parseBooleanInput(req.query.published); if (category && !isValidAnnouncementCategory(category)) return res.status(400).json({ message: 'Invalid category filter' }); let filter = category ? { category } : {}; if (published === true) { filter = { ...filter, ...publishedOrLegacyFilter }; } else if (published === false) { filter = { ...filter, isPublished: false }; } else if (!includeUnpublished) { filter = { ...filter, ...publishedOrLegacyFilter }; } const query = Announcement.find(filter).sort({ createdAt: -1 }).lean(); if (Number.isInteger(parsedLimit) && parsedLimit > 0) query.limit(Math.min(parsedLimit, 100)); res.json(await query); }); const getAnnouncementById = asyncController(async (req, res) => { if (!ensureValidIdOrRespond(res, req.params.id, 'announcement')) return; const announcement = await findByIdOrRespondNotFound({ Model: Announcement, id: req.params.id, res, notFoundMessage: 'Announcement not found', lean: true, }); if (!announcement) return; res.json(announcement); }); const createAnnouncement = asyncController(async (req, res) => { const payload = normalizeAnnouncementInput(req.body); // Strip HTML from free-text fields payload.title = stripHtml(payload.title); payload.description = stripHtml(payload.description); const errors = {}; if (!payload.title) errors.title = 'Title is required'; if (!payload.date) errors.date = 'Date is required'; else if (!isValidDateInput(payload.date)) errors.date = 'Date must use a valid YYYY-MM-DD format'; if (payload.title && payload.title.length > MAX_TITLE_LENGTH) errors.title = `Title must be ${MAX_TITLE_LENGTH} characters or less`; if (payload.description && payload.description.length > MAX_DESCRIPTION_LENGTH) errors.description = `Description must be ${MAX_DESCRIPTION_LENGTH} characters or less`; if (payload.category && !isValidAnnouncementCategory(payload.category)) errors.category = 'Invalid category'; if (Object.keys(errors).length > 0) { return res.status(400).json({ error: 'Validation failed', fields: errors }); } 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) { payload.photo = await saveOptimizedImage({ file: req.file, subdir: 'announcements', width: 1200, height: 900, quality: 80, }); } const announcement = await Announcement.create(payload); emitAnnouncementUpdated({ action: 'created', id: String(announcement._id) }); res.status(201).json(announcement); }, { defaultStatus: 400 }); const updateAnnouncement = asyncController(async (req, res) => { if (!ensureValidIdOrRespond(res, req.params.id, 'announcement')) return; const announcement = await findByIdOrRespondNotFound({ Model: Announcement, id: req.params.id, res, notFoundMessage: 'Announcement not found', }); if (!announcement) return; const payload = normalizeAnnouncementInput(req.body, { partial: true }); 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.title !== undefined) payload.title = stripHtml(payload.title); if (payload.description !== undefined) payload.description = stripHtml(payload.description); const errors = {}; if (payload.title !== undefined && !payload.title) errors.title = 'Title is required'; if (payload.date !== undefined && !payload.date) errors.date = 'Date is required'; else if (payload.date !== undefined && !isValidDateInput(payload.date)) errors.date = 'Date must use a valid YYYY-MM-DD format'; if (payload.title !== undefined && payload.title && payload.title.length > MAX_TITLE_LENGTH) errors.title = `Title must be ${MAX_TITLE_LENGTH} characters or less`; if (payload.description !== undefined && payload.description.length > MAX_DESCRIPTION_LENGTH) errors.description = `Description must be ${MAX_DESCRIPTION_LENGTH} characters or less`; if (payload.category !== undefined && !isValidAnnouncementCategory(payload.category)) errors.category = 'Invalid category'; if (Object.keys(errors).length > 0) { return res.status(400).json({ error: 'Validation failed', fields: errors }); } if (req.file && !ALLOWED_MIME.has(req.file.mimetype)) { return res.status(415).json({ message: 'Unsupported file type. Use JPG, PNG, or WEBP.' }); } Object.assign(announcement, payload); if (req.file) { await removeStoredFile(announcement.photo); announcement.photo = await saveOptimizedImage({ file: req.file, subdir: 'announcements', width: 1200, height: 900, quality: 80, }); } const updated = await announcement.save(); emitAnnouncementUpdated({ action: 'updated', id: String(updated._id) }); res.json(updated); }, { defaultStatus: 400 }); const deleteAnnouncement = asyncController(async (req, res) => { if (!ensureValidIdOrRespond(res, req.params.id, 'announcement')) return; const announcement = await findByIdOrRespondNotFound({ Model: Announcement, id: req.params.id, res, notFoundMessage: 'Announcement not found', }); if (!announcement) return; const deletedId = String(announcement._id); await removeStoredFile(announcement.photo); await announcement.deleteOne(); emitAnnouncementUpdated({ action: 'deleted', id: deletedId }); res.json({ message: 'Announcement deleted' }); }); module.exports = { getAnnouncements, getAnnouncementById, createAnnouncement, updateAnnouncement, deleteAnnouncement };