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 +8 -0
- package/lib/admin.js +3 -7
- package/lib/api.js +88 -64
- package/lib/db.js +7 -0
- package/lib/discord.js +1 -1
- package/lib/email.js +51 -0
- package/lib/scheduler.js +13 -43
- package/lib/settings.js +34 -0
- package/lib/widgets.js +2 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
175
|
+
const allowed = [...new Set([defaultGroup, ...extraGroups].filter(Boolean))];
|
|
176
|
+
if (!allowed.length) return false;
|
|
189
177
|
|
|
190
|
-
//
|
|
178
|
+
// Fast path: compare against user's groups (slug + name).
|
|
191
179
|
try {
|
|
192
|
-
|
|
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
|
-
//
|
|
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;
|
|
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;
|
|
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;
|
|
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 =
|
|
250
|
-
const endIsoDate =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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' ? '
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
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;
|
package/lib/settings.js
ADDED
|
@@ -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
|
|
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
|
|
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
package/plugin.json
CHANGED