nodebb-plugin-equipment-calendar 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # NodeBB Equipment Calendar (v0.1.0)
2
+
3
+ Plugin NodeBB (testé pour NodeBB v4.7.x) pour gérer des réservations de matériel via un calendrier (FullCalendar),
4
+ avec workflow : demande -> validation par un groupe -> lien de paiement HelloAsso -> statut payé/validé.
5
+
6
+ ## Fonctionnement (sans "AJAX applicatif")
7
+ - La page calendrier est rendue côté serveur avec les évènements de la période demandée.
8
+ - Les actions (création, validation, refus) sont des POST classiques, suivis d'une redirection.
9
+ - FullCalendar est utilisé en mode "events inline" (pas de feed JSON automatique).
10
+
11
+ ## Installation
12
+ ```bash
13
+ cd /path/to/nodebb
14
+ npm install /path/to/nodebb-plugin-equipment-calendar
15
+ ./nodebb build
16
+ ./nodebb restart
17
+ ```
18
+
19
+ ## Configuration
20
+ Dans l'ACP : Plugins -> Equipment Calendar
21
+
22
+ - Groupes autorisés à créer une demande
23
+ - Groupe validateur
24
+ - Groupe notifié
25
+ - Matériel (JSON)
26
+ - Paramètres HelloAsso (clientId, clientSecret, organizationSlug, returnUrl, webhookSecret, etc.)
27
+
28
+ ## Webhook HelloAsso
29
+ Déclare l'URL :
30
+ `https://<ton-forum>/equipment/webhook/helloasso`
31
+
32
+ Le plugin vérifie la signature si `webhookSecret` est renseigné (exemple basique).
33
+
34
+ ## Remarques
35
+ - Ce plugin est un squelette complet mais générique : adapte la logique de paiement HelloAsso selon ton besoin exact
36
+ (type de checkout, itemization, montant, etc.).
37
+ - Pour un contrôle d'overlap strict : le plugin empêche les réservations qui chevauchent (même item) pour les statuts bloquants.
package/library.js ADDED
@@ -0,0 +1,649 @@
1
+ 'use strict';
2
+
3
+ const db = require.main.require('./src/database');
4
+ const meta = require.main.require('./src/meta');
5
+ const groups = require.main.require('./src/groups');
6
+ const user = require.main.require('./src/user');
7
+ const notifications = require.main.require('./src/notifications');
8
+ const Emailer = require.main.require('./src/emailer');
9
+ const routeHelpers = require.main.require('./src/routes/helpers');
10
+ const middleware = require.main.require('./src/middleware');
11
+ const helpers = require.main.require('./src/controllers/helpers');
12
+ const nconf = require.main.require('nconf');
13
+
14
+ const axios = require('axios');
15
+ const { DateTime } = require('luxon');
16
+ const { v4: uuidv4 } = require('uuid');
17
+ const crypto = require('crypto');
18
+
19
+ const plugin = {};
20
+ const SETTINGS_KEY = 'equipmentCalendar';
21
+
22
+ const DEFAULT_SETTINGS = {
23
+ creatorGroups: 'registered-users',
24
+ approverGroup: 'administrators',
25
+ notifyGroup: 'administrators',
26
+ // JSON array of items: [{ "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true }]
27
+ itemsJson: '[]',
28
+ // HelloAsso
29
+ ha_clientId: '',
30
+ ha_clientSecret: '',
31
+ ha_organizationSlug: '',
32
+ ha_returnUrl: '',
33
+ ha_webhookSecret: '',
34
+ // calendar
35
+ defaultView: 'dayGridMonth',
36
+ timezone: 'Europe/Paris',
37
+ // privacy
38
+ showRequesterToAll: '0', // 0/1
39
+ };
40
+
41
+ function parseItems(itemsJson) {
42
+ try {
43
+ const arr = JSON.parse(itemsJson || '[]');
44
+ if (!Array.isArray(arr)) return [];
45
+ return arr.map(it => ({
46
+ id: String(it.id || '').trim(),
47
+ name: String(it.name || '').trim(),
48
+ priceCents: Number(it.priceCents || 0),
49
+ location: String(it.location || '').trim(),
50
+ active: it.active !== false,
51
+ })).filter(it => it.id && it.name);
52
+ } catch (e) {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ async function getSettings() {
58
+ const settings = await meta.settings.get(SETTINGS_KEY);
59
+ return { ...DEFAULT_SETTINGS, ...(settings || {}) };
60
+ }
61
+
62
+ // --- Data layer ---
63
+ // Keys:
64
+ // item hash: equipmentCalendar:items (stored in settings as JSON)
65
+ // reservations stored as objects in db, indexed by id, and by itemId
66
+ // reservation object key: equipmentCalendar:res:<id>
67
+ // index by item: equipmentCalendar:item:<itemId>:res (sorted set score=startMillis, value=resId)
68
+
69
+ function resKey(id) { return `equipmentCalendar:res:${id}`; }
70
+ function itemIndexKey(itemId) { return `equipmentCalendar:item:${itemId}:res`; }
71
+
72
+ function statusBlocksItem(status) {
73
+ // statuses that block availability
74
+ return ['pending', 'approved_waiting_payment', 'paid_validated'].includes(status);
75
+ }
76
+
77
+ async function saveReservation(res) {
78
+ await db.setObject(resKey(res.id), res);
79
+ await db.sortedSetAdd(itemIndexKey(res.itemId), res.startMs, res.id);
80
+ }
81
+
82
+ async function getReservation(id) {
83
+ const obj = await db.getObject(resKey(id));
84
+ if (!obj || !obj.id) return null;
85
+ // db returns strings, normalize
86
+ return normalizeReservation(obj);
87
+ }
88
+
89
+ function normalizeReservation(obj) {
90
+ const startMs = Number(obj.startMs);
91
+ const endMs = Number(obj.endMs);
92
+ return {
93
+ id: obj.id,
94
+ itemId: obj.itemId,
95
+ uid: Number(obj.uid),
96
+ startMs,
97
+ endMs,
98
+ status: obj.status,
99
+ createdAtMs: Number(obj.createdAtMs || 0),
100
+ updatedAtMs: Number(obj.updatedAtMs || 0),
101
+ validatorUid: obj.validatorUid ? Number(obj.validatorUid) : 0,
102
+ notesUser: obj.notesUser || '',
103
+ notesAdmin: obj.notesAdmin || '',
104
+ ha_checkoutIntentId: obj.ha_checkoutIntentId || '',
105
+ ha_paymentUrl: obj.ha_paymentUrl || '',
106
+ ha_paymentStatus: obj.ha_paymentStatus || '',
107
+ ha_paidAtMs: Number(obj.ha_paidAtMs || 0),
108
+ };
109
+ }
110
+
111
+ async function listReservationsForRange(itemId, startMs, endMs) {
112
+ // fetch candidates by score range with some padding
113
+ const ids = await db.getSortedSetRangeByScore(itemIndexKey(itemId), 0, -1, startMs - 86400000, endMs + 86400000);
114
+ if (!ids || !ids.length) return [];
115
+ const objs = await db.getObjects(ids.map(resKey));
116
+ return (objs || []).filter(Boolean).map(normalizeReservation).filter(r => {
117
+ // overlap check
118
+ return r.startMs < endMs && r.endMs > startMs;
119
+ });
120
+ }
121
+
122
+ async function hasOverlap(itemId, startMs, endMs) {
123
+ const existing = await listReservationsForRange(itemId, startMs, endMs);
124
+ return existing.some(r => statusBlocksItem(r.status) && r.startMs < endMs && r.endMs > startMs);
125
+ }
126
+
127
+ // --- Permissions helpers ---
128
+ async function isInAnyGroup(uid, groupNamesCsv) {
129
+ if (!uid) return false;
130
+ const groupNames = String(groupNamesCsv || '').split(',').map(s => s.trim()).filter(Boolean);
131
+ if (!groupNames.length) return false;
132
+ for (const g of groupNames) {
133
+ const isMember = await groups.isMember(uid, g);
134
+ if (isMember) return true;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ async function canCreate(uid, settings) {
140
+ return isInAnyGroup(uid, settings.creatorGroups);
141
+ }
142
+
143
+ async function canApprove(uid, settings) {
144
+ return groups.isMember(uid, settings.approverGroup);
145
+ }
146
+
147
+ async function listGroupUids(groupName) {
148
+ try {
149
+ const members = await groups.getMembers(groupName, 0, 9999);
150
+ return (members && members.users) ? members.users.map(u => u.uid) : [];
151
+ } catch (e) {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ async function sendGroupNotificationAndEmail(groupName, notifTitle, notifBody, link) {
157
+ const uids = await listGroupUids(groupName);
158
+ if (!uids.length) return;
159
+
160
+ // NodeBB notifications
161
+ try {
162
+ await notifications.create({
163
+ bodyShort: notifTitle,
164
+ bodyLong: notifBody,
165
+ nid: `equipmentCalendar:${uuidv4()}`,
166
+ from: 0,
167
+ path: link,
168
+ });
169
+ } catch (e) { /* ignore */ }
170
+
171
+ try {
172
+ // push notification to each user
173
+ for (const uid of uids) {
174
+ try {
175
+ await notifications.push({
176
+ uid,
177
+ bodyShort: notifTitle,
178
+ bodyLong: notifBody,
179
+ nid: `equipmentCalendar:${uuidv4()}`,
180
+ from: 0,
181
+ path: link,
182
+ });
183
+ } catch (e) { /* ignore per user */ }
184
+ }
185
+ } catch (e) { /* ignore */ }
186
+
187
+ // Emails (best-effort)
188
+ try {
189
+ const users = await user.getUsersData(uids);
190
+ const emails = (users || []).map(u => u.email).filter(Boolean);
191
+ if (emails.length) {
192
+ await Emailer.send('equipmentCalendar', {
193
+ // Emailer templates: we provide a minimal HTML via meta.template? NodeBB typically uses email templates
194
+ // Here we rely on Emailer plugin config; this is best-effort and may vary per installation.
195
+ // Fallback: send each email individually with raw subject/text if supported.
196
+ subject: notifTitle,
197
+ body: `${notifBody}\n\n${link}`,
198
+ to: emails.join(','),
199
+ });
200
+ }
201
+ } catch (e) {
202
+ // Many NodeBB installs do not support ad-hoc Emailer.send signatures; ignore gracefully.
203
+ }
204
+ }
205
+
206
+ // --- HelloAsso helpers ---
207
+ // This is a minimal integration skeleton.
208
+ // See HelloAsso API v5 docs for exact endpoints and payloads.
209
+ async function helloAssoGetAccessToken(settings) {
210
+ // HelloAsso uses OAuth2 client credentials.
211
+ // Endpoint: https://api.helloasso.com/oauth2/token
212
+ const url = 'https://api.helloasso.com/oauth2/token';
213
+ const params = new URLSearchParams();
214
+ params.append('grant_type', 'client_credentials');
215
+ params.append('client_id', settings.ha_clientId);
216
+ params.append('client_secret', settings.ha_clientSecret);
217
+
218
+ const resp = await axios.post(url, params.toString(), {
219
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
220
+ timeout: 15000,
221
+ });
222
+ return resp.data.access_token;
223
+ }
224
+
225
+ async function helloAssoCreateCheckout(settings, token, reservation, item) {
226
+ // Minimal: create a checkout intent and return redirectUrl
227
+ // This endpoint/payload may need adaptation depending on your HelloAsso setup.
228
+ const org = settings.ha_organizationSlug;
229
+ if (!org) throw new Error('HelloAsso organizationSlug missing');
230
+
231
+ const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
232
+ const amountCents = Math.max(0, Number(item.priceCents || 0));
233
+
234
+ const payload = {
235
+ totalAmount: amountCents,
236
+ initialAmount: amountCents,
237
+ itemName: item.name,
238
+ backUrl: settings.ha_returnUrl || `${nconf.get('url')}/equipment/payment/return?rid=${encodeURIComponent(reservation.id)}`,
239
+ errorUrl: settings.ha_returnUrl || `${nconf.get('url')}/equipment/payment/return?rid=${encodeURIComponent(reservation.id)}&status=error`,
240
+ metadata: {
241
+ reservationId: reservation.id,
242
+ itemId: reservation.itemId,
243
+ uid: String(reservation.uid),
244
+ },
245
+ };
246
+
247
+ const resp = await axios.post(url, payload, {
248
+ headers: { Authorization: `Bearer ${token}` },
249
+ timeout: 15000,
250
+ });
251
+
252
+ // Typical response fields (may vary): id, redirectUrl
253
+ return {
254
+ checkoutIntentId: resp.data.id || resp.data.checkoutIntentId || '',
255
+ paymentUrl: resp.data.redirectUrl || resp.data.paymentUrl || '',
256
+ };
257
+ }
258
+
259
+ // Webhook signature check (simple HMAC SHA256 over raw body)
260
+ function verifyWebhook(req, secret) {
261
+ if (!secret) return true;
262
+ const sig = req.headers['x-helloasso-signature'] || req.headers['x-helloasso-signature-hmac-sha256'];
263
+ if (!sig) return false;
264
+ const raw = req.rawBody || '';
265
+ const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
266
+ // Some providers prefix, some base64; accept hex only in this skeleton
267
+ return String(sig).toLowerCase() === expected.toLowerCase();
268
+ }
269
+
270
+ // --- Rendering helpers ---
271
+ function toEvent(res, item, requesterName, canSeeRequester) {
272
+ const start = DateTime.fromMillis(res.startMs).toISO();
273
+ const end = DateTime.fromMillis(res.endMs).toISO();
274
+
275
+ let icon = '⏳';
276
+ let className = 'ec-status-pending';
277
+ if (res.status === 'approved_waiting_payment') { icon = '💳'; className = 'ec-status-awaitpay'; }
278
+ if (res.status === 'paid_validated') { icon = '✅'; className = 'ec-status-valid'; }
279
+ if (res.status === 'rejected' || res.status === 'cancelled') { icon = '❌'; className = 'ec-status-cancel'; }
280
+
281
+ const titleParts = [icon, item ? item.name : res.itemId];
282
+ if (canSeeRequester && requesterName) titleParts.push(`- ${requesterName}`);
283
+ return {
284
+ id: res.id,
285
+ title: titleParts.join(' '),
286
+ start,
287
+ end,
288
+ allDay: false,
289
+ className,
290
+ extendedProps: {
291
+ status: res.status,
292
+ itemId: res.itemId,
293
+ },
294
+ };
295
+ }
296
+
297
+ function clampRange(startStr, endStr, tz) {
298
+ const start = DateTime.fromISO(startStr, { zone: tz }).isValid ? DateTime.fromISO(startStr, { zone: tz }) : DateTime.now().setZone(tz).startOf('month');
299
+ const end = DateTime.fromISO(endStr, { zone: tz }).isValid ? DateTime.fromISO(endStr, { zone: tz }) : start.plus({ months: 1 });
300
+ // prevent crazy ranges
301
+ const maxDays = 62;
302
+ const diffDays = Math.abs(end.diff(start, 'days').days);
303
+ const safeEnd = diffDays > maxDays ? start.plus({ days: maxDays }) : end;
304
+ return { start, end: safeEnd };
305
+ }
306
+
307
+ // --- Routes ---
308
+ plugin.init = async function (params) {
309
+ const { router } = params;
310
+
311
+ // To verify webhook signature we need raw body; add a rawBody collector for this route only
312
+ router.post('/equipment/webhook/helloasso',
313
+ require.main.require('body-parser').text({ type: '*/*' }),
314
+ async (req, res) => {
315
+ req.rawBody = req.body || '';
316
+ let json;
317
+ try { json = JSON.parse(req.rawBody || '{}'); } catch (e) { json = {}; }
318
+
319
+ const settings = await getSettings();
320
+ if (!verifyWebhook(req, settings.ha_webhookSecret)) {
321
+ return res.status(401).json({ ok: false, error: 'invalid signature' });
322
+ }
323
+
324
+ // Minimal mapping: expect metadata.reservationId and event indicating payment succeeded.
325
+ const reservationId = json?.metadata?.reservationId || json?.data?.metadata?.reservationId || '';
326
+ const paymentStatus = json?.eventType || json?.type || json?.status || 'unknown';
327
+
328
+ if (reservationId) {
329
+ const reservation = await getReservation(reservationId);
330
+ if (reservation) {
331
+ reservation.ha_paymentStatus = String(paymentStatus);
332
+ // Heuristic: if payload includes "OrderPaid" or "Payment" success
333
+ const isPaid = /paid|success|payment/i.test(String(paymentStatus)) || json?.data?.state === 'Paid';
334
+ if (isPaid) {
335
+ reservation.status = 'paid_validated';
336
+ reservation.ha_paidAtMs = Date.now();
337
+ }
338
+ reservation.updatedAtMs = Date.now();
339
+ await saveReservation(reservation);
340
+ }
341
+ }
342
+
343
+ return res.json({ ok: true });
344
+ }
345
+ );
346
+
347
+ // Return endpoint (optional)
348
+ router.get('/equipment/payment/return', middleware.buildHeader, async (req, res) => {
349
+ res.render('equipment-calendar/payment-return', {
350
+ title: 'Paiement',
351
+ rid: req.query.rid || '',
352
+ status: req.query.status || 'ok',
353
+ });
354
+ });
355
+
356
+ // Page routes are attached via filter:router.page as well, but we also add directly to be safe:
357
+ router.get('/equipment/calendar', middleware.buildHeader, renderCalendarPage);
358
+ router.get('/equipment/approvals', middleware.buildHeader, renderApprovalsPage);
359
+
360
+ router.post('/equipment/reservations/create', middleware.applyCSRF, handleCreateReservation);
361
+ router.post('/equipment/reservations/:id/approve', middleware.applyCSRF, handleApproveReservation);
362
+ router.post('/equipment/reservations/:id/reject', middleware.applyCSRF, handleRejectReservation);
363
+ };
364
+
365
+ plugin.addPageRoutes = async function (data) {
366
+ // Ensure routes are treated as "page" routes by NodeBB router filter
367
+ // Not strictly necessary when using router.get with buildHeader, but kept for compatibility.
368
+ return data;
369
+ };
370
+
371
+ plugin.addAdminNavigation = async function (header) {
372
+ header.plugins.push({
373
+ route: '/plugins/equipment-calendar',
374
+ icon: 'fa-calendar',
375
+ name: 'Equipment Calendar',
376
+ });
377
+ return header;
378
+ };
379
+
380
+ // --- Admin page routes (ACP) ---
381
+ plugin.addAdminRoutes = async function (params) {
382
+ const { router, middleware: mid } = params;
383
+ router.get('/admin/plugins/equipment-calendar', mid.admin.buildHeader, renderAdminPage);
384
+ router.get('/api/admin/plugins/equipment-calendar', renderAdminPage);
385
+ };
386
+
387
+ async function renderAdminPage(req, res) {
388
+ const settings = await getSettings();
389
+ res.render('admin/plugins/equipment-calendar', {
390
+ title: 'Equipment Calendar',
391
+ settings,
392
+ });
393
+ }
394
+
395
+ // --- Calendar page ---
396
+ async function renderCalendarPage(req, res) {
397
+ const settings = await getSettings();
398
+ const items = parseItems(settings.itemsJson).filter(i => i.active);
399
+
400
+ const tz = settings.timezone || 'Europe/Paris';
401
+
402
+ const itemId = String(req.query.itemId || (items[0]?.id || '')).trim();
403
+ const chosenItem = items.find(i => i.id === itemId) || items[0] || null;
404
+
405
+ // Determine range to render
406
+ const now = DateTime.now().setZone(tz);
407
+ const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
408
+ const startQ = req.query.start;
409
+ const endQ = req.query.end;
410
+
411
+ let start, end;
412
+ if (startQ && endQ) {
413
+ const r = clampRange(String(startQ), String(endQ), tz);
414
+ start = r.start;
415
+ end = r.end;
416
+ } else {
417
+ // Default to current month range
418
+ start = now.startOf('month');
419
+ end = now.endOf('month').plus({ days: 1 });
420
+ }
421
+
422
+ // Load reservations for chosen item within range
423
+ const reservations = chosenItem ? await listReservationsForRange(chosenItem.id, start.toMillis(), end.toMillis()) : [];
424
+ const showRequesterToAll = String(settings.showRequesterToAll) === '1';
425
+ const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
426
+
427
+ const requesterUids = Array.from(new Set(reservations.map(r => r.uid))).filter(Boolean);
428
+ const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
429
+ const nameByUid = {};
430
+ (users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
431
+
432
+ const events = reservations
433
+ .filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
434
+ .map(r => toEvent(r, chosenItem, nameByUid[r.uid], isApprover || showRequesterToAll));
435
+
436
+ const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
437
+
438
+ res.render('equipment-calendar/calendar', {
439
+ title: 'Réservation de matériel',
440
+ items,
441
+ chosenItemId: chosenItem ? chosenItem.id : '',
442
+ chosenItemName: chosenItem ? chosenItem.name : '',
443
+ chosenItemPriceCents: chosenItem ? chosenItem.priceCents : 0,
444
+ chosenItemLocation: chosenItem ? chosenItem.location : '',
445
+ view,
446
+ tz,
447
+ startISO: start.toISO(),
448
+ endISO: end.toISO(),
449
+ initialDateISO: start.toISODate(),
450
+ eventsJson: JSON.stringify(events),
451
+ canCreate: canUserCreate,
452
+ isApprover,
453
+ csrf: req.csrfToken,
454
+ forumUrl: nconf.get('url'),
455
+ });
456
+ }
457
+
458
+ // --- Approvals page ---
459
+ async function renderApprovalsPage(req, res) {
460
+ const settings = await getSettings();
461
+ const ok = req.uid ? await canApprove(req.uid, settings) : false;
462
+ if (!ok) {
463
+ return helpers.notAllowed(req, res);
464
+ }
465
+
466
+ const items = parseItems(settings.itemsJson);
467
+ // naive scan: for a real install, store a global sorted set too; for now list per item and merge
468
+ // We'll pull recent 200 per item and then filter
469
+ const pending = [];
470
+ for (const it of items) {
471
+ const ids = await db.getSortedSetRevRange(itemIndexKey(it.id), 0, 199);
472
+ if (!ids || !ids.length) continue;
473
+ const objs = await db.getObjects(ids.map(resKey));
474
+ const resArr = (objs || []).filter(Boolean).map(normalizeReservation).filter(r => r.status === 'pending' || r.status === 'approved_waiting_payment');
475
+ for (const r of resArr) pending.push(r);
476
+ }
477
+
478
+ // Sort by createdAt desc
479
+ pending.sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
480
+
481
+ const uids = Array.from(new Set(pending.map(r => r.uid))).filter(Boolean);
482
+ const users = uids.length ? await user.getUsersData(uids) : [];
483
+ const nameByUid = {};
484
+ (users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
485
+
486
+ const rows = pending.map(r => {
487
+ const item = items.find(i => i.id === r.itemId);
488
+ return {
489
+ id: r.id,
490
+ itemName: item ? item.name : r.itemId,
491
+ requester: nameByUid[r.uid] || `uid:${r.uid}`,
492
+ start: DateTime.fromMillis(r.startMs).toFormat('dd/LL/yyyy HH:mm'),
493
+ end: DateTime.fromMillis(r.endMs).toFormat('dd/LL/yyyy HH:mm'),
494
+ status: r.status,
495
+ paymentUrl: r.ha_paymentUrl || '',
496
+ };
497
+ });
498
+
499
+ res.render('equipment-calendar/approvals', {
500
+ title: 'Validation des réservations',
501
+ rows,
502
+ csrf: req.csrfToken,
503
+ });
504
+ }
505
+
506
+ // --- Actions ---
507
+ async function handleCreateReservation(req, res) {
508
+ try {
509
+ const settings = await getSettings();
510
+ if (!req.uid || !(await canCreate(req.uid, settings))) {
511
+ return helpers.notAllowed(req, res);
512
+ }
513
+
514
+ const items = parseItems(settings.itemsJson).filter(i => i.active);
515
+ const itemId = String(req.body.itemId || '').trim();
516
+ const item = items.find(i => i.id === itemId);
517
+ if (!item) return res.status(400).send('Invalid item');
518
+
519
+ const tz = settings.timezone || 'Europe/Paris';
520
+ const start = DateTime.fromISO(String(req.body.start || ''), { zone: tz });
521
+ const end = DateTime.fromISO(String(req.body.end || ''), { zone: tz });
522
+
523
+ if (!start.isValid || !end.isValid) return res.status(400).send('Invalid dates');
524
+ const startMs = start.toMillis();
525
+ const endMs = end.toMillis();
526
+
527
+ if (endMs <= startMs) return res.status(400).send('End must be after start');
528
+ if (end.diff(start, 'days').days > 31) return res.status(400).send('Range too large');
529
+
530
+ // Overlap
531
+ if (await hasOverlap(itemId, startMs, endMs)) {
532
+ return res.status(409).send('Overlap');
533
+ }
534
+
535
+ const reservation = {
536
+ id: uuidv4(),
537
+ itemId,
538
+ uid: req.uid,
539
+ startMs,
540
+ endMs,
541
+ status: 'pending',
542
+ createdAtMs: Date.now(),
543
+ updatedAtMs: Date.now(),
544
+ validatorUid: 0,
545
+ notesUser: String(req.body.notesUser || '').slice(0, 2000),
546
+ notesAdmin: '',
547
+ ha_checkoutIntentId: '',
548
+ ha_paymentUrl: '',
549
+ ha_paymentStatus: '',
550
+ ha_paidAtMs: 0,
551
+ };
552
+
553
+ await saveReservation(reservation);
554
+
555
+ // Notify group
556
+ const link = `/equipment/approvals`;
557
+ await sendGroupNotificationAndEmail(
558
+ settings.notifyGroup || settings.approverGroup,
559
+ 'Nouvelle demande de réservation',
560
+ `Une demande de réservation est en attente (matériel: ${item.name}).`,
561
+ link
562
+ );
563
+
564
+ return res.redirect(`/equipment/calendar?itemId=${encodeURIComponent(itemId)}`);
565
+ } catch (e) {
566
+ return res.status(500).send(e.message || 'error');
567
+ }
568
+ }
569
+
570
+ async function handleApproveReservation(req, res) {
571
+ try {
572
+ const settings = await getSettings();
573
+ if (!req.uid || !(await canApprove(req.uid, settings))) {
574
+ return helpers.notAllowed(req, res);
575
+ }
576
+
577
+ const id = String(req.params.id || '').trim();
578
+ const reservation = await getReservation(id);
579
+ if (!reservation) return res.status(404).send('Not found');
580
+ if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
581
+ return res.status(409).send('Invalid status');
582
+ }
583
+
584
+ const items = parseItems(settings.itemsJson);
585
+ const item = items.find(i => i.id === reservation.itemId);
586
+ if (!item) return res.status(400).send('Invalid item');
587
+
588
+ // Create HelloAsso checkout
589
+ if (!reservation.ha_paymentUrl) {
590
+ if (!settings.ha_clientId || !settings.ha_clientSecret) {
591
+ return res.status(400).send('HelloAsso not configured');
592
+ }
593
+ const token = await helloAssoGetAccessToken(settings);
594
+ const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
595
+ reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
596
+ reservation.ha_paymentUrl = checkout.paymentUrl;
597
+ }
598
+
599
+ reservation.status = 'approved_waiting_payment';
600
+ reservation.validatorUid = req.uid;
601
+ reservation.updatedAtMs = Date.now();
602
+ await saveReservation(reservation);
603
+
604
+ // Notify requester with payment link (best-effort)
605
+ try {
606
+ const u = await user.getUserData(reservation.uid);
607
+ const subject = 'Réservation approuvée - Paiement requis';
608
+ const body = `Ta réservation de "${item.name}" a été approuvée.\n\nLien de paiement:\n${reservation.ha_paymentUrl}\n`;
609
+ // NodeBB Emailer signature may vary; this is best-effort
610
+ await Emailer.send('equipmentCalendar-payment', {
611
+ subject,
612
+ body,
613
+ to: u.email,
614
+ });
615
+ } catch (e) { /* ignore */ }
616
+
617
+ return res.redirect('/equipment/approvals');
618
+ } catch (e) {
619
+ return res.status(500).send(e.message || 'error');
620
+ }
621
+ }
622
+
623
+ async function handleRejectReservation(req, res) {
624
+ try {
625
+ const settings = await getSettings();
626
+ if (!req.uid || !(await canApprove(req.uid, settings))) {
627
+ return helpers.notAllowed(req, res);
628
+ }
629
+
630
+ const id = String(req.params.id || '').trim();
631
+ const reservation = await getReservation(id);
632
+ if (!reservation) return res.status(404).send('Not found');
633
+ if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
634
+ return res.status(409).send('Invalid status');
635
+ }
636
+
637
+ reservation.status = 'rejected';
638
+ reservation.validatorUid = req.uid;
639
+ reservation.notesAdmin = String(req.body.notesAdmin || '').slice(0, 2000);
640
+ reservation.updatedAtMs = Date.now();
641
+ await saveReservation(reservation);
642
+
643
+ return res.redirect('/equipment/approvals');
644
+ } catch (e) {
645
+ return res.status(500).send(e.message || 'error');
646
+ }
647
+ }
648
+
649
+ module.exports = plugin;
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "nodebb-plugin-equipment-calendar",
3
+ "version": "0.1.0",
4
+ "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
+ "main": "library.js",
6
+ "scripts": {
7
+ "lint": "node -v"
8
+ },
9
+ "keywords": [
10
+ "nodebb",
11
+ "plugin",
12
+ "calendar",
13
+ "fullcalendar",
14
+ "helloasso"
15
+ ],
16
+ "author": "Generated by ChatGPT",
17
+ "license": "MIT",
18
+ "dependencies": {
19
+ "axios": "^1.7.9",
20
+ "luxon": "^3.5.0",
21
+ "uuid": "^9.0.1"
22
+ }
23
+ }
package/plugin.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "id": "nodebb-plugin-equipment-calendar",
3
+ "name": "Equipment Calendar",
4
+ "description": "Calendar-based equipment reservations with group approvals and HelloAsso payments.",
5
+ "url": "https://example.invalid",
6
+ "library": "./library.js",
7
+ "hooks": [
8
+ {
9
+ "hook": "static:app.load",
10
+ "method": "init"
11
+ },
12
+ {
13
+ "hook": "filter:admin.header.build",
14
+ "method": "addAdminNavigation"
15
+ },
16
+ {
17
+ "hook": "filter:router.page",
18
+ "method": "addPageRoutes"
19
+ }
20
+ ],
21
+ "staticDirs": {
22
+ "public": "./public"
23
+ },
24
+ "templates": "./public/templates"
25
+ }
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+ /* global $, app */
3
+
4
+ define('admin/plugins/equipment-calendar', function () {
5
+ const EquipmentCalendar = {};
6
+
7
+ EquipmentCalendar.init = function () {
8
+ $('#save').on('click', function () {
9
+ const payload = {
10
+ creatorGroups: $('#creatorGroups').val(),
11
+ approverGroup: $('#approverGroup').val(),
12
+ notifyGroup: $('#notifyGroup').val(),
13
+ itemsJson: $('#itemsJson').val(),
14
+ ha_clientId: $('#ha_clientId').val(),
15
+ ha_clientSecret: $('#ha_clientSecret').val(),
16
+ ha_organizationSlug: $('#ha_organizationSlug').val(),
17
+ ha_returnUrl: $('#ha_returnUrl').val(),
18
+ ha_webhookSecret: $('#ha_webhookSecret').val(),
19
+ defaultView: $('#defaultView').val(),
20
+ timezone: $('#timezone').val(),
21
+ showRequesterToAll: $('#showRequesterToAll').is(':checked') ? '1' : '0',
22
+ };
23
+
24
+ socket.emit('admin.settings.save', { hash: 'equipmentCalendar', values: payload }, function (err) {
25
+ if (err) return app.alertError(err.message || err);
26
+ app.alertSuccess('Sauvegardé');
27
+ });
28
+ });
29
+ };
30
+
31
+ return EquipmentCalendar;
32
+ });
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+ /* global $, window, document, FullCalendar */
3
+
4
+ (function () {
5
+ function submitCreate(startISO, endISO) {
6
+ const form = document.getElementById('ec-create-form');
7
+ if (!form) return;
8
+ form.querySelector('input[name="start"]').value = startISO;
9
+ form.querySelector('input[name="end"]').value = endISO;
10
+ form.submit();
11
+ }
12
+
13
+ $(document).ready(function () {
14
+ const el = document.getElementById('equipment-calendar');
15
+ if (!el) return;
16
+
17
+ const events = window.EC_EVENTS || [];
18
+ const initialDate = window.EC_INITIAL_DATE;
19
+ const initialView = window.EC_INITIAL_VIEW || 'dayGridMonth';
20
+
21
+ const calendar = new FullCalendar.Calendar(el, {
22
+ initialView: initialView,
23
+ initialDate: initialDate,
24
+ timeZone: window.EC_TZ || 'local',
25
+ selectable: window.EC_CAN_CREATE === true,
26
+ selectMirror: true,
27
+ events: events,
28
+ select: function (info) {
29
+ // FullCalendar provides end exclusive for all-day; for timed selections it's fine.
30
+ submitCreate(info.startStr, info.endStr);
31
+ },
32
+ dateClick: function (info) {
33
+ // Create 1-hour slot by default
34
+ const start = info.date;
35
+ const end = new Date(start.getTime() + 60 * 60 * 1000);
36
+ submitCreate(start.toISOString(), end.toISOString());
37
+ },
38
+ eventClick: function (info) {
39
+ // optionally show details
40
+ },
41
+ headerToolbar: {
42
+ left: 'prev,next today',
43
+ center: 'title',
44
+ right: 'dayGridMonth,timeGridWeek,timeGridDay'
45
+ },
46
+ });
47
+
48
+ calendar.render();
49
+ });
50
+ }());
@@ -0,0 +1 @@
1
+ .equipment-calendar-page { }
@@ -0,0 +1,67 @@
1
+ <div class="acp-page-container">
2
+ <h1>Equipment Calendar</h1>
3
+
4
+ <div class="alert alert-warning">
5
+ Les champs "Matériel" doivent être un JSON valide (array). Exemple :
6
+ <pre class="mb-0">[
7
+ { "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true },
8
+ { "id": "light1", "name": "Projecteur", "priceCents": 2000, "location": "Stock B", "active": true }
9
+ ]</pre>
10
+ </div>
11
+
12
+ <div class="row">
13
+ <div class="col-lg-8">
14
+ <div class="card card-body mb-3">
15
+ <h5>Permissions</h5>
16
+ <div class="mb-3">
17
+ <label class="form-label">Groupes autorisés à créer (CSV)</label>
18
+ <input id="creatorGroups" class="form-control" value="{{settings.creatorGroups}}">
19
+ </div>
20
+ <div class="mb-3">
21
+ <label class="form-label">Groupe validateur</label>
22
+ <input id="approverGroup" class="form-control" value="{{settings.approverGroup}}">
23
+ </div>
24
+ <div class="mb-3">
25
+ <label class="form-label">Groupe notifié (emails/notifs)</label>
26
+ <input id="notifyGroup" class="form-control" value="{{settings.notifyGroup}}">
27
+ </div>
28
+ <div class="form-check">
29
+ <input class="form-check-input" type="checkbox" id="showRequesterToAll" {{#if (eq settings.showRequesterToAll "1")}}checked{{/if}}>
30
+ <label class="form-check-label" for="showRequesterToAll">Afficher le demandeur à tout le monde</label>
31
+ </div>
32
+ </div>
33
+
34
+ <div class="card card-body mb-3">
35
+ <h5>Matériel</h5>
36
+ <textarea id="itemsJson" class="form-control" rows="10">{{settings.itemsJson}}</textarea>
37
+ </div>
38
+
39
+ <div class="card card-body mb-3">
40
+ <h5>HelloAsso</h5>
41
+ <div class="mb-3"><label class="form-label">Client ID</label><input id="ha_clientId" class="form-control" value="{{settings.ha_clientId}}"></div>
42
+ <div class="mb-3"><label class="form-label">Client Secret</label><input id="ha_clientSecret" class="form-control" value="{{settings.ha_clientSecret}}"></div>
43
+ <div class="mb-3"><label class="form-label">Organization Slug</label><input id="ha_organizationSlug" class="form-control" value="{{settings.ha_organizationSlug}}"></div>
44
+ <div class="mb-3"><label class="form-label">Return URL</label><input id="ha_returnUrl" class="form-control" value="{{settings.ha_returnUrl}}"></div>
45
+ <div class="mb-3"><label class="form-label">Webhook Secret (HMAC SHA256)</label><input id="ha_webhookSecret" class="form-control" value="{{settings.ha_webhookSecret}}"></div>
46
+ </div>
47
+
48
+ <div class="card card-body mb-3">
49
+ <h5>Calendrier</h5>
50
+ <div class="mb-3"><label class="form-label">Vue par défaut</label>
51
+ <select id="defaultView" class="form-select">
52
+ <option value="dayGridMonth" {{#if (eq settings.defaultView "dayGridMonth")}}selected{{/if}}>Mois</option>
53
+ <option value="timeGridWeek" {{#if (eq settings.defaultView "timeGridWeek")}}selected{{/if}}>Semaine</option>
54
+ <option value="timeGridDay" {{#if (eq settings.defaultView "timeGridDay")}}selected{{/if}}>Jour</option>
55
+ </select>
56
+ </div>
57
+ <div class="mb-3"><label class="form-label">Timezone</label><input id="timezone" class="form-control" value="{{settings.timezone}}"></div>
58
+ </div>
59
+
60
+ <button id="save" class="btn btn-primary">Sauvegarder</button>
61
+ </div>
62
+ </div>
63
+ </div>
64
+
65
+ <script>
66
+ require(['admin/plugins/equipment-calendar'], function (m) { m.init(); });
67
+ </script>
@@ -0,0 +1,51 @@
1
+ <div class="equipment-approvals-page">
2
+ <h1>Validation des réservations</h1>
3
+
4
+ {{#if rows.length}}
5
+ <div class="table-responsive">
6
+ <table class="table table-striped align-middle">
7
+ <thead>
8
+ <tr>
9
+ <th>Matériel</th>
10
+ <th>Demandeur</th>
11
+ <th>Début</th>
12
+ <th>Fin</th>
13
+ <th>Statut</th>
14
+ <th>Paiement</th>
15
+ <th>Actions</th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ {{#rows}}
20
+ <tr>
21
+ <td>{{itemName}}</td>
22
+ <td>{{requester}}</td>
23
+ <td>{{start}}</td>
24
+ <td>{{end}}</td>
25
+ <td><code>{{status}}</code></td>
26
+ <td>
27
+ {{#if paymentUrl}}
28
+ <a href="{{paymentUrl}}" target="_blank" rel="noreferrer">Lien</a>
29
+ {{else}}
30
+ -
31
+ {{/if}}
32
+ </td>
33
+ <td>
34
+ <form method="post" action="/equipment/reservations/{{id}}/approve" class="d-inline">
35
+ <input type="hidden" name="_csrf" value="{{../csrf}}">
36
+ <button class="btn btn-sm btn-success" type="submit">Approuver</button>
37
+ </form>
38
+ <form method="post" action="/equipment/reservations/{{id}}/reject" class="d-inline ms-1">
39
+ <input type="hidden" name="_csrf" value="{{../csrf}}">
40
+ <button class="btn btn-sm btn-danger" type="submit">Refuser</button>
41
+ </form>
42
+ </td>
43
+ </tr>
44
+ {{/rows}}
45
+ </tbody>
46
+ </table>
47
+ </div>
48
+ {{else}}
49
+ <div class="alert alert-success">Aucune demande en attente 🎉</div>
50
+ {{/if}}
51
+ </div>
@@ -0,0 +1,68 @@
1
+ <div class="equipment-calendar-page">
2
+ <h1>Réservation de matériel</h1>
3
+
4
+ <div class="mb-3">
5
+ <form method="get" action="/equipment/calendar" class="d-flex gap-2 align-items-end">
6
+ <div>
7
+ <label class="form-label">Matériel</label>
8
+ <select name="itemId" class="form-select" onchange="this.form.submit()">
9
+ {{#items}}
10
+ <option value="{{id}}" {{#if (eq ../chosenItemId id)}}selected{{/if}}>{{name}} — {{location}}</option>
11
+ {{/items}}
12
+ </select>
13
+ </div>
14
+ <div class="text-muted small">
15
+ <div><strong>Lieu:</strong> {{chosenItemLocation}}</div>
16
+ <div><strong>Prix:</strong> {{chosenItemPriceCents}} cts</div>
17
+ </div>
18
+ </form>
19
+ </div>
20
+
21
+ {{#if canCreate}}
22
+ <form id="ec-create-form" method="post" action="/equipment/reservations/create" class="card card-body mb-3">
23
+ <input type="hidden" name="_csrf" value="{{csrf}}">
24
+ <input type="hidden" name="itemId" value="{{chosenItemId}}">
25
+ <input type="hidden" name="start" value="">
26
+ <input type="hidden" name="end" value="">
27
+ <div class="row g-2 align-items-end">
28
+ <div class="col-md-8">
29
+ <label class="form-label">Note (optionnel)</label>
30
+ <input class="form-control" type="text" name="notesUser" maxlength="2000" placeholder="Ex: besoin de trépied, etc.">
31
+ </div>
32
+ <div class="col-md-4">
33
+ <button class="btn btn-primary w-100" type="submit" onclick="return false;">Sélectionne une date sur le calendrier</button>
34
+ </div>
35
+ </div>
36
+ <div class="form-text">Clique sur une date ou sélectionne une plage sur le calendrier pour soumettre une demande.</div>
37
+ </form>
38
+ {{else}}
39
+ <div class="alert alert-info">Tu peux consulter le calendrier, mais tu n’as pas les droits pour créer une demande.</div>
40
+ {{/if}}
41
+
42
+ <div class="card card-body">
43
+ <div id="equipment-calendar"></div>
44
+ </div>
45
+
46
+ {{#if isApprover}}
47
+ <div class="mt-3">
48
+ <a class="btn btn-outline-secondary" href="/equipment/approvals">Aller à la validation</a>
49
+ </div>
50
+ {{/if}}
51
+ </div>
52
+
53
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.css">
54
+ <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js"></script>
55
+ <script src="/plugins/nodebb-plugin-equipment-calendar/lib/client.js"></script>
56
+
57
+ <script>
58
+ window.EC_EVENTS = {{{eventsJson}}};
59
+ window.EC_INITIAL_DATE = "{{initialDateISO}}";
60
+ window.EC_INITIAL_VIEW = "{{view}}";
61
+ window.EC_TZ = "{{tz}}";
62
+ window.EC_CAN_CREATE = {{#if canCreate}}true{{else}}false{{/if}};
63
+ </script>
64
+
65
+ <style>
66
+ .ec-status-pending .fc-event-title { font-weight: 600; }
67
+ .ec-status-valid .fc-event-title { font-weight: 700; }
68
+ </style>
@@ -0,0 +1,9 @@
1
+ <div class="equipment-payment-return">
2
+ <h1>Paiement</h1>
3
+ {{#if (eq status "error")}}
4
+ <div class="alert alert-danger">Le paiement semble avoir échoué. Référence réservation: <code>{{rid}}</code></div>
5
+ {{else}}
6
+ <div class="alert alert-info">Merci. Si le paiement est confirmé, la réservation passera en "validée". Référence: <code>{{rid}}</code></div>
7
+ {{/if}}
8
+ <a class="btn btn-primary" href="/equipment/calendar">Retour au calendrier</a>
9
+ </div>