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
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 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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue