nodebb-plugin-onekite-calendar 2.0.29 → 2.0.31
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 +21 -0
- package/lib/admin.js +1 -14
- package/lib/api.js +61 -8
- package/lib/helloassoWebhook.js +1 -29
- package/lib/scheduler.js +83 -73
- package/lib/utils.js +70 -0
- package/lib/widgets.js +23 -1
- package/package.json +1 -1
- package/pkg/package/public/client.js +28 -1
- package/plugin.json +1 -1
- package/public/client.js +28 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog – calendar-onekite
|
|
2
2
|
|
|
3
|
+
## 1.3.28
|
|
4
|
+
- Perf (temps réel) : debounce global des `refetchEvents()` (calendrier + widget) et **skip** quand l’onglet est masqué (refetch unique au retour de visibilité).
|
|
5
|
+
- Perf/Robustesse (multi-instance) : ajout d’un **lock distribué Redis** sur le tick scheduler (un seul runner exécute le cycle à un instant donné).
|
|
6
|
+
- Perf (API events) : cache Redis très court (**TTL 2s**) par utilisateur/permissions sur l’endpoint events pour absorber les rafales.
|
|
7
|
+
|
|
8
|
+
## 1.3.27
|
|
9
|
+
- Refactor : ajout d'un module `lib/utils.js` (format dates FR, lecture settings ACP, helpers listes/UIDs) pour supprimer les duplications.
|
|
10
|
+
- Cleanup : suppression d'un doublon de fonctions dans `helloassoWebhook.js` (formatFR + getReservationIdFromPayload).
|
|
11
|
+
- Perf : scheduler optimisé (chargement **batch** des réservations via `getReservations()` au lieu d'un `getReservation()` par id).
|
|
12
|
+
|
|
13
|
+
## 1.3.26
|
|
14
|
+
- Scheduler : garde "send-once" rendue **atomique** en multi-instance (priorité à `db.incrObjectField(key, rid)`), pour garantir qu'au moins une instance envoie les emails/Discord avant suppression.
|
|
15
|
+
- Scheduler : en cas d'erreur DB sur la garde, on **privilégie l'envoi** plutôt que "expire sans email" (dernier recours : autoriser l'envoi même si la garde échoue).
|
|
16
|
+
|
|
17
|
+
## 1.3.25
|
|
18
|
+
- Scheduler : correction robuste du mécanisme "send-once" en environnement multi-process (utilise `db.incrObjectField` quand disponible).
|
|
19
|
+
- Fix : évite le cas "réservation expirée/supprimée sans email/Discord" lorsque la DB ne supporte pas correctement les sets/objets utilisés comme garde.
|
|
20
|
+
|
|
21
|
+
## 1.3.24
|
|
22
|
+
- Multi-process : le scheduler ne démarre pas si NodeBB a `runJobs=false` (évite les exécutions multiples de jobs en cluster).
|
|
23
|
+
|
|
3
24
|
## 1.3.23
|
|
4
25
|
- Scheduler (paiement en attente) : correction de la garde "send-once" pour compatibilité avec tous les adaptateurs DB NodeBB (fallback si `db.setAdd` n'est pas disponible).
|
|
5
26
|
- Scheduler : les erreurs ne sont plus avalées silencieusement (logs `Scheduler error ...`).
|
package/lib/admin.js
CHANGED
|
@@ -3,20 +3,7 @@
|
|
|
3
3
|
const meta = require.main.require('./src/meta');
|
|
4
4
|
const user = require.main.require('./src/user');
|
|
5
5
|
const emailer = require.main.require('./src/emailer');
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
function forumBaseUrl() {
|
|
9
|
-
const base = String(nconf.get('url') || '').trim().replace(/\/$/, '');
|
|
10
|
-
return base;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function formatFR(tsOrIso) {
|
|
14
|
-
const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
|
|
15
|
-
const dd = String(d.getDate()).padStart(2, '0');
|
|
16
|
-
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
17
|
-
const yyyy = d.getFullYear();
|
|
18
|
-
return `${dd}/${mm}/${yyyy}`;
|
|
19
|
-
}
|
|
6
|
+
const { forumBaseUrl, formatFR } = require('./utils');
|
|
20
7
|
|
|
21
8
|
function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
|
|
22
9
|
const base = String(baseLabel || 'Réservation matériel Onekite').trim();
|
package/lib/api.js
CHANGED
|
@@ -9,8 +9,11 @@ const user = require.main.require('./src/user');
|
|
|
9
9
|
const groups = require.main.require('./src/groups');
|
|
10
10
|
const db = require.main.require('./src/database');
|
|
11
11
|
const logger = require.main.require('./src/logger');
|
|
12
|
+
let cache = null;
|
|
13
|
+
try { cache = require.main.require('./src/cache'); } catch (e) { cache = null; }
|
|
12
14
|
|
|
13
15
|
const dbLayer = require('./db');
|
|
16
|
+
const { formatFR } = require('./utils');
|
|
14
17
|
|
|
15
18
|
// Resolve group identifiers from ACP.
|
|
16
19
|
// Admins may enter a group *name* ("Test") or a *slug* ("onekite-ffvl-2026").
|
|
@@ -177,14 +180,6 @@ function overlap(aStart, aEnd, bStart, bEnd) {
|
|
|
177
180
|
}
|
|
178
181
|
|
|
179
182
|
|
|
180
|
-
function formatFR(tsOrIso) {
|
|
181
|
-
const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
|
|
182
|
-
const dd = String(d.getDate()).padStart(2, '0');
|
|
183
|
-
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
184
|
-
const yyyy = d.getFullYear();
|
|
185
|
-
return `${dd}/${mm}/${yyyy}`;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
183
|
function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
|
|
189
184
|
const base = String(baseLabel || '').trim();
|
|
190
185
|
const items = Array.isArray(itemNames) ? itemNames.map((s) => String(s || '').trim()).filter(Boolean) : [];
|
|
@@ -484,6 +479,42 @@ function computeEtag(payload) {
|
|
|
484
479
|
return `W/"${hash}"`;
|
|
485
480
|
}
|
|
486
481
|
|
|
482
|
+
async function cacheGet(key) {
|
|
483
|
+
try {
|
|
484
|
+
if (!cache) return null;
|
|
485
|
+
if (typeof cache.get === 'function') {
|
|
486
|
+
if (cache.get.length >= 2) {
|
|
487
|
+
return await new Promise((resolve) => {
|
|
488
|
+
cache.get(key, (err, data) => resolve(err ? null : data));
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
return await cache.get(key);
|
|
492
|
+
}
|
|
493
|
+
} catch (e) {}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function cacheSet(key, value, ttlSeconds) {
|
|
498
|
+
try {
|
|
499
|
+
if (!cache) return;
|
|
500
|
+
const ttl = Math.max(1, parseInt(ttlSeconds, 10) || 2);
|
|
501
|
+
if (typeof cache.set === 'function') {
|
|
502
|
+
if (cache.set.length >= 4) {
|
|
503
|
+
await new Promise((resolve) => {
|
|
504
|
+
cache.set(key, value, ttl, (err) => resolve(!err));
|
|
505
|
+
});
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
// Some NodeBB versions accept an options object.
|
|
509
|
+
try {
|
|
510
|
+
await cache.set(key, value, { ttl });
|
|
511
|
+
return;
|
|
512
|
+
} catch (e) {}
|
|
513
|
+
await cache.set(key, value, ttl);
|
|
514
|
+
}
|
|
515
|
+
} catch (e) {}
|
|
516
|
+
}
|
|
517
|
+
|
|
487
518
|
api.getEvents = async function (req, res) {
|
|
488
519
|
const qStartRaw = (req && req.query && req.query.start !== undefined) ? String(req.query.start).trim() : '';
|
|
489
520
|
const qEndRaw = (req && req.query && req.query.end !== undefined) ? String(req.query.end).trim() : '';
|
|
@@ -503,6 +534,22 @@ api.getEvents = async function (req, res) {
|
|
|
503
534
|
const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
|
|
504
535
|
const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
|
|
505
536
|
|
|
537
|
+
// Ultra-short Redis-backed cache (2s) to absorb bursts and reduce redundant renders.
|
|
538
|
+
// Keyed per uid + permissions because payload can include private fields.
|
|
539
|
+
const uidKey = req.uid ? String(req.uid) : '0';
|
|
540
|
+
const cacheKey = `calendar-onekite:events:${uidKey}:${canMod ? 'm' : 'u'}:${widgetMode ? 'w' : 'p'}:${qStartYmd || startTs}:${qEndYmd || endTs}`;
|
|
541
|
+
try {
|
|
542
|
+
const cached = await cacheGet(cacheKey);
|
|
543
|
+
if (cached && cached.payload && cached.etag) {
|
|
544
|
+
res.setHeader('ETag', cached.etag);
|
|
545
|
+
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
546
|
+
if (String(req.headers['if-none-match'] || '') === String(cached.etag)) {
|
|
547
|
+
return res.status(304).end();
|
|
548
|
+
}
|
|
549
|
+
return res.json(cached.payload);
|
|
550
|
+
}
|
|
551
|
+
} catch (e) {}
|
|
552
|
+
|
|
506
553
|
// Fetch a wider window because an event can start before the query range
|
|
507
554
|
// and still overlap.
|
|
508
555
|
const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
|
|
@@ -641,6 +688,12 @@ api.getEvents = async function (req, res) {
|
|
|
641
688
|
});
|
|
642
689
|
|
|
643
690
|
const etag = computeEtag(out);
|
|
691
|
+
|
|
692
|
+
// Store in cache for a very short period (burst absorption).
|
|
693
|
+
try {
|
|
694
|
+
await cacheSet(cacheKey, { etag, payload: out }, 2);
|
|
695
|
+
} catch (e) {}
|
|
696
|
+
|
|
644
697
|
res.setHeader('ETag', etag);
|
|
645
698
|
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
646
699
|
if (String(req.headers['if-none-match'] || '') === etag) {
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -10,6 +10,7 @@ const dbLayer = require('./db');
|
|
|
10
10
|
const helloasso = require('./helloasso');
|
|
11
11
|
const discord = require('./discord');
|
|
12
12
|
const realtime = require('./realtime');
|
|
13
|
+
const { formatFR } = require('./utils');
|
|
13
14
|
|
|
14
15
|
async function auditLog(action, actorUid, payload) {
|
|
15
16
|
try {
|
|
@@ -49,14 +50,6 @@ async function sendEmail(template, uid, subject, data) {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
function formatFR(tsOrIso) {
|
|
53
|
-
const d = new Date(tsOrIso);
|
|
54
|
-
const dd = String(d.getDate()).padStart(2, '0');
|
|
55
|
-
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
56
|
-
const yyyy = d.getFullYear();
|
|
57
|
-
return `${dd}/${mm}/${yyyy}`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
53
|
function getReservationIdFromPayload(payload) {
|
|
61
54
|
try {
|
|
62
55
|
const data = payload && payload.data ? payload.data : null;
|
|
@@ -186,27 +179,6 @@ function isConfirmedPayment(payload) {
|
|
|
186
179
|
}
|
|
187
180
|
}
|
|
188
181
|
|
|
189
|
-
function formatFR(tsOrIso) {
|
|
190
|
-
const d = new Date(tsOrIso);
|
|
191
|
-
const dd = String(d.getDate()).padStart(2, '0');
|
|
192
|
-
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
193
|
-
const yyyy = d.getFullYear();
|
|
194
|
-
return `${dd}/${mm}/${yyyy}`;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function getReservationIdFromPayload(payload) {
|
|
198
|
-
try {
|
|
199
|
-
const m = payload && payload.data ? payload.data.meta : null;
|
|
200
|
-
if (!m) return null;
|
|
201
|
-
if (typeof m === 'object' && m.reservationId) return String(m.reservationId);
|
|
202
|
-
if (typeof m === 'object' && m.reservationID) return String(m.reservationID);
|
|
203
|
-
return null;
|
|
204
|
-
} catch (e) {
|
|
205
|
-
return null;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
182
|
function getCheckoutIntentIdFromPayload(payload) {
|
|
211
183
|
try {
|
|
212
184
|
const data = payload && payload.data ? payload.data : null;
|
package/lib/scheduler.js
CHANGED
|
@@ -7,15 +7,62 @@ const discord = require('./discord');
|
|
|
7
7
|
const realtime = require('./realtime');
|
|
8
8
|
const nconf = require.main.require('nconf');
|
|
9
9
|
const groups = require.main.require('./src/groups');
|
|
10
|
+
const utils = require('./utils');
|
|
11
|
+
let redis = null;
|
|
12
|
+
try { redis = require.main.require('./src/redis'); } catch (e) { redis = null; }
|
|
10
13
|
|
|
11
14
|
let timer = null;
|
|
12
15
|
|
|
16
|
+
// Distributed lock (Redis) to avoid running the scheduler on multiple NodeBB instances.
|
|
17
|
+
// Safe no-op if Redis is not available.
|
|
18
|
+
async function acquireSchedulerLock(ttlMs) {
|
|
19
|
+
const key = 'calendar-onekite:scheduler:lock';
|
|
20
|
+
const value = `${process.pid}:${Date.now()}`;
|
|
21
|
+
const ttl = Math.max(1000, parseInt(ttlMs, 10) || 55000);
|
|
22
|
+
|
|
23
|
+
// Prefer Redis SET NX PX.
|
|
24
|
+
try {
|
|
25
|
+
const client = redis && (redis.client || redis);
|
|
26
|
+
if (client && typeof client.set === 'function') {
|
|
27
|
+
const res = await new Promise((resolve, reject) => {
|
|
28
|
+
// node_redis style
|
|
29
|
+
client.set(key, value, 'NX', 'PX', ttl, (err, out) => {
|
|
30
|
+
if (err) return reject(err);
|
|
31
|
+
resolve(out);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
return String(res || '').toUpperCase() === 'OK';
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
// ignore and fall back
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Fallback: in-process lock only.
|
|
41
|
+
if (global.__onekiteSchedulerInProcessLockUntil && Date.now() < global.__onekiteSchedulerInProcessLockUntil) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
global.__onekiteSchedulerInProcessLockUntil = Date.now() + ttl;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
13
48
|
// Some NodeBB database adapters don't expose setAdd/setRemove helpers.
|
|
14
49
|
// Use a safe "add once" guard to avoid crashing the scheduler.
|
|
15
50
|
async function addOnce(key, value) {
|
|
16
51
|
const v = String(value);
|
|
17
52
|
if (!v) return false;
|
|
18
53
|
|
|
54
|
+
// Best-effort atomic guard across multi-process / multi-instance.
|
|
55
|
+
// incrObjectField is implemented by most NodeBB DB adapters (Redis/Mongo).
|
|
56
|
+
// If it exists, it gives us a reliable "first winner" (value === 1).
|
|
57
|
+
if (typeof db.incrObjectField === 'function') {
|
|
58
|
+
try {
|
|
59
|
+
const n = await db.incrObjectField(key, v);
|
|
60
|
+
return Number(n) === 1;
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// fall through
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
19
66
|
// Preferred atomic helper (Redis + some adapters)
|
|
20
67
|
if (typeof db.setAdd === 'function') {
|
|
21
68
|
try {
|
|
@@ -33,29 +80,13 @@ async function addOnce(key, value) {
|
|
|
33
80
|
await db.setObjectField(key, v, 1);
|
|
34
81
|
return true;
|
|
35
82
|
} catch (e) {
|
|
36
|
-
|
|
83
|
+
// Last resort: allow sending rather than silently doing nothing.
|
|
84
|
+
// This may duplicate emails in edge cases, but avoids "expires with no email".
|
|
85
|
+
return true;
|
|
37
86
|
}
|
|
38
87
|
}
|
|
39
88
|
|
|
40
|
-
|
|
41
|
-
const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
|
|
42
|
-
if (v == null || v === '') return fallback;
|
|
43
|
-
if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
|
|
44
|
-
return v;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function formatFR(ts) {
|
|
48
|
-
const d = new Date(ts);
|
|
49
|
-
const dd = String(d.getDate()).padStart(2, '0');
|
|
50
|
-
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
51
|
-
const yyyy = d.getFullYear();
|
|
52
|
-
return `${dd}/${mm}/${yyyy}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function forumBaseUrl() {
|
|
56
|
-
const base = String(nconf.get('url') || '').trim().replace(/\/$/, '');
|
|
57
|
-
return base;
|
|
58
|
-
}
|
|
89
|
+
const { getSetting, formatFR, forumBaseUrl, normalizeAllowedGroups, normalizeUids, arrayifyNames } = utils;
|
|
59
90
|
|
|
60
91
|
// Resolve group identifiers from ACP (name or slug) and return UIDs.
|
|
61
92
|
async function getMembersByGroupIdentifier(groupIdentifier) {
|
|
@@ -97,30 +128,7 @@ async function getMembersByGroupIdentifier(groupIdentifier) {
|
|
|
97
128
|
return Array.isArray(members) ? members : [];
|
|
98
129
|
}
|
|
99
130
|
|
|
100
|
-
|
|
101
|
-
if (!Array.isArray(members)) return [];
|
|
102
|
-
const out = [];
|
|
103
|
-
for (const m of members) {
|
|
104
|
-
if (Number.isInteger(m)) { out.push(m); continue; }
|
|
105
|
-
if (typeof m === 'string' && m.trim() && !Number.isNaN(parseInt(m, 10))) { out.push(parseInt(m, 10)); continue; }
|
|
106
|
-
if (m && typeof m === 'object' && (Number.isInteger(m.uid) || (typeof m.uid === 'string' && m.uid.trim()))) {
|
|
107
|
-
const u = Number.isInteger(m.uid) ? m.uid : parseInt(m.uid, 10);
|
|
108
|
-
if (!Number.isNaN(u)) out.push(u);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return Array.from(new Set(out)).filter((u) => Number.isInteger(u) && u > 0);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function normalizeAllowedGroups(raw) {
|
|
115
|
-
if (!raw) return [];
|
|
116
|
-
if (Array.isArray(raw)) {
|
|
117
|
-
return raw.map((s) => String(s || '').trim()).filter(Boolean);
|
|
118
|
-
}
|
|
119
|
-
return String(raw)
|
|
120
|
-
.split(',')
|
|
121
|
-
.map((s) => String(s || '').trim())
|
|
122
|
-
.filter(Boolean);
|
|
123
|
-
}
|
|
131
|
+
// normalizeUids/normalizeAllowedGroups are provided by utils
|
|
124
132
|
|
|
125
133
|
async function getValidatorUids(settings) {
|
|
126
134
|
const out = new Set();
|
|
@@ -175,12 +183,14 @@ async function expirePending() {
|
|
|
175
183
|
const validatorUids = validatorReminderMins > 0 ? await getValidatorUids(settings) : [];
|
|
176
184
|
|
|
177
185
|
const ids = await dbLayer.listAllReservationIds(5000);
|
|
178
|
-
if (!ids || !ids.length)
|
|
179
|
-
|
|
180
|
-
|
|
186
|
+
if (!ids || !ids.length) return;
|
|
187
|
+
|
|
188
|
+
// Batch load to avoid N+1 queries on Mongo/Redis adapters.
|
|
189
|
+
const reservations = await dbLayer.getReservations(ids);
|
|
181
190
|
|
|
182
|
-
for (
|
|
183
|
-
const
|
|
191
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
192
|
+
const rid = ids[i];
|
|
193
|
+
const resv = reservations[i];
|
|
184
194
|
if (!resv || resv.status !== 'pending') {
|
|
185
195
|
continue;
|
|
186
196
|
}
|
|
@@ -199,7 +209,7 @@ async function expirePending() {
|
|
|
199
209
|
await sendEmail('calendar-onekite_validator_reminder', vuid, 'Location matériel - Demande en attente', {
|
|
200
210
|
rid,
|
|
201
211
|
requesterUsername,
|
|
202
|
-
itemNames: (
|
|
212
|
+
itemNames: arrayifyNames(resv),
|
|
203
213
|
dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
|
|
204
214
|
adminUrl,
|
|
205
215
|
kind: 'pending',
|
|
@@ -224,7 +234,7 @@ async function expirePending() {
|
|
|
224
234
|
await sendEmail('calendar-onekite_expired', requesterUid, 'Location matériel - Demande expirée', {
|
|
225
235
|
uid: requesterUid,
|
|
226
236
|
username: requesterUsername,
|
|
227
|
-
itemNames: (
|
|
237
|
+
itemNames: arrayifyNames(resv),
|
|
228
238
|
dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
|
|
229
239
|
delayMinutes: holdMins,
|
|
230
240
|
reason,
|
|
@@ -238,7 +248,7 @@ async function expirePending() {
|
|
|
238
248
|
await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
|
|
239
249
|
rid,
|
|
240
250
|
requesterUsername,
|
|
241
|
-
itemNames: (
|
|
251
|
+
itemNames: arrayifyNames(resv),
|
|
242
252
|
dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
|
|
243
253
|
adminUrl,
|
|
244
254
|
reason,
|
|
@@ -294,21 +304,16 @@ async function processAwaitingPayment() {
|
|
|
294
304
|
}
|
|
295
305
|
}
|
|
296
306
|
|
|
297
|
-
function formatFR(ts) {
|
|
298
|
-
const d = new Date(ts);
|
|
299
|
-
const dd = String(d.getDate()).padStart(2, '0');
|
|
300
|
-
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
301
|
-
const yyyy = d.getFullYear();
|
|
302
|
-
return `${dd}/${mm}/${yyyy}`;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
307
|
const adminUrl = (() => {
|
|
306
308
|
const base = forumBaseUrl();
|
|
307
309
|
return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
|
|
308
310
|
})();
|
|
309
311
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
+
const reservations = await dbLayer.getReservations(ids);
|
|
313
|
+
|
|
314
|
+
for (let i = 0; i < ids.length; i += 1) {
|
|
315
|
+
const rid = ids[i];
|
|
316
|
+
const r = reservations[i];
|
|
312
317
|
if (!r || r.status !== 'awaiting_payment') continue;
|
|
313
318
|
|
|
314
319
|
const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
|
|
@@ -335,8 +340,8 @@ async function processAwaitingPayment() {
|
|
|
335
340
|
await sendEmail('calendar-onekite_reminder', toUid, 'Location matériel - Rappel', {
|
|
336
341
|
uid: toUid,
|
|
337
342
|
username: (u && u.username) ? u.username : '',
|
|
338
|
-
itemName: (
|
|
339
|
-
itemNames: (
|
|
343
|
+
itemName: arrayifyNames(r).join(', '),
|
|
344
|
+
itemNames: arrayifyNames(r),
|
|
340
345
|
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
341
346
|
paymentUrl: r.paymentUrl || '',
|
|
342
347
|
delayMinutes: holdMins,
|
|
@@ -369,8 +374,8 @@ async function processAwaitingPayment() {
|
|
|
369
374
|
await sendEmail('calendar-onekite_expired', toUid, 'Location matériel - Demande expirée', {
|
|
370
375
|
uid: toUid,
|
|
371
376
|
username: (u && u.username) ? u.username : '',
|
|
372
|
-
itemName: (
|
|
373
|
-
itemNames: (
|
|
377
|
+
itemName: arrayifyNames(r).join(', '),
|
|
378
|
+
itemNames: arrayifyNames(r),
|
|
374
379
|
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
375
380
|
delayMinutes: holdMins,
|
|
376
381
|
reason: 'Paiement non reçu dans le temps imparti.',
|
|
@@ -384,7 +389,7 @@ async function processAwaitingPayment() {
|
|
|
384
389
|
await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
|
|
385
390
|
rid: r.rid || rid,
|
|
386
391
|
requesterUsername: (u && u.username) ? u.username : (r.username || ''),
|
|
387
|
-
itemNames: (
|
|
392
|
+
itemNames: arrayifyNames(r),
|
|
388
393
|
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
389
394
|
adminUrl,
|
|
390
395
|
reason: 'Paiement non reçu dans le temps imparti.',
|
|
@@ -421,17 +426,22 @@ async function processAwaitingPayment() {
|
|
|
421
426
|
function start() {
|
|
422
427
|
const runJobs = nconf.get('runJobs');
|
|
423
428
|
if (runJobs === false || runJobs === 'false' || runJobs === 0 || runJobs === '0') {
|
|
429
|
+
// eslint-disable-next-line no-console
|
|
430
|
+
console.info('[calendar-onekite] Scheduler disabled (runJobs=false)');
|
|
424
431
|
return;
|
|
425
432
|
}
|
|
426
433
|
if (timer) return;
|
|
434
|
+
// eslint-disable-next-line no-console
|
|
435
|
+
console.info('[calendar-onekite] Scheduler enabled');
|
|
427
436
|
timer = setInterval(() => {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
437
|
+
(async () => {
|
|
438
|
+
const locked = await acquireSchedulerLock(55 * 1000);
|
|
439
|
+
if (!locked) return;
|
|
440
|
+
await expirePending();
|
|
441
|
+
await processAwaitingPayment();
|
|
442
|
+
})().catch((err) => {
|
|
433
443
|
// eslint-disable-next-line no-console
|
|
434
|
-
console.warn('[calendar-onekite] Scheduler error
|
|
444
|
+
console.warn('[calendar-onekite] Scheduler tick error', err && err.message ? err.message : err);
|
|
435
445
|
});
|
|
436
446
|
}, 60 * 1000);
|
|
437
447
|
}
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const nconf = require.main.require('nconf');
|
|
4
|
+
|
|
5
|
+
function getSetting(settings, key, fallback) {
|
|
6
|
+
const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
|
|
7
|
+
if (v == null || v === '') return fallback;
|
|
8
|
+
if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
|
|
9
|
+
return v;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatFR(tsOrIso) {
|
|
13
|
+
const ts = (typeof tsOrIso === 'string' && tsOrIso.includes('-')) ? Date.parse(tsOrIso) : Number(tsOrIso);
|
|
14
|
+
const d = new Date(Number.isFinite(ts) ? ts : tsOrIso);
|
|
15
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
16
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
17
|
+
const yyyy = d.getFullYear();
|
|
18
|
+
return `${dd}/${mm}/${yyyy}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatFRShort(tsOrIso) {
|
|
22
|
+
const ts = (typeof tsOrIso === 'string' && tsOrIso.includes('-')) ? Date.parse(tsOrIso) : Number(tsOrIso);
|
|
23
|
+
const d = new Date(Number.isFinite(ts) ? ts : tsOrIso);
|
|
24
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
25
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
26
|
+
return `${dd}/${mm}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function forumBaseUrl() {
|
|
30
|
+
return String(nconf.get('url') || '').trim().replace(/\/$/, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeAllowedGroups(raw) {
|
|
34
|
+
if (!raw) return [];
|
|
35
|
+
if (Array.isArray(raw)) {
|
|
36
|
+
return raw.map((s) => String(s || '').trim()).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
return String(raw)
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((s) => String(s || '').trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeUids(members) {
|
|
45
|
+
if (!Array.isArray(members)) return [];
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const m of members) {
|
|
48
|
+
if (Number.isInteger(m)) { out.push(m); continue; }
|
|
49
|
+
if (typeof m === 'string' && m.trim() && !Number.isNaN(parseInt(m, 10))) { out.push(parseInt(m, 10)); continue; }
|
|
50
|
+
if (m && typeof m === 'object' && (Number.isInteger(m.uid) || (typeof m.uid === 'string' && m.uid.trim()))) {
|
|
51
|
+
const u = Number.isInteger(m.uid) ? m.uid : parseInt(m.uid, 10);
|
|
52
|
+
if (!Number.isNaN(u)) out.push(u);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return Array.from(new Set(out)).filter((u) => Number.isInteger(u) && u > 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function arrayifyNames(obj) {
|
|
59
|
+
return (Array.isArray(obj && obj.itemNames) ? obj.itemNames : (obj && obj.itemName ? [obj.itemName] : [])).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
getSetting,
|
|
64
|
+
formatFR,
|
|
65
|
+
formatFRShort,
|
|
66
|
+
forumBaseUrl,
|
|
67
|
+
normalizeAllowedGroups,
|
|
68
|
+
normalizeUids,
|
|
69
|
+
arrayifyNames,
|
|
70
|
+
};
|
package/lib/widgets.js
CHANGED
|
@@ -376,13 +376,20 @@ dateClick: function() { window.location.href = calUrl; },
|
|
|
376
376
|
let tRefetch = null;
|
|
377
377
|
const refetch = function () {
|
|
378
378
|
try {
|
|
379
|
+
// Skip work while tab is hidden; refetch once when visible again.
|
|
380
|
+
try {
|
|
381
|
+
if (typeof document !== 'undefined' && document && document.hidden) {
|
|
382
|
+
window.__oneKiteWidgetNeedsRefetch = true;
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
} catch (e) {}
|
|
379
386
|
if (tRefetch) return;
|
|
380
387
|
tRefetch = setTimeout(() => {
|
|
381
388
|
tRefetch = null;
|
|
382
389
|
try {
|
|
383
390
|
calendar.refetchEvents();
|
|
384
391
|
} catch (e) {}
|
|
385
|
-
},
|
|
392
|
+
}, 450);
|
|
386
393
|
} catch (e) {}
|
|
387
394
|
};
|
|
388
395
|
|
|
@@ -402,6 +409,21 @@ dateClick: function() { window.location.href = calUrl; },
|
|
|
402
409
|
};
|
|
403
410
|
socket.on('event:calendar-onekite.calendarUpdated', triggerAll);
|
|
404
411
|
socket.on('event:calendar-onekite.reservationUpdated', triggerAll);
|
|
412
|
+
|
|
413
|
+
// If updates occurred while hidden, refetch once when visible again.
|
|
414
|
+
try {
|
|
415
|
+
if (!window.__oneKiteWidgetVisibilityBound && typeof document !== 'undefined' && document && typeof document.addEventListener === 'function') {
|
|
416
|
+
window.__oneKiteWidgetVisibilityBound = true;
|
|
417
|
+
document.addEventListener('visibilitychange', () => {
|
|
418
|
+
try {
|
|
419
|
+
if (!document.hidden && window.__oneKiteWidgetNeedsRefetch) {
|
|
420
|
+
window.__oneKiteWidgetNeedsRefetch = false;
|
|
421
|
+
triggerAll();
|
|
422
|
+
}
|
|
423
|
+
} catch (e) {}
|
|
424
|
+
}, { passive: true });
|
|
425
|
+
}
|
|
426
|
+
} catch (e) {}
|
|
405
427
|
}
|
|
406
428
|
} catch (e) {}
|
|
407
429
|
|
package/package.json
CHANGED
|
@@ -533,13 +533,40 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
533
533
|
function scheduleRefetch(cal) {
|
|
534
534
|
try {
|
|
535
535
|
if (!cal || typeof cal.refetchEvents !== 'function') return;
|
|
536
|
+
|
|
537
|
+
// Avoid refetching while the tab is hidden; do it once when visible again.
|
|
538
|
+
try {
|
|
539
|
+
if (typeof document !== 'undefined' && document && document.hidden) {
|
|
540
|
+
window.__onekiteNeedsRefetch = true;
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
} catch (e) {}
|
|
544
|
+
|
|
536
545
|
clearTimeout(window.__onekiteRefetchTimer);
|
|
537
546
|
window.__onekiteRefetchTimer = setTimeout(() => {
|
|
538
547
|
try { cal.refetchEvents(); } catch (e) {}
|
|
539
|
-
},
|
|
548
|
+
}, 450);
|
|
540
549
|
} catch (e) {}
|
|
541
550
|
}
|
|
542
551
|
|
|
552
|
+
// If updates occurred while hidden, refetch once when visible again.
|
|
553
|
+
try {
|
|
554
|
+
if (!window.__onekiteVisibilityBound && typeof document !== 'undefined' && document && typeof document.addEventListener === 'function') {
|
|
555
|
+
window.__onekiteVisibilityBound = true;
|
|
556
|
+
document.addEventListener('visibilitychange', () => {
|
|
557
|
+
try {
|
|
558
|
+
if (!document.hidden && window.__onekiteNeedsRefetch) {
|
|
559
|
+
window.__onekiteNeedsRefetch = false;
|
|
560
|
+
const cal = window.oneKiteCalendar;
|
|
561
|
+
if (cal && typeof cal.refetchEvents === 'function') {
|
|
562
|
+
scheduleRefetch(cal);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} catch (e) {}
|
|
566
|
+
}, { passive: true });
|
|
567
|
+
}
|
|
568
|
+
} catch (e) {}
|
|
569
|
+
|
|
543
570
|
async function fetchJsonCached(url, opts) {
|
|
544
571
|
const cached = jsonCache.get(url);
|
|
545
572
|
const headers = Object.assign({}, (opts && opts.headers) || {});
|
package/plugin.json
CHANGED
package/public/client.js
CHANGED
|
@@ -543,13 +543,40 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
543
543
|
function scheduleRefetch(cal) {
|
|
544
544
|
try {
|
|
545
545
|
if (!cal || typeof cal.refetchEvents !== 'function') return;
|
|
546
|
+
|
|
547
|
+
// Avoid refetching while the tab is hidden; do it once when visible again.
|
|
548
|
+
try {
|
|
549
|
+
if (typeof document !== 'undefined' && document && document.hidden) {
|
|
550
|
+
window.__onekiteNeedsRefetch = true;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
} catch (e) {}
|
|
554
|
+
|
|
546
555
|
clearTimeout(window.__onekiteRefetchTimer);
|
|
547
556
|
window.__onekiteRefetchTimer = setTimeout(() => {
|
|
548
557
|
try { cal.refetchEvents(); } catch (e) {}
|
|
549
|
-
},
|
|
558
|
+
}, 450);
|
|
550
559
|
} catch (e) {}
|
|
551
560
|
}
|
|
552
561
|
|
|
562
|
+
// If updates occurred while hidden, refetch once when visible again.
|
|
563
|
+
try {
|
|
564
|
+
if (!window.__onekiteVisibilityBound && typeof document !== 'undefined' && document && typeof document.addEventListener === 'function') {
|
|
565
|
+
window.__onekiteVisibilityBound = true;
|
|
566
|
+
document.addEventListener('visibilitychange', () => {
|
|
567
|
+
try {
|
|
568
|
+
if (!document.hidden && window.__onekiteNeedsRefetch) {
|
|
569
|
+
window.__onekiteNeedsRefetch = false;
|
|
570
|
+
const cal = window.oneKiteCalendar;
|
|
571
|
+
if (cal && typeof cal.refetchEvents === 'function') {
|
|
572
|
+
scheduleRefetch(cal);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} catch (e) {}
|
|
576
|
+
}, { passive: true });
|
|
577
|
+
}
|
|
578
|
+
} catch (e) {}
|
|
579
|
+
|
|
553
580
|
async function fetchJsonCached(url, opts) {
|
|
554
581
|
const cached = jsonCache.get(url);
|
|
555
582
|
const headers = Object.assign({}, (opts && opts.headers) || {});
|