nodebb-plugin-onekite-calendar 2.0.87 → 2.0.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/admin.js CHANGED
@@ -180,6 +180,19 @@ admin.approveReservation = async function (req, res) {
180
180
 
181
181
  await dbLayer.saveReservation(r);
182
182
 
183
+ // Audit
184
+ try {
185
+ const year = new Date().getFullYear();
186
+ await dbLayer.addAuditEntry({
187
+ ts: Date.now(), year, action: 'reservation_approved',
188
+ targetType: 'reservation', targetId: String(rid),
189
+ reservationUid: Number(r.uid) || 0,
190
+ reservationUsername: String(r.username || ''),
191
+ actorUid: req.uid || 0,
192
+ actorUsername: r.approvedByUsername || '',
193
+ });
194
+ } catch (e) {}
195
+
183
196
  // Real-time refresh for all viewers
184
197
  realtime.emitCalendarUpdated({ kind: 'reservation', action: 'approved', rid: String(rid), status: r.status });
185
198
 
@@ -259,7 +272,6 @@ admin.markReservationPaid = async function (req, res) {
259
272
  const requesterUid = parseInt(r.uid, 10);
260
273
  const requester = await user.getUserFields(requesterUid, ['username']);
261
274
  if (requesterUid) {
262
- const { sendEmail, formatFR, buildCalendarLinks } = shared;
263
275
  await sendEmail('calendar-onekite_paid', requesterUid, 'Location matériel - Paiement reçu', {
264
276
  uid: requesterUid,
265
277
  username: requester && requester.username ? requester.username : '',
@@ -294,6 +306,19 @@ admin.refuseReservation = async function (req, res) {
294
306
  r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
295
307
  await dbLayer.saveReservation(r);
296
308
 
309
+ // Audit
310
+ try {
311
+ const year = new Date().getFullYear();
312
+ await dbLayer.addAuditEntry({
313
+ ts: Date.now(), year, action: 'reservation_refused',
314
+ targetType: 'reservation', targetId: String(rid),
315
+ reservationUid: Number(r.uid) || 0,
316
+ reservationUsername: String(r.username || ''),
317
+ actorUid: req.uid || 0,
318
+ refusedReason: r.refusedReason || '',
319
+ });
320
+ } catch (e) {}
321
+
297
322
  // Real-time refresh for all viewers
298
323
  realtime.emitCalendarUpdated({ kind: 'reservation', action: 'refused', rid: String(rid), status: r.status });
299
324
 
@@ -327,12 +352,11 @@ admin.purgeByYear = async function (req, res) {
327
352
  const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
328
353
 
329
354
  const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
355
+ const rows = ids && ids.length ? await dbLayer.getReservations(ids) : [];
330
356
  let removed = 0;
331
- for (const rid of ids) {
332
- const r = await dbLayer.getReservation(rid);
333
- if (!r) continue;
334
-
335
- await dbLayer.removeReservation(rid);
357
+ for (let i = 0; i < ids.length; i++) {
358
+ if (!rows[i]) continue;
359
+ await dbLayer.removeReservation(ids[i]);
336
360
  removed++;
337
361
  }
338
362
  try {
package/lib/api.js CHANGED
@@ -621,6 +621,110 @@ api.getReservationDetails = async function (req, res) {
621
621
  return res.json(out);
622
622
  };
623
623
 
624
+ /**
625
+ * Return a fresh HelloAsso payment URL for a reservation in awaiting_payment status.
626
+ * HelloAsso checkout intents expire after ~30 minutes, so stored URLs become stale.
627
+ * This endpoint regenerates a new intent on demand and persists the fresh URL.
628
+ *
629
+ * @route GET /api/v3/plugins/calendar-onekite/reservations/:rid/payment-url
630
+ */
631
+ api.getFreshPaymentUrl = async function (req, res) {
632
+ const uid = req.uid;
633
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
634
+
635
+ const settings = await getSettings();
636
+ const rid = String(req.params.rid || '').trim();
637
+ if (!rid) return res.status(400).json({ error: 'missing-rid' });
638
+
639
+ const r = await dbLayer.getReservation(rid);
640
+ if (!r) return res.status(404).json({ error: 'not-found' });
641
+
642
+ const isOwner = String(r.uid) === String(uid);
643
+ const isMod = await canValidate(uid, settings);
644
+ if (!isOwner && !isMod) return res.status(403).json({ error: 'not-allowed' });
645
+
646
+ if (r.status !== 'awaiting_payment') {
647
+ return res.status(409).json({ error: 'wrong-status', status: r.status });
648
+ }
649
+
650
+ // Build a fresh checkout intent
651
+ const env = settings.helloassoEnv || 'prod';
652
+ const token = await helloasso.getAccessToken({
653
+ env,
654
+ clientId: settings.helloassoClientId,
655
+ clientSecret: settings.helloassoClientSecret,
656
+ });
657
+
658
+ if (!token) {
659
+ return res.status(503).json({ error: 'helloasso-unavailable' });
660
+ }
661
+
662
+ const requester = await user.getUserFields(r.uid, ['email']);
663
+ const year = yearFromTs(Number(r.start));
664
+ const formSlug = autoFormSlugForYear(year);
665
+
666
+ // Recompute total from HelloAsso catalog to always use up-to-date prices.
667
+ let recomputedTotalCents = null;
668
+ try {
669
+ const { items: catalog } = await helloasso.listCatalogItems({
670
+ env,
671
+ token,
672
+ organizationSlug: settings.helloassoOrganizationSlug,
673
+ formType: settings.helloassoFormType,
674
+ formSlug,
675
+ });
676
+ const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
677
+ const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
678
+ const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
679
+ const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
680
+ if (sumCentsPerDay > 0) {
681
+ recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
682
+ }
683
+ } catch (e) { /* fallback to stored total */ }
684
+
685
+ const totalAmount = (typeof recomputedTotalCents === 'number')
686
+ ? recomputedTotalCents
687
+ : Math.max(0, Math.round((Number(r.total) || 0) * 100));
688
+
689
+ if (!totalAmount) {
690
+ return res.status(422).json({ error: 'zero-amount' });
691
+ }
692
+
693
+ const intent = await helloasso.createCheckoutIntent({
694
+ env,
695
+ token,
696
+ organizationSlug: settings.helloassoOrganizationSlug,
697
+ formType: settings.helloassoFormType,
698
+ formSlug,
699
+ totalAmount,
700
+ payerEmail: requester && requester.email ? requester.email : '',
701
+ callbackUrl: normalizeReturnUrl(),
702
+ webhookUrl: normalizeCallbackUrl(settings.helloassoCallbackUrl),
703
+ itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
704
+ containsDonation: false,
705
+ metadata: {
706
+ reservationId: String(rid),
707
+ items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
708
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
709
+ },
710
+ });
711
+
712
+ const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
713
+ ? (intent.paymentUrl || intent.redirectUrl)
714
+ : (typeof intent === 'string' ? intent : null);
715
+
716
+ if (!paymentUrl) {
717
+ return res.status(502).json({ error: 'intent-creation-failed' });
718
+ }
719
+
720
+ // Persist the fresh URL so subsequent requests can also renew from DB.
721
+ r.paymentUrl = paymentUrl;
722
+ if (intent && intent.checkoutIntentId) r.checkoutIntentId = intent.checkoutIntentId;
723
+ await dbLayer.saveReservation(r);
724
+
725
+ return res.json({ ok: true, paymentUrl });
726
+ };
727
+
624
728
  /**
625
729
  * Get detailed information about a special event.
626
730
  *
package/library.js CHANGED
@@ -76,6 +76,7 @@ Plugin.init = async function (params) {
76
76
 
77
77
  router.post('/api/v3/plugins/calendar-onekite/reservations', ...publicExpose, api.createReservation);
78
78
  router.get('/api/v3/plugins/calendar-onekite/reservations/:rid', ...publicExpose, api.getReservationDetails);
79
+ router.get('/api/v3/plugins/calendar-onekite/reservations/:rid/payment-url', ...publicExpose, api.getFreshPaymentUrl);
79
80
  router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/approve', ...publicExpose, api.approveReservation);
80
81
  router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/refuse', ...publicExpose, api.refuseReservation);
81
82
  router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/cancel', ...publicExpose, api.cancelReservation);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.87",
3
+ "version": "2.0.89",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -2403,9 +2403,22 @@ function toDatetimeLocalValue(date) {
2403
2403
  label: 'Payer maintenant',
2404
2404
  className: 'btn-primary',
2405
2405
  callback: () => {
2406
- try {
2407
- window.open(paymentUrl, '_blank', 'noopener');
2408
- } catch (e) {}
2406
+ (async () => {
2407
+ try {
2408
+ // Always fetch a fresh checkout intent — HelloAsso URLs expire after ~30 min.
2409
+ const resp = await fetchJson(
2410
+ `/api/v3/plugins/calendar-onekite/reservations/${encodeURIComponent(String(rid))}/payment-url`
2411
+ );
2412
+ const freshUrl = resp && resp.paymentUrl ? String(resp.paymentUrl) : '';
2413
+ if (freshUrl && /^https?:\/\//i.test(freshUrl)) {
2414
+ window.open(freshUrl, '_blank', 'noopener');
2415
+ } else {
2416
+ showAlert('error', 'Impossible de générer le lien de paiement. Veuillez réessayer.');
2417
+ }
2418
+ } catch (e) {
2419
+ showAlert('error', 'Erreur lors de la génération du lien de paiement.');
2420
+ }
2421
+ })();
2409
2422
  return false;
2410
2423
  },
2411
2424
  };