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:
Claude committed 2026-07-13 14:58:32 +00:00
1 parent fab64bc38a
commit 214250bc39
11 files changed
+426 -55

No files matched your search

+117 -5
View File
@@ -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 });