diff --git a/public/js/admin.js b/public/js/admin.js
index 5e62a0e..b117c36 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -56,6 +56,68 @@ function feedRowTemplate(feed) {
`;
}
+// Discord webhook URLs carry a secret token — mask it in the list view like
+// maskIcsUrl already does for ICS feed URLs.
+function maskDiscordUrl(url) {
+ try {
+ const u = new URL(url);
+ const parts = u.pathname.split('/').filter(Boolean);
+ const webhookId = parts[parts.length - 2];
+ return `${u.hostname}/…/${webhookId}/••••`;
+ } catch {
+ return '••••';
+ }
+}
+
+function discordWebhookRowTemplate(hook) {
+ return `
+
Aucun webhook configuré.
';
+ container.querySelectorAll('.remove-discord-webhook-btn').forEach((btn) => {
+ btn.addEventListener('click', () => onRemoveDiscordWebhook(parseInt(btn.dataset.webhookId, 10)));
+ });
+}
+
+async function onAddDiscordWebhook(e) {
+ e.preventDefault();
+ const form = e.target;
+ const label = form.label.value.trim();
+ const url = form.url.value.trim();
+ const submitBtn = form.querySelector('button[type="submit"]');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Vérification…';
+ try {
+ await api.post('/api/admin/discord-webhooks', { label, url });
+ form.reset();
+ await loadDiscordWebhooks();
+ } catch (err) {
+ showError(err.message);
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = '+ Ajouter';
+ }
+}
+
+async function onRemoveDiscordWebhook(id) {
+ try {
+ await api.del(`/api/admin/discord-webhooks/${id}`);
+ await loadDiscordWebhooks();
+ } catch (err) {
+ showError(err.message);
+ }
+}
+
function calendarUserRowTemplate(user) {
return `
@@ -221,10 +283,26 @@ async function onSaveSlotSettings(e) {
+
+
Notifications Discord
+
+ Chaque webhook reçoit un message quand une répétition est proposée, retirée, acceptée ou refusée.
+ Créer un webhook : sur Discord, Paramètres du salon → Intégrations → Webhooks → Nouveau webhook → Copier l'URL.
+
+
+
`;
document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings);
+ document.getElementById('add-discord-webhook-form').addEventListener('submit', onAddDiscordWebhook);
await loadSlotSettingsForm();
await loadCalendarUsers();
+ await loadDiscordWebhooks();
} catch (err) {
showError(err.message);
}
diff --git a/src/db/migrations/016_discord_webhooks.sql b/src/db/migrations/016_discord_webhooks.sql
new file mode 100644
index 0000000..271d16d
--- /dev/null
+++ b/src/db/migrations/016_discord_webhooks.sql
@@ -0,0 +1,6 @@
+CREATE TABLE discord_webhooks (
+ id SERIAL PRIMARY KEY,
+ label TEXT,
+ url TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/src/lib/discord.js b/src/lib/discord.js
new file mode 100644
index 0000000..ad2a133
--- /dev/null
+++ b/src/lib/discord.js
@@ -0,0 +1,79 @@
+const discordWebhooksRepo = require('../repositories/discordWebhooksRepo');
+
+const WEBHOOK_URL_RE = /^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/\d+\/[\w-]+$/;
+
+function isValidWebhookUrl(url) {
+ return WEBHOOK_URL_RE.test(url);
+}
+
+// Confirms the URL actually points at a live Discord webhook without
+// posting a visible message — Discord's webhook endpoint responds to GET
+// with the webhook's own metadata.
+async function checkWebhookReachable(url) {
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
+ return res.ok;
+}
+
+function formatDateTime(iso) {
+ return new Date(iso).toLocaleString('fr-FR', {
+ timeZone: 'Europe/Paris',
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+// Fire-and-forget: sends `content` to every registered webhook, in parallel,
+// each with its own timeout. Never throws — a Discord/network failure must
+// never break the rehearsal action that triggered the notification.
+async function notifyAll(content) {
+ let webhooks;
+ try {
+ webhooks = await discordWebhooksRepo.findAll();
+ } catch (err) {
+ console.error('[discord] could not load webhooks:', err.message);
+ return;
+ }
+ if (!webhooks.length) return;
+
+ const results = await Promise.allSettled(
+ webhooks.map((w) =>
+ fetch(w.url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content }),
+ signal: AbortSignal.timeout(5000),
+ })
+ )
+ );
+ results.forEach((result, i) => {
+ if (result.status === 'rejected') {
+ console.error(`[discord] webhook ${webhooks[i].id} failed:`, result.reason.message);
+ }
+ });
+}
+
+function rehearsalProposedMessage({ userName, startsAt, location }) {
+ return `🗓️ **${userName}** a proposé une répétition le ${formatDateTime(startsAt)}${location ? ` à ${location}` : ''}.`;
+}
+
+function rehearsalRemovedMessage({ userName, startsAt }) {
+ return `🗑️ **${userName}** a retiré la répétition du ${formatDateTime(startsAt)}.`;
+}
+
+function rehearsalVoteMessage({ userName, vote, startsAt }) {
+ const verb = vote === 'accept' ? 'accepté' : 'refusé';
+ const emoji = vote === 'accept' ? '✔' : '✘';
+ return `${emoji} **${userName}** a ${verb} la répétition du ${formatDateTime(startsAt)}.`;
+}
+
+module.exports = {
+ isValidWebhookUrl,
+ checkWebhookReachable,
+ notifyAll,
+ rehearsalProposedMessage,
+ rehearsalRemovedMessage,
+ rehearsalVoteMessage,
+};
diff --git a/src/repositories/discordWebhooksRepo.js b/src/repositories/discordWebhooksRepo.js
new file mode 100644
index 0000000..dcd8289
--- /dev/null
+++ b/src/repositories/discordWebhooksRepo.js
@@ -0,0 +1,20 @@
+const pool = require('../db/pool');
+
+async function findAll() {
+ const { rows } = await pool.query('SELECT id, label, url, created_at FROM discord_webhooks ORDER BY created_at');
+ return rows;
+}
+
+async function create({ label, url }) {
+ const { rows } = await pool.query(
+ 'INSERT INTO discord_webhooks (label, url) VALUES ($1, $2) RETURNING id, label, url, created_at',
+ [label || null, url]
+ );
+ return rows[0];
+}
+
+async function remove(id) {
+ await pool.query('DELETE FROM discord_webhooks WHERE id = $1', [id]);
+}
+
+module.exports = { findAll, create, remove };
diff --git a/src/routes/admin.js b/src/routes/admin.js
index 0e5137b..cfa7ffe 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -3,6 +3,8 @@ const usersRepo = require('../repositories/usersRepo');
const songsRepo = require('../repositories/songsRepo');
const suggestionsRepo = require('../repositories/suggestionsRepo');
const setlistsRepo = require('../repositories/setlistsRepo');
+const discordWebhooksRepo = require('../repositories/discordWebhooksRepo');
+const discord = require('../lib/discord');
const { requireAdmin } = require('../auth/middleware');
const asyncHandler = require('../lib/asyncHandler');
@@ -53,4 +55,42 @@ router.get(
})
);
+router.get(
+ '/discord-webhooks',
+ requireAdmin,
+ asyncHandler(async (req, res) => {
+ const webhooks = await discordWebhooksRepo.findAll();
+ res.json(webhooks.map((w) => ({ id: w.id, label: w.label, url: w.url, createdAt: w.created_at })));
+ })
+);
+
+router.post(
+ '/discord-webhooks',
+ requireAdmin,
+ asyncHandler(async (req, res) => {
+ const { label, url } = req.body || {};
+ if (!url || !discord.isValidWebhookUrl(url.trim())) {
+ return res.status(400).json({ error: 'invalid_webhook_url' });
+ }
+ const trimmedUrl = url.trim();
+ try {
+ const reachable = await discord.checkWebhookReachable(trimmedUrl);
+ if (!reachable) return res.status(400).json({ error: 'webhook_unreachable' });
+ } catch (err) {
+ return res.status(400).json({ error: 'webhook_unreachable', message: err.message });
+ }
+ const webhook = await discordWebhooksRepo.create({ label: label ? label.trim() : null, url: trimmedUrl });
+ res.status(201).json({ id: webhook.id, label: webhook.label, url: webhook.url, createdAt: webhook.created_at });
+ })
+);
+
+router.delete(
+ '/discord-webhooks/:id',
+ requireAdmin,
+ asyncHandler(async (req, res) => {
+ await discordWebhooksRepo.remove(parseInt(req.params.id, 10));
+ res.status(204).end();
+ })
+);
+
module.exports = router;
diff --git a/src/routes/rehearsals.js b/src/routes/rehearsals.js
index ea510b0..1b2c7e6 100644
--- a/src/routes/rehearsals.js
+++ b/src/routes/rehearsals.js
@@ -1,5 +1,6 @@
const express = require('express');
const rehearsalsRepo = require('../repositories/rehearsalsRepo');
+const discord = require('../lib/discord');
const asyncHandler = require('../lib/asyncHandler');
const router = express.Router();
@@ -46,6 +47,9 @@ router.post(
location: location ? location.trim() : null,
proposedBy: req.user.id,
});
+ discord.notifyAll(
+ discord.rehearsalProposedMessage({ userName: req.user.name, startsAt: rehearsal.starts_at, location: rehearsal.location })
+ );
res.status(201).json({
id: rehearsal.id,
startsAt: rehearsal.starts_at,
@@ -65,6 +69,7 @@ router.delete(
return res.status(403).json({ error: 'forbidden' });
}
await rehearsalsRepo.remove(req.params.id);
+ discord.notifyAll(discord.rehearsalRemovedMessage({ userName: req.user.name, startsAt: rehearsal.starts_at }));
res.status(204).end();
})
);
@@ -79,6 +84,7 @@ router.post(
const rehearsal = await rehearsalsRepo.findById(req.params.id);
if (!rehearsal) return res.status(404).json({ error: 'not_found' });
const saved = await rehearsalsRepo.upsertVote(req.params.id, req.user.id, vote);
+ discord.notifyAll(discord.rehearsalVoteMessage({ userName: req.user.name, vote, startsAt: rehearsal.starts_at }));
res.json({
id: saved.id,
rehearsalId: saved.rehearsal_id,