nodebb-plugin-calendar-onekite 2.2.0 → 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,858 +1,584 @@
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');
10
+ const helpers = require.main.require('./src/controllers/helpers');
11
+ const privileges = require.main.require('./src/privileges');
30
12
 
31
- const Settings = meta.settings;
32
- const helloAsso = require('./helloasso');
13
+ const plugin = {};
33
14
 
34
- const Plugin = {};
35
- let appRef = null;
15
+ const PLUGIN_NS = 'calendar-onekite';
16
+ const RES_PREFIX = `calendar-onekite:reservation`;
17
+ const RES_ZSET_BY_START = `calendar-onekite:reservations:byStart`;
36
18
 
37
- const SETTINGS_KEY = 'calendar-onekite';
38
- const EVENTS_SET_KEY = 'calendar:events:start';
39
- const EVENT_KEY_PREFIX = 'calendar:event:';
19
+ let helloassoTokenCache = { token: null, expiresAt: 0 };
20
+ let itemsCache = { items: null, expiresAt: 0 };
40
21
 
41
- function getEventKey(eid) {
42
- return EVENT_KEY_PREFIX + eid;
22
+ function nowMs() {
23
+ return Date.now();
43
24
  }
44
25
 
45
- async function nextReservationId() {
46
- const rid = await db.incrObjectField('global', 'nextCalendarRid');
47
- return String(rid);
26
+ function safeJsonParse(s, fallback) {
27
+ try { return JSON.parse(s); } catch (e) { return fallback; }
48
28
  }
49
29
 
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
-
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);
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;
60
50
  }
61
51
 
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
- };
98
-
99
- await db.setObject(key, eventObj);
100
- await db.sortedSetAdd(EVENTS_SET_KEY, startTs, eid);
101
- return eventObj;
52
+ function apiBase(env) {
53
+ return env === 'prod' ? 'https://api.helloasso.com' : 'https://api.helloasso-sandbox.com';
102
54
  }
103
55
 
104
- async function getEvent(eid) {
105
- return db.getObject(getEventKey(eid));
106
- }
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
+ }
107
65
 
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
- };
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
+ });
133
73
 
134
- await db.setObject(key, updated);
135
- await db.sortedSetAdd(EVENTS_SET_KEY, startTs, eid);
136
- return updated;
137
- }
74
+ const res = await axios.post(url, body.toString(), {
75
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
+ timeout: 15_000,
77
+ });
138
78
 
139
- async function deleteEvent(eid) {
140
- await db.sortedSetRemove(EVENTS_SET_KEY, eid);
141
- 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;
142
84
  }
143
85
 
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 [];
86
+ async function fetchItemsFromHelloAsso(settings) {
87
+ const baseUrl = apiBase(settings.helloassoEnv);
88
+ const token = await getHelloAssoToken(settings);
149
89
 
150
- const keys = eids.map(eid => getEventKey(eid));
151
- const events = await db.getObjects(keys);
90
+ if (!settings.helloassoOrganizationSlug || !settings.helloassoFormType || !settings.helloassoFormSlug) {
91
+ throw new Error('HelloAsso organizationSlug/formType/formSlug not configured');
92
+ }
152
93
 
153
- return (events || []).filter(Boolean).map(ev => ({
154
- ...ev,
155
- bookingItems: JSON.parse(ev.bookingItems || '[]'),
156
- }));
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;
157
102
  }
158
103
 
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 [];
104
+ async function getItems(settings) {
105
+ const cacheOk = itemsCache.items && itemsCache.expiresAt > nowMs();
106
+ if (cacheOk) return itemsCache.items;
163
107
 
164
- const keys = eids.map(eid => getEventKey(eid));
165
- const events = await db.getObjects(keys);
166
- return (events || []).filter(Boolean);
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;
167
112
  }
168
113
 
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;
178
-
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());
189
-
190
- return groups.some(g => allowedSet.has(g));
114
+ function splitGroups(s) {
115
+ return (s || '')
116
+ .split(',')
117
+ .map(x => x.trim())
118
+ .filter(Boolean);
191
119
  }
192
120
 
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());
210
-
211
- return groups.some(g => allowedSet.has(g));
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;
212
130
  }
213
131
 
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
- };
247
-
248
- await db.setObject(key, updated);
249
- return updated;
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;
138
+ }
139
+ const ok = await isUserInAnyGroup(req.uid, requesterGroups);
140
+ if (!ok) throw new Error('not-allowed');
141
+ return true;
250
142
  }
251
143
 
252
- /* -----------------------------
253
- * Pricing
254
- * ---------------------------- */
255
-
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;
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;
260
149
 
261
- const unit = Number(item.price || 0);
262
- const days = Number(reservation.days || 1);
263
- 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;
264
154
  }
265
155
 
266
- /* -----------------------------
267
- * Renderers (pages)
268
- * ---------------------------- */
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);
169
+ }
170
+ }
171
+ if (!toDelete.length) return;
269
172
 
270
- function renderCalendarPage(req, res) {
271
- res.render('calendar', { title: 'Calendrier' });
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
+ }));
272
177
  }
273
178
 
274
- function renderMyReservationsPage(req, res) {
275
- res.render('calendar-my-reservations', { title: 'Mes réservations' });
179
+ async function nextReservationId() {
180
+ const n = await db.incrObjectField(`calendar-onekite:ids`, 'reservation');
181
+ return String(n);
276
182
  }
277
183
 
278
- function renderPlanningPage(req, res) {
279
- res.render('admin/calendar-planning', { title: 'Planning des réservations' });
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
+ };
280
202
  }
281
203
 
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);
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);
210
+
211
+ const start = startISO ? new Date(startISO).getTime() : -Infinity;
212
+ const end = endISO ? new Date(endISO).getTime() : Infinity;
213
+
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);
295
222
  }
223
+ return out;
296
224
  }
297
225
 
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);
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); });
231
+ }
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);
241
+ }
242
+ }));
312
243
 
313
- // Admin planning page
314
- router.get('/admin/calendar/planning', middleware.admin.buildHeader, renderPlanningPage);
315
- router.get('/api/admin/calendar/planning/page', renderPlanningPage);
244
+ // Send to extra emails
245
+ await Promise.all(extraEmails.map(async (mail) => {
246
+ await emailer.send('default', mail, subject, html);
247
+ }));
248
+ }
316
249
 
317
- /* -----------------------------
318
- * Events API
319
- * ---------------------------- */
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
+ };
320
286
 
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
- }
287
+ const res = await axios.post(url, body, {
288
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
289
+ timeout: 15_000,
330
290
  });
331
291
 
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
- });
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
+ }
342
298
 
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
- });
299
+ plugin.init = async function (params) {
300
+ const { router, middleware } = params;
301
+ const settings = await getSettings();
354
302
 
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 });
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');
364
307
  }
308
+ const isLoggedIn = !!req.uid;
309
+ res.render('calendar-onekite/calendar', {
310
+ title: 'Calendrier',
311
+ isLoggedIn,
312
+ uid: req.uid || 0,
313
+ });
365
314
  });
366
315
 
367
- router.get('/api/calendar/event/:eid', async (req, res) => {
316
+ // API: events
317
+ router.get('/api/calendar-onekite/events', async (req, res) => {
368
318
  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 });
319
+ const s = await getSettings();
320
+ if (!s.enabled) return res.json({ events: [] });
321
+
322
+ await cleanupExpiredPending(s);
323
+
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 });
329
+ } catch (e) {
330
+ res.status(500).json({ error: e.message });
377
331
  }
378
332
  });
379
333
 
380
- /* -----------------------------
381
- * RSVP
382
- * ---------------------------- */
383
-
384
- router.post('/api/calendar/event/:eid/rsvp', middleware.ensureLoggedIn, async (req, res) => {
334
+ // API: items
335
+ router.get('/api/calendar-onekite/items', async (req, res) => {
385
336
  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 });
337
+ const s = await getSettings();
338
+ if (!s.enabled) return res.json({ items: [] });
339
+ const items = await getItems(s);
340
+ res.json({ items });
341
+ } catch (e) {
342
+ res.status(500).json({ error: e.message });
393
343
  }
394
344
  });
395
345
 
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) => {
346
+ // API: create reservation (pending)
347
+ router.post('/api/calendar-onekite/reservations', middleware.authenticate, async (req, res) => {
417
348
  try {
418
- const uid = req.user?.uid || 0;
419
- const eid = req.params.eid;
420
- const { itemId, quantity, dateStart, dateEnd } = req.body;
349
+ const s = await getSettings();
350
+ await assertRequesterPrivileges(req, s);
421
351
 
422
- if (!await userCanBook(uid)) {
423
- return res.status(403).json({ error: 'Vous n’êtes pas autorisé à réserver du matériel.' });
424
- }
425
- if (!dateStart || !dateEnd) {
426
- return res.status(400).json({ error: 'Dates de début et de fin obligatoires.' });
427
- }
428
- if (String(dateEnd) < String(dateStart)) {
429
- return res.status(400).json({ error: 'La date de fin doit être ≥ la date de début.' });
352
+ const { itemId, itemName, start, end, priceCents } = req.body || {};
353
+ if (!itemId || !itemName || !start || !end) {
354
+ return res.status(400).json({ error: 'missing-fields' });
430
355
  }
431
356
 
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
-
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);
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
+ }
454
362
 
455
- return !(endN < startR || startN > endR);
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;
456
373
  });
374
+ if (overlap) {
375
+ return res.status(409).json({ error: 'overlap' });
376
+ }
457
377
 
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.' });
378
+ const id = await nextReservationId();
379
+ const requester = await user.getUserFields(req.uid, ['username', 'fullname']);
380
+ const requesterName = (requester && (requester.fullname || requester.username)) || 'User';
461
381
 
462
- const rid = await nextReservationId();
463
- const now = Date.now();
464
- const nbDays = daysBetween(dateStart, dateEnd);
382
+ const expiresAt = nowMs() + (Math.max(1, s.holdMinutes) * 60_000);
465
383
 
466
- const reservation = {
467
- rid,
468
- eid: String(eid),
469
- uid: String(uid),
384
+ const obj = {
385
+ id,
386
+ uid: String(req.uid),
387
+ requesterName,
470
388
  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 || ''),
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),
479
396
  };
480
397
 
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);
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);
502
417
  }
503
418
 
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 });
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 });
511
423
  }
512
424
  });
513
425
 
514
- /* -----------------------------
515
- * Admin: pending list
516
- * ---------------------------- */
517
-
518
- router.get('/api/admin/calendar/pending', middleware.admin.checkPrivileges, 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) => {
519
428
  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 });
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;
530
447
  }
531
-
532
- res.json(result);
533
- } catch (err) {
534
- res.status(500).json({ error: err.message });
535
- }
536
- });
537
-
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) => {
543
- try {
544
- const rid = req.params.rid;
545
- const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
546
-
547
- let targetEvent = null;
548
- let reservation = null;
549
-
550
- for (const eid of eids) {
551
- const event = await getEvent(eid);
552
- if (!event) continue;
553
-
554
- const reservations = JSON.parse(event.reservations || '[]');
555
- const r = reservations.find(rr => rr.rid === rid);
556
- if (r) {
557
- targetEvent = event;
558
- reservation = r;
559
- break;
560
- }
561
- }
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);
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);
599
476
  }
600
477
 
601
- res.json({ success: true, checkoutUrl });
602
- } catch (err) {
603
- 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 });
604
482
  }
605
483
  });
606
484
 
607
- /* -----------------------------
608
- * Admin: cancel reservation
609
- * ---------------------------- */
610
-
611
- router.post('/api/admin/calendar/reservation/:rid/cancel', middleware.admin.checkPrivileges, async (req, res) => {
485
+ // API: reject reservation
486
+ router.post('/api/calendar-onekite/reservations/:id/reject', middleware.authenticate, async (req, res) => {
612
487
  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
- }
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);
631
514
  }
632
515
 
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 });
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 });
637
520
  }
638
521
  });
639
522
 
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
- }
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);
707
527
  });
708
528
 
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
- }
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 });
724
535
  });
725
536
 
726
- /* -----------------------------
727
- * My reservations API
728
- * ---------------------------- */
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' });
729
541
 
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
- }
758
-
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
- });
542
+ const ids = await db.getSortedSetRange(RES_ZSET_BY_START, 0, 100000);
543
+ if (!ids.length) return res.json({ deleted: 0 });
765
544
 
766
- /* -----------------------------
767
- * Admin planning API (future reservations)
768
- * ---------------------------- */
545
+ const keys = ids.map(id => `${RES_PREFIX}:${id}`);
546
+ const objs = await db.getObjects(keys);
769
547
 
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 });
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
  }
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
+ }));
558
+
559
+ res.json({ deleted: toDelete.length });
808
560
  });
809
561
  };
810
562
 
811
- /* -----------------------------
812
- * ACP nav entry
813
- * ---------------------------- */
814
-
815
- Plugin.addAdminNavigation = function (header) {
563
+ plugin.addAdminNavigation = async function (header) {
816
564
  header.plugins.push({
817
- // NodeBB ACP will resolve this under /admin
818
565
  route: '/plugins/calendar-onekite',
819
- icon: 'fa fa-calendar',
566
+ icon: 'fa-calendar',
820
567
  name: 'Calendar OneKite',
821
568
  });
822
569
  return header;
823
570
  };
824
571
 
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: '',
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
+ });
835
580
  });
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
- }
581
+ return data;
856
582
  };
857
583
 
858
- module.exports = Plugin;
584
+ module.exports = plugin;