nodebb-plugin-calendar-onekite 2.2.0 → 10.0.12

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