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 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
- console.warn('[calendar-onekite] HelloAsso access token not obtained (approve ACP)', { rid: r && r.rid });
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
- // Format yyyy-mm-dd in Europe/Paris to avoid UTC day-shifts for allDay events.
57
- const PARIS_DATE_FMT = new Intl.DateTimeFormat('en-CA', {
58
- timeZone: 'Europe/Paris',
59
- year: 'numeric',
60
- month: '2-digit',
61
- day: '2-digit',
62
- });
63
-
64
- function toParisIsoDate(ts) {
65
- const n = parseInt(ts, 10);
66
- if (!n) return '';
67
- return PARIS_DATE_FMT.format(new Date(n)); // en-CA => YYYY-MM-DD
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 = toParisIsoDate(resv.start);
240
- const endIsoDate = toParisIsoDate(resv.end);
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, metaSig) {
321
+ function computeEtag(payload) {
308
322
  // Weak ETag is fine here: it is only used to skip identical JSON payloads.
309
- // For large payloads, hashing a short signature is cheaper than JSON.stringify(...).
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 getSettingsCached('calendar-onekite');
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
- // Avoid silent truncation if there are >5000 matches.
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
- // For large payloads, avoid hashing the full JSON string.
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 getSettingsCached('calendar-onekite');
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 getSettingsCached('calendar-onekite');
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 getSettingsCached('calendar-onekite');
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 getSettingsCached('calendar-onekite');
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 getSettingsCached('calendar-onekite');
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 getSettingsCached('calendar-onekite');
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
- console.warn('[calendar-onekite] HelloAsso access token not obtained (items API)', { uid: req.uid || 0 });
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 getSettingsCached('calendar-onekite');
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 getSettingsCached('calendar-onekite');
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 getSettingsCached('calendar-onekite');
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 getSettingsCached('calendar-onekite');
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 getSettingsCached('calendar-onekite');
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 username = kind === 'paid' ? 'OneKite • Paiement' : 'OneKite • Réservation';
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: buildReservationMessage(kind, reservation),
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
- async function getAccessToken({ env, clientId, clientSecret }) {
58
- if (!clientId || !clientSecret) return null;
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
- // Be very defensive here: HelloAsso may return HTML (proxy/CDN error pages)
67
- // or empty bodies on failure. This function must never crash the process.
68
- return new Promise((resolve, reject) => {
69
- const u = new URL(url);
70
- const req = https.request(
71
- {
72
- method: 'POST',
73
- hostname: u.hostname,
74
- port: u.port || 443,
75
- path: u.pathname + u.search,
76
- headers: {
77
- 'Accept': 'application/json',
78
- 'Content-Type': 'application/x-www-form-urlencoded',
79
- 'Content-Length': Buffer.byteLength(body),
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
- (res) => {
83
- let data = '';
84
- res.setEncoding('utf8');
85
- res.on('data', (chunk) => (data += chunk));
86
- res.on('end', () => {
87
- const status = res.statusCode || 0;
88
- const snippet = String(data || '').slice(0, 500);
89
-
90
- // If non-2xx, still try to parse JSON to log a meaningful error.
91
- if (status < 200 || status >= 300) {
92
- let parsed = null;
93
- try {
94
- parsed = data ? JSON.parse(data) : null;
95
- } catch (e) {
96
- // ignore
97
- }
98
- console.warn('[calendar-onekite] HelloAsso token request failed', {
99
- status,
100
- body: parsed || snippet,
101
- });
102
- return resolve(null);
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
- // 2xx: must be JSON with access_token
106
- try {
107
- const json = JSON.parse(data || '{}');
108
- const token = json && json.access_token ? String(json.access_token) : null;
109
- if (!token) {
110
- console.warn('[calendar-onekite] HelloAsso token missing in response', { status, body: json || snippet });
111
- }
112
- return resolve(token);
113
- } catch (e) {
114
- console.warn('[calendar-onekite] HelloAsso token parse error', { status, body: snippet });
115
- return resolve(null);
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
- req.on('error', (err) => {
121
- console.warn('[calendar-onekite] HelloAsso token network error', { message: err && err.message ? err.message : String(err) });
122
- resolve(null);
123
- });
124
- req.write(body);
125
- req.end();
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 getSettingsCached('calendar-onekite');
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 reservations = await dbLayer.getReservations(ids);
30
- for (let i = 0; i < ids.length; i++) {
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 getSettingsCached('calendar-onekite');
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 reservations = await dbLayer.getReservations(ids);
71
- for (let i = 0; i < ids.length; i++) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "12.0.23",
3
+ "version": "12.0.25",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "1.3.1"
42
+ "version": "1.3.2"
43
43
  }
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
- };