nodebb-plugin-calendar-onekite 2.0.0 → 2.1.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/README.md CHANGED
@@ -19,3 +19,10 @@ If you need offline / no-CDN, tell me and I will vendor the files under static/v
19
19
  - Pages: /calendar, /calendar/my-reservations
20
20
  - Admin: /admin/plugins/calendar-onekite, /admin/calendar/planning
21
21
  - API: available under /api/... and /api/v3/... (NodeBB v4 client helper uses /api/v3)
22
+
23
+
24
+ ## v2.1 inventory model
25
+ - Global locations/inventory in ACP (locationsJson/inventoryJson)
26
+ - Events select allowed inventory item IDs (bookingItemIds)
27
+ - Availability is global per (itemId, locationId) across all events
28
+ - Admin planning is graphical (FullCalendar) with filters
package/library.js CHANGED
@@ -21,11 +21,28 @@ function getEventKey(eid) {
21
21
 
22
22
  function mountApiBoth(router, method, path, ...handlers) {
23
23
  const m = String(method).toLowerCase();
24
- if (typeof router[m] !== 'function') throw new Error(`Unsupported router method: ${method}`);
25
24
  router[m]('/api' + path, ...handlers);
26
25
  router[m]('/api/v3' + path, ...handlers);
27
26
  }
28
27
 
28
+ async function getParsedSettings() {
29
+ const s = (await Settings.get(SETTINGS_KEY)) || {};
30
+ const parseJson = (str, fallback) => {
31
+ try { return JSON.parse(str || ''); } catch { return fallback; }
32
+ };
33
+
34
+ const locations = parseJson(s.locationsJson, [
35
+ { id: 'arnaud', name: 'Chez Arnaud' },
36
+ { id: 'siege', name: 'Siège Onekite' },
37
+ ]);
38
+
39
+ const inventory = parseJson(s.inventoryJson, [
40
+ { id: 'wing', name: 'Aile Wing', price: 5, stockByLocation: { arnaud: 1, siege: 0 } },
41
+ ]);
42
+
43
+ return { ...s, locations, inventory };
44
+ }
45
+
29
46
  async function nextReservationId() {
30
47
  const rid = await db.incrObjectField('global', 'nextCalendarRid');
31
48
  return String(rid);
@@ -50,7 +67,10 @@ async function createEvent(data, uid) {
50
67
  const now = Date.now();
51
68
  const startTs = Number(new Date(data.start).getTime()) || now;
52
69
  const endTs = Number(new Date(data.end).getTime()) || startTs;
53
- const bookingItems = Array.isArray(data.bookingItems) ? data.bookingItems : [];
70
+
71
+ const bookingItemIds = Array.isArray(data.bookingItemIds)
72
+ ? data.bookingItemIds.map(String)
73
+ : [];
54
74
 
55
75
  const eventObj = {
56
76
  eid: String(eid),
@@ -71,7 +91,7 @@ async function createEvent(data, uid) {
71
91
  visibility: String(data.visibility || 'public'),
72
92
 
73
93
  bookingEnabled: data.bookingEnabled ? 1 : 0,
74
- bookingItems: JSON.stringify(bookingItems),
94
+ bookingItems: JSON.stringify(bookingItemIds), // stores IDs only
75
95
  reservations: JSON.stringify([]),
76
96
  };
77
97
 
@@ -92,9 +112,18 @@ async function updateEvent(eid, data) {
92
112
  const startTs = data.start ? new Date(data.start).getTime() : new Date(existing.start).getTime();
93
113
  const endTs = data.end ? new Date(data.end).getTime() : new Date(existing.end).getTime();
94
114
 
95
- const bookingItems = Array.isArray(data.bookingItems)
96
- ? data.bookingItems
97
- : JSON.parse(existing.bookingItems || '[]');
115
+ const existingIds = (() => {
116
+ try {
117
+ const arr = JSON.parse(existing.bookingItems || '[]');
118
+ return Array.isArray(arr) ? arr.map(String) : [];
119
+ } catch {
120
+ return [];
121
+ }
122
+ })();
123
+
124
+ const bookingItemIds = Array.isArray(data.bookingItemIds)
125
+ ? data.bookingItemIds.map(String)
126
+ : existingIds;
98
127
 
99
128
  const updated = {
100
129
  ...existing,
@@ -106,7 +135,7 @@ async function updateEvent(eid, data) {
106
135
  location: data.location !== undefined ? String(data.location) : existing.location,
107
136
  visibility: data.visibility !== undefined ? String(data.visibility) : existing.visibility,
108
137
  bookingEnabled: data.bookingEnabled !== undefined ? (data.bookingEnabled ? 1 : 0) : (existing.bookingEnabled || 0),
109
- bookingItems: JSON.stringify(bookingItems),
138
+ bookingItems: JSON.stringify(bookingItemIds),
110
139
  updatedAt: String(Date.now()),
111
140
  };
112
141
 
@@ -125,13 +154,11 @@ async function getEventsBetween(start, end) {
125
154
  const endTs = new Date(end).getTime();
126
155
  const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, startTs, endTs);
127
156
  if (!eids || !eids.length) return [];
157
+
128
158
  const keys = eids.map(id => getEventKey(id));
129
159
  const events = await db.getObjects(keys);
130
160
 
131
- return (events || []).filter(Boolean).map(ev => ({
132
- ...ev,
133
- bookingItems: JSON.parse(ev.bookingItems || '[]'),
134
- }));
161
+ return (events || []).filter(Boolean);
135
162
  }
136
163
 
137
164
  async function getUpcomingEvents(limit = 5) {
@@ -212,47 +239,61 @@ async function setRsvp(eid, uid, status) {
212
239
  return updated;
213
240
  }
214
241
 
242
+ /* ---------------- Reservations data helpers ---------------- */
243
+
244
+ async function getAllReservations() {
245
+ const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
246
+ const out = [];
247
+ for (const eid of eids) {
248
+ const ev = await getEvent(eid);
249
+ if (!ev) continue;
250
+ const resList = (() => {
251
+ try { return JSON.parse(ev.reservations || '[]'); } catch { return []; }
252
+ })();
253
+ for (const r of resList) {
254
+ out.push({ event: ev, reservation: r });
255
+ }
256
+ }
257
+ return out;
258
+ }
259
+
215
260
  /* ---------------- Pricing ---------------- */
216
261
 
217
- function computePrice(event, reservation) {
218
- const items = JSON.parse(event.bookingItems || '[]');
219
- const item = items.find(i => i.id === reservation.itemId);
220
- if (!item) return 0;
221
- const unit = Number(item.price || 0);
262
+ function computePrice(inventoryItem, reservation) {
263
+ const unit = Number(inventoryItem?.price || 0);
222
264
  const days = Number(reservation.days || 1);
223
265
  return unit * Number(reservation.quantity || 0) * days;
224
266
  }
225
267
 
226
268
  /* ---------------- Renderers ---------------- */
227
269
 
228
- function renderCalendarPage(req, res) {
229
- res.render('calendar', { title: 'Calendrier' });
230
- }
231
-
232
- function renderMyReservationsPage(req, res) {
233
- res.render('calendar-my-reservations', { title: 'Mes réservations' });
234
- }
235
-
236
- function renderPlanningPage(req, res) {
237
- res.render('admin/calendar-planning', { title: 'Planning des réservations' });
238
- }
270
+ function renderCalendarPage(req, res) { res.render('calendar', { title: 'Calendrier' }); }
271
+ function renderMyReservationsPage(req, res) { res.render('calendar-my-reservations', { title: 'Mes réservations' }); }
272
+ function renderPlanningPage(req, res) { res.render('admin/calendar-planning', { title: 'Planning' }); }
239
273
 
240
274
  async function renderAdminPage(req, res) {
241
275
  try {
242
276
  const settings = (await Settings.get(SETTINGS_KEY)) || {};
243
- res.render('admin/plugins/calendar-onekite', {
244
- title: 'Calendar OneKite',
245
- settings,
246
- });
247
- } catch (err) {
248
- if (req.path && req.path.startsWith('/api/')) {
249
- return res.status(500).json({ error: err.message });
277
+ // Provide defaults JSON strings if empty
278
+ if (!settings.locationsJson) {
279
+ settings.locationsJson = JSON.stringify([
280
+ { id: 'arnaud', name: 'Chez Arnaud' },
281
+ { id: 'siege', name: 'Siège Onekite' },
282
+ ], null, 2);
250
283
  }
251
- res.status(500).send(err.message);
284
+ if (!settings.inventoryJson) {
285
+ settings.inventoryJson = JSON.stringify([
286
+ { id: 'wing', name: 'Aile Wing', price: 5, stockByLocation: { arnaud: 1, siege: 0 } },
287
+ ], null, 2);
288
+ }
289
+
290
+ res.render('admin/plugins/calendar-onekite', { title: 'Calendar OneKite', settings });
291
+ } catch (err) {
292
+ res.status(500).json({ error: err.message });
252
293
  }
253
294
  }
254
295
 
255
- /* ---------------- Admin handlers ---------------- */
296
+ /* ---------------- Admin APIs ---------------- */
256
297
 
257
298
  async function adminGetPending(req, res) {
258
299
  try {
@@ -271,37 +312,42 @@ async function adminGetPending(req, res) {
271
312
  }
272
313
  }
273
314
 
315
+ async function adminSaveSettings(req, res) {
316
+ try {
317
+ await Settings.set(SETTINGS_KEY, req.body);
318
+ res.json({ status: 'ok' });
319
+ } catch (err) {
320
+ res.status(500).json({ error: err.message });
321
+ }
322
+ }
323
+
274
324
  async function adminValidateReservation(req, res) {
275
325
  try {
276
326
  const rid = req.params.rid;
277
- const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
278
-
279
- let targetEvent = null;
280
- let reservation = null;
327
+ const all = await getAllReservations();
281
328
 
282
- for (const eid of eids) {
283
- const event = await getEvent(eid);
284
- if (!event) continue;
285
- const reservations = JSON.parse(event.reservations || '[]');
286
- const r = reservations.find(rr => rr.rid === rid);
287
- if (r) {
288
- targetEvent = event;
289
- reservation = r;
290
- break;
291
- }
329
+ let target = null;
330
+ for (const row of all) {
331
+ if (String(row.reservation.rid) === String(rid)) { target = row; break; }
292
332
  }
333
+ if (!target) return res.status(404).json({ error: 'Réservation introuvable' });
334
+
335
+ const { event } = target;
336
+ const reservation = target.reservation;
293
337
 
294
- if (!reservation) return res.status(404).json({ error: 'Réservation introuvable' });
295
338
  if (reservation.status !== 'pending_admin') return res.status(400).json({ error: 'Réservation déjà traitée' });
296
339
 
297
340
  reservation.status = 'awaiting_payment';
298
- const resList = JSON.parse(targetEvent.reservations || '[]');
299
- const idx = resList.findIndex(r => r.rid === rid);
341
+
342
+ const resList = JSON.parse(event.reservations || '[]');
343
+ const idx = resList.findIndex(r => String(r.rid) === String(rid));
300
344
  resList[idx] = reservation;
301
- targetEvent.reservations = JSON.stringify(resList);
302
- await db.setObject(getEventKey(targetEvent.eid), targetEvent);
345
+ event.reservations = JSON.stringify(resList);
346
+ await db.setObject(getEventKey(event.eid), event);
303
347
 
304
- const amount = computePrice(targetEvent, reservation);
348
+ const settings = await getParsedSettings();
349
+ const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
350
+ const amount = computePrice(invItem, reservation);
305
351
 
306
352
  const checkoutUrl = await helloAsso.createHelloAssoCheckoutIntent({
307
353
  eid: reservation.eid,
@@ -315,11 +361,11 @@ async function adminValidateReservation(req, res) {
315
361
  try {
316
362
  await emailer.send('calendar-reservation-approved', reservation.uid, {
317
363
  subject: 'Votre réservation a été validée',
318
- eventTitle: targetEvent.title,
319
- itemName: reservation.itemId,
364
+ eventTitle: event.title,
365
+ itemName: invItem?.name || reservation.itemId,
320
366
  quantity: reservation.quantity,
321
367
  checkoutUrl,
322
- pickupLocation: reservation.pickupLocation || 'Non précisé',
368
+ pickupLocation: reservation.pickupLocationName || reservation.locationId || 'Non précisé',
323
369
  dateStart: reservation.dateStart,
324
370
  dateEnd: reservation.dateEnd,
325
371
  days: reservation.days || 1,
@@ -338,14 +384,13 @@ async function adminCancelReservation(req, res) {
338
384
  try {
339
385
  const rid = req.params.rid;
340
386
  const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
341
-
342
387
  let found = false;
388
+
343
389
  for (const eid of eids) {
344
390
  const event = await getEvent(eid);
345
391
  if (!event) continue;
346
-
347
392
  const reservations = JSON.parse(event.reservations || '[]');
348
- const idx = reservations.findIndex(rr => rr.rid === rid);
393
+ const idx = reservations.findIndex(r => String(r.rid) === String(rid));
349
394
  if (idx !== -1) {
350
395
  reservations[idx].status = 'cancelled';
351
396
  event.reservations = JSON.stringify(reservations);
@@ -364,35 +409,34 @@ async function adminCancelReservation(req, res) {
364
409
 
365
410
  async function adminGetPlanning(req, res) {
366
411
  try {
367
- const now = new Date();
368
- const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, now.getTime(), '+inf');
369
- const rows = [];
412
+ const settings = await getParsedSettings();
413
+ const locMap = new Map(settings.locations.map(l => [String(l.id), l.name || l.id]));
414
+ const invMap = new Map(settings.inventory.map(i => [String(i.id), i.name || i.id]));
370
415
 
371
- for (const eid of eids) {
372
- const event = await getEvent(eid);
373
- if (!event) continue;
416
+ const all = await getAllReservations();
417
+ const rows = [];
374
418
 
375
- const items = JSON.parse(event.bookingItems || '[]');
376
- const reservations = JSON.parse(event.reservations || '[]');
419
+ for (const row of all) {
420
+ const event = row.event;
421
+ const r = row.reservation;
422
+ if (!r) continue;
423
+ if (!['pending_admin', 'awaiting_payment', 'paid'].includes(String(r.status))) continue;
377
424
 
378
- reservations
379
- .filter(r => r.status === 'pending_admin' || r.status === 'awaiting_payment' || r.status === 'paid')
380
- .forEach(r => {
381
- const item = items.find(i => i.id === r.itemId);
382
- rows.push({
383
- eid: event.eid,
384
- eventTitle: event.title,
385
- itemId: r.itemId,
386
- itemName: item ? item.name : r.itemId,
387
- uid: r.uid,
388
- quantity: r.quantity,
389
- dateStart: r.dateStart,
390
- dateEnd: r.dateEnd,
391
- days: r.days || daysBetween(r.dateStart, r.dateEnd),
392
- status: r.status,
393
- pickupLocation: r.pickupLocation || 'Non précisé',
394
- });
395
- });
425
+ rows.push({
426
+ rid: r.rid,
427
+ eid: event.eid,
428
+ eventTitle: event.title,
429
+ itemId: r.itemId,
430
+ itemName: invMap.get(String(r.itemId)) || r.itemId,
431
+ uid: r.uid,
432
+ quantity: r.quantity,
433
+ dateStart: r.dateStart,
434
+ dateEnd: r.dateEnd,
435
+ days: r.days || daysBetween(r.dateStart, r.dateEnd),
436
+ status: r.status,
437
+ locationId: r.locationId,
438
+ pickupLocation: r.pickupLocationName || locMap.get(String(r.locationId)) || 'Non précisé',
439
+ });
396
440
  }
397
441
 
398
442
  rows.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
@@ -402,22 +446,29 @@ async function adminGetPlanning(req, res) {
402
446
  }
403
447
  }
404
448
 
405
- async function adminSaveSettings(req, res) {
449
+ /* ---------------- Public APIs ---------------- */
450
+
451
+ async function inventoryApi(req, res) {
406
452
  try {
407
- await Settings.set(SETTINGS_KEY, req.body);
408
- res.json({ status: 'ok' });
453
+ const settings = await getParsedSettings();
454
+ res.json({
455
+ locations: settings.locations || [],
456
+ inventory: settings.inventory || [],
457
+ });
409
458
  } catch (err) {
410
459
  res.status(500).json({ error: err.message });
411
460
  }
412
461
  }
413
462
 
414
- /* ---------------- My reservations API ---------------- */
415
-
416
463
  async function myReservations(req, res) {
417
464
  try {
418
465
  const uid = String(req.user?.uid || 0);
419
466
  if (!uid || uid === '0') return res.status(403).json({ error: 'Non connecté' });
420
467
 
468
+ const settings = await getParsedSettings();
469
+ const locMap = new Map(settings.locations.map(l => [String(l.id), l.name || l.id]));
470
+ const invMap = new Map(settings.inventory.map(i => [String(i.id), i.name || i.id]));
471
+
421
472
  const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
422
473
  const result = [];
423
474
 
@@ -425,19 +476,15 @@ async function myReservations(req, res) {
425
476
  const event = await getEvent(eid);
426
477
  if (!event) continue;
427
478
 
428
- const items = JSON.parse(event.bookingItems || '[]');
429
479
  const reservations = JSON.parse(event.reservations || '[]');
430
-
431
480
  reservations
432
481
  .filter(r => String(r.uid) === uid)
433
482
  .forEach(r => {
434
- const item = items.find(i => i.id === r.itemId);
435
483
  result.push({
436
484
  ...r,
437
485
  eventTitle: event.title,
438
- eventStart: event.start,
439
- eventEnd: event.end,
440
- itemName: item ? item.name : r.itemId,
486
+ itemName: invMap.get(String(r.itemId)) || r.itemId,
487
+ pickupLocation: r.pickupLocationName || locMap.get(String(r.locationId)) || '',
441
488
  });
442
489
  });
443
490
  }
@@ -449,55 +496,72 @@ async function myReservations(req, res) {
449
496
  }
450
497
  }
451
498
 
452
- /* ---------------- Booking ---------------- */
453
-
454
499
  async function bookReservation(req, res) {
455
500
  try {
456
501
  const uid = req.user?.uid || 0;
457
502
  const eid = req.params.eid;
458
- const { itemId, quantity, dateStart, dateEnd } = req.body;
503
+ const { itemId, quantity, dateStart, dateEnd, locationId } = req.body;
459
504
 
460
505
  if (!await userCanBook(uid)) {
461
506
  return res.status(403).json({ error: 'Vous n’êtes pas autorisé à réserver du matériel.' });
462
507
  }
463
- if (!dateStart || !dateEnd) {
464
- return res.status(400).json({ error: 'Dates de début et de fin obligatoires.' });
508
+ if (!dateStart || !dateEnd || !itemId || !locationId) {
509
+ return res.status(400).json({ error: 'itemId, locationId, dateStart, dateEnd obligatoires.' });
465
510
  }
466
511
  if (String(dateEnd) < String(dateStart)) {
467
512
  return res.status(400).json({ error: 'La date de fin doit être ≥ la date de début.' });
468
513
  }
469
514
 
470
- const event = await getEvent(eid);
471
- if (!event) return res.status(404).json({ error: 'Événement introuvable' });
472
- if (!Number(event.bookingEnabled)) return res.status(400).json({ error: 'Réservation désactivée' });
515
+ const settings = await getParsedSettings();
516
+ const loc = settings.locations.find(l => String(l.id) === String(locationId));
517
+ if (!loc) return res.status(400).json({ error: 'Lieu invalide.' });
518
+
519
+ const invItem = settings.inventory.find(i => String(i.id) === String(itemId));
520
+ if (!invItem) return res.status(400).json({ error: 'Matériel invalide.' });
473
521
 
474
- const items = JSON.parse(event.bookingItems || '[]');
475
- const item = items.find(i => i.id === itemId);
476
- if (!item) return res.status(400).json({ error: 'Matériel introuvable' });
522
+ const stockTotal = Number(invItem.stockByLocation?.[String(locationId)] || 0);
523
+ if (stockTotal <= 0) return res.status(400).json({ error: 'Stock indisponible sur ce lieu.' });
477
524
 
478
525
  const q = Number(quantity);
479
526
  if (!q || q <= 0) return res.status(400).json({ error: 'Quantité invalide' });
480
527
 
481
- const allRes = JSON.parse(event.reservations || '[]');
482
-
483
- const overlapping = allRes.filter(r => {
484
- if (r.itemId !== itemId) return false;
485
- if (r.status === 'cancelled') return false;
528
+ const event = await getEvent(eid);
529
+ if (!event) return res.status(404).json({ error: 'Événement introuvable' });
530
+ if (!Number(event.bookingEnabled)) return res.status(400).json({ error: 'Réservation désactivée' });
486
531
 
487
- const startR = new Date(r.dateStart);
488
- const endR = new Date(r.dateEnd);
489
- const startN = new Date(dateStart);
490
- const endN = new Date(dateEnd);
532
+ // Ensure item is allowed for this event
533
+ const allowedIds = (() => {
534
+ try {
535
+ const arr = JSON.parse(event.bookingItems || '[]');
536
+ return Array.isArray(arr) ? arr.map(String) : [];
537
+ } catch {
538
+ return [];
539
+ }
540
+ })();
541
+ if (!allowedIds.includes(String(itemId))) {
542
+ return res.status(400).json({ error: 'Ce matériel n’est pas autorisé pour cet événement.' });
543
+ }
491
544
 
492
- return !(endN < startR || startN > endR);
493
- });
545
+ // Global availability: check overlaps across all events reservations (same itemId + locationId)
546
+ const all = await getAllReservations();
547
+ const overlapping = all
548
+ .map(x => x.reservation)
549
+ .filter(r => r && String(r.itemId) === String(itemId))
550
+ .filter(r => String(r.locationId) === String(locationId))
551
+ .filter(r => String(r.status) !== 'cancelled')
552
+ .filter(r => {
553
+ const startR = new Date(r.dateStart);
554
+ const endR = new Date(r.dateEnd);
555
+ const startN = new Date(dateStart);
556
+ const endN = new Date(dateEnd);
557
+ return !(endN < startR || startN > endR);
558
+ });
494
559
 
495
560
  const used = overlapping.reduce((sum, r) => sum + Number(r.quantity || 0), 0);
496
- const available = Number(item.total || 0) - used;
497
- if (q > available) return res.status(400).json({ error: 'Matériel indisponible pour ces dates.' });
561
+ const available = stockTotal - used;
562
+ if (q > available) return res.status(400).json({ error: `Stock insuffisant sur ce lieu (dispo: ${available}).` });
498
563
 
499
564
  const rid = await nextReservationId();
500
- const now = Date.now();
501
565
  const nbDays = daysBetween(dateStart, dateEnd);
502
566
 
503
567
  const reservation = {
@@ -505,31 +569,32 @@ async function bookReservation(req, res) {
505
569
  eid: String(eid),
506
570
  uid: String(uid),
507
571
  itemId: String(itemId),
572
+ locationId: String(locationId),
573
+ pickupLocationName: String(loc.name || loc.id),
508
574
  quantity: q,
509
575
  dateStart,
510
576
  dateEnd,
511
577
  days: nbDays,
512
578
  status: 'pending_admin',
513
579
  helloAssoOrderId: null,
514
- createdAt: now,
515
- pickupLocation: String(item.pickupLocation || ''),
580
+ createdAt: Date.now(),
516
581
  };
517
582
 
518
- allRes.push(reservation);
519
- event.reservations = JSON.stringify(allRes);
583
+ const resList = JSON.parse(event.reservations || '[]');
584
+ resList.push(reservation);
585
+ event.reservations = JSON.stringify(resList);
520
586
  await db.setObject(getEventKey(eid), event);
521
587
 
522
588
  try {
523
589
  await emailer.send('calendar-reservation-created', uid, {
524
590
  subject: 'Votre demande de réservation a été envoyée',
525
591
  eventTitle: event.title,
526
- item: item.name,
592
+ item: invItem.name || invItem.id,
527
593
  quantity: reservation.quantity,
528
- date: new Date().toLocaleString('fr-FR'),
529
594
  dateStart,
530
595
  dateEnd,
531
596
  days: nbDays,
532
- pickupLocation: reservation.pickupLocation || 'Non précisé',
597
+ pickupLocation: reservation.pickupLocationName,
533
598
  });
534
599
  } catch (e) {
535
600
  console.warn('[calendar-onekite] email reservation-created error:', e.message);
@@ -545,7 +610,7 @@ async function bookReservation(req, res) {
545
610
  }
546
611
  }
547
612
 
548
- /* ---------------- Webhook ---------------- */
613
+ /* ---------------- HelloAsso webhook ---------------- */
549
614
 
550
615
  async function helloAssoWebhook(req, res) {
551
616
  try {
@@ -565,7 +630,7 @@ async function helloAssoWebhook(req, res) {
565
630
  if (!event) throw new Error('Event not found');
566
631
 
567
632
  const reservations = JSON.parse(event.reservations || '[]');
568
- const idx = reservations.findIndex(r => r.rid === rid);
633
+ const idx = reservations.findIndex(r => String(r.rid) === String(rid));
569
634
  if (idx === -1) throw new Error('Reservation not found');
570
635
 
571
636
  const reservation = reservations[idx];
@@ -578,13 +643,16 @@ async function helloAssoWebhook(req, res) {
578
643
  event.reservations = JSON.stringify(reservations);
579
644
  await db.setObject(getEventKey(eid), event);
580
645
 
646
+ const settings = await getParsedSettings();
647
+ const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
648
+
581
649
  try {
582
650
  await emailer.send('calendar-payment-confirmed', reservation.uid, {
583
651
  subject: 'Votre paiement a été confirmé',
584
652
  eventTitle: event.title,
585
- itemName: reservation.itemId,
653
+ itemName: invItem?.name || reservation.itemId,
586
654
  quantity: reservation.quantity,
587
- pickupLocation: reservation.pickupLocation || 'Non précisé',
655
+ pickupLocation: reservation.pickupLocationName || reservation.locationId || 'Non précisé',
588
656
  dateStart: reservation.dateStart,
589
657
  dateEnd: reservation.dateEnd,
590
658
  days: reservation.days || 1,
@@ -644,13 +712,13 @@ Plugin.init = async function (params) {
644
712
  appRef = params.app;
645
713
 
646
714
  // Pages
647
- router.get('/calendar', middleware.buildHeader, renderCalendarPage);
648
- router.get('/calendar/my-reservations', middleware.buildHeader, renderMyReservationsPage);
715
+ router.get('/calendar', middleware.buildHeader, (req, res) => res.render('calendar', { title: 'Calendrier' }));
716
+ router.get('/calendar/my-reservations', middleware.buildHeader, (req, res) => res.render('calendar-my-reservations', { title: 'Mes réservations' }));
649
717
 
650
- router.get('/admin/calendar/planning', middleware.admin.buildHeader, renderPlanningPage);
718
+ router.get('/admin/calendar/planning', middleware.admin.buildHeader, (req, res) => res.render('admin/calendar-planning', { title: 'Planning' }));
651
719
  router.get('/admin/plugins/calendar-onekite', middleware.admin.buildHeader, renderAdminPage);
652
720
 
653
- // Ajaxify admin page under /api and /api/v3
721
+ // Admin page (ajaxify)
654
722
  mountApiBoth(router, 'get', '/admin/plugins/calendar-onekite', middleware.admin.checkPrivileges, renderAdminPage);
655
723
 
656
724
  // Settings save
@@ -662,12 +730,10 @@ Plugin.init = async function (params) {
662
730
  mountApiBoth(router, 'post', '/admin/calendar/reservation/:rid/cancel', middleware.admin.checkPrivileges, adminCancelReservation);
663
731
  mountApiBoth(router, 'get', '/admin/calendar/planning', middleware.admin.checkPrivileges, adminGetPlanning);
664
732
 
665
- // Planning page ajaxify helper
666
- router.get('/api/admin/calendar/planning/page', renderPlanningPage);
667
- router.get('/api/v3/admin/calendar/planning/page', renderPlanningPage);
733
+ // Public inventory
734
+ mountApiBoth(router, 'get', '/calendar/inventory', inventoryApi);
668
735
 
669
- // Public APIs + v3
670
- // Events list (FullCalendar)
736
+ // Events list for FullCalendar
671
737
  mountApiBoth(router, 'get', '/calendar/events', async (req, res) => {
672
738
  try {
673
739
  const { start, end } = req.query;
@@ -713,13 +779,29 @@ Plugin.init = async function (params) {
713
779
  }
714
780
  });
715
781
 
782
+ // Event details: enrich with bookingItemIds + resolved items
716
783
  mountApiBoth(router, 'get', '/calendar/event/:eid', async (req, res) => {
717
784
  try {
785
+ const settings = await getParsedSettings();
786
+ const invMap = new Map(settings.inventory.map(i => [String(i.id), i]));
787
+
718
788
  const event = await getEvent(req.params.eid);
719
789
  if (!event) return res.status(404).json({ error: 'Événement introuvable' });
720
- const items = JSON.parse(event.bookingItems || '[]');
721
- const reservations = JSON.parse(event.reservations || '[]');
722
- res.json({ ...event, bookingItems: items, reservations });
790
+
791
+ const bookingItemIds = (() => {
792
+ try {
793
+ const arr = JSON.parse(event.bookingItems || '[]');
794
+ return Array.isArray(arr) ? arr.map(String) : [];
795
+ } catch { return []; }
796
+ })();
797
+
798
+ const bookingItems = bookingItemIds.map(id => invMap.get(String(id))).filter(Boolean);
799
+
800
+ const reservations = (() => {
801
+ try { return JSON.parse(event.reservations || '[]'); } catch { return []; }
802
+ })();
803
+
804
+ res.json({ ...event, bookingItemIds, bookingItems, reservations });
723
805
  } catch (err) {
724
806
  res.status(500).json({ error: err.message });
725
807
  }
@@ -756,7 +838,7 @@ Plugin.init = async function (params) {
756
838
  // My reservations
757
839
  mountApiBoth(router, 'get', '/calendar/my-reservations', middleware.ensureLoggedIn, myReservations);
758
840
 
759
- // HelloAsso webhook (keep non-v3 public path + v3 mirror if needed)
841
+ // HelloAsso webhook
760
842
  router.post('/api/calendar/helloasso/webhook', helloAssoWebhook);
761
843
  router.post('/api/v3/calendar/helloasso/webhook', helloAssoWebhook);
762
844
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Calendar + equipment booking + admin approval + HelloAsso payments for NodeBB v4 (no-jQuery UI)",
5
5
  "main": "library.js",
6
6
  "license": "MIT",