Gestion des rencontres passées + interdiction des actions destructrices aux modérateurs
Rencontres passées (section « Ils y étaient » de l'accueil) : - Nouvel onglet « Rencontres passées » (admin + modérateur) : création, édition, jusqu'à 3 photos par rencontre (1 grande + 2 petites, upload après enregistrement — même schéma que la photo des rencontres à venir) - Nouvelles colonnes rencontres_passees.image_path/_2/_3 (migration ALTER TABLE IF NOT EXISTS) - Home.jsx affiche désormais les vraies photos uploadées (repli sur le motif placeholder si absentes) ; ordre passé de ORDER BY id à ORDER BY id DESC (les plus récentes en premier, seed réordonné en conséquence pour préserver l'affichage actuel) - Suppression réservée aux administrateurs Modérateurs : plus aucune action destructrice possible, imposé côté serveur (adminOnly) et masqué côté interface : - Validation/suspension d'un membre (toggle-valide) - Suppression d'une rencontre, d'une rencontre passée, d'une inscription Vérifié : 34 + 21 + 26 tests existants toujours au vert, 21 nouveaux tests (CRUD rencontres passées, upload des 3 emplacements photo, ordre d'affichage, restrictions modérateur sur chaque action désormais admin- only), parcours navigateur complet (upload des 3 photos, rendu réel sur la page d'accueil). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
11 files changed
+426
-55
No files matched your search
+117
-5
@@ -10,6 +10,8 @@ import {
|
||||
contentSchema,
|
||||
staffUserSchema,
|
||||
roleChangeSchema,
|
||||
rencontrePasseeSchema,
|
||||
pastEventImageParam,
|
||||
idParam,
|
||||
} from '../schemas.js';
|
||||
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
|
||||
@@ -37,7 +39,7 @@ const MEMBER_SQL = `
|
||||
LEFT JOIN users u ON u.member_id = m.id`;
|
||||
|
||||
const RENC_SQL = `
|
||||
SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places,
|
||||
SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, r.image_path,
|
||||
COUNT(i.id)::int AS inscrits
|
||||
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id`;
|
||||
|
||||
@@ -125,7 +127,10 @@ adminRouter.put('/members/:id', validate(idParam, 'params'), validate(adminMembe
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post('/members/:id/toggle-valide', validate(idParam, 'params'), async (req, res, next) => {
|
||||
// Suspending/validating a member's membership is a moderation decision
|
||||
// reserved to admins — moderators can create and edit members but not
|
||||
// block them.
|
||||
adminRouter.post('/members/:id/toggle-valide', adminOnly, validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(
|
||||
'UPDATE members SET valide = NOT valide, updated_at = now() WHERE id = $1 RETURNING valide',
|
||||
@@ -192,15 +197,36 @@ adminRouter.put('/rencontres/:id', validate(idParam, 'params'), validate(rencont
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.delete('/rencontres/:id', validate(idParam, 'params'), async (req, res, next) => {
|
||||
// Deleting is admin-only — moderators manage rencontres but can't destroy data.
|
||||
adminRouter.delete('/rencontres/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
await query('DELETE FROM rencontres WHERE id = $1', [req.params.id]);
|
||||
const result = await query('DELETE FROM rencontres WHERE id = $1 RETURNING image_path', [req.params.id]);
|
||||
if (result.rowCount > 0) await deleteImage(result.rows[0].image_path);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Photo shown on the "Prochaines rencontres" cards on the home page.
|
||||
adminRouter.post('/rencontres/:id/image', validate(idParam, 'params'), (req, res, next) => {
|
||||
imageUpload(req, res, async (err) => {
|
||||
if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' });
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' });
|
||||
const existing = await query('SELECT image_path FROM rencontres WHERE id = $1', [req.params.id]);
|
||||
if (existing.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
|
||||
const publicPath = await saveImage(req.file.buffer);
|
||||
if (!publicPath) return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' });
|
||||
await query('UPDATE rencontres SET image_path = $1 WHERE id = $2', [publicPath, req.params.id]);
|
||||
await deleteImage(existing.rows[0].image_path);
|
||||
res.json({ path: publicPath });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(`${INSCR_SQL} WHERE i.rencontre_id = $1 ORDER BY i.nom`, [req.params.id]);
|
||||
@@ -210,6 +236,90 @@ adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), asy
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Rencontres passées (admin + moderator manage; admin-only delete) ----------
|
||||
// Powers the "Rencontres passées" gallery on the home page.
|
||||
const PAST_SQL = `
|
||||
SELECT id, date_label, lieu, titre, texte, participants, nb_photos,
|
||||
image_path, image_path_2, image_path_3
|
||||
FROM rencontres_passees`;
|
||||
|
||||
adminRouter.get('/rencontres-passees', async (_req, res, next) => {
|
||||
try {
|
||||
const result = await query(`${PAST_SQL} ORDER BY id DESC`);
|
||||
res.json({ rencontresPassees: result.rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.post('/rencontres-passees', validate(rencontrePasseeSchema), async (req, res, next) => {
|
||||
try {
|
||||
const d = req.data;
|
||||
const result = await query(
|
||||
`INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[d.date_label, d.lieu, d.titre, d.texte, d.participants, d.nb_photos]
|
||||
);
|
||||
res.status(201).json({ id: result.rows[0].id });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.put('/rencontres-passees/:id', validate(idParam, 'params'), validate(rencontrePasseeSchema), async (req, res, next) => {
|
||||
try {
|
||||
const d = req.data;
|
||||
const result = await query(
|
||||
`UPDATE rencontres_passees SET date_label=$1, lieu=$2, titre=$3, texte=$4, participants=$5, nb_photos=$6
|
||||
WHERE id=$7 RETURNING id`,
|
||||
[d.date_label, d.lieu, d.titre, d.texte, d.participants, d.nb_photos, req.params.id]
|
||||
);
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre passée introuvable' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Deleting is admin-only, same reasoning as rencontres/inscriptions above.
|
||||
adminRouter.delete('/rencontres-passees/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
const result = await query(
|
||||
'DELETE FROM rencontres_passees WHERE id = $1 RETURNING image_path, image_path_2, image_path_3',
|
||||
[req.params.id]
|
||||
);
|
||||
if (result.rowCount > 0) {
|
||||
const row = result.rows[0];
|
||||
await Promise.all([deleteImage(row.image_path), deleteImage(row.image_path_2), deleteImage(row.image_path_3)]);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Three photo slots per past event (matches the home page's 1 large + 2
|
||||
// small grid). :slot is 1, 2 or 3.
|
||||
const PAST_IMAGE_COLUMNS = { 1: 'image_path', 2: 'image_path_2', 3: 'image_path_3' };
|
||||
adminRouter.post('/rencontres-passees/:id/image/:slot', validate(pastEventImageParam, 'params'), (req, res, next) => {
|
||||
imageUpload(req, res, async (err) => {
|
||||
if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' });
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' });
|
||||
const column = PAST_IMAGE_COLUMNS[req.params.slot];
|
||||
const existing = await query(`SELECT ${column} AS path FROM rencontres_passees WHERE id = $1`, [req.params.id]);
|
||||
if (existing.rowCount === 0) return res.status(404).json({ error: 'Rencontre passée introuvable' });
|
||||
const publicPath = await saveImage(req.file.buffer);
|
||||
if (!publicPath) return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' });
|
||||
await query(`UPDATE rencontres_passees SET ${column} = $1 WHERE id = $2`, [publicPath, req.params.id]);
|
||||
await deleteImage(existing.rows[0].path);
|
||||
res.json({ path: publicPath });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Inscriptions (admin + moderator) ----------
|
||||
adminRouter.get('/inscriptions', async (_req, res, next) => {
|
||||
try {
|
||||
@@ -248,7 +358,9 @@ adminRouter.post('/inscriptions/:id/confirm', validate(idParam, 'params'), async
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.delete('/inscriptions/:id', validate(idParam, 'params'), async (req, res, next) => {
|
||||
// Deleting (cancelling) an inscription is admin-only, same reasoning as
|
||||
// above — moderators edit and confirm, they don't destroy data.
|
||||
adminRouter.delete('/inscriptions/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => {
|
||||
try {
|
||||
await query('DELETE FROM inscriptions WHERE id = $1', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
|
||||
Reference in new issue
Block a user