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.
- package/library.js +540 -528
- 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
|
-
/*
|
|
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
|
|
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(
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
|
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
|
|
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
|
-
/*
|
|
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
|
-
/*
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
274
|
+
function renderMyReservationsPage(req, res) {
|
|
275
|
+
res.render('calendar-my-reservations', { title: 'Mes réservations' });
|
|
276
|
+
}
|
|
297
277
|
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
282
|
+
async function renderAdminPage(req, res) {
|
|
316
283
|
try {
|
|
317
|
-
await Settings.
|
|
318
|
-
res.
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
336
|
-
|
|
302
|
+
Plugin.init = async function (params) {
|
|
303
|
+
const { router, middleware } = params;
|
|
304
|
+
appRef = params.app;
|
|
337
305
|
|
|
338
|
-
|
|
306
|
+
// Public pages
|
|
307
|
+
router.get('/calendar', middleware.buildHeader, renderCalendarPage);
|
|
308
|
+
router.get('/api/calendar', renderCalendarPage);
|
|
339
309
|
|
|
340
|
-
|
|
310
|
+
router.get('/calendar/my-reservations', middleware.buildHeader, renderMyReservationsPage);
|
|
311
|
+
router.get('/api/calendar/my-reservations/page', renderMyReservationsPage);
|
|
341
312
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
317
|
+
/* -----------------------------
|
|
318
|
+
* Events API
|
|
319
|
+
* ---------------------------- */
|
|
351
320
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
res.status(500).json({ error: err.message });
|
|
407
|
-
}
|
|
408
|
-
}
|
|
380
|
+
/* -----------------------------
|
|
381
|
+
* RSVP
|
|
382
|
+
* ---------------------------- */
|
|
409
383
|
|
|
410
|
-
async
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
res.status(500).json({ error: err.message });
|
|
446
|
-
}
|
|
447
|
-
}
|
|
396
|
+
/* -----------------------------
|
|
397
|
+
* Client permissions endpoints
|
|
398
|
+
* ---------------------------- */
|
|
448
399
|
|
|
449
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
res.json({
|
|
455
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (!uid || uid === '0') return res.status(403).json({ error: 'Non connecté' });
|
|
412
|
+
/* -----------------------------
|
|
413
|
+
* Multi-day reservation request
|
|
414
|
+
* ---------------------------- */
|
|
467
415
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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)
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
523
|
-
|
|
440
|
+
const q = Number(quantity);
|
|
441
|
+
if (!q || q <= 0) return res.status(400).json({ error: 'Quantité invalide' });
|
|
524
442
|
|
|
525
|
-
|
|
526
|
-
if (!q || q <= 0) return res.status(400).json({ error: 'Quantité invalide' });
|
|
443
|
+
const allRes = JSON.parse(event.reservations || '[]');
|
|
527
444
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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 (
|
|
600
|
-
|
|
509
|
+
} catch (err) {
|
|
510
|
+
res.status(500).json({ error: err.message });
|
|
601
511
|
}
|
|
512
|
+
});
|
|
602
513
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
532
|
+
res.json(result);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
res.status(500).json({ error: err.message });
|
|
535
|
+
}
|
|
536
|
+
});
|
|
628
537
|
|
|
629
|
-
|
|
630
|
-
|
|
538
|
+
/* -----------------------------
|
|
539
|
+
* Admin: validate reservation -> awaiting_payment + HelloAsso checkout
|
|
540
|
+
* ---------------------------- */
|
|
631
541
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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
|
-
|
|
640
|
-
reservation.helloAssoOrderId = orderId;
|
|
641
|
-
reservations[idx] = reservation;
|
|
566
|
+
reservation.status = 'awaiting_payment';
|
|
642
567
|
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
647
|
-
const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
|
|
573
|
+
await db.setObject(getEventKey(targetEvent.eid), targetEvent);
|
|
648
574
|
|
|
649
|
-
|
|
650
|
-
await
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
/*
|
|
640
|
+
/* -----------------------------
|
|
641
|
+
* HelloAsso webhook (marks reservation paid)
|
|
642
|
+
* ---------------------------- */
|
|
709
643
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
719
|
-
|
|
653
|
+
const custom = order.customFields || {};
|
|
654
|
+
const eid = String(custom.eid || '');
|
|
655
|
+
const rid = String(custom.rid || '');
|
|
720
656
|
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
734
|
-
|
|
682
|
+
event.bookingItems = JSON.stringify(items);
|
|
683
|
+
event.reservations = JSON.stringify(reservations);
|
|
735
684
|
|
|
736
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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
|
|
814
|
-
const
|
|
815
|
-
const
|
|
816
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const allow = await userCanCreate(uid);
|
|
826
|
-
res.json({ allow });
|
|
827
|
-
});
|
|
811
|
+
/* -----------------------------
|
|
812
|
+
* ACP nav entry
|
|
813
|
+
* ---------------------------- */
|
|
828
814
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
836
|
-
|
|
825
|
+
/* -----------------------------
|
|
826
|
+
* Widget
|
|
827
|
+
* ---------------------------- */
|
|
837
828
|
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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