nodebb-plugin-calendar-onekite 12.0.21 → 12.0.23

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,3 +1,14 @@
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
+
1
12
  ## 1.1.0
2
13
  ### Perf / prod (NodeBB v4)
3
14
  - FullCalendar : passage au CDN **@latest** et utilisation de `main.min.css` (supprime l’erreur 404 `index.global.min.css`).
package/lib/admin.js CHANGED
@@ -111,13 +111,9 @@ admin.saveSettings = async function (req, res) {
111
111
 
112
112
  admin.listPending = async function (req, res) {
113
113
  const ids = await dbLayer.listAllReservationIds(5000);
114
- const pending = [];
115
- for (const rid of ids) {
116
- const r = await dbLayer.getReservation(rid);
117
- if (r && r.status === 'pending') {
118
- pending.push(r);
119
- }
120
- }
114
+ // Batch fetch to avoid N DB round-trips.
115
+ const rows = await dbLayer.getReservations(ids);
116
+ const pending = (rows || []).filter(r => r && r.status === 'pending');
121
117
  pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
122
118
  res.json(pending);
123
119
  };
package/lib/api.js CHANGED
@@ -3,7 +3,6 @@
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');
7
6
  const nconf = require.main.require('nconf');
8
7
  const user = require.main.require('./src/user');
9
8
  const groups = require.main.require('./src/groups');
@@ -11,6 +10,8 @@ const db = require.main.require('./src/database');
11
10
  const logger = require.main.require('./src/logger');
12
11
 
13
12
  const dbLayer = require('./db');
13
+ const { getSettingsCached } = require('./settings');
14
+ const { sendEmail } = require('./email');
14
15
 
15
16
  // Fast membership check without N calls to groups.isMember.
16
17
  // NodeBB's groups.getUserGroups([uid]) returns an array (per uid) of group objects.
@@ -52,37 +53,22 @@ function normalizeAllowedGroups(raw) {
52
53
  const helloasso = require('./helloasso');
53
54
  const discord = require('./discord');
54
55
 
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
- }
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
84
68
  }
85
69
 
70
+ // sendEmail is provided by ./email
71
+
86
72
  function normalizeBaseUrl(meta) {
87
73
  // Prefer meta.config.url, fallback to nconf.get('url')
88
74
  let base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
@@ -182,26 +168,27 @@ async function canRequest(uid, settings, startTs) {
182
168
  const defaultGroup = autoCreatorGroupForYear(year);
183
169
 
184
170
  // ACP may store group slugs as CSV string or array depending on NodeBB/admin UI.
171
+ // On some installs, the UI stores *names* rather than slugs; we accept both.
185
172
  const raw = settings.creatorGroups ?? settings.allowedGroups ?? [];
186
173
  const extraGroups = normalizeAllowedGroups(raw);
187
174
 
188
- const allowedSlugs = [...new Set([defaultGroup, ...extraGroups].filter(Boolean))];
175
+ const allowed = [...new Set([defaultGroup, ...extraGroups].filter(Boolean))];
176
+ if (!allowed.length) return false;
189
177
 
190
- // Use getUserGroups (returns group objects) and compare slugs.
178
+ // Fast path: compare against user's groups (slug + name).
191
179
  try {
192
- const ug = await groups.getUserGroups([uid]);
193
- const list = (ug && ug[0]) ? ug[0] : [];
194
- const userSlugs = list.map(g => (g && g.slug) ? String(g.slug) : '').filter(Boolean);
195
- return allowedSlugs.some(s => userSlugs.includes(s));
180
+ if (await userInAnyGroup(uid, allowed)) return true;
196
181
  } catch (e) {
197
- // Fallback: try isMember on each allowed slug (some installs accept slug)
198
- for (const g of allowedSlugs) {
199
- try {
200
- if (await groups.isMember(uid, g)) return true;
201
- } catch (err) {}
202
- }
203
- return false;
182
+ // ignore
204
183
  }
184
+
185
+ // Fallback: try isMember on each allowed entry (slug on most installs)
186
+ for (const g of allowed) {
187
+ try {
188
+ if (await groups.isMember(uid, g)) return true;
189
+ } catch (err) {}
190
+ }
191
+ return false;
205
192
  }
206
193
 
207
194
  async function canValidate(uid, settings) {
@@ -209,7 +196,8 @@ async function canValidate(uid, settings) {
209
196
  // even if validatorGroups is empty.
210
197
  try {
211
198
  const isAdmin = await groups.isMember(uid, 'administrators');
212
- if (isAdmin) return true;} catch (e) {}
199
+ if (isAdmin) return true;
200
+ } catch (e) {}
213
201
 
214
202
  const allowed = normalizeAllowedGroups(settings.validatorGroups || '');
215
203
  if (!allowed.length) return false;
@@ -222,7 +210,8 @@ async function canCreateSpecial(uid, settings) {
222
210
  if (!uid) return false;
223
211
  try {
224
212
  const isAdmin = await groups.isMember(uid, 'administrators');
225
- if (isAdmin) return true;} catch (e) {}
213
+ if (isAdmin) return true;
214
+ } catch (e) {}
226
215
  const allowed = normalizeAllowedGroups(settings.specialCreatorGroups || '');
227
216
  if (!allowed.length) return false;
228
217
  if (await userInAnyGroup(uid, allowed)) return true;
@@ -234,7 +223,8 @@ async function canDeleteSpecial(uid, settings) {
234
223
  if (!uid) return false;
235
224
  try {
236
225
  const isAdmin = await groups.isMember(uid, 'administrators');
237
- if (isAdmin) return true;} catch (e) {}
226
+ if (isAdmin) return true;
227
+ } catch (e) {}
238
228
  const allowed = normalizeAllowedGroups(settings.specialDeleterGroups || settings.specialCreatorGroups || '');
239
229
  if (!allowed.length) return false;
240
230
  if (await userInAnyGroup(uid, allowed)) return true;
@@ -246,8 +236,8 @@ function eventsFor(resv) {
246
236
  const status = resv.status;
247
237
  const icons = { pending: '⏳', awaiting_payment: '💳', paid: '✅' };
248
238
  const colors = { pending: '#f39c12', awaiting_payment: '#d35400', paid: '#27ae60' };
249
- const startIsoDate = new Date(parseInt(resv.start, 10)).toISOString().slice(0, 10);
250
- const endIsoDate = new Date(parseInt(resv.end, 10)).toISOString().slice(0, 10);
239
+ const startIsoDate = toParisIsoDate(resv.start);
240
+ const endIsoDate = toParisIsoDate(resv.end);
251
241
 
252
242
  const itemIds = Array.isArray(resv.itemIds) ? resv.itemIds : (resv.itemId ? [resv.itemId] : []);
253
243
  const itemNames = Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : []);
@@ -314,9 +304,11 @@ function eventsForSpecial(ev) {
314
304
 
315
305
  const api = {};
316
306
 
317
- function computeEtag(payload) {
307
+ function computeEtag(payload, metaSig) {
318
308
  // Weak ETag is fine here: it is only used to skip identical JSON payloads.
319
- const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
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');
320
312
  return `W/"${hash}"`;
321
313
  }
322
314
 
@@ -324,7 +316,7 @@ api.getEvents = async function (req, res) {
324
316
  const startTs = toTs(req.query.start) || 0;
325
317
  const endTs = toTs(req.query.end) || (Date.now() + 365 * 24 * 3600 * 1000);
326
318
 
327
- const settings = await meta.settings.get('calendar-onekite');
319
+ const settings = await getSettingsCached('calendar-onekite');
328
320
  const canMod = req.uid ? await canValidate(req.uid, settings) : false;
329
321
  const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
330
322
  const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
@@ -332,8 +324,27 @@ api.getEvents = async function (req, res) {
332
324
  // Fetch a wider window because an event can start before the query range
333
325
  // and still overlap.
334
326
  const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
335
- const ids = await dbLayer.listReservationIdsByStartRange(wideStart, endTs, 5000);
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
+ }
336
346
  const out = [];
347
+ let maxUpdated = 0;
337
348
  // Batch fetch = major perf win when there are many reservations.
338
349
  const reservations = await dbLayer.getReservations(ids);
339
350
  for (const r of (reservations || [])) {
@@ -345,6 +356,8 @@ api.getEvents = async function (req, res) {
345
356
  const rStart = parseInt(r.start, 10);
346
357
  const rEnd = parseInt(r.end, 10);
347
358
  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;
348
361
  const evs = eventsFor(r);
349
362
  for (const ev of evs) {
350
363
  const p = ev.extendedProps || {};
@@ -382,12 +395,17 @@ api.getEvents = async function (req, res) {
382
395
  // Special events
383
396
  try {
384
397
  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
+ }
385
401
  const specials = await dbLayer.getSpecialEvents(specialIds);
386
402
  for (const sev of (specials || [])) {
387
403
  if (!sev) continue;
388
404
  const sStart = parseInt(sev.start, 10);
389
405
  const sEnd = parseInt(sev.end, 10);
390
406
  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;
391
409
  const full = eventsForSpecial(sev);
392
410
  const minimal = {
393
411
  id: full.id,
@@ -424,7 +442,11 @@ api.getEvents = async function (req, res) {
424
442
  return ai < bi ? -1 : ai > bi ? 1 : 0;
425
443
  });
426
444
 
427
- const etag = computeEtag(out);
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);
428
450
  res.setHeader('ETag', etag);
429
451
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
430
452
  if (String(req.headers['if-none-match'] || '') === etag) {
@@ -438,7 +460,7 @@ api.getReservationDetails = async function (req, res) {
438
460
  const uid = req.uid;
439
461
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
440
462
 
441
- const settings = await meta.settings.get('calendar-onekite');
463
+ const settings = await getSettingsCached('calendar-onekite');
442
464
  const canMod = await canValidate(uid, settings);
443
465
 
444
466
  const rid = String(req.params.rid || '').trim();
@@ -480,7 +502,7 @@ api.getSpecialEventDetails = async function (req, res) {
480
502
  const uid = req.uid;
481
503
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
482
504
 
483
- const settings = await meta.settings.get('calendar-onekite');
505
+ const settings = await getSettingsCached('calendar-onekite');
484
506
  const canMod = await canValidate(uid, settings);
485
507
  const canSpecialDelete = await canDeleteSpecial(uid, settings);
486
508
 
@@ -509,7 +531,7 @@ api.getSpecialEventDetails = async function (req, res) {
509
531
  };
510
532
 
511
533
  api.getCapabilities = async function (req, res) {
512
- const settings = await meta.settings.get('calendar-onekite');
534
+ const settings = await getSettingsCached('calendar-onekite');
513
535
  const uid = req.uid || 0;
514
536
  const canMod = uid ? await canValidate(uid, settings) : false;
515
537
  res.json({
@@ -520,7 +542,7 @@ api.getCapabilities = async function (req, res) {
520
542
  };
521
543
 
522
544
  api.createSpecialEvent = async function (req, res) {
523
- const settings = await meta.settings.get('calendar-onekite');
545
+ const settings = await getSettingsCached('calendar-onekite');
524
546
  if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
525
547
  const ok = await canCreateSpecial(req.uid, settings);
526
548
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
@@ -556,7 +578,7 @@ api.createSpecialEvent = async function (req, res) {
556
578
  };
557
579
 
558
580
  api.deleteSpecialEvent = async function (req, res) {
559
- const settings = await meta.settings.get('calendar-onekite');
581
+ const settings = await getSettingsCached('calendar-onekite');
560
582
  if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
561
583
  const ok = await canDeleteSpecial(req.uid, settings);
562
584
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
@@ -567,7 +589,7 @@ api.deleteSpecialEvent = async function (req, res) {
567
589
  };
568
590
 
569
591
  api.getItems = async function (req, res) {
570
- const settings = await meta.settings.get('calendar-onekite');
592
+ const settings = await getSettingsCached('calendar-onekite');
571
593
 
572
594
  const env = settings.helloassoEnv || 'prod';
573
595
  const token = await helloasso.getAccessToken({
@@ -608,7 +630,7 @@ api.createReservation = async function (req, res) {
608
630
  const uid = req.uid;
609
631
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
610
632
 
611
- const settings = await meta.settings.get('calendar-onekite');
633
+ const settings = await getSettingsCached('calendar-onekite');
612
634
  const startPreview = toTs(req.body.start);
613
635
  const ok = await canRequest(uid, settings, startPreview);
614
636
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
@@ -744,7 +766,7 @@ api.createReservation = async function (req, res) {
744
766
  api.approveReservation = async function (req, res) {
745
767
  const uid = req.uid;
746
768
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
747
- const settings = await meta.settings.get('calendar-onekite');
769
+ const settings = await getSettingsCached('calendar-onekite');
748
770
  const ok = await canValidate(uid, settings);
749
771
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
750
772
 
@@ -771,7 +793,7 @@ api.approveReservation = async function (req, res) {
771
793
  }
772
794
  // Create HelloAsso payment link on validation
773
795
  try {
774
- const settings2 = await meta.settings.get('calendar-onekite');
796
+ const settings2 = await getSettingsCached('calendar-onekite');
775
797
  const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
776
798
  const payer = await user.getUserFields(r.uid, ['email']);
777
799
  const year = yearFromTs(r.start);
@@ -847,7 +869,7 @@ api.approveReservation = async function (req, res) {
847
869
  api.refuseReservation = async function (req, res) {
848
870
  const uid = req.uid;
849
871
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
850
- const settings = await meta.settings.get('calendar-onekite');
872
+ const settings = await getSettingsCached('calendar-onekite');
851
873
  const ok = await canValidate(uid, settings);
852
874
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
853
875
 
@@ -882,7 +904,7 @@ api.cancelReservation = async function (req, res) {
882
904
  const uid = req.uid;
883
905
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
884
906
 
885
- const settings = await meta.settings.get('calendar-onekite');
907
+ const settings = await getSettingsCached('calendar-onekite');
886
908
  const rid = String(req.params.rid || '').trim();
887
909
  if (!rid) return res.status(400).json({ error: 'missing-rid' });
888
910
 
package/lib/db.js CHANGED
@@ -67,6 +67,12 @@ 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
+
70
76
  async function listAllReservationIds(limit = 5000) {
71
77
  return await db.getSortedSetRange(KEY_ZSET, 0, limit - 1);
72
78
  }
@@ -106,5 +112,6 @@ module.exports = {
106
112
  return await db.getSortedSetRangeByScore(KEY_SPECIAL_ZSET, start, stop, startTs, endTs);
107
113
  },
108
114
  listReservationIdsByStartRange,
115
+ listReservationIdsByStartRangePaged,
109
116
  listAllReservationIds,
110
117
  };
package/lib/discord.js CHANGED
@@ -88,7 +88,7 @@ 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 username = kind === 'paid' ? 'OneKite • Paiement' : 'OneKite • Réservation';
92
92
  return {
93
93
  username,
94
94
  content: buildReservationMessage(kind, reservation),
package/lib/email.js ADDED
@@ -0,0 +1,51 @@
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/scheduler.js CHANGED
@@ -1,9 +1,11 @@
1
1
  'use strict';
2
2
 
3
- const meta = require.main.require('./src/meta');
4
3
  const db = require.main.require('./src/database');
5
4
  const dbLayer = require('./db');
6
5
 
6
+ const { getSettingsCached } = require('./settings');
7
+ const { sendEmail } = require('./email');
8
+
7
9
  let timer = null;
8
10
 
9
11
  function getSetting(settings, key, fallback) {
@@ -15,7 +17,7 @@ function getSetting(settings, key, fallback) {
15
17
 
16
18
  // Pending holds: short lock after a user creates a request (defaults to 5 minutes)
17
19
  async function expirePending() {
18
- const settings = await meta.settings.get('calendar-onekite');
20
+ const settings = await getSettingsCached('calendar-onekite');
19
21
  const holdMins = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
20
22
  const now = Date.now();
21
23
 
@@ -24,8 +26,10 @@ async function expirePending() {
24
26
  return;
25
27
  }
26
28
 
27
- for (const rid of ids) {
28
- const resv = await dbLayer.getReservation(rid);
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];
29
33
  if (!resv || resv.status !== 'pending') {
30
34
  continue;
31
35
  }
@@ -43,7 +47,7 @@ async function expirePending() {
43
47
  // - We send a reminder after `paymentHoldMinutes` (default 60)
44
48
  // - We expire (and remove) after `2 * paymentHoldMinutes`
45
49
  async function processAwaitingPayment() {
46
- const settings = await meta.settings.get('calendar-onekite');
50
+ const settings = await getSettingsCached('calendar-onekite');
47
51
  const holdMins = parseInt(
48
52
  getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')),
49
53
  10
@@ -53,44 +57,8 @@ async function processAwaitingPayment() {
53
57
  const ids = await dbLayer.listAllReservationIds(5000);
54
58
  if (!ids || !ids.length) return;
55
59
 
56
- const emailer = require.main.require('./src/emailer');
57
60
  const user = require.main.require('./src/user');
58
61
 
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
-
94
62
  function formatFR(ts) {
95
63
  const d = new Date(ts);
96
64
  const dd = String(d.getDate()).padStart(2, '0');
@@ -99,8 +67,10 @@ async function processAwaitingPayment() {
99
67
  return `${dd}/${mm}/${yyyy}`;
100
68
  }
101
69
 
102
- for (const rid of ids) {
103
- const r = await dbLayer.getReservation(rid);
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];
104
74
  if (!r || r.status !== 'awaiting_payment') continue;
105
75
 
106
76
  const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
@@ -0,0 +1,34 @@
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
+ };
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 (2 semaines)',
44
+ name: 'Calendrier OneKite',
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 (2 semaines)</div>
66
+ <div style="font-weight: 600;">Calendrier</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.21",
3
+ "version": "12.0.23",
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.2.8"
42
+ "version": "1.3.1"
43
43
  }