nodebb-plugin-calendar-onekite 2.1.2 → 2.2.0

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.
Files changed (2) hide show
  1. package/library.js +540 -528
  2. package/package.json +1 -1
package/library.js CHANGED
@@ -1,9 +1,32 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * NodeBB v4 plugin: Calendar OneKite
5
+ * - Events + booking items (with pickupLocation per item)
6
+ * - Multi-day reservations
7
+ * - Admin validation -> HelloAsso checkout link -> payment webhook -> mark paid
8
+ * - Admin planning view + "My reservations" page
9
+ * - Settings:
10
+ * - Settings key: "calendar-onekite"
11
+ * - Admin page: /admin/plugins/calendar-onekite
12
+ * - Admin API: /api/admin/plugins/calendar-onekite
13
+ *
14
+ * Templates expected:
15
+ * - templates/calendar.tpl
16
+ * - templates/calendar-my-reservations.tpl
17
+ * - templates/admin/plugins/calendar-onekite.tpl
18
+ * - templates/admin/calendar-planning.tpl
19
+ * - templates/widgets/calendar-upcoming.tpl
20
+ * - templates/emails/calendar-reservation-created.tpl
21
+ * - templates/emails/calendar-reservation-approved.tpl
22
+ * - templates/emails/calendar-payment-confirmed.tpl
23
+ */
24
+
3
25
  const db = require.main.require('./src/database');
4
26
  const user = require.main.require('./src/user');
5
27
  const meta = require.main.require('./src/meta');
6
28
  const emailer = require.main.require('./src/emailer');
29
+ const winston = require.main.require('winston');
7
30
 
8
31
  const Settings = meta.settings;
9
32
  const helloAsso = require('./helloasso');
@@ -19,46 +42,26 @@ function getEventKey(eid) {
19
42
  return EVENT_KEY_PREFIX + eid;
20
43
  }
21
44
 
22
- function mountApiBoth(router, method, path, ...handlers) {
23
- const m = String(method).toLowerCase();
24
- router[m]('/api' + path, ...handlers);
25
- router[m]('/api/v3' + path, ...handlers);
26
- }
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
-
46
45
  async function nextReservationId() {
47
46
  const rid = await db.incrObjectField('global', 'nextCalendarRid');
48
47
  return String(rid);
49
48
  }
50
49
 
50
+ // number of days between two yyyy-mm-dd dates (inclusive)
51
51
  function daysBetween(start, end) {
52
52
  const d1 = new Date(start);
53
53
  const d2 = new Date(end);
54
54
  if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return 1;
55
+
55
56
  const t1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate());
56
57
  const t2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate());
57
58
  const diff = Math.round((t2 - t1) / (1000 * 60 * 60 * 24));
58
59
  return Math.max(1, diff + 1);
59
60
  }
60
61
 
61
- /* ---------------- Events ---------------- */
62
+ /* -----------------------------
63
+ * Events
64
+ * ---------------------------- */
62
65
 
63
66
  async function createEvent(data, uid) {
64
67
  const eid = await db.incrObjectField('global', 'nextCalendarEid');
@@ -68,9 +71,7 @@ async function createEvent(data, uid) {
68
71
  const startTs = Number(new Date(data.start).getTime()) || now;
69
72
  const endTs = Number(new Date(data.end).getTime()) || startTs;
70
73
 
71
- const bookingItemIds = Array.isArray(data.bookingItemIds)
72
- ? data.bookingItemIds.map(String)
73
- : [];
74
+ const bookingItems = Array.isArray(data.bookingItems) ? data.bookingItems : [];
74
75
 
75
76
  const eventObj = {
76
77
  eid: String(eid),
@@ -91,7 +92,7 @@ async function createEvent(data, uid) {
91
92
  visibility: String(data.visibility || 'public'),
92
93
 
93
94
  bookingEnabled: data.bookingEnabled ? 1 : 0,
94
- bookingItems: JSON.stringify(bookingItemIds), // stores IDs only
95
+ bookingItems: JSON.stringify(bookingItems),
95
96
  reservations: JSON.stringify([]),
96
97
  };
97
98
 
@@ -112,18 +113,9 @@ async function updateEvent(eid, data) {
112
113
  const startTs = data.start ? new Date(data.start).getTime() : new Date(existing.start).getTime();
113
114
  const endTs = data.end ? new Date(data.end).getTime() : new Date(existing.end).getTime();
114
115
 
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;
116
+ const bookingItems = Array.isArray(data.bookingItems)
117
+ ? data.bookingItems
118
+ : JSON.parse(existing.bookingItems || '[]');
127
119
 
128
120
  const updated = {
129
121
  ...existing,
@@ -135,7 +127,7 @@ async function updateEvent(eid, data) {
135
127
  location: data.location !== undefined ? String(data.location) : existing.location,
136
128
  visibility: data.visibility !== undefined ? String(data.visibility) : existing.visibility,
137
129
  bookingEnabled: data.bookingEnabled !== undefined ? (data.bookingEnabled ? 1 : 0) : (existing.bookingEnabled || 0),
138
- bookingItems: JSON.stringify(bookingItemIds),
130
+ bookingItems: JSON.stringify(bookingItems),
139
131
  updatedAt: String(Date.now()),
140
132
  };
141
133
 
@@ -155,35 +147,46 @@ async function getEventsBetween(start, end) {
155
147
  const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, startTs, endTs);
156
148
  if (!eids || !eids.length) return [];
157
149
 
158
- const keys = eids.map(id => getEventKey(id));
150
+ const keys = eids.map(eid => getEventKey(eid));
159
151
  const events = await db.getObjects(keys);
160
152
 
161
- return (events || []).filter(Boolean);
153
+ return (events || []).filter(Boolean).map(ev => ({
154
+ ...ev,
155
+ bookingItems: JSON.parse(ev.bookingItems || '[]'),
156
+ }));
162
157
  }
163
158
 
164
159
  async function getUpcomingEvents(limit = 5) {
165
160
  const now = Date.now();
166
161
  const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, limit - 1, now, '+inf');
167
162
  if (!eids || !eids.length) return [];
168
- const keys = eids.map(id => getEventKey(id));
163
+
164
+ const keys = eids.map(eid => getEventKey(eid));
169
165
  const events = await db.getObjects(keys);
170
166
  return (events || []).filter(Boolean);
171
167
  }
172
168
 
173
- /* ---------------- Permissions ---------------- */
169
+ /* -----------------------------
170
+ * Permissions
171
+ * ---------------------------- */
174
172
 
175
173
  async function userCanCreate(uid) {
176
174
  if (!uid || uid === 0) return false;
175
+
177
176
  const settings = await Settings.get(SETTINGS_KEY);
178
177
  if (!settings || !settings.allowedGroups) return false;
179
178
 
180
179
  const allowedSet = new Set(
181
- settings.allowedGroups.split(',').map(g => g.trim().toLowerCase()).filter(Boolean)
180
+ settings.allowedGroups
181
+ .split(',')
182
+ .map(g => g.trim().toLowerCase())
183
+ .filter(Boolean)
182
184
  );
183
185
  if (!allowedSet.size) return false;
184
186
 
185
187
  const userGroupsArr = await user.getUserGroups([uid]);
186
188
  const groups = (userGroupsArr[0] || []).map(g => (g.name || '').toLowerCase());
189
+
187
190
  return groups.some(g => allowedSet.has(g));
188
191
  }
189
192
 
@@ -191,19 +194,26 @@ async function userCanBook(uid) {
191
194
  if (!uid || uid === 0) return false;
192
195
 
193
196
  const settings = await Settings.get(SETTINGS_KEY);
197
+ // if not configured -> allow any logged-in user
194
198
  if (!settings || !settings.allowedBookingGroups) return true;
195
199
 
196
200
  const allowedSet = new Set(
197
- settings.allowedBookingGroups.split(',').map(g => g.trim().toLowerCase()).filter(Boolean)
201
+ settings.allowedBookingGroups
202
+ .split(',')
203
+ .map(g => g.trim().toLowerCase())
204
+ .filter(Boolean)
198
205
  );
199
206
  if (!allowedSet.size) return true;
200
207
 
201
208
  const userGroupsArr = await user.getUserGroups([uid]);
202
209
  const groups = (userGroupsArr[0] || []).map(g => (g.name || '').toLowerCase());
210
+
203
211
  return groups.some(g => allowedSet.has(g));
204
212
  }
205
213
 
206
- /* ---------------- RSVP ---------------- */
214
+ /* -----------------------------
215
+ * RSVP
216
+ * ---------------------------- */
207
217
 
208
218
  async function setRsvp(eid, uid, status) {
209
219
  const key = getEventKey(eid);
@@ -211,7 +221,7 @@ async function setRsvp(eid, uid, status) {
211
221
  if (!event) throw new Error('Event not found');
212
222
 
213
223
  const parseList = (str) => {
214
- try { return JSON.parse(str || '[]'); } catch { return []; }
224
+ try { return JSON.parse(str || '[]'); } catch (e) { return []; }
215
225
  };
216
226
 
217
227
  let yes = parseList(event.rsvpYes);
@@ -239,608 +249,610 @@ async function setRsvp(eid, uid, status) {
239
249
  return updated;
240
250
  }
241
251
 
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
- }
252
+ /* -----------------------------
253
+ * Pricing
254
+ * ---------------------------- */
259
255
 
260
- /* ---------------- Pricing ---------------- */
256
+ function computePrice(event, reservation) {
257
+ const items = JSON.parse(event.bookingItems || '[]');
258
+ const item = items.find(i => i.id === reservation.itemId);
259
+ if (!item) return 0;
261
260
 
262
- function computePrice(inventoryItem, reservation) {
263
- const unit = Number(inventoryItem?.price || 0);
261
+ const unit = Number(item.price || 0);
264
262
  const days = Number(reservation.days || 1);
265
263
  return unit * Number(reservation.quantity || 0) * days;
266
264
  }
267
265
 
268
- /* ---------------- Renderers ---------------- */
269
-
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' }); }
273
-
274
- async function renderAdminPage(req, res) {
275
- try {
276
- const settings = (await Settings.get(SETTINGS_KEY)) || {};
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);
283
- }
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
- }
266
+ /* -----------------------------
267
+ * Renderers (pages)
268
+ * ---------------------------- */
289
269
 
290
- res.render('admin/plugins/calendar-onekite', { title: 'Calendar OneKite', settings });
291
- } catch (err) {
292
- res.status(500).json({ error: err.message });
293
- }
270
+ function renderCalendarPage(req, res) {
271
+ res.render('calendar', { title: 'Calendrier' });
294
272
  }
295
273
 
296
- /* ---------------- Admin APIs ---------------- */
274
+ function renderMyReservationsPage(req, res) {
275
+ res.render('calendar-my-reservations', { title: 'Mes réservations' });
276
+ }
297
277
 
298
- async function adminGetPending(req, res) {
299
- try {
300
- const result = [];
301
- const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
302
- for (const eid of eids) {
303
- const event = await getEvent(eid);
304
- if (!event) continue;
305
- const reservations = JSON.parse(event.reservations || '[]');
306
- const pending = reservations.filter(r => r.status === 'pending_admin');
307
- if (pending.length) result.push({ event, reservations: pending });
308
- }
309
- res.json(result);
310
- } catch (err) {
311
- res.status(500).json({ error: err.message });
312
- }
278
+ function renderPlanningPage(req, res) {
279
+ res.render('admin/calendar-planning', { title: 'Planning des réservations' });
313
280
  }
314
281
 
315
- async function adminSaveSettings(req, res) {
282
+ async function renderAdminPage(req, res) {
316
283
  try {
317
- await Settings.set(SETTINGS_KEY, req.body);
318
- res.json({ status: 'ok' });
284
+ const settings = (await Settings.get(SETTINGS_KEY)) || {};
285
+ res.render('admin/plugins/calendar-onekite', {
286
+ title: 'Calendar OneKite',
287
+ settings,
288
+ });
319
289
  } catch (err) {
290
+ if (req.path && req.path.startsWith('/api/')) {
291
+ return winston.error(`[calendar-onekite] ${err.stack || err.message}`);
320
292
  res.status(500).json({ error: err.message });
293
+ }
294
+ res.status(500).send(err.message);
321
295
  }
322
296
  }
323
297
 
324
- async function adminValidateReservation(req, res) {
325
- try {
326
- const rid = req.params.rid;
327
- const all = await getAllReservations();
328
-
329
- let target = null;
330
- for (const row of all) {
331
- if (String(row.reservation.rid) === String(rid)) { target = row; break; }
332
- }
333
- if (!target) return res.status(404).json({ error: 'Réservation introuvable' });
298
+ /* -----------------------------
299
+ * Plugin init
300
+ * ---------------------------- */
334
301
 
335
- const { event } = target;
336
- const reservation = target.reservation;
302
+ Plugin.init = async function (params) {
303
+ const { router, middleware } = params;
304
+ appRef = params.app;
337
305
 
338
- if (reservation.status !== 'pending_admin') return res.status(400).json({ error: 'Réservation déjà traitée' });
306
+ // Public pages
307
+ router.get('/calendar', middleware.buildHeader, renderCalendarPage);
308
+ router.get('/api/calendar', renderCalendarPage);
339
309
 
340
- reservation.status = 'awaiting_payment';
310
+ router.get('/calendar/my-reservations', middleware.buildHeader, renderMyReservationsPage);
311
+ router.get('/api/calendar/my-reservations/page', renderMyReservationsPage);
341
312
 
342
- const resList = JSON.parse(event.reservations || '[]');
343
- const idx = resList.findIndex(r => String(r.rid) === String(rid));
344
- resList[idx] = reservation;
345
- event.reservations = JSON.stringify(resList);
346
- await db.setObject(getEventKey(event.eid), event);
313
+ // Admin planning page
314
+ router.get('/admin/calendar/planning', middleware.admin.buildHeader, renderPlanningPage);
315
+ router.get('/api/admin/calendar/planning/page', renderPlanningPage);
347
316
 
348
- const settings = await getParsedSettings();
349
- const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
350
- const amount = computePrice(invItem, reservation);
317
+ /* -----------------------------
318
+ * Events API
319
+ * ---------------------------- */
351
320
 
352
- const checkoutUrl = await helloAsso.createHelloAssoCheckoutIntent({
353
- eid: reservation.eid,
354
- rid,
355
- uid: reservation.uid,
356
- itemId: reservation.itemId,
357
- quantity: reservation.quantity,
358
- amount,
359
- });
321
+ router.get('/api/calendar/events', async (req, res) => {
322
+ try {
323
+ const { start, end } = req.query;
324
+ if (!start || !end) return res.status(400).json({ error: 'Missing start/end' });
325
+ const events = await getEventsBetween(start, end);
326
+ res.json(events);
327
+ } catch (err) {
328
+ res.status(500).json({ error: err.message });
329
+ }
330
+ });
360
331
 
332
+ router.post('/api/calendar/event', middleware.ensureLoggedIn, async (req, res) => {
361
333
  try {
362
- await emailer.send('calendar-reservation-approved', reservation.uid, {
363
- subject: 'Votre réservation a été validée',
364
- eventTitle: event.title,
365
- itemName: invItem?.name || reservation.itemId,
366
- quantity: reservation.quantity,
367
- checkoutUrl,
368
- pickupLocation: reservation.pickupLocationName || reservation.locationId || 'Non précisé',
369
- dateStart: reservation.dateStart,
370
- dateEnd: reservation.dateEnd,
371
- days: reservation.days || 1,
372
- });
373
- } catch (e) {
374
- console.warn('[calendar-onekite] email reservation-approved error:', e.message);
334
+ const uid = req.user?.uid || 0;
335
+ if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
336
+ const event = await createEvent(req.body, uid);
337
+ res.json(event);
338
+ } catch (err) {
339
+ res.status(500).json({ error: err.message });
375
340
  }
341
+ });
376
342
 
377
- res.json({ success: true, checkoutUrl });
378
- } catch (err) {
379
- res.status(500).json({ error: err.message });
380
- }
381
- }
343
+ router.put('/api/calendar/event/:eid', middleware.ensureLoggedIn, async (req, res) => {
344
+ try {
345
+ const uid = req.user?.uid || 0;
346
+ if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
347
+ const eid = req.params.eid;
348
+ const event = await updateEvent(eid, req.body);
349
+ res.json(event);
350
+ } catch (err) {
351
+ res.status(500).json({ error: err.message });
352
+ }
353
+ });
382
354
 
383
- async function adminCancelReservation(req, res) {
384
- try {
385
- const rid = req.params.rid;
386
- const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
387
- let found = false;
355
+ router.delete('/api/calendar/event/:eid', middleware.ensureLoggedIn, async (req, res) => {
356
+ try {
357
+ const uid = req.user?.uid || 0;
358
+ if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
359
+ const eid = req.params.eid;
360
+ await deleteEvent(eid);
361
+ res.json({ status: 'ok' });
362
+ } catch (err) {
363
+ res.status(500).json({ error: err.message });
364
+ }
365
+ });
388
366
 
389
- for (const eid of eids) {
367
+ router.get('/api/calendar/event/:eid', async (req, res) => {
368
+ try {
369
+ const eid = req.params.eid;
390
370
  const event = await getEvent(eid);
391
- if (!event) continue;
371
+ if (!event) return res.status(404).json({ error: 'Événement introuvable' });
372
+ const items = JSON.parse(event.bookingItems || '[]');
392
373
  const reservations = JSON.parse(event.reservations || '[]');
393
- const idx = reservations.findIndex(r => String(r.rid) === String(rid));
394
- if (idx !== -1) {
395
- reservations[idx].status = 'cancelled';
396
- event.reservations = JSON.stringify(reservations);
397
- await db.setObject(getEventKey(event.eid), event);
398
- found = true;
399
- break;
400
- }
374
+ res.json({ ...event, bookingItems: items, reservations });
375
+ } catch (err) {
376
+ res.status(500).json({ error: err.message });
401
377
  }
378
+ });
402
379
 
403
- if (!found) return res.status(404).json({ error: 'Réservation introuvable' });
404
- res.json({ success: true });
405
- } catch (err) {
406
- res.status(500).json({ error: err.message });
407
- }
408
- }
380
+ /* -----------------------------
381
+ * RSVP
382
+ * ---------------------------- */
409
383
 
410
- async function adminGetPlanning(req, res) {
411
- try {
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]));
415
-
416
- const all = await getAllReservations();
417
- const rows = [];
418
-
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;
424
-
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
- });
384
+ router.post('/api/calendar/event/:eid/rsvp', middleware.ensureLoggedIn, async (req, res) => {
385
+ try {
386
+ const eid = req.params.eid;
387
+ const uid = req.user?.uid || 0;
388
+ const status = req.body.status;
389
+ const event = await setRsvp(eid, uid, status);
390
+ res.json(event);
391
+ } catch (err) {
392
+ res.status(500).json({ error: err.message });
440
393
  }
394
+ });
441
395
 
442
- rows.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
443
- res.json(rows);
444
- } catch (err) {
445
- res.status(500).json({ error: err.message });
446
- }
447
- }
396
+ /* -----------------------------
397
+ * Client permissions endpoints
398
+ * ---------------------------- */
448
399
 
449
- /* ---------------- Public APIs ---------------- */
400
+ router.get('/api/calendar/permissions/create', async (req, res) => {
401
+ const uid = req.user?.uid || 0;
402
+ const allow = await userCanCreate(uid);
403
+ res.json({ allow });
404
+ });
450
405
 
451
- async function inventoryApi(req, res) {
452
- try {
453
- const settings = await getParsedSettings();
454
- res.json({
455
- locations: settings.locations || [],
456
- inventory: settings.inventory || [],
457
- });
458
- } catch (err) {
459
- res.status(500).json({ error: err.message });
460
- }
461
- }
406
+ router.get('/api/calendar/permissions/book', async (req, res) => {
407
+ const uid = req.user?.uid || 0;
408
+ const allow = await userCanBook(uid);
409
+ res.json({ allow });
410
+ });
462
411
 
463
- async function myReservations(req, res) {
464
- try {
465
- const uid = String(req.user?.uid || 0);
466
- if (!uid || uid === '0') return res.status(403).json({ error: 'Non connecté' });
412
+ /* -----------------------------
413
+ * Multi-day reservation request
414
+ * ---------------------------- */
467
415
 
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]));
416
+ router.post('/api/calendar/event/:eid/book', middleware.ensureLoggedIn, async (req, res) => {
417
+ try {
418
+ const uid = req.user?.uid || 0;
419
+ const eid = req.params.eid;
420
+ const { itemId, quantity, dateStart, dateEnd } = req.body;
471
421
 
472
- const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
473
- const result = [];
422
+ if (!await userCanBook(uid)) {
423
+ return res.status(403).json({ error: 'Vous n’êtes pas autorisé à réserver du matériel.' });
424
+ }
425
+ if (!dateStart || !dateEnd) {
426
+ return res.status(400).json({ error: 'Dates de début et de fin obligatoires.' });
427
+ }
428
+ if (String(dateEnd) < String(dateStart)) {
429
+ return res.status(400).json({ error: 'La date de fin doit être ≥ la date de début.' });
430
+ }
474
431
 
475
- for (const eid of eids) {
476
432
  const event = await getEvent(eid);
477
- if (!event) continue;
478
-
479
- const reservations = JSON.parse(event.reservations || '[]');
480
- reservations
481
- .filter(r => String(r.uid) === uid)
482
- .forEach(r => {
483
- result.push({
484
- ...r,
485
- eventTitle: event.title,
486
- itemName: invMap.get(String(r.itemId)) || r.itemId,
487
- pickupLocation: r.pickupLocationName || locMap.get(String(r.locationId)) || '',
488
- });
489
- });
490
- }
491
-
492
- result.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
493
- res.json(result);
494
- } catch (err) {
495
- res.status(500).json({ error: err.message });
496
- }
497
- }
498
-
499
- async function bookReservation(req, res) {
500
- try {
501
- const uid = req.user?.uid || 0;
502
- const eid = req.params.eid;
503
- const { itemId, quantity, dateStart, dateEnd, locationId } = req.body;
504
-
505
- if (!await userCanBook(uid)) {
506
- return res.status(403).json({ error: 'Vous n’êtes pas autorisé à réserver du matériel.' });
507
- }
508
- if (!dateStart || !dateEnd || !itemId || !locationId) {
509
- return res.status(400).json({ error: 'itemId, locationId, dateStart, dateEnd obligatoires.' });
510
- }
511
- if (String(dateEnd) < String(dateStart)) {
512
- return res.status(400).json({ error: 'La date de fin doit être ≥ la date de début.' });
513
- }
514
-
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.' });
433
+ if (!event) return res.status(404).json({ error: 'Événement introuvable' });
434
+ if (!Number(event.bookingEnabled)) return res.status(400).json({ error: 'Réservation désactivée' });
518
435
 
519
- const invItem = settings.inventory.find(i => String(i.id) === String(itemId));
520
- if (!invItem) return res.status(400).json({ error: 'Matériel invalide.' });
436
+ const items = JSON.parse(event.bookingItems || '[]');
437
+ const item = items.find(i => i.id === itemId);
438
+ if (!item) return res.status(400).json({ error: 'Matériel introuvable' });
521
439
 
522
- const stockTotal = Number(invItem.stockByLocation?.[String(locationId)] || 0);
523
- if (stockTotal <= 0) return res.status(400).json({ error: 'Stock indisponible sur ce lieu.' });
440
+ const q = Number(quantity);
441
+ if (!q || q <= 0) return res.status(400).json({ error: 'Quantité invalide' });
524
442
 
525
- const q = Number(quantity);
526
- if (!q || q <= 0) return res.status(400).json({ error: 'Quantité invalide' });
443
+ const allRes = JSON.parse(event.reservations || '[]');
527
444
 
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' });
445
+ // Stock check: sum overlapping reservations (pending_admin/awaiting_payment/paid) for the same item
446
+ const overlapping = allRes.filter(r => {
447
+ if (r.itemId !== itemId) return false;
448
+ if (r.status === 'cancelled') return false;
531
449
 
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
- }
544
-
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
450
  const startR = new Date(r.dateStart);
554
451
  const endR = new Date(r.dateEnd);
555
452
  const startN = new Date(dateStart);
556
453
  const endN = new Date(dateEnd);
454
+
557
455
  return !(endN < startR || startN > endR);
558
456
  });
559
457
 
560
- const used = overlapping.reduce((sum, r) => sum + Number(r.quantity || 0), 0);
561
- const available = stockTotal - used;
562
- if (q > available) return res.status(400).json({ error: `Stock insuffisant sur ce lieu (dispo: ${available}).` });
563
-
564
- const rid = await nextReservationId();
565
- const nbDays = daysBetween(dateStart, dateEnd);
566
-
567
- const reservation = {
568
- rid,
569
- eid: String(eid),
570
- uid: String(uid),
571
- itemId: String(itemId),
572
- locationId: String(locationId),
573
- pickupLocationName: String(loc.name || loc.id),
574
- quantity: q,
575
- dateStart,
576
- dateEnd,
577
- days: nbDays,
578
- status: 'pending_admin',
579
- helloAssoOrderId: null,
580
- createdAt: Date.now(),
581
- };
582
-
583
- const resList = JSON.parse(event.reservations || '[]');
584
- resList.push(reservation);
585
- event.reservations = JSON.stringify(resList);
586
- await db.setObject(getEventKey(eid), event);
458
+ const used = overlapping.reduce((sum, r) => sum + Number(r.quantity || 0), 0);
459
+ const available = Number(item.total || 0) - used;
460
+ if (q > available) return res.status(400).json({ error: 'Matériel indisponible pour ces dates.' });
587
461
 
588
- try {
589
- await emailer.send('calendar-reservation-created', uid, {
590
- subject: 'Votre demande de réservation a été envoyée',
591
- eventTitle: event.title,
592
- item: invItem.name || invItem.id,
593
- quantity: reservation.quantity,
462
+ const rid = await nextReservationId();
463
+ const now = Date.now();
464
+ const nbDays = daysBetween(dateStart, dateEnd);
465
+
466
+ const reservation = {
467
+ rid,
468
+ eid: String(eid),
469
+ uid: String(uid),
470
+ itemId: String(itemId),
471
+ quantity: q,
594
472
  dateStart,
595
473
  dateEnd,
596
474
  days: nbDays,
597
- pickupLocation: reservation.pickupLocationName,
475
+ status: 'pending_admin',
476
+ helloAssoOrderId: null,
477
+ createdAt: now,
478
+ pickupLocation: String(item.pickupLocation || ''),
479
+ };
480
+
481
+ allRes.push(reservation);
482
+ event.bookingItems = JSON.stringify(items);
483
+ event.reservations = JSON.stringify(allRes);
484
+
485
+ await db.setObject(getEventKey(eid), event);
486
+
487
+ // Email: request created
488
+ try {
489
+ await emailer.send('calendar-reservation-created', uid, {
490
+ subject: 'Votre demande de réservation a été envoyée',
491
+ eventTitle: event.title,
492
+ item: item.name,
493
+ quantity: reservation.quantity,
494
+ date: new Date().toLocaleString('fr-FR'),
495
+ dateStart,
496
+ dateEnd,
497
+ days: nbDays,
498
+ pickupLocation: reservation.pickupLocation || 'Non précisé',
499
+ });
500
+ } catch (e) {
501
+ console.warn('[calendar-onekite] email reservation-created error:', e.message);
502
+ }
503
+
504
+ res.json({
505
+ success: true,
506
+ status: 'pending_admin',
507
+ message: 'Votre demande de réservation a été envoyée. Elle doit être validée par un administrateur.',
598
508
  });
599
- } catch (e) {
600
- console.warn('[calendar-onekite] email reservation-created error:', e.message);
509
+ } catch (err) {
510
+ res.status(500).json({ error: err.message });
601
511
  }
512
+ });
602
513
 
603
- res.json({
604
- success: true,
605
- status: 'pending_admin',
606
- message: 'Votre demande de réservation a été envoyée. Elle doit être validée par un administrateur.',
607
- });
608
- } catch (err) {
609
- res.status(500).json({ error: err.message });
610
- }
611
- }
514
+ /* -----------------------------
515
+ * Admin: pending list
516
+ * ---------------------------- */
612
517
 
613
- /* ---------------- HelloAsso webhook ---------------- */
518
+ router.get('/api/admin/calendar/pending', middleware.admin.checkPrivileges, async (req, res) => {
519
+ try {
520
+ const result = [];
521
+ const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
614
522
 
615
- async function helloAssoWebhook(req, res) {
616
- try {
617
- const payload = req.body;
618
- const order = payload.order || payload;
523
+ for (const eid of eids) {
524
+ const event = await getEvent(eid);
525
+ if (!event) continue;
619
526
 
620
- const orderId = String(order.id || '');
621
- const state = order.state || order.status || '';
622
- if (state !== 'Paid') return res.json({ ignored: true });
527
+ const reservations = JSON.parse(event.reservations || '[]');
528
+ const pending = reservations.filter(r => r.status === 'pending_admin');
529
+ if (pending.length) result.push({ event, reservations: pending });
530
+ }
623
531
 
624
- const custom = order.metadata || order.customFields || {};
625
- const eid = String(custom.eid || '');
626
- const rid = String(custom.rid || '');
627
- if (!eid || !rid) return res.status(400).json({ error: 'Missing eid/rid in metadata' });
532
+ res.json(result);
533
+ } catch (err) {
534
+ res.status(500).json({ error: err.message });
535
+ }
536
+ });
628
537
 
629
- const event = await getEvent(eid);
630
- if (!event) throw new Error('Event not found');
538
+ /* -----------------------------
539
+ * Admin: validate reservation -> awaiting_payment + HelloAsso checkout
540
+ * ---------------------------- */
631
541
 
632
- const reservations = JSON.parse(event.reservations || '[]');
633
- const idx = reservations.findIndex(r => String(r.rid) === String(rid));
634
- if (idx === -1) throw new Error('Reservation not found');
542
+ router.post('/api/admin/calendar/reservation/:rid/validate', middleware.admin.checkPrivileges, async (req, res) => {
543
+ try {
544
+ const rid = req.params.rid;
545
+ const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
546
+
547
+ let targetEvent = null;
548
+ let reservation = null;
549
+
550
+ for (const eid of eids) {
551
+ const event = await getEvent(eid);
552
+ if (!event) continue;
553
+
554
+ const reservations = JSON.parse(event.reservations || '[]');
555
+ const r = reservations.find(rr => rr.rid === rid);
556
+ if (r) {
557
+ targetEvent = event;
558
+ reservation = r;
559
+ break;
560
+ }
561
+ }
635
562
 
636
- const reservation = reservations[idx];
637
- if (reservation.status === 'paid') return res.json({ ok: true });
563
+ if (!reservation) return res.status(404).json({ error: 'Réservation introuvable' });
564
+ if (reservation.status !== 'pending_admin') return res.status(400).json({ error: 'Réservation déjà traitée' });
638
565
 
639
- reservation.status = 'paid';
640
- reservation.helloAssoOrderId = orderId;
641
- reservations[idx] = reservation;
566
+ reservation.status = 'awaiting_payment';
642
567
 
643
- event.reservations = JSON.stringify(reservations);
644
- await db.setObject(getEventKey(eid), event);
568
+ const resList = JSON.parse(targetEvent.reservations || '[]');
569
+ const idx = resList.findIndex(r => r.rid === rid);
570
+ resList[idx] = reservation;
571
+ targetEvent.reservations = JSON.stringify(resList);
645
572
 
646
- const settings = await getParsedSettings();
647
- const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
573
+ await db.setObject(getEventKey(targetEvent.eid), targetEvent);
648
574
 
649
- try {
650
- await emailer.send('calendar-payment-confirmed', reservation.uid, {
651
- subject: 'Votre paiement a été confirmé',
652
- eventTitle: event.title,
653
- itemName: invItem?.name || reservation.itemId,
575
+ const amount = computePrice(targetEvent, reservation);
576
+ const checkoutUrl = await helloAsso.createHelloAssoCheckoutIntent({
577
+ eid: reservation.eid,
578
+ rid,
579
+ uid: reservation.uid,
580
+ itemId: reservation.itemId,
654
581
  quantity: reservation.quantity,
655
- pickupLocation: reservation.pickupLocationName || reservation.locationId || 'Non précisé',
656
- dateStart: reservation.dateStart,
657
- dateEnd: reservation.dateEnd,
658
- days: reservation.days || 1,
582
+ amount,
659
583
  });
660
- } catch (e) {
661
- console.warn('[calendar-onekite] email payment-confirmed error:', e.message);
662
- }
663
-
664
- res.json({ ok: true });
665
- } catch (err) {
666
- console.error('[calendar-onekite] HelloAsso webhook error:', err);
667
- res.status(500).json({ error: err.message });
668
- }
669
- }
670
584
 
671
- /* ---------------- Widget ---------------- */
585
+ try {
586
+ await emailer.send('calendar-reservation-approved', reservation.uid, {
587
+ subject: 'Votre réservation a été validée',
588
+ eventTitle: targetEvent.title,
589
+ itemName: reservation.itemId,
590
+ quantity: reservation.quantity,
591
+ checkoutUrl,
592
+ pickupLocation: reservation.pickupLocation || 'Non précisé',
593
+ dateStart: reservation.dateStart,
594
+ dateEnd: reservation.dateEnd,
595
+ days: reservation.days || 1,
596
+ });
597
+ } catch (e) {
598
+ console.warn('[calendar-onekite] email reservation-approved error:', e.message);
599
+ }
672
600
 
673
- Plugin.defineWidgets = async function (widgets) {
674
- widgets.push({
675
- widget: 'calendarUpcoming',
676
- name: 'Prochains événements',
677
- description: 'Affiche la liste des prochains événements du calendrier.',
678
- content: '',
601
+ res.json({ success: true, checkoutUrl });
602
+ } catch (err) {
603
+ res.status(500).json({ error: err.message });
604
+ }
679
605
  });
680
- return widgets;
681
- };
682
606
 
683
- Plugin.renderUpcomingWidget = async function (widget, callback) {
684
- try {
685
- const settings = (await Settings.get(SETTINGS_KEY)) || {};
686
- const limit = Number(widget?.data?.limit || settings.limit || 5);
687
- const events = await getUpcomingEvents(limit);
688
- const html = await appRef.renderAsync('widgets/calendar-upcoming', { events });
689
- widget.html = html;
607
+ /* -----------------------------
608
+ * Admin: cancel reservation
609
+ * ---------------------------- */
690
610
 
691
- if (typeof callback === 'function') return callback(null, widget);
692
- return widget;
693
- } catch (err) {
694
- if (typeof callback === 'function') return callback(err);
695
- throw err;
696
- }
697
- };
611
+ router.post('/api/admin/calendar/reservation/:rid/cancel', middleware.admin.checkPrivileges, async (req, res) => {
612
+ try {
613
+ const rid = req.params.rid;
614
+ const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
615
+
616
+ let found = false;
617
+
618
+ for (const eid of eids) {
619
+ const event = await getEvent(eid);
620
+ if (!event) continue;
621
+
622
+ const reservations = JSON.parse(event.reservations || '[]');
623
+ const rIndex = reservations.findIndex(rr => rr.rid === rid);
624
+ if (rIndex !== -1) {
625
+ reservations[rIndex].status = 'cancelled';
626
+ event.reservations = JSON.stringify(reservations);
627
+ await db.setObject(getEventKey(event.eid), event);
628
+ found = true;
629
+ break;
630
+ }
631
+ }
698
632
 
699
- Plugin.addAdminNavigation = function (header) {
700
- header.plugins.push({
701
- route: '/plugins/calendar-onekite',
702
- icon: 'fa fa-calendar',
703
- name: 'Calendar OneKite',
633
+ if (!found) return res.status(404).json({ error: 'Réservation introuvable' });
634
+ res.json({ success: true });
635
+ } catch (err) {
636
+ res.status(500).json({ error: err.message });
637
+ }
704
638
  });
705
- return header;
706
- };
707
639
 
708
- /* ---------------- Init ---------------- */
640
+ /* -----------------------------
641
+ * HelloAsso webhook (marks reservation paid)
642
+ * ---------------------------- */
709
643
 
710
- Plugin.init = async function (params) {
711
- const { router, middleware } = params;
712
- appRef = params.app;
644
+ router.post('/api/calendar/helloasso/webhook', async (req, res) => {
645
+ try {
646
+ const payload = req.body;
647
+ const order = payload.order || payload;
713
648
 
714
- // Pages
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
+ const orderId = String(order.id || '');
650
+ const state = order.state || order.status || '';
651
+ if (state !== 'Paid') return res.json({ ignored: true });
717
652
 
718
- router.get('/admin/calendar/planning', middleware.admin.buildHeader, (req, res) => res.render('admin/calendar-planning', { title: 'Planning' }));
719
- router.get('/admin/plugins/calendar-onekite', middleware.admin.buildHeader, renderAdminPage);
653
+ const custom = order.customFields || {};
654
+ const eid = String(custom.eid || '');
655
+ const rid = String(custom.rid || '');
720
656
 
721
- // Admin page (ajaxify)
722
- mountApiBoth(router, 'get', '/admin/plugins/calendar-onekite', middleware.admin.checkPrivileges, renderAdminPage);
657
+ if (!eid || !rid) return res.status(400).json({ error: 'Missing eid/rid in customFields' });
723
658
 
724
- // Settings save
725
- mountApiBoth(router, 'put', '/admin/plugins/calendar-onekite', middleware.admin.checkPrivileges, adminSaveSettings);
659
+ const event = await getEvent(eid);
660
+ if (!event) throw new Error('Event not found');
661
+
662
+ let reservations = JSON.parse(event.reservations || '[]');
663
+ const rIndex = reservations.findIndex(r => r.rid === rid);
664
+ if (rIndex === -1) throw new Error('Reservation not found');
665
+
666
+ const reservation = reservations[rIndex];
667
+ if (reservation.status === 'paid') return res.json({ ok: true });
668
+
669
+ // Update optional aggregate reserved count
670
+ const items = JSON.parse(event.bookingItems || '[]');
671
+ const itemIndex = items.findIndex(i => i.id === reservation.itemId);
672
+ if (itemIndex !== -1) {
673
+ const it = items[itemIndex];
674
+ it.reserved = (Number(it.reserved || 0) + Number(reservation.quantity || 0));
675
+ items[itemIndex] = it;
676
+ }
726
677
 
727
- // Admin APIs
728
- mountApiBoth(router, 'get', '/admin/calendar/pending', middleware.admin.checkPrivileges, adminGetPending);
729
- mountApiBoth(router, 'post', '/admin/calendar/reservation/:rid/validate', middleware.admin.checkPrivileges, adminValidateReservation);
730
- mountApiBoth(router, 'post', '/admin/calendar/reservation/:rid/cancel', middleware.admin.checkPrivileges, adminCancelReservation);
731
- mountApiBoth(router, 'get', '/admin/calendar/planning', middleware.admin.checkPrivileges, adminGetPlanning);
678
+ reservation.status = 'paid';
679
+ reservation.helloAssoOrderId = orderId;
680
+ reservations[rIndex] = reservation;
732
681
 
733
- // Public inventory
734
- mountApiBoth(router, 'get', '/calendar/inventory', inventoryApi);
682
+ event.bookingItems = JSON.stringify(items);
683
+ event.reservations = JSON.stringify(reservations);
735
684
 
736
- // Events list for FullCalendar
737
- mountApiBoth(router, 'get', '/calendar/events', async (req, res) => {
738
- try {
739
- const { start, end } = req.query;
740
- if (!start || !end) return res.status(400).json({ error: 'Missing start/end' });
741
- const events = await getEventsBetween(start, end);
742
- res.json(events);
743
- } catch (err) {
744
- res.status(500).json({ error: err.message });
745
- }
746
- });
685
+ await db.setObject(getEventKey(eid), event);
747
686
 
748
- // Event CRUD
749
- mountApiBoth(router, 'post', '/calendar/event', middleware.ensureLoggedIn, async (req, res) => {
750
- try {
751
- const uid = req.user?.uid || 0;
752
- if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
753
- const event = await createEvent(req.body, uid);
754
- res.json(event);
755
- } catch (err) {
756
- res.status(500).json({ error: err.message });
757
- }
758
- });
687
+ try {
688
+ await emailer.send('calendar-payment-confirmed', reservation.uid, {
689
+ subject: 'Votre paiement a été confirmé',
690
+ eventTitle: event.title,
691
+ itemName: reservation.itemId,
692
+ quantity: reservation.quantity,
693
+ pickupLocation: reservation.pickupLocation || 'Non précisé',
694
+ dateStart: reservation.dateStart,
695
+ dateEnd: reservation.dateEnd,
696
+ days: reservation.days || 1,
697
+ });
698
+ } catch (e) {
699
+ console.warn('[calendar-onekite] email payment-confirmed error:', e.message);
700
+ }
759
701
 
760
- mountApiBoth(router, 'put', '/calendar/event/:eid', middleware.ensureLoggedIn, async (req, res) => {
761
- try {
762
- const uid = req.user?.uid || 0;
763
- if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
764
- const event = await updateEvent(req.params.eid, req.body);
765
- res.json(event);
702
+ res.json({ ok: true });
766
703
  } catch (err) {
704
+ console.error('[calendar-onekite] HelloAsso webhook error:', err);
767
705
  res.status(500).json({ error: err.message });
768
706
  }
769
707
  });
770
708
 
771
- mountApiBoth(router, 'delete', '/calendar/event/:eid', middleware.ensureLoggedIn, async (req, res) => {
709
+ /* -----------------------------
710
+ * Admin plugin page + settings API
711
+ * IMPORTANT: keep these routes EXACTLY aligned with your admin.js
712
+ * ---------------------------- */
713
+
714
+ router.get('/admin/plugins/calendar-onekite', middleware.admin.buildHeader, renderAdminPage);
715
+ router.get('/api/admin/plugins/calendar-onekite', renderAdminPage);
716
+
717
+ router.put('/api/admin/plugins/calendar-onekite', middleware.admin.checkPrivileges, async (req, res) => {
772
718
  try {
773
- const uid = req.user?.uid || 0;
774
- if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
775
- await deleteEvent(req.params.eid);
719
+ await Settings.set(SETTINGS_KEY, req.body);
776
720
  res.json({ status: 'ok' });
777
721
  } catch (err) {
778
722
  res.status(500).json({ error: err.message });
779
723
  }
780
724
  });
781
725
 
782
- // Event details: enrich with bookingItemIds + resolved items
783
- mountApiBoth(router, 'get', '/calendar/event/:eid', async (req, res) => {
784
- try {
785
- const settings = await getParsedSettings();
786
- const invMap = new Map(settings.inventory.map(i => [String(i.id), i]));
726
+ /* -----------------------------
727
+ * My reservations API
728
+ * ---------------------------- */
787
729
 
788
- const event = await getEvent(req.params.eid);
789
- if (!event) return res.status(404).json({ error: 'Événement introuvable' });
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
- })();
730
+ router.get('/api/calendar/my-reservations', middleware.ensureLoggedIn, async (req, res) => {
731
+ try {
732
+ const uid = String(req.user?.uid || 0);
733
+ if (!uid || uid === '0') return res.status(403).json({ error: 'Non connecté' });
734
+
735
+ const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
736
+ const result = [];
737
+
738
+ for (const eid of eids) {
739
+ const event = await getEvent(eid);
740
+ if (!event) continue;
741
+
742
+ const items = JSON.parse(event.bookingItems || '[]');
743
+ const reservations = JSON.parse(event.reservations || '[]');
744
+
745
+ reservations
746
+ .filter(r => String(r.uid) === uid)
747
+ .forEach(r => {
748
+ const item = items.find(i => i.id === r.itemId);
749
+ result.push({
750
+ ...r,
751
+ eventTitle: event.title,
752
+ eventStart: event.start,
753
+ eventEnd: event.end,
754
+ itemName: item ? item.name : r.itemId,
755
+ });
756
+ });
757
+ }
803
758
 
804
- res.json({ ...event, bookingItemIds, bookingItems, reservations });
759
+ result.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
760
+ res.json(result);
805
761
  } catch (err) {
806
762
  res.status(500).json({ error: err.message });
807
763
  }
808
764
  });
809
765
 
810
- // RSVP
811
- mountApiBoth(router, 'post', '/calendar/event/:eid/rsvp', middleware.ensureLoggedIn, async (req, res) => {
766
+ /* -----------------------------
767
+ * Admin planning API (future reservations)
768
+ * ---------------------------- */
769
+
770
+ router.get('/api/admin/calendar/planning', middleware.admin.checkPrivileges, async (req, res) => {
812
771
  try {
813
- const uid = req.user?.uid || 0;
814
- const status = req.body.status;
815
- const updated = await setRsvp(req.params.eid, uid, status);
816
- res.json(updated);
772
+ const now = new Date();
773
+ const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, now.getTime(), '+inf');
774
+ const rows = [];
775
+
776
+ for (const eid of eids) {
777
+ const event = await getEvent(eid);
778
+ if (!event) continue;
779
+
780
+ const items = JSON.parse(event.bookingItems || '[]');
781
+ const reservations = JSON.parse(event.reservations || '[]');
782
+
783
+ reservations
784
+ .filter(r => r.status === 'pending_admin' || r.status === 'awaiting_payment' || r.status === 'paid')
785
+ .forEach(r => {
786
+ const item = items.find(i => i.id === r.itemId);
787
+ rows.push({
788
+ eid: event.eid,
789
+ eventTitle: event.title,
790
+ itemId: r.itemId,
791
+ itemName: item ? item.name : r.itemId,
792
+ uid: r.uid,
793
+ quantity: r.quantity,
794
+ dateStart: r.dateStart,
795
+ dateEnd: r.dateEnd,
796
+ days: r.days || daysBetween(r.dateStart, r.dateEnd),
797
+ status: r.status,
798
+ pickupLocation: r.pickupLocation || 'Non précisé',
799
+ });
800
+ });
801
+ }
802
+
803
+ rows.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
804
+ res.json(rows);
817
805
  } catch (err) {
818
806
  res.status(500).json({ error: err.message });
819
807
  }
820
808
  });
809
+ };
821
810
 
822
- // Permissions
823
- mountApiBoth(router, 'get', '/calendar/permissions/create', async (req, res) => {
824
- const uid = req.user?.uid || 0;
825
- const allow = await userCanCreate(uid);
826
- res.json({ allow });
827
- });
811
+ /* -----------------------------
812
+ * ACP nav entry
813
+ * ---------------------------- */
828
814
 
829
- mountApiBoth(router, 'get', '/calendar/permissions/book', async (req, res) => {
830
- const uid = req.user?.uid || 0;
831
- const allow = await userCanBook(uid);
832
- res.json({ allow });
815
+ Plugin.addAdminNavigation = function (header) {
816
+ header.plugins.push({
817
+ // NodeBB ACP will resolve this under /admin
818
+ route: '/plugins/calendar-onekite',
819
+ icon: 'fa fa-calendar',
820
+ name: 'Calendar OneKite',
833
821
  });
822
+ return header;
823
+ };
834
824
 
835
- // Booking
836
- mountApiBoth(router, 'post', '/calendar/event/:eid/book', middleware.ensureLoggedIn, bookReservation);
825
+ /* -----------------------------
826
+ * Widget
827
+ * ---------------------------- */
837
828
 
838
- // My reservations
839
- mountApiBoth(router, 'get', '/calendar/my-reservations', middleware.ensureLoggedIn, myReservations);
829
+ Plugin.defineWidgets = async function (widgets) {
830
+ widgets.push({
831
+ widget: 'calendarUpcoming',
832
+ name: 'Prochains événements',
833
+ description: 'Affiche la liste des prochains événements du calendrier.',
834
+ content: '',
835
+ });
836
+ return widgets;
837
+ };
840
838
 
841
- // HelloAsso webhook
842
- router.post('/api/calendar/helloasso/webhook', helloAssoWebhook);
843
- router.post('/api/v3/calendar/helloasso/webhook', helloAssoWebhook);
839
+ Plugin.renderUpcomingWidget = async function (widget, callback) {
840
+ try {
841
+ const settings = (await Settings.get(SETTINGS_KEY)) || {};
842
+ const limit = Number((widget && widget.data && widget.data.limit) || settings.limit || 5);
843
+ const events = await getUpcomingEvents(limit);
844
+ const html = await appRef.renderAsync('widgets/calendar-upcoming', { events });
845
+ widget.html = html;
846
+ if (typeof callback === 'function') {
847
+ return callback(null, widget);
848
+ }
849
+ return widget;
850
+ } catch (err) {
851
+ if (typeof callback === 'function') {
852
+ return callback(err);
853
+ }
854
+ throw err;
855
+ }
844
856
  };
845
857
 
846
858
  module.exports = Plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
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",