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 +7 -0
- package/library.js +230 -148
- package/package.json +1 -1
- package/plugin.json +2 -2
- package/static/js/admin-planning.js +133 -0
- package/static/js/admin.bundle.js +248 -0
- package/static/js/admin.js +9 -0
- package/static/js/calendar.bundle.js +269 -0
- package/static/js/calendar.js +130 -105
- package/static/js/my-reservations.bundle.js +42 -0
- package/templates/admin/calendar-planning.tpl +32 -20
- package/templates/admin/plugins/calendar-onekite.tpl +20 -0
- package/templates/calendar-my-reservations.tpl +2 -0
- package/templates/calendar.tpl +11 -2
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
|
-
|
|
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(
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
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)
|
|
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(
|
|
218
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
settings
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
278
|
-
|
|
279
|
-
let targetEvent = null;
|
|
280
|
-
let reservation = null;
|
|
327
|
+
const all = await getAllReservations();
|
|
281
328
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
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
|
-
|
|
299
|
-
const
|
|
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
|
-
|
|
302
|
-
await db.setObject(getEventKey(
|
|
345
|
+
event.reservations = JSON.stringify(resList);
|
|
346
|
+
await db.setObject(getEventKey(event.eid), event);
|
|
303
347
|
|
|
304
|
-
const
|
|
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:
|
|
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.
|
|
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(
|
|
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
|
|
368
|
-
const
|
|
369
|
-
const
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
if (!event) continue;
|
|
416
|
+
const all = await getAllReservations();
|
|
417
|
+
const rows = [];
|
|
374
418
|
|
|
375
|
-
|
|
376
|
-
const
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
449
|
+
/* ---------------- Public APIs ---------------- */
|
|
450
|
+
|
|
451
|
+
async function inventoryApi(req, res) {
|
|
406
452
|
try {
|
|
407
|
-
await
|
|
408
|
-
res.json({
|
|
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
|
-
|
|
439
|
-
|
|
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: '
|
|
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
|
|
471
|
-
|
|
472
|
-
if (!
|
|
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
|
|
475
|
-
|
|
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
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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 =
|
|
497
|
-
if (q > available) return res.status(400).json({ error:
|
|
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
|
-
|
|
519
|
-
|
|
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:
|
|
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.
|
|
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
|
-
/* ----------------
|
|
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.
|
|
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,
|
|
648
|
-
router.get('/calendar/my-reservations', middleware.buildHeader,
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
//
|
|
666
|
-
router
|
|
667
|
-
router.get('/api/v3/admin/calendar/planning/page', renderPlanningPage);
|
|
733
|
+
// Public inventory
|
|
734
|
+
mountApiBoth(router, 'get', '/calendar/inventory', inventoryApi);
|
|
668
735
|
|
|
669
|
-
//
|
|
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
|
-
|
|
721
|
-
const
|
|
722
|
-
|
|
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
|
|
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