nodebb-plugin-equipment-calendar 0.5.0 → 0.7.1

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,5 +1,9 @@
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
+
3
7
  const db = require.main.require('./src/database');
4
8
  const meta = require.main.require('./src/meta');
5
9
  const groups = require.main.require('./src/groups');
@@ -29,6 +33,8 @@ const DEFAULT_SETTINGS = {
29
33
  ha_itemsFormType: '',
30
34
  ha_itemsFormSlug: '',
31
35
  ha_locationMapJson: '{}',
36
+ ha_calendarItemNamePrefix: 'Location matériel',
37
+ paymentTimeoutMinutes: 10,
32
38
  // HelloAsso
33
39
  ha_clientId: '',
34
40
  ha_clientSecret: '',
@@ -80,7 +86,7 @@ async function getHelloAssoAccessToken(settings) {
80
86
  formBody.set('client_secret', String(settings.ha_clientSecret || ''));
81
87
  }
82
88
 
83
- const resp = await fetch('https://api.helloasso.com/oauth2/token', {
89
+ const resp = await fetchFn('https://api.helloasso.com/oauth2/token', {
84
90
  method: 'POST',
85
91
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
86
92
  body: formBody.toString(),
@@ -111,6 +117,93 @@ async function getHelloAssoAccessToken(settings) {
111
117
  return accessToken;
112
118
  }
113
119
 
120
+
121
+ async function createHelloAssoCheckoutIntent(settings, bookingId, reservations) {
122
+ const org = String(settings.ha_organizationSlug || '').trim();
123
+ if (!org) throw new Error('HelloAsso organization slug missing');
124
+
125
+ const baseUrl = (meta && meta.config && meta.config.url) ? meta.config.url.replace(/\/$/, '') : '';
126
+ const backUrl = baseUrl + '/equipment/calendar';
127
+ const returnUrl = baseUrl + `/equipment/helloasso/callback?type=return&bookingId=${encodeURIComponent(bookingId)}`;
128
+ const errorUrl = baseUrl + `/equipment/helloasso/callback?type=error&bookingId=${encodeURIComponent(bookingId)}`;
129
+
130
+ const items = await getActiveItems(settings);
131
+ const byId = {};
132
+ items.forEach(i => { byId[i.id] = i; });
133
+
134
+ const names = reservations.map(r => (byId[r.itemId] && byId[r.itemId].name) || r.itemId);
135
+ const totalAmount = reservations.reduce((sum, r) => {
136
+ const price = (byId[r.itemId] && parseInt(byId[r.itemId].priceCents, 10)) || 0;
137
+ return sum + (Number.isFinite(price) ? price : 0);
138
+ }, 0);
139
+
140
+ if (!totalAmount || totalAmount <= 0) {
141
+ throw new Error('Montant total invalide (prix manquant sur les items).');
142
+ }
143
+
144
+ const body = {
145
+ totalAmount,
146
+ initialAmount: totalAmount,
147
+ itemName: `${String(settings.ha_calendarItemNamePrefix || 'Location matériel')}: ${names.join(', ')}`.slice(0, 250),
148
+ backUrl,
149
+ errorUrl,
150
+ returnUrl,
151
+ containsDonation: false,
152
+ metadata: {
153
+ bookingId,
154
+ rids: reservations.map(r => r.rid || r.id),
155
+ uid: reservations[0] && reservations[0].uid,
156
+ startMs: reservations[0] && reservations[0].startMs,
157
+ endMs: reservations[0] && reservations[0].endMs,
158
+ },
159
+ };
160
+
161
+ const token = await getHelloAssoAccessToken(settings);
162
+ const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
163
+ const resp = await fetchFn(url, {
164
+ method: 'POST',
165
+ headers: {
166
+ accept: 'application/json',
167
+ 'content-type': 'application/json',
168
+ authorization: `Bearer ${token}`,
169
+ },
170
+ body: JSON.stringify(body),
171
+ });
172
+
173
+ if (!resp.ok) {
174
+ const t = await resp.text();
175
+ throw new Error(`HelloAsso checkout-intents error: ${resp.status} ${t}`);
176
+ }
177
+ return await resp.json();
178
+ }
179
+
180
+ async function fetchHelloAssoCheckoutIntent(settings, checkoutIntentId) {
181
+ const org = String(settings.ha_organizationSlug || '').trim();
182
+ const token = await getHelloAssoAccessToken(settings);
183
+ const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents/${encodeURIComponent(checkoutIntentId)}`;
184
+ const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
185
+ if (!resp.ok) {
186
+ const t = await resp.text();
187
+ throw new Error(`HelloAsso checkout-intents GET error: ${resp.status} ${t}`);
188
+ }
189
+ return await resp.json();
190
+ }
191
+
192
+ function isCheckoutPaid(checkout) {
193
+ const payments = (checkout && (checkout.payments || (checkout.order && checkout.order.payments))) || [];
194
+ const list = Array.isArray(payments) ? payments : [];
195
+ const states = list.map(p => String(p.state || p.status || p.paymentState || '').toLowerCase());
196
+ if (states.some(s => ['authorized', 'paid', 'succeeded', 'success'].includes(s))) return true;
197
+
198
+ const op = String(checkout && (checkout.state || checkout.operationState || '')).toLowerCase();
199
+ if (['authorized', 'paid', 'succeeded'].includes(op)) return true;
200
+
201
+ if (checkout && (checkout.order || checkout.orderId)) {
202
+ if (list.length) return true;
203
+ }
204
+ return false;
205
+ }
206
+
114
207
  async function fetchHelloAssoItems(settings) {
115
208
  const org = String(settings.ha_organizationSlug || '').trim();
116
209
  const formType = String(settings.ha_itemsFormType || '').trim();
@@ -128,7 +221,7 @@ async function fetchHelloAssoItems(settings) {
128
221
 
129
222
  const token = await getHelloAssoAccessToken(settings);
130
223
  const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/items`;
131
- const resp = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
224
+ const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}` } });
132
225
  if (!resp.ok) {
133
226
  const t = await resp.text();
134
227
  throw new Error(`HelloAsso items error: ${resp.status} ${t}`);
@@ -207,11 +300,30 @@ function resKey(id) { return `equipmentCalendar:res:${id}`; }
207
300
  function itemIndexKey(itemId) { return `equipmentCalendar:item:${itemId}:res`; }
208
301
 
209
302
  function statusBlocksItem(status) {
210
- // statuses that block availability
211
- return ['pending', 'approved_waiting_payment', 'paid_validated'].includes(status);
303
+ const s = String(status || '');
304
+ if (s === 'rejected' || s === 'cancelled') return false;
305
+ return (s === 'pending' || s === 'approved' || s === 'payment_pending' || s === 'paid');
306
+ }
307
+
308
+
309
+
310
+
311
+ async function saveBooking(booking) {
312
+ const bid = booking.bookingId;
313
+ await db.setObject(`equipmentCalendar:booking:${bid}`, booking);
314
+ }
315
+
316
+ async function getBooking(bookingId) {
317
+ return await db.getObject(`equipmentCalendar:booking:${bookingId}`);
212
318
  }
213
319
 
320
+ async function addReservationToBooking(bookingId, rid) {
321
+ await db.setAdd(`equipmentCalendar:booking:${bookingId}:rids`, rid);
322
+ }
214
323
 
324
+ async function getBookingRids(bookingId) {
325
+ return await db.getSetMembers(`equipmentCalendar:booking:${bookingId}:rids`) || [];
326
+ }
215
327
 
216
328
  async function saveReservation(res) {
217
329
  await db.setObject(resKey(res.id), res);
@@ -674,6 +786,8 @@ async function renderAdminPage(req, res) {
674
786
  res.render('admin/plugins/equipment-calendar', {
675
787
  title: 'Equipment Calendar',
676
788
  settings,
789
+ view_itemsSourceManual: String(settings.itemsSource || 'manual') !== 'helloasso',
790
+ view_itemsSourceHelloasso: String(settings.itemsSource || 'manual') === 'helloasso',
677
791
  saved: req.query && String(req.query.saved || '') === '1',
678
792
  purged: req.query && parseInt(req.query.purged, 10) || 0,
679
793
  view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
@@ -684,6 +798,65 @@ async function renderAdminPage(req, res) {
684
798
  }
685
799
 
686
800
  // --- Calendar page ---
801
+
802
+ async function handleHelloAssoCallback(req, res) {
803
+ const settings = await getSettings();
804
+
805
+ const bookingId = String(req.query.bookingId || '').trim();
806
+ const checkoutIntentId = String(req.query.checkoutIntentId || '').trim();
807
+ const code = String(req.query.code || '').trim();
808
+ const type = String(req.query.type || '').trim();
809
+
810
+ let status = 'pending';
811
+ let message = 'Retour paiement reçu. Vérification en cours…';
812
+
813
+ try {
814
+ if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
815
+ const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
816
+
817
+ const paid = isCheckoutPaid(checkout);
818
+ if (paid) {
819
+ status = 'paid';
820
+ message = 'Paiement confirmé. Merci !';
821
+
822
+ if (bookingId) {
823
+ const rids = await getBookingRids(bookingId);
824
+ for (const rid of rids) {
825
+ // eslint-disable-next-line no-await-in-loop
826
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
827
+ if (!r || !r.rid) continue;
828
+ r.status = 'paid';
829
+ r.paidAt = Date.now();
830
+ r.checkoutIntentId = checkoutIntentId;
831
+ // eslint-disable-next-line no-await-in-loop
832
+ await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
833
+ }
834
+ try {
835
+ const b = (await getBooking(bookingId)) || { bookingId };
836
+ b.status = 'paid';
837
+ b.checkoutIntentId = checkoutIntentId;
838
+ b.paidAt = Date.now();
839
+ await saveBooking(b);
840
+ } catch (e) {}
841
+ }
842
+ } else {
843
+ status = (type === 'error' || code) ? 'error' : 'pending';
844
+ message = 'Paiement non confirmé. Si vous venez de payer, réessayez dans quelques instants.';
845
+ }
846
+ } catch (e) {
847
+ status = 'error';
848
+ message = e.message || 'Erreur de vérification paiement';
849
+ }
850
+
851
+ res.render('equipment-calendar/payment-return', {
852
+ title: 'Retour paiement',
853
+ status,
854
+ statusError: status === 'error',
855
+ statusPaid: status === 'paid',
856
+ message,
857
+ });
858
+ }
859
+
687
860
  async function renderCalendarPage(req, res) {
688
861
  const settings = await getSettings();
689
862
  const items = await getActiveItems(settings);
@@ -889,7 +1062,7 @@ async function renderApprovalsPage(req, res) {
889
1062
 
890
1063
  // --- Actions ---
891
1064
 
892
- async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser) {
1065
+ async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
893
1066
  const items = await getActiveItems(settings);
894
1067
  const item = items.find(i => i.id === itemId);
895
1068
  if (!item) {
@@ -912,9 +1085,13 @@ async function createReservationForItem(req, res, settings, itemId, startMs, end
912
1085
  endMs,
913
1086
  status: 'pending',
914
1087
  notesUser: notesUser || '',
1088
+ bookingId: bookingId || '',
915
1089
  createdAt: Date.now(),
916
1090
  };
917
1091
  await saveReservation(data);
1092
+ if (bookingId) {
1093
+ await addReservationToBooking(bookingId, rid);
1094
+ }
918
1095
  return data;
919
1096
  }
920
1097
 
@@ -1158,6 +1335,10 @@ async function handleAdminSave(req, res) {
1158
1335
  approverGroup: String(req.body.approverGroup || DEFAULT_SETTINGS.approverGroup),
1159
1336
  notifyGroup: String(req.body.notifyGroup || DEFAULT_SETTINGS.notifyGroup),
1160
1337
  itemsJson: String(req.body.itemsJson || DEFAULT_SETTINGS.itemsJson),
1338
+ itemsSource: String(req.body.itemsSource || DEFAULT_SETTINGS.itemsSource),
1339
+ ha_itemsFormType: String(req.body.ha_itemsFormType || DEFAULT_SETTINGS.ha_itemsFormType),
1340
+ ha_itemsFormSlug: String(req.body.ha_itemsFormSlug || DEFAULT_SETTINGS.ha_itemsFormSlug),
1341
+ ha_locationMapJson: String(req.body.ha_locationMapJson || DEFAULT_SETTINGS.ha_locationMapJson),
1161
1342
  ha_clientId: String(req.body.ha_clientId || ''),
1162
1343
  ha_clientSecret: String(req.body.ha_clientSecret || ''),
1163
1344
  ha_organizationSlug: String(req.body.ha_organizationSlug || ''),
@@ -1165,6 +1346,7 @@ async function handleAdminSave(req, res) {
1165
1346
  ha_webhookSecret: String(req.body.ha_webhookSecret || ''),
1166
1347
  defaultView: String(req.body.defaultView || DEFAULT_SETTINGS.defaultView),
1167
1348
  timezone: String(req.body.timezone || DEFAULT_SETTINGS.timezone),
1349
+ paymentTimeoutMinutes: String(req.body.paymentTimeoutMinutes || DEFAULT_SETTINGS.paymentTimeoutMinutes),
1168
1350
  showRequesterToAll: (req.body.showRequesterToAll === '1' || req.body.showRequesterToAll === 'on') ? '1' : '0',
1169
1351
  };
1170
1352
 
@@ -1175,4 +1357,54 @@ async function handleAdminSave(req, res) {
1175
1357
  }
1176
1358
  }
1177
1359
 
1360
+
1361
+ let paymentTimeoutInterval = null;
1362
+
1363
+ function startPaymentTimeoutScheduler() {
1364
+ if (paymentTimeoutInterval) return;
1365
+ // Run every minute
1366
+ paymentTimeoutInterval = setInterval(async () => {
1367
+ try {
1368
+ const settings = await getSettings();
1369
+ const timeoutMin = Math.max(1, parseInt(settings.paymentTimeoutMinutes, 10) || 10);
1370
+ const cutoff = Date.now() - timeoutMin * 60 * 1000;
1371
+
1372
+ // Scan bookings keys (we keep an index set)
1373
+ const bookingIds = await db.getSetMembers('equipmentCalendar:bookings') || [];
1374
+ for (const bid of bookingIds) {
1375
+ // eslint-disable-next-line no-await-in-loop
1376
+ const b = await getBooking(bid);
1377
+ if (!b || !b.bookingId) continue;
1378
+ const status = String(b.status || '');
1379
+ if (status !== 'payment_pending') continue;
1380
+
1381
+ const pendingAt = parseInt(b.paymentPendingAt, 10) || parseInt(b.createdAt, 10) || 0;
1382
+ if (!pendingAt || pendingAt > cutoff) continue;
1383
+
1384
+ // Timeout reached -> cancel booking + all reservations
1385
+ // eslint-disable-next-line no-await-in-loop
1386
+ const rids = await getBookingRids(bid);
1387
+ for (const rid of rids) {
1388
+ // eslint-disable-next-line no-await-in-loop
1389
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
1390
+ if (!r || !r.rid) continue;
1391
+ if (String(r.status) === 'paid') continue; // safety
1392
+ r.status = 'cancelled';
1393
+ r.cancelledAt = Date.now();
1394
+ r.paymentUrl = '';
1395
+ // eslint-disable-next-line no-await-in-loop
1396
+ await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
1397
+ }
1398
+
1399
+ b.status = 'cancelled';
1400
+ b.cancelledAt = Date.now();
1401
+ b.paymentUrl = '';
1402
+ await saveBooking(b);
1403
+ }
1404
+ } catch (e) {
1405
+ // ignore to keep scheduler alive
1406
+ }
1407
+ }, 60 * 1000);
1408
+ }
1409
+
1178
1410
  module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "0.5.0",
3
+ "version": "0.7.1",
4
4
  "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
5
  "main": "library.js",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -26,6 +26,6 @@
26
26
  "scripts": [
27
27
  "public/js/client.js"
28
28
  ],
29
- "version": "0.3.1",
29
+ "version": "0.4.2",
30
30
  "minver": "4.7.1"
31
31
  }
@@ -58,8 +58,8 @@
58
58
  <div class="mb-3">
59
59
  <label class="form-label">Source</label>
60
60
  <select class="form-select" name="itemsSource">
61
- <option value="manual">Manuel (JSON)</option>
62
- <option value="helloasso">HelloAsso (articles d’un formulaire)</option>
61
+ <option value="manual" {{{ if view_itemsSourceManual }}}selected{{{ end }}}>Manuel (JSON)</option>
62
+ <option value="helloasso" {{{ if view_itemsSourceHelloasso }}}selected{{{ end }}}>HelloAsso (articles d’un formulaire)</option>
63
63
  </select>
64
64
  <div class="form-text">Si HelloAsso est choisi, la liste du matériel est récupérée via l’API HelloAsso (items d’un formulaire).</div>
65
65
  </div>
@@ -95,6 +95,11 @@
95
95
 
96
96
  <div class="card card-body mb-3">
97
97
  <h5>Calendrier</h5>
98
+ <div class="mb-3">
99
+ <label class="form-label">Timeout paiement (minutes)</label>
100
+ <input class="form-control" type="number" min="1" name="paymentTimeoutMinutes" value="{settings.paymentTimeoutMinutes}">
101
+ <div class="form-text">Après validation, si le paiement n’est pas confirmé dans ce délai, la réservation est annulée et le matériel est débloqué.</div>
102
+ </div>
98
103
  <div class="mb-3">
99
104
  <label class="form-label">Vue par défaut</label>
100
105
  <select name="defaultView" class="form-select">
@@ -1,9 +1,15 @@
1
- <div class="equipment-payment-return">
2
- <h1>Paiement</h1>
1
+ <div class="card card-body">
2
+ <h4>Retour paiement</h4>
3
+
4
+ {{{ if statusPaid }}}
5
+ <div class="alert alert-success">✅ Paiement confirmé.</div>
6
+ {{{ end }}}
7
+
3
8
  {{{ if statusError }}}
4
- <div class="alert alert-danger">Le paiement semble avoir échoué. Référence réservation: <code>{rid}</code></div>
5
- {{{ else }}}
6
- <div class="alert alert-info">Merci. Si le paiement est confirmé, la réservation passera en "validée". Référence: <code>{rid}</code></div>
9
+ <div class="alert alert-danger">❌ Paiement non confirmé.</div>
7
10
  {{{ end }}}
11
+
12
+ <p>{message}</p>
13
+
8
14
  <a class="btn btn-primary" href="/equipment/calendar">Retour au calendrier</a>
9
15
  </div>