nodebb-plugin-equipment-calendar 9.0.14 → 9.1.3

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/library.js CHANGED
@@ -1,791 +1,147 @@
1
1
  'use strict';
2
2
 
3
- // Node 18+ has global fetch; fallback to undici if needed
4
- let fetchFn = global.fetch;
5
- try { if (!fetchFn) { fetchFn = require('undici').fetch; } } catch (e) {}
6
-
7
- const db = require.main.require('./src/database');
3
+ const nconf = require.main.require('nconf');
8
4
  const meta = require.main.require('./src/meta');
9
- const groups = require.main.require('./src/groups');
5
+ const db = require.main.require('./src/database');
10
6
  const user = require.main.require('./src/user');
11
- const notifications = require.main.require('./src/notifications');
12
- const Emailer = require.main.require('./src/emailer');
13
- const routeHelpers = require.main.require('./src/routes/helpers');
14
- const middleware = require.main.require('./src/middleware');
15
- const helpers = require.main.require('./src/controllers/helpers');
16
- const nconf = require.main.require('nconf');
17
-
7
+ const groups = require.main.require('./src/groups');
18
8
  const winston = require.main.require('winston');
19
9
 
20
- function generateId() {
21
- try {
22
- // Node 14+ / modern: crypto.randomUUID
23
- const crypto = require('crypto');
24
- if (crypto.randomUUID) return crypto.randomUUID();
25
- return crypto.randomBytes(16).toString('hex');
26
- } catch (e) {
27
- return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
28
- }
29
- }
30
-
31
- function normalizeItemIds(itemIdsRaw) {
32
- if (!itemIdsRaw) return [];
33
- if (Array.isArray(itemIdsRaw)) {
34
- return itemIdsRaw.map(String).flatMap((v) => String(v).split(',')).map(s => s.trim()).filter(Boolean);
35
- }
36
- if (typeof itemIdsRaw === 'string') {
37
- return itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
38
- }
39
- // fallback: single value (number/object/etc.)
40
- try {
41
- return [String(itemIdsRaw).trim()].filter(Boolean);
42
- } catch (e) {
43
- return [];
44
- }
45
- }
46
-
47
- function parseDateInput(v) {
48
- if (v === null || v === undefined) return null;
49
- if (typeof v === 'number' || (typeof v === 'string' && v.trim() && /^\d+$/.test(v.trim()))) {
50
- const ms = typeof v === 'number' ? v : parseInt(v.trim(), 10);
51
- const d = new Date(ms);
52
- return Number.isNaN(d.getTime()) ? null : d;
53
- }
54
- if (typeof v === 'string') {
55
- const s = v.trim();
56
- if (!s) return null;
57
- if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
58
- const d = new Date(s + 'T00:00:00Z');
59
- return Number.isNaN(d.getTime()) ? null : d;
60
- }
61
- const d = new Date(s);
62
- return Number.isNaN(d.getTime()) ? null : d;
63
- }
64
- try {
65
- const d = new Date(String(v));
66
- return Number.isNaN(d.getTime()) ? null : d;
67
- } catch (e) {
68
- return null;
69
- }
70
- }
71
-
72
- function tzOffsetMs(date, timeZone) {
73
- const asTz = new Date(date.toLocaleString('en-US', { timeZone }));
74
- return asTz.getTime() - date.getTime();
75
- }
76
-
77
- function tzMidnightMsFromIso(iso, timeZone) {
78
- const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
79
- if (!m) return null;
80
- const y = parseInt(m[1], 10);
81
- const mo = parseInt(m[2], 10) - 1;
82
- const d = parseInt(m[3], 10);
83
- const utcMidnight = new Date(Date.UTC(y, mo, d, 0, 0, 0));
84
- const offset = tzOffsetMs(utcMidnight, timeZone);
85
- return utcMidnight.getTime() - offset;
86
- }
87
-
88
- function addDaysIso(iso, days) {
89
- const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
90
- if (!m) return iso;
91
- const y = parseInt(m[1], 10);
92
- const mo = parseInt(m[2], 10) - 1;
93
- const d = parseInt(m[3], 10);
94
- const dt = new Date(Date.UTC(y, mo, d));
95
- dt.setUTCDate(dt.getUTCDate() + (days || 0));
96
- const pad = (x) => String(x).padStart(2, '0');
97
- return dt.getUTCFullYear() + '-' + pad(dt.getUTCMonth() + 1) + '-' + pad(dt.getUTCDate());
98
- }
99
-
100
- function formatDateTimeFR(ms) {
101
- const n = Number(ms || 0);
102
- if (!n) return '';
103
- try {
104
- const parts = new Intl.DateTimeFormat('fr-FR', {
105
- timeZone: 'Europe/Paris',
106
- year: 'numeric',
107
- month: '2-digit',
108
- day: '2-digit',
109
- hour: '2-digit',
110
- minute: '2-digit',
111
- hour12: false,
112
- }).formatToParts(new Date(n));
113
- const get = (t) => (parts.find(p => p.type === t) || {}).value || '';
114
- return `${get('day')}/${get('month')}/${get('year')} ${get('hour')}:${get('minute')}`;
115
- } catch (e) {
116
- const d = new Date(n);
117
- const pad = (x) => String(x).padStart(2,'0');
118
- return `${pad(d.getDate())}/${pad(d.getMonth()+1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
119
- }
120
- }
121
-
122
- const axios = require('axios');
123
- const { DateTime } = require('luxon');
124
- const { v4: uuidv4 } = require('uuid');
125
- const crypto = require('crypto');
126
-
127
10
  const plugin = {};
128
11
  const SETTINGS_KEY = 'equipmentCalendar';
129
12
 
130
13
  const DEFAULT_SETTINGS = {
131
- creatorGroups: 'registered-users',
14
+ creatorGroups: '',
132
15
  approverGroup: 'administrators',
133
16
  notifyGroup: 'administrators',
134
- // JSON array of items: [{ "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true }]
135
- itemsJson: '[]',
136
- itemsSource: 'manual',
137
- ha_itemsFormType: '',
138
- ha_itemsFormSlug: '',
139
- ha_locationMapJson: '{}',
140
- ha_calendarItemNamePrefix: 'Location matériel',
141
- paymentTimeoutMinutes: 10,
142
- // HelloAsso
17
+ ha_apiBaseUrl: 'https://api.helloasso.com',
18
+ ha_organizationSlug: '',
143
19
  ha_clientId: '',
144
20
  ha_clientSecret: '',
145
- ha_organizationSlug: '',
21
+ ha_itemsFormType: 'shop',
22
+ ha_itemsFormSlug: '',
146
23
  ha_returnUrl: '',
147
- ha_webhookSecret: '',
148
- // calendar
24
+ paymentTimeoutMinutes: 10,
149
25
  defaultView: 'dayGridMonth',
150
- timezone: 'Europe/Paris',
151
- // privacy
152
- showRequesterToAll: '0', // 0/1
153
26
  };
154
27
 
155
- function normalizeSettings(raw) {
156
- const out = {};
157
- const keys = Object.keys(DEFAULT_SETTINGS);
158
- keys.forEach((k) => {
159
- const def = DEFAULT_SETTINGS[k];
160
- const str = raw && raw[k];
161
- if (typeof str !== 'string') {
162
- out[k] = def;
163
- return;
164
- }
165
- try {
166
- const v = JSON.parse(str);
167
- out[k] = (typeof v === typeof def) ? v : def;
168
- } catch (e) {
169
- out[k] = def;
170
- }
171
- });
172
- return out;
173
- }
174
28
  async function getSettings() {
175
- const raw = await meta.settings.get('equipmentCalendar');
176
- const merged = Object.assign({}, DEFAULT_SETTINGS, raw || {});
177
- // coerce
178
- merged.paymentTimeoutMinutes = parseInt(merged.paymentTimeoutMinutes, 10) || DEFAULT_SETTINGS.paymentTimeoutMinutes;
179
- return merged;
180
- }
181
- async function setSettings(payload) {
182
- const sets = {};
183
- Object.keys(payload || {}).forEach((k) => {
184
- sets[k] = JSON.stringify(payload[k]);
185
- });
186
- if (!resp.ok) {
187
- const t = await resp.text();
188
- throw new Error(`HelloAsso token error: ${resp.status} ${t}`);
189
- }
190
- const json = await resp.json();
191
- const accessToken = json.access_token;
192
- const refreshToken = json.refresh_token;
193
-
194
- const expiresIn = parseInt(json.expires_in, 10) || 0;
195
- const expMs = now + (expiresIn * 1000);
196
-
197
- // Per docs, refresh token is valid ~30 days and rotates; keep a conservative expiry (29 days)
198
- const refreshExpMs = now + (29 * 24 * 60 * 60 * 1000);
199
-
200
- haTokenCache = { accessToken, refreshToken, expMs };
201
-
202
- try {
203
- await db.setObject(tokenKey, {
204
- refresh_token: refreshToken || stored && stored.refresh_token || '',
205
- refresh_expires_at: String(refreshExpMs),
206
- });
207
- } catch (e) {}
208
-
209
- return accessToken;
210
- }
211
-
212
- async function createHelloAssoCheckoutIntent(settings, bookingId, reservations) {
213
- const org = String(settings.ha_organizationSlug || '').trim();
214
- if (!org) throw new Error('HelloAsso organization slug missing');
215
-
216
- const baseUrl = (meta && meta.config && meta.config.url) ? meta.config.url.replace(/\/$/, '') : '';
217
- const backUrl = baseUrl + '/equipment/calendar';
218
- const returnUrl = baseUrl + `/equipment/helloasso/callback?type=return&bookingId=${encodeURIComponent(bookingId)}`;
219
- const errorUrl = baseUrl + `/equipment/helloasso/callback?type=error&bookingId=${encodeURIComponent(bookingId)}`;
220
-
221
- const items = await getActiveItems(settings);
222
- const byId = {};
223
- items.forEach(i => { byId[i.id] = i; });
224
-
225
- const names = reservations.map(r => (byId[r.itemId] && byId[r.itemId].name) || r.itemId);
226
- const totalAmount = reservations.reduce((sum, r) => {
227
- const price = (byId[r.itemId] && parseInt(byId[r.itemId].priceCents, 10)) || 0;
228
- return sum + (Number.isFinite(price) ? price : 0);
229
- }, 0);
230
-
231
- if (!totalAmount || totalAmount <= 0) {
232
- throw new Error('Montant total invalide (prix manquant sur les items).');
233
- }
234
-
235
- const body = {
236
- totalAmount,
237
- initialAmount: totalAmount,
238
- itemName: `${String(settings.ha_calendarItemNamePrefix || 'Location matériel')}: ${names.join(', ')}`.slice(0, 250),
239
- backUrl,
240
- errorUrl,
241
- returnUrl,
242
- containsDonation: false,
243
- metadata: {
244
- bookingId,
245
- rids: reservations.map(r => r.rid || r.id),
246
- uid: reservations[0] && reservations[0].uid,
247
- startMs: reservations[0] && reservations[0].startMs,
248
- endMs: reservations[0] && reservations[0].endMs,
249
- },
250
- };
251
-
252
- const token = await getHelloAssoAccessToken(settings);
253
- const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
254
- const resp = await fetchFn(url, {
255
- method: 'POST',
256
- headers: {
257
- accept: 'application/json',
258
- 'content-type': 'application/json',
259
- authorization: `Bearer ${token}`,
260
- },
261
- body: JSON.stringify(body),
262
- });
263
-
264
- if (!resp.ok) {
265
- const t = await resp.text();
266
- throw new Error(`HelloAsso checkout-intents error: ${resp.status} ${t}`);
267
- }
268
- return await resp.json();
29
+ const raw = await meta.settings.get(SETTINGS_KEY);
30
+ const s = Object.assign({}, DEFAULT_SETTINGS, raw || {});
31
+ s.paymentTimeoutMinutes = parseInt(s.paymentTimeoutMinutes, 10) || DEFAULT_SETTINGS.paymentTimeoutMinutes;
32
+ return s;
269
33
  }
270
34
 
271
- async function fetchHelloAssoCheckoutIntent(settings, checkoutIntentId) {
272
- const org = String(settings.ha_organizationSlug || '').trim();
273
- const token = await getHelloAssoAccessToken(settings);
274
- const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents/${encodeURIComponent(checkoutIntentId)}`;
275
- const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
276
- if (!resp.ok) {
277
- const t = await resp.text();
278
- throw new Error(`HelloAsso checkout-intents GET error: ${resp.status} ${t}`);
279
- }
280
- return await resp.json();
281
- }
282
-
283
- function isCheckoutPaid(checkout) {
284
- const payments = (checkout && (checkout.payments || (checkout.order && checkout.order.payments))) || [];
285
- const list = Array.isArray(payments) ? payments : [];
286
- const states = list.map(p => String(p.state || p.status || p.paymentState || '').toLowerCase());
287
- if (states.some(s => ['authorized', 'paid', 'succeeded', 'success'].includes(s))) return true;
288
-
289
- const op = String(checkout && (checkout.state || checkout.operationState || '')).toLowerCase();
290
- if (['authorized', 'paid', 'succeeded'].includes(op)) return true;
291
-
292
- if (checkout && (checkout.order || checkout.orderId)) {
293
- if (list.length) return true;
35
+ async function isUserInAnyGroups(uid, groupCsv) {
36
+ if (!uid || !groupCsv) return false;
37
+ const names = String(groupCsv).split(',').map(s => s.trim()).filter(Boolean);
38
+ if (!names.length) return false;
39
+ for (const name of names) {
40
+ const inGroup = await groups.isMember(uid, name);
41
+ if (inGroup) return true;
294
42
  }
295
43
  return false;
296
44
  }
297
45
 
298
- async function fetchHelloAssoItems(settings) {
299
- const org = String(settings.ha_organizationSlug || '').trim();
300
- const formType = String(settings.ha_itemsFormType || '').trim();
301
- const formSlug = String(settings.ha_itemsFormSlug || '').trim();
302
- if (!org || !formType || !formSlug) return [];
303
-
304
- const token = await getHelloAssoAccessToken(settings);
305
- const base = (String(settings.ha_apiBaseUrl || '').trim() || 'https://api.helloasso.com').replace(/\/$/, '');
306
- const cacheKey = `equipmentCalendar:ha:items:${org}:${formType}:${formSlug}`;
307
-
308
- // Cache 10 minutes
309
- const cached = await db.getObject(cacheKey);
310
- const now = Date.now();
311
- if (cached && cached.itemsJson && cached.expMs && now < parseInt(cached.expMs, 10)) {
312
- try { return JSON.parse(cached.itemsJson); } catch (e) {}
313
- }
314
-
315
- // IMPORTANT:
316
- // - /forms/.../items = "articles vendus" (liés aux commandes) => peut renvoyer 0 si aucune commande
317
- // - /forms/.../public = données publiques détaillées => contient la structure (tiers / products) du formulaire
318
- const publicUrl = `${base}/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/public`;
319
- const resp = await fetchFn(publicUrl, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
320
- if (!resp.ok) {
321
- const t = await resp.text();
322
- throw new Error(`HelloAsso public form error: ${resp.status} ${t}`);
323
- }
324
- const data = await resp.json();
325
-
326
- // Extract catalog items:
327
- // structure differs by formType; common pattern: data.tiers[] or data.tiers[].products[] / items[]
328
- const out = [];
329
- const tiers = (data && (data.tiers || data.tiersList || data.prices || data.priceCategories)) || [];
330
- const tierArr = Array.isArray(tiers) ? tiers : [];
331
-
332
- function pushItem(it, tierName) {
333
- if (!it) return;
334
- const id = String(it.id || it.itemId || it.reference || it.slug || it.code || it.name || '').trim();
335
- const name = String(it.name || it.label || it.title || '').trim() || (tierName ? String(tierName) : id);
336
- if (!id || !name) return;
337
- const amount = it.amount || it.price || it.unitPrice || it.totalAmount || it.initialAmount;
338
- const priceCents = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
339
- const raw = (typeof priceCents === 'number' ? priceCents : parseInt(priceCents, 10)) || 0;
340
- // Heuristic: HelloAsso amounts are often in cents; convert when it looks like cents.
341
- const price = (raw >= 1000 && raw % 100 === 0) ? (raw / 100) : raw;
342
- out.push({ id, name, price, priceRaw: raw });
343
- }
344
-
345
- // Try a few known layouts
346
- for (const t of tierArr) {
347
- const tierName = t && (t.name || t.label || t.title);
348
- const products = (t && (t.items || t.products || t.prices || t.options)) || [];
349
- const arr = Array.isArray(products) ? products : [];
350
- if (arr.length) {
351
- arr.forEach(p => pushItem(p, tierName));
352
- } else {
353
- // sometimes tier itself is the product
354
- pushItem(t, tierName);
355
- }
356
- }
357
-
358
- // Fallback: some forms expose items directly
359
- if (!out.length && data && Array.isArray(data.items)) {
360
- data.items.forEach(p => pushItem(p, ''));
361
- }
362
-
363
- await db.setObject(cacheKey, {
364
- itemsJson: JSON.stringify(out),
365
- expMs: String(Date.now() + 10 * 60 * 1000),
46
+ function rid() {
47
+ // UUID v4 (no dependency)
48
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
49
+ const r = Math.random() * 16 | 0;
50
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
51
+ return v.toString(16);
366
52
  });
367
-
368
- return out;
369
53
  }
370
54
 
371
- async function getActiveItems() {
372
- const settings = await getSettings();
373
- const rawItems = await fetchHelloAssoItems(settings);
374
- const items = (Array.isArray(rawItems) ? rawItems : []).map((it) => ({
375
- id: String(it.id || '').trim(),
376
- name: String(it.name || '').trim(),
377
- price: (Number(it.price || 0) || ((Number(it.priceCents || 0) || 0) >= 1000 && (Number(it.priceCents||0)%100===0) ? (Number(it.priceCents||0)/100) : (Number(it.priceCents||0)||0)) || 0),
378
- active: true,
379
- })).filter(it => it.id && it.name);
380
- return items;
381
- }
382
-
383
- // --- Data layer ---
384
- // Keys:
385
- // item hash: equipmentCalendar:items (stored in settings as JSON)
386
- // reservations stored as objects in db, indexed by id, and by itemId
387
- // reservation object key: equipmentCalendar:reservation:<rid>
388
- // index by item: equipmentCalendar:item:<itemId>:res (sorted set score=startMillis, value=resId)
389
-
390
- function resKey(rid) { return `equipmentCalendar:reservation:${rid}`; }
391
- function itemIndexKey(itemId) { return `equipmentCalendar:item:${itemId}:res`; }
392
-
393
- function statusBlocksItem(status) {
394
- const s = String(status || '');
395
- if (s === 'rejected' || s === 'cancelled') return false;
396
- return (s === 'pending' || s === 'approved' || s === 'payment_pending' || s === 'paid');
397
- }
398
-
399
- async function saveBooking(booking) {
400
- const bid = booking.bookingId;
401
- await db.setObject(`equipmentCalendar:booking:${bid}`, booking);
402
- }
403
-
404
- async function getBooking(bookingId) {
405
- return await db.getObject(`equipmentCalendar:booking:${bookingId}`);
406
- }
407
-
408
- async function addReservationToBooking(bookingId, rid) {
409
- await db.setAdd(`equipmentCalendar:booking:${bookingId}:rids`, rid);
410
- }
411
-
412
- async function getBookingRids(bookingId) {
413
- return await db.getSetMembers(`equipmentCalendar:booking:${bookingId}:rids`) || [];
414
- }
55
+ function reservationKey(rid) { return `equipmentCalendar:reservation:${rid}`; }
56
+ const reservationsZset = 'equipmentCalendar:reservations';
415
57
 
416
58
  async function saveReservation(r) {
417
- const rid = String(r.rid || r.id || '');
418
- if (!rid) throw new Error('missing rid');
419
- r.rid = rid;
420
- await db.setObject(resKey(rid), r);
421
- await db.sortedSetAdd(itemIndexKey(r.itemId), r.startMs, rid);
422
- await db.sortedSetAdd('equipmentCalendar:reservations', r.startMs, rid);
59
+ await db.setObject(reservationKey(r.rid), r);
60
+ await db.sortedSetAdd(reservationsZset, r.createdAt || Date.now(), r.rid);
423
61
  }
424
62
 
425
- async function getReservation(rid) {
426
- const id = String(rid || '');
427
- if (!id) return null;
428
- return await db.getObject(resKey(id));
63
+ async function listReservations(limit=500) {
64
+ const rids = await db.getSortedSetRevRange(reservationsZset, 0, limit - 1);
65
+ const objs = await Promise.all(rids.map(id => db.getObject(reservationKey(id))));
66
+ return objs.filter(Boolean);
429
67
  }
430
68
 
431
- async function setReservationStatus(rid, status) {
432
- const r = await getReservation(rid);
433
- if (!r) throw new Error('not_found');
434
- r.status = status;
435
- await db.setObject(resKey(rid), r);
436
- }
69
+ function toEvent(r) {
70
+ // FullCalendar allDay uses exclusive end
71
+ const start = String(r.startIso).slice(0,10);
72
+ const end = new Date(String(r.endIso).slice(0,10) + 'T00:00:00Z');
73
+ end.setUTCDate(end.getUTCDate() + 1);
74
+ const endExcl = end.toISOString().slice(0,10);
437
75
 
438
- async function deleteReservation(rid) {
439
- const r = await getReservation(rid);
440
- if (!r) return;
441
- // remove from global index
442
- try { await db.sortedSetRemove('equipmentCalendar:reservations', rid); } catch (e) {}
443
- // remove from item index
444
- try { await db.sortedSetRemove(itemIndexKey(r.itemId), rid); } catch (e) {}
445
- // delete object
446
- await db.delete(resKey(rid));
447
- }
76
+ const status = r.status || 'pending';
77
+ const title = status === 'paid' ? '[PAID] Reservation' : (status === 'approved' ? '[APPROVED] Reservation' : '[PENDING] Reservation');
448
78
 
449
- function normalizeReservation(obj) {
450
- const startMs = Number(obj.startMs);
451
- const endMs = Number(obj.endMs);
452
79
  return {
453
- id: obj.id,
454
- itemId: obj.itemId,
455
- uid: Number(obj.uid),
456
- startMs,
457
- endMs,
458
- status: obj.status,
459
- createdAtMs: Number(obj.createdAtMs || 0),
460
- updatedAtMs: Number(obj.updatedAtMs || 0),
461
- validatorUid: obj.validatorUid ? Number(obj.validatorUid) : 0,
462
- notesUser: obj.notesUser || '',
463
- notesAdmin: obj.notesAdmin || '',
464
- ha_checkoutIntentId: obj.ha_checkoutIntentId || '',
465
- ha_paymentUrl: obj.ha_paymentUrl || '',
466
- ha_paymentStatus: obj.ha_paymentStatus || '',
467
- ha_paidAtMs: Number(obj.ha_paidAtMs || 0),
468
- };
469
- }
470
-
471
- async function listReservationsForRange(itemId, startMs, endMs) {
472
- // fetch candidates by score range with some padding
473
- const ids = await db.getSortedSetRangeByScore(itemIndexKey(itemId), 0, -1, startMs - 86400000, endMs + 86400000);
474
- if (!ids || !ids.length) return [];
475
- const objs = await db.getObjects(ids.map(resKey));
476
- return (objs || []).filter(Boolean).map(normalizeReservation).filter(r => {
477
- // overlap check
478
- return r.startMs < endMs && r.endMs > startMs;
479
- });
480
- }
481
-
482
- async function hasOverlap(itemId, startMs, endMs) {
483
- const existing = await listReservationsForRange(itemId, startMs, endMs);
484
- return existing.some(r => statusBlocksItem(r.status) && r.startMs < endMs && r.endMs > startMs);
485
- }
486
-
487
- // --- Permissions helpers ---
488
- async function isInAnyGroup(uid, groupNamesCsv) {
489
- if (!uid) return false;
490
- const groupNames = String(groupNamesCsv || '').split(',').map(s => s.trim()).filter(Boolean);
491
- if (!groupNames.length) return false;
492
- for (const g of groupNames) {
493
- const isMember = await groups.isMember(uid, g);
494
- if (isMember) return true;
495
- }
496
- return false;
497
- }
498
-
499
- async function canCreate(uid, settings) {
500
- return isInAnyGroup(uid, settings.creatorGroups);
501
- }
502
-
503
- async function canApprove(uid, settings) {
504
- return groups.isMember(uid, settings.approverGroup);
505
- }
506
-
507
- async function listGroupUids(groupName) {
508
- try {
509
- const members = await groups.getMembers(groupName, 0, 9999);
510
- return (members && members.users) ? members.users.map(u => u.uid) : [];
511
- } catch (e) {
512
- return [];
513
- }
514
- }
515
-
516
- async function sendGroupNotificationAndEmail(groupName, notifTitle, notifBody, link) {
517
- const uids = await listGroupUids(groupName);
518
- if (!uids.length) return;
519
-
520
- // NodeBB notifications
521
- try {
522
- await notifications.create({
523
- bodyShort: notifTitle,
524
- bodyLong: notifBody,
525
- nid: `equipmentCalendar:${uuidv4()}`,
526
- from: 0,
527
- path: link,
528
- });
529
- } catch (e) { /* ignore */ }
530
-
531
- try {
532
- // push notification to each user
533
- for (const uid of uids) {
534
- try {
535
- await notifications.push({
536
- uid,
537
- bodyShort: notifTitle,
538
- bodyLong: notifBody,
539
- nid: `equipmentCalendar:${uuidv4()}`,
540
- from: 0,
541
- path: link,
542
- });
543
- } catch (e) { /* ignore per user */ }
544
- }
545
- } catch (e) { /* ignore */ }
546
-
547
- // Emails (best-effort)
548
- try {
549
- const users = await user.getUsersData(uids);
550
- const emails = (users || []).map(u => u.email).filter(Boolean);
551
- if (emails.length) {
552
- await Emailer.send('equipmentCalendar', {
553
- // Emailer templates: we provide a minimal HTML via meta.template? NodeBB typically uses email templates
554
- // Here we rely on Emailer plugin config; this is best-effort and may vary per installation.
555
- // Fallback: send each email individually with raw subject/text if supported.
556
- subject: notifTitle,
557
- body: `${notifBody}\n\n${link}`,
558
- to: emails.join(','),
559
- });
560
- }
561
- } catch (e) {
562
- // Many NodeBB installs do not support ad-hoc Emailer.send signatures; ignore gracefully.
563
- }
564
- }
565
-
566
- // --- HelloAsso helpers ---
567
- // This is a minimal integration skeleton.
568
- // See HelloAsso API v5 docs for exact endpoints and payloads.
569
- async function helloAssoGetAccessToken(settings) {
570
- // HelloAsso uses OAuth2 client credentials.
571
- // Endpoint: https://api.helloasso.com/oauth2/token
572
- const url = 'https://api.helloasso.com/oauth2/token';
573
- const params = new URLSearchParams();
574
- params.append('grant_type', 'client_credentials');
575
- params.append('client_id', settings.ha_clientId);
576
- params.append('client_secret', settings.ha_clientSecret);
577
-
578
- const resp = await axios.post(url, params.toString(), {
579
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
580
- timeout: 15000,
581
- });
582
- return resp.data.access_token;
583
- }
584
-
585
- async function helloAssoCreateCheckout(settings, token, reservation, item) {
586
- // Minimal: create a checkout intent and return redirectUrl
587
- // This endpoint/payload may need adaptation depending on your HelloAsso setup.
588
- const org = settings.ha_organizationSlug;
589
- if (!org) throw new Error('HelloAsso organizationSlug missing');
590
-
591
- const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
592
- const amountCents = Math.max(0, Number(item.priceCents || 0));
593
-
594
- const payload = {
595
- totalAmount: amountCents,
596
- initialAmount: amountCents,
597
- itemName: item.name,
598
- backUrl: settings.ha_returnUrl || `${nconf.get('url')}/equipment/payment/return?rid=${encodeURIComponent(reservation.id)}`,
599
- errorUrl: settings.ha_returnUrl || `${nconf.get('url')}/equipment/payment/return?rid=${encodeURIComponent(reservation.id)}&status=error`,
600
- metadata: {
601
- reservationId: reservation.id,
602
- itemId: reservation.itemId,
603
- uid: String(reservation.uid),
604
- },
605
- };
606
-
607
- const resp = await axios.post(url, payload, {
608
- headers: { Authorization: `Bearer ${token}` },
609
- timeout: 15000,
610
- });
611
-
612
- // Typical response fields (may vary): id, redirectUrl
613
- return {
614
- checkoutIntentId: resp.data.id || resp.data.checkoutIntentId || '',
615
- paymentUrl: resp.data.redirectUrl || resp.data.paymentUrl || '',
616
- };
617
- }
618
-
619
- // Webhook signature check (simple HMAC SHA256 over raw body)
620
- function verifyWebhook(req, secret) {
621
- if (!secret) return true;
622
- const sig = req.headers['x-helloasso-signature'] || req.headers['x-helloasso-signature-hmac-sha256'];
623
- if (!sig) return false;
624
- const raw = req.rawBody || '';
625
- const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
626
- // Some providers prefix, some base64; accept hex only in this skeleton
627
- return String(sig).toLowerCase() === expected.toLowerCase();
628
- }
629
-
630
- // --- Rendering helpers ---
631
- function toEvent(res, item, requesterName, canSeeRequester) {
632
- const start = DateTime.fromMillis(res.startMs).toISO();
633
- const end = DateTime.fromMillis(res.endMs).toISO();
634
-
635
- let icon = '⏳';
636
- let className = 'ec-status-pending';
637
- if (res.status === 'approved_waiting_payment') { icon = '💳'; className = 'ec-status-awaitpay'; }
638
- if (res.status === 'paid_validated') { icon = '✅'; className = 'ec-status-valid'; }
639
- if (res.status === 'rejected' || res.status === 'cancelled') { icon = '❌'; className = 'ec-status-cancel'; }
640
-
641
- const titleParts = [icon, item ? item.name : res.itemId];
642
- if (canSeeRequester && requesterName) titleParts.push(`- ${requesterName}`);
643
- return {
644
- id: res.id,
645
- title: titleParts.join(' '),
80
+ id: r.rid,
81
+ title,
646
82
  start,
647
- end,
83
+ end: endExcl,
648
84
  allDay: true,
649
- className,
650
- extendedProps: {
651
- status: res.status,
652
- itemId: res.itemId,
653
- },
654
85
  };
655
86
  }
656
87
 
657
- function clampRange(startStr, endStr, tz) {
658
- const start = DateTime.fromISO(startStr, { zone: tz }).isValid ? DateTime.fromISO(startStr, { zone: tz }) : DateTime.now().setZone(tz).startOf('month');
659
- const end = DateTime.fromISO(endStr, { zone: tz }).isValid ? DateTime.fromISO(endStr, { zone: tz }) : start.plus({ months: 1 });
660
- // prevent crazy ranges
661
- const maxDays = 62;
662
- const diffDays = Math.abs(end.diff(start, 'days').days);
663
- const safeEnd = diffDays > maxDays ? start.plus({ days: maxDays }) : end;
664
- return { start, end: safeEnd };
665
- }
666
-
667
- // --- Routes ---
668
88
  plugin.init = async function (params) {
669
- const { router } = params;
670
- const mid = params.middleware;
89
+ const { router, middleware } = params;
671
90
 
672
- // Admin (ACP) routes
673
- if (mid && mid.admin) {
674
-
675
- await meta.settings.set('equipmentCalendar', out);
676
- return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar?saved=1');
677
- } catch (e) {
678
- winston.error('[equipment-calendar] admin save error', e);
679
- return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar?error=1');
680
- }
91
+ // Admin page
92
+ router.get('/admin/plugins/equipment-calendar', middleware.admin.buildHeader, async (req, res) => {
93
+ res.render('admin/plugins/equipment-calendar', {});
681
94
  });
682
-
683
- router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
684
- router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
685
- router.get('/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, mid.admin.buildHeader, handleHelloAssoTest);
686
- router.get('/api/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, handleHelloAssoTest);
687
- router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
688
- router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
689
- router.post('/admin/plugins/equipment-calendar/purge', middleware.applyCSRF, handleAdminPurge);
690
- router.post('/admin/plugins/equipment-calendar/reservations/:rid/approve', middleware.applyCSRF, handleAdminApprove);
691
- router.post('/admin/plugins/equipment-calendar/reservations/:rid/reject', middleware.applyCSRF, handleAdminReject);
692
- router.post('/admin/plugins/equipment-calendar/reservations/:rid/delete', middleware.applyCSRF, handleAdminDelete);
693
- }
694
-
695
- // Convenience alias (optional): /calendar -> /equipment/calendar
696
- router.get('/calendar', (req, res) => res.redirect('/equipment/calendar'));
697
-
698
- // To verify webhook signature we need raw body; add a rawBody collector for this route only
699
- router.post('/equipment/webhook/helloasso',
700
- require.main.require('body-parser').text({ type: '*/*' }),
701
- async (req, res) => {
702
- req.rawBody = req.body || '';
703
- let json;
704
- try { json = JSON.parse(req.rawBody || '{}'); } catch (e) { json = {}; }
705
-
706
- const settings = await getSettings();
707
- if (!verifyWebhook(req, settings.ha_webhookSecret)) {
708
- return res.status(401).json({ ok: false, error: 'invalid signature' });
709
- }
710
-
711
- // Minimal mapping: expect metadata.reservationId and event indicating payment succeeded.
712
- const reservationId = json?.metadata?.reservationId || json?.data?.metadata?.reservationId || '';
713
- const paymentStatus = json?.eventType || json?.type || json?.status || 'unknown';
714
-
715
- if (reservationId) {
716
- const reservation = await getReservation(reservationId);
717
- if (reservation) {
718
- reservation.ha_paymentStatus = String(paymentStatus);
719
- // Heuristic: if payload includes "OrderPaid" or "Payment" success
720
- const isPaid = /paid|success|payment/i.test(String(paymentStatus)) || json?.data?.state === 'Paid';
721
- if (isPaid) {
722
- reservation.status = 'paid_validated';
723
- reservation.ha_paidAtMs = Date.now();
724
- }
725
- reservation.updatedAtMs = Date.now();
726
- await saveReservation(reservation);
727
- }
728
- }
729
-
730
- return res.json({ ok: true });
731
- }
732
- );
733
-
734
- // Return endpoint (optional)
735
- router.get('/equipment/payment/return', middleware.buildHeader, async (req, res) => {
736
- res.render('equipment-calendar/payment-return', {
737
- title: 'Paiement',
738
- rid: req.query.rid || '',
739
- status: req.query.status || 'ok',
740
- statusError: String(req.query.status || '') === 'error',
741
- });
95
+ router.get('/api/admin/plugins/equipment-calendar', async (req, res) => {
96
+ res.json({});
742
97
  });
743
98
 
744
- // Page routes are attached via filter:router.page as well, but we also add directly to be safe:
745
-
746
- // Calendar inline actions (approve/reject/delete) via API
747
- router.post('/api/equipment/reservations/:rid/action', middleware.applyCSRF, async (req, res) => {
748
- try {
749
- const settings = await getSettings();
750
- const rid = String(req.params.rid || '').trim();
751
- const action = String(req.body.action || '').trim();
752
- if (!rid || !action) return res.status(400).json({ error: 'missing' });
753
-
754
- // Only approvers/admins can moderate
755
- const allowed = await isApprover(req.uid, settings);
756
- if (!allowed) return res.status(403).json({ error: 'forbidden' });
99
+ // Calendar page
100
+ router.get('/equipment/calendar', middleware.buildHeader, async (req, res) => {
101
+ res.render('equipment-calendar/calendar', {});
102
+ });
103
+ router.get('/api/equipment/calendar', async (req, res) => {
104
+ res.json({});
105
+ });
757
106
 
758
- if (action === 'approve') {
759
- await setReservationStatus(rid, 'approved');
760
- } else if (action === 'reject') {
761
- await setReservationStatus(rid, 'rejected');
762
- } else if (action === 'delete') {
763
- await deleteReservation(rid);
764
- } else {
765
- return res.status(400).json({ error: 'bad_action' });
766
- }
767
- return res.json({ ok: true });
768
- } catch (e) {
769
- winston.error('[equipment-calendar] api action error', e);
770
- return res.status(500).json({ error: 'server' });
771
- }
107
+ // API: events
108
+ router.get('/api/plugins/equipment-calendar/events', async (req, res) => {
109
+ const list = await listReservations(1000);
110
+ res.json({ events: list.map(toEvent) });
772
111
  });
773
112
 
774
- router.get('/equipment/calendar', middleware.buildHeader, renderCalendarPage);
775
- router.get('/equipment/approvals', middleware.buildHeader, renderApprovalsPage);
113
+ // API: create reservation
114
+ router.post('/api/plugins/equipment-calendar/reservations', middleware.applyCSRF, async (req, res) => {
115
+ const uid = req.uid;
116
+ if (!uid) return res.status(403).json({ message: 'forbidden' });
776
117
 
777
- router.post('/equipment/reservations/create', middleware.applyCSRF, handleCreateReservation);
778
- router.post('/equipment/reservations/:id/approve', middleware.applyCSRF, handleApproveReservation);
779
- router.post('/equipment/reservations/:id/reject', middleware.applyCSRF, handleRejectReservation);
780
- };
118
+ const settings = await getSettings();
119
+ const canCreate = await isUserInAnyGroups(uid, settings.creatorGroups) || await groups.isMember(uid, 'administrators');
120
+ if (!canCreate) return res.status(403).json({ message: 'forbidden' });
121
+
122
+ const startIso = String(req.body.startIso || '').slice(0,10);
123
+ const endIso = String(req.body.endIso || '').slice(0,10);
124
+ const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
125
+ if (!startIso || !endIso) return res.status(400).json({ message: 'dates required' });
126
+ if (!itemIds.length) return res.status(400).json({ message: 'items required' });
127
+
128
+ const r = {
129
+ rid: rid(),
130
+ uid,
131
+ startIso,
132
+ endIso,
133
+ itemIds: itemIds.map(String),
134
+ status: 'pending',
135
+ createdAt: Date.now(),
136
+ };
137
+ await saveReservation(r);
138
+ res.json({ ok: true, rid: r.rid });
139
+ });
781
140
 
782
- plugin.addPageRoutes = async function (data) {
783
- // Ensure routes are treated as "page" routes by NodeBB router filter
784
- // Not strictly necessary when using router.get with buildHeader, but kept for compatibility.
785
- return data;
141
+ winston.info('[equipment-calendar] loaded (official4x skeleton)');
786
142
  };
787
143
 
788
- plugin.addAdminNavigation = async function (header) {
144
+ plugin.addAdminMenu = async function (header) {
789
145
  header.plugins.push({
790
146
  route: '/plugins/equipment-calendar',
791
147
  icon: 'fa-calendar',
@@ -794,805 +150,11 @@ plugin.addAdminNavigation = async function (header) {
794
150
  return header;
795
151
  };
796
152
 
797
- // --- Admin page routes (ACP) ---
798
- plugin.addAdminRoutes = async function (params) {
799
- const { router, middleware: mid } = params;
800
- router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
801
- router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
802
- router.get('/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, mid.admin.buildHeader, handleHelloAssoTest);
803
- router.get('/api/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, handleHelloAssoTest);
804
- router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
805
- router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
153
+ // Optional: allow /calendar -> /equipment/calendar without hard redirect
154
+ plugin.addRouteAliases = async function (hookData) {
155
+ // hookData: { router, middleware, controllers } - but for filter:router.page we can inject page routes elsewhere;
156
+ // Keep empty to avoid breaking installs. Alias handled by theme route config if needed.
157
+ return hookData;
806
158
  };
807
159
 
808
- async function renderAdminReservationsPage(req, res) {
809
- if (!(await ensureIsAdmin(req, res))) return;
810
-
811
- const settings = await getSettings();
812
- const items = await getActiveItems(settings);
813
- const itemById = {};
814
- items.forEach(it => { itemById[it.id] = it; });
815
-
816
- const status = String(req.query.status || ''); // optional filter
817
- const itemId = String(req.query.itemId || ''); // optional filter
818
- const q = String(req.query.q || '').trim(); // search rid/user/notes
819
-
820
- const page = Math.max(1, parseInt(req.query.page, 10) || 1);
821
- const perPage = Math.min(100, Math.max(10, parseInt(req.query.perPage, 10) || 50));
822
-
823
- const allRids = await db.getSortedSetRevRange('equipmentCalendar:reservations', 0, -1);
824
- const totalAll = allRids.length;
825
-
826
- const rows = [];
827
- for (const rid of allRids) {
828
- // eslint-disable-next-line no-await-in-loop
829
- const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
830
- if (!r || !r.rid) continue;
831
-
832
- if (status && String(r.status) !== status) continue;
833
- if (itemId && String(r.itemId) !== itemId) continue;
834
-
835
- const notes = String(r.notesUser || '');
836
- const ridStr = String(r.rid || rid);
837
- if (q) {
838
- const hay = (ridStr + ' ' + String(r.uid || '') + ' ' + notes).toLowerCase();
839
- if (!hay.includes(q.toLowerCase())) continue;
840
- }
841
-
842
- const startMs = parseInt(r.startMs, 10) || 0;
843
- const endMs = parseInt(r.endMs, 10) || 0;
844
- const createdAt = parseInt(r.createdAt, 10) || 0;
845
-
846
- rows.push({
847
- rid: ridStr,
848
- itemId: String(r.itemId || ''),
849
- itemName: (itemById[String(r.itemId || '')] && itemById[String(r.itemId || '')].name) || String(r.itemId || ''),
850
- uid: String(r.uid || ''),
851
- status: String(r.status || ''),
852
- start: r.startIso,
853
- end: addDaysIso(r.endIso, 1),
854
- createdAt: formatDateTimeFR(r.createdAt || 0),
855
- notesUser: notes,
856
- });
857
- }
858
-
859
- const total = rows.length;
860
- const totalPages = Math.max(1, Math.ceil(total / perPage));
861
- const safePage = Math.min(page, totalPages);
862
- const startIndex = (safePage - 1) * perPage;
863
- const pageRows = rows.slice(startIndex, startIndex + perPage);
864
-
865
- const itemOptions = [{ id: '', name: 'Tous' }].concat(items.map(i => ({ id: i.id, name: i.name })));
866
- const statusOptions = [
867
- { id: '', name: 'Tous' },
868
- { id: 'pending', name: 'pending' },
869
- { id: 'approved', name: 'approved' },
870
- { id: 'paid', name: 'paid' },
871
- { id: 'rejected', name: 'rejected' },
872
- { id: 'cancelled', name: 'cancelled' },
873
- ];
874
-
875
- res.render('admin/plugins/equipment-calendar-reservations', {
876
- title: 'Equipment Calendar - Réservations',
877
- settings,
878
- rows: pageRows,
879
- hasRows: pageRows.length > 0,
880
- itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
881
- statusOptions: statusOptions.map(o => ({ ...o, selected: o.id === status })),
882
- q,
883
- page: safePage,
884
- perPage,
885
- total,
886
- totalAll,
887
- totalPages,
888
- prevPage: safePage > 1 ? safePage - 1 : 0,
889
- nextPage: safePage < totalPages ? safePage + 1 : 0,
890
- actionBase: '/admin/plugins/equipment-calendar/reservations',
891
- });
892
- }
893
-
894
- async function handleAdminApprove(req, res) {
895
- if (!(await ensureIsAdmin(req, res))) return;
896
- const rid = String(req.params.rid || '').trim();
897
- if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
898
-
899
- const key = `equipmentCalendar:reservation:${rid}`;
900
- const r = await db.getObject(key);
901
- if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
902
-
903
- r.status = 'approved';
904
- await db.setObject(key, r);
905
-
906
- return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
907
- }
908
-
909
- async function handleAdminReject(req, res) {
910
- if (!(await ensureIsAdmin(req, res))) return;
911
- const rid = String(req.params.rid || '').trim();
912
- if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
913
-
914
- const key = `equipmentCalendar:reservation:${rid}`;
915
- const r = await db.getObject(key);
916
- if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
917
-
918
- r.status = 'rejected';
919
- await db.setObject(key, r);
920
-
921
- return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
922
- }
923
-
924
- async function handleAdminDelete(req, res) {
925
- if (!(await ensureIsAdmin(req, res))) return;
926
- const rid = String(req.params.rid || '').trim();
927
- if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
928
- await deleteReservation(rid);
929
- return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
930
- }
931
-
932
- async function handleHelloAssoTest(req, res) {
933
- const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
934
- if (!isAdmin) return helpers.notAllowed(req, res);
935
-
936
- const settings = await getSettings();
937
- let sampleItems = [];
938
- let hasSampleItems = false;
939
- let ok = false;
940
- let message = '';
941
- let count = 0;
942
-
943
- try {
944
- const force = String(req.query.force || '') === '1';
945
- const clear = String(req.query.clear || '') === '1';
946
- // force=1 skips in-memory cache and refresh_token; clear=1 wipes stored refresh token
947
- haTokenCache = null;
948
- await getHelloAssoAccessToken(settings, { force, clearStored: clear });
949
- const items = await fetchHelloAssoItems(settings);
950
- const list = Array.isArray(items) ? items : (Array.isArray(items.data) ? items.data : []);
951
- count = list.length;
952
- sampleItems = list.slice(0, 10).map(it => ({
953
- id: String(it.id || it.itemId || it.reference || it.slug || it.name || '').trim(),
954
- name: String(it.name || it.label || it.title || '').trim(),
955
- rawName: String(it.name || it.label || it.title || it.id || '').trim(),
956
- }));
957
- hasSampleItems = sampleItems && sampleItems.length > 0;
958
- ok = true;
959
- message = `OK: token valide. Catalogue récupéré via /public : ${count} item(s).`;
960
- } catch (e) {
961
- ok = false;
962
- message = (e && e.message) ? e.message : String(e);
963
- }
964
-
965
- res.render('admin/plugins/equipment-calendar-helloasso-test', {
966
- title: 'Equipment Calendar - Test HelloAsso',
967
- ok,
968
- message,
969
- count,
970
- settings,
971
- sampleItems,
972
- hasSampleItems,
973
- hasSampleItems: sampleItems && sampleItems.length > 0,
974
- });
975
- }
976
-
977
- async function renderAdminPage(req, res) {
978
- const settings = await getSettings();
979
- res.render('admin/plugins/equipment-calendar', {
980
- title: 'Equipment Calendar',
981
- settings,
982
- saved: req.query && String(req.query.saved || '') === '1',
983
- purged: req.query && parseInt(req.query.purged, 10) || 0,
984
- view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
985
- view_timeGridWeek: (settings.defaultView || '') === 'timeGridWeek',
986
- view_timeGridDay: (settings.defaultView || '') === 'timeGridDay',
987
- view_showRequesterToAll: String(settings.showRequesterToAll || '0') === '1',
988
- });
989
- }
990
-
991
- // --- Calendar page ---
992
-
993
- async function handleHelloAssoCallback(req, res) {
994
- const settings = await getSettings();
995
-
996
- const bookingId = String(req.query.bookingId || '').trim();
997
- const checkoutIntentId = String(req.query.checkoutIntentId || '').trim();
998
- const code = String(req.query.code || '').trim();
999
- const type = String(req.query.type || '').trim();
1000
-
1001
- let status = 'pending';
1002
- let message = 'Retour paiement reçu. Vérification en cours…';
1003
-
1004
- try {
1005
- if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
1006
- const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
1007
-
1008
- const paid = isCheckoutPaid(checkout);
1009
- if (paid) {
1010
- status = 'paid';
1011
- message = 'Paiement confirmé. Merci !';
1012
-
1013
- if (bookingId) {
1014
- const rids = await getBookingRids(bookingId);
1015
- for (const rid of rids) {
1016
- // eslint-disable-next-line no-await-in-loop
1017
- const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
1018
- if (!r || !r.rid) continue;
1019
- r.status = 'paid';
1020
- r.paidAt = Date.now();
1021
- r.checkoutIntentId = checkoutIntentId;
1022
- // eslint-disable-next-line no-await-in-loop
1023
- await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
1024
- }
1025
- try {
1026
- const b = (await getBooking(bookingId)) || { bookingId };
1027
- b.status = 'paid';
1028
- b.checkoutIntentId = checkoutIntentId;
1029
- b.paidAt = Date.now();
1030
- await saveBooking(b);
1031
- } catch (e) {}
1032
- }
1033
- } else {
1034
- status = (type === 'error' || code) ? 'error' : 'pending';
1035
- message = 'Paiement non confirmé. Si vous venez de payer, réessayez dans quelques instants.';
1036
- }
1037
- } catch (e) {
1038
- status = 'error';
1039
- message = e.message || 'Erreur de vérification paiement';
1040
- }
1041
-
1042
- res.render('equipment-calendar/payment-return', {
1043
- title: 'Retour paiement',
1044
- status,
1045
- statusError: status === 'error',
1046
- statusPaid: status === 'paid',
1047
- message,
1048
- });
1049
- }
1050
-
1051
- async function renderCalendarPage(req, res) {
1052
- const settings = await getSettings();
1053
- const items = await getActiveItems(settings);
1054
-
1055
- const tz = settings.timezone || 'Europe/Paris';
1056
-
1057
- // Determine range to render
1058
- const now = DateTime.now().setZone(tz);
1059
- const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
1060
- const startQ = req.query.start;
1061
- const endQ = req.query.end;
1062
-
1063
- let start, end;
1064
- if (startQ && endQ) {
1065
- const r = clampRange(String(startQ), String(endQ), tz);
1066
- start = r.start;
1067
- end = r.end;
1068
- } else {
1069
- // Default to current month range
1070
- start = now.startOf('month');
1071
- end = now.endOf('month').plus({ days: 1 });
1072
- }
1073
-
1074
- // Load reservations for ALL items within range (so we can build availability client-side without extra requests)
1075
- const allReservations = [];
1076
- for (const it of items) {
1077
- // eslint-disable-next-line no-await-in-loop
1078
- const resForItem = await listReservationsForRange(it.id, start.toMillis(), end.toMillis());
1079
- for (const r of resForItem) allReservations.push(r);
1080
- }
1081
-
1082
- const showRequesterToAll = String(settings.showRequesterToAll) === '1';
1083
- const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
1084
-
1085
- const requesterUids = Array.from(new Set(allReservations.map(r => r.uid))).filter(Boolean);
1086
- const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
1087
- const nameByUid = {};
1088
- (users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
1089
-
1090
- const itemById = {};
1091
- items.forEach(it => { itemById[it.id] = it; });
1092
-
1093
- const events = allReservations
1094
- .filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
1095
- .map(r => {
1096
- const item = itemById[r.itemId];
1097
- // Include item name in title so "all items" view is readable
1098
- const requesterName = nameByUid[r.uid] || '';
1099
- return toEvent(r, item, requesterName, isApprover || showRequesterToAll);
1100
- });
1101
-
1102
- const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
1103
-
1104
- // We expose minimal reservation data for availability checks in the modal
1105
- const blocks = allReservations
1106
- .filter(r => statusBlocksItem(r.status))
1107
- .map(r => ({
1108
- itemId: r.itemId,
1109
- startMs: r.startMs,
1110
- endMs: r.endMs,
1111
- status: r.status,
1112
- }));
1113
-
1114
- res.render('equipment-calendar/calendar', {
1115
- title: 'Réservation de matériel',
1116
- items,
1117
- view,
1118
- tz,
1119
- startISO: start.toISO(),
1120
- endISO: end.toISO(),
1121
- initialDateISO: start.toISODate(),
1122
- canCreate: canUserCreate,
1123
- canCreateJs: canUserCreate ? 'true' : 'false',
1124
- isApprover,
1125
- // events are base64 encoded to avoid template escaping issues
1126
- eventsB64: Buffer.from(JSON.stringify(events), 'utf8').toString('base64'),
1127
- blocksB64: Buffer.from(JSON.stringify(blocks), 'utf8').toString('base64'),
1128
- itemsB64: Buffer.from(JSON.stringify(items.map(i => ({ id: i.id, name: i.name, location: i.location }))), 'utf8').toString('base64'),
1129
- });
1130
- }
1131
-
1132
- // --- Approvals page ---
1133
-
1134
- async function notifyApprovers(reservations, settings) {
1135
- const groupName = (settings.notifyGroup || settings.approverGroup || 'administrators').trim();
1136
- if (!groupName) return;
1137
-
1138
- const rid = reservations[0] && (reservations[0].rid || reservations[0].id) || '';
1139
- const path = '/equipment/approvals';
1140
-
1141
- // In-app notification (NodeBB notifications)
1142
- try {
1143
- const Notifications = require.main.require('./src/notifications');
1144
- const notif = await Notifications.create({
1145
- bodyShort: 'Nouvelle demande de réservation',
1146
- bodyLong: 'Une nouvelle demande de réservation est en attente de validation.',
1147
- nid: 'equipment-calendar:' + rid,
1148
- path,
1149
- });
1150
- if (Notifications.pushGroup) {
1151
- await Notifications.pushGroup(notif, groupName);
1152
- }
1153
- } catch (e) {
1154
- // ignore
1155
- }
1156
-
1157
- // Direct email to members of notifyGroup (uses NodeBB Emailer + your SMTP/emailer plugin)
1158
- try {
1159
- const Emailer = require.main.require('./src/emailer');
1160
- const uids = await groups.getMembers(groupName, 0, -1);
1161
- if (!uids || !uids.length) return;
1162
-
1163
- // Build a short summary
1164
- const items = await getActiveItems(await getSettings());
1165
- const nameById = {};
1166
- items.forEach(i => { nameById[i.id] = i.name; });
1167
-
1168
- const start = new Date(reservations[0].startMs).toISOString();
1169
- const end = new Date(reservations[0].endMs).toISOString();
1170
- const itemList = reservations.map(r => nameById[r.itemId] || r.itemId).join(', ');
1171
-
1172
- // Use a core template ('notification') that exists in NodeBB installs
1173
- // Params vary a bit by version; we provide common fields.
1174
- const params = {
1175
- subject: '[Réservation] Nouvelle demande en attente',
1176
- intro: 'Une nouvelle demande de réservation a été créée.',
1177
- body: `Matériel: ${itemList}\nDébut: ${start}\nFin: ${end}\nVoir: ${path}`,
1178
- notification_url: path,
1179
- url: path,
1180
- // Some templates use "site_title" etc but NodeBB will inject globals
1181
- };
1182
-
1183
- for (const uid of uids) {
1184
- // eslint-disable-next-line no-await-in-loop
1185
- const email = await user.getUserField(uid, 'email');
1186
- if (!email) continue;
1187
-
1188
- // eslint-disable-next-line no-await-in-loop
1189
- const lang = (await user.getUserField(uid, 'language')) || 'fr';
1190
- if (Emailer.sendToEmail) {
1191
- // eslint-disable-next-line no-await-in-loop
1192
- await Emailer.sendToEmail('notification', email, lang, params);
1193
- } else if (Emailer.send) {
1194
- // Some NodeBB versions use Emailer.send(template, uid/email, params)
1195
- // eslint-disable-next-line no-await-in-loop
1196
- await Emailer.send('notification', uid, params);
1197
- }
1198
- }
1199
- } catch (e) {
1200
- // ignore email errors to not block reservation flow
1201
- }
1202
- }
1203
-
1204
- async function renderApprovalsPage(req, res) {
1205
- const settings = await getSettings();
1206
- const ok = req.uid ? await canApprove(req.uid, settings) : false;
1207
- if (!ok) {
1208
- return helpers.notAllowed(req, res);
1209
- }
1210
-
1211
- const items = parseItems(settings.itemsJson);
1212
- // naive scan: for a real install, store a global sorted set too; for now list per item and merge
1213
- // We'll pull recent 200 per item and then filter
1214
- const pending = [];
1215
- for (const it of items) {
1216
- const ids = await db.getSortedSetRevRange(itemIndexKey(it.id), 0, 199);
1217
- if (!ids || !ids.length) continue;
1218
- const objs = await db.getObjects(ids.map(resKey));
1219
- const resArr = (objs || []).filter(Boolean).map(normalizeReservation).filter(r => r.status === 'pending' || r.status === 'approved_waiting_payment');
1220
- for (const r of resArr) pending.push(r);
1221
- }
1222
-
1223
- // Sort by createdAt desc
1224
- pending.sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
1225
-
1226
- const uids = Array.from(new Set(pending.map(r => r.uid))).filter(Boolean);
1227
- const users = uids.length ? await user.getUsersData(uids) : [];
1228
- const nameByUid = {};
1229
- (users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
1230
-
1231
- const rows = pending.map(r => {
1232
- const item = items.find(i => i.id === r.itemId);
1233
- return {
1234
- id: r.id,
1235
- itemName: item ? item.name : r.itemId,
1236
- requester: nameByUid[r.uid] || `uid:${r.uid}`,
1237
- start: r.startIso,
1238
- end: addDaysIso(r.endIso, 1),
1239
- status: r.status,
1240
- paymentUrl: r.ha_paymentUrl || '',
1241
- };
1242
- });
1243
-
1244
- res.render('equipment-calendar/approvals', {
1245
- title: 'Validation des réservations',
1246
- rows,
1247
- hasRows: Array.isArray(rows) && rows.length > 0,
1248
- });
1249
- }
1250
-
1251
- // --- Actions ---
1252
-
1253
- async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
1254
- const items = await getActiveItems(settings);
1255
- const item = items.find(i => i.id === itemId);
1256
- if (!item) {
1257
- throw new Error('Unknown item: ' + itemId);
1258
- }
1259
- // Reject if overlaps any blocking reservation
1260
- const overlapsBlocking = await hasOverlap(itemId, startMs, endMs);
1261
- if (overlapsBlocking) {
1262
- const err = new Error('Item not available: ' + itemId);
1263
- err.code = 'ITEM_UNAVAILABLE';
1264
- throw err;
1265
- }
1266
- const rid = generateId();
1267
- const data = {
1268
- id: rid,
1269
- rid,
1270
- itemId,
1271
- uid: req.uid,
1272
- startMs,
1273
- endMs,
1274
- status: 'pending',
1275
- notesUser: notesUser || '',
1276
- bookingId: bookingId || '',
1277
- createdAt: formatDateTimeFR(r.createdAt || 0),
1278
- };
1279
- await saveReservation(data);
1280
- if (bookingId) {
1281
- await addReservationToBooking(bookingId, rid);
1282
- }
1283
- return data;
1284
- }
1285
-
1286
- async function handleCreateReservation(req, res) {
1287
- try {
1288
- const settings = await getSettings();
1289
- if (!req.uid || !(await canCreate(req.uid, settings))) {
1290
- return helpers.notAllowed(req, res);
1291
- }
1292
-
1293
- const itemIdsRaw = (req.body.itemIds !== undefined ? req.body.itemIds : req.body.itemId);
1294
- const itemIds = normalizeItemIds(itemIdsRaw);
1295
- if (!itemIds.length) return res.status(400).send('itemIds required');
1296
-
1297
- // Dates: accept YYYY-MM-DD or ISO-with-time; take first 10 chars
1298
- let _startIso = String(req.body.start || req.body.startDate || req.body.startIso || req.body.startStr || '').trim().slice(0, 10);
1299
- let _endIso = String(req.body.end || req.body.endDate || req.body.endIso || req.body.endStr || '').trim().slice(0, 10);
1300
-
1301
- // Fallback: timestamps
1302
- if (!/^\d{4}-\d{2}-\d{2}$/.test(_startIso)) {
1303
- const ms = Number(req.body.startMs || req.body.startTime || 0);
1304
- if (ms) _startIso = new Date(ms).toISOString().slice(0, 10);
1305
- }
1306
- if (!/^\d{4}-\d{2}-\d{2}$/.test(_endIso)) {
1307
- const ms = Number(req.body.endMs || req.body.endTime || 0);
1308
- if (ms) _endIso = new Date(ms).toISOString().slice(0, 10);
1309
- }
1310
-
1311
- if (!/^\d{4}-\d{2}-\d{2}$/.test(_startIso) || !/^\d{4}-\d{2}-\d{2}$/.test(_endIso)) {
1312
- return res.status(400).send('dates required');
1313
- }
1314
-
1315
- const startMs = Date.parse(_startIso + 'T00:00:00Z');
1316
- const endMs = Date.parse(addDaysIso(_endIso, 1) + 'T00:00:00Z'); // exclusive end
1317
- if (!startMs || !endMs || endMs <= startMs) {
1318
- return res.status(400).send('dates required');
1319
- }
1320
-
1321
- const days = Math.max(1, Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)));
1322
-
1323
- const items = await getActiveItems(settings);
1324
- const byId = {};
1325
- items.forEach((it) => { byId[String(it.id)] = it; });
1326
-
1327
- const validIds = itemIds.map(String).filter((id) => byId[id]);
1328
- if (!validIds.length) return res.status(400).send('unknown item');
1329
-
1330
- const notesUser = String(req.body.notesUser || '').trim();
1331
-
1332
- for (const itemId of validIds) {
1333
- const it = byId[itemId];
1334
- const unitPrice = Number(it.price || 0) || 0;
1335
- const total = unitPrice * days;
1336
-
1337
- const rid = generateId();
1338
- const r = {
1339
- rid,
1340
- uid: req.uid,
1341
- itemId,
1342
- startMs,
1343
- endMs,
1344
- startIso: _startIso,
1345
- endIso: _endIso,
1346
- days,
1347
- unitPrice,
1348
- total,
1349
- notesUser,
1350
- status: 'pending',
1351
- createdAt: Date.now(),
1352
- };
1353
- // eslint-disable-next-line no-await-in-loop
1354
- await saveReservation(r);
1355
- }
1356
-
1357
- return res.redirect(nconf.get('relative_path') + '/calendar?created=1');
1358
- } catch (err) {
1359
- winston.error('[equipment-calendar] create error: ' + (err && err.message ? err.message : ''), err);
1360
- return res.status(500).send('error');
1361
- }
1362
- }
1363
-
1364
- async function handleApproveReservation(req, res) {
1365
- try {
1366
- const settings = await getSettings();
1367
- if (!req.uid || !(await canApprove(req.uid, settings))) {
1368
- return helpers.notAllowed(req, res);
1369
- }
1370
-
1371
- const id = String(req.params.id || '').trim();
1372
- const reservation = await getReservation(id);
1373
- if (!reservation) return res.status(404).send('Not found');
1374
- if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
1375
- return res.status(409).send('Invalid status');
1376
- }
1377
-
1378
- const items = parseItems(settings.itemsJson);
1379
- const item = items.find(i => i.id === reservation.itemId);
1380
- if (!item) return res.status(400).send('Invalid item');
1381
-
1382
- // Create HelloAsso checkout
1383
- if (!reservation.ha_paymentUrl) {
1384
- if (!settings.ha_clientId || !settings.ha_clientSecret) {
1385
- return res.status(400).send('HelloAsso not configured');
1386
- }
1387
- const token = await helloAssoGetAccessToken(settings);
1388
- const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
1389
- reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
1390
- reservation.ha_paymentUrl = checkout.paymentUrl;
1391
- }
1392
-
1393
- reservation.status = 'approved_waiting_payment';
1394
- reservation.validatorUid = req.uid;
1395
- reservation.updatedAtMs = Date.now();
1396
- await saveReservation(reservation);
1397
-
1398
- // Notify requester with payment link (best-effort)
1399
- try {
1400
- const u = await user.getUserData(reservation.uid);
1401
- const subject = 'Réservation approuvée - Paiement requis';
1402
- const body = `Ta réservation de "${item.name}" a été approuvée.\n\nLien de paiement:\n${reservation.ha_paymentUrl}\n`;
1403
- // NodeBB Emailer signature may vary; this is best-effort
1404
- await Emailer.send('equipmentCalendar-payment', {
1405
- subject,
1406
- body,
1407
- to: u.email,
1408
- });
1409
- } catch (e) { /* ignore */ }
1410
-
1411
- return res.redirect('/equipment/approvals');
1412
- } catch (e) {
1413
- return res.status(500).send(e.message || 'error');
1414
- }
1415
- }
1416
-
1417
- async function handleRejectReservation(req, res) {
1418
- try {
1419
- const settings = await getSettings();
1420
- if (!req.uid || !(await canApprove(req.uid, settings))) {
1421
- return helpers.notAllowed(req, res);
1422
- }
1423
-
1424
- const id = String(req.params.id || '').trim();
1425
- const reservation = await getReservation(id);
1426
- if (!reservation) return res.status(404).send('Not found');
1427
- if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
1428
- return res.status(409).send('Invalid status');
1429
- }
1430
-
1431
- reservation.status = 'rejected';
1432
- reservation.validatorUid = req.uid;
1433
- reservation.notesAdmin = String(req.body.notesAdmin || '').slice(0, 2000);
1434
- reservation.updatedAtMs = Date.now();
1435
- await saveReservation(reservation);
1436
-
1437
- return res.redirect('/equipment/approvals');
1438
- } catch (e) {
1439
- return res.status(500).send(e.message || 'error');
1440
- }
1441
- }
1442
-
1443
- async function ensureIsAdmin(req, res) {
1444
- const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
1445
- if (!isAdmin) {
1446
- helpers.notAllowed(req, res);
1447
- return false;
1448
- }
1449
- return true;
1450
- }
1451
-
1452
- async function deleteReservation(rid) {
1453
- const key = `equipmentCalendar:reservation:${rid}`;
1454
- const data = await db.getObject(key);
1455
- if (data && data.itemId) {
1456
- await db.sortedSetRemove(`equipmentCalendar:item:${data.itemId}:reservations`, rid);
1457
- }
1458
- await db.delete(key);
1459
- await db.sortedSetRemove('equipmentCalendar:reservations', rid);
1460
- }
1461
-
1462
- async function handleAdminPurge(req, res) {
1463
- try {
1464
- const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
1465
- if (!isAdmin) {
1466
- return helpers.notAllowed(req, res);
1467
- }
1468
-
1469
- const mode = String(req.body.mode || 'nonblocking'); // nonblocking|olderThan|all
1470
- const olderThanDays = parseInt(req.body.olderThanDays, 10);
1471
-
1472
- // Load all reservation ids
1473
- const rids = await db.getSortedSetRange('equipmentCalendar:reservations', 0, -1);
1474
- let deleted = 0;
1475
-
1476
- const now = Date.now();
1477
- for (const rid of rids) {
1478
- // eslint-disable-next-line no-await-in-loop
1479
- const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
1480
- if (!r || !r.rid) {
1481
- // eslint-disable-next-line no-await-in-loop
1482
- await db.sortedSetRemove('equipmentCalendar:reservations', rid);
1483
- continue;
1484
- }
1485
-
1486
- const status = String(r.status || '');
1487
- const createdAt = parseInt(r.createdAt, 10) || 0;
1488
- const startMs = parseInt(r.startMs, 10) || 0;
1489
- const endMs = parseInt(r.endMs, 10) || 0;
1490
-
1491
- let shouldDelete = false;
1492
-
1493
- if (mode === 'all') {
1494
- shouldDelete = true;
1495
- } else if (mode === 'olderThan') {
1496
- const days = Number.isFinite(olderThanDays) ? olderThanDays : 0;
1497
- if (days > 0) {
1498
- const cutoff = now - days * 24 * 60 * 60 * 1000;
1499
- // use createdAt if present, otherwise startMs
1500
- const t = createdAt || startMs || endMs;
1501
- shouldDelete = t > 0 && t < cutoff;
1502
- }
1503
- } else {
1504
- // nonblocking cleanup: delete rejected/cancelled only
1505
- shouldDelete = (status === 'rejected' || status === 'cancelled');
1506
- }
1507
-
1508
- if (shouldDelete) {
1509
- // eslint-disable-next-line no-await-in-loop
1510
- await deleteReservation(rid);
1511
- deleted++;
1512
- }
1513
- }
1514
-
1515
- return res.redirect(`/admin/plugins/equipment-calendar?saved=1&purged=${deleted}`);
1516
- } catch (e) {
1517
- return res.status(500).send(e.message || 'error');
1518
- }
1519
- }
1520
-
1521
- let paymentTimeoutInterval = null;
1522
-
1523
- function startPaymentTimeoutScheduler() {
1524
- if (paymentTimeoutInterval) return;
1525
- // Run every minute
1526
- paymentTimeoutInterval = setInterval(async () => {
1527
- try {
1528
- const settings = await getSettings();
1529
- const timeoutMin = Math.max(1, parseInt(settings.paymentTimeoutMinutes, 10) || 10);
1530
- const cutoff = Date.now() - timeoutMin * 60 * 1000;
1531
-
1532
- // Scan bookings keys (we keep an index set)
1533
- const bookingIds = await db.getSetMembers('equipmentCalendar:bookings') || [];
1534
- for (const bid of bookingIds) {
1535
- // eslint-disable-next-line no-await-in-loop
1536
- const b = await getBooking(bid);
1537
- if (!b || !b.bookingId) continue;
1538
- const status = String(b.status || '');
1539
- if (status !== 'payment_pending') continue;
1540
-
1541
- const pendingAt = parseInt(b.paymentPendingAt, 10) || parseInt(b.createdAt, 10) || 0;
1542
- if (!pendingAt || pendingAt > cutoff) continue;
1543
-
1544
- // Timeout reached -> cancel booking + all reservations
1545
- // eslint-disable-next-line no-await-in-loop
1546
- const rids = await getBookingRids(bid);
1547
- for (const rid of rids) {
1548
- // eslint-disable-next-line no-await-in-loop
1549
- const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
1550
- if (!r || !r.rid) continue;
1551
- if (String(r.status) === 'paid') continue; // safety
1552
- r.status = 'cancelled';
1553
- r.cancelledAt = Date.now();
1554
- r.paymentUrl = '';
1555
- // eslint-disable-next-line no-await-in-loop
1556
- await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
1557
- }
1558
-
1559
- b.status = 'cancelled';
1560
- b.cancelledAt = Date.now();
1561
- b.paymentUrl = '';
1562
- await saveBooking(b);
1563
- }
1564
- } catch (e) {
1565
- // ignore to keep scheduler alive
1566
- }
1567
- }, 60 * 1000);
1568
- }
1569
-
1570
160
  module.exports = plugin;
1571
-
1572
- async function handleGetReservation(req, res) {
1573
- if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
1574
- const rid = String(req.params.id || '');
1575
- const r = await db.getObject(resKey(rid));
1576
- if (!r || !r.id) return res.status(404).json({ error: 'not-found' });
1577
-
1578
- const settings = await getSettings();
1579
- const isAdmin = await user.isAdminOrGlobalMod(req.uid);
1580
- const isOwner = String(r.uid) === String(req.uid);
1581
- const canSee = isAdmin || isOwner || String(settings.showRequesterToAll || '0') === '1';
1582
- if (!canSee) return res.status(403).json({ error: 'forbidden' });
1583
-
1584
- res.json({
1585
- id: r.id,
1586
- bookingId: r.bookingId || '',
1587
- itemId: r.itemId,
1588
- itemName: r.itemName || '',
1589
- startMs: parseInt(r.startMs, 10) || 0,
1590
- endMs: parseInt(r.endMs, 10) || 0,
1591
- days: parseInt(r.days, 10) || 1,
1592
- status: r.status || 'pending',
1593
- total: Number(r.total || 0) || 0,
1594
- unitPrice: Number(r.unitPrice || 0) || 0,
1595
- notesUser: r.notesUser || '',
1596
- uid: r.uid,
1597
- });
1598
- }