mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: integrate discord webhook
This commit is contained in:
parent
0b4abe5b80
commit
60d20e764d
6 changed files with 229 additions and 0 deletions
|
|
@ -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 `
|
||||||
|
<div class="feed-row" data-webhook-id="${hook.id}">
|
||||||
|
<span class="feed-label">${hook.label ? `${escapeHtml(hook.label)} — ` : ''}${escapeHtml(maskDiscordUrl(hook.url))}</span>
|
||||||
|
<button type="button" class="secondary icon-btn remove-discord-webhook-btn" data-webhook-id="${hook.id}">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('')
|
||||||
|
: '<p class="empty">Aucun webhook configuré.</p>';
|
||||||
|
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) {
|
function calendarUserRowTemplate(user) {
|
||||||
return `
|
return `
|
||||||
<div class="admin-user-row calendar-person-row" data-user-id="${user.id}">
|
<div class="admin-user-row calendar-person-row" data-user-id="${user.id}">
|
||||||
|
|
@ -221,10 +283,26 @@ async function onSaveSlotSettings(e) {
|
||||||
<div class="panel admin-user-list" id="calendar-people-list">
|
<div class="panel admin-user-list" id="calendar-people-list">
|
||||||
<p class="empty">Chargement…</p>
|
<p class="empty">Chargement…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>Notifications Discord</h2>
|
||||||
|
<p class="note">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="panel" id="discord-webhooks-list">
|
||||||
|
<p class="empty">Chargement…</p>
|
||||||
|
</div>
|
||||||
|
<form id="add-discord-webhook-form" class="inline-form">
|
||||||
|
<input name="label" placeholder="Libellé (optionnel)">
|
||||||
|
<input name="url" type="url" placeholder="URL du webhook Discord" required>
|
||||||
|
<button type="submit" class="secondary icon-btn">+ Ajouter</button>
|
||||||
|
</form>
|
||||||
`;
|
`;
|
||||||
document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings);
|
document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings);
|
||||||
|
document.getElementById('add-discord-webhook-form').addEventListener('submit', onAddDiscordWebhook);
|
||||||
await loadSlotSettingsForm();
|
await loadSlotSettingsForm();
|
||||||
await loadCalendarUsers();
|
await loadCalendarUsers();
|
||||||
|
await loadDiscordWebhooks();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err.message);
|
showError(err.message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
src/db/migrations/016_discord_webhooks.sql
Normal file
6
src/db/migrations/016_discord_webhooks.sql
Normal file
|
|
@ -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()
|
||||||
|
);
|
||||||
79
src/lib/discord.js
Normal file
79
src/lib/discord.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
20
src/repositories/discordWebhooksRepo.js
Normal file
20
src/repositories/discordWebhooksRepo.js
Normal file
|
|
@ -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 };
|
||||||
|
|
@ -3,6 +3,8 @@ const usersRepo = require('../repositories/usersRepo');
|
||||||
const songsRepo = require('../repositories/songsRepo');
|
const songsRepo = require('../repositories/songsRepo');
|
||||||
const suggestionsRepo = require('../repositories/suggestionsRepo');
|
const suggestionsRepo = require('../repositories/suggestionsRepo');
|
||||||
const setlistsRepo = require('../repositories/setlistsRepo');
|
const setlistsRepo = require('../repositories/setlistsRepo');
|
||||||
|
const discordWebhooksRepo = require('../repositories/discordWebhooksRepo');
|
||||||
|
const discord = require('../lib/discord');
|
||||||
const { requireAdmin } = require('../auth/middleware');
|
const { requireAdmin } = require('../auth/middleware');
|
||||||
const asyncHandler = require('../lib/asyncHandler');
|
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;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const rehearsalsRepo = require('../repositories/rehearsalsRepo');
|
const rehearsalsRepo = require('../repositories/rehearsalsRepo');
|
||||||
|
const discord = require('../lib/discord');
|
||||||
const asyncHandler = require('../lib/asyncHandler');
|
const asyncHandler = require('../lib/asyncHandler');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -46,6 +47,9 @@ router.post(
|
||||||
location: location ? location.trim() : null,
|
location: location ? location.trim() : null,
|
||||||
proposedBy: req.user.id,
|
proposedBy: req.user.id,
|
||||||
});
|
});
|
||||||
|
discord.notifyAll(
|
||||||
|
discord.rehearsalProposedMessage({ userName: req.user.name, startsAt: rehearsal.starts_at, location: rehearsal.location })
|
||||||
|
);
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
id: rehearsal.id,
|
id: rehearsal.id,
|
||||||
startsAt: rehearsal.starts_at,
|
startsAt: rehearsal.starts_at,
|
||||||
|
|
@ -65,6 +69,7 @@ router.delete(
|
||||||
return res.status(403).json({ error: 'forbidden' });
|
return res.status(403).json({ error: 'forbidden' });
|
||||||
}
|
}
|
||||||
await rehearsalsRepo.remove(req.params.id);
|
await rehearsalsRepo.remove(req.params.id);
|
||||||
|
discord.notifyAll(discord.rehearsalRemovedMessage({ userName: req.user.name, startsAt: rehearsal.starts_at }));
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -79,6 +84,7 @@ router.post(
|
||||||
const rehearsal = await rehearsalsRepo.findById(req.params.id);
|
const rehearsal = await rehearsalsRepo.findById(req.params.id);
|
||||||
if (!rehearsal) return res.status(404).json({ error: 'not_found' });
|
if (!rehearsal) return res.status(404).json({ error: 'not_found' });
|
||||||
const saved = await rehearsalsRepo.upsertVote(req.params.id, req.user.id, vote);
|
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({
|
res.json({
|
||||||
id: saved.id,
|
id: saved.id,
|
||||||
rehearsalId: saved.rehearsal_id,
|
rehearsalId: saved.rehearsal_id,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue