Ajoute les inscriptions modifiables et les paramètres association

This commit is contained in:
Michael committed 2026-07-16 19:48:14 +02:00
1 parent c49c45e65f
commit cf67fdc9de
22 files changed
+442 -77

No files matched your search

+8 -6
View File
@@ -1,3 +1,5 @@
import { associationSettings } from './siteSettings.js';
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&')
@@ -33,7 +35,8 @@ function ctaButton(url, label) {
</table>`;
}
export function buildCustomEmail({ content, baseUrl }) {
export function buildCustomEmail({ content, baseUrl, association = {} }) {
const settings = associationSettings(association);
const logoUrl = `${baseUrl}/assets/logo.jpg`;
const button = ctaButton(content.buttonUrl, content.buttonLabel);
const html = `<!doctype html>
@@ -55,10 +58,9 @@ export function buildCustomEmail({ content, baseUrl }) {
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:620px;background-color:#FFFFFF;border:1px solid #E7E2DB;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;">
<tr><td bgcolor="#161514" style="background-color:#161514;padding:26px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><tr>
<td width="52" valign="middle"><img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;background-color:#FFFFFF;"></td>
<td width="52" valign="middle"><img src="${esc(logoUrl)}" width="52" height="52" alt="${esc(settings.association_name)}" style="display:block;background-color:#FFFFFF;"></td>
<td valign="middle" style="padding-left:16px;">
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:22px;">Business Club</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:#E0777A;font-weight:bold;line-height:16px;">SLUC Nancy</div>
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:22px;">${esc(settings.association_name)}</div>
</td>
</tr></table>
</td></tr>
@@ -70,8 +72,8 @@ export function buildCustomEmail({ content, baseUrl }) {
${button ? `<tr><td align="center" style="padding:18px 40px 12px;">${button}</td></tr>` : ''}
${content.signature ? `<tr><td style="padding:24px 40px 38px;font-family:Georgia,'Times New Roman',serif;font-size:16px;line-height:26px;color:#1B1B1B;">${lines(content.signature)}</td></tr>` : '<tr><td height="26" style="height:26px;line-height:26px;">&nbsp;</td></tr>'}
<tr><td bgcolor="#0F0E0D" style="background-color:#0F0E0D;padding:24px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:22px;">
Business Club SLUC Nancy &middot; Palais des Sports Jean Weille, Nancy<br>
<a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a>
${esc(settings.association_name)}${settings.association_address ? ` &middot; ${lines(settings.association_address)}` : ''}<br>
${settings.association_contact ? `${esc(settings.association_contact)} &middot; ` : ''}${settings.association_email ? `<a href="mailto:${esc(settings.association_email)}" style="color:#E0777A;text-decoration:none;">${esc(settings.association_email)}</a>` : ''}${settings.association_phone ? ` &middot; ${esc(settings.association_phone)}` : ''}
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
+12 -9
View File
@@ -1,3 +1,5 @@
import { associationSettings } from './siteSettings.js';
// Invitation email for a rencontre, generated on demand from the admin
// backend. Email-client constraints apply: table layout, inline styles,
// web-safe font stacks, absolute URLs. The palette mirrors the site theme
@@ -17,7 +19,8 @@ function escML(v) {
// Default wording of each editable zone. The admin can rewrite any of
// them in the composer; clearing a field removes the block entirely.
export function defaultEmailTexts() {
export function defaultEmailTexts(association = {}) {
const settings = associationSettings(association);
return {
greeting: 'Chère membre, cher membre,',
intro:
@@ -27,13 +30,14 @@ export function defaultEmailTexts() {
outro:
"Les places sont limitées — pensez à vous inscrire dès maintenant. N'hésitez pas à "
+ "transférer cette invitation à un dirigeant de votre réseau qui souhaiterait découvrir le Club.",
signature: "À très bientôt,\nL'équipe du Business Club SLUC Nancy",
signature: `À très bientôt,\nL'équipe de ${settings.association_name}`,
};
}
export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
export function buildInvitationEmail({ rencontre, baseUrl, texts = {}, association = {} }) {
const r = rencontre;
const defaults = defaultEmailTexts();
const settings = associationSettings(association);
const defaults = defaultEmailTexts(settings);
// undefined -> default wording; '' (cleared by the admin) -> block hidden
const t = {
greeting: texts.greeting ?? defaults.greeting,
@@ -109,11 +113,10 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
<tr><td bgcolor="#161514" style="background-color:#161514;padding:26px 40px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;"><tr>
<td width="52" valign="middle">
<img src="${esc(logoUrl)}" width="52" height="52" alt="SLUC Business Club Nancy" style="display:block;border-radius:6px;background-color:#FFFFFF;">
<img src="${esc(logoUrl)}" width="52" height="52" alt="${esc(settings.association_name)}" style="display:block;border-radius:6px;background-color:#FFFFFF;">
</td>
<td valign="middle" style="padding-left:16px;">
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:1.1;">Business Club</div>
<div style="font-family:Arial,Helvetica,sans-serif;font-size:10px;letter-spacing:3px;text-transform:uppercase;color:#E0777A;font-weight:bold;padding-top:4px;">SLUC Nancy</div>
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:bold;color:#FFFFFF;line-height:1.1;">${esc(settings.association_name)}</div>
</td>
</tr></table>
</td></tr>
@@ -153,8 +156,8 @@ export function buildInvitationEmail({ rencontre, baseUrl, texts = {} }) {
</td></tr>
<tr><td bgcolor="#0F0E0D" style="background-color:#0F0E0D;padding:24px 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#B7AFA6;line-height:22px;">
Business Club SLUC Nancy &middot; Palais des Sports Jean Weille, Nancy<br>
<a href="mailto:contact@sluc-businessclub.fr" style="color:#E0777A;text-decoration:none;">contact@sluc-businessclub.fr</a>
${esc(settings.association_name)}${settings.association_address ? ` &middot; ${escML(settings.association_address)}` : ''}<br>
${settings.association_contact ? `${esc(settings.association_contact)} &middot; ` : ''}${settings.association_email ? `<a href="mailto:${esc(settings.association_email)}" style="color:#E0777A;text-decoration:none;">${esc(settings.association_email)}</a>` : ''}${settings.association_phone ? ` &middot; ${esc(settings.association_phone)}` : ''}
</td></tr>
</table>
+1
View File
@@ -31,6 +31,7 @@ app.use(
fontSrc: ['https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"],
frameSrc: ["'self'", 'https://www.google.com', 'https://maps.google.com'],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
+16 -9
View File
@@ -33,7 +33,7 @@ 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.adhesion, m.email, m.tel, m.site, m.adresse, 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,
@@ -53,6 +53,11 @@ const INSCR_SQL = `
i.rencontre_id, r.titre AS rencontre
FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`;
async function loadAssociationSettings() {
const result = await query(`SELECT key, value FROM site_content WHERE key LIKE 'association_%'`);
return Object.fromEntries(result.rows.map((row) => [row.key, row.value]));
}
// ---------- Dashboard (admin only) ----------
adminRouter.get('/dashboard', adminOnly, async (_req, res, next) => {
try {
@@ -89,10 +94,10 @@ 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`,
`INSERT INTO members (nom, secteur, categorie_id, dirigeant, adhesion, email, tel, site, adresse, presentation, valide)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 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]
d.email || null, d.tel, d.site, d.adresse, d.presentation, d.valide ?? true]
);
const id = result.rows[0].id;
// A login account (with a temporary password) is only created when an
@@ -120,10 +125,10 @@ adminRouter.put('/members/:id', validate(idParam, 'params'), validate(adminMembe
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`,
tel=$6, site=$7, adresse=$8, presentation=$9, valide=COALESCE($10, valide), updated_at=now()
WHERE id=$11 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]
d.tel, d.site, d.adresse, d.presentation, d.valide ?? null, req.params.id]
);
if (result.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' });
res.json({ ok: true });
@@ -257,7 +262,8 @@ adminRouter.post('/rencontres/:id/email', validate(idParam, 'params'), validate(
// invalid base — keep the fallback
}
}
res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl, texts }));
const association = await loadAssociationSettings();
res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl, texts, association }));
} catch (err) {
next(err);
}
@@ -281,7 +287,8 @@ adminRouter.post('/emails/preview', adminOnly, validate(customEmailSchema), asyn
const parsed = new URL(base);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') baseUrl = parsed.origin;
}
res.json(buildCustomEmail({ content, baseUrl }));
const association = await loadAssociationSettings();
res.json(buildCustomEmail({ content, baseUrl, association }));
} catch (err) {
next(err);
}
+22 -5
View File
@@ -11,7 +11,7 @@ memberRouter.use(requireAuth('member'));
const PROFILE_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.adhesion, m.email, m.tel, m.site, m.adresse, m.presentation, m.valide,
m.logo_path, m.photo_path
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
WHERE m.id = $1`;
@@ -28,12 +28,12 @@ memberRouter.get('/profile', async (req, res, next) => {
memberRouter.put('/profile', validate(memberProfileSchema), async (req, res, next) => {
try {
const { nom, secteur, categorie_id, dirigeant, email, tel, site, presentation } = req.data;
const { nom, secteur, categorie_id, dirigeant, email, tel, site, adresse, presentation } = req.data;
await query(
`UPDATE members SET nom=$1, secteur=$2, categorie_id=$3, dirigeant=$4, email=$5,
tel=$6, site=$7, presentation=$8, updated_at=now()
WHERE id=$9`,
[nom, secteur, categorie_id ?? null, dirigeant, email || null, tel, site, presentation, req.user.memberId]
tel=$6, site=$7, adresse=$8, presentation=$9, updated_at=now()
WHERE id=$10`,
[nom, secteur, categorie_id ?? null, dirigeant, email || null, tel, site, adresse, presentation, req.user.memberId]
);
const result = await query(PROFILE_SQL, [req.user.memberId]);
res.json({ member: result.rows[0] });
@@ -42,6 +42,23 @@ memberRouter.put('/profile', validate(memberProfileSchema), async (req, res, nex
}
});
memberRouter.get('/inscriptions', async (req, res, next) => {
try {
const result = await query(
`SELECT i.rencontre_id, COUNT(*)::int AS participants,
bool_and(i.statut = 'confirmee') AS confirmee
FROM inscriptions i
JOIN rencontres r ON r.id = i.rencontre_id
WHERE i.member_id = $1 AND r.date_renc >= CURRENT_DATE
GROUP BY i.rencontre_id`,
[req.user.memberId]
);
res.json({ inscriptions: result.rows });
} catch (err) {
next(err);
}
});
function imageRoute(column) {
return (req, res, next) => {
imageUpload(req, res, async (err) => {
+1 -1
View File
@@ -14,7 +14,7 @@ publicRouter.get('/bootstrap', async (_req, res, next) => {
query('SELECT key, value FROM site_content'),
query('SELECT id, name FROM categories ORDER BY id'),
query(
`SELECT m.id, m.nom, m.secteur, m.dirigeant, m.adhesion, m.email, m.tel, m.site,
`SELECT m.id, m.nom, m.secteur, m.dirigeant, m.adhesion, m.email, m.tel, m.site, m.adresse,
m.presentation, m.logo_path, m.photo_path, c.name AS categorie
FROM members m LEFT JOIN categories c ON c.id = m.categorie_id
WHERE m.valide = true ORDER BY m.nom`
+14
View File
@@ -39,6 +39,7 @@ export const memberProfileSchema = z.object({
email: trimmed(254).email().or(z.literal('')).optional().default(''),
tel: trimmed(30).optional().default(''),
site: trimmed(200).optional().default(''),
adresse: trimmed(300).optional().default(''),
presentation: trimmed(2000).optional().default(''),
});
@@ -95,6 +96,19 @@ export const roleChangeSchema = z.object({ role: staffRoleEnum });
export const contentSchema = z.object({
hero_quote_text: trimmed(300).optional(),
hero_quote_author: trimmed(120).optional(),
association_name: trimmed(160, 1).optional(),
association_address: trimmed(500).optional(),
association_email: trimmed(254).email().or(z.literal('')).optional(),
association_phone: trimmed(40).optional(),
association_contact: trimmed(160).optional(),
association_website: z.string().trim().url().max(300).or(z.literal('')).optional(),
association_facebook_url: z.string().trim().url().max(500).or(z.literal('')).optional(),
association_linkedin_url: z.string().trim().url().max(500).or(z.literal('')).optional(),
association_president: trimmed(160).optional(),
association_vice_president: trimmed(160).optional(),
association_treasurer: trimmed(160).optional(),
association_secretary: trimmed(160).optional(),
association_board_members: trimmed(3000).optional(),
});
// Invitation email composer: every text zone is optional — absent fields
+19
View File
@@ -0,0 +1,19 @@
export const ASSOCIATION_DEFAULTS = {
association_name: 'Business Club SLUC Nancy',
association_address: 'Palais des Sports Jean Weille\nNancy (54)',
association_email: 'contact@sluc-businessclub.fr',
association_phone: '+33 3 83 00 00 00',
association_contact: '',
association_website: '',
association_facebook_url: 'https://www.facebook.com/search/top?q=sluc%20business%20club',
association_linkedin_url: 'https://www.linkedin.com/company/club-affaires-stanislas/posts/?feedView=all',
association_president: 'Jean-Marc Lefèvre',
association_vice_president: '',
association_treasurer: '',
association_secretary: '',
association_board_members: '',
};
export function associationSettings(content = {}) {
return { ...ASSOCIATION_DEFAULTS, ...content };
}
+2
View File
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS members (
email CITEXT CHECK (char_length(email) <= 254),
tel TEXT CHECK (char_length(tel) <= 30),
site TEXT CHECK (char_length(site) <= 200),
adresse TEXT CHECK (char_length(adresse) <= 300),
presentation TEXT NOT NULL DEFAULT '' CHECK (char_length(presentation) <= 2000),
valide BOOLEAN NOT NULL DEFAULT false,
logo_path TEXT,
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS members (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE members ADD COLUMN IF NOT EXISTS adresse TEXT CHECK (char_length(adresse) <= 300);
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
+14 -1
View File
@@ -58,7 +58,20 @@ INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES
INSERT INTO site_content (key, value) VALUES
('hero_quote_text', 'On ne réussit jamais seul.'),
('hero_quote_author', 'L''esprit du Club'),
('hero_photo', '')
('hero_photo', ''),
('association_name', 'Business Club SLUC Nancy'),
('association_address', E'Palais des Sports Jean Weille\nNancy (54)'),
('association_email', 'contact@sluc-businessclub.fr'),
('association_phone', '+33 3 83 00 00 00'),
('association_contact', ''),
('association_website', ''),
('association_facebook_url', 'https://www.facebook.com/search/top?q=sluc%20business%20club'),
('association_linkedin_url', 'https://www.linkedin.com/company/club-affaires-stanislas/posts/?feedView=all'),
('association_president', 'Jean-Marc Lefèvre'),
('association_vice_president', ''),
('association_treasurer', ''),
('association_secretary', ''),
('association_board_members', '')
ON CONFLICT (key) DO NOTHING;
-- Accounts: created locked ('*seed*'); real passwords are applied at startup