+
+
+
-
+
+
-
-
+
+
|
@@ -108,17 +118,17 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
|
- Invitation · Rencontre
- ${t.greeting ? `${escML(t.greeting)} ` : ''}
+
+ ${t.greeting ? `` : ''}
${para(t.intro, 'padding-bottom:26px;')}
|
-
- |
- ${esc(r.titre)}
- ${r.description ? `${esc(r.description)} ` : ''}
-
+
+
+
+ ${r.description ? `` : ''}
+
${infoLines.join(' ')}
|
@@ -128,30 +138,31 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
-
+
|
${ctaButton(inscriptionUrl, "Je m'inscris à la rencontre")}
|
- Réservé aux membres à jour de leur adhésion pour la saison en cours.
+ | Réservé aux membres à jour de leur adhésion pour la saison en cours. |
|
${para(t.outro, t.signature ? 'padding-bottom:24px;' : '')}
- ${t.signature ? ` ${escML(t.signature)} ` : ''}
+ ${t.signature ? `` : ''}
|
- |
+ |
Business Club SLUC Nancy · Palais des Sports Jean Weille, Nancy
contact@sluc-businessclub.fr
|
- |
diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js
index 01876ad..daa8297 100644
--- a/server/src/routes/admin.js
+++ b/server/src/routes/admin.js
@@ -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 });
diff --git a/server/src/routes/public.js b/server/src/routes/public.js
index 6d470ab..9e05497 100644
--- a/server/src/routes/public.js
+++ b/server/src/routes/public.js
@@ -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();
+ }
+ }
+);
diff --git a/server/src/schemas.js b/server/src/schemas.js
index 7d6bbb9..d8a4284 100644
--- a/server/src/schemas.js
+++ b/server/src/schemas.js
@@ -27,10 +27,7 @@ export const demandeSchema = z.object({
});
export const inscriptionPublicSchema = z.object({
- nom: trimmed(120, 1),
- entreprise: trimmed(120, 1),
- email: trimmed(254, 3).email(),
- tel: trimmed(30).optional().default(''),
+ participants: z.array(trimmed(120, 1)).min(1).max(100),
});
export const memberProfileSchema = z.object({
@@ -55,6 +52,7 @@ export const rencontreSchema = z.object({
lieu: trimmed(200).optional().default(''),
description: trimmed(2000).optional().default(''),
places: z.coerce.number().int().min(0).max(100000),
+ participants_par_compte: z.coerce.number().int().min(1).max(100).optional().default(1),
});
export const inscriptionAdminSchema = z.object({
diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql
index 2a252a0..29d4c54 100644
--- a/server/src/sql/schema.sql
+++ b/server/src/sql/schema.sql
@@ -56,14 +56,20 @@ 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),
+ participants_par_compte INTEGER NOT NULL DEFAULT 1 CHECK (participants_par_compte BETWEEN 1 AND 100),
image_path TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE rencontres ADD COLUMN IF NOT EXISTS image_path TEXT;
+ALTER TABLE rencontres ADD COLUMN IF NOT EXISTS participants_par_compte INTEGER NOT NULL DEFAULT 1;
+ALTER TABLE rencontres DROP CONSTRAINT IF EXISTS rencontres_participants_par_compte_check;
+ALTER TABLE rencontres ADD CONSTRAINT rencontres_participants_par_compte_check
+ CHECK (participants_par_compte BETWEEN 1 AND 100);
CREATE TABLE IF NOT EXISTS inscriptions (
id SERIAL PRIMARY KEY,
rencontre_id INTEGER NOT NULL REFERENCES rencontres(id) ON DELETE CASCADE,
+ member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
nom TEXT NOT NULL CHECK (char_length(nom) BETWEEN 1 AND 120),
entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120),
email CITEXT CHECK (char_length(email) <= 254),
@@ -72,6 +78,12 @@ CREATE TABLE IF NOT EXISTS inscriptions (
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_inscriptions_rencontre ON inscriptions(rencontre_id);
+ALTER TABLE inscriptions ADD COLUMN IF NOT EXISTS member_id INTEGER REFERENCES members(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_inscriptions_member_rencontre ON inscriptions(member_id, rencontre_id);
+-- Attach legacy registrations when their email identifies a member account.
+UPDATE inscriptions i SET member_id = m.id
+ FROM members m
+ WHERE i.member_id IS NULL AND i.email IS NOT NULL AND i.email = m.email;
CREATE TABLE IF NOT EXISTS rencontres_passees (
id SERIAL PRIMARY KEY,
diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx
index bad6ecb..8a1758d 100644
--- a/web/src/components/admin/AdminTabs.jsx
+++ b/web/src/components/admin/AdminTabs.jsx
@@ -225,8 +225,8 @@ function RencFormModal({ renc, onClose, onSaved }) {
const { user } = useAuth();
const [form, setForm] = useState(
renc
- ? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places }
- : { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30 }
+ ? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places, participants_par_compte: renc.participants_par_compte || 1 }
+ : { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30, participants_par_compte: 1 }
);
const [image, setImage] = useState(renc?.image_path || '');
const [error, setError] = useState('');
@@ -281,13 +281,16 @@ function RencFormModal({ renc, onClose, onSaved }) {
-
+
-
Description
@@ -316,6 +319,34 @@ function escHtml(v) {
return String(v ?? '').replace(/&/g, '&').replace(//g, '>');
}
+// Clipboard HTML must be a fragment, not a second complete document. Outlook
+// otherwise rebuilds the pasted and drops part of the outer layout.
+function emailBodyFragment(html) {
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ return doc.body.innerHTML.trim();
+}
+
+// Older browsers do not expose ClipboardItem. A real rich-text selection is
+// still preferable to copying the HTML source as plain text.
+function legacyCopyRichHtml(html) {
+ const holder = document.createElement('div');
+ holder.setAttribute('contenteditable', 'true');
+ holder.setAttribute('aria-hidden', 'true');
+ holder.style.cssText = 'position:fixed;left:-10000px;top:0;width:700px;opacity:0;pointer-events:none;';
+ holder.innerHTML = html;
+ document.body.appendChild(holder);
+
+ const selection = window.getSelection();
+ const range = document.createRange();
+ range.selectNodeContents(holder);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ const copied = document.execCommand('copy');
+ selection.removeAllRanges();
+ holder.remove();
+ if (!copied) throw new Error('Rich HTML copy failed');
+}
+
// Composer + preview of the ready-to-send HTML invitation email generated
// server-side for one rencontre. Every text zone (salutation, intro,
// conclusion, signature) is editable — the preview regenerates as the
@@ -371,15 +402,16 @@ function EmailModal({ renc, onClose }) {
const copyEmail = async () => {
setError('');
try {
+ const fragment = emailBodyFragment(data.html);
if (navigator.clipboard?.write && window.ClipboardItem) {
await navigator.clipboard.write([
new window.ClipboardItem({
- 'text/html': new Blob([data.html], { type: 'text/html' }),
+ 'text/html': new Blob([fragment], { type: 'text/html' }),
'text/plain': new Blob([`${data.subject}\n${data.inscriptionUrl}`], { type: 'text/plain' }),
}),
]);
} else {
- await navigator.clipboard.writeText(data.html);
+ legacyCopyRichHtml(fragment);
}
flash('email');
} catch {
@@ -602,6 +634,7 @@ export function RencontresTab() {
{e.inscrits}
inscrits / {e.places}
+ max. {e.participants_par_compte} / compte
@@ -49,26 +85,74 @@ function InscriptionModal({ rencontre, onClose, onDone }) {
maxWidth={460}
header={{ kicker: 'Inscription', title: rencontre.titre, meta: `${jour} ${mois} · ${rencontre.heure} · ${rencontre.lieu}` }}
>
- |
| |
|
| | |