nodebb-plugin-calendar-onekite 11.1.38 → 11.1.40

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
@@ -4,6 +4,14 @@ const meta = require.main.require('./src/meta');
4
4
  const user = require.main.require('./src/user');
5
5
  const emailer = require.main.require('./src/emailer');
6
6
 
7
+ function formatFR(tsOrIso) {
8
+ const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
9
+ const dd = String(d.getDate()).padStart(2, '0');
10
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
11
+ const yyyy = d.getFullYear();
12
+ return `${dd}/${mm}/${yyyy}`;
13
+ }
14
+
7
15
  async function sendEmail(template, toEmail, subject, data) {
8
16
  if (!toEmail) return;
9
17
  try {
@@ -68,7 +76,7 @@ admin.approveReservation = async function (req, res) {
68
76
  const r = await dbLayer.getReservation(rid);
69
77
  if (!r) return res.status(404).json({ error: 'not-found' });
70
78
 
71
- r.status = 'approved';
79
+ r.status = 'awaiting_payment';
72
80
  r.adminNote = String((req.body && (req.body.adminNote || req.body.note)) || '').trim();
73
81
  r.pickupTime = String((req.body && (req.body.pickupTime || req.body.pickup)) || '').trim();
74
82
  r.approvedAt = Date.now();
@@ -85,29 +93,8 @@ admin.approveReservation = async function (req, res) {
85
93
  let paymentUrl = null;
86
94
  if (token) {
87
95
  const requester = await user.getUserFields(r.uid, ['username', 'email']);
88
- // Determine amount from HelloAsso items (pricing comes from HelloAsso)
89
- let totalAmount = 0;
90
- try {
91
- const items = await helloasso.listItems({
92
- env,
93
- token,
94
- organizationSlug: settings.helloassoOrganizationSlug,
95
- formType: settings.helloassoFormType,
96
- formSlug: settings.helloassoFormSlug,
97
- });
98
- const normalized = (items || []).map((it) => ({
99
- id: String(it.id || it.itemId || it.reference || it.name),
100
- price: it.price || it.amount || it.unitPrice || 0,
101
- })).filter(it => it.id);
102
- const match = normalized.find(it => it.id === String(r.itemId));
103
- totalAmount = match ? parseInt(match.price, 10) || 0 : 0;
104
- } catch (e) {
105
- totalAmount = 0;
106
- }
107
-
108
- if (!totalAmount) {
109
- return res.status(400).json({ error: 'item-price-not-found' });
110
- }
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));
111
98
  paymentUrl = await helloasso.createCheckoutIntent({
112
99
  env,
113
100
  token,
@@ -116,6 +103,7 @@ admin.approveReservation = async function (req, res) {
116
103
  formSlug: settings.helloassoFormSlug,
117
104
  totalAmount,
118
105
  payerEmail: requester && requester.email,
106
+ callbackUrl: 'https://api.onekite.com/helloasso',
119
107
  });
120
108
  }
121
109
 
@@ -129,11 +117,11 @@ admin.approveReservation = async function (req, res) {
129
117
  try {
130
118
  const requester = await user.getUserFields(r.uid, ['username', 'email']);
131
119
  if (requester && requester.email) {
132
- await sendEmail('calendar-onekite_approved', requester.email, 'Demande de réservation matériel - Validée', {
120
+ await sendEmail('calendar-onekite_approved', requester.email, 'Demande de réservation de matériel', {
133
121
  username: requester.username,
134
122
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
135
- start: new Date(parseInt(r.start, 10)).toISOString(),
136
- end: new Date(parseInt(r.end, 10)).toISOString(),
123
+ start: formatFR(r.start),
124
+ end: formatFR(r.end),
137
125
  paymentUrl: paymentUrl || '',
138
126
  adminNote: r.adminNote || '',
139
127
  pickupTime: r.pickupTime || '',
@@ -158,8 +146,8 @@ admin.refuseReservation = async function (req, res) {
158
146
  await sendEmail('calendar-onekite_refused', requester.email, 'Demande de réservation matériel - Refusée', {
159
147
  username: requester.username,
160
148
  itemName: r.itemName,
161
- start: new Date(parseInt(r.start, 10)).toISOString(),
162
- end: new Date(parseInt(r.end, 10)).toISOString(),
149
+ start: formatFR(r.start),
150
+ end: formatFR(r.end),
163
151
  });
164
152
  }
165
153
  } catch (e) {}
package/lib/api.js CHANGED
@@ -45,6 +45,15 @@ function overlap(aStart, aEnd, bStart, bEnd) {
45
45
  return aStart < bEnd && bStart < aEnd;
46
46
  }
47
47
 
48
+
49
+ function formatFR(tsOrIso) {
50
+ const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
51
+ const dd = String(d.getDate()).padStart(2, '0');
52
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
53
+ const yyyy = d.getFullYear();
54
+ return `${dd}/${mm}/${yyyy}`;
55
+ }
56
+
48
57
  function toTs(v) {
49
58
  if (!v) return NaN;
50
59
  const d = new Date(v);
@@ -73,7 +82,7 @@ async function canValidate(uid, settings) {
73
82
 
74
83
  function eventsFor(resv) {
75
84
  const status = resv.status;
76
- const icons = { pending: '⏳', awaiting_payment: '', approved: '✅', paid: '✅', refused: '⛔' };
85
+ const icons = { pending: '⏳', awaiting_payment: '💳', paid: '✅' };
77
86
  const startIsoDate = new Date(parseInt(resv.start, 10)).toISOString().slice(0, 10);
78
87
  const endIsoDate = new Date(parseInt(resv.end, 10)).toISOString().slice(0, 10);
79
88
 
@@ -97,8 +106,10 @@ function eventsFor(resv) {
97
106
  rid: resv.rid,
98
107
  status,
99
108
  uid: resv.uid,
100
- itemIds: [itemId].filter(Boolean),
101
- itemNames: [itemName].filter(Boolean),
109
+ itemIds: itemIds.filter(Boolean),
110
+ itemNames: itemNames.filter(Boolean),
111
+ itemIdLine: itemId,
112
+ itemNameLine: itemName,
102
113
  },
103
114
  });
104
115
  }
@@ -123,7 +134,7 @@ api.getEvents = async function (req, res) {
123
134
  const r = await dbLayer.getReservation(rid);
124
135
  if (!r) continue;
125
136
  // Only show active statuses
126
- if (!['pending', 'awaiting_payment', 'approved', 'paid'].includes(r.status)) continue;
137
+ if (!['pending', 'awaiting_payment', 'paid'].includes(r.status)) continue;
127
138
  const rStart = parseInt(r.start, 10);
128
139
  const rEnd = parseInt(r.end, 10);
129
140
  if (!(rStart < endTs && startTs < rEnd)) continue; // overlap check
@@ -218,7 +229,6 @@ api.createReservation = async function (req, res) {
218
229
  const rid = crypto.randomUUID();
219
230
 
220
231
  const resv = {
221
- rid,
222
232
  uid,
223
233
  itemIds,
224
234
  itemNames: itemNames.length ? itemNames : itemIds,
@@ -249,14 +259,13 @@ api.createReservation = async function (req, res) {
249
259
  await sendEmail(
250
260
  'calendar-onekite_pending',
251
261
  md.email,
252
- 'Demande de réservation matériel - Nouvelle demande',
262
+ 'Demande de réservation de matériel',
253
263
  {
254
264
  username: md.username,
255
265
  requester: requester.username,
256
266
  itemName: itemsLabel,
257
- start: new Date(start).toISOString(),
258
- end: new Date(end).toISOString(),
259
- rid,
267
+ start: formatFR(start),
268
+ end: formatFR(end),
260
269
  total: resv.total || 0,
261
270
  }
262
271
  );
@@ -284,20 +293,43 @@ api.approveReservation = async function (req, res) {
284
293
  if (!r) return res.status(404).json({ error: 'not-found' });
285
294
  if (r.status !== 'pending') return res.status(400).json({ error: 'bad-status' });
286
295
 
287
- r.status = 'approved';
296
+ r.status = 'awaiting_payment';
288
297
  r.adminNote = String((req.body && req.body.adminNote) || '').trim();
289
298
  r.pickupTime = String((req.body && req.body.pickupTime) || '').trim();
290
299
  r.approvedAt = Date.now();
300
+ // Create HelloAsso payment link on validation
301
+ try {
302
+ const settings2 = await meta.settings.get('calendar-onekite');
303
+ const token = await helloasso.getAccessToken(settings2);
304
+ const payer = await user.getUserFields(r.uid, ['email']);
305
+ const paymentUrl = await helloasso.createCheckoutIntent({
306
+ env: settings2.helloassoEnv,
307
+ token,
308
+ organizationSlug: settings2.helloassoOrganizationSlug,
309
+ formType: settings2.helloassoFormType,
310
+ formSlug: settings2.helloassoFormSlug,
311
+ // r.total is stored as an estimated total in euros; HelloAsso expects cents.
312
+ totalAmount: Math.max(0, Math.round((Number(r.total) || 0) * 100)),
313
+ payerEmail: payer && payer.email ? payer.email : '',
314
+ callbackUrl: 'https://api.onekite.com/helloasso',
315
+ });
316
+ if (paymentUrl) {
317
+ r.paymentUrl = paymentUrl;
318
+ }
319
+ } catch (e) {
320
+ // ignore payment link errors, admin can retry
321
+ }
322
+
291
323
  await dbLayer.saveReservation(r);
292
324
 
293
325
  // Email requester
294
326
  const requester = await user.getUserFields(r.uid, ['username', 'email']);
295
327
  if (requester && requester.email) {
296
- await sendEmail('calendar-onekite_approved', requester.email, 'Demande de réservation matériel - Validée', {
328
+ await sendEmail('calendar-onekite_approved', requester.email, 'Demande de réservation de matériel', {
297
329
  username: requester.username,
298
330
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
299
- start: new Date(parseInt(r.start, 10)).toISOString(),
300
- end: new Date(parseInt(r.end, 10)).toISOString(),
331
+ start: formatFR(r.start),
332
+ end: formatFR(r.end),
301
333
  adminNote: r.adminNote || '',
302
334
  pickupTime: r.pickupTime || '',
303
335
  paymentUrl: r.paymentUrl || '',
@@ -326,8 +358,8 @@ api.refuseReservation = async function (req, res) {
326
358
  await sendEmail('calendar-onekite_refused', requester.email, 'Demande de réservation matériel - Supprimée', {
327
359
  username: requester.username,
328
360
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
329
- start: new Date(parseInt(r.start, 10)).toISOString(),
330
- end: new Date(parseInt(r.end, 10)).toISOString(),
361
+ start: formatFR(r.start),
362
+ end: formatFR(r.end),
331
363
  });
332
364
  }
333
365
 
package/lib/helloasso.js CHANGED
@@ -174,7 +174,7 @@ async function listCatalogItems({ env, token, organizationSlug, formType, formSl
174
174
  };
175
175
  }
176
176
 
177
- async function createCheckoutIntent({ env, token, organizationSlug, formType, formSlug, totalAmount, payerEmail }) {
177
+ async function createCheckoutIntent({ env, token, organizationSlug, formType, formSlug, totalAmount, payerEmail, callbackUrl }) {
178
178
  if (!token || !organizationSlug) return null;
179
179
  // Checkout intents are created at organization level.
180
180
  const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/checkout-intents`;
@@ -182,9 +182,9 @@ async function createCheckoutIntent({ env, token, organizationSlug, formType, fo
182
182
  totalAmount: totalAmount,
183
183
  initialAmount: totalAmount,
184
184
  payer: payerEmail ? { email: payerEmail } : undefined,
185
- backUrl: '',
186
- errorUrl: '',
187
- returnUrl: '',
185
+ backUrl: callbackUrl || '',
186
+ errorUrl: callbackUrl || '',
187
+ returnUrl: callbackUrl || '',
188
188
  };
189
189
  const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
190
190
  if (status >= 200 && status < 300 && json) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "11.1.38",
3
+ "version": "11.1.40",
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/admin.js CHANGED
@@ -42,6 +42,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
42
42
  });
43
43
  }
44
44
 
45
+
45
46
  function renderPending(list) {
46
47
  const wrap = document.getElementById('onekite-pending');
47
48
  if (!wrap) return;
@@ -52,28 +53,101 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
52
53
  return;
53
54
  }
54
55
 
56
+ const fmtFR = (ts) => {
57
+ const d = new Date(parseInt(ts, 10));
58
+ const dd = String(d.getDate()).padStart(2, '0');
59
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
60
+ const yyyy = d.getFullYear();
61
+ const hh = String(d.getHours()).padStart(2, '0');
62
+ const mi = String(d.getMinutes()).padStart(2, '0');
63
+ return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
64
+ };
65
+
55
66
  for (const r of list) {
56
- const created = r.createdAt ? new Date(parseInt(r.createdAt, 10)).toLocaleString('fr-FR') : '';
57
- const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId];
58
- for (const name of itemNames) {
59
- const div = document.createElement('div');
60
- div.className = 'list-group-item';
61
- div.innerHTML = `
62
- <div class="d-flex justify-content-between align-items-center">
63
- <div><strong>${escapeHtml(name)}</strong></div>
64
- <div class="text-muted" style="font-size: 0.85rem;">${escapeHtml(created)}</div>
67
+ const created = r.createdAt ? fmtFR(r.createdAt) : '';
68
+ const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
69
+ const itemsHtml = itemNames.map(n => `<div>• ${escapeHtml(String(n))}</div>`).join('');
70
+ const div = document.createElement('div');
71
+ div.className = 'list-group-item';
72
+ div.innerHTML = `
73
+ <div class="d-flex justify-content-between align-items-start gap-2">
74
+ <div style="min-width: 0;">
75
+ <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
76
+ <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
77
+ <div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
78
+ </div>
79
+ <div class="d-flex gap-2">
80
+ <button class="btn btn-outline-danger btn-sm">Rejeter</button>
81
+ <button class="btn btn-success btn-sm">Valider</button>
82
+ </div>
83
+ </div>
84
+ `;
85
+ const [refuseBtn, approveBtn] = div.querySelectorAll('button');
86
+ refuseBtn.addEventListener('click', async () => {
87
+ if (!confirm('Rejeter cette demande ?')) return;
88
+ try {
89
+ // Server route: PUT /api/v3/admin/plugins/calendar-onekite/reservations/:rid/refuse
90
+ await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${encodeURIComponent(r.rid)}/refuse`, { method: 'PUT' });
91
+ showAlert('success', 'Demande rejetée.');
92
+ await loadPending();
93
+ } catch (e) {
94
+ showAlert('error', 'Rejet impossible.');
95
+ }
96
+ });
97
+
98
+ approveBtn.addEventListener('click', async () => {
99
+ const opts = (() => {
100
+ const out = [];
101
+ for (let h = 0; h < 24; h++) {
102
+ for (let m = 0; m < 60; m += 5) {
103
+ out.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
104
+ }
105
+ }
106
+ return out;
107
+ })().map(t => `<option value="${t}">${t}</option>`).join('');
108
+
109
+ const html = `
110
+ <div class="mb-3">
111
+ <label class="form-label">Note (lieu de récupération)</label>
112
+ <textarea class="form-control" id="onekite-admin-note" placeholder="Matériel à récupérer à l'adresse : ..."></textarea>
65
113
  </div>
66
- <div class="text-muted" style="font-size: 0.9rem;">${new Date(parseInt(r.start,10)).toLocaleDateString('fr-FR')} → ${new Date(parseInt(r.end,10)).toLocaleDateString('fr-FR')}</div>
67
- <div class="mt-2">
68
- <button class="btn btn-sm btn-success me-1" data-action="approve" data-rid="${r.rid}">Valider</button>
69
- <button class="btn btn-sm btn-outline-danger" data-action="refuse" data-rid="${r.rid}">Refuser</button>
114
+ <div class="mb-2">
115
+ <label class="form-label">Heure de récupération</label>
116
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
70
117
  </div>
71
118
  `;
72
- wrap.appendChild(div);
73
- }
119
+
120
+ bootbox.dialog({
121
+ title: 'Valider la demande',
122
+ message: html,
123
+ buttons: {
124
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
125
+ ok: {
126
+ label: 'Valider',
127
+ className: 'btn-success',
128
+ callback: async () => {
129
+ try {
130
+ const adminNote = (document.getElementById('onekite-admin-note')?.value || '').trim();
131
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
132
+ // Server route: PUT /api/v3/admin/plugins/calendar-onekite/reservations/:rid/approve
133
+ await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${encodeURIComponent(r.rid)}/approve`, { method: 'PUT', body: JSON.stringify({ adminNote, pickupTime }) });
134
+ showAlert('success', 'Demande validée.');
135
+ await loadPending();
136
+ } catch (e) {
137
+ showAlert('error', 'Validation impossible.');
138
+ }
139
+ },
140
+ },
141
+ },
142
+ });
143
+ return false;
144
+ });
145
+
146
+ wrap.appendChild(div);
74
147
  }
75
148
  }
76
149
 
150
+
77
151
  function timeOptions(stepMinutes) {
78
152
  const step = stepMinutes || 5;
79
153
  const out = [];
package/public/client.js CHANGED
@@ -7,6 +7,17 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
7
7
  // interactions or quick re-renders.
8
8
  let isDialogOpen = false;
9
9
 
10
+ function statusLabel(s) {
11
+ const map = {
12
+ pending: 'En attente',
13
+ awaiting_payment: 'Validée – paiement en attente',
14
+ paid: 'Payée',
15
+ rejected: 'Rejetée',
16
+ expired: 'Expirée',
17
+ };
18
+ return map[String(s || '')] || String(s || '');
19
+ }
20
+
10
21
  function showAlert(type, msg) {
11
22
  try {
12
23
  if (alerts && typeof alerts[type] === 'function') {
@@ -234,7 +245,14 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
234
245
  calendar.unselect();
235
246
  isDialogOpen = false;
236
247
  } catch (e) {
237
- showAlert('error', 'Impossible de créer la demande (droits/groupe ?).');
248
+ const code = String(e && e.message || '');
249
+ if (code === '403') {
250
+ showAlert('error', 'Impossible de créer la demande : droits insuffisants (groupe).');
251
+ } else if (code === '409') {
252
+ showAlert('error', 'Impossible : au moins un matériel est déjà réservé ou en attente sur cette période.');
253
+ } else {
254
+ showAlert('error', 'Erreur lors de la création de la demande.');
255
+ }
238
256
  calendar.unselect();
239
257
  isDialogOpen = false;
240
258
  }
@@ -260,7 +278,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
260
278
  const baseHtml = `
261
279
  <div class="mb-2"><strong>Matériel</strong><br>${String(itemsLabel).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
262
280
  <div class="mb-2"><strong>Période</strong><br>${period}</div>
263
- <div class="text-muted" style="font-size: 12px;">Statut: ${String(status)}</div>
281
+ <div class="text-muted" style="font-size: 12px;">Statut: ${statusLabel(status)}</div>
264
282
  `;
265
283
 
266
284
  const showModeration = canModerate && isPending;
@@ -6,11 +6,12 @@
6
6
  <li>Fin : {end}</li>
7
7
  </ul>
8
8
  <!-- IF pickupTime -->
9
- <p><strong>Heure de récupération :</strong> {pickupTime}</p>
10
- <!-- ENDIF pickupTime -->
9
+ <p><strong>Heure de récupération :</strong> {pickupTime}<!-- IF adminNote --> à {adminNote}<!-- ENDIF adminNote --></p>
10
+ <!-- ELSE -->
11
11
  <!-- IF adminNote -->
12
- <p><strong>Note :</strong><br>{adminNote}</p>
12
+ <p><strong>Heure de récupération :</strong> xx:xx à {adminNote}</p>
13
13
  <!-- ENDIF adminNote -->
14
+ <!-- ENDIF pickupTime -->
14
15
  <!-- IF paymentUrl -->
15
16
  <p>Lien de paiement : <a href="{paymentUrl}">{paymentUrl}</a></p>
16
17
  <!-- ENDIF paymentUrl -->
@@ -5,6 +5,5 @@
5
5
  <li>Matériel : {itemName}</li>
6
6
  <li>Début : {start}</li>
7
7
  <li>Fin : {end}</li>
8
- <li>ID : {rid}</li>
9
8
  </ul>
10
9
  <p>Validation/refus via l’ACP.</p>