nodebb-plugin-calendar-onekite 12.0.21 → 12.0.22

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,11 @@
1
+ ## 1.3.0
2
+ ### Optimisations / robustesse
3
+ - Scheduler : lecture des réservations en batch (suppression du N+1 DB) + envoi email factorisé.
4
+ - Settings : cache mémoire court (TTL) pour réduire les `meta.settings.get` répétitifs.
5
+ - API events : pagination interne (évite la troncature silencieuse > 5000) + ETag optimisé pour gros payloads.
6
+ - Dates allDay : formatage en **Europe/Paris** pour éviter les décalages UTC.
7
+ - Widget : titre simplifié (suppression de “2 semaines”).
8
+
1
9
  ## 1.1.0
2
10
  ### Perf / prod (NodeBB v4)
3
11
  - 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,13 +395,20 @@ 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);
410
+ const supd = parseInt(sev.updatedAt || sev.modifiedAt || sev.createdAt || 0, 10) || 0;
411
+ if (supd > maxUpdated) maxUpdated = supd;
392
412
  const minimal = {
393
413
  id: full.id,
394
414
  title: full.title,
@@ -424,7 +444,11 @@ api.getEvents = async function (req, res) {
424
444
  return ai < bi ? -1 : ai > bi ? 1 : 0;
425
445
  });
426
446
 
427
- const etag = computeEtag(out);
447
+ // For large payloads, avoid hashing the full JSON string.
448
+ const metaSig = out.length > 2000
449
+ ? `start:${startTs};end:${endTs};wide:${wideStart};len:${out.length};maxUpdated:${maxUpdated};mod:${canMod?1:0};sc:${canSpecialCreate?1:0};sd:${canSpecialDelete?1:0}`
450
+ : null;
451
+ const etag = computeEtag(out, metaSig);
428
452
  res.setHeader('ETag', etag);
429
453
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
430
454
  if (String(req.headers['if-none-match'] || '') === etag) {
@@ -438,7 +462,7 @@ api.getReservationDetails = async function (req, res) {
438
462
  const uid = req.uid;
439
463
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
440
464
 
441
- const settings = await meta.settings.get('calendar-onekite');
465
+ const settings = await getSettingsCached('calendar-onekite');
442
466
  const canMod = await canValidate(uid, settings);
443
467
 
444
468
  const rid = String(req.params.rid || '').trim();
@@ -480,7 +504,7 @@ api.getSpecialEventDetails = async function (req, res) {
480
504
  const uid = req.uid;
481
505
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
482
506
 
483
- const settings = await meta.settings.get('calendar-onekite');
507
+ const settings = await getSettingsCached('calendar-onekite');
484
508
  const canMod = await canValidate(uid, settings);
485
509
  const canSpecialDelete = await canDeleteSpecial(uid, settings);
486
510
 
@@ -509,7 +533,7 @@ api.getSpecialEventDetails = async function (req, res) {
509
533
  };
510
534
 
511
535
  api.getCapabilities = async function (req, res) {
512
- const settings = await meta.settings.get('calendar-onekite');
536
+ const settings = await getSettingsCached('calendar-onekite');
513
537
  const uid = req.uid || 0;
514
538
  const canMod = uid ? await canValidate(uid, settings) : false;
515
539
  res.json({
@@ -520,7 +544,7 @@ api.getCapabilities = async function (req, res) {
520
544
  };
521
545
 
522
546
  api.createSpecialEvent = async function (req, res) {
523
- const settings = await meta.settings.get('calendar-onekite');
547
+ const settings = await getSettingsCached('calendar-onekite');
524
548
  if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
525
549
  const ok = await canCreateSpecial(req.uid, settings);
526
550
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
@@ -556,7 +580,7 @@ api.createSpecialEvent = async function (req, res) {
556
580
  };
557
581
 
558
582
  api.deleteSpecialEvent = async function (req, res) {
559
- const settings = await meta.settings.get('calendar-onekite');
583
+ const settings = await getSettingsCached('calendar-onekite');
560
584
  if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
561
585
  const ok = await canDeleteSpecial(req.uid, settings);
562
586
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
@@ -567,7 +591,7 @@ api.deleteSpecialEvent = async function (req, res) {
567
591
  };
568
592
 
569
593
  api.getItems = async function (req, res) {
570
- const settings = await meta.settings.get('calendar-onekite');
594
+ const settings = await getSettingsCached('calendar-onekite');
571
595
 
572
596
  const env = settings.helloassoEnv || 'prod';
573
597
  const token = await helloasso.getAccessToken({
@@ -608,7 +632,7 @@ api.createReservation = async function (req, res) {
608
632
  const uid = req.uid;
609
633
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
610
634
 
611
- const settings = await meta.settings.get('calendar-onekite');
635
+ const settings = await getSettingsCached('calendar-onekite');
612
636
  const startPreview = toTs(req.body.start);
613
637
  const ok = await canRequest(uid, settings, startPreview);
614
638
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
@@ -744,7 +768,7 @@ api.createReservation = async function (req, res) {
744
768
  api.approveReservation = async function (req, res) {
745
769
  const uid = req.uid;
746
770
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
747
- const settings = await meta.settings.get('calendar-onekite');
771
+ const settings = await getSettingsCached('calendar-onekite');
748
772
  const ok = await canValidate(uid, settings);
749
773
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
750
774
 
@@ -771,7 +795,7 @@ api.approveReservation = async function (req, res) {
771
795
  }
772
796
  // Create HelloAsso payment link on validation
773
797
  try {
774
- const settings2 = await meta.settings.get('calendar-onekite');
798
+ const settings2 = await getSettingsCached('calendar-onekite');
775
799
  const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
776
800
  const payer = await user.getUserFields(r.uid, ['email']);
777
801
  const year = yearFromTs(r.start);
@@ -847,7 +871,7 @@ api.approveReservation = async function (req, res) {
847
871
  api.refuseReservation = async function (req, res) {
848
872
  const uid = req.uid;
849
873
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
850
- const settings = await meta.settings.get('calendar-onekite');
874
+ const settings = await getSettingsCached('calendar-onekite');
851
875
  const ok = await canValidate(uid, settings);
852
876
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
853
877
 
@@ -882,7 +906,7 @@ api.cancelReservation = async function (req, res) {
882
906
  const uid = req.uid;
883
907
  if (!uid) return res.status(401).json({ error: 'not-logged-in' });
884
908
 
885
- const settings = await meta.settings.get('calendar-onekite');
909
+ const settings = await getSettingsCached('calendar-onekite');
886
910
  const rid = String(req.params.rid || '').trim();
887
911
  if (!rid) return res.status(400).json({ error: 'missing-rid' });
888
912
 
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.22",
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.0"
43
43
  }