From 60d20e764dc3f751345f195563b338cd21bf9e2e Mon Sep 17 00:00:00 2001 From: Nathan FONTEYNE Date: Thu, 16 Jul 2026 12:00:30 +0200 Subject: [PATCH] update: integrate discord webhook --- public/js/admin.js | 78 +++++++++++++++++++++ src/db/migrations/016_discord_webhooks.sql | 6 ++ src/lib/discord.js | 79 ++++++++++++++++++++++ src/repositories/discordWebhooksRepo.js | 20 ++++++ src/routes/admin.js | 40 +++++++++++ src/routes/rehearsals.js | 6 ++ 6 files changed, 229 insertions(+) create mode 100644 src/db/migrations/016_discord_webhooks.sql create mode 100644 src/lib/discord.js create mode 100644 src/repositories/discordWebhooksRepo.js 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 ` +
+ ${hook.label ? `${escapeHtml(hook.label)} — ` : ''}${escapeHtml(maskDiscordUrl(hook.url))} + +
+ `; +} + +async function loadDiscordWebhooks() { + const webhooks = await api.get('/api/admin/discord-webhooks'); + const container = document.getElementById('discord-webhooks-list'); + container.innerHTML = webhooks.length + ? webhooks.map(discordWebhookRowTemplate).join('') + : '

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) {

Chargement…

+ +

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. +

+
+

Chargement…

+
+
+ + + +
`; 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,