nodebb-plugin-onekite-calendar 2.0.11 → 2.0.13

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/admin.js +21 -9
  3. package/lib/api.js +235 -4
  4. package/lib/db.js +114 -0
  5. package/lib/helloassoWebhook.js +28 -0
  6. package/library.js +7 -0
  7. package/package.json +1 -1
  8. package/pkg/package/CHANGELOG.md +106 -0
  9. package/pkg/package/lib/admin.js +554 -0
  10. package/pkg/package/lib/api.js +1458 -0
  11. package/pkg/package/lib/controllers.js +11 -0
  12. package/pkg/package/lib/db.js +224 -0
  13. package/pkg/package/lib/discord.js +190 -0
  14. package/pkg/package/lib/helloasso.js +352 -0
  15. package/pkg/package/lib/helloassoWebhook.js +389 -0
  16. package/pkg/package/lib/scheduler.js +201 -0
  17. package/pkg/package/lib/widgets.js +460 -0
  18. package/pkg/package/library.js +164 -0
  19. package/pkg/package/package.json +14 -0
  20. package/pkg/package/plugin.json +43 -0
  21. package/pkg/package/public/admin.js +1477 -0
  22. package/pkg/package/public/client.js +2228 -0
  23. package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
  24. package/pkg/package/templates/calendar-onekite.tpl +51 -0
  25. package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
  26. package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
  27. package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
  28. package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
  29. package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
  30. package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
  31. package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
  32. package/plugin.json +1 -1
  33. package/public/admin.js +205 -4
  34. package/public/client.js +238 -7
  35. package/templates/admin/plugins/calendar-onekite.tpl +74 -0
@@ -0,0 +1,389 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const db = require.main.require('./src/database');
6
+ const meta = require.main.require('./src/meta');
7
+ const user = require.main.require('./src/user');
8
+ // Real-time updates
9
+ let io;
10
+ try {
11
+ const socketIO = require.main.require('./src/socket.io');
12
+ io = socketIO.server || socketIO;
13
+ } catch (e) {
14
+ io = null;
15
+ }
16
+
17
+
18
+ const dbLayer = require('./db');
19
+ const helloasso = require('./helloasso');
20
+ const discord = require('./discord');
21
+
22
+ async function auditLog(action, actorUid, payload) {
23
+ try {
24
+ const uid = actorUid ? parseInt(actorUid, 10) : 0;
25
+ let actorUsername = '';
26
+ if (uid) {
27
+ try {
28
+ const u = await user.getUserFields(uid, ['username']);
29
+ actorUsername = (u && u.username) ? String(u.username) : '';
30
+ } catch (e) {}
31
+ }
32
+ const ts = Date.now();
33
+ const year = new Date(ts).getFullYear();
34
+ await dbLayer.addAuditEntry(Object.assign({ ts, year, action, actorUid: uid, actorUsername }, payload || {}));
35
+ } catch (e) {}
36
+ }
37
+
38
+ const SETTINGS_KEY = 'calendar-onekite';
39
+
40
+ // Replay protection: store processed payment ids.
41
+ const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
42
+
43
+ async function sendEmail(template, uid, subject, data) {
44
+ const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
45
+ if (!Number.isInteger(toUid) || toUid <= 0) return;
46
+ try {
47
+ const emailer = require.main.require('./src/emailer');
48
+ if (typeof emailer.send !== 'function') return;
49
+ const params = Object.assign({}, data || {}, subject ? { subject } : {});
50
+ // NodeBB 4.x: send(template, uid, params)
51
+ // Do NOT branch on function.length: it is unreliable once wrapped/bound and
52
+ // can lead to params being dropped (empty email bodies / missing subjects).
53
+ await emailer.send(template, toUid, params);
54
+ } catch (err) {
55
+ // eslint-disable-next-line no-console
56
+ console.warn('[calendar-onekite] Failed to send email (webhook)', { template, uid: toUid, err: String((err && err.message) || err) });
57
+ }
58
+ }
59
+
60
+ function formatFR(tsOrIso) {
61
+ const d = new Date(tsOrIso);
62
+ const dd = String(d.getDate()).padStart(2, '0');
63
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
64
+ const yyyy = d.getFullYear();
65
+ return `${dd}/${mm}/${yyyy}`;
66
+ }
67
+
68
+ function getReservationIdFromPayload(payload) {
69
+ try {
70
+ const data = payload && payload.data ? payload.data : null;
71
+ if (!data) return null;
72
+
73
+ // HelloAsso commonly uses "metadata" for checkout-intents/payments.
74
+ const metaCandidates = [
75
+ data.meta,
76
+ data.metadata,
77
+ data.checkoutIntent && (data.checkoutIntent.meta || data.checkoutIntent.metadata),
78
+ data.order && (data.order.meta || data.order.metadata),
79
+ ].filter(Boolean);
80
+
81
+ for (const metaObj of metaCandidates) {
82
+ if (typeof metaObj === 'object' && !Array.isArray(metaObj) && metaObj.reservationId) {
83
+ return String(metaObj.reservationId);
84
+ }
85
+ // Some systems send metadata as array of key/value pairs
86
+ if (Array.isArray(metaObj)) {
87
+ const found = metaObj.find((x) => x && (x.key === 'reservationId' || x.name === 'reservationId'));
88
+ if (found && (found.value || found.val)) return String(found.value || found.val);
89
+ }
90
+ }
91
+ } catch (e) {
92
+ // ignore
93
+ }
94
+ return null;
95
+ }
96
+
97
+ async function tryRecoverReservationIdFromPayment(settings, paymentId) {
98
+ if (!paymentId) return null;
99
+ try {
100
+ const token = await helloasso.getAccessToken({
101
+ env: settings.helloassoEnv || 'live',
102
+ clientId: settings.helloassoClientId,
103
+ clientSecret: settings.helloassoClientSecret,
104
+ });
105
+ if (!token) return null;
106
+ const details = await helloasso.getPaymentDetails({
107
+ env: settings.helloassoEnv || 'live',
108
+ token,
109
+ paymentId,
110
+ });
111
+ if (!details) return null;
112
+ // Reuse the same extraction logic on the payment details object.
113
+ return getReservationIdFromPayload({ data: details });
114
+ } catch (e) {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ async function tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId) {
120
+ if (!checkoutIntentId) return null;
121
+ try {
122
+ // First, try a direct mapping stored when the checkout intent was created.
123
+ const mapped = await db.getObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId));
124
+ if (mapped) return String(mapped);
125
+
126
+ // If no mapping exists (older reservations), query HelloAsso for intent details to read metadata.
127
+ const token = await helloasso.getAccessToken({
128
+ env: settings.helloassoEnv || 'live',
129
+ clientId: settings.helloassoClientId,
130
+ clientSecret: settings.helloassoClientSecret,
131
+ });
132
+ if (!token) return null;
133
+ const details = await helloasso.getCheckoutIntentDetails({
134
+ env: settings.helloassoEnv || 'live',
135
+ token,
136
+ organizationSlug: settings.helloassoOrganizationSlug,
137
+ checkoutIntentId,
138
+ });
139
+ if (!details) return null;
140
+ const rid = getReservationIdFromPayload({ data: details });
141
+ if (rid) {
142
+ // persist mapping for next webhooks
143
+ try {
144
+ await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid));
145
+ } catch (e) {}
146
+ return String(rid);
147
+ }
148
+ } catch (e) {
149
+ return null;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ async function alreadyProcessed(paymentId) {
155
+ if (!paymentId) return false;
156
+ try {
157
+ return await db.isSetMember(PROCESSED_KEY, String(paymentId));
158
+ } catch (e) {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ async function markProcessed(paymentId) {
164
+ if (!paymentId) return;
165
+ try {
166
+ await db.setAdd(PROCESSED_KEY, String(paymentId));
167
+ } catch (e) {
168
+ // ignore
169
+ }
170
+ }
171
+
172
+ function isConfirmedPayment(payload) {
173
+ try {
174
+ if (!payload || !payload.data) return false;
175
+ const eventType = String(payload.eventType || '').toLowerCase();
176
+ const stateRaw = payload.data.state || payload.data.status || payload.data.paymentState || '';
177
+ const state = String(stateRaw).toLowerCase();
178
+
179
+ // We accept the most common "paid" states seen in HelloAsso webhooks.
180
+ const okState = ['confirmed', 'authorized', 'paid', 'processed', 'succeeded', 'success'].includes(state);
181
+
182
+ // HelloAsso may send eventType "Payment" and/or "Order".
183
+ if (eventType === 'payment') {
184
+ return okState;
185
+ }
186
+ if (eventType === 'order') {
187
+ // Order payloads may not carry a payment-like "state" field; accept if missing,
188
+ // but still require a recognizable "paid" state when provided.
189
+ return !state || okState;
190
+ }
191
+ return false;
192
+ } catch (e) {
193
+ return false;
194
+ }
195
+ }
196
+
197
+ function formatFR(tsOrIso) {
198
+ const d = new Date(tsOrIso);
199
+ const dd = String(d.getDate()).padStart(2, '0');
200
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
201
+ const yyyy = d.getFullYear();
202
+ return `${dd}/${mm}/${yyyy}`;
203
+ }
204
+
205
+ function getReservationIdFromPayload(payload) {
206
+ try {
207
+ const m = payload && payload.data ? payload.data.meta : null;
208
+ if (!m) return null;
209
+ if (typeof m === 'object' && m.reservationId) return String(m.reservationId);
210
+ if (typeof m === 'object' && m.reservationID) return String(m.reservationID);
211
+ return null;
212
+ } catch (e) {
213
+ return null;
214
+ }
215
+ }
216
+
217
+
218
+ function getCheckoutIntentIdFromPayload(payload) {
219
+ try {
220
+ const data = payload && payload.data ? payload.data : null;
221
+ if (!data) return null;
222
+ // Common locations based on HelloAsso docs and community reports:
223
+ // - Order event often contains checkoutIntentId at root of data
224
+ // - Return/back redirects contain checkoutIntentId in query params (not here)
225
+ const candidates = [
226
+ data.checkoutIntentId,
227
+ data.checkoutIntent && (data.checkoutIntent.id || data.checkoutIntent.checkoutIntentId),
228
+ data.order && (data.order.checkoutIntentId || (data.order.checkoutIntent && data.order.checkoutIntent.id)),
229
+ ].filter(Boolean);
230
+ if (candidates.length) return String(candidates[0]);
231
+ } catch (e) {}
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * Hardened HelloAsso webhook handler.
237
+ * - Requires x-ha-signature (HMAC SHA-256) verification.
238
+ * - Only accepts eventType=Payment with state=Confirmed.
239
+ * - Provides basic replay protection using the payment id.
240
+ *
241
+ * This handler is "safe" by default: if we cannot verify the signature,
242
+ * we refuse the request.
243
+ */
244
+ async function handler(req, res, next) {
245
+ try {
246
+ if (req.method === 'GET') {
247
+ return res.json({ ok: true });
248
+ }
249
+ if (req.method !== 'POST') {
250
+ return res.status(405).json({ ok: false, error: 'method-not-allowed' });
251
+ }
252
+
253
+ const settings = await meta.settings.get(SETTINGS_KEY);
254
+
255
+ // Restrict webhook calls by IP (HelloAsso partner mode signature is not used).
256
+ const defaultAllowed = ['51.138.206.200', '4.233.135.234'];
257
+ const raw = String((settings && settings.helloassoWebhookAllowedIps) || '').trim();
258
+ const allowedIps = (raw ? raw.split(/[\s,;]+/g) : defaultAllowed).map(s => String(s || '').trim()).filter(Boolean);
259
+
260
+ const clientIp = (() => {
261
+ const cf = req.headers['cf-connecting-ip'];
262
+ if (cf) return String(cf).trim();
263
+ const xff = req.headers['x-forwarded-for'];
264
+ if (xff) return String(xff).split(',')[0].trim();
265
+ return String(req.ip || '').trim();
266
+ })();
267
+
268
+ if (allowedIps.length && clientIp && !allowedIps.includes(clientIp)) {
269
+ // eslint-disable-next-line no-console
270
+ console.warn('[calendar-onekite] HelloAsso webhook blocked by IP allowlist', { ip: clientIp, allowed: allowedIps });
271
+ return res.status(403).json({ ok: false, error: 'ip-not-allowed' });
272
+ }
273
+
274
+ // At this point, the payload is trusted.
275
+ const payload = req.body;
276
+
277
+ if (!isConfirmedPayment(payload)) {
278
+ // Acknowledge but ignore other event types/states.
279
+ return res.json({ ok: true, ignored: true });
280
+ }
281
+
282
+ // Ignore incomplete Payment events (HelloAsso sometimes omits metadata and checkoutIntentId on Payment webhooks).
283
+ // We rely on Order events (or checkoutIntent mappings) for reliable reconciliation.
284
+ const _eventType = String((payload && payload.eventType) || '').toLowerCase();
285
+ const _rid = getReservationIdFromPayload(payload);
286
+ const _checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
287
+ if (_eventType === 'payment' && !_rid && !_checkoutIntentId) {
288
+ return res.json({ ok: true, ignored: true, incompletePayment: true });
289
+ }
290
+
291
+ const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId) : null;
292
+ // If we can't identify the payment, acknowledge and ignore (prevents accidental crashes).
293
+ if (!paymentId) {
294
+ return res.json({ ok: true, ignored: true, missingPaymentId: true });
295
+ }
296
+ if (await alreadyProcessed(paymentId)) {
297
+ return res.json({ ok: true, duplicate: true });
298
+ }
299
+
300
+ const rid = getReservationIdFromPayload(payload);
301
+ let resolvedRid = rid;
302
+ const checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
303
+ if (!resolvedRid && checkoutIntentId) {
304
+ // Some webhook payloads omit metadata but provide checkoutIntentId (common on Order events).
305
+ resolvedRid = await tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId);
306
+ }
307
+ if (!resolvedRid && paymentId) {
308
+ // Some webhook payloads omit metadata; try to fetch the payment details.
309
+ resolvedRid = await tryRecoverReservationIdFromPayment(settings, paymentId);
310
+ }
311
+ if (!resolvedRid) {
312
+ // eslint-disable-next-line no-console
313
+ console.warn('[calendar-onekite] HelloAsso webhook missing reservationId in metadata', { eventType: payload && payload.eventType, paymentId, checkoutIntentId });
314
+ // Do NOT mark as processed: if metadata/config is fixed later, a manual replay may be possible.
315
+ return res.json({ ok: true, processed: false, missingReservationId: true });
316
+ }
317
+
318
+ const r = await dbLayer.getReservation(resolvedRid);
319
+ if (!r) {
320
+ await markProcessed(paymentId);
321
+ return res.json({ ok: true, processed: true, reservationNotFound: true });
322
+ }
323
+
324
+ // Mark as paid and persist payment metadata.
325
+ r.status = 'paid';
326
+ r.paidAt = Date.now();
327
+ r.paymentId = paymentId ? String(paymentId) : '';
328
+ if (payload.data && payload.data.paymentReceiptUrl) {
329
+ r.paymentReceiptUrl = String(payload.data.paymentReceiptUrl);
330
+ }
331
+ await dbLayer.saveReservation(r);
332
+
333
+ await auditLog('reservation_paid', 0, {
334
+ targetType: 'reservation',
335
+ targetId: String(r.rid),
336
+ reservationUid: Number(r.uid) || 0,
337
+ reservationUsername: String(r.username || ''),
338
+ itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
339
+ itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
340
+ startDate: r.startDate || '',
341
+ endDate: r.endDate || '',
342
+ paymentId: r.paymentId || '',
343
+ });
344
+
345
+ // Real-time notify: refresh calendars for all viewers (owner + validators/admins)
346
+ try {
347
+ if (io && io.sockets && typeof io.sockets.emit === 'function') {
348
+ io.sockets.emit('event:calendar-onekite.reservationUpdated', { rid: r.rid, status: r.status });
349
+ }
350
+ } catch (e) {}
351
+
352
+ // Notify requester
353
+ const requesterUid = parseInt(r.uid, 10);
354
+ const requester = await user.getUserFields(requesterUid, ['username']);
355
+ if (requesterUid) {
356
+ await sendEmail('calendar-onekite_paid', requesterUid, 'Location matériel - Paiement reçu', {
357
+ uid: requesterUid,
358
+ username: requester && requester.username ? requester.username : '',
359
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
360
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
361
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
362
+ paymentReceiptUrl: r.paymentReceiptUrl || '',
363
+ });
364
+ }
365
+
366
+ // Discord webhook (optional)
367
+ try {
368
+ await discord.notifyPaymentReceived(settings, {
369
+ rid: r.rid,
370
+ uid: r.uid,
371
+ username: (requester && requester.username) ? requester.username : (r.username || ''),
372
+ itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
373
+ itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
374
+ start: r.start,
375
+ end: r.end,
376
+ status: r.status,
377
+ });
378
+ } catch (e) {}
379
+
380
+ await markProcessed(paymentId);
381
+ return res.json({ ok: true, processed: true });
382
+ } catch (err) {
383
+ return next(err);
384
+ }
385
+ }
386
+
387
+ module.exports = {
388
+ handler,
389
+ };
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ const meta = require.main.require('./src/meta');
4
+ const db = require.main.require('./src/database');
5
+ const dbLayer = require('./db');
6
+ const discord = require('./discord');
7
+
8
+ let timer = null;
9
+
10
+ function getSetting(settings, key, fallback) {
11
+ const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
12
+ if (v == null || v === '') return fallback;
13
+ if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
14
+ return v;
15
+ }
16
+
17
+ // Pending holds: short lock after a user creates a request (defaults to 5 minutes)
18
+ async function expirePending() {
19
+ const settings = await meta.settings.get('calendar-onekite');
20
+ const holdMins = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
21
+ const now = Date.now();
22
+
23
+ const ids = await dbLayer.listAllReservationIds(5000);
24
+ if (!ids || !ids.length) {
25
+ return;
26
+ }
27
+
28
+ for (const rid of ids) {
29
+ const resv = await dbLayer.getReservation(rid);
30
+ if (!resv || resv.status !== 'pending') {
31
+ continue;
32
+ }
33
+ const createdAt = parseInt(resv.createdAt, 10) || 0;
34
+ const expiresAt = createdAt + holdMins * 60 * 1000;
35
+ if (now > expiresAt) {
36
+ // Expire (remove from calendar)
37
+ await dbLayer.removeReservation(rid);
38
+ }
39
+ }
40
+ }
41
+
42
+ // Payment window logic:
43
+ // - When a reservation is validated it becomes awaiting_payment
44
+ // - We send a reminder after `paymentHoldMinutes` (default 60)
45
+ // - We expire (and remove) after `2 * paymentHoldMinutes`
46
+ async function processAwaitingPayment() {
47
+ const settings = await meta.settings.get('calendar-onekite');
48
+ const holdMins = parseInt(
49
+ getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')),
50
+ 10
51
+ ) || 60;
52
+ const now = Date.now();
53
+
54
+ const ids = await dbLayer.listAllReservationIds(5000);
55
+ if (!ids || !ids.length) return;
56
+
57
+ const emailer = require.main.require('./src/emailer');
58
+ const user = require.main.require('./src/user');
59
+
60
+ // NodeBB 4.x: always send by uid.
61
+ async function sendEmail(template, uid, subject, data) {
62
+ const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
63
+ if (!Number.isInteger(toUid) || toUid <= 0) return;
64
+
65
+ const params = Object.assign({}, data || {}, subject ? { subject } : {});
66
+
67
+ try {
68
+ if (typeof emailer.send !== 'function') return;
69
+ // NodeBB 4.x: send(template, uid, params)
70
+ // Do NOT branch on function.length (unreliable once wrapped/bound).
71
+ await emailer.send(template, toUid, params);
72
+ } catch (err) {
73
+ // eslint-disable-next-line no-console
74
+ console.warn('[calendar-onekite] Failed to send email (scheduler)', {
75
+ template,
76
+ uid: toUid,
77
+ err: String((err && err.message) || err),
78
+ });
79
+ }
80
+ }
81
+
82
+ function formatFR(ts) {
83
+ const d = new Date(ts);
84
+ const dd = String(d.getDate()).padStart(2, '0');
85
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
86
+ const yyyy = d.getFullYear();
87
+ return `${dd}/${mm}/${yyyy}`;
88
+ }
89
+
90
+ for (const rid of ids) {
91
+ const r = await dbLayer.getReservation(rid);
92
+ if (!r || r.status !== 'awaiting_payment') continue;
93
+
94
+ const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
95
+ if (!approvedAt) continue;
96
+
97
+ const reminderAt = approvedAt + holdMins * 60 * 1000;
98
+ const expireAt = approvedAt + 2 * holdMins * 60 * 1000;
99
+
100
+ if (!r.reminderSent && now >= reminderAt && now < expireAt) {
101
+ // Send reminder once (guarded across clustered NodeBB processes)
102
+ const reminderKey = 'calendar-onekite:email:reminderSent';
103
+ const first = await db.setAdd(reminderKey, rid);
104
+ if (!first) {
105
+ // another process already sent it
106
+ r.reminderSent = true;
107
+ r.reminderAt = now;
108
+ await dbLayer.saveReservation(r);
109
+ continue;
110
+ }
111
+
112
+ const toUid = parseInt(r.uid, 10);
113
+ const u = await user.getUserFields(toUid, ['username']);
114
+ if (toUid) {
115
+ await sendEmail('calendar-onekite_reminder', toUid, 'Location matériel - Rappel', {
116
+ uid: toUid,
117
+ username: (u && u.username) ? u.username : '',
118
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
119
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
120
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
121
+ paymentUrl: r.paymentUrl || '',
122
+ delayMinutes: holdMins,
123
+ pickupLine: r.pickupTime ? (r.adminNote ? `${r.pickupTime} à ${r.adminNote}` : r.pickupTime) : '',
124
+ });
125
+ }
126
+
127
+ r.reminderSent = true;
128
+ r.reminderAt = now;
129
+ await dbLayer.saveReservation(r);
130
+ continue;
131
+ }
132
+
133
+ if (now >= expireAt) {
134
+ // Expire: remove reservation so it disappears from calendar and frees items
135
+ // Guard email send across clustered NodeBB processes
136
+ const expiredKey = 'calendar-onekite:email:expiredSent';
137
+ const firstExpired = await db.setAdd(expiredKey, rid);
138
+ const shouldEmail = !!firstExpired;
139
+
140
+ // Guard Discord notification across clustered NodeBB processes
141
+ const discordKey = 'calendar-onekite:discord:cancelledSent';
142
+ const firstDiscord = await db.setAdd(discordKey, rid);
143
+ const shouldDiscord = !!firstDiscord;
144
+
145
+ const toUid = parseInt(r.uid, 10);
146
+ const u = await user.getUserFields(toUid, ['username']);
147
+
148
+ if (shouldEmail && toUid) {
149
+ await sendEmail('calendar-onekite_expired', toUid, 'Location matériel - Rappel', {
150
+ uid: toUid,
151
+ username: (u && u.username) ? u.username : '',
152
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
153
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
154
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
155
+ delayMinutes: holdMins,
156
+ });
157
+ }
158
+
159
+ if (shouldDiscord) {
160
+ try {
161
+ await discord.notifyReservationCancelled(settings, {
162
+ rid: r.rid || rid,
163
+ uid: r.uid,
164
+ username: (u && u.username) ? u.username : (r.username || ''),
165
+ itemIds: r.itemIds || [],
166
+ itemNames: r.itemNames || [],
167
+ start: r.start,
168
+ end: r.end,
169
+ status: 'cancelled',
170
+ cancelledAt: now,
171
+ cancelledBy: 'system',
172
+ });
173
+ } catch (e) {}
174
+ }
175
+
176
+ await dbLayer.removeReservation(rid);
177
+ }
178
+ }
179
+ }
180
+
181
+ function start() {
182
+ if (timer) return;
183
+ timer = setInterval(() => {
184
+ expirePending().catch(() => {});
185
+ processAwaitingPayment().catch(() => {});
186
+ }, 60 * 1000);
187
+ }
188
+
189
+ function stop() {
190
+ if (timer) {
191
+ clearInterval(timer);
192
+ timer = null;
193
+ }
194
+ }
195
+
196
+ module.exports = {
197
+ start,
198
+ stop,
199
+ expirePending,
200
+ processAwaitingPayment,
201
+ };