nodebb-plugin-onekite-calendar 2.0.94 → 2.0.95

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/lib/api.js CHANGED
@@ -793,8 +793,6 @@ api.leaveSpecialEvent = async function (req, res) {
793
793
  api.getCapabilities = async function (req, res) {
794
794
  const settings = await getSettings();
795
795
  const uid = req.uid || 0;
796
- const pendingHoldMinutes = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
797
- const paymentHoldMinutes = parseInt(getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')), 10) || 60;
798
796
  if (!uid) {
799
797
  return res.json({
800
798
  canModerate: false,
@@ -803,8 +801,6 @@ api.getCapabilities = async function (req, res) {
803
801
  canCreateOuting: false,
804
802
  canCreateReservation: false,
805
803
  specialEventCategoryCid: 0,
806
- pendingHoldMinutes,
807
- paymentHoldMinutes,
808
804
  });
809
805
  }
810
806
  const [canMod, canSpecialC, canSpecialD, canReq] = await Promise.all([
@@ -821,8 +817,6 @@ api.getCapabilities = async function (req, res) {
821
817
  canCreateOuting: canMod || canReq,
822
818
  canCreateReservation: canReq,
823
819
  specialEventCategoryCid: parseInt(settings && settings.specialEventCategoryId, 10) || 0,
824
- pendingHoldMinutes,
825
- paymentHoldMinutes,
826
820
  });
827
821
  };
828
822
 
package/lib/scheduler.js CHANGED
@@ -1,338 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const nconf = require.main.require('nconf');
4
- const db = require.main.require('./src/database');
5
- const user = require.main.require('./src/user');
6
- const dbLayer = require('./db');
7
- const discord = require('./discord');
8
- const realtime = require('./realtime');
9
- const { getSettings } = require('./settings');
10
4
 
11
- const shared = require('./shared');
12
- const {
13
- getSetting,
14
- formatFR,
15
- arrayifyNames,
16
- forumBaseUrl,
17
- normalizeAllowedGroups,
18
- normalizeUids,
19
- getMembersByGroupIdentifier,
20
- sendEmail,
21
- } = shared;
22
5
  let timer = null;
23
6
 
24
- // Some NodeBB database adapters don't expose setAdd/setRemove helpers.
25
- // Use a safe "add once" guard to avoid crashing the scheduler.
26
- async function addOnce(key, value) {
27
- const v = String(value);
28
- if (!v) return false;
29
-
30
- // Best-effort atomic guard across multi-process / multi-instance.
31
- // incrObjectField is implemented by most NodeBB DB adapters (Redis/Mongo).
32
- // If it exists, it gives us a reliable "first winner" (value === 1).
33
- if (typeof db.incrObjectField === 'function') {
34
- try {
35
- const n = await db.incrObjectField(key, v);
36
- return Number(n) === 1;
37
- } catch (e) {
38
- // fall through
39
- }
40
- }
41
-
42
- // Preferred atomic helper (Redis + some adapters)
43
- if (typeof db.setAdd === 'function') {
44
- try {
45
- return !!(await db.setAdd(key, v));
46
- } catch (e) {
47
- // fall through
48
- }
49
- }
50
-
51
- // Fallback: store a marker in an object map.
52
- // Not perfectly atomic across clustered processes, but avoids a hard failure.
53
- try {
54
- const existing = await db.getObjectField(key, v);
55
- if (existing) return false;
56
- await db.setObjectField(key, v, 1);
57
- return true;
58
- } catch (e) {
59
- // Last resort: allow sending rather than silently doing nothing.
60
- // This may duplicate emails in edge cases, but avoids "expires with no email".
61
- return true;
62
- }
63
- }
64
-
65
- // Helpers imported from shared.js above
66
- async function getValidatorUids(settings) {
67
- const out = new Set();
68
- // Always include administrators
69
- try {
70
- const admins = await getMembersByGroupIdentifier('administrators');
71
- normalizeUids(admins).forEach((u) => out.add(u));
72
- } catch (e) {}
73
-
74
- const groupsCsv = normalizeAllowedGroups(settings && settings.validatorGroups);
75
- for (const g of groupsCsv) {
76
- try {
77
- const members = await getMembersByGroupIdentifier(g);
78
- normalizeUids(members).forEach((u) => out.add(u));
79
- } catch (e) {}
80
- }
81
- return Array.from(out);
82
- }
83
-
84
-
85
- async function getNotifyUids(settings) {
86
- const out = new Set();
87
- const groupsCsv = normalizeAllowedGroups(settings && settings.notifyGroups);
88
- for (const g of groupsCsv) {
89
- try {
90
- const members = await getMembersByGroupIdentifier(g);
91
- normalizeUids(members).forEach((u) => out.add(u));
92
- } catch (e) {}
93
- }
94
- return Array.from(out);
95
- }
96
-
97
- async function getNotifiedValidatorUids(settings) {
98
- const [validators, notified] = await Promise.all([
99
- getValidatorUids(settings),
100
- getNotifyUids(settings),
101
- ]);
102
- const notifiedSet = new Set((notified || []).map((u) => parseInt(u, 10)).filter(Number.isFinite));
103
- return (validators || []).filter((u) => notifiedSet.has(parseInt(u, 10)));
104
- }
105
-
106
- // Pending holds: short lock after a user creates a request (defaults to 5 minutes)
107
- async function expirePending(preIds, preReservations) {
108
- const settings = await getSettings();
109
- const holdMins = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
110
- const validatorReminderMins = parseInt(getSetting(settings, 'validatorReminderMinutesPending', '0'), 10) || 0;
111
- const now = Date.now();
112
-
113
- const adminUrl = (() => {
114
- const base = forumBaseUrl();
115
- return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
116
- })();
117
-
118
- const validatorUids = validatorReminderMins > 0 ? await getNotifiedValidatorUids(settings) : [];
119
-
120
- const ids = preIds || await dbLayer.listAllReservationIds(5000);
121
- if (!ids || !ids.length) return;
122
-
123
- const reservations = preReservations || await dbLayer.getReservations(ids);
124
-
125
- for (let i = 0; i < ids.length; i += 1) {
126
- const rid = ids[i];
127
- const resv = reservations[i];
128
- if (!resv || resv.status !== 'pending') {
129
- continue;
130
- }
131
- const createdAt = parseInt(resv.createdAt, 10) || 0;
132
- const expiresAt = createdAt + holdMins * 60 * 1000;
133
-
134
- // Reminder to validators while still pending
135
- if (validatorReminderMins > 0 && createdAt && now >= (createdAt + validatorReminderMins * 60 * 1000) && now < expiresAt) {
136
- const reminderKey = 'calendar-onekite:email:validatorReminder:pending';
137
- const first = await addOnce(reminderKey, rid);
138
- if (first && validatorUids.length) {
139
- const requesterUid = parseInt(resv.uid, 10) || 0;
140
- const requester = requesterUid ? await user.getUserFields(requesterUid, ['username']) : null;
141
- const requesterUsername = requester && requester.username ? requester.username : (resv.username || '');
142
- for (const vuid of validatorUids) {
143
- await sendEmail('calendar-onekite_validator_reminder', vuid, 'Location matériel - Demande en attente', {
144
- rid,
145
- requesterUsername,
146
- itemNames: arrayifyNames(resv),
147
- dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
148
- adminUrl,
149
- kind: 'pending',
150
- delayMinutes: validatorReminderMins,
151
- });
152
- }
153
- }
154
- }
155
-
156
- if (now > expiresAt) {
157
- // Expire (remove from calendar) + notify requester + validators
158
- const expiredKey = 'calendar-onekite:email:expiredSent:pending';
159
- const firstExpired = await addOnce(expiredKey, rid);
160
-
161
- const requesterUid = parseInt(resv.uid, 10) || 0;
162
- const requester = requesterUid ? await user.getUserFields(requesterUid, ['username']) : null;
163
- const requesterUsername = requester && requester.username ? requester.username : (resv.username || '');
164
-
165
- const reason = 'Demande non prise en charge dans le temps imparti.';
166
-
167
- if (firstExpired && requesterUid) {
168
- await sendEmail('calendar-onekite_expired', requesterUid, 'Location matériel - Demande expirée', {
169
- uid: requesterUid,
170
- username: requesterUsername,
171
- itemNames: arrayifyNames(resv),
172
- dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
173
- delayMinutes: holdMins,
174
- reason,
175
- });
176
- }
177
-
178
- // Validators info email (best-effort)
179
- if (firstExpired) {
180
- const validators = await getNotifiedValidatorUids(settings);
181
- for (const vuid of validators) {
182
- await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
183
- rid,
184
- requesterUsername,
185
- itemNames: arrayifyNames(resv),
186
- dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
187
- adminUrl,
188
- reason,
189
- });
190
- }
191
- }
192
-
193
- await dbLayer.removeReservation(rid);
194
-
195
- // Real-time refresh for all viewers
196
- realtime.emitCalendarUpdated({ kind: 'reservation', action: 'expired', rid: String(rid), status: 'expired' });
197
- }
198
- }
199
- }
200
-
201
- // Payment window logic:
202
- // - When a reservation is validated it becomes awaiting_payment
203
- // - We send a reminder after `paymentHoldMinutes` (default 60)
204
- // - We expire (and remove) after `2 * paymentHoldMinutes`
205
- async function processAwaitingPayment(preIds, preReservations) {
206
- const settings = await getSettings();
207
- const holdMins = parseInt(
208
- getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')),
209
- 10
210
- ) || 60;
211
- const now = Date.now();
212
-
213
- const ids = preIds || await dbLayer.listAllReservationIds(5000);
214
- if (!ids || !ids.length) return;
215
-
216
- const adminUrl = (() => {
217
- const base = forumBaseUrl();
218
- return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
219
- })();
220
-
221
- const reservations = preReservations || await dbLayer.getReservations(ids);
222
-
223
- for (let i = 0; i < ids.length; i += 1) {
224
- const rid = ids[i];
225
- const r = reservations[i];
226
- if (!r || r.status !== 'awaiting_payment') continue;
227
-
228
- const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
229
- if (!approvedAt) continue;
230
-
231
- const reminderAt = approvedAt + holdMins * 60 * 1000;
232
- const expireAt = approvedAt + 2 * holdMins * 60 * 1000;
233
-
234
- if (!r.reminderSent && now >= reminderAt && now < expireAt) {
235
- // Send reminder once (guarded across clustered NodeBB processes)
236
- const reminderKey = 'calendar-onekite:email:reminderSent';
237
- const first = await addOnce(reminderKey, rid);
238
- if (!first) {
239
- // another process already sent it
240
- r.reminderSent = true;
241
- r.reminderAt = now;
242
- await dbLayer.saveReservation(r);
243
- continue;
244
- }
245
-
246
- const toUid = parseInt(r.uid, 10);
247
- const u = await user.getUserFields(toUid, ['username']);
248
- if (toUid) {
249
- await sendEmail('calendar-onekite_reminder', toUid, 'Location matériel - Rappel', {
250
- uid: toUid,
251
- username: (u && u.username) ? u.username : '',
252
- itemName: arrayifyNames(r).join(', '),
253
- itemNames: arrayifyNames(r),
254
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
255
- paymentUrl: r.paymentUrl || '',
256
- calendarUrl: `${forumBaseUrl()}/calendar`,
257
- delayMinutes: holdMins,
258
- pickupLine: r.pickupTime ? (r.adminNote ? `${r.pickupTime} à ${r.adminNote}` : r.pickupTime) : '',
259
- });
260
- }
261
-
262
- r.reminderSent = true;
263
- r.reminderAt = now;
264
- await dbLayer.saveReservation(r);
265
- continue;
266
- }
267
-
268
- if (now >= expireAt) {
269
- // Expire: remove reservation so it disappears from calendar and frees items
270
- // Guard email send across clustered NodeBB processes
271
- const expiredKey = 'calendar-onekite:email:expiredSent';
272
- const firstExpired = await addOnce(expiredKey, rid);
273
- const shouldEmail = !!firstExpired;
274
-
275
- // Guard Discord notification across clustered NodeBB processes
276
- const discordKey = 'calendar-onekite:discord:cancelledSent';
277
- const firstDiscord = await addOnce(discordKey, rid);
278
- const shouldDiscord = !!firstDiscord;
279
-
280
- const toUid = parseInt(r.uid, 10);
281
- const u = await user.getUserFields(toUid, ['username']);
282
-
283
- if (shouldEmail && toUid) {
284
- await sendEmail('calendar-onekite_expired', toUid, 'Location matériel - Demande expirée', {
285
- uid: toUid,
286
- username: (u && u.username) ? u.username : '',
287
- itemName: arrayifyNames(r).join(', '),
288
- itemNames: arrayifyNames(r),
289
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
290
- delayMinutes: holdMins,
291
- reason: 'Paiement non reçu dans le temps imparti.',
292
- });
293
- }
294
-
295
- // Validators info email (best-effort)
296
- if (shouldEmail) {
297
- const validators = await getNotifiedValidatorUids(settings);
298
- for (const vuid of validators) {
299
- await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
300
- rid: r.rid || rid,
301
- requesterUsername: (u && u.username) ? u.username : (r.username || ''),
302
- itemNames: arrayifyNames(r),
303
- dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
304
- adminUrl,
305
- reason: 'Paiement non reçu dans le temps imparti.',
306
- paymentUrl: r.paymentUrl || '',
307
- });
308
- }
309
- }
310
-
311
- if (shouldDiscord) {
312
- try {
313
- await discord.notifyReservationCancelled(settings, {
314
- rid: r.rid || rid,
315
- uid: r.uid,
316
- username: (u && u.username) ? u.username : (r.username || ''),
317
- itemIds: r.itemIds || [],
318
- itemNames: r.itemNames || [],
319
- start: r.start,
320
- end: r.end,
321
- status: 'cancelled',
322
- cancelledAt: now,
323
- cancelledBy: 'system',
324
- });
325
- } catch (e) {}
326
- }
327
-
328
- await dbLayer.removeReservation(rid);
329
-
330
- // Real-time refresh for all viewers
331
- realtime.emitCalendarUpdated({ kind: 'reservation', action: 'expired', rid: String(rid), status: 'expired' });
332
- }
333
- }
334
- }
335
-
336
7
  function start() {
337
8
  const runJobs = nconf.get('runJobs');
338
9
  if (runJobs === false || runJobs === 'false' || runJobs === 0 || runJobs === '0') {
@@ -340,24 +11,8 @@ function start() {
340
11
  console.info('[calendar-onekite] Scheduler disabled (runJobs=false)');
341
12
  return;
342
13
  }
343
- if (timer) return;
344
14
  // eslint-disable-next-line no-console
345
- console.info('[calendar-onekite] Scheduler enabled');
346
- timer = setInterval(async () => {
347
- try {
348
- // Single DB fetch shared between both jobs (avoids duplicate listAll + getReservations)
349
- const ids = await dbLayer.listAllReservationIds(5000);
350
- const reservations = ids && ids.length ? await dbLayer.getReservations(ids) : [];
351
- await expirePending(ids, reservations).catch((err) => {
352
- console.warn('[calendar-onekite] Scheduler error in expirePending', err && err.message ? err.message : err);
353
- });
354
- await processAwaitingPayment(ids, reservations).catch((err) => {
355
- console.warn('[calendar-onekite] Scheduler error in processAwaitingPayment', err && err.message ? err.message : err);
356
- });
357
- } catch (err) {
358
- console.warn('[calendar-onekite] Scheduler tick error', err && err.message ? err.message : err);
359
- }
360
- }, 60 * 1000);
15
+ console.info('[calendar-onekite] Scheduler started (no expiry timers configured)');
361
16
  }
362
17
 
363
18
  function stop() {
@@ -367,9 +22,4 @@ function stop() {
367
22
  }
368
23
  }
369
24
 
370
- module.exports = {
371
- start,
372
- stop,
373
- expirePending,
374
- processAwaitingPayment,
375
- };
25
+ module.exports = { start, stop };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.94",
3
+ "version": "2.0.95",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1244,14 +1244,6 @@ function toDatetimeLocalValue(date) {
1244
1244
  </div>
1245
1245
  ` : '';
1246
1246
 
1247
- const holdPending = parseInt((opts && opts.pendingHoldMinutes), 10) || 5;
1248
- const holdPayment = parseInt((opts && opts.paymentHoldMinutes), 10) || 60;
1249
- const delayBannerHtml = `
1250
- <div class="mt-2 p-2 rounded" style="font-size: 12px; background: var(--bs-info-bg-subtle, #cff4fc); border: 1px solid var(--bs-info-border-subtle, #9eeaf9); color: var(--bs-info-text-emphasis, #055160);">
1251
- <strong>Délais :</strong>
1252
- validation sous <strong>${fmtDuration(holdPending)}</strong> · paiement sous <strong>${fmtDuration(holdPayment)}</strong> après validation
1253
- </div>
1254
- `;
1255
1247
 
1256
1248
  const messageHtml = `
1257
1249
  <div class="mb-2" id="onekite-period"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(endDisplay)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span></div>
@@ -1269,7 +1261,6 @@ function toDatetimeLocalValue(date) {
1269
1261
  <div id="onekite-total" style="font-size: 18px;"><strong>0,00 €</strong></div>
1270
1262
  </div>
1271
1263
  <div class="text-muted" style="font-size: 12px;">Les matériels grisés sont déjà réservés ou en attente.</div>
1272
- ${delayBannerHtml}
1273
1264
  `;
1274
1265
 
1275
1266
  return new Promise((resolve) => {
@@ -1446,9 +1437,6 @@ function toDatetimeLocalValue(date) {
1446
1437
  const canCreateReservation = !!caps.canCreateReservation;
1447
1438
  const isValidator = !!caps.isValidator;
1448
1439
  const specialEventCategoryCid = parseInt(caps.specialEventCategoryCid, 10) || 0;
1449
- const pendingHoldMinutes = parseInt(caps.pendingHoldMinutes, 10) || 5;
1450
- const paymentHoldMinutes = parseInt(caps.paymentHoldMinutes, 10) || 60;
1451
-
1452
1440
  // Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
1453
1441
 
1454
1442
  // Inject lightweight responsive CSS once.
@@ -1596,7 +1584,7 @@ function toDatetimeLocalValue(date) {
1596
1584
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1597
1585
  return;
1598
1586
  }
1599
- const chosen = await openReservationDialog(sel, items, { isValidator, pendingHoldMinutes, paymentHoldMinutes });
1587
+ const chosen = await openReservationDialog(sel, items, { isValidator });
1600
1588
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1601
1589
  const startDate = toLocalYmd(sel.start);
1602
1590
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
@@ -1688,7 +1676,7 @@ function toDatetimeLocalValue(date) {
1688
1676
  showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1689
1677
  return;
1690
1678
  }
1691
- const chosen = await openReservationDialog(sel, items, { isValidator, pendingHoldMinutes, paymentHoldMinutes });
1679
+ const chosen = await openReservationDialog(sel, items, { isValidator });
1692
1680
  if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1693
1681
  const startDate = toLocalYmd(sel.start);
1694
1682
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
@@ -51,23 +51,6 @@
51
51
  <input class="form-control" name="notifyGroups" placeholder="ex: administrators">
52
52
  </div>
53
53
 
54
- <div class="mb-3">
55
- <label class="form-label">Durée de blocage en attente (minutes)</label>
56
- <input class="form-control" name="pendingHoldMinutes" placeholder="5">
57
- </div>
58
-
59
- <div class="mb-3">
60
- <label class="form-label">Rappel validateurs (demande en attente) après (minutes)</label>
61
- <input class="form-control" name="validatorReminderMinutesPending" placeholder="0">
62
- <div class="form-text">Si &gt; 0, un email de rappel est envoyé aux validateurs après ce délai tant que la demande est toujours en attente.</div>
63
- </div>
64
-
65
- <div class="mb-3">
66
- <label class="form-label">Délai rappel paiement (minutes)</label>
67
- <input class="form-control" name="paymentHoldMinutes" placeholder="60">
68
- <div class="form-text">Après validation (statut <code>paiement en attente</code>), un rappel est envoyé après ce délai. La réservation est ensuite expirée après <strong>2×</strong> ce délai.</div>
69
- </div>
70
-
71
54
  <div class="mb-3">
72
55
  <label class="form-label">Location longue durée (jours) pour validateurs</label>
73
56
  <input class="form-control" name="validatorFreeMaxDays" placeholder="0">