Files
SBC/server/src/routes/admin.js
T

640 lines
26 KiB
JavaScript

import { Router } from 'express';
import { query } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import {
adminMemberSchema,
rencontreSchema,
inscriptionAdminSchema,
categorySchema,
contentSchema,
staffUserSchema,
roleChangeSchema,
rencontrePasseeSchema,
pastPhotoParam,
emailComposeSchema,
customEmailSchema,
idParam,
} from '../schemas.js';
import { imageUpload, saveImage, deleteImage } from '../uploads.js';
import { ensureMemberAccess } from '../memberAccess.js';
import { createStaffUser, resetStaffAccess } from '../userAccess.js';
import { AccessError } from '../errors.js';
import { buildInvitationEmail } from '../emailTemplate.js';
import { buildCustomEmail } from '../customEmailTemplate.js';
export const adminRouter = Router();
// Every route below requires at least an authenticated staff session
// (admin or moderator); individual routes further restrict to admin-only
// where noted.
adminRouter.use(requireAuth(['admin', 'moderator']));
const adminOnly = requireAuth('admin');
const MEMBER_SQL = `
SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant,
m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide,
m.logo_path, m.photo_path,
(u.id IS NOT NULL) AS has_login,
COALESCE(u.must_change_password, false) AS must_change_password,
CASE WHEN u.must_change_password THEN u.temp_password ELSE NULL END AS temp_password
FROM members m
LEFT JOIN categories c ON c.id = m.categorie_id
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,
r.participants_par_compte, r.image_path,
COUNT(i.id)::int AS inscrits
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id`;
const INSCR_SQL = `
SELECT i.id, i.nom, i.entreprise, i.email, i.tel, i.statut, i.created_at,
i.rencontre_id, r.titre AS rencontre
FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`;
// ---------- Dashboard (admin only) ----------
adminRouter.get('/dashboard', adminOnly, async (_req, res, next) => {
try {
const [kpis, latest, upcoming] = await Promise.all([
query(`
SELECT
(SELECT COUNT(*)::int FROM members WHERE valide) AS membres_valides,
(SELECT COUNT(*)::int FROM members WHERE NOT valide) AS a_renouveler,
(SELECT COUNT(*)::int FROM rencontres WHERE date_renc >= CURRENT_DATE) AS rencontres_a_venir,
(SELECT COUNT(*)::int FROM inscriptions WHERE statut = 'en_attente') AS inscriptions_attente,
(SELECT COUNT(*)::int FROM demandes_adhesion WHERE statut = 'nouvelle') AS demandes_nouvelles,
(SELECT MIN(date_renc) FROM rencontres WHERE date_renc >= CURRENT_DATE) AS prochaine_date
`),
query(`${INSCR_SQL} ORDER BY i.created_at DESC, i.id DESC LIMIT 5`),
query(`${RENC_SQL} WHERE r.date_renc >= CURRENT_DATE GROUP BY r.id ORDER BY r.date_renc LIMIT 5`),
]);
res.json({ kpis: kpis.rows[0], latestInscriptions: latest.rows, upcoming: upcoming.rows });
} catch (err) {
next(err);
}
});
// ---------- Members (admin + moderator) ----------
adminRouter.get('/members', async (_req, res, next) => {
try {
const result = await query(`${MEMBER_SQL} ORDER BY m.nom`);
res.json({ members: result.rows });
} catch (err) {
next(err);
}
});
adminRouter.post('/members', validate(adminMemberSchema), async (req, res, next) => {
try {
const d = req.data;
const result = await query(
`INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, presentation, valide)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, new Date().getFullYear(),
d.email || null, d.tel, d.site, d.presentation, d.valide ?? true]
);
const id = result.rows[0].id;
// A login account (with a temporary password) is only created when an
// email is provided — it's the login identifier.
let tempPassword = null;
let accessError = null;
if (d.email) {
try {
tempPassword = await ensureMemberAccess(id, d.email);
} catch (err) {
if (!(err instanceof AccessError)) throw err;
// Member created, but the email already belongs to another login —
// surface it without failing the whole creation.
accessError = err.message;
}
}
res.status(201).json({ id, tempPassword, accessError });
} catch (err) {
next(err);
}
});
adminRouter.put('/members/:id', validate(idParam, 'params'), validate(adminMemberSchema), async (req, res, next) => {
try {
const d = req.data;
const result = await query(
`UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5,
tel=$6, site=$7, presentation=$8, valide=COALESCE($9, valide), updated_at=now()
WHERE id=$10 RETURNING id`,
[d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, d.email || null,
d.tel, d.site, d.presentation, d.valide ?? null, req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// 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',
[req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' });
res.json({ valide: result.rows[0].valide });
} catch (err) {
next(err);
}
});
// Creates the member's login if it doesn't have one yet, or resets its
// password (e.g. the member lost it) — either way returns a fresh
// temporary password that must be changed at next login.
adminRouter.post('/members/:id/reset-access', validate(idParam, 'params'), async (req, res, next) => {
try {
const member = await query('SELECT email FROM members WHERE id = $1', [req.params.id]);
if (member.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' });
const tempPassword = await ensureMemberAccess(req.params.id, member.rows[0].email);
res.json({ tempPassword });
} catch (err) {
if (err instanceof AccessError) return res.status(err.status).json({ error: err.message });
next(err);
}
});
// ---------- Rencontres (admin + moderator) ----------
adminRouter.get('/rencontres', async (_req, res, next) => {
try {
const result = await query(`${RENC_SQL} GROUP BY r.id ORDER BY r.date_renc`);
res.json({ rencontres: result.rows });
} catch (err) {
next(err);
}
});
adminRouter.post('/rencontres', validate(rencontreSchema), async (req, res, next) => {
try {
const d = req.data;
const result = await query(
`INSERT INTO rencontres (titre, date_renc, heure, lieu, description, places, participants_par_compte)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places, d.participants_par_compte]
);
res.status(201).json({ id: result.rows[0].id });
} catch (err) {
next(err);
}
});
adminRouter.put('/rencontres/:id', validate(idParam, 'params'), validate(rencontreSchema), async (req, res, next) => {
try {
const d = req.data;
const result = await query(
`UPDATE rencontres SET titre=$1, date_renc=$2, heure=$3, lieu=$4, description=$5,
places=$6, participants_par_compte=$7
WHERE id=$8 RETURNING id`,
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places,
d.participants_par_compte, req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// 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 {
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);
}
});
});
// Ready-to-send HTML invitation email for a rencontre (admin + moderator).
// The registration link opens the public site with the inscription modal
// pre-opened (/?inscription=<id>). The body carries the editable text
// zones (greeting/intro/outro/signature — omitted = default wording,
// empty = block hidden) plus `base`: URLs must be absolute for email
// clients, and the frontend's window.location.origin is exactly the
// public address the admin is browsing — reliable even behind a
// TLS-terminating reverse proxy where req.protocol would say "http".
// The request's host is only a fallback.
adminRouter.post('/rencontres/:id/email', validate(idParam, 'params'), validate(emailComposeSchema), async (req, res, next) => {
try {
const result = await query(`${RENC_SQL} WHERE r.id = $1 GROUP BY r.id`, [req.params.id]);
if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
const { base, ...texts } = req.data;
let baseUrl = `${req.protocol}://${req.get('host')}`;
if (base) {
try {
const u = new URL(base);
if (u.protocol === 'http:' || u.protocol === 'https:') baseUrl = u.origin;
} catch {
// invalid base — keep the fallback
}
}
res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl, texts }));
} catch (err) {
next(err);
}
});
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]);
res.json({ inscriptions: result.rows });
} catch (err) {
next(err);
}
});
// General-purpose branded email composer, reserved to administrators.
adminRouter.post('/emails/preview', adminOnly, validate(customEmailSchema), async (req, res, next) => {
try {
const { base, ...content } = req.data;
let baseUrl = `${req.protocol}://${req.get('host')}`;
if (base) {
const parsed = new URL(base);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') baseUrl = parsed.origin;
}
res.json(buildCustomEmail({ content, baseUrl }));
} catch (err) {
next(err);
}
});
// ---------- Rencontres passées (admin + moderator manage; admin-only delete) ----------
// Powers the "Rencontres passées" section on the home page and its dedicated
// public page. Participant count is derived from the linked rencontre's
// inscriptions when set, otherwise the manual fallback. Photo count and the
// photo list come from the rencontre_passee_photos gallery.
const PAST_SQL = `
SELECT rp.id, rp.date_label, rp.lieu, rp.titre, rp.texte, rp.participants, rp.rencontre_id,
CASE WHEN rp.rencontre_id IS NOT NULL THEN COALESCE(rc.inscrits, 0) ELSE rp.participants END AS participants_display,
COALESCE(ph.n, 0) AS nb_photos,
COALESCE(ph.photos, '[]'::json) AS photos
FROM rencontres_passees rp
LEFT JOIN (
SELECT rencontre_passee_id, COUNT(*)::int AS n,
json_agg(json_build_object('id', id, 'path', image_path) ORDER BY position, id) AS photos
FROM rencontre_passee_photos GROUP BY rencontre_passee_id
) ph ON ph.rencontre_passee_id = rp.id
LEFT JOIN (
SELECT rencontre_id, COUNT(*)::int AS inscrits FROM inscriptions GROUP BY rencontre_id
) rc ON rc.rencontre_id = rp.rencontre_id`;
adminRouter.get('/rencontres-passees', async (_req, res, next) => {
try {
const result = await query(`${PAST_SQL} ORDER BY rp.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, rencontre_id)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[d.date_label, d.lieu, d.titre, d.texte, d.participants ?? 0, d.rencontre_id ?? null]
);
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, rencontre_id=$6
WHERE id=$7 RETURNING id`,
[d.date_label, d.lieu, d.titre, d.texte, d.participants ?? 0, d.rencontre_id ?? null, 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.
// ON DELETE CASCADE removes the photo rows; we delete their files first.
adminRouter.delete('/rencontres-passees/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => {
try {
const photos = await query('SELECT image_path FROM rencontre_passee_photos WHERE rencontre_passee_id = $1', [req.params.id]);
await query('DELETE FROM rencontres_passees WHERE id = $1', [req.params.id]);
await Promise.all(photos.rows.map((p) => deleteImage(p.image_path)));
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// Add a photo to a past event's gallery (unlimited). Admin + moderator.
adminRouter.post('/rencontres-passees/:id/photos', 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 event = await query('SELECT 1 FROM rencontres_passees WHERE id = $1', [req.params.id]);
if (event.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.' });
const result = await query(
`INSERT INTO rencontre_passee_photos (rencontre_passee_id, image_path, position)
VALUES ($1, $2, COALESCE((SELECT MAX(position) + 1 FROM rencontre_passee_photos WHERE rencontre_passee_id = $1), 0))
RETURNING id`,
[req.params.id, publicPath]
);
res.status(201).json({ id: result.rows[0].id, path: publicPath });
} catch (e) {
next(e);
}
});
});
// Promote a photo to first position (the large one on the home page). Admin + moderator.
adminRouter.post('/rencontres-passees/:id/photos/:photoId/principale', validate(pastPhotoParam, 'params'), async (req, res, next) => {
try {
const result = await query(
`UPDATE rencontre_passee_photos
SET position = COALESCE((SELECT MIN(position) FROM rencontre_passee_photos WHERE rencontre_passee_id = $1), 0) - 1
WHERE id = $2 AND rencontre_passee_id = $1 RETURNING id`,
[req.params.id, req.params.photoId]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Photo introuvable' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// Removing a photo is a deletion — admin only.
adminRouter.delete('/rencontres-passees/:id/photos/:photoId', adminOnly, validate(pastPhotoParam, 'params'), async (req, res, next) => {
try {
const result = await query(
'DELETE FROM rencontre_passee_photos WHERE id = $1 AND rencontre_passee_id = $2 RETURNING image_path',
[req.params.photoId, req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Photo introuvable' });
await deleteImage(result.rows[0].image_path);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// ---------- Inscriptions (admin + moderator) ----------
adminRouter.get('/inscriptions', async (_req, res, next) => {
try {
const result = await query(`${INSCR_SQL} ORDER BY i.created_at DESC, i.id DESC`);
res.json({ inscriptions: result.rows });
} catch (err) {
next(err);
}
});
adminRouter.put('/inscriptions/:id', validate(idParam, 'params'), validate(inscriptionAdminSchema), async (req, res, next) => {
try {
const d = req.data;
const result = await query(
`UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4, rencontre_id=$5, statut=$6
WHERE id=$7 RETURNING id`,
[d.nom, d.entreprise, d.email || null, d.tel, d.rencontre_id, d.statut, req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Inscription introuvable' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
adminRouter.post('/inscriptions/:id/confirm', validate(idParam, 'params'), async (req, res, next) => {
try {
const result = await query(
`UPDATE inscriptions SET statut = 'confirmee' WHERE id = $1 RETURNING id`,
[req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Inscription introuvable' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// 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 });
} catch (err) {
next(err);
}
});
// ---------- Categories ----------
// Read access is shared with moderators: the member form's category
// dropdown needs it. Managing categories (create/rename/delete) stays
// admin-only.
adminRouter.get('/categories', async (_req, res, next) => {
try {
const result = await query(`
SELECT c.id, c.name, COUNT(m.id)::int AS count
FROM categories c LEFT JOIN members m ON m.categorie_id = c.id
GROUP BY c.id ORDER BY c.id`);
res.json({ categories: result.rows });
} catch (err) {
next(err);
}
});
adminRouter.post('/categories', adminOnly, validate(categorySchema), async (req, res, next) => {
try {
const result = await query(
'INSERT INTO categories (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id',
[req.data.name]
);
if (result.rowCount === 0) return res.status(409).json({ error: 'Cette catégorie existe déjà.' });
res.status(201).json({ id: result.rows[0].id });
} catch (err) {
next(err);
}
});
adminRouter.put('/categories/:id', adminOnly, validate(idParam, 'params'), validate(categorySchema), async (req, res, next) => {
try {
const result = await query('UPDATE categories SET name = $1 WHERE id = $2 RETURNING id', [
req.data.name,
req.params.id,
]);
if (result.rowCount === 0) return res.status(404).json({ error: 'Catégorie introuvable' });
res.json({ ok: true });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Ce nom existe déjà.' });
next(err);
}
});
adminRouter.delete('/categories/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => {
try {
// members.categorie_id has ON DELETE SET NULL: they become "Non classée"
await query('DELETE FROM categories WHERE id = $1', [req.params.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// ---------- Site content (admin only) ----------
adminRouter.put('/content', adminOnly, validate(contentSchema), async (req, res, next) => {
try {
const entries = Object.entries(req.data).filter(([, v]) => v !== undefined);
for (const [key, value] of entries) {
await query(
`INSERT INTO site_content (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[key, value]
);
}
res.json({ ok: true });
} catch (err) {
next(err);
}
});
adminRouter.post('/content/hero-photo', adminOnly, (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 publicPath = await saveImage(req.file.buffer);
if (!publicPath) return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' });
const prev = await query(`SELECT value FROM site_content WHERE key = 'hero_photo'`);
await query(
`INSERT INTO site_content (key, value) VALUES ('hero_photo', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[publicPath]
);
await deleteImage(prev.rows[0]?.value);
res.json({ path: publicPath });
} catch (e) {
next(e);
}
});
});
// ---------- Demandes d'adhésion (admin only) ----------
adminRouter.get('/demandes', adminOnly, async (_req, res, next) => {
try {
const result = await query('SELECT * FROM demandes_adhesion ORDER BY created_at DESC');
res.json({ demandes: result.rows });
} catch (err) {
next(err);
}
});
// ---------- Admin & moderator accounts (admin only) ----------
adminRouter.get('/users', adminOnly, async (req, res, next) => {
try {
const result = await query(`
SELECT id, email, full_name, role, must_change_password,
CASE WHEN must_change_password THEN temp_password ELSE NULL END AS temp_password,
created_at
FROM users WHERE role IN ('admin', 'moderator')
ORDER BY role, full_name, email`);
res.json({ users: result.rows.map((u) => ({ ...u, is_self: u.id === req.user.sub })) });
} catch (err) {
next(err);
}
});
adminRouter.post('/users', adminOnly, validate(staffUserSchema), async (req, res, next) => {
try {
const { fullName, email, role } = req.data;
const { id, tempPassword } = await createStaffUser({ fullName, email, role });
res.status(201).json({ id, tempPassword });
} catch (err) {
if (err instanceof AccessError) return res.status(err.status).json({ error: err.message });
next(err);
}
});
adminRouter.post('/users/:id/reset-access', adminOnly, validate(idParam, 'params'), async (req, res, next) => {
try {
const tempPassword = await resetStaffAccess(req.params.id);
res.json({ tempPassword });
} catch (err) {
if (err instanceof AccessError) return res.status(err.status).json({ error: err.message });
next(err);
}
});
adminRouter.put('/users/:id/role', adminOnly, validate(idParam, 'params'), validate(roleChangeSchema), async (req, res, next) => {
try {
if (req.params.id === req.user.sub) {
return res.status(400).json({ error: 'Vous ne pouvez pas modifier votre propre rôle.' });
}
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]);
if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' });
if (target.rows[0].role === 'admin' && req.data.role !== 'admin') {
const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`);
if (adminCount.rows[0].n <= 1) {
return res.status(400).json({ error: 'Impossible : il doit rester au moins un administrateur.' });
}
}
await query('UPDATE users SET role = $1 WHERE id = $2', [req.data.role, req.params.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
adminRouter.delete('/users/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => {
try {
if (req.params.id === req.user.sub) {
return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte.' });
}
const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]);
if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' });
if (target.rows[0].role === 'admin') {
const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`);
if (adminCount.rows[0].n <= 1) {
return res.status(400).json({ error: 'Impossible de supprimer le dernier compte administrateur.' });
}
}
await query(`DELETE FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});