nodebb-plugin-onekite-calendar 1.0.12 → 1.0.13

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/lib/api.js ADDED
@@ -0,0 +1,941 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const meta = require.main.require('./src/meta');
6
+ const emailer = require.main.require('./src/emailer');
7
+ const nconf = require.main.require('nconf');
8
+ const user = require.main.require('./src/user');
9
+ const groups = require.main.require('./src/groups');
10
+ const db = require.main.require('./src/database');
11
+ const logger = require.main.require('./src/logger');
12
+
13
+ const dbLayer = require('./db');
14
+
15
+ // Fast membership check without N calls to groups.isMember.
16
+ // NodeBB's groups.getUserGroups([uid]) returns an array (per uid) of group objects.
17
+ // We compare against both group slugs and names to be tolerant with older settings.
18
+ async function userInAnyGroup(uid, allowed) {
19
+ if (!uid || !Array.isArray(allowed) || !allowed.length) return false;
20
+ const ug = await groups.getUserGroups([uid]);
21
+ const list = (ug && ug[0]) ? ug[0] : [];
22
+ const seen = new Set();
23
+ for (const g of list) {
24
+ if (!g) continue;
25
+ if (g.slug) seen.add(String(g.slug));
26
+ if (g.name) seen.add(String(g.name));
27
+ if (g.groupName) seen.add(String(g.groupName));
28
+ if (g.displayName) seen.add(String(g.displayName));
29
+ }
30
+ return allowed.some(v => seen.has(String(v)));
31
+ }
32
+
33
+
34
+ function normalizeAllowedGroups(raw) {
35
+ if (!raw) return [];
36
+ if (Array.isArray(raw)) return raw.map(v => String(v).trim()).filter(Boolean);
37
+ const s = String(raw).trim();
38
+ if (!s) return [];
39
+ // Some admin UIs store JSON arrays as strings
40
+ if ((s.startsWith('[') && s.endsWith(']')) || (s.startsWith('"[') && s.endsWith(']"'))) {
41
+ try {
42
+ const parsed = JSON.parse(s.startsWith('"') ? JSON.parse(s) : s);
43
+ if (Array.isArray(parsed)) return parsed.map(v => String(v).trim()).filter(Boolean);
44
+ } catch (e) {}
45
+ }
46
+ return s.split(',').map(v => String(v).trim().replace(/^"+|"+$/g, '')).filter(Boolean);
47
+ }
48
+
49
+ // NOTE: Avoid per-group async checks (groups.isMember) when possible.
50
+
51
+
52
+ const helloasso = require('./helloasso');
53
+ const discord = require('./discord');
54
+
55
+ // Email helper: NodeBB's Emailer signature differs across versions.
56
+ // We try the common forms. Any failure is logged for debugging.
57
+ async function sendEmail(template, toEmail, subject, data) {
58
+ if (!toEmail) return;
59
+ try {
60
+ // NodeBB core signature (historically):
61
+ // Emailer.sendToEmail(template, email, language, params[, callback])
62
+ // Subject is not a positional arg; it must be injected (either by NodeBB itself
63
+ // or via filter:email.modify). We always pass it in params.subject.
64
+ const language = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
65
+ const params = Object.assign({}, data || {}, subject ? { subject } : {});
66
+ if (typeof emailer.sendToEmail === 'function') {
67
+ await emailer.sendToEmail(template, toEmail, language, params);
68
+ return;
69
+ }
70
+ // Fallback for older/unusual builds (rare)
71
+ if (typeof emailer.send === 'function') {
72
+ // Some builds accept (template, email, language, params)
73
+ if (emailer.send.length >= 4) {
74
+ await emailer.send(template, toEmail, language, params);
75
+ return;
76
+ }
77
+ // Some builds accept (template, email, params)
78
+ await emailer.send(template, toEmail, params);
79
+ }
80
+ } catch (err) {
81
+ // eslint-disable-next-line no-console
82
+ console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String(err) });
83
+ }
84
+ }
85
+
86
+ function normalizeBaseUrl(meta) {
87
+ // Prefer meta.config.url, fallback to nconf.get('url')
88
+ let base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
89
+ if (!base) {
90
+ base = String(nconf.get('url') || '').trim();
91
+ }
92
+ base = String(base || '').trim().replace(/\/$/, '');
93
+ // Ensure absolute with scheme
94
+ if (base && !/^https?:\/\//i.test(base)) {
95
+ base = `https://${base.replace(/^\/\//, '')}`;
96
+ }
97
+ return base;
98
+ }
99
+
100
+ function normalizeCallbackUrl(configured, meta) {
101
+ const base = normalizeBaseUrl(meta);
102
+ let url = (configured || '').trim();
103
+ if (!url) {
104
+ // Default webhook endpoint (recommended): namespaced under /plugins
105
+ url = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
106
+ }
107
+ if (url && url.startsWith('/') && base) {
108
+ url = `${base}${url}`;
109
+ }
110
+ // Ensure scheme for absolute URLs
111
+ if (url && !/^https?:\/\//i.test(url)) {
112
+ url = `https://${url.replace(/^\/\//, '')}`;
113
+ }
114
+ return url;
115
+ }
116
+
117
+ function normalizeReturnUrl(meta) {
118
+ const base = normalizeBaseUrl(meta);
119
+ return base ? `${base}/calendar` : '';
120
+ }
121
+
122
+
123
+ function overlap(aStart, aEnd, bStart, bEnd) {
124
+ return aStart < bEnd && bStart < aEnd;
125
+ }
126
+
127
+
128
+ function formatFR(tsOrIso) {
129
+ const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
130
+ const dd = String(d.getDate()).padStart(2, '0');
131
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
132
+ const yyyy = d.getFullYear();
133
+ return `${dd}/${mm}/${yyyy}`;
134
+ }
135
+
136
+ function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
137
+ const base = String(baseLabel || '').trim();
138
+ const items = Array.isArray(itemNames) ? itemNames.map((s) => String(s || '').trim()).filter(Boolean) : [];
139
+ const range = (start && end) ? ('Du ' + formatFR(start) + ' au ' + formatFR(end)) : '';
140
+
141
+ // IMPORTANT:
142
+ // On the public HelloAsso checkout page, line breaks are not always rendered consistently.
143
+ // Build a single-line label using bullet separators.
144
+ let out = '';
145
+ if (base) out += base;
146
+ if (items.length) out += (out ? ' — ' : '') + items.map((it) => '• ' + it).join(' ');
147
+ if (range) out += (out ? ' — ' : '') + range;
148
+
149
+ out = String(out || '').trim();
150
+ if (!out) out = 'Réservation matériel';
151
+
152
+ // HelloAsso constraint: itemName max 250 chars
153
+ if (out.length > 250) {
154
+ out = out.slice(0, 249).trimEnd() + '…';
155
+ }
156
+ return out;
157
+ }
158
+
159
+ function toTs(v) {
160
+ if (v === undefined || v === null || v === '') return NaN;
161
+ // Accept milliseconds timestamps passed as strings or numbers.
162
+ if (typeof v === 'number') return v;
163
+ const s = String(v).trim();
164
+ if (/^[0-9]+$/.test(s)) return parseInt(s, 10);
165
+
166
+ // IMPORTANT: Date-only strings like "2026-01-09" are interpreted as UTC by JS Date(),
167
+ // which can create 1h overlaps in Europe/Paris (and other TZs) between consecutive days.
168
+ // We treat date-only inputs as local-midnight by appending a time component.
169
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
170
+ const dLocal = new Date(s + 'T00:00:00');
171
+ return dLocal.getTime();
172
+ }
173
+
174
+ const d = new Date(s);
175
+ return d.getTime();
176
+ }
177
+
178
+ function yearFromTs(ts) {
179
+ const d = new Date(Number(ts));
180
+ return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
181
+ }
182
+
183
+ function autoCreatorGroupForYear(year) {
184
+ return `onekite-ffvl-${year}`;
185
+ }
186
+
187
+
188
+ // (removed) group slug/name resolving helpers: we now use userInAnyGroup() which
189
+ // matches both slugs and names and avoids extra DB lookups.
190
+
191
+
192
+ function autoFormSlugForYear(year) {
193
+ return `locations-materiel-${year}`;
194
+ }
195
+
196
+ async function canRequest(uid, settings, startTs) {
197
+ if (!uid) return false;
198
+
199
+ // Always allow administrators to create.
200
+ try {
201
+ if (await groups.isMember(uid, 'administrators')) return true;
202
+ } catch (e) {}
203
+
204
+ const year = yearFromTs(startTs);
205
+ const defaultGroup = autoCreatorGroupForYear(year);
206
+
207
+ // ACP may store group slugs as CSV string or array depending on NodeBB/admin UI.
208
+ // On some installs, the UI stores *names* rather than slugs; we accept both.
209
+ const raw = settings.creatorGroups ?? settings.allowedGroups ?? [];
210
+ const extraGroups = normalizeAllowedGroups(raw);
211
+
212
+ const allowed = [...new Set([defaultGroup, ...extraGroups].filter(Boolean))];
213
+ if (!allowed.length) return false;
214
+
215
+ // Fast path: compare against user's groups (slug + name).
216
+ try {
217
+ if (await userInAnyGroup(uid, allowed)) return true;
218
+ } catch (e) {
219
+ // ignore
220
+ }
221
+
222
+ // Fallback: try isMember on each allowed entry (slug on most installs)
223
+ for (const g of allowed) {
224
+ try {
225
+ if (await groups.isMember(uid, g)) return true;
226
+ } catch (err) {}
227
+ }
228
+ return false;
229
+ }
230
+
231
+ async function canValidate(uid, settings) {
232
+ // Always allow forum administrators (and global moderators) to validate,
233
+ // even if validatorGroups is empty.
234
+ try {
235
+ const isAdmin = await groups.isMember(uid, 'administrators');
236
+ if (isAdmin) return true;
237
+ } catch (e) {}
238
+
239
+ const allowed = normalizeAllowedGroups(settings.validatorGroups || '');
240
+ if (!allowed.length) return false;
241
+ if (await userInAnyGroup(uid, allowed)) return true;
242
+
243
+ return false;
244
+ }
245
+
246
+ async function canCreateSpecial(uid, settings) {
247
+ if (!uid) return false;
248
+ try {
249
+ const isAdmin = await groups.isMember(uid, 'administrators');
250
+ if (isAdmin) return true;
251
+ } catch (e) {}
252
+ const allowed = normalizeAllowedGroups(settings.specialCreatorGroups || '');
253
+ if (!allowed.length) return false;
254
+ if (await userInAnyGroup(uid, allowed)) return true;
255
+
256
+ return false;
257
+ }
258
+
259
+ async function canDeleteSpecial(uid, settings) {
260
+ if (!uid) return false;
261
+ try {
262
+ const isAdmin = await groups.isMember(uid, 'administrators');
263
+ if (isAdmin) return true;
264
+ } catch (e) {}
265
+ const allowed = normalizeAllowedGroups(settings.specialDeleterGroups || settings.specialCreatorGroups || '');
266
+ if (!allowed.length) return false;
267
+ if (await userInAnyGroup(uid, allowed)) return true;
268
+
269
+ return false;
270
+ }
271
+
272
+ function eventsFor(resv) {
273
+ const status = resv.status;
274
+ const icons = { pending: '⏳', awaiting_payment: '💳', paid: '✅' };
275
+ const colors = { pending: '#f39c12', awaiting_payment: '#d35400', paid: '#27ae60' };
276
+ const startIsoDate = new Date(parseInt(resv.start, 10)).toISOString().slice(0, 10);
277
+ const endIsoDate = new Date(parseInt(resv.end, 10)).toISOString().slice(0, 10);
278
+
279
+ const itemIds = Array.isArray(resv.itemIds) ? resv.itemIds : (resv.itemId ? [resv.itemId] : []);
280
+ const itemNames = Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : []);
281
+
282
+ // One line = one material: return one calendar event per item
283
+ const out = [];
284
+ const count = Math.max(itemIds.length, itemNames.length, 1);
285
+ for (let i = 0; i < count; i++) {
286
+ const itemId = String(itemIds[i] || itemIds[0] || resv.itemId || '');
287
+ const itemName = String(itemNames[i] || itemNames[0] || resv.itemName || itemId);
288
+ out.push({
289
+ // keep id unique per item for FullCalendar, but keep the real rid in extendedProps.rid
290
+ id: `${resv.rid}:${itemId || i}`,
291
+ title: `${icons[status] || ''} ${itemName}`.trim(),
292
+ backgroundColor: colors[status] || '#3498db',
293
+ borderColor: colors[status] || '#3498db',
294
+ textColor: '#ffffff',
295
+ allDay: true,
296
+ start: startIsoDate,
297
+ end: endIsoDate,
298
+ extendedProps: {
299
+ rid: resv.rid,
300
+ status,
301
+ uid: resv.uid,
302
+ approvedBy: resv.approvedBy || 0,
303
+ approvedByUsername: resv.approvedByUsername || '',
304
+ itemIds: itemIds.filter(Boolean),
305
+ itemNames: itemNames.filter(Boolean),
306
+ itemIdLine: itemId,
307
+ itemNameLine: itemName,
308
+ },
309
+ });
310
+ }
311
+ return out;
312
+ }
313
+
314
+ function eventsForSpecial(ev) {
315
+ const start = new Date(parseInt(ev.start, 10));
316
+ const end = new Date(parseInt(ev.end, 10));
317
+ const startIso = start.toISOString();
318
+ const endIso = end.toISOString();
319
+ return {
320
+ id: `special:${ev.eid}`,
321
+ title: `${ev.title || 'Évènement'}`.trim(),
322
+ allDay: false,
323
+ start: startIso,
324
+ end: endIso,
325
+ backgroundColor: '#8e44ad',
326
+ borderColor: '#8e44ad',
327
+ textColor: '#ffffff',
328
+ extendedProps: {
329
+ type: 'special',
330
+ eid: ev.eid,
331
+ title: ev.title || '',
332
+ notes: ev.notes || '',
333
+ pickupAddress: ev.address || '',
334
+ pickupLat: ev.lat || '',
335
+ pickupLon: ev.lon || '',
336
+ createdBy: ev.uid || 0,
337
+ username: ev.username || '',
338
+ },
339
+ };
340
+ }
341
+
342
+ const api = {};
343
+
344
+ function computeEtag(payload) {
345
+ // Weak ETag is fine here: it is only used to skip identical JSON payloads.
346
+ const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
347
+ return `W/"${hash}"`;
348
+ }
349
+
350
+ api.getEvents = async function (req, res) {
351
+ const startTs = toTs(req.query.start) || 0;
352
+ const endTs = toTs(req.query.end) || (Date.now() + 365 * 24 * 3600 * 1000);
353
+
354
+ const settings = await meta.settings.get('calendar-onekite');
355
+ const canMod = req.uid ? await canValidate(req.uid, settings) : false;
356
+ const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
357
+ const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
358
+
359
+ // Fetch a wider window because an event can start before the query range
360
+ // and still overlap.
361
+ const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
362
+ const ids = await dbLayer.listReservationIdsByStartRange(wideStart, endTs, 5000);
363
+ const out = [];
364
+ // Batch fetch = major perf win when there are many reservations.
365
+ const reservations = await dbLayer.getReservations(ids);
366
+ for (const r of (reservations || [])) {
367
+ if (!r) continue;
368
+ // Only show active statuses
369
+ if (!['pending', 'awaiting_payment', 'paid'].includes(r.status)) continue;
370
+ const rStart = parseInt(r.start, 10);
371
+ const rEnd = parseInt(r.end, 10);
372
+ if (!(rStart < endTs && startTs < rEnd)) continue; // overlap check
373
+ const evs = eventsFor(r);
374
+ for (const ev of evs) {
375
+ const p = ev.extendedProps || {};
376
+ const minimal = {
377
+ id: ev.id,
378
+ title: ev.title,
379
+ backgroundColor: ev.backgroundColor,
380
+ borderColor: ev.borderColor,
381
+ textColor: ev.textColor,
382
+ allDay: ev.allDay,
383
+ start: ev.start,
384
+ end: ev.end,
385
+ extendedProps: {
386
+ type: 'reservation',
387
+ rid: p.rid,
388
+ status: p.status,
389
+ uid: p.uid,
390
+ canModerate: canMod,
391
+ },
392
+ };
393
+ // Only expose username on the event list to owner/moderators.
394
+ if (r.username && ((req.uid && String(req.uid) === String(r.uid)) || canMod)) {
395
+ minimal.extendedProps.username = String(r.username);
396
+ }
397
+ // Let the UI decide if a "Payer" button might exist, without exposing the URL in list.
398
+ if (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl))) {
399
+ if ((req.uid && String(req.uid) === String(r.uid)) || canMod) {
400
+ minimal.extendedProps.hasPayment = true;
401
+ }
402
+ }
403
+ out.push(minimal);
404
+ }
405
+ }
406
+
407
+ // Special events
408
+ try {
409
+ const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
410
+ const specials = await dbLayer.getSpecialEvents(specialIds);
411
+ for (const sev of (specials || [])) {
412
+ if (!sev) continue;
413
+ const sStart = parseInt(sev.start, 10);
414
+ const sEnd = parseInt(sev.end, 10);
415
+ if (!(sStart < endTs && startTs < sEnd)) continue;
416
+ const full = eventsForSpecial(sev);
417
+ const minimal = {
418
+ id: full.id,
419
+ title: full.title,
420
+ allDay: full.allDay,
421
+ start: full.start,
422
+ end: full.end,
423
+ backgroundColor: full.backgroundColor,
424
+ borderColor: full.borderColor,
425
+ textColor: full.textColor,
426
+ extendedProps: {
427
+ type: 'special',
428
+ eid: sev.eid,
429
+ canCreateSpecial: canSpecialCreate,
430
+ canDeleteSpecial: canSpecialDelete,
431
+ },
432
+ };
433
+ if (sev.username && (canMod || canSpecialDelete || (req.uid && String(req.uid) === String(sev.uid)))) {
434
+ minimal.extendedProps.username = String(sev.username);
435
+ }
436
+ out.push(minimal);
437
+ }
438
+ } catch (e) {
439
+ // ignore
440
+ }
441
+
442
+ // Stable ordering -> stable ETag
443
+ out.sort((a, b) => {
444
+ const as = String(a.start || '');
445
+ const bs = String(b.start || '');
446
+ if (as !== bs) return as < bs ? -1 : 1;
447
+ const ai = String(a.id || '');
448
+ const bi = String(b.id || '');
449
+ return ai < bi ? -1 : ai > bi ? 1 : 0;
450
+ });
451
+
452
+ const etag = computeEtag(out);
453
+ res.setHeader('ETag', etag);
454
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
455
+ if (String(req.headers['if-none-match'] || '') === etag) {
456
+ return res.status(304).end();
457
+ }
458
+
459
+ return res.json(out);
460
+ };
461
+
462
+ api.getReservationDetails = async function (req, res) {
463
+ const uid = req.uid;
464
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
465
+
466
+ const settings = await meta.settings.get('calendar-onekite');
467
+ const canMod = await canValidate(uid, settings);
468
+
469
+ const rid = String(req.params.rid || '').trim();
470
+ if (!rid) return res.status(400).json({ error: 'missing-rid' });
471
+ const r = await dbLayer.getReservation(rid);
472
+ if (!r) return res.status(404).json({ error: 'not-found' });
473
+
474
+ const isOwner = String(r.uid) === String(uid);
475
+ if (!isOwner && !canMod) return res.status(403).json({ error: 'not-allowed' });
476
+
477
+ const out = {
478
+ rid: r.rid,
479
+ status: r.status,
480
+ uid: r.uid,
481
+ username: r.username || '',
482
+ itemNames: Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []),
483
+ itemIds: Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : []),
484
+ start: r.start,
485
+ end: r.end,
486
+ approvedByUsername: r.approvedByUsername || '',
487
+ pickupAddress: r.pickupAddress || '',
488
+ pickupTime: r.pickupTime || '',
489
+ pickupLat: r.pickupLat || '',
490
+ pickupLon: r.pickupLon || '',
491
+ notes: r.notes || '',
492
+ refusedReason: r.refusedReason || '',
493
+ total: r.total || 0,
494
+ canModerate: canMod,
495
+ };
496
+
497
+ if (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl))) {
498
+ out.paymentUrl = String(r.paymentUrl);
499
+ }
500
+
501
+ return res.json(out);
502
+ };
503
+
504
+ api.getSpecialEventDetails = async function (req, res) {
505
+ const uid = req.uid;
506
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
507
+
508
+ const settings = await meta.settings.get('calendar-onekite');
509
+ const canMod = await canValidate(uid, settings);
510
+ const canSpecialDelete = await canDeleteSpecial(uid, settings);
511
+
512
+ const eid = String(req.params.eid || '').trim();
513
+ if (!eid) return res.status(400).json({ error: 'missing-eid' });
514
+ const ev = await dbLayer.getSpecialEvent(eid);
515
+ if (!ev) return res.status(404).json({ error: 'not-found' });
516
+
517
+ // Anyone who can see the calendar can view special events, but creator username
518
+ // is only visible to moderators/allowed users or the creator.
519
+ const out = {
520
+ eid: ev.eid,
521
+ title: ev.title || '',
522
+ start: ev.start,
523
+ end: ev.end,
524
+ address: ev.address || '',
525
+ lat: ev.lat || '',
526
+ lon: ev.lon || '',
527
+ notes: ev.notes || '',
528
+ canDeleteSpecial: canSpecialDelete,
529
+ };
530
+ if (ev.username && (canMod || canSpecialDelete || (uid && String(uid) === String(ev.uid)))) {
531
+ out.username = String(ev.username);
532
+ }
533
+ return res.json(out);
534
+ };
535
+
536
+ api.getCapabilities = async function (req, res) {
537
+ const settings = await meta.settings.get('calendar-onekite');
538
+ const uid = req.uid || 0;
539
+ const canMod = uid ? await canValidate(uid, settings) : false;
540
+ res.json({
541
+ canModerate: canMod,
542
+ canCreateSpecial: uid ? await canCreateSpecial(uid, settings) : false,
543
+ canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings) : false,
544
+ });
545
+ };
546
+
547
+ api.createSpecialEvent = async function (req, res) {
548
+ const settings = await meta.settings.get('calendar-onekite');
549
+ if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
550
+ const ok = await canCreateSpecial(req.uid, settings);
551
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
552
+
553
+ const title = String((req.body && req.body.title) || '').trim() || 'Évènement';
554
+ const startTs = toTs(req.body && req.body.start);
555
+ const endTs = toTs(req.body && req.body.end);
556
+ if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || !(startTs < endTs)) {
557
+ return res.status(400).json({ error: 'bad-dates' });
558
+ }
559
+ const address = String((req.body && req.body.address) || '').trim();
560
+ const notes = String((req.body && req.body.notes) || '').trim();
561
+ const lat = String((req.body && req.body.lat) || '').trim();
562
+ const lon = String((req.body && req.body.lon) || '').trim();
563
+
564
+ const u = await user.getUserFields(req.uid, ['username']);
565
+ const eid = crypto.randomUUID();
566
+ const ev = {
567
+ eid,
568
+ title,
569
+ start: String(startTs),
570
+ end: String(endTs),
571
+ address,
572
+ notes,
573
+ lat,
574
+ lon,
575
+ uid: String(req.uid),
576
+ username: u && u.username ? String(u.username) : '',
577
+ createdAt: String(Date.now()),
578
+ };
579
+ await dbLayer.saveSpecialEvent(ev);
580
+ res.json({ ok: true, eid });
581
+ };
582
+
583
+ api.deleteSpecialEvent = async function (req, res) {
584
+ const settings = await meta.settings.get('calendar-onekite');
585
+ if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
586
+ const ok = await canDeleteSpecial(req.uid, settings);
587
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
588
+ const eid = String(req.params.eid || '').replace(/^special:/, '').trim();
589
+ if (!eid) return res.status(400).json({ error: 'bad-id' });
590
+ await dbLayer.removeSpecialEvent(eid);
591
+ res.json({ ok: true });
592
+ };
593
+
594
+ api.getItems = async function (req, res) {
595
+ const settings = await meta.settings.get('calendar-onekite');
596
+
597
+ const env = settings.helloassoEnv || 'prod';
598
+ const token = await helloasso.getAccessToken({
599
+ env,
600
+ clientId: settings.helloassoClientId,
601
+ clientSecret: settings.helloassoClientSecret,
602
+ });
603
+ if (!token) {
604
+
605
+ }
606
+
607
+ if (!token) {
608
+ return res.json([]);
609
+ }
610
+
611
+ // Important: the /items endpoint on HelloAsso lists *sold items*.
612
+ // For a shop catalog, use the /public form endpoint and extract the catalog.
613
+ const year = new Date().getFullYear();
614
+ const { items: catalog } = await helloasso.listCatalogItems({
615
+ env,
616
+ token,
617
+ organizationSlug: settings.helloassoOrganizationSlug,
618
+ formType: settings.helloassoFormType,
619
+ // Form slug is derived from the year
620
+ formSlug: autoFormSlugForYear(year),
621
+ });
622
+
623
+ const normalized = (catalog || []).map((it) => ({
624
+ id: it.id,
625
+ name: it.name,
626
+ price: typeof it.price === 'number' ? it.price : 0,
627
+ })).filter(it => it.id && it.name);
628
+
629
+ res.json(normalized);
630
+ };
631
+
632
+ api.createReservation = async function (req, res) {
633
+ const uid = req.uid;
634
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
635
+
636
+ const settings = await meta.settings.get('calendar-onekite');
637
+ const startPreview = toTs(req.body.start);
638
+ const ok = await canRequest(uid, settings, startPreview);
639
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
640
+
641
+ const start = parseInt(toTs(req.body.start), 10);
642
+ const end = parseInt(toTs(req.body.end), 10);
643
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
644
+ return res.status(400).json({ error: "bad-dates" });
645
+ }
646
+
647
+ // Support both legacy single itemId and new itemIds[] payload
648
+ const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds.map(String) : ((req.body.itemId ? [String(req.body.itemId)] : []));
649
+ const itemNames = Array.isArray(req.body.itemNames) ? req.body.itemNames.map(String) : (req.body.itemName ? [String(req.body.itemName)] : []);
650
+
651
+ const total = typeof req.body.total === 'number' ? req.body.total : parseFloat(String(req.body.total || '0'));
652
+
653
+ if (!start || !end || !itemIds.length) {
654
+ return res.status(400).json({ error: 'missing-fields' });
655
+ }
656
+
657
+ // Prevent double booking: block if any selected item overlaps with an active reservation
658
+ const blocking = new Set(['pending', 'awaiting_payment', 'paid']);
659
+ const wideStart2 = Math.max(0, start - 366 * 24 * 3600 * 1000);
660
+ const candidateIds = await dbLayer.listReservationIdsByStartRange(wideStart2, end, 5000);
661
+ const conflicts = [];
662
+ const existingRows = await dbLayer.getReservations(candidateIds);
663
+ for (const existing of (existingRows || [])) {
664
+ if (!existing || !blocking.has(existing.status)) continue;
665
+ const exStart = parseInt(existing.start, 10);
666
+ const exEnd = parseInt(existing.end, 10);
667
+ if (!(exStart < end && start < exEnd)) continue;
668
+ const exItemIds = Array.isArray(existing.itemIds) ? existing.itemIds : (existing.itemId ? [existing.itemId] : []);
669
+ const shared = exItemIds.filter(x => itemIds.includes(String(x)));
670
+ if (shared.length) {
671
+ conflicts.push({ rid: existing.rid, itemIds: shared, status: existing.status });
672
+ }
673
+ }
674
+ if (conflicts.length) {
675
+ return res.status(409).json({ error: 'conflict', conflicts });
676
+ }
677
+
678
+ const now = Date.now();
679
+ const rid = crypto.randomUUID();
680
+
681
+ // Snapshot username for display in calendar popups
682
+ let username = null;
683
+ try {
684
+ username = await user.getUserField(uid, 'username');
685
+ } catch (e) {}
686
+
687
+ const resv = {
688
+ rid,
689
+ uid,
690
+ username: username || null,
691
+ itemIds,
692
+ itemNames: itemNames.length ? itemNames : itemIds,
693
+ // keep legacy fields for backward compatibility
694
+ itemId: itemIds[0],
695
+ itemName: (itemNames[0] || itemIds[0]),
696
+ start,
697
+ end,
698
+ status: 'pending',
699
+ createdAt: now,
700
+ total: isNaN(total) ? 0 : total,
701
+ };
702
+
703
+ // Save
704
+ await dbLayer.saveReservation(resv);
705
+
706
+ // Notify groups by email (NodeBB emailer config)
707
+ try {
708
+ const notifyGroups = (settings.notifyGroups || '').split(',').map(s => s.trim()).filter(Boolean);
709
+ if (notifyGroups.length) {
710
+ const requester = await user.getUserFields(uid, ['username', 'email']);
711
+ const itemsLabel = (resv.itemNames || []).join(', ');
712
+ for (const g of notifyGroups) {
713
+ const members = await groups.getMembers(g, 0, -1);
714
+ const uids = Array.isArray(members) ? members : [];
715
+
716
+ // Batch fetch user email/username when supported by this NodeBB version.
717
+ let usersData = [];
718
+ try {
719
+ if (typeof user.getUsersFields === 'function') {
720
+ usersData = await user.getUsersFields(uids, ['username', 'email']);
721
+ } else {
722
+ usersData = await Promise.all(uids.map(async (memberUid) => {
723
+ try { return await user.getUserFields(memberUid, ['username', 'email']); }
724
+ catch (e) { return null; }
725
+ }));
726
+ }
727
+ } catch (e) {
728
+ usersData = [];
729
+ }
730
+
731
+ for (const md of (usersData || [])) {
732
+ if (md && md.email) {
733
+ await sendEmail('calendar-onekite_pending', md.email, 'Location matériel - Demande de réservation', {
734
+ username: md.username,
735
+ requester: requester.username,
736
+ itemName: itemsLabel,
737
+ itemNames: resv.itemNames || [],
738
+ dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
739
+ start: formatFR(start),
740
+ end: formatFR(end),
741
+ total: resv.total || 0,
742
+ });
743
+ }
744
+ }
745
+ }
746
+ }
747
+ } catch (e) {
748
+ console.warn('[calendar-onekite] Failed to send pending email', e && e.message ? e.message : e);
749
+ }
750
+
751
+ // Discord webhook (optional)
752
+ try {
753
+ await discord.notifyReservationRequested(settings, {
754
+ rid: resv.rid,
755
+ uid: resv.uid,
756
+ username: resv.username || '',
757
+ itemIds: resv.itemIds || [],
758
+ itemNames: resv.itemNames || [],
759
+ start: resv.start,
760
+ end: resv.end,
761
+ status: resv.status,
762
+ });
763
+ } catch (e) {}
764
+
765
+ res.json({ ok: true, rid });
766
+ };
767
+
768
+ // Validator actions (from calendar popup)
769
+ api.approveReservation = async function (req, res) {
770
+ const uid = req.uid;
771
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
772
+ const settings = await meta.settings.get('calendar-onekite');
773
+ const ok = await canValidate(uid, settings);
774
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
775
+
776
+ const rid = req.params.rid;
777
+ const r = await dbLayer.getReservation(rid);
778
+ if (!r) return res.status(404).json({ error: 'not-found' });
779
+ if (r.status !== 'pending') return res.status(400).json({ error: 'bad-status' });
780
+
781
+ r.status = 'awaiting_payment';
782
+ // Backwards compatible: old clients sent `adminNote` to describe the pickup place.
783
+ r.pickupAddress = String((req.body && (req.body.pickupAddress || req.body.adminNote)) || '').trim();
784
+ r.notes = String((req.body && req.body.notes) || '').trim();
785
+ r.pickupTime = String((req.body && req.body.pickupTime) || '').trim();
786
+ r.pickupLat = String((req.body && req.body.pickupLat) || '').trim();
787
+ r.pickupLon = String((req.body && req.body.pickupLon) || '').trim();
788
+ r.approvedAt = Date.now();
789
+ try {
790
+ const approver = await user.getUserFields(uid, ['username']);
791
+ r.approvedBy = uid;
792
+ r.approvedByUsername = approver && approver.username ? approver.username : '';
793
+ } catch (e) {
794
+ r.approvedBy = uid;
795
+ r.approvedByUsername = '';
796
+ }
797
+ // Create HelloAsso payment link on validation
798
+ try {
799
+ const settings2 = await meta.settings.get('calendar-onekite');
800
+ const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
801
+ const payer = await user.getUserFields(r.uid, ['email']);
802
+ const year = yearFromTs(r.start);
803
+ const intent = await helloasso.createCheckoutIntent({
804
+ env: settings2.helloassoEnv,
805
+ token,
806
+ organizationSlug: settings2.helloassoOrganizationSlug,
807
+ formType: settings2.helloassoFormType,
808
+ // Form slug is derived from the year of the reservation start date
809
+ formSlug: autoFormSlugForYear(year),
810
+ // r.total is stored as an estimated total in euros; HelloAsso expects cents.
811
+ totalAmount: (() => {
812
+ const cents = Math.max(0, Math.round((Number(r.total) || 0) * 100));
813
+ if (!cents) {
814
+ console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve API)', { rid, total: r.total });
815
+ }
816
+ return cents;
817
+ })(),
818
+ payerEmail: payer && payer.email ? payer.email : '',
819
+ // By default, point to the forum base url so the webhook hits this NodeBB instance.
820
+ // Can be overridden via ACP setting `helloassoCallbackUrl`.
821
+ callbackUrl: normalizeReturnUrl(meta),
822
+ webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl, meta),
823
+ itemName: buildHelloAssoItemName('', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
824
+ containsDonation: false,
825
+ metadata: {
826
+ reservationId: String(rid),
827
+ items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
828
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
829
+ },
830
+ });
831
+ const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
832
+ const checkoutIntentId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
833
+ if (paymentUrl) {
834
+ r.paymentUrl = paymentUrl;
835
+ if (checkoutIntentId) {
836
+ r.checkoutIntentId = checkoutIntentId;
837
+ }
838
+ } else {
839
+ console.warn('[calendar-onekite] HelloAsso payment link not created (approve API)', { rid });
840
+ }
841
+ } catch (e) {
842
+ // ignore payment link errors, admin can retry
843
+ }
844
+
845
+ await dbLayer.saveReservation(r);
846
+
847
+ // Email requester
848
+ const requester = await user.getUserFields(r.uid, ['username', 'email']);
849
+ if (requester && requester.email) {
850
+ const latNum = Number(r.pickupLat);
851
+ const lonNum = Number(r.pickupLon);
852
+ const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
853
+ ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
854
+ : '';
855
+ await sendEmail('calendar-onekite_approved', requester.email, 'Location matériel - Réservation validée', {
856
+ username: requester.username,
857
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
858
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
859
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
860
+ start: formatFR(r.start),
861
+ end: formatFR(r.end),
862
+ pickupAddress: r.pickupAddress || '',
863
+ notes: r.notes || '',
864
+ pickupTime: r.pickupTime || '',
865
+ pickupLat: r.pickupLat || '',
866
+ pickupLon: r.pickupLon || '',
867
+ mapUrl,
868
+ paymentUrl: r.paymentUrl || '',
869
+ validatedBy: r.approvedByUsername || '',
870
+ validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
871
+ });
872
+ }
873
+ return res.json({ ok: true });
874
+ };
875
+
876
+ api.refuseReservation = async function (req, res) {
877
+ const uid = req.uid;
878
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
879
+ const settings = await meta.settings.get('calendar-onekite');
880
+ const ok = await canValidate(uid, settings);
881
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
882
+
883
+ const rid = req.params.rid;
884
+ const r = await dbLayer.getReservation(rid);
885
+ if (!r) return res.status(404).json({ error: 'not-found' });
886
+
887
+ r.status = 'refused';
888
+ r.refusedAt = Date.now();
889
+ r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
890
+ await dbLayer.saveReservation(r);
891
+
892
+ const requester = await user.getUserFields(r.uid, ['username', 'email']);
893
+ if (requester && requester.email) {
894
+ await sendEmail('calendar-onekite_refused', requester.email, 'Location matériel - Demande de réservation', {
895
+ username: requester.username,
896
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
897
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
898
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
899
+ start: formatFR(r.start),
900
+ end: formatFR(r.end),
901
+ refusedReason: r.refusedReason || '',
902
+ });
903
+ }
904
+
905
+ return res.json({ ok: true });
906
+ };
907
+
908
+
909
+
910
+ api.cancelReservation = async function (req, res) {
911
+ const uid = req.uid;
912
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
913
+
914
+ const settings = await meta.settings.get('calendar-onekite');
915
+ const rid = String(req.params.rid || '').trim();
916
+ if (!rid) return res.status(400).json({ error: 'missing-rid' });
917
+
918
+ const r = await dbLayer.getReservation(rid);
919
+ if (!r) return res.status(404).json({ error: 'not-found' });
920
+
921
+ const canMod = await canValidate(uid, settings);
922
+ const isOwner = String(r.uid) === String(uid);
923
+
924
+ // Owner can cancel their own reservation only while payment is not completed.
925
+ // Validators/admins can cancel any reservation.
926
+ if (!isOwner && !canMod) return res.status(403).json({ error: 'not-allowed' });
927
+
928
+ const isPaid = String(r.status) === 'paid';
929
+ if (isOwner && isPaid) return res.status(400).json({ error: 'cannot-cancel-paid' });
930
+
931
+ if (r.status === 'cancelled') return res.json({ ok: true, status: 'cancelled' });
932
+
933
+ r.status = 'cancelled';
934
+ r.cancelledAt = Date.now();
935
+ r.cancelledBy = uid;
936
+
937
+ await dbLayer.saveReservation(r);
938
+ return res.json({ ok: true, status: 'cancelled' });
939
+ };
940
+
941
+ module.exports = api;