nodebb-plugin-calendar-onekite 2.1.2 → 10.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -1,846 +1,584 @@
1
1
  'use strict';
2
2
 
3
+ const axios = require('axios');
4
+
3
5
  const db = require.main.require('./src/database');
4
- const user = require.main.require('./src/user');
5
6
  const meta = require.main.require('./src/meta');
7
+ const user = require.main.require('./src/user');
8
+ const groups = require.main.require('./src/groups');
6
9
  const emailer = require.main.require('./src/emailer');
10
+ const helpers = require.main.require('./src/controllers/helpers');
11
+ const privileges = require.main.require('./src/privileges');
7
12
 
8
- const Settings = meta.settings;
9
- const helloAsso = require('./helloasso');
10
-
11
- const Plugin = {};
12
- let appRef = null;
13
-
14
- const SETTINGS_KEY = 'calendar-onekite';
15
- const EVENTS_SET_KEY = 'calendar:events:start';
16
- const EVENT_KEY_PREFIX = 'calendar:event:';
17
-
18
- function getEventKey(eid) {
19
- return EVENT_KEY_PREFIX + eid;
20
- }
21
-
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
- ]);
13
+ const plugin = {};
38
14
 
39
- const inventory = parseJson(s.inventoryJson, [
40
- { id: 'wing', name: 'Aile Wing', price: 5, stockByLocation: { arnaud: 1, siege: 0 } },
41
- ]);
15
+ const PLUGIN_NS = 'calendar-onekite';
16
+ const RES_PREFIX = `calendar-onekite:reservation`;
17
+ const RES_ZSET_BY_START = `calendar-onekite:reservations:byStart`;
42
18
 
43
- return { ...s, locations, inventory };
44
- }
19
+ let helloassoTokenCache = { token: null, expiresAt: 0 };
20
+ let itemsCache = { items: null, expiresAt: 0 };
45
21
 
46
- async function nextReservationId() {
47
- const rid = await db.incrObjectField('global', 'nextCalendarRid');
48
- return String(rid);
22
+ function nowMs() {
23
+ return Date.now();
49
24
  }
50
25
 
51
- function daysBetween(start, end) {
52
- const d1 = new Date(start);
53
- const d2 = new Date(end);
54
- if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return 1;
55
- const t1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate());
56
- const t2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate());
57
- const diff = Math.round((t2 - t1) / (1000 * 60 * 60 * 24));
58
- return Math.max(1, diff + 1);
26
+ function safeJsonParse(s, fallback) {
27
+ try { return JSON.parse(s); } catch (e) { return fallback; }
59
28
  }
60
29
 
61
- /* ---------------- Events ---------------- */
62
-
63
- async function createEvent(data, uid) {
64
- const eid = await db.incrObjectField('global', 'nextCalendarEid');
65
- const key = getEventKey(eid);
66
-
67
- const now = Date.now();
68
- const startTs = Number(new Date(data.start).getTime()) || now;
69
- const endTs = Number(new Date(data.end).getTime()) || startTs;
70
-
71
- const bookingItemIds = Array.isArray(data.bookingItemIds)
72
- ? data.bookingItemIds.map(String)
73
- : [];
74
-
75
- const eventObj = {
76
- eid: String(eid),
77
- title: String(data.title || ''),
78
- description: String(data.description || ''),
79
- start: new Date(startTs).toISOString(),
80
- end: new Date(endTs).toISOString(),
81
- allDay: data.allDay ? 1 : 0,
82
- location: String(data.location || ''),
83
- createdByUid: String(uid || 0),
84
- createdAt: String(now),
85
- updatedAt: String(now),
86
-
87
- rsvpYes: '[]',
88
- rsvpMaybe: '[]',
89
- rsvpNo: '[]',
90
-
91
- visibility: String(data.visibility || 'public'),
92
-
93
- bookingEnabled: data.bookingEnabled ? 1 : 0,
94
- bookingItems: JSON.stringify(bookingItemIds), // stores IDs only
95
- reservations: JSON.stringify([]),
96
- };
97
-
98
- await db.setObject(key, eventObj);
99
- await db.sortedSetAdd(EVENTS_SET_KEY, startTs, eid);
100
- return eventObj;
30
+ async function getSettings() {
31
+ const settings = await meta.settings.get(PLUGIN_NS);
32
+ // defaults
33
+ settings.enabled = settings.enabled === 'on' || settings.enabled === true;
34
+ settings.holdMinutes = parseInt(settings.holdMinutes || '60', 10);
35
+ settings.readerGroups = (settings.readerGroups || '').trim(); // currently unused (calendar page is public; NodeBB category perms cover it)
36
+ settings.requesterGroups = (settings.requesterGroups || '').trim(); // who can create requests
37
+ settings.approverGroups = (settings.approverGroups || '').trim(); // who can approve/reject
38
+ settings.notifyEmails = (settings.notifyEmails || '').trim(); // optional extra comma-separated emails
39
+ settings.helloassoEnv = (settings.helloassoEnv || 'sandbox').trim(); // sandbox|prod
40
+ settings.helloassoClientId = (settings.helloassoClientId || '').trim();
41
+ settings.helloassoClientSecret = (settings.helloassoClientSecret || '').trim();
42
+ settings.helloassoOrganizationSlug = (settings.helloassoOrganizationSlug || '').trim();
43
+ settings.helloassoFormType = (settings.helloassoFormType || 'event').trim();
44
+ settings.helloassoFormSlug = (settings.helloassoFormSlug || '').trim();
45
+ settings.helloassoBackUrl = (settings.helloassoBackUrl || '').trim();
46
+ settings.helloassoErrorUrl = (settings.helloassoErrorUrl || '').trim();
47
+ settings.helloassoReturnUrl = (settings.helloassoReturnUrl || '').trim();
48
+ settings.itemsCacheMinutes = parseInt(settings.itemsCacheMinutes || '360', 10);
49
+ return settings;
101
50
  }
102
51
 
103
- async function getEvent(eid) {
104
- return db.getObject(getEventKey(eid));
52
+ function apiBase(env) {
53
+ return env === 'prod' ? 'https://api.helloasso.com' : 'https://api.helloasso-sandbox.com';
105
54
  }
106
55
 
107
- async function updateEvent(eid, data) {
108
- const key = getEventKey(eid);
109
- const existing = await db.getObject(key);
110
- if (!existing) throw new Error('Event not found');
111
-
112
- const startTs = data.start ? new Date(data.start).getTime() : new Date(existing.start).getTime();
113
- const endTs = data.end ? new Date(data.end).getTime() : new Date(existing.end).getTime();
56
+ async function getHelloAssoToken(settings) {
57
+ const baseUrl = apiBase(settings.helloassoEnv);
58
+ const cacheOk = helloassoTokenCache.token && helloassoTokenCache.expiresAt > nowMs() + 30_000;
59
+ if (cacheOk) {
60
+ return helloassoTokenCache.token;
61
+ }
62
+ if (!settings.helloassoClientId || !settings.helloassoClientSecret) {
63
+ throw new Error('HelloAsso clientId/clientSecret not configured');
64
+ }
114
65
 
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;
127
-
128
- const updated = {
129
- ...existing,
130
- title: data.title !== undefined ? String(data.title) : existing.title,
131
- description: data.description !== undefined ? String(data.description) : existing.description,
132
- start: new Date(startTs).toISOString(),
133
- end: new Date(endTs).toISOString(),
134
- allDay: data.allDay !== undefined ? (data.allDay ? 1 : 0) : existing.allDay,
135
- location: data.location !== undefined ? String(data.location) : existing.location,
136
- visibility: data.visibility !== undefined ? String(data.visibility) : existing.visibility,
137
- bookingEnabled: data.bookingEnabled !== undefined ? (data.bookingEnabled ? 1 : 0) : (existing.bookingEnabled || 0),
138
- bookingItems: JSON.stringify(bookingItemIds),
139
- updatedAt: String(Date.now()),
140
- };
66
+ const url = `${baseUrl}/oauth2/token`;
67
+ // client_credentials
68
+ const body = new URLSearchParams({
69
+ grant_type: 'client_credentials',
70
+ client_id: settings.helloassoClientId,
71
+ client_secret: settings.helloassoClientSecret,
72
+ });
141
73
 
142
- await db.setObject(key, updated);
143
- await db.sortedSetAdd(EVENTS_SET_KEY, startTs, eid);
144
- return updated;
145
- }
74
+ const res = await axios.post(url, body.toString(), {
75
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
+ timeout: 15_000,
77
+ });
146
78
 
147
- async function deleteEvent(eid) {
148
- await db.sortedSetRemove(EVENTS_SET_KEY, eid);
149
- await db.delete(getEventKey(eid));
79
+ const token = res.data && res.data.access_token;
80
+ const expiresIn = (res.data && res.data.expires_in) ? parseInt(res.data.expires_in, 10) : 1800;
81
+ if (!token) throw new Error('HelloAsso token response missing access_token');
82
+ helloassoTokenCache = { token, expiresAt: nowMs() + (expiresIn * 1000) };
83
+ return token;
150
84
  }
151
85
 
152
- async function getEventsBetween(start, end) {
153
- const startTs = new Date(start).getTime();
154
- const endTs = new Date(end).getTime();
155
- const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, startTs, endTs);
156
- if (!eids || !eids.length) return [];
86
+ async function fetchItemsFromHelloAsso(settings) {
87
+ const baseUrl = apiBase(settings.helloassoEnv);
88
+ const token = await getHelloAssoToken(settings);
157
89
 
158
- const keys = eids.map(id => getEventKey(id));
159
- const events = await db.getObjects(keys);
160
-
161
- return (events || []).filter(Boolean);
162
- }
90
+ if (!settings.helloassoOrganizationSlug || !settings.helloassoFormType || !settings.helloassoFormSlug) {
91
+ throw new Error('HelloAsso organizationSlug/formType/formSlug not configured');
92
+ }
163
93
 
164
- async function getUpcomingEvents(limit = 5) {
165
- const now = Date.now();
166
- const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, limit - 1, now, '+inf');
167
- if (!eids || !eids.length) return [];
168
- const keys = eids.map(id => getEventKey(id));
169
- const events = await db.getObjects(keys);
170
- return (events || []).filter(Boolean);
94
+ const url = `${baseUrl}/v5/organizations/${encodeURIComponent(settings.helloassoOrganizationSlug)}/forms/${encodeURIComponent(settings.helloassoFormType)}/${encodeURIComponent(settings.helloassoFormSlug)}/items`;
95
+ const res = await axios.get(url, {
96
+ headers: { Authorization: `Bearer ${token}` },
97
+ timeout: 15_000,
98
+ });
99
+ // API returns array of items (docs), sometimes wrapped; we normalize
100
+ const items = Array.isArray(res.data) ? res.data : (res.data && (res.data.data || res.data.items) ? (res.data.data || res.data.items) : []);
101
+ return items;
171
102
  }
172
103
 
173
- /* ---------------- Permissions ---------------- */
174
-
175
- async function userCanCreate(uid) {
176
- if (!uid || uid === 0) return false;
177
- const settings = await Settings.get(SETTINGS_KEY);
178
- if (!settings || !settings.allowedGroups) return false;
179
-
180
- const allowedSet = new Set(
181
- settings.allowedGroups.split(',').map(g => g.trim().toLowerCase()).filter(Boolean)
182
- );
183
- if (!allowedSet.size) return false;
104
+ async function getItems(settings) {
105
+ const cacheOk = itemsCache.items && itemsCache.expiresAt > nowMs();
106
+ if (cacheOk) return itemsCache.items;
184
107
 
185
- const userGroupsArr = await user.getUserGroups([uid]);
186
- const groups = (userGroupsArr[0] || []).map(g => (g.name || '').toLowerCase());
187
- return groups.some(g => allowedSet.has(g));
108
+ const items = await fetchItemsFromHelloAsso(settings);
109
+ const ttl = Math.max(1, settings.itemsCacheMinutes) * 60_000;
110
+ itemsCache = { items, expiresAt: nowMs() + ttl };
111
+ return items;
188
112
  }
189
113
 
190
- async function userCanBook(uid) {
191
- if (!uid || uid === 0) return false;
192
-
193
- const settings = await Settings.get(SETTINGS_KEY);
194
- if (!settings || !settings.allowedBookingGroups) return true;
195
-
196
- const allowedSet = new Set(
197
- settings.allowedBookingGroups.split(',').map(g => g.trim().toLowerCase()).filter(Boolean)
198
- );
199
- if (!allowedSet.size) return true;
200
-
201
- const userGroupsArr = await user.getUserGroups([uid]);
202
- const groups = (userGroupsArr[0] || []).map(g => (g.name || '').toLowerCase());
203
- return groups.some(g => allowedSet.has(g));
114
+ function splitGroups(s) {
115
+ return (s || '')
116
+ .split(',')
117
+ .map(x => x.trim())
118
+ .filter(Boolean);
204
119
  }
205
120
 
206
- /* ---------------- RSVP ---------------- */
207
-
208
- async function setRsvp(eid, uid, status) {
209
- const key = getEventKey(eid);
210
- const event = await db.getObject(key);
211
- if (!event) throw new Error('Event not found');
212
-
213
- const parseList = (str) => {
214
- try { return JSON.parse(str || '[]'); } catch { return []; }
215
- };
216
-
217
- let yes = parseList(event.rsvpYes);
218
- let maybe = parseList(event.rsvpMaybe);
219
- let no = parseList(event.rsvpNo);
220
- const u = String(uid);
221
-
222
- yes = yes.filter(id => id !== u);
223
- maybe = maybe.filter(id => id !== u);
224
- no = no.filter(id => id !== u);
225
-
226
- if (status === 'yes') yes.push(u);
227
- if (status === 'maybe') maybe.push(u);
228
- if (status === 'no') no.push(u);
229
-
230
- const updated = {
231
- ...event,
232
- rsvpYes: JSON.stringify(yes),
233
- rsvpMaybe: JSON.stringify(maybe),
234
- rsvpNo: JSON.stringify(no),
235
- updatedAt: String(Date.now()),
236
- };
237
-
238
- await db.setObject(key, updated);
239
- return updated;
121
+ async function isUserInAnyGroup(uid, groupNames) {
122
+ if (!uid) return false;
123
+ if (!groupNames || !groupNames.length) return false;
124
+ for (const g of groupNames) {
125
+ // allows special names like 'administrators'
126
+ const isMember = await groups.isMember(uid, g);
127
+ if (isMember) return true;
128
+ }
129
+ return false;
240
130
  }
241
131
 
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
- }
132
+ async function assertRequesterPrivileges(req, settings) {
133
+ if (!req.uid) throw new Error('not-authenticated');
134
+ const requesterGroups = splitGroups(settings.requesterGroups);
135
+ if (!requesterGroups.length) {
136
+ // if not set, fallback: any logged-in user can request
137
+ return true;
256
138
  }
257
- return out;
139
+ const ok = await isUserInAnyGroup(req.uid, requesterGroups);
140
+ if (!ok) throw new Error('not-allowed');
141
+ return true;
258
142
  }
259
143
 
260
- /* ---------------- Pricing ---------------- */
144
+ async function assertApproverPrivileges(req, settings) {
145
+ if (!req.uid) throw new Error('not-authenticated');
146
+ // admins always ok
147
+ const isAdmin = await user.isAdministrator(req.uid);
148
+ if (isAdmin) return true;
261
149
 
262
- function computePrice(inventoryItem, reservation) {
263
- const unit = Number(inventoryItem?.price || 0);
264
- const days = Number(reservation.days || 1);
265
- return unit * Number(reservation.quantity || 0) * days;
150
+ const approverGroups = splitGroups(settings.approverGroups);
151
+ const ok = await isUserInAnyGroup(req.uid, approverGroups);
152
+ if (!ok) throw new Error('not-allowed');
153
+ return true;
266
154
  }
267
155
 
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);
156
+ async function cleanupExpiredPending(settings) {
157
+ const now = nowMs();
158
+ // scan upcoming + recent; since we don't have range query in db zset, take last 2000
159
+ const ids = await db.getSortedSetRevRange(RES_ZSET_BY_START, 0, 2000);
160
+ if (!ids.length) return;
161
+
162
+ const keys = ids.map(id => `${RES_PREFIX}:${id}`);
163
+ const objs = await db.getObjects(keys);
164
+ const toDelete = [];
165
+ for (const obj of (objs || [])) {
166
+ if (!obj) continue;
167
+ if (obj.status === 'pending' && obj.expiresAt && parseInt(obj.expiresAt, 10) < now) {
168
+ toDelete.push(obj.id);
288
169
  }
289
-
290
- res.render('admin/plugins/calendar-onekite', { title: 'Calendar OneKite', settings });
291
- } catch (err) {
292
- res.status(500).json({ error: err.message });
293
170
  }
294
- }
171
+ if (!toDelete.length) return;
295
172
 
296
- /* ---------------- Admin APIs ---------------- */
297
-
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
- }
173
+ await Promise.all(toDelete.map(async (id) => {
174
+ await db.delete(`${RES_PREFIX}:${id}`);
175
+ await db.sortedSetRemove(RES_ZSET_BY_START, id);
176
+ }));
313
177
  }
314
178
 
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
- }
179
+ async function nextReservationId() {
180
+ const n = await db.incrObjectField(`calendar-onekite:ids`, 'reservation');
181
+ return String(n);
322
182
  }
323
183
 
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' });
334
-
335
- const { event } = target;
336
- const reservation = target.reservation;
337
-
338
- if (reservation.status !== 'pending_admin') return res.status(400).json({ error: 'Réservation déjà traitée' });
339
-
340
- reservation.status = 'awaiting_payment';
341
-
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);
347
-
348
- const settings = await getParsedSettings();
349
- const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
350
- const amount = computePrice(invItem, reservation);
351
-
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
- });
360
-
361
- 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);
375
- }
376
-
377
- res.json({ success: true, checkoutUrl });
378
- } catch (err) {
379
- res.status(500).json({ error: err.message });
380
- }
184
+ async function reservationToEvent(obj) {
185
+ const status = obj.status || 'pending';
186
+ const icon = status === 'approved' ? '✅' : (status === 'rejected' ? '❌' : '⏳');
187
+ const title = `${icon} ${obj.itemName || 'Matériel'}${obj.requesterName ? ' — ' + obj.requesterName : ''}`;
188
+ return {
189
+ id: obj.id,
190
+ title,
191
+ start: obj.start,
192
+ end: obj.end,
193
+ extendedProps: {
194
+ status,
195
+ itemId: obj.itemId,
196
+ itemName: obj.itemName,
197
+ requesterUid: obj.uid,
198
+ requesterName: obj.requesterName,
199
+ paymentUrl: obj.paymentUrl || '',
200
+ },
201
+ };
381
202
  }
382
203
 
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;
388
-
389
- for (const eid of eids) {
390
- const event = await getEvent(eid);
391
- if (!event) continue;
392
- 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
- }
401
- }
204
+ async function getReservationsInRange(startISO, endISO) {
205
+ // naive: get latest N and filter
206
+ const ids = await db.getSortedSetRange(RES_ZSET_BY_START, 0, 5000);
207
+ if (!ids.length) return [];
208
+ const keys = ids.map(id => `${RES_PREFIX}:${id}`);
209
+ const objs = await db.getObjects(keys);
402
210
 
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
- }
409
-
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
- });
440
- }
211
+ const start = startISO ? new Date(startISO).getTime() : -Infinity;
212
+ const end = endISO ? new Date(endISO).getTime() : Infinity;
441
213
 
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 });
214
+ const out = [];
215
+ for (const obj of (objs || [])) {
216
+ if (!obj) continue;
217
+ const s = new Date(obj.start).getTime();
218
+ const e = new Date(obj.end).getTime();
219
+ if (isNaN(s) || isNaN(e)) continue;
220
+ // overlap
221
+ if (s < end && e > start) out.push(obj);
446
222
  }
223
+ return out;
447
224
  }
448
225
 
449
- /* ---------------- Public APIs ---------------- */
450
-
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 });
226
+ async function sendEmailToGroups(settings, subject, html, groupNames) {
227
+ const all = new Set();
228
+ for (const g of (groupNames || [])) {
229
+ const members = await groups.getMembers(g, 0, 2000);
230
+ (members || []).forEach(m => { if (m && m.uid) all.add(m.uid); });
460
231
  }
461
- }
462
-
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é' });
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
-
472
- const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
473
- const result = [];
474
-
475
- for (const eid of eids) {
476
- 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
- });
232
+ // Also allow extra direct emails (optional)
233
+ const extraEmails = (settings.notifyEmails || '').split(',').map(s => s.trim()).filter(Boolean);
234
+
235
+ const uids = Array.from(all);
236
+ // Send to uids
237
+ await Promise.all(uids.map(async (uid) => {
238
+ const u = await user.getUserFields(uid, ['email', 'username']);
239
+ if (u && u.email) {
240
+ await emailer.send('default', u.email, subject, html);
490
241
  }
242
+ }));
491
243
 
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
- }
244
+ // Send to extra emails
245
+ await Promise.all(extraEmails.map(async (mail) => {
246
+ await emailer.send('default', mail, subject, html);
247
+ }));
497
248
  }
498
249
 
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.' });
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.' });
250
+ async function createCheckoutIntent(settings, reservationObj, amountCents) {
251
+ const baseUrl = apiBase(settings.helloassoEnv);
252
+ const token = await getHelloAssoToken(settings);
253
+
254
+ if (!settings.helloassoOrganizationSlug) throw new Error('HelloAsso organizationSlug missing');
255
+ const url = `${baseUrl}/v5/organizations/${encodeURIComponent(settings.helloassoOrganizationSlug)}/checkout-intents`;
256
+
257
+ const requester = await user.getUserFields(reservationObj.uid, ['username', 'fullname', 'email']);
258
+ const payerName = (requester && (requester.fullname || requester.username) || '').trim();
259
+ const parts = payerName.split(/\s+/).filter(Boolean);
260
+ const firstName = parts[0] || 'User';
261
+ const lastName = parts.slice(1).join(' ') || 'NodeBB';
262
+
263
+ // Required by HelloAsso: totalAmount, initialAmount, itemName, backUrl, errorUrl, returnUrl, containsDonation (bool)
264
+ const body = {
265
+ totalAmount: amountCents,
266
+ initialAmount: amountCents,
267
+ itemName: reservationObj.itemName || 'Reservation',
268
+ backUrl: settings.helloassoBackUrl || settings.helloassoReturnUrl || settings.helloassoErrorUrl,
269
+ errorUrl: settings.helloassoErrorUrl || settings.helloassoReturnUrl || settings.helloassoBackUrl,
270
+ returnUrl: settings.helloassoReturnUrl || settings.helloassoBackUrl || settings.helloassoErrorUrl,
271
+ containsDonation: false,
272
+ payer: {
273
+ firstName,
274
+ lastName,
275
+ email: (requester && requester.email) || undefined,
276
+ },
277
+ metadata: {
278
+ plugin: 'calendar-onekite',
279
+ reservationId: reservationObj.id,
280
+ uid: reservationObj.uid,
281
+ itemId: reservationObj.itemId,
282
+ start: reservationObj.start,
283
+ end: reservationObj.end,
284
+ },
285
+ };
521
286
 
522
- const stockTotal = Number(invItem.stockByLocation?.[String(locationId)] || 0);
523
- if (stockTotal <= 0) return res.status(400).json({ error: 'Stock indisponible sur ce lieu.' });
287
+ const res = await axios.post(url, body, {
288
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
289
+ timeout: 15_000,
290
+ });
524
291
 
525
- const q = Number(quantity);
526
- if (!q || q <= 0) return res.status(400).json({ error: 'Quantité invalide' });
292
+ // docs: returns checkoutIntentId and redirectUrl (naming may vary)
293
+ const data = res.data || {};
294
+ const redirectUrl = data.redirectUrl || data.url || data.redirectURL || '';
295
+ const checkoutIntentId = data.checkoutIntentId || data.id || data.checkoutIntentID || '';
296
+ return { redirectUrl, checkoutIntentId, raw: data };
297
+ }
527
298
 
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' });
299
+ plugin.init = async function (params) {
300
+ const { router, middleware } = params;
301
+ const settings = await getSettings();
531
302
 
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.' });
303
+ // Page route: /calendar
304
+ router.get('/calendar', middleware.buildHeader, async (req, res) => {
305
+ if (!settings.enabled) {
306
+ return res.status(404).send('Calendar plugin disabled');
543
307
  }
308
+ const isLoggedIn = !!req.uid;
309
+ res.render('calendar-onekite/calendar', {
310
+ title: 'Calendrier',
311
+ isLoggedIn,
312
+ uid: req.uid || 0,
313
+ });
314
+ });
544
315
 
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
- });
316
+ // API: events
317
+ router.get('/api/calendar-onekite/events', async (req, res) => {
318
+ try {
319
+ const s = await getSettings();
320
+ if (!s.enabled) return res.json({ events: [] });
559
321
 
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);
322
+ await cleanupExpiredPending(s);
587
323
 
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,
594
- dateStart,
595
- dateEnd,
596
- days: nbDays,
597
- pickupLocation: reservation.pickupLocationName,
598
- });
324
+ const start = req.query.start;
325
+ const end = req.query.end;
326
+ const objs = await getReservationsInRange(start, end);
327
+ const events = await Promise.all(objs.map(reservationToEvent));
328
+ res.json({ events });
599
329
  } catch (e) {
600
- console.warn('[calendar-onekite] email reservation-created error:', e.message);
330
+ res.status(500).json({ error: e.message });
601
331
  }
332
+ });
602
333
 
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
- }
612
-
613
- /* ---------------- HelloAsso webhook ---------------- */
614
-
615
- async function helloAssoWebhook(req, res) {
616
- try {
617
- const payload = req.body;
618
- const order = payload.order || payload;
619
-
620
- const orderId = String(order.id || '');
621
- const state = order.state || order.status || '';
622
- if (state !== 'Paid') return res.json({ ignored: true });
623
-
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' });
628
-
629
- const event = await getEvent(eid);
630
- if (!event) throw new Error('Event not found');
631
-
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');
635
-
636
- const reservation = reservations[idx];
637
- if (reservation.status === 'paid') return res.json({ ok: true });
638
-
639
- reservation.status = 'paid';
640
- reservation.helloAssoOrderId = orderId;
641
- reservations[idx] = reservation;
642
-
643
- event.reservations = JSON.stringify(reservations);
644
- await db.setObject(getEventKey(eid), event);
645
-
646
- const settings = await getParsedSettings();
647
- const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
648
-
334
+ // API: items
335
+ router.get('/api/calendar-onekite/items', async (req, res) => {
649
336
  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,
654
- 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,
659
- });
337
+ const s = await getSettings();
338
+ if (!s.enabled) return res.json({ items: [] });
339
+ const items = await getItems(s);
340
+ res.json({ items });
660
341
  } catch (e) {
661
- console.warn('[calendar-onekite] email payment-confirmed error:', e.message);
342
+ res.status(500).json({ error: e.message });
662
343
  }
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
-
671
- /* ---------------- Widget ---------------- */
672
-
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: '',
679
344
  });
680
- return widgets;
681
- };
682
-
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;
690
-
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
- };
698
345
 
699
- Plugin.addAdminNavigation = function (header) {
700
- header.plugins.push({
701
- route: '/plugins/calendar-onekite',
702
- icon: 'fa fa-calendar',
703
- name: 'Calendar OneKite',
704
- });
705
- return header;
706
- };
707
-
708
- /* ---------------- Init ---------------- */
709
-
710
- Plugin.init = async function (params) {
711
- const { router, middleware } = params;
712
- appRef = params.app;
713
-
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' }));
717
-
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);
346
+ // API: create reservation (pending)
347
+ router.post('/api/calendar-onekite/reservations', middleware.authenticate, async (req, res) => {
348
+ try {
349
+ const s = await getSettings();
350
+ await assertRequesterPrivileges(req, s);
720
351
 
721
- // Admin page (ajaxify)
722
- mountApiBoth(router, 'get', '/admin/plugins/calendar-onekite', middleware.admin.checkPrivileges, renderAdminPage);
352
+ const { itemId, itemName, start, end, priceCents } = req.body || {};
353
+ if (!itemId || !itemName || !start || !end) {
354
+ return res.status(400).json({ error: 'missing-fields' });
355
+ }
723
356
 
724
- // Settings save
725
- mountApiBoth(router, 'put', '/admin/plugins/calendar-onekite', middleware.admin.checkPrivileges, adminSaveSettings);
357
+ const startMs = new Date(start).getTime();
358
+ const endMs = new Date(end).getTime();
359
+ if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) {
360
+ return res.status(400).json({ error: 'invalid-dates' });
361
+ }
726
362
 
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);
363
+ // Check overlap with approved OR pending (not expired) for same item
364
+ await cleanupExpiredPending(s);
365
+ const rangeObjs = await getReservationsInRange(new Date(startMs - 365*24*3600*1000).toISOString(), new Date(endMs + 365*24*3600*1000).toISOString());
366
+ const overlap = rangeObjs.some(o => {
367
+ if (!o) return false;
368
+ if (o.itemId !== String(itemId)) return false;
369
+ if (o.status !== 'approved' && o.status !== 'pending') return false;
370
+ const os = new Date(o.start).getTime();
371
+ const oe = new Date(o.end).getTime();
372
+ return os < endMs && oe > startMs;
373
+ });
374
+ if (overlap) {
375
+ return res.status(409).json({ error: 'overlap' });
376
+ }
732
377
 
733
- // Public inventory
734
- mountApiBoth(router, 'get', '/calendar/inventory', inventoryApi);
378
+ const id = await nextReservationId();
379
+ const requester = await user.getUserFields(req.uid, ['username', 'fullname']);
380
+ const requesterName = (requester && (requester.fullname || requester.username)) || 'User';
381
+
382
+ const expiresAt = nowMs() + (Math.max(1, s.holdMinutes) * 60_000);
383
+
384
+ const obj = {
385
+ id,
386
+ uid: String(req.uid),
387
+ requesterName,
388
+ itemId: String(itemId),
389
+ itemName: String(itemName),
390
+ start: new Date(startMs).toISOString(),
391
+ end: new Date(endMs).toISOString(),
392
+ status: 'pending',
393
+ createdAt: String(nowMs()),
394
+ expiresAt: String(expiresAt),
395
+ priceCents: String(parseInt(priceCents || '0', 10) || 0),
396
+ };
397
+
398
+ await db.setObject(`${RES_PREFIX}:${id}`, obj);
399
+ await db.sortedSetAdd(RES_ZSET_BY_START, startMs, id);
400
+
401
+ // Notify approvers
402
+ const approverGroups = splitGroups(s.approverGroups);
403
+ if (approverGroups.length) {
404
+ const subject = `[OneKite] Nouvelle réservation à valider (#${id})`;
405
+ const html = `
406
+ <p>Une nouvelle réservation est en attente de validation.</p>
407
+ <ul>
408
+ <li>ID: ${id}</li>
409
+ <li>Matériel: ${obj.itemName}</li>
410
+ <li>Début: ${obj.start}</li>
411
+ <li>Fin: ${obj.end}</li>
412
+ <li>Demandeur: ${requesterName}</li>
413
+ </ul>
414
+ <p>Connectez-vous à NodeBB et ouvrez le calendrier pour valider/refuser.</p>
415
+ `;
416
+ await sendEmailToGroups(s, subject, html, approverGroups);
417
+ }
735
418
 
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 });
419
+ res.json({ reservation: obj });
420
+ } catch (e) {
421
+ const status = e.message === 'not-allowed' ? 403 : 500;
422
+ res.status(status).json({ error: e.message });
745
423
  }
746
424
  });
747
425
 
748
- // Event CRUD
749
- mountApiBoth(router, 'post', '/calendar/event', middleware.ensureLoggedIn, async (req, res) => {
426
+ // API: approve reservation (creates checkout intent, sends payer link)
427
+ router.post('/api/calendar-onekite/reservations/:id/approve', middleware.authenticate, async (req, res) => {
750
428
  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
- });
429
+ const s = await getSettings();
430
+ await assertApproverPrivileges(req, s);
431
+
432
+ const id = req.params.id;
433
+ const key = `${RES_PREFIX}:${id}`;
434
+ const obj = await db.getObject(key);
435
+ if (!obj) return res.status(404).json({ error: 'not-found' });
436
+ if (obj.status !== 'pending') return res.status(400).json({ error: 'not-pending' });
437
+
438
+ // figure amount: from stored priceCents or from items list
439
+ let amountCents = parseInt(obj.priceCents || '0', 10) || 0;
440
+ if (!amountCents) {
441
+ const items = await getItems(s);
442
+ const match = (items || []).find(it => String(it.id || it.itemId || it.itemID) === String(obj.itemId));
443
+ // Try common fields for price
444
+ const price = match && (match.price || match.amount || match.unitPrice || match.totalAmount || match.publicAmount);
445
+ if (typeof price === 'number') amountCents = price;
446
+ if (!amountCents && match && match.price && typeof match.price.value === 'number') amountCents = match.price.value;
447
+ }
448
+ if (!amountCents) return res.status(400).json({ error: 'missing-amount' });
449
+
450
+ const { redirectUrl, checkoutIntentId } = await createCheckoutIntent(s, obj, amountCents);
451
+ if (!redirectUrl) return res.status(500).json({ error: 'helloasso-missing-redirectUrl' });
452
+
453
+ obj.status = 'approved';
454
+ obj.paymentUrl = redirectUrl;
455
+ obj.checkoutIntentId = checkoutIntentId ? String(checkoutIntentId) : '';
456
+ obj.approvedAt = String(nowMs());
457
+ obj.approvedBy = String(req.uid);
458
+
459
+ await db.setObject(key, obj);
460
+
461
+ // notify requester
462
+ const requester = await user.getUserFields(parseInt(obj.uid, 10), ['email', 'username', 'fullname']);
463
+ if (requester && requester.email) {
464
+ const subject = `[OneKite] Réservation validée (#${id}) - Paiement`;
465
+ const html = `
466
+ <p>Votre réservation a été validée ✅</p>
467
+ <ul>
468
+ <li>Matériel: ${obj.itemName}</li>
469
+ <li>Début: ${obj.start}</li>
470
+ <li>Fin: ${obj.end}</li>
471
+ </ul>
472
+ <p>Merci d'effectuer le paiement via ce lien :</p>
473
+ <p><a href="${redirectUrl}">${redirectUrl}</a></p>
474
+ `;
475
+ await emailer.send('default', requester.email, subject, html);
476
+ }
759
477
 
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);
766
- } catch (err) {
767
- res.status(500).json({ error: err.message });
478
+ res.json({ reservation: obj });
479
+ } catch (e) {
480
+ const status = e.message === 'not-allowed' ? 403 : 500;
481
+ res.status(status).json({ error: e.message });
768
482
  }
769
483
  });
770
484
 
771
- mountApiBoth(router, 'delete', '/calendar/event/:eid', middleware.ensureLoggedIn, async (req, res) => {
485
+ // API: reject reservation
486
+ router.post('/api/calendar-onekite/reservations/:id/reject', middleware.authenticate, async (req, res) => {
772
487
  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);
776
- res.json({ status: 'ok' });
777
- } catch (err) {
778
- res.status(500).json({ error: err.message });
488
+ const s = await getSettings();
489
+ await assertApproverPrivileges(req, s);
490
+
491
+ const id = req.params.id;
492
+ const key = `${RES_PREFIX}:${id}`;
493
+ const obj = await db.getObject(key);
494
+ if (!obj) return res.status(404).json({ error: 'not-found' });
495
+ if (obj.status !== 'pending') return res.status(400).json({ error: 'not-pending' });
496
+
497
+ obj.status = 'rejected';
498
+ obj.rejectedAt = String(nowMs());
499
+ obj.rejectedBy = String(req.uid);
500
+ await db.setObject(key, obj);
501
+
502
+ const requester = await user.getUserFields(parseInt(obj.uid, 10), ['email', 'username', 'fullname']);
503
+ if (requester && requester.email) {
504
+ const subject = `[OneKite] Réservation refusée (#${id})`;
505
+ const html = `
506
+ <p>Votre réservation a été refusée ❌</p>
507
+ <ul>
508
+ <li>Matériel: ${obj.itemName}</li>
509
+ <li>Début: ${obj.start}</li>
510
+ <li>Fin: ${obj.end}</li>
511
+ </ul>
512
+ `;
513
+ await emailer.send('default', requester.email, subject, html);
514
+ }
515
+
516
+ res.json({ reservation: obj });
517
+ } catch (e) {
518
+ const status = e.message === 'not-allowed' ? 403 : 500;
519
+ res.status(status).json({ error: e.message });
779
520
  }
780
521
  });
781
522
 
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]));
523
+ // Admin API: get/save settings
524
+ router.get('/api/admin/plugins/calendar-onekite', middleware.authenticate, middleware.adminsOnly, async (req, res) => {
525
+ const settings = await meta.settings.get(PLUGIN_NS);
526
+ res.json(settings);
527
+ });
787
528
 
788
- const event = await getEvent(req.params.eid);
789
- if (!event) return res.status(404).json({ error: 'Événement introuvable' });
529
+ router.post('/api/admin/plugins/calendar-onekite', middleware.authenticate, middleware.adminsOnly, async (req, res) => {
530
+ await meta.settings.set(PLUGIN_NS, req.body);
531
+ // invalidate caches when settings change
532
+ helloassoTokenCache = { token: null, expiresAt: 0 };
533
+ itemsCache = { items: null, expiresAt: 0 };
534
+ res.json({ ok: true });
535
+ });
790
536
 
791
- const bookingItemIds = (() => {
792
- try {
793
- const arr = JSON.parse(event.bookingItems || '[]');
794
- return Array.isArray(arr) ? arr.map(String) : [];
795
- } catch { return []; }
796
- })();
537
+ // Admin API: purge by year
538
+ router.post('/api/admin/plugins/calendar-onekite/purge', middleware.authenticate, middleware.adminsOnly, async (req, res) => {
539
+ const year = parseInt((req.body && req.body.year) || '0', 10);
540
+ if (!year || year < 1970 || year > 3000) return res.status(400).json({ error: 'invalid-year' });
797
541
 
798
- const bookingItems = bookingItemIds.map(id => invMap.get(String(id))).filter(Boolean);
542
+ const ids = await db.getSortedSetRange(RES_ZSET_BY_START, 0, 100000);
543
+ if (!ids.length) return res.json({ deleted: 0 });
799
544
 
800
- const reservations = (() => {
801
- try { return JSON.parse(event.reservations || '[]'); } catch { return []; }
802
- })();
545
+ const keys = ids.map(id => `${RES_PREFIX}:${id}`);
546
+ const objs = await db.getObjects(keys);
803
547
 
804
- res.json({ ...event, bookingItemIds, bookingItems, reservations });
805
- } catch (err) {
806
- res.status(500).json({ error: err.message });
548
+ const toDelete = [];
549
+ for (const obj of (objs || [])) {
550
+ if (!obj) continue;
551
+ const s = new Date(obj.start).getUTCFullYear();
552
+ if (s === year) toDelete.push(obj.id);
807
553
  }
808
- });
554
+ await Promise.all(toDelete.map(async (id) => {
555
+ await db.delete(`${RES_PREFIX}:${id}`);
556
+ await db.sortedSetRemove(RES_ZSET_BY_START, id);
557
+ }));
809
558
 
810
- // RSVP
811
- mountApiBoth(router, 'post', '/calendar/event/:eid/rsvp', middleware.ensureLoggedIn, async (req, res) => {
812
- 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);
817
- } catch (err) {
818
- res.status(500).json({ error: err.message });
819
- }
559
+ res.json({ deleted: toDelete.length });
820
560
  });
561
+ };
821
562
 
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 });
563
+ plugin.addAdminNavigation = async function (header) {
564
+ header.plugins.push({
565
+ route: '/plugins/calendar-onekite',
566
+ icon: 'fa-calendar',
567
+ name: 'Calendar OneKite',
827
568
  });
569
+ return header;
570
+ };
828
571
 
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 });
572
+ plugin.addPageRoute = async function (data) {
573
+ // ensure /calendar works as "page route" for theme router filter edge cases
574
+ data.router.get('/calendar', data.middleware.buildHeader, async (req, res) => {
575
+ res.render('calendar-onekite/calendar', {
576
+ title: 'Calendrier',
577
+ isLoggedIn: !!req.uid,
578
+ uid: req.uid || 0,
579
+ });
833
580
  });
834
-
835
- // Booking
836
- mountApiBoth(router, 'post', '/calendar/event/:eid/book', middleware.ensureLoggedIn, bookReservation);
837
-
838
- // My reservations
839
- mountApiBoth(router, 'get', '/calendar/my-reservations', middleware.ensureLoggedIn, myReservations);
840
-
841
- // HelloAsso webhook
842
- router.post('/api/calendar/helloasso/webhook', helloAssoWebhook);
843
- router.post('/api/v3/calendar/helloasso/webhook', helloAssoWebhook);
581
+ return data;
844
582
  };
845
583
 
846
- module.exports = Plugin;
584
+ module.exports = plugin;