Add account-based event registration and robust email HTML

This commit is contained in:
Michael committed 2026-07-14 11:18:11 +02:00
1 parent c647c90078
commit e170d57577
7 files changed
+340 -95

No files matched your search

+10 -7
View File
@@ -41,7 +41,8 @@ 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, r.image_path,
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`;
@@ -174,9 +175,9 @@ 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)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places]
`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) {
@@ -188,9 +189,11 @@ adminRouter.put('/rencontres/:id', validate(idParam, 'params'), validate(rencont
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
WHERE id=$7 RETURNING id`,
[d.titre, d.date_renc, d.heure, d.lieu, d.description, d.places, req.params.id]
`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 });
+133 -32
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { query } from '../db.js';
import { pool, query } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { publicFormLimiter } from '../middleware/security.js';
import { demandeSchema, inscriptionPublicSchema, idParam } from '../schemas.js';
@@ -19,7 +20,8 @@ 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, r.image_path,
`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
WHERE r.date_renc >= CURRENT_DATE
@@ -73,42 +75,141 @@ publicRouter.post(
}
);
// Event registration (public modal)
publicRouter.post(
'/rencontres/:id/inscriptions',
publicFormLimiter,
// Registration context for the connected member account. Existing names are
// returned so the member can review or amend the complete participant list.
publicRouter.get(
'/rencontres/:id/inscription',
requireAuth('member'),
validate(idParam, 'params'),
validate(inscriptionPublicSchema),
async (req, res, next) => {
try {
const { id } = req.params;
const { nom, entreprise, email, tel } = req.data;
const renc = await query(
`SELECT r.places, COUNT(i.id)::int AS inscrits
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE
GROUP BY r.id`,
[id]
);
if (renc.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
if (renc.rows[0].inscrits >= renc.rows[0].places) {
return res.status(409).json({ error: 'Cette rencontre est complète.' });
const [member, rencontre, participants] = await Promise.all([
query('SELECT id, nom, dirigeant, email, tel, valide FROM members WHERE id = $1', [req.user.memberId]),
query(
`SELECT r.id, r.places, r.participants_par_compte, COUNT(i.id)::int AS inscrits
FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id
WHERE r.id = $1 AND r.date_renc >= CURRENT_DATE
GROUP BY r.id`,
[req.params.id]
),
query(
`SELECT nom, statut FROM inscriptions
WHERE rencontre_id = $1 AND member_id = $2 ORDER BY created_at, id`,
[req.params.id, req.user.memberId]
),
]);
if (member.rowCount === 0) return res.status(403).json({ error: 'Compte membre introuvable.' });
if (!member.rows[0].valide) {
return res.status(403).json({ error: "Votre adhésion doit être validée pour vous inscrire." });
}
const dup = await query(
'SELECT 1 FROM inscriptions WHERE rencontre_id = $1 AND email = $2',
[id, email]
);
if (dup.rowCount > 0) {
return res.status(409).json({ error: 'Une inscription existe déjà avec cet email.' });
}
await query(
`INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut)
VALUES ($1, $2, $3, $4, $5, 'en_attente')`,
[id, nom, entreprise, email, tel]
);
res.status(201).json({ ok: true });
if (rencontre.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
res.json({ member: member.rows[0], rencontre: rencontre.rows[0], participants: participants.rows });
} catch (err) {
next(err);
}
}
);
// One authenticated member account can register several named people, within
// both the per-account limit and the event's total capacity.
publicRouter.post(
'/rencontres/:id/inscriptions',
publicFormLimiter,
requireAuth('member'),
validate(idParam, 'params'),
validate(inscriptionPublicSchema),
async (req, res, next) => {
let client;
try {
client = await pool.connect();
const { id } = req.params;
const participants = req.data.participants;
const names = participants.map((name) => name.toLocaleLowerCase('fr-FR'));
if (new Set(names).size !== names.length) {
return res.status(400).json({ error: 'Chaque participant doit avoir un nom différent.' });
}
await client.query('BEGIN');
// Lock the event before counting places so concurrent registrations
// cannot overbook it.
const renc = await client.query(
`SELECT id, places, participants_par_compte FROM rencontres
WHERE id = $1 AND date_renc >= CURRENT_DATE FOR UPDATE`,
[id]
);
if (renc.rowCount === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Rencontre introuvable' });
}
const member = await client.query(
'SELECT id, nom, email, tel, valide FROM members WHERE id = $1',
[req.user.memberId]
);
if (member.rowCount === 0 || !member.rows[0].valide) {
await client.query('ROLLBACK');
return res.status(403).json({ error: "Votre adhésion doit être validée pour vous inscrire." });
}
if (participants.length > renc.rows[0].participants_par_compte) {
await client.query('ROLLBACK');
return res.status(409).json({
error: `Cette rencontre autorise ${renc.rows[0].participants_par_compte} participant(s) maximum par compte.`,
});
}
const otherCount = await client.query(
`SELECT COUNT(*)::int AS n FROM inscriptions
WHERE rencontre_id = $1 AND (member_id IS NULL OR member_id <> $2)`,
[id, req.user.memberId]
);
if (otherCount.rows[0].n + participants.length > renc.rows[0].places) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'Il ne reste pas assez de places pour ces participants.' });
}
const existing = await client.query(
`SELECT id, nom FROM inscriptions
WHERE rencontre_id = $1 AND member_id = $2 ORDER BY created_at, id`,
[id, req.user.memberId]
);
const available = new Map();
for (const row of existing.rows) {
const key = row.nom.toLocaleLowerCase('fr-FR');
if (!available.has(key)) available.set(key, []);
available.get(key).push(row);
}
const keptIds = [];
const account = member.rows[0];
for (const nom of participants) {
const key = nom.toLocaleLowerCase('fr-FR');
const row = available.get(key)?.shift();
if (row) {
keptIds.push(row.id);
await client.query(
'UPDATE inscriptions SET nom=$1, entreprise=$2, email=$3, tel=$4 WHERE id=$5',
[nom, account.nom, account.email, account.tel, row.id]
);
} else {
const inserted = await client.query(
`INSERT INTO inscriptions (rencontre_id, member_id, nom, entreprise, email, tel, statut)
VALUES ($1, $2, $3, $4, $5, $6, 'en_attente') RETURNING id`,
[id, account.id, nom, account.nom, account.email, account.tel]
);
keptIds.push(inserted.rows[0].id);
}
}
await client.query(
`DELETE FROM inscriptions
WHERE rencontre_id = $1 AND member_id = $2 AND NOT (id = ANY($3::int[]))`,
[id, account.id, keptIds]
);
await client.query('COMMIT');
res.status(201).json({ ok: true, participants: participants.length });
} catch (err) {
if (client) await client.query('ROLLBACK').catch(() => {});
next(err);
} finally {
client?.release();
}
}
);