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