Initial commit: Club DynaSQY

This commit is contained in:
2026-08-23 09:04:47 +00:00
commit 0875890dc7
145 changed files with 16947 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
DB_HOST=localhost
DB_PORT=3306
DB_USER=club_dynasqy
DB_PASSWORD=change_this_password
DB_NAME=club_dynasqy
JWT_SECRET=change_this_jwt_secret_key_64chars_min
PORT=3000
# SMTP (Infomaniak)
SMTP_HOST=mail.infomaniak.com
SMTP_PORT=465
SMTP_USER=invitation@votredomaine.com
SMTP_PASS=your_smtp_password
EMAIL_FROM=invitation@votredomaine.com
PUBLIC_URL=https://dyna.secourit.fr
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.env
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-alpine
RUN apk add --no-cache libreoffice
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
+16
View File
@@ -0,0 +1,16 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3306,
user: process.env.DB_USER || 'club_dynasqy',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'club_dynasqy',
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
module.exports = pool;
+73
View File
@@ -0,0 +1,73 @@
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'mail.infomaniak.com',
port: parseInt(process.env.SMTP_PORT, 10) || 465,
secure: parseInt(process.env.SMTP_PORT, 10) === 465 || true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
async function sendInvitationEmail(guest, inviter, token, baseUrl) {
const link = `${baseUrl}/invitation/${token}`;
const dateStr = guest.date_club
? new Date(guest.date_club).toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
: 'à venir';
const html = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Arial,Helvetica,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr><td style="padding:40px 20px;">
<table role="presentation" width="480" align="center" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">
<tr><td style="background:#1e3a5f;padding:24px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:22px;">Dyna Biz Coignieres</h1>
</td></tr>
<tr><td style="padding:32px 28px;">
<p style="margin:0 0 16px;font-size:16px;color:#333;">Bonjour <strong>${guest.prenom} ${guest.nom}</strong>,</p>
<p style="margin:0 0 16px;font-size:15px;color:#555;">
${inviter.prenom} ${inviter.nom} de Dyna Biz Coignieres vous invite à participer à notre prochain événement réseau.
</p>
${guest.date_club ? `<p style="margin:0 0 16px;font-size:15px;color:#555;"><strong>Date :</strong> ${dateStr}</p>` : ''}
<p style="margin:0 0 16px;font-size:15px;color:#555;"><strong>Horaires :</strong> 10h45 - 13h00</p>
<p style="margin:0 0 16px;font-size:15px;color:#555;"><strong>Lieu :</strong> 7 Av. Roger Hennequin, 78190 Trappes</p>
<p style="margin:0 0 24px;font-size:15px;color:#555;">Merci de nous confirmer votre présence en cliquant sur l'un des boutons ci-dessous :</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding:0 4px 12px;">
<a href="${link}?reponse=acceptee" style="display:inline-block;padding:14px 40px;background:#16a34a;color:#fff;text-decoration:none;border-radius:12px;font-size:16px;font-weight:bold;">J'accepte</a>
</td>
<td align="center" style="padding:0 4px 12px;">
<a href="${link}?reponse=refusee" style="display:inline-block;padding:14px 40px;background:#dc2626;color:#fff;text-decoration:none;border-radius:12px;font-size:16px;font-weight:bold;">Je refuse</a>
</td>
</tr>
</table>
<p style="margin:20px 0 0;font-size:13px;color:#888;text-align:center;">
Ou rendez-vous sur :<br><a href="${link}" style="color:#1e3a5f;">${link}</a>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
try {
await transporter.sendMail({
from: `"Dyna Biz Coignieres" <${process.env.EMAIL_FROM}>`,
to: guest.email,
subject: `Invitation - Dyna Biz Coignieres ${guest.date_club ? new Date(guest.date_club).toLocaleDateString('fr-FR') : ''}`,
html,
});
return true;
} catch (err) {
console.error('Email send error:', err);
throw err;
}
}
module.exports = { transporter, sendInvitationEmail };
+70
View File
@@ -0,0 +1,70 @@
const pool = require('../config/db');
const { logActivity } = require('../services/activityLog');
exports.listByMember = async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT a.*,
u.prenom AS created_prenom,
r.prenom AS remplace_prenom, r.nom AS remplace_nom,
rg.prenom AS remplace_guest_prenom, rg.nom AS remplace_guest_nom
FROM absences a
LEFT JOIN users u ON u.id = a.created_by
LEFT JOIN users r ON r.id = a.remplace_par_id AND (a.remplace_par_type = 'member' OR a.remplace_par_type IS NULL)
LEFT JOIN invites rg ON rg.id = a.remplace_par_guest_id AND a.remplace_par_type = 'guest'
WHERE a.membre_id = ?
ORDER BY a.date_absence DESC
`, [req.params.id]);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
exports.create = async (req, res) => {
try {
const { membre_id, date_absence, motif, prevenu, remplace_par_type, remplace_par_guest_id, remplace_par_id } = req.body;
const rType = remplace_par_type === 'guest' && remplace_par_guest_id ? 'guest' : 'member'
const rMemberId = rType === 'member' ? (remplace_par_id || null) : null
const rGuestId = rType === 'guest' ? (remplace_par_guest_id || null) : null
await pool.query(
'INSERT INTO absences (membre_id, date_absence, motif, prevenu, remplace_par_id, remplace_par_type, remplace_par_guest_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[membre_id, date_absence, motif || null, prevenu ? 1 : 0, rMemberId, rType, rGuestId, req.user.id]
);
logActivity(req, 'absence_ajout', { membre_id, date_absence });
res.status(201).json({ message: 'Absence enregistrée' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
exports.remove = async (req, res) => {
try {
await pool.query('DELETE FROM absences WHERE id = ?', [req.params.id]);
logActivity(req, 'absence_suppression', { absenceId: req.params.id });
res.json({ message: 'Absence supprimée' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
exports.stats = async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT u.id, u.prenom, u.nom, COUNT(a.id) AS total_absences
FROM users u
LEFT JOIN absences a ON a.membre_id = u.id
WHERE u.actif = 1
GROUP BY u.id
ORDER BY total_absences DESC
LIMIT 10
`);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
@@ -0,0 +1,32 @@
const pool = require('../config/db');
exports.getLogs = async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, Math.max(10, parseInt(req.query.limit) || 50));
const offset = (page - 1) * limit;
const actionFilter = req.query.action || '';
let where = '';
const params = [];
if (actionFilter) {
where = 'WHERE al.action LIKE ?';
params.push(`%${actionFilter}%`);
}
const [countRows] = await pool.query(
`SELECT COUNT(*) AS total FROM activity_logs al ${where}`, params
);
const total = countRows[0].total;
const [rows] = await pool.query(
`SELECT al.* FROM activity_logs al ${where} ORDER BY al.created_at DESC LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
res.json({ logs: rows, total, page, limit, pages: Math.ceil(total / limit) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur' });
}
};
+32
View File
@@ -0,0 +1,32 @@
const pool = require('../config/db');
const { logActivity } = require('../services/activityLog');
exports.listUsers = async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT id, prenom, nom, societe, email, telephone, role, actif, date_adhesion, date_echeance FROM users ORDER BY prenom ASC'
);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.updateUserRole = async (req, res) => {
try {
const { role } = req.body;
if (!['admin', 'member'].includes(role)) {
return res.status(400).json({ error: 'Role must be admin or member' });
}
const userId = req.params.id;
if (Number(userId) === req.user.id) {
return res.status(400).json({ error: 'Vous ne pouvez pas modifier votre propre rôle' });
}
await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, userId]);
logActivity(req, 'role_changement', { targetUserId: userId, newRole: role });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
+73
View File
@@ -0,0 +1,73 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const pool = require('../config/db');
const { logActivity } = require('../services/activityLog');
exports.login = async (req, res) => {
try {
const { identifier, password } = req.body;
if (!identifier || !password) {
return res.status(400).json({ error: 'Identifiant et mot de passe requis' });
}
const [rows] = await pool.query(
'SELECT * FROM users WHERE (prenom = ? OR email = ?) AND actif = 1',
[identifier, identifier]
);
if (rows.length === 0) {
return res.status(401).json({ error: 'Identifiants invalides' });
}
const user = rows[0];
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
return res.status(401).json({ error: 'Identifiants invalides' });
}
const token = jwt.sign(
{ id: user.id, prenom: user.prenom, nom: user.nom, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
logActivity(user, 'connexion');
res.json({
token,
user: { id: user.id, prenom: user.prenom, nom: user.nom, societe: user.societe, email: user.email, role: user.role },
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
exports.me = async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT id, prenom, nom, societe, activite, email, telephone, role, date_adhesion, date_echeance FROM users WHERE id = ?',
[req.user.id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Utilisateur introuvable' });
res.json(rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
exports.changePassword = async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Mot de passe actuel et nouveau requis' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'Le nouveau mot de passe doit faire au moins 6 caractères' });
}
const [rows] = await pool.query('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
const match = await bcrypt.compare(currentPassword, rows[0].password_hash);
if (!match) return res.status(401).json({ error: 'Mot de passe actuel incorrect' });
const hash = await bcrypt.hash(newPassword, 10);
await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [hash, req.user.id]);
logActivity(req, 'changement_mot_de_passe');
res.json({ message: 'Mot de passe modifié' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
+133
View File
@@ -0,0 +1,133 @@
const pool = require('../config/db');
const cheerio = require('cheerio');
const { logActivity } = require('../services/activityLog');
const MONTHS_FR = {
'janv': '01', 'janvier': '01', 'févr': '02', 'février': '02', 'fevrier': '02',
'mars': '03', 'avr': '04', 'avril': '04', 'mai': '05', 'juin': '06',
'juil': '07', 'juillet': '07', 'août': '08', 'aout': '08',
'sept': '09', 'septembre': '09', 'oct': '10', 'octobre': '10',
'nov': '11', 'novembre': '11', 'déc': '12', 'décembre': '12', 'decembre': '12'
};
function parseFrenchDate(str) {
const m = str.trim().match(/^(\d+)\s+(\S+)\s+(\d{4})$/);
if (!m) return null;
const month = MONTHS_FR[m[2].toLowerCase().replace(/\.$/, '')];
if (!month) return null;
return `${m[3]}-${month}-${m[1].padStart(2, '0')}`;
}
exports.list = async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM evenements ORDER BY date ASC');
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.create = async (req, res) => {
try {
const { date, nom_evenement, lieu, horaire } = req.body;
if (!date || !nom_evenement) {
return res.status(400).json({ error: 'Date and name required' });
}
const [result] = await pool.query(
'INSERT INTO evenements (date, nom_evenement, lieu, horaire) VALUES (?, ?, ?, ?)',
[date, nom_evenement, lieu || null, horaire || null]
);
res.status(201).json({ id: result.insertId, message: 'Event created' });
logActivity(req, 'evenement_ajout', { date, nom_evenement });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.update = async (req, res) => {
try {
const { date, nom_evenement, lieu, horaire } = req.body;
await pool.query(
'UPDATE evenements SET date = ?, nom_evenement = ?, lieu = ?, horaire = ? WHERE id = ?',
[date, nom_evenement, lieu || null, horaire || null, req.params.id]
);
res.json({ message: 'Updated' });
logActivity(req, 'evenement_modification', { id: req.params.id, date, nom_evenement });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.remove = async (req, res) => {
try {
await pool.query('DELETE FROM evenements WHERE id = ?', [req.params.id]);
res.json({ message: 'Deleted' });
logActivity(req, 'evenement_suppression', { id: req.params.id });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.deletePast = async (req, res) => {
try {
const [result] = await pool.query('DELETE FROM evenements WHERE date < CURDATE()');
res.json({ message: `${result.affectedRows} événement(s) supprimé(s)` });
logActivity(req, 'evenements_passes_supprimes', { count: result.affectedRows });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.scrape = async (req, res) => {
try {
const url = 'https://www.rencontres-dirigeants.com/nos-rencontres?region=11&startAt=&endAt=&dateRange=&format=&agency%5B%5D=289&department=';
const resp = await fetch(url);
const html = await resp.text();
const $ = cheerio.load(html);
const candidates = [];
$('article, [class*="card"], [class*="event"], [class*="rencontre"]').each((i, el) => {
const text = $(el).text();
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
let location = '', dateStr = '', timeStr = '';
for (const line of lines) {
if (!location && line.length > 3 && line[0] === line[0].toUpperCase() && !line.includes('€') && !line.includes('Participation') && !line.includes('places')) {
location = line;
}
const dateMatch = line.match(/(\d{1,2}\s+\S+\s+\d{4})/);
if (dateMatch && !dateStr) dateStr = dateMatch[1];
const timeMatch = line.match(/(\d{2}h\d{2})\s*[àa]\s*(\d{2}h\d{2})/);
if (timeMatch && !timeStr) timeStr = `${timeMatch[1]} - ${timeMatch[2]}`;
}
if (!dateStr) return;
const isoDate = parseFrenchDate(dateStr);
if (!isoDate) return;
candidates.push({ isoDate, location, timeStr });
});
const imported = [];
for (const c of candidates) {
const [existing] = await pool.query('SELECT id FROM evenements WHERE date = ? AND nom_evenement LIKE ?', [c.isoDate, `%Rencontre%`]);
if (existing.length > 0) continue;
const title = c.location ? `Rencontre dirigeants - ${c.location}` : 'Rencontre dirigeants Dynabuy';
await pool.query(
'INSERT INTO evenements (date, nom_evenement, lieu, horaire) VALUES (?, ?, ?, ?)',
[c.isoDate, title, c.location || null, c.timeStr || null]
);
imported.push({ date: c.isoDate, title, location: c.location, time: c.timeStr });
}
res.json({ message: `${imported.length} événement(s) importé(s)`, events: imported });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur lors de l\'import: ' + err.message });
}
};
+65
View File
@@ -0,0 +1,65 @@
const pool = require('../config/db');
function escapeCsv(val) {
if (val == null) return '';
const s = String(val);
return s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')
? `"${s.replace(/"/g, '""')}"`
: s;
}
function rowsToCsv(headers, rows) {
const lines = [headers.map(escapeCsv).join(',')];
for (const row of rows) {
lines.push(headers.map((h) => escapeCsv(row[h])).join(','));
}
return '\uFEFF' + lines.join('\r\n');
}
exports.exportCsv = async (req, res) => {
try {
const [members] = await pool.query(
'SELECT id, prenom, nom, email, telephone, societe, fonction, activite, site_web, role, actif, date_echeance, created_at FROM users ORDER BY nom, prenom'
);
const [guests] = await pool.query(
`SELECT i.id, i.prenom, i.nom, i.email, i.telephone, i.societe, i.activite, i.site_web, i.statut, i.visit_number, i.invite_par_id, u.prenom AS invite_par_prenom, u.nom AS invite_par_nom, i.date_club, i.commentaires, i.created_at
FROM invites i
LEFT JOIN users u ON u.id = i.invite_par_id
ORDER BY i.date_club DESC, i.nom, i.prenom`
);
const [recs] = await pool.query(
`SELECT r.id, r.date, r.nom_prospect, r.type, r.transformee, r.ca_realise,
m.prenom AS membre_prenom, m.nom AS membre_nom, m.societe AS membre_societe,
d.prenom AS dest_prenom, d.nom AS dest_nom, d.societe AS dest_societe
FROM recommandations r
LEFT JOIN users m ON m.id = r.membre_id
LEFT JOIN users d ON d.id = r.destinataire_id
ORDER BY r.date DESC`
);
const membresCsv = rowsToCsv(
['id', 'prenom', 'nom', 'email', 'telephone', 'societe', 'fonction', 'activite', 'site_web', 'role', 'actif', 'date_echeance', 'date_inscription'],
members
);
const invitesCsv = rowsToCsv(
['id', 'prenom', 'nom', 'email', 'telephone', 'societe', 'activite', 'site_web', 'statut', 'visite_numero', 'invite_par_id', 'invite_par_prenom', 'invite_par_nom', 'date_club', 'commentaires', 'date_creation'],
guests
);
const recosCsv = rowsToCsv(
['id', 'date', 'nom_prospect', 'type', 'transformee', 'ca_realise', 'membre_prenom', 'membre_nom', 'membre_societe', 'destinataire_prenom', 'destinataire_nom', 'destinataire_societe'],
recs
);
const date = new Date().toISOString().slice(0, 10);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="dyna-biz-export-${date}.csv"`);
res.write('=== MEMBRES ===\r\n' + membresCsv + '\r\n\r\n');
res.write('=== INVITES ===\r\n' + invitesCsv + '\r\n\r\n');
res.write('=== RECOMMANDATIONS ===\r\n' + recosCsv);
res.end();
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur export' });
}
};
+77
View File
@@ -0,0 +1,77 @@
const fs = require('fs');
const path = require('path');
const pool = require('../config/db');
const galleryDir = path.join(__dirname, '..', 'gallery');
exports.list = async (req, res) => {
try {
if (!fs.existsSync(galleryDir)) return res.json([]);
const dates = fs.readdirSync(galleryDir).filter((d) => fs.statSync(path.join(galleryDir, d)).isDirectory()).sort().reverse();
const result = [];
for (const date of dates) {
const dirPath = path.join(galleryDir, date);
const files = fs.readdirSync(dirPath)
.filter((f) => /\.(jpg|jpeg|png|gif|webp|heic)$/i.test(f) && f !== '.thumbnails')
.map((f) => ({ filename: f, url: `/gallery/${date}/${encodeURIComponent(f)}` }))
.sort();
if (files.length > 0) result.push({ date, files });
}
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
exports.upload = async (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'Aucun fichier sélectionné' });
}
const { date_club } = req.body;
if (!date_club) return res.status(400).json({ error: 'Date du rendez-vous requise' });
const dateFolder = date_club.replace(/-/g, '');
const targetDir = path.join(galleryDir, dateFolder);
if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
const saved = [];
for (const file of req.files) {
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext);
let destPath = path.join(targetDir, file.originalname);
let counter = 1;
while (fs.existsSync(destPath)) {
destPath = path.join(targetDir, `${baseName}_${counter}${ext}`);
counter++;
}
fs.renameSync(file.path, destPath);
saved.push({ filename: path.basename(destPath), url: `/gallery/${dateFolder}/${path.basename(destPath)}` });
}
res.json({ message: `${saved.length} photo(s) ajoutée(s)`, files: saved });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur upload' });
}
};
exports.deletePhoto = async (req, res) => {
try {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin seulement' });
const { date, filename } = req.params;
const filePath = path.join(galleryDir, date, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Fichier introuvable' });
fs.unlinkSync(filePath);
const thumbPath = path.join(galleryDir, date, '.thumbnails', filename);
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
const dirPath = path.join(galleryDir, date);
if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).filter((f) => f !== '.thumbnails').length === 0) {
const thumbDir = path.join(galleryDir, date, '.thumbnails');
if (fs.existsSync(thumbDir)) fs.rmdirSync(thumbDir);
fs.rmdirSync(dirPath);
}
res.json({ message: 'Photo supprimée' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
+231
View File
@@ -0,0 +1,231 @@
const pool = require('../config/db');
const crypto = require('crypto');
const { sendInvitationEmail } = require('../config/email');
const { logActivity } = require('../services/activityLog');
exports.list = async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT i.*, u.prenom AS invite_par_prenom, u.nom AS invite_par_nom
FROM invites i
LEFT JOIN users u ON u.id = i.invite_par_id
WHERE i.statut_candidature IS NULL OR i.statut_candidature != 'accepte'
ORDER BY i.date_club DESC, i.created_at DESC
`);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.getById = async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM invites WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.search = async (req, res) => {
try {
const { q } = req.query;
if (!q || q.length < 2) return res.json([]);
const [rows] = await pool.query(`
SELECT prenom, nom, societe, activite, telephone, email, site_web,
MAX(visit_number) AS visit_number, COUNT(*) AS total_visits
FROM invites
WHERE CONCAT(prenom, ' ', nom) LIKE ?
GROUP BY prenom, nom, societe, activite, telephone, email, site_web
ORDER BY total_visits DESC LIMIT 20
`, [`%${q}%`]);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
const BLOCKED_DATES = ['2026-10-20', '2026-12-15'];
exports.create = async (req, res) => {
try {
const { prenom, nom, societe, activite, telephone, email, site_web, commentaires, date_club } = req.body;
if (!prenom || !nom || !telephone || !email || !date_club) {
return res.status(400).json({ error: 'Tous les champs sauf commentaires sont requis' });
}
if (BLOCKED_DATES.includes(date_club)) {
return res.status(403).json({ error: "Cette date n'accepte pas de nouveaux invités" });
}
const invite_par_id = req.user.id;
// Determine visit_number
const [existing] = await pool.query(
'SELECT MAX(visit_number) AS max_vn FROM invites WHERE prenom = ? AND nom = ?',
[prenom, nom]
);
const visit_number = (existing[0].max_vn || 0) + 1;
// Only admin can add 3rd+ visit
if (visit_number >= 3 && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Seul un administrateur peut ajouter une 3e visite ou plus' });
}
const [result] = await pool.query(
`INSERT INTO invites (prenom, nom, societe, activite, telephone, email, site_web, commentaires, date_club, invite_par_id, visit_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[prenom, nom, societe || null, activite || null, telephone, email, site_web || null, commentaires || null, date_club, invite_par_id, visit_number]
);
logActivity(req, 'invite_ajout', { prenom, nom });
res.status(201).json({ id: result.insertId, message: 'Guest added', visit_number });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.update = async (req, res) => {
try {
const guestId = req.params.id;
const [existing] = await pool.query('SELECT invite_par_id FROM invites WHERE id = ?', [guestId]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
if (existing[0].invite_par_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'You can only edit your own guests' });
}
const { prenom, nom, societe, activite, telephone, email, site_web, commentaires, date_club } = req.body;
if (BLOCKED_DATES.includes(date_club)) {
return res.status(403).json({ error: "Cette date n'accepte pas de nouveaux invités" });
}
await pool.query(
`UPDATE invites SET prenom=?, nom=?, societe=?, activite=?, telephone=?, email=?, site_web=?, commentaires=?, date_club=? WHERE id=?`,
[prenom, nom, societe || null, activite || null, telephone, email, site_web || null, commentaires || null, date_club, guestId]
);
logActivity(req, 'invite_modification', { guestId, prenom, nom });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.remove = async (req, res) => {
try {
const guestId = req.params.id;
const [existing] = await pool.query('SELECT invite_par_id FROM invites WHERE id = ?', [guestId]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
if (existing[0].invite_par_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Vous ne pouvez supprimer que vos propres invités' });
}
await pool.query('DELETE FROM invites WHERE id = ?', [guestId]);
logActivity(req, 'invite_suppression', { guestId });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.sendInvitation = async (req, res) => {
try {
const guestId = req.params.id;
const [guests] = await pool.query(
`SELECT i.*, u.prenom AS invite_par_prenom, u.nom AS invite_par_nom
FROM invites i
LEFT JOIN users u ON u.id = i.invite_par_id
WHERE i.id = ?`,
[guestId]
);
if (guests.length === 0) return res.status(404).json({ error: 'Invité non trouvé' });
const guest = guests[0];
if (guest.invite_par_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Vous ne pouvez envoyer que vos propres invitations' });
}
if (!guest.email) {
return res.status(400).json({ error: "Cet invité n'a pas d'adresse email" });
}
const token = crypto.randomBytes(32).toString('hex');
const baseUrl = process.env.PUBLIC_URL || 'https://dyna.secourit.fr';
const [inviter] = await pool.query('SELECT prenom, nom FROM users WHERE id = ?', [guest.invite_par_id]);
await sendInvitationEmail(guest, inviter[0], token, baseUrl);
await pool.query(
'UPDATE invites SET token=?, mail_envoye=1, invitation_sent_at=NOW(), statut="en_attente" WHERE id=?',
[token, guestId]
);
logActivity(req, 'invite_email_envoye', { guestId, email: guest.email });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Erreur lors de l'envoi de l'email" });
}
};
exports.candidature = async (req, res) => {
try {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin seulement' });
const guestId = req.params.id;
const { action } = req.body;
if (!['start', 'accept', 'reject'].includes(action)) {
return res.status(400).json({ error: 'Action invalide (start/accept/reject)' });
}
const [guests] = await pool.query('SELECT * FROM invites WHERE id = ?', [guestId]);
if (guests.length === 0) return res.status(404).json({ error: 'Invité non trouvé' });
const guest = guests[0];
if (action === 'start') {
await pool.query(
'UPDATE invites SET statut_candidature = ?, candidature_date = NOW() WHERE id = ?',
['candidature', guestId]
);
logActivity(req, 'candidature_demarrage', { guestId, prenom: guest.prenom, nom: guest.nom });
return res.json({ message: 'Candidature en cours', statut_candidature: 'candidature' });
}
if (action === 'reject') {
await pool.query(
'UPDATE invites SET statut_candidature = ? WHERE id = ?',
['refuse', guestId]
);
logActivity(req, 'candidature_refus', { guestId, prenom: guest.prenom, nom: guest.nom });
return res.json({ message: 'Candidature refusée', statut_candidature: 'refuse' });
}
if (action === 'accept') {
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash('Bienvenue2026', 10);
const [result] = await pool.query(
`INSERT INTO users (prenom, nom, societe, activite, telephone, email, site_web, role, password_hash, date_adhesion, invite_par_id, actif)
VALUES (?, ?, ?, ?, ?, ?, ?, 'member', ?, CURDATE(), ?, 1)`,
[guest.prenom, guest.nom, guest.societe || null, guest.activite || null,
guest.telephone || null, guest.email || null, guest.site_web || null,
hash, guest.invite_par_id]
);
await pool.query('DELETE FROM invites WHERE id = ?', [guestId]);
logActivity(req, 'candidature_acceptee', { guestId, prenom: guest.prenom, nom: guest.nom });
return res.json({ message: 'Membre créé avec succès', user_id: result.insertId, defaultPassword: 'Bienvenue2026' });
}
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ error: 'Cet email est déjà utilisé par un membre existant' });
}
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
exports.nextEventGuestCount = async (req, res) => {
try {
const [events] = await pool.query(
'SELECT date FROM evenements WHERE date >= CURDATE() ORDER BY date ASC LIMIT 1'
);
if (events.length === 0) return res.json({ count: 0, next_event_date: null });
const nextDate = events[0].date;
const [rows] = await pool.query(
'SELECT COUNT(*) AS count FROM invites WHERE date_club = ?',
[nextDate]
);
res.json({ count: rows[0].count, next_event_date: nextDate });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
+61
View File
@@ -0,0 +1,61 @@
const pool = require('../config/db');
const { logActivity } = require('../services/activityLog');
exports.list = async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT mh.*, u.prenom, u.nom, u.societe AS membre_societe
FROM mises_en_honneur mh
LEFT JOIN users u ON u.id = mh.membre_id
ORDER BY mh.date ASC
`);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.create = async (req, res) => {
try {
const { date, membre_id, societe, theme_sujet } = req.body;
if (!date || !theme_sujet) {
return res.status(400).json({ error: 'Date and theme required' });
}
const [result] = await pool.query(
'INSERT INTO mises_en_honneur (date, membre_id, societe, theme_sujet) VALUES (?, ?, ?, ?)',
[date, membre_id || null, societe || null, theme_sujet]
);
res.status(201).json({ id: result.insertId, message: 'Honor added' });
logActivity(req, 'honneur_ajout', { date, membre_id, theme_sujet });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.update = async (req, res) => {
try {
const { date, membre_id, societe, theme_sujet } = req.body;
await pool.query(
'UPDATE mises_en_honneur SET date = ?, membre_id = ?, societe = ?, theme_sujet = ? WHERE id = ?',
[date, membre_id || null, societe || null, theme_sujet, req.params.id]
);
res.json({ message: 'Updated' });
logActivity(req, 'honneur_modification', { id: req.params.id, date, membre_id, theme_sujet });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.remove = async (req, res) => {
try {
await pool.query('DELETE FROM mises_en_honneur WHERE id = ?', [req.params.id]);
res.json({ message: 'Deleted' });
logActivity(req, 'honneur_suppression', { id: req.params.id });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
+185
View File
@@ -0,0 +1,185 @@
const pool = require('../config/db');
const path = require('path');
const fs = require('fs');
exports.showPage = async (req, res) => {
try {
const { token } = req.params;
const reponse = req.query.reponse || null;
const [rows] = await pool.query(
`SELECT i.*, u.prenom AS invite_par_prenom, u.nom AS invite_par_nom, u.societe AS invite_par_societe
FROM invites i
LEFT JOIN users u ON u.id = i.invite_par_id
WHERE i.token = ?`,
[token]
);
if (rows.length === 0) {
return sendErrorPage(res, 'Lien invalide ou expiré.');
}
const guest = rows[0];
if (reponse && ['acceptee', 'refusee'].includes(reponse)) {
await pool.query('UPDATE invites SET statut=? WHERE id=?', [reponse, guest.id]);
guest.statut = reponse;
}
const dateStr = guest.date_club
? new Date(guest.date_club).toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
: '';
const statutLabels = { en_attente: 'En attente', acceptee: 'Acceptée', refusee: 'Refusée' };
const statutColors = { en_attente: '#f59e0b', acceptee: '#16a34a', refusee: '#dc2626' };
const html = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Invitation Dyna Biz</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:20px;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#fff;border-radius:16px;box-shadow:0 2px 12px rgba(0,0,0,.08);max-width:480px;width:100%;overflow:hidden}
.header{background:#1e3a5f;padding:24px;text-align:center}
.header h1{color:#fff;font-size:20px;margin:0}
.body{padding:28px 24px}
.body h2{font-size:18px;color:#333;margin-bottom:16px}
.body p{font-size:15px;color:#555;margin-bottom:12px;line-height:1.5}
.label{color:#888;font-size:13px}
.statut{display:inline-block;padding:4px 12px;border-radius:20px;font-size:14px;font-weight:bold;color:#fff;margin-top:4px}
.btn-group{display:flex;gap:12px;margin:24px 0;flex-wrap:wrap}
.btn{flex:1;min-width:120px;padding:14px 20px;border:none;border-radius:12px;font-size:16px;font-weight:bold;color:#fff;cursor:pointer;text-align:center;text-decoration:none;display:inline-block;transition:opacity .2s}
.btn:active{opacity:.8}
.btn-accept{background:#16a34a}
.btn-refuse{background:#dc2626}
.btn:disabled{opacity:.5;cursor:not-allowed}
.btn-upload{background:#1e3a5f;color:#fff;padding:10px 20px;border:none;border-radius:8px;cursor:pointer;font-size:14px;margin-top:8px}
.file-info{font-size:13px;color:#888;margin-top:6px}
.msg{background:#f0fdf4;border:1px solid #bbf7d0;border-radius:12px;padding:16px;text-align:center;margin:16px 0}
.msg.error{background:#fef2f2;border-color:#fecaca}
.msg.info{background:#eff6ff;border-color:#bfdbfe}
.photo-preview{max-width:200px;max-height:200px;border-radius:12px;margin-top:12px;display:block}
.link{color:#1e3a5f;text-decoration:underline;font-size:14px}
.hidden{display:none}
</style>
</head>
<body>
<div class="card">
<div class="header">
<h1>Dyna Biz Coignieres</h1>
</div>
<div class="body">
<h2>Bonjour ${guest.prenom} ${guest.nom} !</h2>
<p><strong>${guest.invite_par_prenom} ${guest.invite_par_nom}</strong>${guest.invite_par_societe ? ' (' + guest.invite_par_societe + ')' : ''} vous invite au club Dyna Biz Coignieres.</p>
${guest.date_club ? '<p><strong>Date :</strong> ' + dateStr + '</p>' : ''}
<p style="margin-top:16px;"><span class="label">Statut actuel :</span><br><span class="statut" style="background:${statutColors[guest.statut]}">${statutLabels[guest.statut]}</span></p>
${guest.statut === 'en_attente' ? `
<div class="btn-group">
<a href="/invitation/${token}?reponse=acceptee" class="btn btn-accept">Accepter</a>
<a href="/invitation/${token}?reponse=refusee" class="btn btn-refuse">Refuser</a>
</div>
` : `
<div class="msg${guest.statut === 'acceptee' ? '' : ' error'}">
${guest.statut === 'acceptee' ? 'Vous avez accepté l\'invitation. Merci !' : 'Vous avez refusé l\'invitation.'}
</div>
`}
${guest.statut === 'acceptee' ? `
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0">
<h2>Ajouter une photo</h2>
<p>Vous pouvez ajouter une photo pour que les membres puissent vous reconnaître.</p>
<img src="${guest.photo_url || ''}" alt="Votre photo" class="photo-preview" style="${guest.photo_url ? '' : 'display:none'}">
<form id="photoForm" enctype="multipart/form-data">
<input type="file" name="photo" accept="image/*" capture="environment" id="photoInput" style="display:block;margin:12px 0;font-size:14px">
<button type="submit" class="btn-upload" id="uploadBtn">Envoyer la photo</button>
<p class="file-info" id="uploadMsg"></p>
</form>
<script>
document.getElementById('photoForm').addEventListener('submit', async function(e) {
e.preventDefault();
const input = document.getElementById('photoInput');
const btn = document.getElementById('uploadBtn');
const msg = document.getElementById('uploadMsg');
if (!input.files.length) { msg.textContent = 'Veuillez sélectionner une photo.'; return; }
btn.disabled = true; btn.textContent = 'Envoi...';
const fd = new FormData();
fd.append('photo', input.files[0]);
try {
const r = await fetch('/api/public/invitation/${token}/photo', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Erreur');
const data = await r.json();
msg.textContent = 'Photo envoyée avec succès !';
msg.style.color = '#16a34a';
if (data.photo_url) {
const img = document.querySelector('.photo-preview');
if (img) { img.src = data.photo_url + '?t=' + Date.now(); img.style.display = 'block'; }
}
input.value = '';
} catch(err) {
msg.textContent = 'Erreur lors de l\\'envoi.';
msg.style.color = '#dc2626';
} finally {
btn.disabled = false; btn.textContent = 'Envoyer la photo';
}
});
</script>
` : ''}
</div>
</div>
</body>
</html>`;
res.send(html);
} catch (err) {
console.error(err);
sendErrorPage(res, 'Une erreur est survenue.');
}
};
exports.respond = async (req, res) => {
try {
const { token } = req.params;
const { reponse } = req.body;
if (!['acceptee', 'refusee'].includes(reponse)) {
return res.status(400).json({ error: 'Réponse invalide' });
}
const [rows] = await pool.query('SELECT id FROM invites WHERE token = ?', [token]);
if (rows.length === 0) return res.status(404).json({ error: 'Lien invalide' });
await pool.query('UPDATE invites SET statut=? WHERE id=?', [reponse, rows[0].id]);
res.json({ message: 'Réponse enregistrée' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.uploadPhoto = async (req, res) => {
try {
const { token } = req.params;
const [rows] = await pool.query('SELECT id, photo_url FROM invites WHERE token = ?', [token]);
if (rows.length === 0) return res.status(404).json({ error: 'Lien invalide' });
if (!req.file) return res.status(400).json({ error: 'Fichier requis' });
if (rows[0].photo_url) {
const oldPath = path.join(__dirname, '..', rows[0].photo_url);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
const photoUrl = '/uploads/' + req.file.filename;
await pool.query('UPDATE invites SET photo_url=? WHERE id=?', [photoUrl, rows[0].id]);
res.json({ message: 'Photo enregistrée', photo_url: photoUrl });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
function sendErrorPage(res, msg) {
res.status(404).send(`<!DOCTYPE html>
<html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Invitation Dyna Biz</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f3f4f6;padding:20px}
.card{background:#fff;border-radius:16px;padding:32px;text-align:center;max-width:400px;box-shadow:0 2px 12px rgba(0,0,0,.08)}
.card h2{color:#333;margin:0 0 8px}.card p{color:#888;font-size:15px}</style>
</head><body><div class="card"><h2>Oups !</h2><p>${msg}</p></div></body></html>`);
}
+171
View File
@@ -0,0 +1,171 @@
const pool = require('../config/db');
const { logActivity } = require('../services/activityLog');
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
exports.list = async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT u.id, u.prenom, u.nom, u.societe, u.activite, u.email, u.telephone,
u.photo_url, u.merged_display_name, u.site_web, u.role, u.date_adhesion, u.date_echeance,
u.invite_par_id, ip.prenom AS invite_par_prenom, ip.nom AS invite_par_nom,
u.date_postulation, u.reception_connu, u.reception_inconnu,
rc.prenom AS reception_connu_prenom, rc.nom AS reception_connu_nom,
ri.prenom AS reception_inconnu_prenom, ri.nom AS reception_inconnu_nom,
u.date_entretien_postulation, u.date_demande_whatsapp,
u.fonction, u.fonction_emoji, u.kdrive_link
FROM users u
LEFT JOIN users ip ON ip.id = u.invite_par_id
LEFT JOIN users rc ON rc.id = u.reception_connu
LEFT JOIN users ri ON ri.id = u.reception_inconnu
WHERE u.actif = 1
ORDER BY u.prenom ASC
`);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
exports.getById = async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT u.*,
ip.prenom AS invite_par_prenom, ip.nom AS invite_par_nom,
rc.prenom AS reception_connu_prenom, rc.nom AS reception_connu_nom,
ri.prenom AS reception_inconnu_prenom, ri.nom AS reception_inconnu_nom
FROM users u
LEFT JOIN users ip ON ip.id = u.invite_par_id
LEFT JOIN users rc ON rc.id = u.reception_connu
LEFT JOIN users ri ON ri.id = u.reception_inconnu
WHERE u.id = ?
`, [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
const { password_hash, ...member } = rows[0];
res.json(member);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.update = async (req, res) => {
try {
const memberId = req.params.id;
if (Number(memberId) !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Unauthorized' });
}
const { prenom, nom, societe, activite, email, telephone, site_web,
date_adhesion, date_echeance,
invite_par_id, date_postulation, reception_connu, reception_inconnu,
date_entretien_postulation, date_demande_whatsapp,
fonction, fonction_emoji, kdrive_link, merged_display_name } = req.body;
if (!prenom || !nom) return res.status(400).json({ error: 'Prénom et nom requis' });
const isAdmin = req.user.role === 'admin';
const adminFields = isAdmin ? {
date_adhesion: date_adhesion || null,
date_echeance: date_echeance || null,
invite_par_id: invite_par_id || null,
date_postulation: date_postulation || null,
reception_connu: reception_connu || null,
reception_inconnu: reception_inconnu || null,
date_entretien_postulation: date_entretien_postulation || null,
date_demande_whatsapp: date_demande_whatsapp || null,
fonction: fonction || null,
fonction_emoji: fonction_emoji || null,
kdrive_link: kdrive_link || null,
merged_display_name: merged_display_name || null,
} : {};
await pool.query(
`UPDATE users SET prenom=?, nom=?, societe=?, activite=?, email=?, telephone=?, site_web=?,
date_adhesion=?, date_echeance=?, invite_par_id=?, date_postulation=?,
reception_connu=?, reception_inconnu=?, date_entretien_postulation=?,
date_demande_whatsapp=?, fonction=?, fonction_emoji=?, kdrive_link=?, merged_display_name=? WHERE id=?`,
[prenom, nom, societe || null, activite || null, email || null, telephone || null, site_web || null,
adminFields.date_adhesion, adminFields.date_echeance,
adminFields.invite_par_id, adminFields.date_postulation,
adminFields.reception_connu, adminFields.reception_inconnu,
adminFields.date_entretien_postulation, adminFields.date_demande_whatsapp,
adminFields.fonction, adminFields.fonction_emoji, adminFields.kdrive_link,
adminFields.merged_display_name,
memberId]
);
logActivity(req, 'membre_modification', { memberId });
res.json({ message: 'Mis à jour' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
};
exports.uploadPhoto = async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const filePath = req.file.path;
const ext = path.extname(filePath);
const jpgPath = ext === '.jpg' ? filePath : filePath.replace(ext, '.jpg');
try {
await sharp(filePath).resize(400, 400, { fit: 'cover', position: 'centre' }).jpeg({ quality: 85 }).toFile(jpgPath);
if (jpgPath !== filePath) fs.unlinkSync(filePath);
} catch (sharpErr) {
console.error('Sharp error, fallback:', sharpErr);
if (jpgPath !== filePath) fs.renameSync(filePath, jpgPath);
}
const finalUrl = `/uploads/${path.basename(jpgPath)}`;
const [existing] = await pool.query('SELECT photo_url FROM users WHERE id = ?', [req.user.id]);
if (existing[0]?.photo_url) {
const oldRelativePath = existing[0].photo_url.replace(/^\//, '');
const oldPath = path.join(__dirname, '..', oldRelativePath);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
await pool.query('UPDATE users SET photo_url = ? WHERE id = ?', [finalUrl, req.user.id]);
logActivity(req, 'photo_changement');
res.json({ photo_url: finalUrl });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.create = async (req, res) => {
try {
const { prenom, nom, societe, activite, email, telephone, role } = req.body;
if (!prenom || !nom || !email) {
return res.status(400).json({ error: 'Prénom, nom et email requis' });
}
const defaultPassword = 'Bienvenue2026';
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(defaultPassword, 10);
const [result] = await pool.query(
'INSERT INTO users (prenom, nom, societe, activite, email, telephone, role, password_hash, date_adhesion, actif) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURDATE(), 1)',
[prenom, nom, societe || null, activite || null, email, telephone || null, role || 'member', hash]
);
res.status(201).json({
id: result.insertId,
message: 'Membre créé',
defaultPassword: 'Bienvenue2026',
});
logActivity(req, 'membre_ajout', { prenom, nom });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ error: 'Cet email est déjà utilisé' });
}
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.remove = async (req, res) => {
try {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin seulement' });
const memberId = req.params.id;
if (Number(memberId) === req.user.id) return res.status(400).json({ error: 'Vous ne pouvez pas vous supprimer vous-même' });
await pool.query('UPDATE users SET actif = 0 WHERE id = ?', [memberId]);
logActivity(req, 'membre_desactivation', { memberId });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
@@ -0,0 +1,112 @@
const pool = require('../config/db');
const { logActivity } = require('../services/activityLog');
exports.list = async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT r.*,
m.prenom AS membre_prenom, m.nom AS membre_nom,
d.prenom AS dest_prenom, d.nom AS dest_nom
FROM recommandations r
LEFT JOIN users m ON m.id = r.membre_id
LEFT JOIN users d ON d.id = r.destinataire_id
ORDER BY r.date DESC
`);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.getById = async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM recommandations WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.create = async (req, res) => {
try {
const { date, destinataire_id, nom_prospect, type, transformee, sans_suite, ca_realise } = req.body;
if (!date || !nom_prospect || !type) {
return res.status(400).json({ error: 'Date, prospect name and type required' });
}
const membre_id = req.user.id;
const [result] = await pool.query(
`INSERT INTO recommandations (date, membre_id, destinataire_id, nom_prospect, type, transformee, sans_suite, ca_realise)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[date, membre_id, destinataire_id || null, nom_prospect, type, transformee ? 1 : 0, sans_suite ? 1 : 0, ca_realise || null]
);
logActivity(req, 'recommandation_ajout', { nom_prospect, type });
res.status(201).json({ id: result.insertId, message: 'Recommendation added' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.update = async (req, res) => {
try {
const recId = req.params.id;
const [existing] = await pool.query('SELECT membre_id FROM recommandations WHERE id = ?', [recId]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
if (existing[0].membre_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'You can only edit your own recommendations' });
}
const { date, destinataire_id, nom_prospect, type, transformee, sans_suite, ca_realise } = req.body;
await pool.query(
`UPDATE recommandations SET date=?, destinataire_id=?, nom_prospect=?, type=?, transformee=?, sans_suite=?, ca_realise=? WHERE id=?`,
[date, destinataire_id || null, nom_prospect, type, transformee ? 1 : 0, sans_suite ? 1 : 0, ca_realise || null, recId]
);
logActivity(req, 'recommandation_modification', { recId, nom_prospect });
res.json({ message: 'Updated' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.remove = async (req, res) => {
try {
const recId = req.params.id;
const [existing] = await pool.query('SELECT membre_id FROM recommandations WHERE id = ?', [recId]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
if (existing[0].membre_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Vous ne pouvez supprimer que vos propres recommandations' });
}
await pool.query('DELETE FROM recommandations WHERE id = ?', [recId]);
logActivity(req, 'recommandation_suppression', { recId });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.setStatus = async (req, res) => {
try {
const recId = req.params.id;
const [existing] = await pool.query('SELECT membre_id FROM recommandations WHERE id = ?', [recId]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
if (existing[0].membre_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Vous ne pouvez modifier que vos propres recommandations' });
}
const { status, ca_realise } = req.body;
if (status === 'transformee') {
await pool.query('UPDATE recommandations SET transformee=1, sans_suite=0, ca_realise=? WHERE id=?', [ca_realise || null, recId]);
} else if (status === 'sans_suite') {
await pool.query('UPDATE recommandations SET transformee=0, sans_suite=1, ca_realise=NULL WHERE id=?', [recId]);
} else {
await pool.query('UPDATE recommandations SET transformee=0, sans_suite=0 WHERE id=?', [recId]);
}
logActivity(req, 'recommandation_statut', { recId, status });
res.json({ message: 'Status updated' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
+203
View File
@@ -0,0 +1,203 @@
const pool = require('../config/db');
exports.dashboard = async (req, res) => {
try {
const { filter } = req.query;
let dateCondition;
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
if (filter === 'year') {
dateCondition = `r.date >= '${year}-01-01'`;
} else if (filter === 'month') {
dateCondition = `r.date >= '${year}-${month}-01'`;
} else {
dateCondition = `r.date >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)`;
}
const nextEventPromise = pool.query(`
SELECT e.date, e.nom_evenement,
(SELECT COUNT(*) FROM invites WHERE date_club = e.date) AS invites,
(SELECT SUM(statut = 'acceptee') FROM invites WHERE date_club = e.date) AS acceptees,
(SELECT SUM(statut = 'refusee') FROM invites WHERE date_club = e.date) AS refusees,
(SELECT SUM(visit_number >= 2) FROM invites WHERE date_club = e.date) AS secondes_visites
FROM evenements e
WHERE e.nom_evenement = 'Club d''Affaires' AND e.date >= CURDATE()
ORDER BY e.date ASC LIMIT 1
`);
const honorsPromise = pool.query(
"SELECT mh.*, u.prenom, u.nom, u.societe AS membre_societe FROM mises_en_honneur mh LEFT JOIN users u ON u.id = mh.membre_id WHERE mh.date >= CURDATE() ORDER BY mh.date ASC LIMIT 5"
);
const eventsPromise = pool.query(
'SELECT * FROM evenements WHERE date >= CURDATE() ORDER BY date ASC LIMIT 3'
);
const recsPromise = pool.query(`
SELECT r.id, r.date, r.nom_prospect, r.type, r.transformee, r.ca_realise,
m.prenom AS membre_prenom, m.nom AS membre_nom,
d.prenom AS dest_prenom, d.nom AS dest_nom
FROM recommandations r
LEFT JOIN users m ON m.id = r.membre_id
LEFT JOIN users d ON d.id = r.destinataire_id
WHERE ${dateCondition}
ORDER BY r.date DESC
`);
const countPromise = pool.query(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN r.type='donnee' THEN 1 ELSE 0 END) AS donnees,
SUM(CASE WHEN r.type='recue' THEN 1 ELSE 0 END) AS recues,
SUM(r.transformee) AS transformees,
SUM(COALESCE(r.ca_realise,0)) AS ca_total
FROM recommandations r
WHERE ${dateCondition}
`);
const memberRecsPromise = pool.query(`
SELECT
u.id, u.prenom, u.nom, u.societe,
COUNT(r.id) AS total_recs,
SUM(r.transformee) AS total_transformees,
SUM(COALESCE(r.ca_realise,0)) AS total_ca
FROM users u
LEFT JOIN recommandations r ON r.membre_id = u.id AND ${dateCondition}
WHERE u.actif = 1
GROUP BY u.id
ORDER BY total_recs DESC
`);
const guestCountPromise = pool.query(`
SELECT i.invite_par_id, u.prenom, u.nom, COUNT(i.id) AS total_invites
FROM invites i
JOIN users u ON u.id = i.invite_par_id
WHERE i.date_club >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
GROUP BY i.invite_par_id, u.prenom, u.nom
ORDER BY total_invites DESC
`);
const echeancePromise = pool.query(
"SELECT id, prenom, nom, societe, date_echeance FROM users WHERE actif = 1 AND date_echeance IS NOT NULL AND date_echeance BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY) ORDER BY date_echeance ASC"
);
const [nextEvent] = await nextEventPromise;
const [honors] = await honorsPromise;
const [events] = await eventsPromise;
const [recs] = await recsPromise;
const [counts] = await countPromise;
const [memberRecs] = await memberRecsPromise;
const [guestCounts] = await guestCountPromise;
const guestMetricsPromise = pool.query(`
SELECT
COUNT(*) AS total_invites,
SUM(CASE WHEN statut = 'acceptee' THEN 1 ELSE 0 END) AS acceptees,
SUM(CASE WHEN statut = 'refusee' THEN 1 ELSE 0 END) AS refusees,
SUM(CASE WHEN visit_number >= 2 THEN 1 ELSE 0 END) AS secondes_visites
FROM invites
`);
const [echeances] = await echeancePromise;
const [guestMetricsRows] = await guestMetricsPromise;
let top_absences = [];
if (req.user.role === 'admin') {
const [absRows] = await pool.query(`
SELECT u.id, u.prenom, u.nom, COUNT(a.id) AS total_absences
FROM users u
LEFT JOIN absences a ON a.membre_id = u.id
WHERE u.actif = 1
GROUP BY u.id
HAVING total_absences > 0
ORDER BY total_absences DESC
LIMIT 5
`);
top_absences = absRows;
}
let top_conversion = [];
const [convRows] = await pool.query(`
SELECT u.id, u.prenom, u.nom, COUNT(m.id) AS total_convertis
FROM users u
JOIN users m ON m.invite_par_id = u.id
WHERE u.actif = 1
GROUP BY u.id
ORDER BY total_convertis DESC
LIMIT 15
`);
top_conversion = convRows;
const [candRows] = await pool.query(
'SELECT id, prenom, nom, societe, created_at FROM invites WHERE statut_candidature = ? ORDER BY created_at DESC',
['candidature']
);
const candidatures = candRows;
const total = Number(counts[0].total) || 0;
const transformees = Number(counts[0].transformees) || 0;
const tauxTransformation = total > 0 ? ((transformees / total) * 100).toFixed(1) : 0;
const topDonnees = [...memberRecs].filter(m => m.total_recs > 0).sort((a, b) => b.total_recs - a.total_recs).slice(0, 15);
const topInvites = [...guestCounts].sort((a, b) => b.total_invites - a.total_invites).slice(0, 15);
const flopDonnees = [...memberRecs].sort((a, b) => a.total_recs - b.total_recs).slice(0, 5);
res.json({
prochaine_seance: nextEvent.length > 0 ? {
date: nextEvent[0].date,
nom: nextEvent[0].nom_evenement,
invites: Number(nextEvent[0].invites) || 0,
acceptees: Number(nextEvent[0].acceptees) || 0,
refusees: Number(nextEvent[0].refusees) || 0,
secondes_visites: Number(nextEvent[0].secondes_visites) || 0,
} : null,
counts: {
total,
donnees: Number(counts[0].donnees) || 0,
recues: Number(counts[0].recues) || 0,
transformees,
ca_total: Number(counts[0].ca_total) || 0,
taux_transformation: tauxTransformation,
},
top_donnees: topDonnees,
top_invites: topInvites,
flop_donnees: flopDonnees,
prochaines_honors: honors,
prochains_evenements: events,
alertes_echeance: echeances,
guest_metrics: {
total_invites: Number(guestMetricsRows[0].total_invites) || 0,
acceptees: Number(guestMetricsRows[0].acceptees) || 0,
refusees: Number(guestMetricsRows[0].refusees) || 0,
secondes_visites: Number(guestMetricsRows[0].secondes_visites) || 0,
},
top_absences,
top_conversion,
candidatures,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
exports.memberStats = async (req, res) => {
try {
const userId = req.params.id || req.user.id;
const [recs] = await pool.query(`
SELECT r.*, d.prenom AS dest_prenom, d.nom AS dest_nom
FROM recommandations r
LEFT JOIN users d ON d.id = r.destinataire_id
WHERE r.membre_id = ?
ORDER BY r.date DESC
`, [userId]);
const [guests] = await pool.query(
'SELECT * FROM invites WHERE invite_par_id = ? ORDER BY created_at DESC',
[userId]
);
res.json({ recommandations: recs, invites: guests });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
};
+4
View File
@@ -0,0 +1,4 @@
const webPush = require('web-push');
const keys = webPush.generateVAPIDKeys();
console.log(keys.publicKey);
console.log(keys.privateKey);
Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+25
View File
@@ -0,0 +1,25 @@
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token required' });
}
const token = header.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
function adminOnly(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
module.exports = { authenticate, adminOnly };
+23
View File
@@ -0,0 +1,23 @@
{
"name": "club-dynasqy-backend",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"cheerio": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.12.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.14",
"sharp": "^0.33.5",
"web-push": "^3.6.7"
}
}
+12
View File
@@ -0,0 +1,12 @@
const { Router } = require('express');
const { listByMember, create, remove, stats } = require('../controllers/absenceController');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = Router();
router.get('/stats', authenticate, adminOnly, stats);
router.get('/member/:id', authenticate, adminOnly, listByMember);
router.post('/', authenticate, adminOnly, create);
router.delete('/:id', authenticate, adminOnly, remove);
module.exports = router;
+8
View File
@@ -0,0 +1,8 @@
const { Router } = require('express');
const { getLogs } = require('../controllers/activityLogController');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = Router();
router.get('/', authenticate, adminOnly, getLogs);
module.exports = router;
+9
View File
@@ -0,0 +1,9 @@
const { Router } = require('express');
const { listUsers, updateUserRole } = require('../controllers/adminController');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = Router();
router.get('/users', authenticate, adminOnly, listUsers);
router.put('/users/:id/role', authenticate, adminOnly, updateUserRole);
module.exports = router;
+11
View File
@@ -0,0 +1,11 @@
const { Router } = require('express');
const { login, me, changePassword } = require('../controllers/authController');
const { authenticate } = require('../middleware/auth');
const router = Router();
router.post('/login', login);
router.get('/me', authenticate, me);
router.put('/password', authenticate, changePassword);
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
const { Router } = require('express');
const { list, create, update, remove, scrape, deletePast } = require('../controllers/eventController');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = Router();
router.get('/', authenticate, list);
router.post('/', authenticate, adminOnly, create);
router.post('/scrape', authenticate, adminOnly, scrape);
router.delete('/past', authenticate, adminOnly, deletePast);
router.put('/:id', authenticate, adminOnly, update);
router.delete('/:id', authenticate, adminOnly, remove);
module.exports = router;
+8
View File
@@ -0,0 +1,8 @@
const { Router } = require('express');
const { exportCsv } = require('../controllers/exportController');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = Router();
router.get('/csv', authenticate, adminOnly, exportCsv);
module.exports = router;
+52
View File
@@ -0,0 +1,52 @@
const { Router } = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const { list, upload, deletePhoto } = require('../controllers/galleryController');
const { authenticate } = require('../middleware/auth');
const galleryDir = path.join(__dirname, '..', 'gallery');
if (!fs.existsSync(galleryDir)) fs.mkdirSync(galleryDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, galleryDir),
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname),
});
const uploadMulter = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } });
const router = Router();
router.get('/', authenticate, list);
router.post('/upload', authenticate, uploadMulter.array('photos', 20), upload);
router.get('/thumbnail/:date/:filename', async (req, res) => {
try {
const { date, filename } = req.params;
const filePath = path.join(galleryDir, date, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Fichier introuvable' });
const ext = path.extname(filename).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
return res.sendFile(filePath);
}
const thumbDir = path.join(galleryDir, date, '.thumbnails');
if (!fs.existsSync(thumbDir)) fs.mkdirSync(thumbDir, { recursive: true });
const thumbPath = path.join(thumbDir, filename);
if (!fs.existsSync(thumbPath)) {
await sharp(filePath)
.resize(300, 200, { fit: 'cover', position: 'centre' })
.jpeg({ quality: 70 })
.toFile(thumbPath);
}
res.sendFile(thumbPath);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur' });
}
});
router.delete('/:date/:filename', authenticate, deletePhoto);
module.exports = router;
+16
View File
@@ -0,0 +1,16 @@
const { Router } = require('express');
const { list, getById, create, update, remove, search, sendInvitation, candidature, nextEventGuestCount } = require('../controllers/guestController');
const { authenticate } = require('../middleware/auth');
const router = Router();
router.get('/', authenticate, list);
router.get('/search', authenticate, search);
router.get('/:id', authenticate, getById);
router.post('/', authenticate, create);
router.put('/:id', authenticate, update);
router.delete('/:id', authenticate, remove);
router.post('/:id/send-invitation', authenticate, sendInvitation);
router.post('/:id/candidature', authenticate, candidature);
router.get('/next-count', authenticate, nextEventGuestCount);
module.exports = router;
+11
View File
@@ -0,0 +1,11 @@
const { Router } = require('express');
const { list, create, update, remove } = require('../controllers/honorController');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = Router();
router.get('/', authenticate, list);
router.post('/', authenticate, adminOnly, create);
router.put('/:id', authenticate, adminOnly, update);
router.delete('/:id', authenticate, adminOnly, remove);
module.exports = router;
+21
View File
@@ -0,0 +1,21 @@
const { Router } = require('express');
const multer = require('multer');
const path = require('path');
const { showPage, respond, uploadPhoto } = require('../controllers/invitationController');
const router = Router();
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', 'uploads')),
filename: (req, file, cb) => {
const { token } = req.params;
cb(null, `invite_${token}_${Date.now()}${path.extname(file.originalname)}`);
},
});
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
router.get('/:token', showPage);
router.post('/:token/respond', respond);
router.post('/:token/photo', upload.single('photo'), uploadPhoto);
module.exports = router;
+100
View File
@@ -0,0 +1,100 @@
const { Router } = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const pool = require('../config/db');
const { authenticate } = require('../middleware/auth');
const { logActivity } = require('../services/activityLog');
const slidesDir = path.join(__dirname, '..', 'member_slides');
if (!fs.existsSync(slidesDir)) fs.mkdirSync(slidesDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, slidesDir),
filename: (req, file, cb) => {
const memberId = req.body.member_id && req.user.role === 'admin' ? req.body.member_id : req.user.id;
const dateKey = req.body.date_key || '00000000';
const ext = path.extname(file.originalname);
cb(null, `${memberId}_${dateKey}_${Date.now()}${ext}`);
},
});
const uploadMulter = multer({ storage, limits: { fileSize: 300 * 1024 * 1024 } });
const router = Router();
router.post('/upload', authenticate, uploadMulter.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'Aucun fichier' });
const { date_key } = req.body;
if (!date_key) return res.status(400).json({ error: 'date_key requis' });
const memberId = req.body.member_id && req.user.role === 'admin' ? req.body.member_id : req.user.id;
let filename = req.file.filename;
const ext = path.extname(filename).toLowerCase();
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
const videoExts = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv'];
const allowedExts = [...imageExts, ...videoExts];
if (!allowedExts.includes(ext)) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Seules les images et vidéos sont acceptées' });
}
const [result] = await pool.query(
'INSERT INTO member_slides (member_id, filename, original_name, date_key) VALUES (?, ?, ?, ?)',
[memberId, filename, req.file.originalname, date_key]
);
res.json({ success: true, id: result.insertId, filename });
logActivity(req, 'slide_upload', { memberId, date_key, original_name: req.file.originalname });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur upload' });
}
});
router.get('/:memberId', authenticate, async (req, res) => {
try {
const { memberId } = req.params;
if (Number(memberId) !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Interdit' });
const [rows] = await pool.query(
'SELECT id, filename, original_name, date_key, created_at FROM member_slides WHERE member_id = ? ORDER BY created_at DESC',
[memberId]
);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur' });
}
});
router.delete('/:id', authenticate, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM member_slides WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Introuvable' });
const slide = rows[0];
if (slide.member_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Interdit' });
const filePath = path.join(slidesDir, slide.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
await pool.query('DELETE FROM member_slides WHERE id = ?', [req.params.id]);
res.json({ success: true });
logActivity(req, 'slide_suppression', { slideId: req.params.id, filename: slide.filename });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur' });
}
});
router.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'Le fichier dépasse la limite autorisée (300 Mo maximum)' });
}
return res.status(400).json({ error: err.message });
}
if (err) return res.status(500).json({ error: 'Erreur serveur' });
next();
});
module.exports = router;
+22
View File
@@ -0,0 +1,22 @@
const { Router } = require('express');
const multer = require('multer');
const path = require('path');
const { list, getById, update, uploadPhoto, create, remove } = require('../controllers/memberController');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = Router();
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => cb(null, `user_${req.user.id}_${Date.now()}${path.extname(file.originalname)}`)
});
const upload = multer({ storage });
router.get('/', authenticate, list);
router.get('/:id', authenticate, getById);
router.put('/:id', authenticate, update);
router.post('/upload-photo', authenticate, upload.single('photo'), uploadPhoto);
router.post('/', authenticate, adminOnly, create);
router.delete('/:id', authenticate, adminOnly, remove);
module.exports = router;
+97
View File
@@ -0,0 +1,97 @@
const { Router } = require('express');
const pool = require('../config/db');
const { authenticate } = require('../middleware/auth');
const router = Router();
router.get('/', authenticate, async (req, res) => {
try {
const [rows] = await pool.query(
`SELECT n.id, n.title, n.body, n.created_at,
u.prenom AS sender_prenom, u.nom AS sender_nom,
nr.read_at, 'admin' AS source,
NULL AS invite_id,
NULL AS guest_name
FROM notifications n
JOIN notification_recipients nr ON nr.notification_id = n.id
JOIN users u ON u.id = n.sender_id
WHERE nr.user_id = ?
UNION ALL
SELECT -(i.id) AS id,
CONCAT('Invitation en attente - ', i.prenom, ' ', i.nom) AS title,
CONCAT('Vous n''avez pas encore envoy?? l''email d''invitation ?? ', i.prenom, ' ', i.nom) AS body,
i.created_at,
NULL AS sender_prenom, NULL AS sender_nom,
NULL AS read_at,
'pending_invite' AS source,
i.id AS invite_id,
CONCAT(i.prenom, ' ', i.nom) AS guest_name
FROM invites i
WHERE i.invite_par_id = ? AND (i.mail_envoye IS NULL OR i.mail_envoye = 0)
ORDER BY created_at DESC`,
[req.user.id, req.user.id]
);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.get('/unread-count', authenticate, async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT COUNT(*) AS count FROM notification_recipients WHERE user_id = ? AND read_at IS NULL',
[req.user.id]
);
const [inviteRows] = await pool.query(
'SELECT COUNT(*) AS count FROM invites WHERE invite_par_id = ? AND (mail_envoye IS NULL OR mail_envoye = 0)',
[req.user.id]
);
res.json({ count: Number(rows[0].count) + Number(inviteRows[0].count) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.post('/:id/read', authenticate, async (req, res) => {
try {
await pool.query(
'UPDATE notification_recipients SET read_at = NOW() WHERE notification_id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
res.json({ message: 'Marqu?? comme lu' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.post('/read-all', authenticate, async (req, res) => {
try {
await pool.query(
'UPDATE notification_recipients SET read_at = NOW() WHERE user_id = ? AND read_at IS NULL',
[req.user.id]
);
res.json({ message: 'Tout marqu?? comme lu' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.delete('/:id/recipient', authenticate, async (req, res) => {
try {
await pool.query(
'DELETE FROM notification_recipients WHERE notification_id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
res.json({ message: 'Supprim??' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
module.exports = router;
+136
View File
@@ -0,0 +1,136 @@
const { Router } = require('express');
const pool = require('../config/db');
const { authenticate } = require('../middleware/auth');
const router = Router();
router.get('/:date', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin seulement' });
try {
const { date } = req.params;
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ error: 'Format de date invalide (AAAA-MM-JJ)' });
}
const [absences] = await pool.query(`
SELECT a.membre_id, a.prevenu, a.motif,
a.remplace_par_type, a.remplace_par_id, a.remplace_par_guest_id,
r.prenom AS remplace_prenom, r.nom AS remplace_nom,
rg.prenom AS remplace_guest_prenom, rg.nom AS remplace_guest_nom
FROM absences a
LEFT JOIN users r ON r.id = a.remplace_par_id AND (a.remplace_par_type = 'member' OR a.remplace_par_type IS NULL)
LEFT JOIN invites rg ON rg.id = a.remplace_par_guest_id AND a.remplace_par_type = 'guest'
WHERE a.date_absence = ?
`, [date]);
const [allMembers] = await pool.query(
`SELECT id, prenom, nom, societe, activite, photo_url, merged_display_name, fonction, fonction_emoji,
telephone, site_web, email
FROM users WHERE actif = 1 ORDER BY fonction IS NULL, fonction, prenom`
);
const absentWithoutReplacement = new Set(
absences.filter(a => !a.remplace_par_id && !a.remplace_par_guest_id).map(a => a.membre_id)
);
const members = allMembers.filter(m => !absentWithoutReplacement.has(m.id));
const [guests] = await pool.query(
`SELECT i.id, i.prenom, i.nom, i.societe, i.activite, i.photo_url,
i.telephone, i.site_web, i.email, i.statut,
u.prenom AS invite_par_prenom, u.nom AS invite_par_nom
FROM invites i
LEFT JOIN users u ON u.id = i.invite_par_id
WHERE i.date_club = ? AND (i.statut != 'refusee' OR i.statut IS NULL)
ORDER BY i.prenom`,
[date]
);
const [currentHonorRow] = await pool.query(
`SELECT CONCAT(u.prenom, ' ', u.nom) AS currentHonor
FROM mises_en_honneur mh
JOIN users u ON u.id = mh.membre_id
WHERE mh.date = ?
LIMIT 1`,
[date]
);
const currentHonor = currentHonorRow[0]?.currentHonor || null;
const [nextHonorRow] = await pool.query(
`SELECT CONCAT(u.prenom, ' ', u.nom) AS nextHonor
FROM mises_en_honneur mh
JOIN users u ON u.id = mh.membre_id
WHERE mh.date > ?
ORDER BY mh.date ASC LIMIT 1`,
[date]
);
const nextHonor = nextHonorRow[0]?.nextHonor || null;
const [events] = await pool.query(
`SELECT nom_evenement AS titre, date AS date_event, horaire AS heure_debut, lieu
FROM evenements WHERE date >= ? ORDER BY date ASC LIMIT 5`,
[date]
);
const [y, m] = date.split('-')
const monthStart = `${y}-${m}-01`
const lastDay = new Date(y, m, 0).getDate()
const monthEnd = `${y}-${m}-${lastDay}`
const [recommendations] = await pool.query(
`SELECT r.id, r.type, r.nom_prospect, r.ca_realise, r.created_at,
u.prenom, u.nom, u.societe
FROM recommandations r
JOIN users u ON u.id = r.membre_id
WHERE r.created_at >= ? AND r.created_at <= ?
ORDER BY r.created_at DESC LIMIT 10`,
[monthStart, monthEnd]
);
const [yr, mo, dy] = date.split('-')
const dateKey = `${dy}${mo}${yr}`
const [memberSlides] = await pool.query(
'SELECT id, member_id, filename FROM member_slides WHERE date_key = ?',
[dateKey]
);
const [topInviters] = await pool.query(
`SELECT u.id, u.prenom, u.nom, u.photo_url, COUNT(i.id) AS total
FROM users u
LEFT JOIN invites i ON i.invite_par_id = u.id AND i.created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
WHERE u.actif = 1
GROUP BY u.id, u.prenom, u.nom, u.photo_url
ORDER BY total DESC LIMIT 3`
);
const [topDonnees] = await pool.query(
`SELECT u.id, u.prenom, u.nom, u.photo_url, COUNT(r.id) AS total
FROM users u
LEFT JOIN recommandations r ON r.membre_id = u.id
WHERE u.actif = 1
GROUP BY u.id, u.prenom, u.nom, u.photo_url
ORDER BY total DESC LIMIT 3`
);
const [topConversion] = await pool.query(
`SELECT u.id, u.prenom, u.nom, u.photo_url, COUNT(m2.id) AS total
FROM users u
LEFT JOIN invites i ON i.invite_par_id = u.id AND i.statut = 'acceptee'
LEFT JOIN users m2 ON m2.email = i.email
WHERE u.actif = 1
GROUP BY u.id, u.prenom, u.nom, u.photo_url
HAVING total > 0
ORDER BY total DESC LIMIT 3`
);
const [overrides] = await pool.query(
'SELECT slide_key, content, hidden, sort_order FROM presentation_overrides WHERE date_key = ?',
[dateKey]
);
res.json({ members, guests, absences, currentHonor: currentHonor || null, nextHonor: nextHonor || null, events, recommendations, memberSlides, topInviters, topDonnees, topConversion, overrides });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
module.exports = router;
+55
View File
@@ -0,0 +1,55 @@
const { Router } = require('express');
const pool = require('../config/db');
const { authenticate } = require('../middleware/auth');
const router = Router();
router.get('/:date', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin seulement' });
try {
const { date } = req.params;
const [yr, mo, dy] = date.split('-');
const dateKey = `${dy}${mo}${yr}`;
const [rows] = await pool.query(
'SELECT id, slide_key, content, hidden, sort_order FROM presentation_overrides WHERE date_key = ? ORDER BY sort_order ASC',
[dateKey]
);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.put('/:date', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin seulement' });
try {
const { date } = req.params;
const { overrides } = req.body;
const [yr, mo, dy] = date.split('-');
const dateKey = `${dy}${mo}${yr}`;
if (!Array.isArray(overrides)) return res.status(400).json({ error: 'overrides doit être un tableau' });
for (const ov of overrides) {
const { slide_key, content, hidden, sort_order } = ov;
if (!slide_key) continue;
await pool.query(
`INSERT INTO presentation_overrides (date_key, slide_key, content, hidden, sort_order)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE content = VALUES(content), hidden = VALUES(hidden), sort_order = VALUES(sort_order)`,
[dateKey, slide_key, content ? JSON.stringify(content) : null, hidden ? 1 : 0, sort_order ?? 0]
);
}
const [rows] = await pool.query(
'SELECT slide_key, content, hidden, sort_order FROM presentation_overrides WHERE date_key = ? ORDER BY sort_order ASC',
[dateKey]
);
res.json({ overrides: rows });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
module.exports = router;
+175
View File
@@ -0,0 +1,175 @@
const { Router } = require('express');
const pool = require('../config/db');
const { authenticate } = require('../middleware/auth');
const webPush = require('web-push');
const router = Router();
const publicVapidKey = process.env.VAPID_PUBLIC_KEY;
const privateVapidKey = process.env.VAPID_PRIVATE_KEY;
if (publicVapidKey && privateVapidKey) {
webPush.setVapidDetails('mailto:pierre@secourit.fr', publicVapidKey, privateVapidKey);
}
router.post('/subscribe', authenticate, async (req, res) => {
try {
const { endpoint, keys, reminderDays } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'endpoint, p256dh, and auth required' });
}
await pool.query(
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, reminder_days)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE p256dh = VALUES(p256dh), auth = VALUES(auth), reminder_days = VALUES(reminder_days)`,
[req.user.id, endpoint, keys.p256dh, keys.auth, reminderDays || 3]
);
res.json({ message: 'Abonnement aux notifications enregistré' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.delete('/unsubscribe', authenticate, async (req, res) => {
try {
const { endpoint } = req.body;
if (!endpoint) return res.status(400).json({ error: 'endpoint required' });
await pool.query('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?', [req.user.id, endpoint]);
res.json({ message: 'Désabonné' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.get('/subscriptions', authenticate, async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT id, endpoint, reminder_days, last_sent_at, created_at FROM push_subscriptions WHERE user_id = ?',
[req.user.id]
);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.get('/vapid-public-key', (req, res) => {
res.json({ publicKey: publicVapidKey });
});
router.get('/members-with-push', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin seulement' });
try {
const [rows] = await pool.query(
`SELECT u.id, u.prenom, u.nom, u.societe,
(SELECT COUNT(*) FROM push_subscriptions ps WHERE ps.user_id = u.id) AS has_push
FROM users u WHERE u.actif = 1 ORDER BY u.prenom`
);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.post('/broadcast', authenticate, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin seulement' });
try {
const { title, body, url, userIds } = req.body;
if (!title || !body) return res.status(400).json({ error: 'Titre et message requis' });
let subs;
if (userIds && Array.isArray(userIds) && userIds.length > 0) {
const placeholders = userIds.map(() => '?').join(',');
[subs] = await pool.query(
`SELECT endpoint, p256dh, auth, user_id FROM push_subscriptions WHERE user_id IN (${placeholders})`,
userIds
);
} else {
[subs] = await pool.query('SELECT endpoint, p256dh, auth, user_id FROM push_subscriptions');
}
if (subs.length === 0) return res.status(400).json({ error: 'Aucun abonné' });
const [notifResult] = await pool.query(
'INSERT INTO notifications (sender_id, title, body) VALUES (?, ?, ?)',
[req.user.id, title.slice(0, 100), body.slice(0, 255)]
);
const notifId = notifResult.insertId;
const recipientIds = [...new Set(subs.map((s) => s.user_id))];
for (const uid of recipientIds) {
await pool.query(
'INSERT IGNORE INTO notification_recipients (notification_id, user_id) VALUES (?, ?)',
[notifId, uid]
);
}
const payload = JSON.stringify({
title: title.slice(0, 100),
body: body.slice(0, 255),
icon: '/logo.svg',
badge: '/favicon.svg',
data: { url: '/notifications' },
});
let sent = 0;
for (const sub of subs) {
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload
);
sent++;
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
await pool.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [sub.endpoint]);
}
}
}
res.json({ message: `Notification envoyée à ${sent} abonné(s) sur ${recipientIds.length} destinataire(s)` });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
router.post('/test', authenticate, async (req, res) => {
try {
const [subs] = await pool.query(
'SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?',
[req.user.id]
);
if (subs.length === 0) return res.status(400).json({ error: 'Aucun abonnement trouvé' });
const payload = JSON.stringify({
title: 'Dyna Biz Coignières',
body: 'Notification de test ✓',
icon: '/logo.svg',
badge: '/favicon.svg',
data: { url: '/' },
});
for (const sub of subs) {
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload
);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
await pool.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [sub.endpoint]);
}
}
}
res.json({ message: 'Notification de test envoyée' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Erreur serveur' });
}
});
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
const { Router } = require('express');
const { list, getById, create, update, remove, setStatus } = require('../controllers/recommendationController');
const { authenticate } = require('../middleware/auth');
const router = Router();
router.get('/', authenticate, list);
router.get('/:id', authenticate, getById);
router.post('/', authenticate, create);
router.put('/:id', authenticate, update);
router.patch('/:id/status', authenticate, setStatus);
router.delete('/:id', authenticate, remove);
module.exports = router;
+9
View File
@@ -0,0 +1,9 @@
const { Router } = require('express');
const { dashboard, memberStats } = require('../controllers/statsController');
const { authenticate } = require('../middleware/auth');
const router = Router();
router.get('/dashboard', authenticate, dashboard);
router.get('/member/:id?', authenticate, memberStats);
module.exports = router;
+39
View File
@@ -0,0 +1,39 @@
const bcrypt = require('bcrypt');
const mysql = require('mysql2/promise');
require('dotenv').config();
const members = [
{ email: 'pierre@dynabiz.fr', password: 'Pierre78@' },
{ email: 'estelle@dynabiz.fr', password: 'Estelle78@' },
{ email: 'patricia@dynabiz.fr', password: 'Patricia78@' },
{ email: 'jennifer@dynabiz.fr', password: 'Jennifer78@' },
{ email: 'amandine@dynabiz.fr', password: 'Amandine78@' },
{ email: 'anais@dynabiz.fr', password: 'Anais78@' },
{ email: 'bastien@dynabiz.fr', password: 'Bastien78@' },
{ email: 'henrique@dynabiz.fr', password: 'Henrique78@' },
{ email: 'laetitia@dynabiz.fr', password: 'Laetitia78@' },
{ email: 'marie@dynabiz.fr', password: 'Marie78@' },
{ email: 'miguel@dynabiz.fr', password: 'Miguel78@' },
{ email: 'seb@dynabiz.fr', password: 'Seb78@' },
{ email: 'christelle@dynabiz.fr', password: 'Christelle78@' },
{ email: 'mathieu@dynabiz.fr', password: 'Mathieu78@' },
{ email: 'morgane@dynabiz.fr', password: 'Morgane78@' },
];
async function seed() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3306,
user: process.env.DB_USER || 'dynausr',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'dynasqy',
});
for (const m of members) {
const hash = await bcrypt.hash(m.password, 10);
await connection.execute('UPDATE users SET password_hash = ? WHERE email = ?', [hash, m.email]);
}
console.log('Mots de passe individuels crees !');
for (const m of members) console.log(' ' + m.email.split('@')[0] + ' -> ' + m.password);
await connection.end();
}
seed().catch(console.error);
+68
View File
@@ -0,0 +1,68 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
require('dotenv').config();
const db = require('./config/db');
const authRoutes = require('./routes/auth');
const memberRoutes = require('./routes/members');
const guestRoutes = require('./routes/guests');
const recommendationRoutes = require('./routes/recommendations');
const honorRoutes = require('./routes/honors');
const eventRoutes = require('./routes/events');
const statsRoutes = require('./routes/stats');
const adminRoutes = require('./routes/admin');
const invitationRoutes = require('./routes/invitation');
const absenceRoutes = require('./routes/absences');
const galleryRoutes = require('./routes/gallery');
const pushRoutes = require('./routes/push');
const notificationRoutes = require('./routes/notifications');
const presentationRoutes = require('./routes/presentation');
const memberSlidesRoutes = require('./routes/memberSlides');
const exportRoutes = require('./routes/export');
const activityLogRoutes = require('./routes/activityLog');
const presentationEditorRoutes = require('./routes/presentationEditor');
const { startPushCron } = require('./services/pushNotifications');
const app = express();
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const galleryDir = path.join(__dirname, 'gallery');
if (!fs.existsSync(galleryDir)) fs.mkdirSync(galleryDir, { recursive: true });
app.use(cors());
app.use(express.json({ limit: '100mb' }));
app.use('/uploads', express.static(uploadsDir));
app.use('/gallery', express.static(galleryDir));
app.use('/api/auth', authRoutes);
app.use('/api/members', memberRoutes);
app.use('/api/guests', guestRoutes);
app.use('/api/recommendations', recommendationRoutes);
app.use('/api/honors', honorRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/admin/presentation-editor', presentationEditorRoutes);
app.use('/api/public/invitation', invitationRoutes);
app.use('/invitation', invitationRoutes);
app.use('/api/absences', absenceRoutes);
app.use('/api/gallery', galleryRoutes);
app.use('/api/push', pushRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/presentation', presentationRoutes);
app.use('/api/member-slides', memberSlidesRoutes);
app.use('/api/export', exportRoutes);
app.use('/api/activity-logs', activityLogRoutes);
app.use('/member-slides', express.static(path.join(__dirname, 'member_slides')));
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
startPushCron();
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Backend running on port ${PORT}`));
+28
View File
@@ -0,0 +1,28 @@
const pool = require('../config/db');
async function logActivity(reqOrUser, action, details = null) {
let userId, userPrenom, userNom, ip;
if (reqOrUser && reqOrUser.user) {
userId = reqOrUser.user.id;
userPrenom = reqOrUser.user.prenom;
userNom = reqOrUser.user.nom;
ip = reqOrUser.ip || reqOrUser.connection?.remoteAddress || null;
} else if (reqOrUser && reqOrUser.id) {
userId = reqOrUser.id;
userPrenom = reqOrUser.prenom || '';
userNom = reqOrUser.nom || '';
ip = null;
} else {
return;
}
try {
await pool.query(
'INSERT INTO activity_logs (user_id, user_prenom, user_nom, action, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)',
[userId, userPrenom, userNom, action, details ? JSON.stringify(details) : null, ip]
);
} catch (err) {
console.error('Failed to log activity:', err.message);
}
}
module.exports = { logActivity };
+86
View File
@@ -0,0 +1,86 @@
const cron = require('node-cron');
const webPush = require('web-push');
const pool = require('../config/db');
const publicVapidKey = process.env.VAPID_PUBLIC_KEY;
const privateVapidKey = process.env.VAPID_PRIVATE_KEY;
if (publicVapidKey && privateVapidKey) {
webPush.setVapidDetails('mailto:pierre@secourit.fr', publicVapidKey, privateVapidKey);
}
const REMINDER_MESSAGES = [
{
title: 'Dyna Biz Coignières',
body: 'Pensez à mettre à jour vos invités et recommandations !',
icon: '/logo.svg',
badge: '/favicon.svg',
},
];
function getNextReminderDate(days) {
const d = new Date();
d.setDate(d.getDate() + days);
d.setHours(9, 0, 0, 0);
return d;
}
async function sendReminders() {
console.log('[Push Cron] Checking reminders...');
try {
const [subscriptions] = await pool.query(
`SELECT ps.*, u.prenom, u.nom
FROM push_subscriptions ps
JOIN users u ON u.id = ps.user_id
WHERE ps.last_sent_at IS NULL
OR DATE_ADD(ps.last_sent_at, INTERVAL ps.reminder_days DAY) <= NOW()`
);
console.log(`[Push Cron] Found ${subscriptions.length} subscription(s) to notify`);
for (const sub of subscriptions) {
try {
const msg = REMINDER_MESSAGES[0];
const payload = JSON.stringify({
title: msg.title,
body: msg.body,
icon: msg.icon,
badge: msg.badge,
data: { url: '/' },
});
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload
);
await pool.query('UPDATE push_subscriptions SET last_sent_at = NOW() WHERE id = ?', [sub.id]);
console.log(`[Push Cron] Sent to ${sub.prenom} ${sub.nom}`);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
await pool.query('DELETE FROM push_subscriptions WHERE id = ?', [sub.id]);
console.log(`[Push Cron] Removed expired subscription for ${sub.prenom} ${sub.nom}`);
} else {
console.error(`[Push Cron] Error sending to ${sub.prenom} ${sub.nom}:`, err.message);
}
}
}
} catch (err) {
console.error('[Push Cron] Error:', err);
}
}
function startPushCron() {
if (!publicVapidKey || !privateVapidKey) {
console.log('[Push Cron] VAPID keys not configured — skipping cron start');
return;
}
cron.schedule('0 9 * * *', () => {
sendReminders();
});
console.log('[Push Cron] Scheduled daily at 09:00');
}
module.exports = { startPushCron, sendReminders };