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 });
+2 -2
View File
@@ -19,13 +19,13 @@ publicRouter.get('/bootstrap', async (_req, res, next) => {
WHERE m.valide = true ORDER BY m.nom`
),
query(
`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
WHERE r.date_renc >= CURRENT_DATE
GROUP BY r.id ORDER BY r.date_renc`
),
query('SELECT * FROM rencontres_passees ORDER BY id'),
query('SELECT * FROM rencontres_passees ORDER BY id DESC'),
]);
const contentMap = Object.fromEntries(content.rows.map((r) => [r.key, r.value]));
res.json({
+14
View File
@@ -66,6 +66,20 @@ export const inscriptionAdminSchema = z.object({
statut: z.enum(['confirmee', 'en_attente']),
});
export const rencontrePasseeSchema = z.object({
date_label: trimmed(60, 1),
lieu: trimmed(200, 1),
titre: trimmed(200, 1),
texte: trimmed(2000, 1),
participants: z.coerce.number().int().min(0).max(100000),
nb_photos: z.coerce.number().int().min(0).max(100000),
});
export const pastEventImageParam = z.object({
id: z.coerce.number().int().positive(),
slot: z.coerce.number().int().min(1).max(3),
});
export const categorySchema = z.object({ name: trimmed(80, 1) });
export const staffRoleEnum = z.enum(['admin', 'moderator']);
+10 -1
View File
@@ -56,8 +56,10 @@ CREATE TABLE IF NOT EXISTS rencontres (
lieu TEXT NOT NULL DEFAULT '' CHECK (char_length(lieu) <= 200),
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
places INTEGER NOT NULL CHECK (places >= 0 AND places <= 100000),
image_path TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE rencontres ADD COLUMN IF NOT EXISTS image_path TEXT;
CREATE TABLE IF NOT EXISTS inscriptions (
id SERIAL PRIMARY KEY,
@@ -78,8 +80,15 @@ CREATE TABLE IF NOT EXISTS rencontres_passees (
titre TEXT NOT NULL,
texte TEXT NOT NULL,
participants INTEGER NOT NULL DEFAULT 0,
nb_photos INTEGER NOT NULL DEFAULT 0
nb_photos INTEGER NOT NULL DEFAULT 0,
-- 1 large + 2 small photos, matching the home page grid.
image_path TEXT,
image_path_2 TEXT,
image_path_3 TEXT
);
ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS image_path TEXT;
ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS image_path_2 TEXT;
ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS image_path_3 TEXT;
CREATE TABLE IF NOT EXISTS demandes_adhesion (
id SERIAL PRIMARY KEY,
+4 -2
View File
@@ -40,10 +40,12 @@ INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut) VAL
((SELECT id FROM rencontres WHERE titre = 'Afterwork Business & Basket'), 'Émilie Colin', 'Cristal Événements', 'e.colin@cristal-events.fr', '+33 3 83 38 66 12', 'confirmee'),
((SELECT id FROM rencontres WHERE titre = 'Conférence — L''esprit d''équipe'), 'Philippe Aubry', 'Groupe Batigest', 'p.aubry@batigest.fr', '+33 3 83 22 18 04', 'confirmee');
-- Inserted oldest-first: the home page and admin list show these newest
-- first (ORDER BY id DESC), so insertion order must be chronological.
INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos) VALUES
('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210, 48),
('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56, 15),
('Avril 2026', 'Site industriel, Florange', 'Visite privée — Usine ArcelorMittal', 'Immersion industrielle exclusive pour nos membres au cœur d''un fleuron de la métallurgie lorraine, suivie d''un échange avec la direction du site.', 34, 22),
('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56, 15);
('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210, 48);
INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES
('Marc Dubois', 'Directeur général', 'Dubois Traiteur', 'm.dubois@dubois-traiteur.fr'),