nodebb-plugin-calendar-onekite 12.0.23 → 12.0.25
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/CHANGELOG.md +9 -11
- package/lib/admin.js +1 -1
- package/lib/api.js +49 -67
- package/lib/db.js +0 -7
- package/lib/discord.js +39 -3
- package/lib/helloasso.js +134 -60
- package/lib/scheduler.js +43 -13
- package/lib/widgets.js +2 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/lib/email.js +0 -51
- package/lib/settings.js +0 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
## 1.3.1
|
|
2
|
-
- Hotfix: fix duplicate `const supd` declaration causing startup crash.
|
|
3
|
-
|
|
4
|
-
## 1.3.0
|
|
5
|
-
### Optimisations / robustesse
|
|
6
|
-
- Scheduler : lecture des réservations en batch (suppression du N+1 DB) + envoi email factorisé.
|
|
7
|
-
- Settings : cache mémoire court (TTL) pour réduire les `meta.settings.get` répétitifs.
|
|
8
|
-
- API events : pagination interne (évite la troncature silencieuse > 5000) + ETag optimisé pour gros payloads.
|
|
9
|
-
- Dates allDay : formatage en **Europe/Paris** pour éviter les décalages UTC.
|
|
10
|
-
- Widget : titre simplifié (suppression de “2 semaines”).
|
|
11
|
-
|
|
12
1
|
## 1.1.0
|
|
2
|
+
|
|
3
|
+
## 1.3.4
|
|
4
|
+
- Fix: HelloAsso OAuth token rate-limit (429/1015) by caching token, de-duplicating requests, and adding backoff retries (no extra logs).
|
|
5
|
+
- Discord: add ⏳/💳 icons in embed titles.
|
|
13
6
|
### Perf / prod (NodeBB v4)
|
|
14
7
|
- FullCalendar : passage au CDN **@latest** et utilisation de `main.min.css` (supprime l’erreur 404 `index.global.min.css`).
|
|
15
8
|
- API `events` : payload allégé (les détails sont chargés à la demande), tri stable.
|
|
@@ -26,6 +19,11 @@
|
|
|
26
19
|
|
|
27
20
|
# Changelog
|
|
28
21
|
|
|
22
|
+
## 1.3.2
|
|
23
|
+
|
|
24
|
+
- Discord : notifications envoyées en **embed** (plus lisible) pour les demandes et paiements.
|
|
25
|
+
- Discord : contenu texte désactivé (embed only) pour éviter le bruit.
|
|
26
|
+
|
|
29
27
|
## 1.0.0
|
|
30
28
|
|
|
31
29
|
### ACP
|
package/lib/admin.js
CHANGED
|
@@ -149,7 +149,7 @@ admin.approveReservation = async function (req, res) {
|
|
|
149
149
|
clientSecret: settings.helloassoClientSecret,
|
|
150
150
|
});
|
|
151
151
|
if (!token) {
|
|
152
|
-
|
|
152
|
+
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
let paymentUrl = null;
|
package/lib/api.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
|
|
5
5
|
const meta = require.main.require('./src/meta');
|
|
6
|
+
const emailer = require.main.require('./src/emailer');
|
|
6
7
|
const nconf = require.main.require('nconf');
|
|
7
8
|
const user = require.main.require('./src/user');
|
|
8
9
|
const groups = require.main.require('./src/groups');
|
|
@@ -10,8 +11,6 @@ const db = require.main.require('./src/database');
|
|
|
10
11
|
const logger = require.main.require('./src/logger');
|
|
11
12
|
|
|
12
13
|
const dbLayer = require('./db');
|
|
13
|
-
const { getSettingsCached } = require('./settings');
|
|
14
|
-
const { sendEmail } = require('./email');
|
|
15
14
|
|
|
16
15
|
// Fast membership check without N calls to groups.isMember.
|
|
17
16
|
// NodeBB's groups.getUserGroups([uid]) returns an array (per uid) of group objects.
|
|
@@ -53,22 +52,37 @@ function normalizeAllowedGroups(raw) {
|
|
|
53
52
|
const helloasso = require('./helloasso');
|
|
54
53
|
const discord = require('./discord');
|
|
55
54
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
55
|
+
// Email helper: NodeBB's Emailer signature differs across versions.
|
|
56
|
+
// We try the common forms. Any failure is logged for debugging.
|
|
57
|
+
async function sendEmail(template, toEmail, subject, data) {
|
|
58
|
+
if (!toEmail) return;
|
|
59
|
+
try {
|
|
60
|
+
// NodeBB core signature (historically):
|
|
61
|
+
// Emailer.sendToEmail(template, email, language, params[, callback])
|
|
62
|
+
// Subject is not a positional arg; it must be injected (either by NodeBB itself
|
|
63
|
+
// or via filter:email.modify). We always pass it in params.subject.
|
|
64
|
+
const language = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
|
|
65
|
+
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
66
|
+
if (typeof emailer.sendToEmail === 'function') {
|
|
67
|
+
await emailer.sendToEmail(template, toEmail, language, params);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Fallback for older/unusual builds (rare)
|
|
71
|
+
if (typeof emailer.send === 'function') {
|
|
72
|
+
// Some builds accept (template, email, language, params)
|
|
73
|
+
if (emailer.send.length >= 4) {
|
|
74
|
+
await emailer.send(template, toEmail, language, params);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Some builds accept (template, email, params)
|
|
78
|
+
await emailer.send(template, toEmail, params);
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String(err) });
|
|
83
|
+
}
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
// sendEmail is provided by ./email
|
|
71
|
-
|
|
72
86
|
function normalizeBaseUrl(meta) {
|
|
73
87
|
// Prefer meta.config.url, fallback to nconf.get('url')
|
|
74
88
|
let base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
@@ -236,8 +250,8 @@ function eventsFor(resv) {
|
|
|
236
250
|
const status = resv.status;
|
|
237
251
|
const icons = { pending: '⏳', awaiting_payment: '💳', paid: '✅' };
|
|
238
252
|
const colors = { pending: '#f39c12', awaiting_payment: '#d35400', paid: '#27ae60' };
|
|
239
|
-
const startIsoDate =
|
|
240
|
-
const endIsoDate =
|
|
253
|
+
const startIsoDate = new Date(parseInt(resv.start, 10)).toISOString().slice(0, 10);
|
|
254
|
+
const endIsoDate = new Date(parseInt(resv.end, 10)).toISOString().slice(0, 10);
|
|
241
255
|
|
|
242
256
|
const itemIds = Array.isArray(resv.itemIds) ? resv.itemIds : (resv.itemId ? [resv.itemId] : []);
|
|
243
257
|
const itemNames = Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : []);
|
|
@@ -304,11 +318,9 @@ function eventsForSpecial(ev) {
|
|
|
304
318
|
|
|
305
319
|
const api = {};
|
|
306
320
|
|
|
307
|
-
function computeEtag(payload
|
|
321
|
+
function computeEtag(payload) {
|
|
308
322
|
// Weak ETag is fine here: it is only used to skip identical JSON payloads.
|
|
309
|
-
|
|
310
|
-
const input = metaSig ? String(metaSig) : JSON.stringify(payload);
|
|
311
|
-
const hash = crypto.createHash('sha1').update(input).digest('hex');
|
|
323
|
+
const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
|
|
312
324
|
return `W/"${hash}"`;
|
|
313
325
|
}
|
|
314
326
|
|
|
@@ -316,7 +328,7 @@ api.getEvents = async function (req, res) {
|
|
|
316
328
|
const startTs = toTs(req.query.start) || 0;
|
|
317
329
|
const endTs = toTs(req.query.end) || (Date.now() + 365 * 24 * 3600 * 1000);
|
|
318
330
|
|
|
319
|
-
const settings = await
|
|
331
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
320
332
|
const canMod = req.uid ? await canValidate(req.uid, settings) : false;
|
|
321
333
|
const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
|
|
322
334
|
const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
|
|
@@ -324,27 +336,8 @@ api.getEvents = async function (req, res) {
|
|
|
324
336
|
// Fetch a wider window because an event can start before the query range
|
|
325
337
|
// and still overlap.
|
|
326
338
|
const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
|
|
327
|
-
|
|
328
|
-
const CHUNK = 5000;
|
|
329
|
-
let offset = 0;
|
|
330
|
-
const ids = [];
|
|
331
|
-
while (true) {
|
|
332
|
-
const part = await dbLayer.listReservationIdsByStartRangePaged(wideStart, endTs, offset, CHUNK);
|
|
333
|
-
if (!part || !part.length) break;
|
|
334
|
-
ids.push(...part);
|
|
335
|
-
if (part.length < CHUNK) break;
|
|
336
|
-
offset += CHUNK;
|
|
337
|
-
// Safety to avoid unbounded loops on pathological datasets.
|
|
338
|
-
if (offset > 200000) {
|
|
339
|
-
logger.warn('[calendar-onekite] getEvents exceeded 200k ids; truncating response', {
|
|
340
|
-
wideStart,
|
|
341
|
-
endTs,
|
|
342
|
-
});
|
|
343
|
-
break;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
339
|
+
const ids = await dbLayer.listReservationIdsByStartRange(wideStart, endTs, 5000);
|
|
346
340
|
const out = [];
|
|
347
|
-
let maxUpdated = 0;
|
|
348
341
|
// Batch fetch = major perf win when there are many reservations.
|
|
349
342
|
const reservations = await dbLayer.getReservations(ids);
|
|
350
343
|
for (const r of (reservations || [])) {
|
|
@@ -356,8 +349,6 @@ api.getEvents = async function (req, res) {
|
|
|
356
349
|
const rStart = parseInt(r.start, 10);
|
|
357
350
|
const rEnd = parseInt(r.end, 10);
|
|
358
351
|
if (!(rStart < endTs && startTs < rEnd)) continue; // overlap check
|
|
359
|
-
const upd = parseInt(r.updatedAt || r.modifiedAt || r.approvedAt || r.validatedAt || r.createdAt || 0, 10) || 0;
|
|
360
|
-
if (upd > maxUpdated) maxUpdated = upd;
|
|
361
352
|
const evs = eventsFor(r);
|
|
362
353
|
for (const ev of evs) {
|
|
363
354
|
const p = ev.extendedProps || {};
|
|
@@ -395,17 +386,12 @@ api.getEvents = async function (req, res) {
|
|
|
395
386
|
// Special events
|
|
396
387
|
try {
|
|
397
388
|
const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
|
|
398
|
-
if (Array.isArray(specialIds) && specialIds.length === 5000) {
|
|
399
|
-
logger.warn('[calendar-onekite] getEvents special list may be truncated at 5000', { wideStart, endTs });
|
|
400
|
-
}
|
|
401
389
|
const specials = await dbLayer.getSpecialEvents(specialIds);
|
|
402
390
|
for (const sev of (specials || [])) {
|
|
403
391
|
if (!sev) continue;
|
|
404
392
|
const sStart = parseInt(sev.start, 10);
|
|
405
393
|
const sEnd = parseInt(sev.end, 10);
|
|
406
394
|
if (!(sStart < endTs && startTs < sEnd)) continue;
|
|
407
|
-
const supd = parseInt(sev.updatedAt || sev.modifiedAt || sev.createdAt || 0, 10) || 0;
|
|
408
|
-
if (supd > maxUpdated) maxUpdated = supd;
|
|
409
395
|
const full = eventsForSpecial(sev);
|
|
410
396
|
const minimal = {
|
|
411
397
|
id: full.id,
|
|
@@ -442,11 +428,7 @@ api.getEvents = async function (req, res) {
|
|
|
442
428
|
return ai < bi ? -1 : ai > bi ? 1 : 0;
|
|
443
429
|
});
|
|
444
430
|
|
|
445
|
-
|
|
446
|
-
const metaSig = out.length > 2000
|
|
447
|
-
? `start:${startTs};end:${endTs};wide:${wideStart};len:${out.length};maxUpdated:${maxUpdated};mod:${canMod?1:0};sc:${canSpecialCreate?1:0};sd:${canSpecialDelete?1:0}`
|
|
448
|
-
: null;
|
|
449
|
-
const etag = computeEtag(out, metaSig);
|
|
431
|
+
const etag = computeEtag(out);
|
|
450
432
|
res.setHeader('ETag', etag);
|
|
451
433
|
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
452
434
|
if (String(req.headers['if-none-match'] || '') === etag) {
|
|
@@ -460,7 +442,7 @@ api.getReservationDetails = async function (req, res) {
|
|
|
460
442
|
const uid = req.uid;
|
|
461
443
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
462
444
|
|
|
463
|
-
const settings = await
|
|
445
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
464
446
|
const canMod = await canValidate(uid, settings);
|
|
465
447
|
|
|
466
448
|
const rid = String(req.params.rid || '').trim();
|
|
@@ -502,7 +484,7 @@ api.getSpecialEventDetails = async function (req, res) {
|
|
|
502
484
|
const uid = req.uid;
|
|
503
485
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
504
486
|
|
|
505
|
-
const settings = await
|
|
487
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
506
488
|
const canMod = await canValidate(uid, settings);
|
|
507
489
|
const canSpecialDelete = await canDeleteSpecial(uid, settings);
|
|
508
490
|
|
|
@@ -531,7 +513,7 @@ api.getSpecialEventDetails = async function (req, res) {
|
|
|
531
513
|
};
|
|
532
514
|
|
|
533
515
|
api.getCapabilities = async function (req, res) {
|
|
534
|
-
const settings = await
|
|
516
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
535
517
|
const uid = req.uid || 0;
|
|
536
518
|
const canMod = uid ? await canValidate(uid, settings) : false;
|
|
537
519
|
res.json({
|
|
@@ -542,7 +524,7 @@ api.getCapabilities = async function (req, res) {
|
|
|
542
524
|
};
|
|
543
525
|
|
|
544
526
|
api.createSpecialEvent = async function (req, res) {
|
|
545
|
-
const settings = await
|
|
527
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
546
528
|
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
547
529
|
const ok = await canCreateSpecial(req.uid, settings);
|
|
548
530
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
@@ -578,7 +560,7 @@ api.createSpecialEvent = async function (req, res) {
|
|
|
578
560
|
};
|
|
579
561
|
|
|
580
562
|
api.deleteSpecialEvent = async function (req, res) {
|
|
581
|
-
const settings = await
|
|
563
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
582
564
|
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
583
565
|
const ok = await canDeleteSpecial(req.uid, settings);
|
|
584
566
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
@@ -589,7 +571,7 @@ api.deleteSpecialEvent = async function (req, res) {
|
|
|
589
571
|
};
|
|
590
572
|
|
|
591
573
|
api.getItems = async function (req, res) {
|
|
592
|
-
const settings = await
|
|
574
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
593
575
|
|
|
594
576
|
const env = settings.helloassoEnv || 'prod';
|
|
595
577
|
const token = await helloasso.getAccessToken({
|
|
@@ -598,7 +580,7 @@ api.getItems = async function (req, res) {
|
|
|
598
580
|
clientSecret: settings.helloassoClientSecret,
|
|
599
581
|
});
|
|
600
582
|
if (!token) {
|
|
601
|
-
|
|
583
|
+
|
|
602
584
|
}
|
|
603
585
|
|
|
604
586
|
if (!token) {
|
|
@@ -630,7 +612,7 @@ api.createReservation = async function (req, res) {
|
|
|
630
612
|
const uid = req.uid;
|
|
631
613
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
632
614
|
|
|
633
|
-
const settings = await
|
|
615
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
634
616
|
const startPreview = toTs(req.body.start);
|
|
635
617
|
const ok = await canRequest(uid, settings, startPreview);
|
|
636
618
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
@@ -766,7 +748,7 @@ api.createReservation = async function (req, res) {
|
|
|
766
748
|
api.approveReservation = async function (req, res) {
|
|
767
749
|
const uid = req.uid;
|
|
768
750
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
769
|
-
const settings = await
|
|
751
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
770
752
|
const ok = await canValidate(uid, settings);
|
|
771
753
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
772
754
|
|
|
@@ -793,7 +775,7 @@ api.approveReservation = async function (req, res) {
|
|
|
793
775
|
}
|
|
794
776
|
// Create HelloAsso payment link on validation
|
|
795
777
|
try {
|
|
796
|
-
const settings2 = await
|
|
778
|
+
const settings2 = await meta.settings.get('calendar-onekite');
|
|
797
779
|
const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
|
|
798
780
|
const payer = await user.getUserFields(r.uid, ['email']);
|
|
799
781
|
const year = yearFromTs(r.start);
|
|
@@ -869,7 +851,7 @@ api.approveReservation = async function (req, res) {
|
|
|
869
851
|
api.refuseReservation = async function (req, res) {
|
|
870
852
|
const uid = req.uid;
|
|
871
853
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
872
|
-
const settings = await
|
|
854
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
873
855
|
const ok = await canValidate(uid, settings);
|
|
874
856
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
875
857
|
|
|
@@ -904,7 +886,7 @@ api.cancelReservation = async function (req, res) {
|
|
|
904
886
|
const uid = req.uid;
|
|
905
887
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
906
888
|
|
|
907
|
-
const settings = await
|
|
889
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
908
890
|
const rid = String(req.params.rid || '').trim();
|
|
909
891
|
if (!rid) return res.status(400).json({ error: 'missing-rid' });
|
|
910
892
|
|
package/lib/db.js
CHANGED
|
@@ -67,12 +67,6 @@ async function listReservationIdsByStartRange(startTs, endTs, limit = 1000) {
|
|
|
67
67
|
return await db.getSortedSetRangeByScore(KEY_ZSET, start, stop, startTs, endTs);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
async function listReservationIdsByStartRangePaged(startTs, endTs, offset = 0, limit = 1000) {
|
|
71
|
-
const start = Math.max(0, parseInt(offset, 10) || 0);
|
|
72
|
-
const stop = start + Math.max(0, (parseInt(limit, 10) || 1000) - 1);
|
|
73
|
-
return await db.getSortedSetRangeByScore(KEY_ZSET, start, stop, startTs, endTs);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
70
|
async function listAllReservationIds(limit = 5000) {
|
|
77
71
|
return await db.getSortedSetRange(KEY_ZSET, 0, limit - 1);
|
|
78
72
|
}
|
|
@@ -112,6 +106,5 @@ module.exports = {
|
|
|
112
106
|
return await db.getSortedSetRangeByScore(KEY_SPECIAL_ZSET, start, stop, startTs, endTs);
|
|
113
107
|
},
|
|
114
108
|
listReservationIdsByStartRange,
|
|
115
|
-
listReservationIdsByStartRangePaged,
|
|
116
109
|
listAllReservationIds,
|
|
117
110
|
};
|
package/lib/discord.js
CHANGED
|
@@ -88,10 +88,46 @@ function buildReservationMessage(kind, reservation) {
|
|
|
88
88
|
function buildWebhookPayload(kind, reservation) {
|
|
89
89
|
// Discord "regroupe" visuellement les messages consécutifs d'un même auteur.
|
|
90
90
|
// En utilisant un username différent par action, on obtient un message bien distinct.
|
|
91
|
-
const
|
|
91
|
+
const webhookUsername = kind === 'paid' ? 'OneKite • Paiement' : 'OneKite • Réservation';
|
|
92
|
+
|
|
93
|
+
const calUrl = 'https://www.onekite.com/calendar';
|
|
94
|
+
const username = reservation && reservation.username ? String(reservation.username) : '';
|
|
95
|
+
const items = (reservation && Array.isArray(reservation.itemNames) && reservation.itemNames.length)
|
|
96
|
+
? reservation.itemNames.map(String)
|
|
97
|
+
: (reservation && Array.isArray(reservation.itemIds) ? reservation.itemIds.map(String) : []);
|
|
98
|
+
const start = reservation && reservation.start ? Number(reservation.start) : NaN;
|
|
99
|
+
const end = reservation && reservation.end ? Number(reservation.end) : NaN;
|
|
100
|
+
|
|
101
|
+
const title = kind === 'paid' ? '💳 Paiement reçu' : '⏳ Demande de réservation';
|
|
102
|
+
|
|
103
|
+
const fields = [];
|
|
104
|
+
if (username) {
|
|
105
|
+
fields.push({ name: 'Membre', value: username, inline: true });
|
|
106
|
+
}
|
|
107
|
+
if (items.length) {
|
|
108
|
+
const label = kind === 'paid' ? 'Matériel' : 'Matériel demandé';
|
|
109
|
+
fields.push({ name: label, value: items.map((it) => `• ${it}`).join('\n'), inline: false });
|
|
110
|
+
}
|
|
111
|
+
if (Number.isFinite(start) && Number.isFinite(end)) {
|
|
112
|
+
fields.push({ name: 'Période', value: `Du ${formatFRShort(start)} au ${formatFRShort(end)}`, inline: false });
|
|
113
|
+
}
|
|
114
|
+
|
|
92
115
|
return {
|
|
93
|
-
username,
|
|
94
|
-
content
|
|
116
|
+
username: webhookUsername,
|
|
117
|
+
// On laisse "content" vide pour privilégier l'embed (plus lisible sur Discord)
|
|
118
|
+
content: '',
|
|
119
|
+
embeds: [
|
|
120
|
+
{
|
|
121
|
+
title,
|
|
122
|
+
url: calUrl,
|
|
123
|
+
description: kind === 'paid'
|
|
124
|
+
? 'Un paiement a été reçu pour une réservation.'
|
|
125
|
+
: 'Une nouvelle demande de réservation a été créée.',
|
|
126
|
+
fields,
|
|
127
|
+
footer: { text: 'OneKite • Calendrier' },
|
|
128
|
+
timestamp: new Date().toISOString(),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
95
131
|
};
|
|
96
132
|
}
|
|
97
133
|
|
package/lib/helloasso.js
CHANGED
|
@@ -54,8 +54,41 @@ function baseUrl(env) {
|
|
|
54
54
|
: 'https://api.helloasso.com';
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// In-memory access token cache (per NodeBB process)
|
|
58
|
+
let _tokenCache = { token: null, expiresAt: 0 };
|
|
59
|
+
let _tokenInFlight = null;
|
|
60
|
+
|
|
61
|
+
function _sleep(ms) {
|
|
62
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _tokenLooksValid() {
|
|
66
|
+
// Refresh 60s before expiration to avoid edge cases
|
|
67
|
+
return !!(_tokenCache.token && Date.now() < (_tokenCache.expiresAt - 60_000));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _isRateLimited(status, parsedJson, bodySnippet) {
|
|
71
|
+
if (status === 429) return true;
|
|
72
|
+
const snip = String(bodySnippet || '').toLowerCase();
|
|
73
|
+
if (snip.includes('1015')) return true; // Cloudflare rate limited
|
|
74
|
+
if (parsedJson && typeof parsedJson === 'object') {
|
|
75
|
+
const j = JSON.stringify(parsedJson).toLowerCase();
|
|
76
|
+
if (j.includes('1015')) return true;
|
|
77
|
+
if (j.includes('rate') && j.includes('limit')) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _retryAfterMs(headers) {
|
|
83
|
+
if (!headers) return 0;
|
|
84
|
+
const ra = headers['retry-after'] || headers['Retry-After'];
|
|
85
|
+
if (!ra) return 0;
|
|
86
|
+
const n = parseInt(Array.isArray(ra) ? ra[0] : String(ra), 10);
|
|
87
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
88
|
+
return Math.min(120_000, n * 1000);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _requestAccessTokenRaw({ env, clientId, clientSecret }) {
|
|
59
92
|
const url = `${baseUrl(env)}/oauth2/token`;
|
|
60
93
|
const body = new URLSearchParams({
|
|
61
94
|
grant_type: 'client_credentials',
|
|
@@ -63,67 +96,108 @@ async function getAccessToken({ env, clientId, clientSecret }) {
|
|
|
63
96
|
client_secret: clientSecret,
|
|
64
97
|
}).toString();
|
|
65
98
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
try {
|
|
101
|
+
const u = new URL(url);
|
|
102
|
+
const req = https.request(
|
|
103
|
+
{
|
|
104
|
+
method: 'POST',
|
|
105
|
+
hostname: u.hostname,
|
|
106
|
+
port: u.port || 443,
|
|
107
|
+
path: u.pathname + u.search,
|
|
108
|
+
headers: {
|
|
109
|
+
'Accept': 'application/json',
|
|
110
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
111
|
+
'Content-Length': Buffer.byteLength(body),
|
|
112
|
+
},
|
|
80
113
|
},
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
114
|
+
(res) => {
|
|
115
|
+
let data = '';
|
|
116
|
+
res.setEncoding('utf8');
|
|
117
|
+
res.on('data', (chunk) => (data += chunk));
|
|
118
|
+
res.on('end', () => {
|
|
119
|
+
const status = res.statusCode || 0;
|
|
120
|
+
const headers = res.headers || {};
|
|
121
|
+
const snippet = String(data || '').slice(0, 1000);
|
|
122
|
+
resolve({ status, headers, bodyText: data || '', snippet });
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
req.on('error', (err) => {
|
|
127
|
+
resolve({ status: 0, headers: {}, bodyText: '', snippet: err && err.message ? String(err.message) : String(err) });
|
|
128
|
+
});
|
|
129
|
+
req.write(body);
|
|
130
|
+
req.end();
|
|
131
|
+
} catch (e) {
|
|
132
|
+
resolve({ status: 0, headers: {}, bodyText: '', snippet: e && e.message ? String(e.message) : String(e) });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
104
136
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
137
|
+
async function _fetchAccessTokenWithRetry(params) {
|
|
138
|
+
// 3 attempts max with exponential backoff on rate-limit/network
|
|
139
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
140
|
+
const { status, headers, bodyText, snippet } = await _requestAccessTokenRaw(params);
|
|
141
|
+
|
|
142
|
+
// 2xx: parse and return token (+ cache expiry)
|
|
143
|
+
if (status >= 200 && status < 300) {
|
|
144
|
+
try {
|
|
145
|
+
const json = JSON.parse(bodyText || '{}');
|
|
146
|
+
const token = json && json.access_token ? String(json.access_token) : null;
|
|
147
|
+
const expiresIn = parseInt(json && json.expires_in ? String(json.expires_in) : '3600', 10) || 3600;
|
|
148
|
+
if (token) {
|
|
149
|
+
const expiresAt = Date.now() + Math.max(60, expiresIn) * 1000;
|
|
150
|
+
return { token, expiresAt };
|
|
151
|
+
}
|
|
152
|
+
return { token: null, expiresAt: 0 };
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return { token: null, expiresAt: 0 };
|
|
118
155
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Non-2xx: decide whether to retry
|
|
159
|
+
let parsed = null;
|
|
160
|
+
try { parsed = bodyText ? JSON.parse(bodyText) : null; } catch (e) { /* ignore */ }
|
|
161
|
+
|
|
162
|
+
const rateLimited = _isRateLimited(status, parsed, snippet);
|
|
163
|
+
const networkish = status === 0;
|
|
164
|
+
|
|
165
|
+
if ((rateLimited || networkish) && attempt < 2) {
|
|
166
|
+
const base = 1500 * (2 ** attempt);
|
|
167
|
+
const ra = _retryAfterMs(headers);
|
|
168
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
169
|
+
const waitMs = Math.min(60_000, Math.max(base, ra) + jitter);
|
|
170
|
+
await _sleep(waitMs);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { token: null, expiresAt: 0 };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { token: null, expiresAt: 0 };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function getAccessToken({ env, clientId, clientSecret }) {
|
|
181
|
+
if (!clientId || !clientSecret) return null;
|
|
182
|
+
|
|
183
|
+
if (_tokenLooksValid()) return _tokenCache.token;
|
|
184
|
+
|
|
185
|
+
// De-duplicate concurrent token requests (prevents bursts -> 429/1015)
|
|
186
|
+
if (_tokenInFlight) return _tokenInFlight;
|
|
187
|
+
|
|
188
|
+
_tokenInFlight = (async () => {
|
|
189
|
+
const { token, expiresAt } = await _fetchAccessTokenWithRetry({ env, clientId, clientSecret });
|
|
190
|
+
if (token) {
|
|
191
|
+
_tokenCache = { token, expiresAt };
|
|
192
|
+
}
|
|
193
|
+
return token || null;
|
|
194
|
+
})();
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
return await _tokenInFlight;
|
|
198
|
+
} finally {
|
|
199
|
+
_tokenInFlight = null;
|
|
200
|
+
}
|
|
127
201
|
}
|
|
128
202
|
|
|
129
203
|
async function listItems({ env, token, organizationSlug, formType, formSlug }) {
|
package/lib/scheduler.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const meta = require.main.require('./src/meta');
|
|
3
4
|
const db = require.main.require('./src/database');
|
|
4
5
|
const dbLayer = require('./db');
|
|
5
6
|
|
|
6
|
-
const { getSettingsCached } = require('./settings');
|
|
7
|
-
const { sendEmail } = require('./email');
|
|
8
|
-
|
|
9
7
|
let timer = null;
|
|
10
8
|
|
|
11
9
|
function getSetting(settings, key, fallback) {
|
|
@@ -17,7 +15,7 @@ function getSetting(settings, key, fallback) {
|
|
|
17
15
|
|
|
18
16
|
// Pending holds: short lock after a user creates a request (defaults to 5 minutes)
|
|
19
17
|
async function expirePending() {
|
|
20
|
-
const settings = await
|
|
18
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
21
19
|
const holdMins = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
|
|
22
20
|
const now = Date.now();
|
|
23
21
|
|
|
@@ -26,10 +24,8 @@ async function expirePending() {
|
|
|
26
24
|
return;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const rid = ids[i];
|
|
32
|
-
const resv = reservations[i];
|
|
27
|
+
for (const rid of ids) {
|
|
28
|
+
const resv = await dbLayer.getReservation(rid);
|
|
33
29
|
if (!resv || resv.status !== 'pending') {
|
|
34
30
|
continue;
|
|
35
31
|
}
|
|
@@ -47,7 +43,7 @@ async function expirePending() {
|
|
|
47
43
|
// - We send a reminder after `paymentHoldMinutes` (default 60)
|
|
48
44
|
// - We expire (and remove) after `2 * paymentHoldMinutes`
|
|
49
45
|
async function processAwaitingPayment() {
|
|
50
|
-
const settings = await
|
|
46
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
51
47
|
const holdMins = parseInt(
|
|
52
48
|
getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')),
|
|
53
49
|
10
|
|
@@ -57,8 +53,44 @@ async function processAwaitingPayment() {
|
|
|
57
53
|
const ids = await dbLayer.listAllReservationIds(5000);
|
|
58
54
|
if (!ids || !ids.length) return;
|
|
59
55
|
|
|
56
|
+
const emailer = require.main.require('./src/emailer');
|
|
60
57
|
const user = require.main.require('./src/user');
|
|
61
58
|
|
|
59
|
+
async function sendEmail(template, toEmail, subject, data) {
|
|
60
|
+
if (!toEmail) return;
|
|
61
|
+
try {
|
|
62
|
+
// NodeBB core signature:
|
|
63
|
+
// Emailer.sendToEmail(template, email, language, params[, callback])
|
|
64
|
+
// Subject is NOT a positional argument; it must be provided in params.subject
|
|
65
|
+
// and optionally copied into the final email by filter:email.modify.
|
|
66
|
+
const language = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
|
|
67
|
+
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
68
|
+
|
|
69
|
+
if (typeof emailer.sendToEmail === 'function') {
|
|
70
|
+
await emailer.sendToEmail(template, toEmail, language, params);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fallbacks for older/unusual builds
|
|
75
|
+
if (typeof emailer.send === 'function') {
|
|
76
|
+
// Some builds accept (template, email, language, params)
|
|
77
|
+
if (emailer.send.length >= 4) {
|
|
78
|
+
await emailer.send(template, toEmail, language, params);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Some builds accept (template, email, params)
|
|
82
|
+
await emailer.send(template, toEmail, params);
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
console.warn('[calendar-onekite] Failed to send email (scheduler)', {
|
|
87
|
+
template,
|
|
88
|
+
toEmail,
|
|
89
|
+
err: String((err && err.message) || err),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
62
94
|
function formatFR(ts) {
|
|
63
95
|
const d = new Date(ts);
|
|
64
96
|
const dd = String(d.getDate()).padStart(2, '0');
|
|
@@ -67,10 +99,8 @@ async function processAwaitingPayment() {
|
|
|
67
99
|
return `${dd}/${mm}/${yyyy}`;
|
|
68
100
|
}
|
|
69
101
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
const rid = ids[i];
|
|
73
|
-
const r = reservations[i];
|
|
102
|
+
for (const rid of ids) {
|
|
103
|
+
const r = await dbLayer.getReservation(rid);
|
|
74
104
|
if (!r || r.status !== 'awaiting_payment') continue;
|
|
75
105
|
|
|
76
106
|
const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
|
package/lib/widgets.js
CHANGED
|
@@ -41,7 +41,7 @@ widgets.defineWidgets = async function (widgetData) {
|
|
|
41
41
|
|
|
42
42
|
list.push({
|
|
43
43
|
widget: 'calendar-onekite-twoweeks',
|
|
44
|
-
name: 'Calendrier OneKite',
|
|
44
|
+
name: 'Calendrier OneKite (2 semaines)',
|
|
45
45
|
description: 'Affiche la semaine courante + la semaine suivante (FullCalendar via CDN).',
|
|
46
46
|
content: '',
|
|
47
47
|
});
|
|
@@ -63,7 +63,7 @@ widgets.renderTwoWeeksWidget = async function (data) {
|
|
|
63
63
|
const html = `
|
|
64
64
|
<div class="onekite-twoweeks">
|
|
65
65
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
66
|
-
<div style="font-weight: 600;">Calendrier</div>
|
|
66
|
+
<div style="font-weight: 600;">Calendrier (2 semaines)</div>
|
|
67
67
|
<a href="${escapeHtml(calUrl)}" class="btn btn-sm btn-outline-secondary" style="line-height: 1.1;">Ouvrir</a>
|
|
68
68
|
</div>
|
|
69
69
|
<div id="${escapeHtml(id)}"></div>
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/lib/email.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const meta = require.main.require('./src/meta');
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Send an email using NodeBB's emailer, supporting common signatures.
|
|
7
|
-
*
|
|
8
|
-
* @param {string} template
|
|
9
|
-
* @param {string} toEmail
|
|
10
|
-
* @param {string} subject
|
|
11
|
-
* @param {object} data
|
|
12
|
-
*/
|
|
13
|
-
async function sendEmail(template, toEmail, subject, data) {
|
|
14
|
-
if (!toEmail) return;
|
|
15
|
-
const emailer = require.main.require('./src/emailer');
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
// NodeBB core signature:
|
|
19
|
-
// Emailer.sendToEmail(template, email, language, params[, callback])
|
|
20
|
-
// Subject is not positional; pass it in params so filters can use it.
|
|
21
|
-
const language = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
|
|
22
|
-
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
23
|
-
|
|
24
|
-
if (typeof emailer.sendToEmail === 'function') {
|
|
25
|
-
await emailer.sendToEmail(template, toEmail, language, params);
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Fallbacks for older/unusual builds
|
|
30
|
-
if (typeof emailer.send === 'function') {
|
|
31
|
-
// Some builds accept (template, email, language, params)
|
|
32
|
-
if (emailer.send.length >= 4) {
|
|
33
|
-
await emailer.send(template, toEmail, language, params);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
// Some builds accept (template, email, params)
|
|
37
|
-
await emailer.send(template, toEmail, params);
|
|
38
|
-
}
|
|
39
|
-
} catch (err) {
|
|
40
|
-
// eslint-disable-next-line no-console
|
|
41
|
-
console.warn('[calendar-onekite] Failed to send email', {
|
|
42
|
-
template,
|
|
43
|
-
toEmail,
|
|
44
|
-
err: String((err && err.message) || err),
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
module.exports = {
|
|
50
|
-
sendEmail,
|
|
51
|
-
};
|
package/lib/settings.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const meta = require.main.require('./src/meta');
|
|
4
|
-
|
|
5
|
-
// Tiny in-memory cache to avoid repeated meta.settings.get() calls.
|
|
6
|
-
// Safe because NodeBB settings rarely change per-second.
|
|
7
|
-
const cache = new Map();
|
|
8
|
-
|
|
9
|
-
async function getSettingsCached(namespace, ttlMs = 3000) {
|
|
10
|
-
const key = String(namespace || '');
|
|
11
|
-
const now = Date.now();
|
|
12
|
-
const hit = cache.get(key);
|
|
13
|
-
if (hit && hit.expiresAt > now) {
|
|
14
|
-
return hit.value;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
let value = {};
|
|
18
|
-
try {
|
|
19
|
-
value = (await meta.settings.get(key)) || {};
|
|
20
|
-
} catch (e) {
|
|
21
|
-
value = {};
|
|
22
|
-
}
|
|
23
|
-
cache.set(key, { value, expiresAt: now + (parseInt(ttlMs, 10) || 0) });
|
|
24
|
-
return value;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function invalidateSettings(namespace) {
|
|
28
|
-
cache.delete(String(namespace || ''));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
module.exports = {
|
|
32
|
-
getSettingsCached,
|
|
33
|
-
invalidateSettings,
|
|
34
|
-
};
|