nodebb-plugin-calendar-onekite 12.0.17 → 12.0.19
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 +68 -86
- package/lib/db.js +38 -0
- package/lib/discord.js +119 -0
- package/lib/helloassoWebhook.js +15 -0
- package/lib/scheduler.js +16 -2
- package/lib/widgets.js +171 -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
|
@@ -12,11 +12,22 @@ const logger = require.main.require('./src/logger');
|
|
|
12
12
|
|
|
13
13
|
const dbLayer = require('./db');
|
|
14
14
|
|
|
15
|
+
// Fast membership check without N calls to groups.isMember.
|
|
16
|
+
// NodeBB's groups.getUserGroups([uid]) returns an array (per uid) of group objects.
|
|
17
|
+
// We compare against both group slugs and names to be tolerant with older settings.
|
|
15
18
|
async function userInAnyGroup(uid, allowed) {
|
|
16
|
-
if (!uid || !allowed || !allowed.length) return false;
|
|
19
|
+
if (!uid || !Array.isArray(allowed) || !allowed.length) return false;
|
|
17
20
|
const ug = await groups.getUserGroups([uid]);
|
|
18
|
-
const
|
|
19
|
-
|
|
21
|
+
const list = (ug && ug[0]) ? ug[0] : [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const g of list) {
|
|
24
|
+
if (!g) continue;
|
|
25
|
+
if (g.slug) seen.add(String(g.slug));
|
|
26
|
+
if (g.name) seen.add(String(g.name));
|
|
27
|
+
if (g.groupName) seen.add(String(g.groupName));
|
|
28
|
+
if (g.displayName) seen.add(String(g.displayName));
|
|
29
|
+
}
|
|
30
|
+
return allowed.some(v => seen.has(String(v)));
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
|
|
@@ -35,20 +46,11 @@ function normalizeAllowedGroups(raw) {
|
|
|
35
46
|
return s.split(',').map(v => String(v).trim().replace(/^"+|"+$/g, '')).filter(Boolean);
|
|
36
47
|
}
|
|
37
48
|
|
|
38
|
-
async
|
|
39
|
-
if (!uid || !allowed || !allowed.length) return false;
|
|
40
|
-
for (const g of allowed) {
|
|
41
|
-
try {
|
|
42
|
-
if (await groups.isMember(uid, g)) return true;
|
|
43
|
-
} catch (e) {
|
|
44
|
-
// ignore
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
+
// NOTE: Avoid per-group async checks (groups.isMember) when possible.
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
const helloasso = require('./helloasso');
|
|
53
|
+
const discord = require('./discord');
|
|
52
54
|
|
|
53
55
|
// Email helper: NodeBB's Emailer signature differs across versions.
|
|
54
56
|
// We try the common forms. Any failure is logged for debugging.
|
|
@@ -160,53 +162,8 @@ function autoCreatorGroupForYear(year) {
|
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (!g) return '';
|
|
166
|
-
try {
|
|
167
|
-
// NodeBB stores a mapping from slug -> groupName in groupslug:groupname
|
|
168
|
-
// groupName is the internal identifier used by groups.isMember()
|
|
169
|
-
const mapped = await db.getObjectField('groupslug:groupname', g);
|
|
170
|
-
if (mapped) return String(mapped);
|
|
171
|
-
} catch (e) {}
|
|
172
|
-
return g;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function resolveAllowedGroups(list) {
|
|
176
|
-
const out = [];
|
|
177
|
-
for (const raw of (list || [])) {
|
|
178
|
-
const gn = await resolveGroupName(raw);
|
|
179
|
-
if (gn) out.push(gn);
|
|
180
|
-
}
|
|
181
|
-
// de-duplicate, preserve order
|
|
182
|
-
return [...new Set(out)];
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
async function expandGroupCandidates(rawList) {
|
|
187
|
-
const out = [];
|
|
188
|
-
for (const raw of (rawList || [])) {
|
|
189
|
-
const g = String(raw || '').trim();
|
|
190
|
-
if (!g) continue;
|
|
191
|
-
out.push(g);
|
|
192
|
-
const mapped = await resolveGroupName(g);
|
|
193
|
-
if (mapped && mapped !== g) out.push(mapped);
|
|
194
|
-
}
|
|
195
|
-
return [...new Set(out)];
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function checkAnyMembership(uid, rawList, fnLabel) {
|
|
199
|
-
const candidates = await expandGroupCandidates(rawList);
|
|
200
|
-
for (const g of candidates) {
|
|
201
|
-
try {
|
|
202
|
-
const ok = await groups.isMember(uid, g);
|
|
203
|
-
if (ok) return { ok: true, matched: g, candidates };
|
|
204
|
-
} catch (e) {
|
|
205
|
-
logger.warn('[calendar-onekite] auth check error', { uid, group: g, fn: fnLabel, err: String(e && e.message || e) });
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return { ok: false, matched: '', candidates };
|
|
209
|
-
}
|
|
165
|
+
// (removed) group slug/name resolving helpers: we now use userInAnyGroup() which
|
|
166
|
+
// matches both slugs and names and avoids extra DB lookups.
|
|
210
167
|
|
|
211
168
|
|
|
212
169
|
function autoFormSlugForYear(year) {
|
|
@@ -278,8 +235,7 @@ async function canDeleteSpecial(uid, settings) {
|
|
|
278
235
|
try {
|
|
279
236
|
const isAdmin = await groups.isMember(uid, 'administrators');
|
|
280
237
|
if (isAdmin) return true;} catch (e) {}
|
|
281
|
-
const
|
|
282
|
-
const allowed = await resolveAllowedGroups(rawAllowed);
|
|
238
|
+
const allowed = normalizeAllowedGroups(settings.specialDeleterGroups || settings.specialCreatorGroups || '');
|
|
283
239
|
if (!allowed.length) return false;
|
|
284
240
|
if (await userInAnyGroup(uid, allowed)) return true;
|
|
285
241
|
|
|
@@ -378,8 +334,9 @@ api.getEvents = async function (req, res) {
|
|
|
378
334
|
const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
|
|
379
335
|
const ids = await dbLayer.listReservationIdsByStartRange(wideStart, endTs, 5000);
|
|
380
336
|
const out = [];
|
|
381
|
-
|
|
382
|
-
|
|
337
|
+
// Batch fetch = major perf win when there are many reservations.
|
|
338
|
+
const reservations = await dbLayer.getReservations(ids);
|
|
339
|
+
for (const r of (reservations || [])) {
|
|
383
340
|
if (!r) continue;
|
|
384
341
|
// Purged from calendar view (kept for accounting)
|
|
385
342
|
if (r.calendarPurgedAt) continue;
|
|
@@ -425,8 +382,8 @@ api.getEvents = async function (req, res) {
|
|
|
425
382
|
// Special events
|
|
426
383
|
try {
|
|
427
384
|
const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
|
|
428
|
-
|
|
429
|
-
|
|
385
|
+
const specials = await dbLayer.getSpecialEvents(specialIds);
|
|
386
|
+
for (const sev of (specials || [])) {
|
|
430
387
|
if (!sev) continue;
|
|
431
388
|
const sStart = parseInt(sev.start, 10);
|
|
432
389
|
const sEnd = parseInt(sev.end, 10);
|
|
@@ -677,8 +634,8 @@ api.createReservation = async function (req, res) {
|
|
|
677
634
|
const wideStart2 = Math.max(0, start - 366 * 24 * 3600 * 1000);
|
|
678
635
|
const candidateIds = await dbLayer.listReservationIdsByStartRange(wideStart2, end, 5000);
|
|
679
636
|
const conflicts = [];
|
|
680
|
-
|
|
681
|
-
|
|
637
|
+
const existingRows = await dbLayer.getReservations(candidateIds);
|
|
638
|
+
for (const existing of (existingRows || [])) {
|
|
682
639
|
if (!existing || !blocking.has(existing.status)) continue;
|
|
683
640
|
const exStart = parseInt(existing.start, 10);
|
|
684
641
|
const exEnd = parseInt(existing.end, 10);
|
|
@@ -729,24 +686,35 @@ api.createReservation = async function (req, res) {
|
|
|
729
686
|
const itemsLabel = (resv.itemNames || []).join(', ');
|
|
730
687
|
for (const g of notifyGroups) {
|
|
731
688
|
const members = await groups.getMembers(g, 0, -1);
|
|
732
|
-
|
|
733
|
-
|
|
689
|
+
const uids = Array.isArray(members) ? members : [];
|
|
690
|
+
|
|
691
|
+
// Batch fetch user email/username when supported by this NodeBB version.
|
|
692
|
+
let usersData = [];
|
|
693
|
+
try {
|
|
694
|
+
if (typeof user.getUsersFields === 'function') {
|
|
695
|
+
usersData = await user.getUsersFields(uids, ['username', 'email']);
|
|
696
|
+
} else {
|
|
697
|
+
usersData = await Promise.all(uids.map(async (memberUid) => {
|
|
698
|
+
try { return await user.getUserFields(memberUid, ['username', 'email']); }
|
|
699
|
+
catch (e) { return null; }
|
|
700
|
+
}));
|
|
701
|
+
}
|
|
702
|
+
} catch (e) {
|
|
703
|
+
usersData = [];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
for (const md of (usersData || [])) {
|
|
734
707
|
if (md && md.email) {
|
|
735
|
-
await sendEmail(
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
start: formatFR(start),
|
|
746
|
-
end: formatFR(end),
|
|
747
|
-
total: resv.total || 0,
|
|
748
|
-
}
|
|
749
|
-
);
|
|
708
|
+
await sendEmail('calendar-onekite_pending', md.email, 'Location matériel - Demande de réservation', {
|
|
709
|
+
username: md.username,
|
|
710
|
+
requester: requester.username,
|
|
711
|
+
itemName: itemsLabel,
|
|
712
|
+
itemNames: resv.itemNames || [],
|
|
713
|
+
dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
|
|
714
|
+
start: formatFR(start),
|
|
715
|
+
end: formatFR(end),
|
|
716
|
+
total: resv.total || 0,
|
|
717
|
+
});
|
|
750
718
|
}
|
|
751
719
|
}
|
|
752
720
|
}
|
|
@@ -755,6 +723,20 @@ api.createReservation = async function (req, res) {
|
|
|
755
723
|
console.warn('[calendar-onekite] Failed to send pending email', e && e.message ? e.message : e);
|
|
756
724
|
}
|
|
757
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
|
+
|
|
758
740
|
res.json({ ok: true, rid });
|
|
759
741
|
};
|
|
760
742
|
|
package/lib/db.js
CHANGED
|
@@ -10,10 +10,36 @@ const KEY_CHECKOUT_INTENT_TO_RID = 'calendar-onekite:helloasso:checkoutIntentToR
|
|
|
10
10
|
const KEY_SPECIAL_ZSET = 'calendar-onekite:special';
|
|
11
11
|
const KEY_SPECIAL_OBJ = (eid) => `calendar-onekite:special:${eid}`;
|
|
12
12
|
|
|
13
|
+
// Helpers
|
|
14
|
+
function reservationKey(rid) {
|
|
15
|
+
return KEY_OBJ(rid);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function specialKey(eid) {
|
|
19
|
+
return KEY_SPECIAL_OBJ(eid);
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
async function getReservation(rid) {
|
|
14
23
|
return await db.getObject(KEY_OBJ(rid));
|
|
15
24
|
}
|
|
16
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Batch fetch reservations in one DB roundtrip.
|
|
28
|
+
* Returns an array aligned with rids (missing objects => null).
|
|
29
|
+
*/
|
|
30
|
+
async function getReservations(rids) {
|
|
31
|
+
const ids = Array.isArray(rids) ? rids.filter(Boolean) : [];
|
|
32
|
+
if (!ids.length) return [];
|
|
33
|
+
const keys = ids.map(reservationKey);
|
|
34
|
+
const rows = await db.getObjects(keys);
|
|
35
|
+
// Ensure rid is present even if older objects were missing it.
|
|
36
|
+
return (rows || []).map((row, idx) => {
|
|
37
|
+
if (!row) return null;
|
|
38
|
+
if (!row.rid) row.rid = String(ids[idx]);
|
|
39
|
+
return row;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
async function saveReservation(resv) {
|
|
18
44
|
await db.setObject(KEY_OBJ(resv.rid), resv);
|
|
19
45
|
// score = start timestamp
|
|
@@ -50,10 +76,22 @@ module.exports = {
|
|
|
50
76
|
KEY_SPECIAL_ZSET,
|
|
51
77
|
KEY_CHECKOUT_INTENT_TO_RID,
|
|
52
78
|
getReservation,
|
|
79
|
+
getReservations,
|
|
53
80
|
saveReservation,
|
|
54
81
|
removeReservation,
|
|
55
82
|
// Special events
|
|
56
83
|
getSpecialEvent: async (eid) => await db.getObject(KEY_SPECIAL_OBJ(eid)),
|
|
84
|
+
getSpecialEvents: async (eids) => {
|
|
85
|
+
const ids = Array.isArray(eids) ? eids.filter(Boolean) : [];
|
|
86
|
+
if (!ids.length) return [];
|
|
87
|
+
const keys = ids.map(specialKey);
|
|
88
|
+
const rows = await db.getObjects(keys);
|
|
89
|
+
return (rows || []).map((row, idx) => {
|
|
90
|
+
if (!row) return null;
|
|
91
|
+
if (!row.eid) row.eid = String(ids[idx]);
|
|
92
|
+
return row;
|
|
93
|
+
});
|
|
94
|
+
},
|
|
57
95
|
saveSpecialEvent: async (ev) => {
|
|
58
96
|
await db.setObject(KEY_SPECIAL_OBJ(ev.eid), ev);
|
|
59
97
|
await db.sortedSetAdd(KEY_SPECIAL_ZSET, ev.start, ev.eid);
|
package/lib/discord.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
'Contenu du message',
|
|
77
|
+
'',
|
|
78
|
+
kind === 'paid' ? '**Matériel :**' : '**Matériel demandé :**',
|
|
79
|
+
];
|
|
80
|
+
for (const it of items) {
|
|
81
|
+
lines.push(`- ${it}`);
|
|
82
|
+
}
|
|
83
|
+
if (Number.isFinite(start) && Number.isFinite(end)) {
|
|
84
|
+
lines.push('');
|
|
85
|
+
lines.push(`Du ${formatFRShort(start)} au ${formatFRShort(end)}`);
|
|
86
|
+
}
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function notifyReservationRequested(settings, reservation) {
|
|
91
|
+
const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
|
|
92
|
+
if (!url) return;
|
|
93
|
+
if (!isEnabled(settings.discordNotifyOnRequest, true)) return;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await postWebhook(url, { content: buildReservationMessage('request', reservation) });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// eslint-disable-next-line no-console
|
|
99
|
+
console.warn('[calendar-onekite] Discord webhook failed (request)', e && e.message ? e.message : String(e));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function notifyPaymentReceived(settings, reservation) {
|
|
104
|
+
const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
|
|
105
|
+
if (!url) return;
|
|
106
|
+
if (!isEnabled(settings.discordNotifyOnPaid, true)) return;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await postWebhook(url, { content: buildReservationMessage('paid', reservation) });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// eslint-disable-next-line no-console
|
|
112
|
+
console.warn('[calendar-onekite] Discord webhook failed (paid)', e && e.message ? e.message : String(e));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
notifyReservationRequested,
|
|
118
|
+
notifyPaymentReceived,
|
|
119
|
+
};
|
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/scheduler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const meta = require.main.require('./src/meta');
|
|
4
|
+
const db = require.main.require('./src/database');
|
|
4
5
|
const dbLayer = require('./db');
|
|
5
6
|
|
|
6
7
|
let timer = null;
|
|
@@ -109,7 +110,16 @@ async function processAwaitingPayment() {
|
|
|
109
110
|
const expireAt = approvedAt + 2 * holdMins * 60 * 1000;
|
|
110
111
|
|
|
111
112
|
if (!r.reminderSent && now >= reminderAt && now < expireAt) {
|
|
112
|
-
// Send reminder once
|
|
113
|
+
// Send reminder once (guarded across clustered NodeBB processes)
|
|
114
|
+
const reminderKey = 'calendar-onekite:email:reminderSent';
|
|
115
|
+
const first = await db.setAdd(reminderKey, rid);
|
|
116
|
+
if (!first) {
|
|
117
|
+
// another process already sent it
|
|
118
|
+
r.reminderSent = true;
|
|
119
|
+
r.reminderAt = now;
|
|
120
|
+
await dbLayer.saveReservation(r);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
113
123
|
const u = await user.getUserFields(r.uid, ['username', 'email']);
|
|
114
124
|
if (u && u.email) {
|
|
115
125
|
await sendEmail('calendar-onekite_reminder', u.email, 'Location matériel - Rappel', {
|
|
@@ -130,8 +140,12 @@ async function processAwaitingPayment() {
|
|
|
130
140
|
|
|
131
141
|
if (now >= expireAt) {
|
|
132
142
|
// Expire: remove reservation so it disappears from calendar and frees items
|
|
143
|
+
// Guard email send across clustered NodeBB processes
|
|
144
|
+
const expiredKey = 'calendar-onekite:email:expiredSent';
|
|
145
|
+
const firstExpired = await db.setAdd(expiredKey, rid);
|
|
146
|
+
const shouldEmail = !!firstExpired;
|
|
133
147
|
const u = await user.getUserFields(r.uid, ['username', 'email']);
|
|
134
|
-
if (u && u.email) {
|
|
148
|
+
if (shouldEmail && u && u.email) {
|
|
135
149
|
await sendEmail('calendar-onekite_expired', u.email, 'Location matériel - Rappel', {
|
|
136
150
|
username: u.username,
|
|
137
151
|
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
package/lib/widgets.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
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 passes an object with .widgets array.
|
|
32
|
+
if (!widgetData || !Array.isArray(widgetData.widgets)) {
|
|
33
|
+
return widgetData;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
widgetData.widgets.push({
|
|
37
|
+
widget: 'calendar-onekite-twoweeks',
|
|
38
|
+
name: 'Calendrier OneKite (2 semaines)',
|
|
39
|
+
description: 'Affiche la semaine courante + la semaine suivante (FullCalendar via CDN).',
|
|
40
|
+
content: '',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return widgetData;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
widgets.renderTwoWeeksWidget = async function (data) {
|
|
47
|
+
// data: { widget, ... }
|
|
48
|
+
const id = makeDomId();
|
|
49
|
+
const calUrl = widgetCalendarUrl();
|
|
50
|
+
const apiBase = forumBaseUrl();
|
|
51
|
+
const eventsEndpoint = `${apiBase}/api/v3/plugins/calendar-onekite/events`;
|
|
52
|
+
|
|
53
|
+
const idJson = JSON.stringify(id);
|
|
54
|
+
const calUrlJson = JSON.stringify(calUrl);
|
|
55
|
+
const eventsEndpointJson = JSON.stringify(eventsEndpoint);
|
|
56
|
+
|
|
57
|
+
const html = `
|
|
58
|
+
<div class="onekite-twoweeks">
|
|
59
|
+
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
60
|
+
<div style="font-weight: 600;">Calendrier (2 semaines)</div>
|
|
61
|
+
<a href="${escapeHtml(calUrl)}" class="btn btn-sm btn-outline-secondary" style="line-height: 1.1;">Ouvrir</a>
|
|
62
|
+
</div>
|
|
63
|
+
<div id="${escapeHtml(id)}"></div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@latest/main.min.css" />
|
|
67
|
+
<script>
|
|
68
|
+
(function(){
|
|
69
|
+
const containerId = ${idJson};
|
|
70
|
+
const calUrl = ${calUrlJson};
|
|
71
|
+
const eventsEndpoint = ${eventsEndpointJson};
|
|
72
|
+
|
|
73
|
+
function loadOnce(tag, attrs) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
try {
|
|
76
|
+
const key = attrs && (attrs.id || attrs.href || attrs.src);
|
|
77
|
+
if (key && document.querySelector((attrs.id ? ('#' + attrs.id) : (attrs.href ? ('link[href="' + attrs.href + '"]') : ('script[src="' + attrs.src + '"]'))))) {
|
|
78
|
+
resolve();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const el = document.createElement(tag);
|
|
82
|
+
Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
|
|
83
|
+
el.onload = () => resolve();
|
|
84
|
+
el.onerror = () => reject(new Error('load-failed'));
|
|
85
|
+
document.head.appendChild(el);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
reject(e);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function ensureFullCalendar() {
|
|
93
|
+
if (window.FullCalendar && window.FullCalendar.Calendar) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await loadOnce('script', {
|
|
97
|
+
id: 'onekite-fullcalendar-global',
|
|
98
|
+
src: 'https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.js',
|
|
99
|
+
async: 'true'
|
|
100
|
+
});
|
|
101
|
+
await loadOnce('script', {
|
|
102
|
+
id: 'onekite-fullcalendar-locales',
|
|
103
|
+
src: 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@latest/locales-all.global.min.js',
|
|
104
|
+
async: 'true'
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function init() {
|
|
109
|
+
const el = document.getElementById(containerId);
|
|
110
|
+
if (!el) return;
|
|
111
|
+
|
|
112
|
+
await ensureFullCalendar();
|
|
113
|
+
|
|
114
|
+
// Define a 2-week dayGrid view
|
|
115
|
+
const calendar = new window.FullCalendar.Calendar(el, {
|
|
116
|
+
initialView: 'dayGridTwoWeek',
|
|
117
|
+
views: {
|
|
118
|
+
dayGridTwoWeek: {
|
|
119
|
+
type: 'dayGrid',
|
|
120
|
+
duration: { weeks: 2 },
|
|
121
|
+
buttonText: '2 semaines',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
locale: 'fr',
|
|
125
|
+
firstDay: 1,
|
|
126
|
+
height: 'auto',
|
|
127
|
+
headerToolbar: {
|
|
128
|
+
left: 'prev,next',
|
|
129
|
+
center: 'title',
|
|
130
|
+
right: '',
|
|
131
|
+
},
|
|
132
|
+
navLinks: false,
|
|
133
|
+
eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
|
|
134
|
+
events: function(info, successCallback, failureCallback) {
|
|
135
|
+
const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
|
|
136
|
+
fetch(eventsEndpoint + '?' + qs.toString(), { credentials: 'same-origin' })
|
|
137
|
+
.then((r) => r.json())
|
|
138
|
+
.then((json) => successCallback(json || []))
|
|
139
|
+
.catch((e) => failureCallback(e));
|
|
140
|
+
},
|
|
141
|
+
dateClick: function() {
|
|
142
|
+
window.location.href = calUrl;
|
|
143
|
+
},
|
|
144
|
+
eventClick: function() {
|
|
145
|
+
window.location.href = calUrl;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
calendar.render();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Widgets can be rendered after ajaxify; delay a tick.
|
|
153
|
+
setTimeout(() => init().catch(() => {}), 0);
|
|
154
|
+
})();
|
|
155
|
+
</script>
|
|
156
|
+
|
|
157
|
+
<style>
|
|
158
|
+
.onekite-twoweeks .fc .fc-toolbar-title { font-size: 1rem; }
|
|
159
|
+
.onekite-twoweeks .fc .fc-button { padding: .2rem .35rem; font-size: .75rem; }
|
|
160
|
+
.onekite-twoweeks .fc .fc-daygrid-day-number { font-size: .75rem; padding: 2px; }
|
|
161
|
+
.onekite-twoweeks .fc .fc-col-header-cell-cushion { font-size: .75rem; }
|
|
162
|
+
.onekite-twoweeks .fc .fc-event-title { font-size: .72rem; }
|
|
163
|
+
</style>
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
data = data || {};
|
|
167
|
+
data.html = html;
|
|
168
|
+
return data;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
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.1.1"
|
|
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">
|