nodebb-plugin-onekite-calendar 1.0.2 → 1.0.4
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 +31 -2
- package/lib/admin.js +15 -155
- package/lib/api.js +158 -103
- package/lib/constants.js +7 -0
- package/lib/controllers.js +2 -2
- package/lib/db.js +5 -5
- package/lib/discord.js +6 -5
- package/lib/email.js +26 -0
- package/lib/helloasso.js +4 -3
- package/lib/helloassoWebhook.js +8 -7
- package/lib/log.js +29 -0
- package/lib/scheduler.js +8 -11
- package/lib/widgets.js +128 -8
- package/library.js +39 -63
- package/package.json +1 -1
- package/plugin.json +5 -11
- package/public/admin.js +20 -92
- package/public/client.js +23 -23
- package/templates/admin/plugins/{calendar-onekite.tpl → onekite-calendar.tpl} +4 -23
- package/templates/{calendar-onekite.tpl → onekite-calendar.tpl} +11 -0
- /package/templates/emails/{calendar-onekite_approved.tpl → onekite-calendar_approved.tpl} +0 -0
- /package/templates/emails/{calendar-onekite_expired.tpl → onekite-calendar_expired.tpl} +0 -0
- /package/templates/emails/{calendar-onekite_paid.tpl → onekite-calendar_paid.tpl} +0 -0
- /package/templates/emails/{calendar-onekite_pending.tpl → onekite-calendar_pending.tpl} +0 -0
- /package/templates/emails/{calendar-onekite_refused.tpl → onekite-calendar_refused.tpl} +0 -0
- /package/templates/emails/{calendar-onekite_reminder.tpl → onekite-calendar_reminder.tpl} +0 -0
package/lib/api.js
CHANGED
|
@@ -3,14 +3,50 @@
|
|
|
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');
|
|
10
9
|
const db = require.main.require('./src/database');
|
|
11
|
-
|
|
10
|
+
// logger available if you want to debug locally; we avoid noisy logs in prod.
|
|
12
11
|
|
|
13
12
|
const dbLayer = require('./db');
|
|
13
|
+
const { sendEmail } = require('./email');
|
|
14
|
+
const log = require('./log');
|
|
15
|
+
|
|
16
|
+
// Ultra-perf: short-lived in-memory cache for the events feed.
|
|
17
|
+
// We cache a "base" event list without per-user privileges, then decorate
|
|
18
|
+
// it per request. This reduces DB load when many users/widgets refresh.
|
|
19
|
+
const EVENTS_CACHE = new Map();
|
|
20
|
+
const EVENTS_CACHE_TTL_MS = 30 * 1000;
|
|
21
|
+
const EVENTS_CACHE_MAX = 50;
|
|
22
|
+
|
|
23
|
+
function eventsCacheKey(startTs, endTs) {
|
|
24
|
+
// Keep the key stable and short.
|
|
25
|
+
return `${Math.max(0, Number(startTs) || 0)}:${Math.max(0, Number(endTs) || 0)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getEventsCache(key) {
|
|
29
|
+
const hit = EVENTS_CACHE.get(key);
|
|
30
|
+
if (!hit) return null;
|
|
31
|
+
if ((Date.now() - hit.ts) > EVENTS_CACHE_TTL_MS) {
|
|
32
|
+
EVENTS_CACHE.delete(key);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return hit.data;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setEventsCache(key, data) {
|
|
39
|
+
EVENTS_CACHE.set(key, { ts: Date.now(), data });
|
|
40
|
+
if (EVENTS_CACHE.size > EVENTS_CACHE_MAX) {
|
|
41
|
+
// Drop oldest entries.
|
|
42
|
+
const entries = Array.from(EVENTS_CACHE.entries());
|
|
43
|
+
entries.sort((a, b) => (a[1]?.ts || 0) - (b[1]?.ts || 0));
|
|
44
|
+
const toDrop = Math.max(1, EVENTS_CACHE.size - EVENTS_CACHE_MAX);
|
|
45
|
+
for (let i = 0; i < toDrop; i++) {
|
|
46
|
+
EVENTS_CACHE.delete(entries[i][0]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
14
50
|
|
|
15
51
|
// Fast membership check without N calls to groups.isMember.
|
|
16
52
|
// NodeBB's groups.getUserGroups([uid]) returns an array (per uid) of group objects.
|
|
@@ -52,36 +88,7 @@ function normalizeAllowedGroups(raw) {
|
|
|
52
88
|
const helloasso = require('./helloasso');
|
|
53
89
|
const discord = require('./discord');
|
|
54
90
|
|
|
55
|
-
// Email helper
|
|
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
|
-
}
|
|
84
|
-
}
|
|
91
|
+
// Email helper is in lib/email.js (no logs here).
|
|
85
92
|
|
|
86
93
|
function normalizeBaseUrl(meta) {
|
|
87
94
|
// Prefer meta.config.url, fallback to nconf.get('url')
|
|
@@ -102,7 +109,8 @@ function normalizeCallbackUrl(configured, meta) {
|
|
|
102
109
|
let url = (configured || '').trim();
|
|
103
110
|
if (!url) {
|
|
104
111
|
// Default webhook endpoint (recommended): namespaced under /plugins
|
|
105
|
-
|
|
112
|
+
// Prefer the new namespace; legacy endpoint remains available.
|
|
113
|
+
url = base ? `${base}/plugins/onekite-calendar/helloasso` : '';
|
|
106
114
|
}
|
|
107
115
|
if (url && url.startsWith('/') && base) {
|
|
108
116
|
url = `${base}${url}`;
|
|
@@ -328,7 +336,7 @@ api.getEvents = async function (req, res) {
|
|
|
328
336
|
const startTs = toTs(req.query.start) || 0;
|
|
329
337
|
const endTs = toTs(req.query.end) || (Date.now() + 365 * 24 * 3600 * 1000);
|
|
330
338
|
|
|
331
|
-
const settings = await meta.settings.get('calendar
|
|
339
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
332
340
|
const canMod = req.uid ? await canValidate(req.uid, settings) : false;
|
|
333
341
|
const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
|
|
334
342
|
const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
|
|
@@ -336,20 +344,89 @@ api.getEvents = async function (req, res) {
|
|
|
336
344
|
// Fetch a wider window because an event can start before the query range
|
|
337
345
|
// and still overlap.
|
|
338
346
|
const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
|
|
339
|
-
|
|
347
|
+
|
|
348
|
+
const cacheKey = eventsCacheKey(startTs, endTs);
|
|
349
|
+
let base = getEventsCache(cacheKey);
|
|
350
|
+
if (!base) {
|
|
351
|
+
const baseOut = [];
|
|
352
|
+
|
|
353
|
+
const ids = await dbLayer.listReservationIdsByStartRange(wideStart, endTs, 5000);
|
|
354
|
+
// Batch fetch = major perf win when there are many reservations.
|
|
355
|
+
const reservations = await dbLayer.getReservations(ids);
|
|
356
|
+
for (const r of (reservations || [])) {
|
|
357
|
+
if (!r) continue;
|
|
358
|
+
if (!['pending', 'awaiting_payment', 'paid'].includes(r.status)) continue;
|
|
359
|
+
const rStart = parseInt(r.start, 10);
|
|
360
|
+
const rEnd = parseInt(r.end, 10);
|
|
361
|
+
if (!(rStart < endTs && startTs < rEnd)) continue;
|
|
362
|
+
|
|
363
|
+
const evs = eventsFor(r);
|
|
364
|
+
const hasPay = (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl)));
|
|
365
|
+
for (const ev of (evs || [])) {
|
|
366
|
+
const p = ev.extendedProps || {};
|
|
367
|
+
baseOut.push({
|
|
368
|
+
id: ev.id,
|
|
369
|
+
title: ev.title,
|
|
370
|
+
backgroundColor: ev.backgroundColor,
|
|
371
|
+
borderColor: ev.borderColor,
|
|
372
|
+
textColor: ev.textColor,
|
|
373
|
+
allDay: ev.allDay,
|
|
374
|
+
start: ev.start,
|
|
375
|
+
end: ev.end,
|
|
376
|
+
extendedProps: {
|
|
377
|
+
type: 'reservation',
|
|
378
|
+
rid: p.rid,
|
|
379
|
+
status: p.status,
|
|
380
|
+
uid: p.uid,
|
|
381
|
+
_username: r.username ? String(r.username) : '',
|
|
382
|
+
_hasPayment: !!hasPay,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Special events
|
|
389
|
+
try {
|
|
390
|
+
const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
|
|
391
|
+
const specials = await dbLayer.getSpecialEvents(specialIds);
|
|
392
|
+
for (const sev of (specials || [])) {
|
|
393
|
+
if (!sev) continue;
|
|
394
|
+
const sStart = parseInt(sev.start, 10);
|
|
395
|
+
const sEnd = parseInt(sev.end, 10);
|
|
396
|
+
if (!(sStart < endTs && startTs < sEnd)) continue;
|
|
397
|
+
const full = eventsForSpecial(sev);
|
|
398
|
+
baseOut.push({
|
|
399
|
+
id: full.id,
|
|
400
|
+
title: full.title,
|
|
401
|
+
allDay: full.allDay,
|
|
402
|
+
start: full.start,
|
|
403
|
+
end: full.end,
|
|
404
|
+
backgroundColor: full.backgroundColor,
|
|
405
|
+
borderColor: full.borderColor,
|
|
406
|
+
textColor: full.textColor,
|
|
407
|
+
extendedProps: {
|
|
408
|
+
type: 'special',
|
|
409
|
+
eid: sev.eid,
|
|
410
|
+
uid: sev.uid,
|
|
411
|
+
_username: sev.username ? String(sev.username) : '',
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
} catch (e) {
|
|
416
|
+
// ignore
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
base = baseOut;
|
|
420
|
+
setEventsCache(cacheKey, baseOut);
|
|
421
|
+
}
|
|
422
|
+
|
|
340
423
|
const out = [];
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const rStart = parseInt(r.start, 10);
|
|
348
|
-
const rEnd = parseInt(r.end, 10);
|
|
349
|
-
if (!(rStart < endTs && startTs < rEnd)) continue; // overlap check
|
|
350
|
-
const evs = eventsFor(r);
|
|
351
|
-
for (const ev of evs) {
|
|
352
|
-
const p = ev.extendedProps || {};
|
|
424
|
+
const uidStr = (req.uid || req.uid === 0) ? String(req.uid) : '';
|
|
425
|
+
for (const ev of (base || [])) {
|
|
426
|
+
if (!ev) continue;
|
|
427
|
+
const p = ev.extendedProps || {};
|
|
428
|
+
if (p.type === 'reservation') {
|
|
429
|
+
const ownerOrMod = !!(p.uid && uidStr && String(p.uid) === uidStr) || canMod;
|
|
353
430
|
const minimal = {
|
|
354
431
|
id: ev.id,
|
|
355
432
|
title: ev.title,
|
|
@@ -367,53 +444,30 @@ api.getEvents = async function (req, res) {
|
|
|
367
444
|
canModerate: canMod,
|
|
368
445
|
},
|
|
369
446
|
};
|
|
370
|
-
|
|
371
|
-
if (
|
|
372
|
-
minimal.extendedProps.username = String(r.username);
|
|
373
|
-
}
|
|
374
|
-
// Let the UI decide if a "Payer" button might exist, without exposing the URL in list.
|
|
375
|
-
if (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl))) {
|
|
376
|
-
if ((req.uid && String(req.uid) === String(r.uid)) || canMod) {
|
|
377
|
-
minimal.extendedProps.hasPayment = true;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
447
|
+
if (p._username && ownerOrMod) minimal.extendedProps.username = String(p._username);
|
|
448
|
+
if (p._hasPayment && ownerOrMod) minimal.extendedProps.hasPayment = true;
|
|
380
449
|
out.push(minimal);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
// Special events
|
|
385
|
-
try {
|
|
386
|
-
const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
|
|
387
|
-
const specials = await dbLayer.getSpecialEvents(specialIds);
|
|
388
|
-
for (const sev of (specials || [])) {
|
|
389
|
-
if (!sev) continue;
|
|
390
|
-
const sStart = parseInt(sev.start, 10);
|
|
391
|
-
const sEnd = parseInt(sev.end, 10);
|
|
392
|
-
if (!(sStart < endTs && startTs < sEnd)) continue;
|
|
393
|
-
const full = eventsForSpecial(sev);
|
|
450
|
+
} else if (p.type === 'special') {
|
|
451
|
+
const ownerOrPriv = canMod || canSpecialDelete || (p.uid && uidStr && String(p.uid) === uidStr);
|
|
394
452
|
const minimal = {
|
|
395
|
-
id:
|
|
396
|
-
title:
|
|
397
|
-
allDay:
|
|
398
|
-
start:
|
|
399
|
-
end:
|
|
400
|
-
backgroundColor:
|
|
401
|
-
borderColor:
|
|
402
|
-
textColor:
|
|
453
|
+
id: ev.id,
|
|
454
|
+
title: ev.title,
|
|
455
|
+
allDay: ev.allDay,
|
|
456
|
+
start: ev.start,
|
|
457
|
+
end: ev.end,
|
|
458
|
+
backgroundColor: ev.backgroundColor,
|
|
459
|
+
borderColor: ev.borderColor,
|
|
460
|
+
textColor: ev.textColor,
|
|
403
461
|
extendedProps: {
|
|
404
462
|
type: 'special',
|
|
405
|
-
eid:
|
|
463
|
+
eid: p.eid,
|
|
406
464
|
canCreateSpecial: canSpecialCreate,
|
|
407
465
|
canDeleteSpecial: canSpecialDelete,
|
|
408
466
|
},
|
|
409
467
|
};
|
|
410
|
-
if (
|
|
411
|
-
minimal.extendedProps.username = String(sev.username);
|
|
412
|
-
}
|
|
468
|
+
if (p._username && ownerOrPriv) minimal.extendedProps.username = String(p._username);
|
|
413
469
|
out.push(minimal);
|
|
414
470
|
}
|
|
415
|
-
} catch (e) {
|
|
416
|
-
// ignore
|
|
417
471
|
}
|
|
418
472
|
|
|
419
473
|
// Stable ordering -> stable ETag
|
|
@@ -428,7 +482,8 @@ api.getEvents = async function (req, res) {
|
|
|
428
482
|
|
|
429
483
|
const etag = computeEtag(out);
|
|
430
484
|
res.setHeader('ETag', etag);
|
|
431
|
-
|
|
485
|
+
// Short caching + ETag revalidation keeps the feed snappy and reduces bandwidth.
|
|
486
|
+
res.setHeader('Cache-Control', 'private, max-age=15, must-revalidate');
|
|
432
487
|
if (String(req.headers['if-none-match'] || '') === etag) {
|
|
433
488
|
return res.status(304).end();
|
|
434
489
|
}
|
|
@@ -440,7 +495,7 @@ api.getReservationDetails = async function (req, res) {
|
|
|
440
495
|
const uid = req.uid;
|
|
441
496
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
442
497
|
|
|
443
|
-
const settings = await meta.settings.get('calendar
|
|
498
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
444
499
|
const canMod = await canValidate(uid, settings);
|
|
445
500
|
|
|
446
501
|
const rid = String(req.params.rid || '').trim();
|
|
@@ -482,7 +537,7 @@ api.getSpecialEventDetails = async function (req, res) {
|
|
|
482
537
|
const uid = req.uid;
|
|
483
538
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
484
539
|
|
|
485
|
-
const settings = await meta.settings.get('calendar
|
|
540
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
486
541
|
const canMod = await canValidate(uid, settings);
|
|
487
542
|
const canSpecialDelete = await canDeleteSpecial(uid, settings);
|
|
488
543
|
|
|
@@ -511,7 +566,7 @@ api.getSpecialEventDetails = async function (req, res) {
|
|
|
511
566
|
};
|
|
512
567
|
|
|
513
568
|
api.getCapabilities = async function (req, res) {
|
|
514
|
-
const settings = await meta.settings.get('calendar
|
|
569
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
515
570
|
const uid = req.uid || 0;
|
|
516
571
|
const canMod = uid ? await canValidate(uid, settings) : false;
|
|
517
572
|
res.json({
|
|
@@ -522,7 +577,7 @@ api.getCapabilities = async function (req, res) {
|
|
|
522
577
|
};
|
|
523
578
|
|
|
524
579
|
api.createSpecialEvent = async function (req, res) {
|
|
525
|
-
const settings = await meta.settings.get('calendar
|
|
580
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
526
581
|
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
527
582
|
const ok = await canCreateSpecial(req.uid, settings);
|
|
528
583
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
@@ -558,7 +613,7 @@ api.createSpecialEvent = async function (req, res) {
|
|
|
558
613
|
};
|
|
559
614
|
|
|
560
615
|
api.deleteSpecialEvent = async function (req, res) {
|
|
561
|
-
const settings = await meta.settings.get('calendar
|
|
616
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
562
617
|
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
563
618
|
const ok = await canDeleteSpecial(req.uid, settings);
|
|
564
619
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
@@ -569,7 +624,7 @@ api.deleteSpecialEvent = async function (req, res) {
|
|
|
569
624
|
};
|
|
570
625
|
|
|
571
626
|
api.getItems = async function (req, res) {
|
|
572
|
-
const settings = await meta.settings.get('calendar
|
|
627
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
573
628
|
|
|
574
629
|
const env = settings.helloassoEnv || 'prod';
|
|
575
630
|
const token = await helloasso.getAccessToken({
|
|
@@ -610,7 +665,7 @@ api.createReservation = async function (req, res) {
|
|
|
610
665
|
const uid = req.uid;
|
|
611
666
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
612
667
|
|
|
613
|
-
const settings = await meta.settings.get('calendar
|
|
668
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
614
669
|
const startPreview = toTs(req.body.start);
|
|
615
670
|
const ok = await canRequest(uid, settings, startPreview);
|
|
616
671
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
@@ -707,7 +762,7 @@ api.createReservation = async function (req, res) {
|
|
|
707
762
|
|
|
708
763
|
for (const md of (usersData || [])) {
|
|
709
764
|
if (md && md.email) {
|
|
710
|
-
await sendEmail('
|
|
765
|
+
await sendEmail('onekite-calendar_pending', md.email, 'Location matériel - Demande de réservation', {
|
|
711
766
|
username: md.username,
|
|
712
767
|
requester: requester.username,
|
|
713
768
|
itemName: itemsLabel,
|
|
@@ -722,7 +777,7 @@ api.createReservation = async function (req, res) {
|
|
|
722
777
|
}
|
|
723
778
|
}
|
|
724
779
|
} catch (e) {
|
|
725
|
-
|
|
780
|
+
log.warn('Failed to send pending email', e && e.message ? e.message : e);
|
|
726
781
|
}
|
|
727
782
|
|
|
728
783
|
// Discord webhook (optional)
|
|
@@ -746,7 +801,7 @@ api.createReservation = async function (req, res) {
|
|
|
746
801
|
api.approveReservation = async function (req, res) {
|
|
747
802
|
const uid = req.uid;
|
|
748
803
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
749
|
-
const settings = await meta.settings.get('calendar
|
|
804
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
750
805
|
const ok = await canValidate(uid, settings);
|
|
751
806
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
752
807
|
|
|
@@ -773,7 +828,7 @@ api.approveReservation = async function (req, res) {
|
|
|
773
828
|
}
|
|
774
829
|
// Create HelloAsso payment link on validation
|
|
775
830
|
try {
|
|
776
|
-
const settings2 = await meta.settings.get('calendar
|
|
831
|
+
const settings2 = await meta.settings.get('onekite-calendar');
|
|
777
832
|
const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
|
|
778
833
|
const payer = await user.getUserFields(r.uid, ['email']);
|
|
779
834
|
const year = yearFromTs(r.start);
|
|
@@ -788,7 +843,7 @@ api.approveReservation = async function (req, res) {
|
|
|
788
843
|
totalAmount: (() => {
|
|
789
844
|
const cents = Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
790
845
|
if (!cents) {
|
|
791
|
-
|
|
846
|
+
log.warn('HelloAsso totalAmount is 0 (approve API)', { rid, total: r.total });
|
|
792
847
|
}
|
|
793
848
|
return cents;
|
|
794
849
|
})(),
|
|
@@ -797,7 +852,7 @@ api.approveReservation = async function (req, res) {
|
|
|
797
852
|
// Can be overridden via ACP setting `helloassoCallbackUrl`.
|
|
798
853
|
callbackUrl: normalizeReturnUrl(meta),
|
|
799
854
|
webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl, meta),
|
|
800
|
-
itemName: 'Réservation matériel
|
|
855
|
+
itemName: 'Réservation matériel Onekite',
|
|
801
856
|
containsDonation: false,
|
|
802
857
|
metadata: { reservationId: String(rid) },
|
|
803
858
|
});
|
|
@@ -809,7 +864,7 @@ api.approveReservation = async function (req, res) {
|
|
|
809
864
|
r.checkoutIntentId = checkoutIntentId;
|
|
810
865
|
}
|
|
811
866
|
} else {
|
|
812
|
-
|
|
867
|
+
log.warn('HelloAsso payment link not created (approve API)', { rid });
|
|
813
868
|
}
|
|
814
869
|
} catch (e) {
|
|
815
870
|
// ignore payment link errors, admin can retry
|
|
@@ -825,7 +880,7 @@ api.approveReservation = async function (req, res) {
|
|
|
825
880
|
const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
|
|
826
881
|
? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
|
|
827
882
|
: '';
|
|
828
|
-
await sendEmail('
|
|
883
|
+
await sendEmail('onekite-calendar_approved', requester.email, 'Location matériel - Réservation validée', {
|
|
829
884
|
username: requester.username,
|
|
830
885
|
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
831
886
|
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
@@ -849,7 +904,7 @@ api.approveReservation = async function (req, res) {
|
|
|
849
904
|
api.refuseReservation = async function (req, res) {
|
|
850
905
|
const uid = req.uid;
|
|
851
906
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
852
|
-
const settings = await meta.settings.get('calendar
|
|
907
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
853
908
|
const ok = await canValidate(uid, settings);
|
|
854
909
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
855
910
|
|
|
@@ -864,7 +919,7 @@ api.refuseReservation = async function (req, res) {
|
|
|
864
919
|
|
|
865
920
|
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
866
921
|
if (requester && requester.email) {
|
|
867
|
-
await sendEmail('
|
|
922
|
+
await sendEmail('onekite-calendar_refused', requester.email, 'Location matériel - Demande de réservation', {
|
|
868
923
|
username: requester.username,
|
|
869
924
|
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
870
925
|
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
@@ -884,7 +939,7 @@ api.cancelReservation = async function (req, res) {
|
|
|
884
939
|
const uid = req.uid;
|
|
885
940
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
886
941
|
|
|
887
|
-
const settings = await meta.settings.get('calendar
|
|
942
|
+
const settings = await meta.settings.get('onekite-calendar');
|
|
888
943
|
const rid = String(req.params.rid || '').trim();
|
|
889
944
|
if (!rid) return res.status(400).json({ error: 'missing-rid' });
|
|
890
945
|
|
package/lib/constants.js
ADDED
package/lib/controllers.js
CHANGED
package/lib/db.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
const db = require.main.require('./src/database');
|
|
4
4
|
|
|
5
|
-
const KEY_ZSET = 'calendar
|
|
6
|
-
const KEY_OBJ = (rid) => `calendar
|
|
7
|
-
const KEY_CHECKOUT_INTENT_TO_RID = 'calendar
|
|
5
|
+
const KEY_ZSET = 'onekite-calendar:reservations';
|
|
6
|
+
const KEY_OBJ = (rid) => `onekite-calendar:reservation:${rid}`;
|
|
7
|
+
const KEY_CHECKOUT_INTENT_TO_RID = 'onekite-calendar:helloasso:checkoutIntentToRid';
|
|
8
8
|
|
|
9
9
|
// Special events (non-reservation events shown in a different colour)
|
|
10
|
-
const KEY_SPECIAL_ZSET = 'calendar
|
|
11
|
-
const KEY_SPECIAL_OBJ = (eid) => `calendar
|
|
10
|
+
const KEY_SPECIAL_ZSET = 'onekite-calendar:special';
|
|
11
|
+
const KEY_SPECIAL_OBJ = (eid) => `onekite-calendar:special:${eid}`;
|
|
12
12
|
|
|
13
13
|
// Helpers
|
|
14
14
|
function reservationKey(rid) {
|
package/lib/discord.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const https = require('https');
|
|
4
|
+
const log = require('./log');
|
|
4
5
|
const { URL } = require('url');
|
|
5
6
|
|
|
6
7
|
function isEnabled(v, defaultValue) {
|
|
@@ -36,7 +37,7 @@ function postWebhook(webhookUrl, payload) {
|
|
|
36
37
|
headers: {
|
|
37
38
|
'Content-Type': 'application/json',
|
|
38
39
|
'Content-Length': body.length,
|
|
39
|
-
'User-Agent': 'nodebb-plugin-calendar
|
|
40
|
+
'User-Agent': 'nodebb-plugin-onekite-calendar',
|
|
40
41
|
},
|
|
41
42
|
}, (res) => {
|
|
42
43
|
const ok = res.statusCode && res.statusCode >= 200 && res.statusCode < 300;
|
|
@@ -88,7 +89,7 @@ function buildReservationMessage(kind, reservation) {
|
|
|
88
89
|
function buildWebhookPayload(kind, reservation) {
|
|
89
90
|
// Discord "regroupe" visuellement les messages consécutifs d'un même auteur.
|
|
90
91
|
// En utilisant un username différent par action, on obtient un message bien distinct.
|
|
91
|
-
const webhookUsername = kind === 'paid' ? '
|
|
92
|
+
const webhookUsername = kind === 'paid' ? 'Onekite • Paiement' : 'Onekite • Réservation';
|
|
92
93
|
|
|
93
94
|
const calUrl = 'https://www.onekite.com/calendar';
|
|
94
95
|
const username = reservation && reservation.username ? String(reservation.username) : '';
|
|
@@ -124,7 +125,7 @@ function buildWebhookPayload(kind, reservation) {
|
|
|
124
125
|
? 'Un paiement a été reçu pour une réservation.'
|
|
125
126
|
: 'Une nouvelle demande de réservation a été créée.',
|
|
126
127
|
fields,
|
|
127
|
-
footer: { text: '
|
|
128
|
+
footer: { text: 'Onekite • Calendrier' },
|
|
128
129
|
timestamp: new Date().toISOString(),
|
|
129
130
|
},
|
|
130
131
|
],
|
|
@@ -140,7 +141,7 @@ async function notifyReservationRequested(settings, reservation) {
|
|
|
140
141
|
await postWebhook(url, buildWebhookPayload('request', reservation));
|
|
141
142
|
} catch (e) {
|
|
142
143
|
// eslint-disable-next-line no-console
|
|
143
|
-
|
|
144
|
+
log.warn('Discord webhook failed (request)', e && e.message ? e.message : String(e));
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
147
|
|
|
@@ -153,7 +154,7 @@ async function notifyPaymentReceived(settings, reservation) {
|
|
|
153
154
|
await postWebhook(url, buildWebhookPayload('paid', reservation));
|
|
154
155
|
} catch (e) {
|
|
155
156
|
// eslint-disable-next-line no-console
|
|
156
|
-
|
|
157
|
+
log.warn('Discord webhook failed (paid)', e && e.message ? e.message : String(e));
|
|
157
158
|
}
|
|
158
159
|
}
|
|
159
160
|
|
package/lib/email.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const meta = require.main.require('./src/meta');
|
|
4
|
+
const emailer = require.main.require('./src/emailer');
|
|
5
|
+
|
|
6
|
+
function defaultLanguage() {
|
|
7
|
+
return (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Send a transactional email using NodeBB's emailer (NodeBB 4.x).
|
|
12
|
+
*/
|
|
13
|
+
async function sendEmail(template, toEmail, subject, data) {
|
|
14
|
+
if (!toEmail) return;
|
|
15
|
+
if (!emailer || typeof emailer.sendToEmail !== 'function') {
|
|
16
|
+
throw new Error('Emailer not available (sendToEmail missing)');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const language = defaultLanguage();
|
|
20
|
+
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
21
|
+
await emailer.sendToEmail(template, toEmail, language, params);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
sendEmail,
|
|
26
|
+
};
|
package/lib/helloasso.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const https = require('https');
|
|
4
|
+
const log = require('./log');
|
|
4
5
|
|
|
5
6
|
function requestJson(method, url, headers = {}, bodyObj = null) {
|
|
6
7
|
return new Promise((resolve) => {
|
|
@@ -284,11 +285,11 @@ async function listCatalogItems({ env, token, organizationSlug, formType, formSl
|
|
|
284
285
|
async function createCheckoutIntent({ env, token, organizationSlug, formType, formSlug, totalAmount, payerEmail, callbackUrl, webhookUrl, itemName, containsDonation, metadata }) {
|
|
285
286
|
if (!token || !organizationSlug) return null;
|
|
286
287
|
if (!callbackUrl || !/^https?:\/\//i.test(String(callbackUrl))) {
|
|
287
|
-
|
|
288
|
+
log.warn('HelloAsso invalid return/back/error URL', { callbackUrl });
|
|
288
289
|
return null;
|
|
289
290
|
}
|
|
290
291
|
if (webhookUrl && !/^https?:\/\//i.test(String(webhookUrl))) {
|
|
291
|
-
|
|
292
|
+
log.warn('HelloAsso invalid webhook URL', { webhookUrl });
|
|
292
293
|
}
|
|
293
294
|
// Checkout intents are created at organization level.
|
|
294
295
|
const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/checkout-intents`;
|
|
@@ -311,7 +312,7 @@ async function createCheckoutIntent({ env, token, organizationSlug, formType, fo
|
|
|
311
312
|
// Log the error payload to help diagnose configuration issues (slug, env, urls, amount, etc.)
|
|
312
313
|
try {
|
|
313
314
|
// eslint-disable-next-line no-console
|
|
314
|
-
|
|
315
|
+
log.warn('HelloAsso checkout-intent failed', { status, json });
|
|
315
316
|
} catch (e) { /* ignore */ }
|
|
316
317
|
return null;
|
|
317
318
|
}
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
+
const log = require('./log');
|
|
4
5
|
|
|
5
6
|
const db = require.main.require('./src/database');
|
|
6
7
|
const meta = require.main.require('./src/meta');
|
|
@@ -19,10 +20,10 @@ const dbLayer = require('./db');
|
|
|
19
20
|
const helloasso = require('./helloasso');
|
|
20
21
|
const discord = require('./discord');
|
|
21
22
|
|
|
22
|
-
const SETTINGS_KEY = 'calendar
|
|
23
|
+
const SETTINGS_KEY = 'onekite-calendar';
|
|
23
24
|
|
|
24
25
|
// Replay protection: store processed payment ids.
|
|
25
|
-
const PROCESSED_KEY = 'calendar
|
|
26
|
+
const PROCESSED_KEY = 'onekite-calendar:helloasso:processedPayments';
|
|
26
27
|
|
|
27
28
|
async function sendEmail(template, toEmail, subject, data) {
|
|
28
29
|
const uidFromData = data && Number.isInteger(data.uid) ? data.uid : null;
|
|
@@ -67,7 +68,7 @@ async function sendEmail(template, toEmail, subject, data) {
|
|
|
67
68
|
// fall back to sendToEmail only.
|
|
68
69
|
} catch (err) {
|
|
69
70
|
// eslint-disable-next-line no-console
|
|
70
|
-
|
|
71
|
+
log.warn('Failed to send email (webhook)', { template, toEmail, err: String(err && err.message || err) });
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -281,7 +282,7 @@ async function handler(req, res, next) {
|
|
|
281
282
|
|
|
282
283
|
if (allowedIps.length && clientIp && !allowedIps.includes(clientIp)) {
|
|
283
284
|
// eslint-disable-next-line no-console
|
|
284
|
-
|
|
285
|
+
log.warn('HelloAsso webhook blocked by IP allowlist', { ip: clientIp, allowed: allowedIps });
|
|
285
286
|
return res.status(403).json({ ok: false, error: 'ip-not-allowed' });
|
|
286
287
|
}
|
|
287
288
|
|
|
@@ -324,7 +325,7 @@ async function handler(req, res, next) {
|
|
|
324
325
|
}
|
|
325
326
|
if (!resolvedRid) {
|
|
326
327
|
// eslint-disable-next-line no-console
|
|
327
|
-
|
|
328
|
+
log.warn('HelloAsso webhook missing reservationId in metadata', { eventType: payload && payload.eventType, paymentId, checkoutIntentId });
|
|
328
329
|
// Do NOT mark as processed: if metadata/config is fixed later, a manual replay may be possible.
|
|
329
330
|
return res.json({ ok: true, processed: false, missingReservationId: true });
|
|
330
331
|
}
|
|
@@ -347,14 +348,14 @@ async function handler(req, res, next) {
|
|
|
347
348
|
// Real-time notify: refresh calendars for all viewers (owner + validators/admins)
|
|
348
349
|
try {
|
|
349
350
|
if (io && io.sockets && typeof io.sockets.emit === 'function') {
|
|
350
|
-
io.sockets.emit('event:calendar
|
|
351
|
+
io.sockets.emit('event:onekite-calendar.reservationUpdated', { rid: r.rid, status: r.status });
|
|
351
352
|
}
|
|
352
353
|
} catch (e) {}
|
|
353
354
|
|
|
354
355
|
// Notify requester
|
|
355
356
|
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
356
357
|
if (requester && requester.email) {
|
|
357
|
-
await sendEmail('
|
|
358
|
+
await sendEmail('onekite-calendar_paid', requester.email, 'Location matériel - Paiement reçu', {
|
|
358
359
|
uid: parseInt(r.uid, 10),
|
|
359
360
|
username: requester.username,
|
|
360
361
|
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|