nodebb-plugin-calendar-onekite 11.1.43 → 11.1.45

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
@@ -84,35 +84,52 @@ admin.approveReservation = async function (req, res) {
84
84
  // Create HelloAsso payment link if configured
85
85
  const settings = await meta.settings.get('calendar-onekite');
86
86
  const env = settings.helloassoEnv || 'prod';
87
- const token = await helloasso.getAccessToken({
88
- env,
89
- clientId: settings.helloassoClientId,
90
- clientSecret: settings.helloassoClientSecret,
91
- });
92
-
93
87
  let paymentUrl = null;
94
- if (token) {
95
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
96
- // r.total is stored as an estimated total in euros; HelloAsso expects cents.
97
- const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
98
- paymentUrl = await helloasso.createCheckoutIntent({
99
- env,
100
- token,
101
- organizationSlug: settings.helloassoOrganizationSlug,
102
- formType: settings.helloassoFormType,
103
- formSlug: settings.helloassoFormSlug,
104
- totalAmount,
105
- payerEmail: requester && requester.email,
106
- callbackUrl: 'https://api.onekite.com/helloasso',
107
- itemName: 'Réservation matériel OneKite',
108
- containsDonation: false,
109
- metadata: { reservationId: String(rid) },
110
- });
88
+ try {
89
+ const hasHelloAssoConfig = !!(
90
+ settings &&
91
+ settings.helloassoClientId &&
92
+ settings.helloassoClientSecret &&
93
+ settings.helloassoOrganizationSlug
94
+ );
95
+ if (hasHelloAssoConfig) {
96
+ const token = await helloasso.getAccessToken({
97
+ env,
98
+ clientId: settings.helloassoClientId,
99
+ clientSecret: settings.helloassoClientSecret,
100
+ });
101
+
102
+ if (token) {
103
+ const requester = await user.getUserFields(r.uid, ['username', 'email']);
104
+ // r.total is stored as an estimated total in euros; HelloAsso expects cents.
105
+ const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
106
+ // Default callback: forum base url if available, otherwise empty.
107
+ const callbackUrl =
108
+ (settings.helloassoCallbackUrl || (meta.config && meta.config.url ? `${meta.config.url}/helloasso` : ''));
109
+ paymentUrl = await helloasso.createCheckoutIntent({
110
+ env,
111
+ token,
112
+ organizationSlug: settings.helloassoOrganizationSlug,
113
+ formType: settings.helloassoFormType,
114
+ formSlug: settings.helloassoFormSlug,
115
+ totalAmount,
116
+ payerEmail: requester && requester.email,
117
+ callbackUrl,
118
+ itemName: 'Réservation matériel OneKite',
119
+ containsDonation: false,
120
+ metadata: { reservationId: String(rid) },
121
+ });
122
+ }
123
+ }
124
+ } catch (e) {
125
+ // Do not block approval if HelloAsso is not configured or temporarily unavailable.
126
+ console.warn('[calendar-onekite] HelloAsso error during approval (ACP)', { rid, err: String(e && e.message || e) });
111
127
  }
112
128
 
113
129
  if (paymentUrl) {
114
130
  r.paymentUrl = paymentUrl;
115
131
  } else {
132
+ // This is expected when HelloAsso is not configured.
116
133
  console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
117
134
  }
118
135
 
package/lib/api.js CHANGED
@@ -41,23 +41,6 @@ async function sendEmail(template, toEmail, subject, data) {
41
41
  }
42
42
  }
43
43
 
44
- async function getGroupEmails(groupNamesCsv) {
45
- const names = String(groupNamesCsv || '').split(',').map(s => s.trim()).filter(Boolean);
46
- if (!names.length) return [];
47
- const emails = new Set();
48
- for (const g of names) {
49
- try {
50
- const members = await groups.getMembers(g, 0, -1);
51
- for (const memberUid of (members || [])) {
52
- const md = await user.getUserFields(memberUid, ['email']);
53
- if (md && md.email) emails.add(md.email);
54
- }
55
- } catch (e) {}
56
- }
57
- return Array.from(emails);
58
- }
59
-
60
-
61
44
  function overlap(aStart, aEnd, bStart, bEnd) {
62
45
  return aStart < bEnd && bStart < aEnd;
63
46
  }
@@ -88,6 +71,15 @@ async function canRequest(uid, settings) {
88
71
  }
89
72
 
90
73
  async function canValidate(uid, settings) {
74
+ // Always allow forum administrators (and global moderators) to validate,
75
+ // even if validatorGroups is empty.
76
+ try {
77
+ const isAdmin = await groups.isMember(uid, 'administrators');
78
+ if (isAdmin) return true;
79
+ const isGlobalMod = await groups.isMember(uid, 'Global Moderators');
80
+ if (isGlobalMod) return true;
81
+ } catch (e) {}
82
+
91
83
  const allowed = (settings.validatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
92
84
  if (!allowed.length) return false;
93
85
  for (const g of allowed) {
@@ -161,8 +153,6 @@ api.getEvents = async function (req, res) {
161
153
  ev.extendedProps.canModerate = canMod;
162
154
  ev.extendedProps.total = r.total || 0;
163
155
  ev.extendedProps.createdAt = r.createdAt || null;
164
- ev.extendedProps.canCancel = !!req.uid && String(req.uid) === String(r.uid) && r.status !== 'paid';
165
-
166
156
  out.push(ev);
167
157
  }
168
158
  }
@@ -224,7 +214,7 @@ api.createReservation = async function (req, res) {
224
214
  }
225
215
 
226
216
  // Prevent double booking: block if any selected item overlaps with an active reservation
227
- const blocking = new Set(['pending', 'awaiting_payment', 'approved', 'paid']);
217
+ const blocking = new Set(['pending', 'awaiting_payment', 'paid']);
228
218
  const wideStart2 = Math.max(0, start - 366 * 24 * 3600 * 1000);
229
219
  const candidateIds = await dbLayer.listReservationIdsByStartRange(wideStart2, end, 5000);
230
220
  const conflicts = [];
@@ -299,49 +289,6 @@ api.createReservation = async function (req, res) {
299
289
  res.json({ ok: true, rid });
300
290
  };
301
291
 
302
- api.cancelReservationByUser = async function (req, res) {
303
- const uid = req.uid;
304
- if (!uid) return res.status(401).json({ error: 'not-logged-in' });
305
-
306
- const rid = req.params.rid;
307
- if (!rid) return res.status(400).json({ error: 'missing-rid' });
308
-
309
- const r = await dbLayer.getReservation(rid);
310
- if (!r) return res.status(404).json({ error: 'not-found' });
311
-
312
- if (String(r.uid) !== String(uid)) return res.status(403).json({ error: 'forbidden' });
313
- if (r.status === 'paid') return res.status(400).json({ error: 'already-paid' });
314
-
315
- const settings = await meta.settings.get('calendar-onekite');
316
- await dbLayer.removeReservation(rid);
317
-
318
- try {
319
- const requester = await user.getUserFields(uid, ['username', 'email']);
320
- const itemsLabel = (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).join(', ');
321
- const validatorEmails = await getGroupEmails(settings.validatorGroups);
322
-
323
- const data = {
324
- username: requester?.username || 'Utilisateur',
325
- startDate: formatFR(r.start),
326
- endDate: formatFR(r.end),
327
- itemsLabel,
328
- };
329
-
330
- const subject = 'Annulation de réservation de matériel';
331
-
332
- if (requester?.email) {
333
- await sendEmail('calendar-onekite_cancelled', requester.email, subject, data);
334
- }
335
- for (const email of validatorEmails) {
336
- await sendEmail('calendar-onekite_cancelled', email, subject, data);
337
- }
338
- } catch (e) {
339
- console.warn('[calendar-onekite] Failed to send cancellation email', e?.message || e);
340
- }
341
-
342
- return res.json({ ok: true });
343
- };
344
-
345
292
  // Validator actions (from calendar popup)
346
293
  api.approveReservation = async function (req, res) {
347
294
  const uid = req.uid;
@@ -373,7 +320,9 @@ api.approveReservation = async function (req, res) {
373
320
  // r.total is stored as an estimated total in euros; HelloAsso expects cents.
374
321
  totalAmount: Math.max(0, Math.round((Number(r.total) || 0) * 100)),
375
322
  payerEmail: payer && payer.email ? payer.email : '',
376
- callbackUrl: 'https://api.onekite.com/helloasso',
323
+ // By default, point to the forum base url so the webhook hits this NodeBB instance.
324
+ // Can be overridden via ACP setting `helloassoCallbackUrl`.
325
+ callbackUrl: (settings2.helloassoCallbackUrl || (meta.config && meta.config.url ? `${meta.config.url}/helloasso` : '')),
377
326
  itemName: 'Réservation matériel OneKite',
378
327
  containsDonation: false,
379
328
  metadata: { reservationId: String(rid) },
@@ -422,7 +371,7 @@ api.refuseReservation = async function (req, res) {
422
371
 
423
372
  const requester = await user.getUserFields(r.uid, ['username', 'email']);
424
373
  if (requester && requester.email) {
425
- await sendEmail('calendar-onekite_refused', requester.email, 'Demande de réservation matériel - Supprimée', {
374
+ await sendEmail('calendar-onekite_refused', requester.email, 'Demande de réservation de matériel', {
426
375
  username: requester.username,
427
376
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
428
377
  start: formatFR(r.start),
package/lib/helloasso.js CHANGED
@@ -188,6 +188,7 @@ async function createCheckoutIntent({ env, token, organizationSlug, formType, fo
188
188
  backUrl: callbackUrl || '',
189
189
  errorUrl: callbackUrl || '',
190
190
  returnUrl: callbackUrl || '',
191
+ notificationUrl: callbackUrl || '',
191
192
  };
192
193
  const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
193
194
  if (status >= 200 && status < 300 && json) {
@@ -4,12 +4,64 @@ const crypto = require('crypto');
4
4
 
5
5
  const db = require.main.require('./src/database');
6
6
  const meta = require.main.require('./src/meta');
7
+ const user = require.main.require('./src/user');
8
+
9
+ const dbLayer = require('./db');
7
10
 
8
11
  const SETTINGS_KEY = 'calendar-onekite';
9
12
 
10
13
  // Replay protection: store processed payment ids.
11
14
  const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
12
15
 
16
+ async function sendEmail(template, toEmail, subject, data) {
17
+ if (!toEmail) return;
18
+ const emailer = require.main.require('./src/emailer');
19
+ try {
20
+ if (typeof emailer.sendToEmail === 'function') {
21
+ await emailer.sendToEmail(template, toEmail, subject, data);
22
+ return;
23
+ }
24
+ if (typeof emailer.send === 'function') {
25
+ if (emailer.send.length >= 4) {
26
+ await emailer.send(template, toEmail, subject, data);
27
+ return;
28
+ }
29
+ if (emailer.send.length === 3) {
30
+ await emailer.send(template, toEmail, data);
31
+ return;
32
+ }
33
+ await emailer.send(template, toEmail, subject, data);
34
+ }
35
+ } catch (err) {
36
+ // eslint-disable-next-line no-console
37
+ console.warn('[calendar-onekite] Failed to send email (webhook)', { template, toEmail, err: String(err && err.message || err) });
38
+ }
39
+ }
40
+
41
+ function formatFR(tsOrIso) {
42
+ const d = new Date(tsOrIso);
43
+ const dd = String(d.getDate()).padStart(2, '0');
44
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
45
+ const yyyy = d.getFullYear();
46
+ return `${dd}/${mm}/${yyyy}`;
47
+ }
48
+
49
+ function getReservationIdFromPayload(payload) {
50
+ try {
51
+ const metaObj = payload && payload.data ? payload.data.meta : null;
52
+ if (!metaObj) return null;
53
+ if (typeof metaObj === 'object' && metaObj.reservationId) return String(metaObj.reservationId);
54
+ // Some systems send meta as array of key/value pairs
55
+ if (Array.isArray(metaObj)) {
56
+ const found = metaObj.find((x) => x && (x.key === 'reservationId' || x.name === 'reservationId'));
57
+ if (found && (found.value || found.val)) return String(found.value || found.val);
58
+ }
59
+ } catch (e) {
60
+ // ignore
61
+ }
62
+ return null;
63
+ }
64
+
13
65
  function timingSafeHexEqual(aHex, bHex) {
14
66
  if (!aHex || !bHex) return false;
15
67
  try {
@@ -121,6 +173,51 @@ function isConfirmedPayment(payload) {
121
173
  }
122
174
  }
123
175
 
176
+ async function sendEmail(template, toEmail, subject, data) {
177
+ if (!toEmail) return;
178
+ const emailer = require.main.require('./src/emailer');
179
+ try {
180
+ if (typeof emailer.sendToEmail === 'function') {
181
+ await emailer.sendToEmail(template, toEmail, subject, data);
182
+ return;
183
+ }
184
+ if (typeof emailer.send === 'function') {
185
+ if (emailer.send.length >= 4) {
186
+ await emailer.send(template, toEmail, subject, data);
187
+ return;
188
+ }
189
+ if (emailer.send.length === 3) {
190
+ await emailer.send(template, toEmail, data);
191
+ return;
192
+ }
193
+ await emailer.send(template, toEmail, subject, data);
194
+ }
195
+ } catch (err) {
196
+ // eslint-disable-next-line no-console
197
+ console.warn('[calendar-onekite] Failed to send email (webhook)', { template, toEmail, err: String(err && err.message || err) });
198
+ }
199
+ }
200
+
201
+ function formatFR(tsOrIso) {
202
+ const d = new Date(tsOrIso);
203
+ const dd = String(d.getDate()).padStart(2, '0');
204
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
205
+ const yyyy = d.getFullYear();
206
+ return `${dd}/${mm}/${yyyy}`;
207
+ }
208
+
209
+ function getReservationIdFromPayload(payload) {
210
+ try {
211
+ const m = payload && payload.data ? payload.data.meta : null;
212
+ if (!m) return null;
213
+ if (typeof m === 'object' && m.reservationId) return String(m.reservationId);
214
+ if (typeof m === 'object' && m.reservationID) return String(m.reservationID);
215
+ return null;
216
+ } catch (e) {
217
+ return null;
218
+ }
219
+ }
220
+
124
221
  /**
125
222
  * Hardened HelloAsso webhook handler.
126
223
  * - Requires x-ha-signature (HMAC SHA-256) verification.
@@ -154,10 +251,40 @@ async function handler(req, res, next) {
154
251
  return res.json({ ok: true, duplicate: true });
155
252
  }
156
253
 
157
- // We don't process reservation state updates here in this zip yet.
158
- // We only harden the webhook and prevent duplicates.
159
- await markProcessed(paymentId);
254
+ const rid = getReservationIdFromPayload(payload);
255
+ if (!rid) {
256
+ await markProcessed(paymentId);
257
+ return res.json({ ok: true, processed: true, missingReservationId: true });
258
+ }
259
+
260
+ const r = await dbLayer.getReservation(rid);
261
+ if (!r) {
262
+ await markProcessed(paymentId);
263
+ return res.json({ ok: true, processed: true, reservationNotFound: true });
264
+ }
160
265
 
266
+ // Mark as paid and persist payment metadata.
267
+ r.status = 'paid';
268
+ r.paidAt = Date.now();
269
+ r.paymentId = paymentId ? String(paymentId) : '';
270
+ if (payload.data && payload.data.paymentReceiptUrl) {
271
+ r.paymentReceiptUrl = String(payload.data.paymentReceiptUrl);
272
+ }
273
+ await dbLayer.saveReservation(r);
274
+
275
+ // Notify requester
276
+ const requester = await user.getUserFields(r.uid, ['username', 'email']);
277
+ if (requester && requester.email) {
278
+ await sendEmail('calendar-onekite_paid', requester.email, 'Demande de réservation de matériel', {
279
+ username: requester.username,
280
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
281
+ start: formatFR(r.start),
282
+ end: formatFR(r.end),
283
+ paymentReceiptUrl: r.paymentReceiptUrl || '',
284
+ });
285
+ }
286
+
287
+ await markProcessed(paymentId);
161
288
  return res.json({ ok: true, processed: true });
162
289
  } catch (err) {
163
290
  return next(err);
package/lib/scheduler.js CHANGED
@@ -5,9 +5,10 @@ const dbLayer = require('./db');
5
5
 
6
6
  let timer = null;
7
7
 
8
+ // Pending holds: short lock after a user creates a request (defaults to 5 minutes)
8
9
  async function expirePending() {
9
10
  const settings = await meta.settings.get('calendar-onekite');
10
- const holdMins = parseInt(settings.pendingHoldMinutes || settings.holdMinutes || '5', 10) || 5;
11
+ const holdMins = parseInt(settings.pendingHoldMinutes || '5', 10) || 5;
11
12
  const now = Date.now();
12
13
 
13
14
  const ids = await dbLayer.listAllReservationIds(5000);
@@ -29,10 +30,105 @@ async function expirePending() {
29
30
  }
30
31
  }
31
32
 
33
+ // Payment window logic:
34
+ // - When a reservation is validated it becomes awaiting_payment
35
+ // - We send a reminder after `paymentHoldMinutes` (default 60)
36
+ // - We expire (and remove) after `2 * paymentHoldMinutes`
37
+ async function processAwaitingPayment() {
38
+ const settings = await meta.settings.get('calendar-onekite');
39
+ const holdMins = parseInt(settings.paymentHoldMinutes || settings.holdMinutes || '60', 10) || 60;
40
+ const now = Date.now();
41
+
42
+ const ids = await dbLayer.listAllReservationIds(5000);
43
+ if (!ids || !ids.length) return;
44
+
45
+ const emailer = require.main.require('./src/emailer');
46
+ const user = require.main.require('./src/user');
47
+
48
+ async function sendEmail(template, toEmail, subject, data) {
49
+ if (!toEmail) return;
50
+ try {
51
+ if (typeof emailer.sendToEmail === 'function') {
52
+ await emailer.sendToEmail(template, toEmail, subject, data);
53
+ return;
54
+ }
55
+ if (typeof emailer.send === 'function') {
56
+ if (emailer.send.length >= 4) {
57
+ await emailer.send(template, toEmail, subject, data);
58
+ return;
59
+ }
60
+ if (emailer.send.length === 3) {
61
+ await emailer.send(template, toEmail, data);
62
+ return;
63
+ }
64
+ await emailer.send(template, toEmail, subject, data);
65
+ }
66
+ } catch (err) {
67
+ // eslint-disable-next-line no-console
68
+ console.warn('[calendar-onekite] Failed to send email (scheduler)', { template, toEmail, err: String(err && err.message || err) });
69
+ }
70
+ }
71
+
72
+ function formatFR(ts) {
73
+ const d = new Date(ts);
74
+ const dd = String(d.getDate()).padStart(2, '0');
75
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
76
+ const yyyy = d.getFullYear();
77
+ return `${dd}/${mm}/${yyyy}`;
78
+ }
79
+
80
+ for (const rid of ids) {
81
+ const r = await dbLayer.getReservation(rid);
82
+ if (!r || r.status !== 'awaiting_payment') continue;
83
+
84
+ const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
85
+ if (!approvedAt) continue;
86
+
87
+ const reminderAt = approvedAt + holdMins * 60 * 1000;
88
+ const expireAt = approvedAt + 2 * holdMins * 60 * 1000;
89
+
90
+ if (!r.reminderSent && now >= reminderAt && now < expireAt) {
91
+ // Send reminder once
92
+ const u = await user.getUserFields(r.uid, ['username', 'email']);
93
+ if (u && u.email) {
94
+ await sendEmail('calendar-onekite_reminder', u.email, 'Demande de réservation de matériel', {
95
+ username: u.username,
96
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
97
+ start: formatFR(r.start),
98
+ end: formatFR(r.end),
99
+ paymentUrl: r.paymentUrl || '',
100
+ delayMinutes: holdMins,
101
+ pickupLine: r.pickupTime ? (r.adminNote ? `${r.pickupTime} à ${r.adminNote}` : r.pickupTime) : '',
102
+ });
103
+ }
104
+ r.reminderSent = true;
105
+ r.reminderAt = now;
106
+ await dbLayer.saveReservation(r);
107
+ continue;
108
+ }
109
+
110
+ if (now >= expireAt) {
111
+ // Expire: remove reservation so it disappears from calendar and frees items
112
+ const u = await user.getUserFields(r.uid, ['username', 'email']);
113
+ if (u && u.email) {
114
+ await sendEmail('calendar-onekite_expired', u.email, 'Demande de réservation de matériel', {
115
+ username: u.username,
116
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
117
+ start: formatFR(r.start),
118
+ end: formatFR(r.end),
119
+ delayMinutes: holdMins,
120
+ });
121
+ }
122
+ await dbLayer.removeReservation(rid);
123
+ }
124
+ }
125
+ }
126
+
32
127
  function start() {
33
128
  if (timer) return;
34
129
  timer = setInterval(() => {
35
130
  expirePending().catch(() => {});
131
+ processAwaitingPayment().catch(() => {});
36
132
  }, 60 * 1000);
37
133
  }
38
134
 
@@ -47,4 +143,5 @@ module.exports = {
47
143
  start,
48
144
  stop,
49
145
  expirePending,
146
+ processAwaitingPayment,
50
147
  };
package/library.js CHANGED
@@ -12,13 +12,13 @@ const api = require('./lib/api');
12
12
  const admin = require('./lib/admin');
13
13
  const scheduler = require('./lib/scheduler');
14
14
  const helloassoWebhook = require('./lib/helloassoWebhook');
15
+ const bodyParser = require('body-parser');
15
16
 
16
17
  const Plugin = {};
17
18
 
18
19
  const isFn = (fn) => typeof fn === 'function';
19
20
  const mw = (...fns) => fns.filter(isFn);
20
21
 
21
-
22
22
  Plugin.init = async function (params) {
23
23
  const { router, middleware } = params;
24
24
 
@@ -68,11 +68,6 @@ Plugin.init = async function (params) {
68
68
  router.post(p, ...publicAuth, api.createReservation);
69
69
  });
70
70
 
71
- ['/api/v3/plugins/calendar-onekite/reservations/:rid', '/api/plugins/calendar-onekite/reservations/:rid'].forEach((p) => {
72
- router.delete(p, ...publicAuth, api.cancelReservationByUser);
73
- });
74
-
75
-
76
71
  // Validator actions from the calendar popup (requires login + validatorGroups)
77
72
  ['/api/v3/plugins/calendar-onekite/reservations/:rid/approve', '/api/plugins/calendar-onekite/reservations/:rid/approve'].forEach((p) => {
78
73
  router.put(p, ...publicAuth, api.approveReservation);
@@ -100,7 +95,14 @@ Plugin.init = async function (params) {
100
95
  // - Only accepts POST
101
96
  // - Verifies x-ha-signature (HMAC SHA-256) using the configured client secret
102
97
  // - Basic replay protection
103
- router.post('/helloasso', helloassoWebhook.handler);
98
+ // NOTE: we capture the raw body for signature verification.
99
+ const helloassoJson = bodyParser.json({
100
+ verify: (req, _res, buf) => {
101
+ req.rawBody = buf;
102
+ },
103
+ type: ['application/json', 'application/*+json'],
104
+ });
105
+ router.post('/helloasso', helloassoJson, helloassoWebhook.handler);
104
106
 
105
107
  // Optional: keep GET for simple health-checks without exposing processing.
106
108
  router.get('/helloasso', (req, res) => res.json({ ok: true }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "11.1.43",
3
+ "version": "11.1.45",
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/plugin.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "Calendar OneKite",
4
4
  "description": "Equipment reservation calendar (FullCalendar) with admin approval & HelloAsso checkout",
5
5
  "url": "https://www.onekite.com/calendar",
6
- "main": "./library.js",
6
+ "library": "./library.js",
7
7
  "hooks": [
8
8
  { "hook": "static:app.load", "method": "init" },
9
9
  { "hook": "filter:admin.header.build", "method": "addAdminNavigation" }
package/public/admin.js CHANGED
@@ -106,7 +106,16 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
106
106
  return out;
107
107
  })().map(t => `<option value="${t}">${t}</option>`).join('');
108
108
 
109
+ const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
110
+ ? r.itemNames
111
+ : [r.itemName || r.itemId].filter(Boolean);
112
+ const itemsHtml = itemNames.length
113
+ ? `<ul class="mb-3">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`
114
+ : '';
115
+
109
116
  const html = `
117
+ <div class="mb-2"><strong>Matériels</strong></div>
118
+ ${itemsHtml}
110
119
  <div class="mb-3">
111
120
  <label class="form-label">Note (lieu de récupération)</label>
112
121
  <textarea class="form-control" id="onekite-admin-note" placeholder="Matériel à récupérer à l'adresse : ..."></textarea>
package/public/client.js CHANGED
@@ -273,7 +273,6 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
273
273
  const period = `${formatDt(ev.start)} → ${formatDt(ev.end)}`;
274
274
 
275
275
  const canModerate = !!p.canModerate;
276
- const canCancel = !!p.canCancel;
277
276
  const isPending = status === 'pending';
278
277
 
279
278
  const baseHtml = `
@@ -313,7 +312,16 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
313
312
  }
314
313
  return out;
315
314
  })().map(t => `<option value="${t}">${t}</option>`).join('');
315
+ const items = Array.isArray(p.itemNames) && p.itemNames.length
316
+ ? p.itemNames
317
+ : (itemsLabel ? String(itemsLabel).split(',').map(s => s.trim()).filter(Boolean) : []);
318
+ const itemsHtml = items.length
319
+ ? `<ul class="mb-3">${items.map(n => `<li>${String(n).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</li>`).join('')}</ul>`
320
+ : '';
321
+
316
322
  const html = `
323
+ <div class="mb-2"><strong>Matériels</strong></div>
324
+ ${itemsHtml}
317
325
  <div class="mb-3">
318
326
  <label class="form-label">Note (incluse dans l'email)</label>
319
327
  <textarea class="form-control" id="onekite-admin-note" rows="3" placeholder="Matériel à récupérer à l'adresse : ..."></textarea>
@@ -347,21 +355,6 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
347
355
  });
348
356
  },
349
357
  };
350
- if (canCancel) {
351
- buttons.cancelUser = {
352
- label: 'Annuler la réservation',
353
- className: 'btn-outline-danger',
354
- callback: async () => {
355
- const ok = await new Promise((resolve) => bootbox.confirm('Voulez-vous vraiment annuler cette réservation ?', resolve));
356
- if (!ok) return;
357
-
358
- await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${rid}`, { method: 'DELETE' });
359
- showAlert('success', 'Réservation annulée.');
360
- calendar.refetchEvents();
361
- },
362
- };
363
- }
364
-
365
358
  }
366
359
 
367
360
  bootbox.dialog({
@@ -0,0 +1,13 @@
1
+ <p>Bonjour {username},</p>
2
+
3
+ <p>Paiement non reçu dans le délai de {delayMinutes} minutes après relance.</p>
4
+
5
+ <p>La réservation a été annulée et le matériel est de nouveau réservable.</p>
6
+
7
+ <p>
8
+ <strong>Matériel :</strong> {itemName}<br>
9
+ <strong>Du :</strong> {start}<br>
10
+ <strong>Au :</strong> {end}
11
+ </p>
12
+
13
+ <p>Merci,<br>OneKite</p>
@@ -0,0 +1,15 @@
1
+ <p>Bonjour {username},</p>
2
+
3
+ <p>Votre paiement a bien été reçu. Votre réservation est désormais <strong>payée</strong>.</p>
4
+
5
+ <p>
6
+ <strong>Matériel :</strong> {itemName}<br>
7
+ <strong>Du :</strong> {start}<br>
8
+ <strong>Au :</strong> {end}
9
+ </p>
10
+
11
+ <!-- IF paymentReceiptUrl -->
12
+ <p>Reçu de paiement : {paymentReceiptUrl}</p>
13
+ <!-- ENDIF paymentReceiptUrl -->
14
+
15
+ <p>Merci,<br>OneKite</p>
@@ -0,0 +1,21 @@
1
+ <p>Bonjour {username},</p>
2
+
3
+ <p>Rappel : votre réservation a été validée mais le paiement n'a pas encore été reçu.</p>
4
+
5
+ <p>
6
+ <strong>Matériel :</strong> {itemName}<br>
7
+ <strong>Du :</strong> {start}<br>
8
+ <strong>Au :</strong> {end}
9
+ </p>
10
+
11
+ <!-- IF pickupLine -->
12
+ <p><strong>Heure de récupération :</strong> {pickupLine}</p>
13
+ <!-- ENDIF pickupLine -->
14
+
15
+ <!-- IF paymentUrl -->
16
+ <p>Lien de paiement : {paymentUrl}</p>
17
+ <!-- ENDIF paymentUrl -->
18
+
19
+ <p>Merci d'effectuer le paiement sous {delayMinutes} minutes, sinon la réservation sera annulée.</p>
20
+
21
+ <p>Merci,<br>OneKite</p>
@@ -1,11 +0,0 @@
1
- Bonjour {username},
2
-
3
- Votre réservation de matériel a été annulée.
4
-
5
- Matériel : {itemsLabel}
6
- Dates : du {startDate} au {endDate}
7
-
8
- Le matériel est de nouveau réservable.
9
-
10
- --
11
- OneKite