nodebb-plugin-calendar-onekite 12.0.18 → 12.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api.js +15 -0
- package/lib/discord.js +127 -0
- package/lib/helloassoWebhook.js +15 -0
- package/lib/widgets.js +177 -0
- package/library.js +5 -0
- package/package.json +1 -1
- package/plugin.json +9 -1
- package/templates/admin/plugins/calendar-onekite.tpl +25 -0
package/lib/api.js
CHANGED
|
@@ -50,6 +50,7 @@ function normalizeAllowedGroups(raw) {
|
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
const helloasso = require('./helloasso');
|
|
53
|
+
const discord = require('./discord');
|
|
53
54
|
|
|
54
55
|
// Email helper: NodeBB's Emailer signature differs across versions.
|
|
55
56
|
// We try the common forms. Any failure is logged for debugging.
|
|
@@ -722,6 +723,20 @@ api.createReservation = async function (req, res) {
|
|
|
722
723
|
console.warn('[calendar-onekite] Failed to send pending email', e && e.message ? e.message : e);
|
|
723
724
|
}
|
|
724
725
|
|
|
726
|
+
// Discord webhook (optional)
|
|
727
|
+
try {
|
|
728
|
+
await discord.notifyReservationRequested(settings, {
|
|
729
|
+
rid: resv.rid,
|
|
730
|
+
uid: resv.uid,
|
|
731
|
+
username: resv.username || '',
|
|
732
|
+
itemIds: resv.itemIds || [],
|
|
733
|
+
itemNames: resv.itemNames || [],
|
|
734
|
+
start: resv.start,
|
|
735
|
+
end: resv.end,
|
|
736
|
+
status: resv.status,
|
|
737
|
+
});
|
|
738
|
+
} catch (e) {}
|
|
739
|
+
|
|
725
740
|
res.json({ ok: true, rid });
|
|
726
741
|
};
|
|
727
742
|
|
package/lib/discord.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const { URL } = require('url');
|
|
5
|
+
|
|
6
|
+
function isEnabled(v, defaultValue) {
|
|
7
|
+
if (v === undefined || v === null || v === '') return defaultValue !== false;
|
|
8
|
+
const s = String(v).trim().toLowerCase();
|
|
9
|
+
if (['1', 'true', 'yes', 'on'].includes(s)) return true;
|
|
10
|
+
if (['0', 'false', 'no', 'off'].includes(s)) return false;
|
|
11
|
+
return defaultValue !== false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatFRShort(ts) {
|
|
15
|
+
const d = new Date(ts);
|
|
16
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
17
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
18
|
+
const yy = String(d.getFullYear()).slice(-2);
|
|
19
|
+
return `${dd}/${mm}/${yy}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function postWebhook(webhookUrl, payload) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(String(webhookUrl));
|
|
26
|
+
if (u.protocol !== 'https:') {
|
|
27
|
+
return reject(new Error('discord-webhook-must-be-https'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const body = Buffer.from(JSON.stringify(payload || {}), 'utf8');
|
|
31
|
+
const req = https.request({
|
|
32
|
+
method: 'POST',
|
|
33
|
+
hostname: u.hostname,
|
|
34
|
+
port: u.port || 443,
|
|
35
|
+
path: u.pathname + (u.search || ''),
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
'Content-Length': body.length,
|
|
39
|
+
'User-Agent': 'nodebb-plugin-calendar-onekite',
|
|
40
|
+
},
|
|
41
|
+
}, (res) => {
|
|
42
|
+
const ok = res.statusCode && res.statusCode >= 200 && res.statusCode < 300;
|
|
43
|
+
const chunks = [];
|
|
44
|
+
res.on('data', (c) => chunks.push(c));
|
|
45
|
+
res.on('end', () => {
|
|
46
|
+
if (ok) return resolve(true);
|
|
47
|
+
const msg = Buffer.concat(chunks).toString('utf8');
|
|
48
|
+
return reject(new Error(`discord-webhook-http-${res.statusCode}: ${msg}`));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
req.on('error', reject);
|
|
52
|
+
req.write(body);
|
|
53
|
+
req.end();
|
|
54
|
+
} catch (e) {
|
|
55
|
+
reject(e);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildReservationMessage(kind, reservation) {
|
|
61
|
+
const calUrl = 'https://www.onekite.com/calendar';
|
|
62
|
+
const username = reservation && reservation.username ? String(reservation.username) : '';
|
|
63
|
+
const items = (reservation && Array.isArray(reservation.itemNames) && reservation.itemNames.length)
|
|
64
|
+
? reservation.itemNames.map(String)
|
|
65
|
+
: (reservation && Array.isArray(reservation.itemIds) ? reservation.itemIds.map(String) : []);
|
|
66
|
+
const start = reservation && reservation.start ? Number(reservation.start) : NaN;
|
|
67
|
+
const end = reservation && reservation.end ? Number(reservation.end) : NaN;
|
|
68
|
+
|
|
69
|
+
const title = kind === 'paid'
|
|
70
|
+
? `**[Paiement reçu](${calUrl})** — ${username}`
|
|
71
|
+
: `**[Demande réservation](${calUrl})** — ${username}`;
|
|
72
|
+
|
|
73
|
+
const lines = [
|
|
74
|
+
title,
|
|
75
|
+
'',
|
|
76
|
+
kind === 'paid' ? '**Matériel :**' : '**Matériel demandé :**',
|
|
77
|
+
];
|
|
78
|
+
for (const it of items) {
|
|
79
|
+
lines.push(`- ${it}`);
|
|
80
|
+
}
|
|
81
|
+
if (Number.isFinite(start) && Number.isFinite(end)) {
|
|
82
|
+
lines.push('');
|
|
83
|
+
lines.push(`Du ${formatFRShort(start)} au ${formatFRShort(end)}`);
|
|
84
|
+
}
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildWebhookPayload(kind, reservation) {
|
|
89
|
+
// Discord "regroupe" visuellement les messages consécutifs d'un même auteur.
|
|
90
|
+
// En utilisant un username différent par action, on obtient un message bien distinct.
|
|
91
|
+
const username = kind === 'paid' ? 'OneKite • Paiement' : 'OneKite • Réservation';
|
|
92
|
+
return {
|
|
93
|
+
username,
|
|
94
|
+
content: buildReservationMessage(kind, reservation),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function notifyReservationRequested(settings, reservation) {
|
|
99
|
+
const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
|
|
100
|
+
if (!url) return;
|
|
101
|
+
if (!isEnabled(settings.discordNotifyOnRequest, true)) return;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await postWebhook(url, buildWebhookPayload('request', reservation));
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// eslint-disable-next-line no-console
|
|
107
|
+
console.warn('[calendar-onekite] Discord webhook failed (request)', e && e.message ? e.message : String(e));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function notifyPaymentReceived(settings, reservation) {
|
|
112
|
+
const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
|
|
113
|
+
if (!url) return;
|
|
114
|
+
if (!isEnabled(settings.discordNotifyOnPaid, true)) return;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await postWebhook(url, buildWebhookPayload('paid', reservation));
|
|
118
|
+
} catch (e) {
|
|
119
|
+
// eslint-disable-next-line no-console
|
|
120
|
+
console.warn('[calendar-onekite] Discord webhook failed (paid)', e && e.message ? e.message : String(e));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
notifyReservationRequested,
|
|
126
|
+
notifyPaymentReceived,
|
|
127
|
+
};
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -17,6 +17,7 @@ try {
|
|
|
17
17
|
|
|
18
18
|
const dbLayer = require('./db');
|
|
19
19
|
const helloasso = require('./helloasso');
|
|
20
|
+
const discord = require('./discord');
|
|
20
21
|
|
|
21
22
|
const SETTINGS_KEY = 'calendar-onekite';
|
|
22
23
|
|
|
@@ -363,6 +364,20 @@ async function handler(req, res, next) {
|
|
|
363
364
|
});
|
|
364
365
|
}
|
|
365
366
|
|
|
367
|
+
// Discord webhook (optional)
|
|
368
|
+
try {
|
|
369
|
+
await discord.notifyPaymentReceived(settings, {
|
|
370
|
+
rid: r.rid,
|
|
371
|
+
uid: r.uid,
|
|
372
|
+
username: (requester && requester.username) ? requester.username : (r.username || ''),
|
|
373
|
+
itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
|
|
374
|
+
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
375
|
+
start: r.start,
|
|
376
|
+
end: r.end,
|
|
377
|
+
status: r.status,
|
|
378
|
+
});
|
|
379
|
+
} catch (e) {}
|
|
380
|
+
|
|
366
381
|
await markProcessed(paymentId);
|
|
367
382
|
return res.json({ ok: true, processed: true });
|
|
368
383
|
} catch (err) {
|
package/lib/widgets.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const nconf = require.main.require('nconf');
|
|
4
|
+
|
|
5
|
+
function forumBaseUrl() {
|
|
6
|
+
return String(nconf.get('url') || '').trim().replace(/\/$/, '');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function escapeHtml(s) {
|
|
10
|
+
return String(s || '')
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeDomId() {
|
|
19
|
+
const r = Math.floor(Math.random() * 1e9);
|
|
20
|
+
return `onekite-twoweeks-${Date.now()}-${r}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function widgetCalendarUrl() {
|
|
24
|
+
// Per request, keep the public URL fixed (even if forum base differs)
|
|
25
|
+
return 'https://www.onekite.com/calendar';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const widgets = {};
|
|
29
|
+
|
|
30
|
+
widgets.defineWidgets = async function (widgetData) {
|
|
31
|
+
// NodeBB versions differ:
|
|
32
|
+
// - Some pass an object: { widgets: [...] }
|
|
33
|
+
// - Others pass the array directly
|
|
34
|
+
const list = Array.isArray(widgetData)
|
|
35
|
+
? widgetData
|
|
36
|
+
: (widgetData && Array.isArray(widgetData.widgets) ? widgetData.widgets : null);
|
|
37
|
+
|
|
38
|
+
if (!list) {
|
|
39
|
+
return widgetData;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
list.push({
|
|
43
|
+
widget: 'calendar-onekite-twoweeks',
|
|
44
|
+
name: 'Calendrier OneKite (2 semaines)',
|
|
45
|
+
description: 'Affiche la semaine courante + la semaine suivante (FullCalendar via CDN).',
|
|
46
|
+
content: '',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return widgetData;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
widgets.renderTwoWeeksWidget = async function (data) {
|
|
53
|
+
// data: { widget, ... }
|
|
54
|
+
const id = makeDomId();
|
|
55
|
+
const calUrl = widgetCalendarUrl();
|
|
56
|
+
const apiBase = forumBaseUrl();
|
|
57
|
+
const eventsEndpoint = `${apiBase}/api/v3/plugins/calendar-onekite/events`;
|
|
58
|
+
|
|
59
|
+
const idJson = JSON.stringify(id);
|
|
60
|
+
const calUrlJson = JSON.stringify(calUrl);
|
|
61
|
+
const eventsEndpointJson = JSON.stringify(eventsEndpoint);
|
|
62
|
+
|
|
63
|
+
const html = `
|
|
64
|
+
<div class="onekite-twoweeks">
|
|
65
|
+
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
66
|
+
<div style="font-weight: 600;">Calendrier (2 semaines)</div>
|
|
67
|
+
<a href="${escapeHtml(calUrl)}" class="btn btn-sm btn-outline-secondary" style="line-height: 1.1;">Ouvrir</a>
|
|
68
|
+
</div>
|
|
69
|
+
<div id="${escapeHtml(id)}"></div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@latest/main.min.css" />
|
|
73
|
+
<script>
|
|
74
|
+
(function(){
|
|
75
|
+
const containerId = ${idJson};
|
|
76
|
+
const calUrl = ${calUrlJson};
|
|
77
|
+
const eventsEndpoint = ${eventsEndpointJson};
|
|
78
|
+
|
|
79
|
+
function loadOnce(tag, attrs) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
try {
|
|
82
|
+
const key = attrs && (attrs.id || attrs.href || attrs.src);
|
|
83
|
+
if (key && document.querySelector((attrs.id ? ('#' + attrs.id) : (attrs.href ? ('link[href="' + attrs.href + '"]') : ('script[src="' + attrs.src + '"]'))))) {
|
|
84
|
+
resolve();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const el = document.createElement(tag);
|
|
88
|
+
Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
|
|
89
|
+
el.onload = () => resolve();
|
|
90
|
+
el.onerror = () => reject(new Error('load-failed'));
|
|
91
|
+
document.head.appendChild(el);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
reject(e);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function ensureFullCalendar() {
|
|
99
|
+
if (window.FullCalendar && window.FullCalendar.Calendar) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await loadOnce('script', {
|
|
103
|
+
id: 'onekite-fullcalendar-global',
|
|
104
|
+
src: 'https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.js',
|
|
105
|
+
async: 'true'
|
|
106
|
+
});
|
|
107
|
+
await loadOnce('script', {
|
|
108
|
+
id: 'onekite-fullcalendar-locales',
|
|
109
|
+
src: 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@latest/locales-all.global.min.js',
|
|
110
|
+
async: 'true'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function init() {
|
|
115
|
+
const el = document.getElementById(containerId);
|
|
116
|
+
if (!el) return;
|
|
117
|
+
|
|
118
|
+
await ensureFullCalendar();
|
|
119
|
+
|
|
120
|
+
// Define a 2-week dayGrid view
|
|
121
|
+
const calendar = new window.FullCalendar.Calendar(el, {
|
|
122
|
+
initialView: 'dayGridTwoWeek',
|
|
123
|
+
views: {
|
|
124
|
+
dayGridTwoWeek: {
|
|
125
|
+
type: 'dayGrid',
|
|
126
|
+
duration: { weeks: 2 },
|
|
127
|
+
buttonText: '2 semaines',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
locale: 'fr',
|
|
131
|
+
firstDay: 1,
|
|
132
|
+
height: 'auto',
|
|
133
|
+
headerToolbar: {
|
|
134
|
+
left: 'prev,next',
|
|
135
|
+
center: 'title',
|
|
136
|
+
right: '',
|
|
137
|
+
},
|
|
138
|
+
navLinks: false,
|
|
139
|
+
eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
|
|
140
|
+
events: function(info, successCallback, failureCallback) {
|
|
141
|
+
const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
|
|
142
|
+
fetch(eventsEndpoint + '?' + qs.toString(), { credentials: 'same-origin' })
|
|
143
|
+
.then((r) => r.json())
|
|
144
|
+
.then((json) => successCallback(json || []))
|
|
145
|
+
.catch((e) => failureCallback(e));
|
|
146
|
+
},
|
|
147
|
+
dateClick: function() {
|
|
148
|
+
window.location.href = calUrl;
|
|
149
|
+
},
|
|
150
|
+
eventClick: function() {
|
|
151
|
+
window.location.href = calUrl;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
calendar.render();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Widgets can be rendered after ajaxify; delay a tick.
|
|
159
|
+
setTimeout(() => init().catch(() => {}), 0);
|
|
160
|
+
})();
|
|
161
|
+
</script>
|
|
162
|
+
|
|
163
|
+
<style>
|
|
164
|
+
.onekite-twoweeks .fc .fc-toolbar-title { font-size: 1rem; }
|
|
165
|
+
.onekite-twoweeks .fc .fc-button { padding: .2rem .35rem; font-size: .75rem; }
|
|
166
|
+
.onekite-twoweeks .fc .fc-daygrid-day-number { font-size: .75rem; padding: 2px; }
|
|
167
|
+
.onekite-twoweeks .fc .fc-col-header-cell-cushion { font-size: .75rem; }
|
|
168
|
+
.onekite-twoweeks .fc .fc-event-title { font-size: .72rem; }
|
|
169
|
+
</style>
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
data = data || {};
|
|
173
|
+
data.html = html;
|
|
174
|
+
return data;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
module.exports = widgets;
|
package/library.js
CHANGED
|
@@ -12,6 +12,7 @@ const api = require('./lib/api');
|
|
|
12
12
|
const admin = require('./lib/admin');
|
|
13
13
|
const scheduler = require('./lib/scheduler');
|
|
14
14
|
const helloassoWebhook = require('./lib/helloassoWebhook');
|
|
15
|
+
const widgets = require('./lib/widgets');
|
|
15
16
|
const bodyParser = require('body-parser');
|
|
16
17
|
|
|
17
18
|
const Plugin = {};
|
|
@@ -149,4 +150,8 @@ Plugin.emailModify = async function (data) {
|
|
|
149
150
|
return data;
|
|
150
151
|
};
|
|
151
152
|
|
|
153
|
+
// Widgets
|
|
154
|
+
Plugin.defineWidgets = widgets.defineWidgets;
|
|
155
|
+
Plugin.renderTwoWeeksWidget = widgets.renderTwoWeeksWidget;
|
|
156
|
+
|
|
152
157
|
module.exports = Plugin;
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
{
|
|
16
16
|
"hook": "filter:email.modify",
|
|
17
17
|
"method": "emailModify"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"hook": "filter:widgets.getWidgets",
|
|
21
|
+
"method": "defineWidgets"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"hook": "filter:widget.render:calendar-onekite-twoweeks",
|
|
25
|
+
"method": "renderTwoWeeksWidget"
|
|
18
26
|
}
|
|
19
27
|
],
|
|
20
28
|
"staticDirs": {
|
|
@@ -31,5 +39,5 @@
|
|
|
31
39
|
"acpScripts": [
|
|
32
40
|
"public/admin.js"
|
|
33
41
|
],
|
|
34
|
-
"version": "1.
|
|
42
|
+
"version": "1.2.8"
|
|
35
43
|
}
|
|
@@ -53,6 +53,31 @@
|
|
|
53
53
|
<div class="form-text">Après validation (statut <code>paiement en attente</code>), un rappel est envoyé après ce délai. La réservation est ensuite expirée après <strong>2×</strong> ce délai.</div>
|
|
54
54
|
</div>
|
|
55
55
|
|
|
56
|
+
<hr class="my-4" />
|
|
57
|
+
<h4>Discord</h4>
|
|
58
|
+
|
|
59
|
+
<div class="mb-3">
|
|
60
|
+
<label class="form-label">Webhook URL</label>
|
|
61
|
+
<input class="form-control" name="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
|
|
62
|
+
<div class="form-text">Si vide, aucune notification Discord n'est envoyée.</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="mb-3">
|
|
66
|
+
<label class="form-label">Envoyer une notification à la demande</label>
|
|
67
|
+
<select class="form-select" name="discordNotifyOnRequest">
|
|
68
|
+
<option value="1">Oui</option>
|
|
69
|
+
<option value="0">Non</option>
|
|
70
|
+
</select>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="mb-3">
|
|
74
|
+
<label class="form-label">Envoyer une notification au paiement reçu</label>
|
|
75
|
+
<select class="form-select" name="discordNotifyOnPaid">
|
|
76
|
+
<option value="1">Oui</option>
|
|
77
|
+
<option value="0">Non</option>
|
|
78
|
+
</select>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
56
81
|
<h4 class="mt-4">HelloAsso</h4>
|
|
57
82
|
|
|
58
83
|
<div class="mb-3">
|