nodebb-plugin-equipment-calendar 6.0.0 → 8.0.2

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
@@ -27,6 +27,24 @@ function generateId() {
27
27
  return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
28
28
  }
29
29
  }
30
+
31
+
32
+ function normalizeItemIds(itemIdsRaw) {
33
+ if (!itemIdsRaw) return [];
34
+ if (Array.isArray(itemIdsRaw)) {
35
+ return itemIdsRaw.map(String).flatMap((v) => String(v).split(',')).map(s => s.trim()).filter(Boolean);
36
+ }
37
+ if (typeof itemIdsRaw === 'string') {
38
+ return itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
39
+ }
40
+ // fallback: single value (number/object/etc.)
41
+ try {
42
+ return [String(itemIdsRaw).trim()].filter(Boolean);
43
+ } catch (e) {
44
+ return [];
45
+ }
46
+ }
47
+
30
48
  const axios = require('axios');
31
49
  const { DateTime } = require('luxon');
32
50
  const { v4: uuidv4 } = require('uuid');
@@ -256,21 +274,15 @@ async function fetchHelloAssoItems(settings) {
256
274
 
257
275
  function pushItem(it, tierName) {
258
276
  if (!it) return;
259
- const id = String(it.id || it.itemId || it.productId || it.code || it.slug || '').trim();
260
- const baseName = String(it.name || it.label || it.title || '').trim();
261
- const name = (tierName && baseName) ? `${tierName} — ${baseName}` : (baseName || tierName || id);
262
- let amount = it.amount ?? it.price ?? it.unitPrice ?? it.totalAmount ?? it.initialAmount ?? it.minimumAmount;
263
- if (amount && typeof amount === 'object') {
264
- amount = amount.amount ?? amount.value ?? amount.total ?? amount.price ?? amount.cents ?? amount.centAmount ?? 0;
265
- }
266
- let price = 0;
267
- if (typeof amount === 'number') price = amount;
268
- else if (typeof amount === 'string') {
269
- const s = amount.replace(',', '.').replace(/[^0-9.\-]/g, '');
270
- price = parseFloat(s) || 0;
271
- }
272
- if (!id) return;
273
- out.push({ id, name, price, location: '' });
277
+ const id = String(it.id || it.itemId || it.reference || it.slug || it.code || it.name || '').trim();
278
+ const name = String(it.name || it.label || it.title || '').trim() || (tierName ? String(tierName) : id);
279
+ if (!id || !name) return;
280
+ const amount = it.amount || it.price || it.unitPrice || it.totalAmount || it.initialAmount;
281
+ const priceCents = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
282
+ const raw = (typeof priceCents === 'number' ? priceCents : parseInt(priceCents, 10)) || 0;
283
+ // Heuristic: HelloAsso amounts are often in cents; convert when it looks like cents.
284
+ const price = (raw >= 1000 && raw % 100 === 0) ? (raw / 100) : raw;
285
+ out.push({ id, name, price, priceRaw: raw });
274
286
  }
275
287
 
276
288
  // Try a few known layouts
@@ -307,7 +319,7 @@ async function getActiveItems() {
307
319
  const items = (Array.isArray(rawItems) ? rawItems : []).map((it) => ({
308
320
  id: String(it.id || '').trim(),
309
321
  name: String(it.name || '').trim(),
310
- price: Number((it.price !== undefined ? it.price : it.priceCents) || 0) || 0,
322
+ 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),
311
323
  active: true,
312
324
  })).filter(it => it.id && it.name);
313
325
  return items;
@@ -322,10 +334,10 @@ async function getSettings() {
322
334
  // Keys:
323
335
  // item hash: equipmentCalendar:items (stored in settings as JSON)
324
336
  // reservations stored as objects in db, indexed by id, and by itemId
325
- // reservation object key: equipmentCalendar:res:<id>
337
+ // reservation object key: equipmentCalendar:reservation:<rid>
326
338
  // index by item: equipmentCalendar:item:<itemId>:res (sorted set score=startMillis, value=resId)
327
339
 
328
- function resKey(id) { return `equipmentCalendar:res:${id}`; }
340
+ function resKey(rid) { return `equipmentCalendar:reservation:${rid}`; }
329
341
  function itemIndexKey(itemId) { return `equipmentCalendar:item:${itemId}:res`; }
330
342
 
331
343
  function statusBlocksItem(status) {
@@ -354,18 +366,19 @@ async function getBookingRids(bookingId) {
354
366
  return await db.getSetMembers(`equipmentCalendar:booking:${bookingId}:rids`) || [];
355
367
  }
356
368
 
357
- async function saveReservation(res) {
358
- await db.setObject(resKey(res.id), res);
359
- await db.sortedSetAdd(itemIndexKey(res.itemId), res.startMs, res.id);
360
- // Global index for ACP listing
361
- await db.sortedSetAdd('equipmentCalendar:reservations', res.startMs, res.id);
369
+ async function saveReservation(r) {
370
+ const rid = String(r.rid || r.id || '');
371
+ if (!rid) throw new Error('missing rid');
372
+ r.rid = rid;
373
+ await db.setObject(resKey(rid), r);
374
+ await db.sortedSetAdd(itemIndexKey(r.itemId), r.startMs, rid);
375
+ await db.sortedSetAdd('equipmentCalendar:reservations', r.startMs, rid);
362
376
  }
363
377
 
364
- async function getReservation(id) {
365
- const obj = await db.getObject(resKey(id));
366
- if (!obj || !obj.id) return null;
367
- // db returns strings, normalize
368
- return normalizeReservation(obj);
378
+ async function getReservation(rid) {
379
+ const id = String(rid || '');
380
+ if (!id) return null;
381
+ return await db.getObject(resKey(id));
369
382
  }
370
383
 
371
384
  function normalizeReservation(obj) {
@@ -551,22 +564,28 @@ function verifyWebhook(req, secret) {
551
564
 
552
565
  // --- Rendering helpers ---
553
566
  function toEvent(res, item, requesterName, canSeeRequester) {
554
- const start = DateTime.fromMillis(res.startMs).toISODate();
555
- const end = DateTime.fromMillis(res.endMs).toISODate();
567
+ const start = DateTime.fromMillis(res.startMs).toISO();
568
+ const end = DateTime.fromMillis(res.endMs).toISO();
569
+
570
+ let icon = '⏳';
556
571
  let className = 'ec-status-pending';
557
- if (res.status === 'approved_waiting_payment' || res.status === 'approved') { className = 'ec-status-approved'; }
558
- if (res.status === 'paid_validated' || res.status === 'paid') { className = 'ec-status-paid'; }
559
- if (res.status === 'rejected' || res.status === 'cancelled') { className = 'ec-status-cancelled'; }
560
- const titleParts = [item ? item.name : res.itemId];
572
+ if (res.status === 'approved_waiting_payment') { icon = '💳'; className = 'ec-status-awaitpay'; }
573
+ if (res.status === 'paid_validated') { icon = ''; className = 'ec-status-valid'; }
574
+ if (res.status === 'rejected' || res.status === 'cancelled') { icon = '❌'; className = 'ec-status-cancel'; }
575
+
576
+ const titleParts = [icon, item ? item.name : res.itemId];
561
577
  if (canSeeRequester && requesterName) titleParts.push(`- ${requesterName}`);
562
578
  return {
563
579
  id: res.id,
564
580
  title: titleParts.join(' '),
565
581
  start,
566
582
  end,
567
- allDay: true,
568
- classNames: [className],
569
- extendedProps: { status: res.status, itemId: res.itemId },
583
+ allDay: false,
584
+ className,
585
+ extendedProps: {
586
+ status: res.status,
587
+ itemId: res.itemId,
588
+ },
570
589
  };
571
590
  }
572
591
 
@@ -1175,47 +1194,61 @@ async function handleCreateReservation(req, res) {
1175
1194
  return helpers.notAllowed(req, res);
1176
1195
  }
1177
1196
 
1178
- const itemIdsRaw = req.body.itemIds || req.body.itemId || '';
1179
- const itemIds = (Array.isArray(itemIdsRaw) ? itemIdsRaw : String(itemIdsRaw))
1180
- .split(',')
1181
- .map(s => String(s).trim())
1182
- .filter(Boolean);
1197
+ const itemIdsRaw = (req.body.itemIds !== undefined ? req.body.itemIds : req.body.itemId);
1198
+ const itemIds = normalizeItemIds(itemIdsRaw);
1199
+ if (!itemIds.length) return res.status(400).send('itemIds required');
1183
1200
 
1184
- if (!itemIds.length) {
1185
- return res.status(400).send('itemIds required');
1186
- }
1201
+ // Dates come from modal as YYYY-MM-DD
1202
+ const startStr = String(req.body.start || req.body.startDate || req.body.startIso || '');
1203
+ const endStr = String(req.body.end || req.body.endDate || req.body.endIso || '');
1204
+ if (!startStr || !endStr) return res.status(400).send('dates required');
1187
1205
 
1188
- const tz = settings.timezone || 'Europe/Paris';
1189
- const start = DateTime.fromISO(String(req.body.start || ''), { zone: tz });
1190
- const end = DateTime.fromISO(String(req.body.end || ''), { zone: tz });
1191
- if (!start.isValid || !end.isValid) return res.status(400).send('Invalid dates');
1206
+ const start = new Date(startStr + 'T00:00:00Z');
1207
+ const end = new Date(endStr + 'T00:00:00Z');
1208
+ const startMs = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate());
1209
+ let endMs = Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate());
1210
+ if (endMs <= startMs) endMs = startMs + 24 * 60 * 60 * 1000;
1192
1211
 
1193
- // Normalize to whole days (no hours). End is exclusive.
1194
- const startDay = start.startOf('day');
1195
- let endDay = end.startOf('day');
1196
- if (endDay <= startDay) {
1197
- endDay = startDay.plus({ days: 1 });
1198
- }
1212
+ const days = Math.max(1, Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)));
1199
1213
 
1200
- const startMs = startDay.toMillis();
1201
- const endMs = endDay.toMillis();
1214
+ const items = await getActiveItems(settings);
1215
+ const byId = {};
1216
+ items.forEach((it) => { byId[String(it.id)] = it; });
1202
1217
 
1203
- const notesUser = String(req.body.notesUser || '').trim();
1218
+ const validIds = itemIds.map(String).filter((id) => byId[id]);
1219
+ if (!validIds.length) return res.status(400).send('unknown item');
1204
1220
 
1205
- const bookingId = `${req.uid}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
1221
+ const notesUser = String(req.body.notesUser || '').trim();
1206
1222
 
1207
- // Create one reservation per item, linked by bookingId
1208
- const created = [];
1209
- for (const itemId of itemIds) {
1210
- const rid = await createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId);
1211
- created.push(rid);
1223
+ for (const itemId of validIds) {
1224
+ const it = byId[itemId];
1225
+ const unitPrice = Number(it.price || 0) || 0;
1226
+ const total = unitPrice * days;
1227
+
1228
+ const rid = generateId();
1229
+ const r = {
1230
+ rid,
1231
+ uid: req.uid,
1232
+ itemId,
1233
+ startMs,
1234
+ endMs,
1235
+ startIso: new Date(startMs).toISOString().slice(0, 10),
1236
+ endIso: new Date(endMs).toISOString().slice(0, 10),
1237
+ days,
1238
+ unitPrice,
1239
+ total,
1240
+ notesUser,
1241
+ status: 'pending',
1242
+ createdAt: Date.now(),
1243
+ };
1244
+ // eslint-disable-next-line no-await-in-loop
1245
+ await saveReservation(r);
1212
1246
  }
1213
1247
 
1214
- // Redirect to calendar with a success flag
1215
- return res.redirect('/equipment/calendar?requested=1');
1248
+ return res.redirect(nconf.get('relative_path') + '/calendar?created=1');
1216
1249
  } catch (err) {
1217
- winston.error(err);
1218
- return res.status(500).send(err.message || 'Error');
1250
+ winston.error('[equipment-calendar] create error', err);
1251
+ return res.status(500).send('error');
1219
1252
  }
1220
1253
  }
1221
1254
 
@@ -1464,3 +1497,32 @@ function startPaymentTimeoutScheduler() {
1464
1497
  }
1465
1498
 
1466
1499
  module.exports = plugin;
1500
+
1501
+
1502
+ async function handleGetReservation(req, res) {
1503
+ if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
1504
+ const rid = String(req.params.id || '');
1505
+ const r = await db.getObject(resKey(rid));
1506
+ if (!r || !r.id) return res.status(404).json({ error: 'not-found' });
1507
+
1508
+ const settings = await getSettings();
1509
+ const isAdmin = await user.isAdminOrGlobalMod(req.uid);
1510
+ const isOwner = String(r.uid) === String(req.uid);
1511
+ const canSee = isAdmin || isOwner || String(settings.showRequesterToAll || '0') === '1';
1512
+ if (!canSee) return res.status(403).json({ error: 'forbidden' });
1513
+
1514
+ res.json({
1515
+ id: r.id,
1516
+ bookingId: r.bookingId || '',
1517
+ itemId: r.itemId,
1518
+ itemName: r.itemName || '',
1519
+ startMs: parseInt(r.startMs, 10) || 0,
1520
+ endMs: parseInt(r.endMs, 10) || 0,
1521
+ days: parseInt(r.days, 10) || 1,
1522
+ status: r.status || 'pending',
1523
+ total: Number(r.total || 0) || 0,
1524
+ unitPrice: Number(r.unitPrice || 0) || 0,
1525
+ notesUser: r.notesUser || '',
1526
+ uid: r.uid,
1527
+ });
1528
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "6.0.0",
3
+ "version": "8.0.2",
4
4
  "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
5
  "main": "library.js",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -25,6 +25,6 @@
25
25
  "scripts": [
26
26
  "public/js/client.js"
27
27
  ],
28
- "version": "3.0.0-stable1",
28
+ "version": "3.0.0-stable4",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -29,108 +29,32 @@ require(['jquery', 'bootstrap'], function ($, bootstrap) {
29
29
  }
30
30
 
31
31
  function updateTotalPrice() {
32
- const sel = document.getElementById('ec-item-ids');
33
- const out = document.getElementById('ec-total-price');
34
- const daysEl = document.getElementById('ec-total-days');
35
- if (!sel || !out) return;
36
-
37
- let unitTotal = 0;
38
- Array.from(sel.selectedOptions || []).forEach((opt) => {
39
- const p = parseFloat(opt.getAttribute('data-price') || '0');
40
- if (!Number.isNaN(p)) unitTotal += p;
41
- });
42
-
43
- const days = getReservationDays();
44
- if (daysEl) daysEl.textContent = days + (days > 1 ? ' jours' : ' jour');
45
-
46
- const finalTotal = unitTotal * days;
47
- const txt = Number.isInteger(finalTotal) ? String(finalTotal) : finalTotal.toFixed(2);
48
- out.textContent = txt + ' €';
49
- }
50
-
51
- function syncHiddenIsoFields() {
52
- const s = document.getElementById('ec-start-date')?.value;
53
- const e = document.getElementById('ec-end-date')?.value;
54
- const startIsoEl = document.getElementById('ec-start-iso');
55
- const endIsoEl = document.getElementById('ec-end-iso');
56
- if (!s || !e || !startIsoEl || !endIsoEl) return;
57
-
58
- // Start inclusive at 00:00Z; End exclusive = day after end date at 00:00Z
59
- const startMs = parseDateInputToMs(s);
60
- const endMsExclusive = parseDateInputToMs(e) + 24 * 60 * 60 * 1000;
61
- startIsoEl.value = new Date(startMs).toISOString();
62
- endIsoEl.value = new Date(endMsExclusive).toISOString();
63
- }
64
-
65
- function openModalWithRange(startMs, endMsExclusive) {
66
- // Convert to date inputs: end date = endExclusive -1 day
67
- const startDate = toIsoDateUTC(startMs);
68
- const endDate = toIsoDateUTC(endMsExclusive - 24 * 60 * 60 * 1000);
69
-
70
- const startEl = document.getElementById('ec-start-date');
71
- const endEl = document.getElementById('ec-end-date');
72
- if (startEl) startEl.value = startDate;
73
- if (endEl) endEl.value = endDate;
74
-
75
- syncHiddenIsoFields();
76
- updateTotalPrice();
77
-
78
- const modalEl = document.getElementById('ec-create-modal');
79
- const BS = (bootstrap && bootstrap.Modal) ? bootstrap : (window.bootstrap && window.bootstrap.Modal ? window.bootstrap : null);
80
- if (modalEl && BS && BS.Modal) {
81
- BS.Modal.getOrCreateInstance(modalEl).show();
82
- } else {
83
- // Fallback: if bootstrap modal isn't available, scroll to form area
84
- modalEl?.scrollIntoView({ behavior: 'smooth', block: 'center' });
85
- }
86
- }
87
-
88
- function initCalendar() {
89
- const calendarEl = document.getElementById('ec-calendar');
90
- if (!calendarEl || !window.FullCalendar) return;
91
-
92
- const canCreate = !!window.EC_CAN_CREATE;
93
- const events = Array.isArray(window.EC_EVENTS) ? window.EC_EVENTS : [];
94
-
95
- const calendar = new window.FullCalendar.Calendar(calendarEl, {
96
- initialView: 'dayGridMonth',
97
- locale: 'fr',
98
- headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,dayGridWeek,listMonth,listWeek' },
99
- selectable: canCreate,
100
- selectMirror: true,
101
- events,
102
- dateClick: function (info) {
103
- if (!canCreate) return;
104
- const startMs = Date.UTC(info.date.getUTCFullYear(), info.date.getUTCMonth(), info.date.getUTCDate());
105
- const endMs = startMs + 24 * 60 * 60 * 1000;
106
- console.debug('[equipment-calendar] open modal', { startMs, endMs });
107
- openModalWithRange(startMs, endMs);
108
- },
109
- select: function (info) {
110
- if (!canCreate) return;
111
- const startMs = Date.UTC(info.start.getUTCFullYear(), info.start.getUTCMonth(), info.start.getUTCDate());
112
- const endMs = Date.UTC(info.end.getUTCFullYear(), info.end.getUTCMonth(), info.end.getUTCDate());
113
- console.debug('[equipment-calendar] open modal', { startMs, endMs });
114
- openModalWithRange(startMs, endMs);
115
- },
116
- });
117
-
118
- calendar.render();
119
-
120
- document.getElementById('ec-item-ids')?.addEventListener('change', updateTotalPrice);
121
- document.getElementById('ec-start-date')?.addEventListener('change', function () {
122
- syncHiddenIsoFields();
123
- updateTotalPrice();
124
- });
125
- document.getElementById('ec-end-date')?.addEventListener('change', function () {
126
- syncHiddenIsoFields();
127
- updateTotalPrice();
128
- });
129
-
130
- updateTotalPrice();
131
- }
132
-
133
- $(window).off('action:ajaxify.end.ec').on('action:ajaxify.end.ec', initCalendar);
134
- $(initCalendar);
135
- })();
32
+ try {
33
+ const sel = document.getElementById('ec-item-ids');
34
+ const out = document.getElementById('ec-total');
35
+ const startEl = document.getElementById('ec-start');
36
+ const endEl = document.getElementById('ec-end');
37
+ if (!sel || !out || !startEl || !endEl) return;
38
+
39
+ const start = startEl.value ? new Date(startEl.value + 'T00:00:00Z') : null;
40
+ const end = endEl.value ? new Date(endEl.value + 'T00:00:00Z') : null;
41
+ let days = 1;
42
+ if (start && end) {
43
+ const ms = end.getTime() - start.getTime();
44
+ days = Math.max(1, Math.round(ms / (24 * 60 * 60 * 1000)));
45
+ }
46
+
47
+ let sum = 0;
48
+ const opts = Array.from(sel.selectedOptions || []);
49
+ opts.forEach((opt) => {
50
+ const raw = (opt && opt.dataset) ? opt.dataset.price : '';
51
+ const num = Number(String(raw).replace(',', '.'));
52
+ if (!Number.isNaN(num)) sum += num;
53
+ });
54
+
55
+ const total = sum * days;
56
+ const txt = Number.isInteger(total) ? String(total) : total.toFixed(2);
57
+ out.textContent = txt + ' €';
58
+ } catch (e) {}
59
+ })();
136
60
  });
@@ -43,7 +43,7 @@
43
43
  <label class="form-label">Matériel</label>
44
44
  <select class="form-select" id="ec-item-ids" name="itemIds" multiple required>
45
45
  <!-- BEGIN items -->
46
- <option value="{items.id}" data-price="{items.price}">{items.name} — {items.price} €</option>
46
+ <option value="{items.id}" data-price="{items.price}">{items.name}</option>
47
47
  <!-- END items -->
48
48
  </select>
49
49
  <div class="form-text">Tu peux sélectionner plusieurs matériels.</div>
@@ -82,12 +82,3 @@
82
82
  window.EC_CAN_CREATE = {canCreateJs};
83
83
  window.EC_TZ = '{tz}';
84
84
  </script>
85
-
86
- <style>
87
- /* Event styling */
88
- .fc .fc-event-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
89
- .fc-event.ec-status-pending { background: rgba(108,117,125,.18); border-color: rgba(108,117,125,.45); }
90
- .fc-event.ec-status-approved { background: rgba(13,110,253,.18); border-color: rgba(13,110,253,.45); }
91
- .fc-event.ec-status-paid { background: rgba(25,135,84,.18); border-color: rgba(25,135,84,.45); }
92
- .fc-event.ec-status-cancelled { background: rgba(220,53,69,.10); border-color: rgba(220,53,69,.35); opacity:.65; text-decoration: line-through; }
93
- </style>