const Achievement = require('../models/Achievement'); const { normalizeAchievementCreateInput, normalizeAchievementUpdateInput, } = require('../validation/achievementValidation'); const { ensureValidIdOrRespond, findByIdOrRespondNotFound } = require('../utils/controllerResponses'); const { asyncController } = require('../utils/asyncController'); const { saveOptimizedImage, removeStoredFile } = require('../services/mediaService'); 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); }; // GET /api/achievements const getAchievements = asyncController(async (req, res) => { const limit = req.query.limit !== undefined ? parseBoundedInt(req.query.limit, { min: 1, max: 5000, fallback: 1000 }) : undefined; const q = Achievement.find().sort({ tahun: -1 }); if (limit !== undefined) q.limit(limit); const achievements = await q.lean(); res.json(achievements); }); // GET /api/achievements/:id const getAchievementById = asyncController(async (req, res) => { if (!ensureValidIdOrRespond(res, req.params.id, 'achievement')) return; const achievement = await findByIdOrRespondNotFound({ Model: Achievement, id: req.params.id, res, notFoundMessage: 'Achievement not found', lean: true, }); if (!achievement) return; res.json(achievement); }); const ALLOWED_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/x-png']); const stripHtml = (value) => String(value || '').replace(/<[^>]*>/g, ''); // POST /api/achievements const createAchievement = asyncController(async (req, res) => { const payload = normalizeAchievementCreateInput(req.body); // Strip HTML from free-text fields payload.nama = stripHtml(payload.nama); payload.namaKejuaraan = stripHtml(payload.namaKejuaraan); payload.penyelenggara = stripHtml(payload.penyelenggara); payload.juara = stripHtml(payload.juara); const errors = {}; if (!payload.nama) errors.nama = 'Nama is required'; if (!payload.nrp) errors.nrp = 'NRP is required'; if (!payload.namaKejuaraan) errors.namaKejuaraan = 'Nama Kejuaraan is required'; if (!payload.penyelenggara) errors.penyelenggara = 'Penyelenggara is required'; if (!payload.juara) errors.juara = 'Juara is required'; if (!Number.isFinite(payload.tahun)) errors.tahun = 'Tahun is required and must be a number'; 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) { const photoPath = await saveOptimizedImage({ file: req.file, subdir: 'achievements', width: 800, height: 800, quality: 80, }); if (photoPath) payload.photo = photoPath; } const achievement = await Achievement.create(payload); res.status(201).json(achievement); }, { defaultStatus: 400 }); // PUT /api/achievements/:id const updateAchievement = asyncController(async (req, res) => { if (!ensureValidIdOrRespond(res, req.params.id, 'achievement')) return; const achievement = await findByIdOrRespondNotFound({ Model: Achievement, id: req.params.id, res, notFoundMessage: 'Achievement not found', }); if (!achievement) return; const payload = normalizeAchievementUpdateInput(req.body); // Strip HTML from free-text fields when present if (payload.nama !== undefined) payload.nama = stripHtml(payload.nama); if (payload.namaKejuaraan !== undefined) payload.namaKejuaraan = stripHtml(payload.namaKejuaraan); if (payload.penyelenggara !== undefined) payload.penyelenggara = stripHtml(payload.penyelenggara); if (payload.juara !== undefined) payload.juara = stripHtml(payload.juara); 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 oldPhoto = achievement.photo; const photoPath = await saveOptimizedImage({ file: req.file, subdir: 'achievements', width: 800, height: 800, quality: 80, }); if (photoPath) { payload.photo = photoPath; await removeStoredFile(oldPhoto); } } else if (req.body.removePhoto === 'true') { await removeStoredFile(achievement.photo); payload.photo = ''; } if (!Object.keys(payload).length) { return res.status(400).json({ message: 'No fields provided to update' }); } Object.assign(achievement, payload); const updated = await achievement.save(); res.json(updated); }, { defaultStatus: 400 }); // DELETE /api/achievements/:id const deleteAchievement = asyncController(async (req, res) => { if (!ensureValidIdOrRespond(res, req.params.id, 'achievement')) return; const achievement = await findByIdOrRespondNotFound({ Model: Achievement, id: req.params.id, res, notFoundMessage: 'Achievement not found', }); if (!achievement) return; await removeStoredFile(achievement.photo); await achievement.deleteOne(); res.json({ message: 'Achievement deleted' }); }); module.exports = { getAchievements, getAchievementById, createAchievement, updateAchievement, deleteAchievement };