nodebb-plugin-equipment-calendar 0.4.1 → 0.5.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/library.js CHANGED
@@ -25,6 +25,10 @@ const DEFAULT_SETTINGS = {
25
25
  notifyGroup: 'administrators',
26
26
  // JSON array of items: [{ "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true }]
27
27
  itemsJson: '[]',
28
+ itemsSource: 'manual',
29
+ ha_itemsFormType: '',
30
+ ha_itemsFormSlug: '',
31
+ ha_locationMapJson: '{}',
28
32
  // HelloAsso
29
33
  ha_clientId: '',
30
34
  ha_clientSecret: '',
@@ -38,6 +42,109 @@ const DEFAULT_SETTINGS = {
38
42
  showRequesterToAll: '0', // 0/1
39
43
  };
40
44
 
45
+
46
+ function parseLocationMap(locationMapJson) {
47
+ try {
48
+ const obj = JSON.parse(locationMapJson || '{}');
49
+ return (obj && typeof obj === 'object') ? obj : {};
50
+ } catch (e) {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ let haTokenCache = null; // { accessToken, refreshToken, expMs }
56
+
57
+ async function getHelloAssoAccessToken(settings) {
58
+ const now = Date.now();
59
+ if (haTokenCache && haTokenCache.accessToken && haTokenCache.expMs && now < haTokenCache.expMs - 30_000) {
60
+ return haTokenCache.accessToken;
61
+ }
62
+
63
+ const tokenKey = 'equipmentCalendar:ha:token';
64
+ let stored = null;
65
+ try {
66
+ stored = await db.getObject(tokenKey);
67
+ } catch (e) {}
68
+
69
+ // If refresh token exists and not expired locally, try refresh flow first
70
+ const canRefresh = stored && stored.refresh_token;
71
+ const useRefresh = canRefresh && stored.refresh_expires_at && now < parseInt(stored.refresh_expires_at, 10);
72
+
73
+ const formBody = new URLSearchParams();
74
+ if (useRefresh) {
75
+ formBody.set('grant_type', 'refresh_token');
76
+ formBody.set('refresh_token', stored.refresh_token);
77
+ } else {
78
+ formBody.set('grant_type', 'client_credentials');
79
+ formBody.set('client_id', String(settings.ha_clientId || ''));
80
+ formBody.set('client_secret', String(settings.ha_clientSecret || ''));
81
+ }
82
+
83
+ const resp = await fetch('https://api.helloasso.com/oauth2/token', {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
86
+ body: formBody.toString(),
87
+ });
88
+ if (!resp.ok) {
89
+ const t = await resp.text();
90
+ throw new Error(`HelloAsso token error: ${resp.status} ${t}`);
91
+ }
92
+ const json = await resp.json();
93
+ const accessToken = json.access_token;
94
+ const refreshToken = json.refresh_token;
95
+
96
+ const expiresIn = parseInt(json.expires_in, 10) || 0;
97
+ const expMs = now + (expiresIn * 1000);
98
+
99
+ // Per docs, refresh token is valid ~30 days and rotates; keep a conservative expiry (29 days)
100
+ const refreshExpMs = now + (29 * 24 * 60 * 60 * 1000);
101
+
102
+ haTokenCache = { accessToken, refreshToken, expMs };
103
+
104
+ try {
105
+ await db.setObject(tokenKey, {
106
+ refresh_token: refreshToken || stored && stored.refresh_token || '',
107
+ refresh_expires_at: String(refreshExpMs),
108
+ });
109
+ } catch (e) {}
110
+
111
+ return accessToken;
112
+ }
113
+
114
+ async function fetchHelloAssoItems(settings) {
115
+ const org = String(settings.ha_organizationSlug || '').trim();
116
+ const formType = String(settings.ha_itemsFormType || '').trim();
117
+ const formSlug = String(settings.ha_itemsFormSlug || '').trim();
118
+ if (!org || !formType || !formSlug) return [];
119
+
120
+ const cacheKey = `equipmentCalendar:ha:items:${org}:${formType}:${formSlug}`;
121
+ const cache = await db.getObject(cacheKey);
122
+ const now = Date.now();
123
+ if (cache && cache.payload && cache.expiresAt && now < parseInt(cache.expiresAt, 10)) {
124
+ try {
125
+ return JSON.parse(cache.payload);
126
+ } catch (e) {}
127
+ }
128
+
129
+ const token = await getHelloAssoAccessToken(settings);
130
+ const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/items`;
131
+ const resp = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
132
+ if (!resp.ok) {
133
+ const t = await resp.text();
134
+ throw new Error(`HelloAsso items error: ${resp.status} ${t}`);
135
+ }
136
+ const json = await resp.json();
137
+ // API responses are usually { data: [...] } but keep it flexible
138
+ const list = Array.isArray(json) ? json : (Array.isArray(json.data) ? json.data : []);
139
+
140
+ // cache 15 minutes
141
+ try {
142
+ await db.setObject(cacheKey, { payload: JSON.stringify(list), expiresAt: String(now + 15 * 60 * 1000) });
143
+ } catch (e) {}
144
+
145
+ return list;
146
+ }
147
+
41
148
  function parseItems(itemsJson) {
42
149
  try {
43
150
  const arr = JSON.parse(itemsJson || '[]');
@@ -54,6 +161,36 @@ function parseItems(itemsJson) {
54
161
  }
55
162
  }
56
163
 
164
+ async function getActiveItems(settings) {
165
+ const source = String(settings.itemsSource || 'manual');
166
+ if (source === 'helloasso') {
167
+ const rawItems = await fetchHelloAssoItems(settings);
168
+ const locMap = parseLocationMap(settings.ha_locationMapJson);
169
+ return (rawItems || []).map((it) => {
170
+ const id = String(it.id || it.itemId || it.reference || it.slug || it.name || '').trim();
171
+ const name = String(it.name || it.label || it.title || id).trim();
172
+ // Price handling is not displayed in the public calendar, keep a field if you want later
173
+ const price =
174
+ (it.price && (it.price.value || it.price.amount)) ||
175
+ (it.amount && (it.amount.value || it.amount)) ||
176
+ it.price;
177
+ const priceCents = typeof price === 'number' ? price : 0;
178
+
179
+ return {
180
+ id: id || name,
181
+ name,
182
+ location: String(locMap[id] || ''),
183
+ priceCents,
184
+ active: true,
185
+ source: 'helloasso',
186
+ };
187
+ }).filter(i => i.id);
188
+ }
189
+
190
+ // manual
191
+ return parseItems(settings.itemsJson).filter(i => i.active);
192
+ }
193
+
57
194
  async function getSettings() {
58
195
  const settings = await meta.settings.get(SETTINGS_KEY);
59
196
  return { ...DEFAULT_SETTINGS, ...(settings || {}) };
@@ -74,6 +211,8 @@ function statusBlocksItem(status) {
74
211
  return ['pending', 'approved_waiting_payment', 'paid_validated'].includes(status);
75
212
  }
76
213
 
214
+
215
+
77
216
  async function saveReservation(res) {
78
217
  await db.setObject(resKey(res.id), res);
79
218
  await db.sortedSetAdd(itemIndexKey(res.itemId), res.startMs, res.id);
@@ -312,8 +451,14 @@ plugin.init = async function (params) {
312
451
  // Admin (ACP) routes
313
452
  if (mid && mid.admin) {
314
453
  router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
454
+ router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
455
+ router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
315
456
  router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
316
457
  router.post('/admin/plugins/equipment-calendar/save', middleware.applyCSRF, handleAdminSave);
458
+ router.post('/admin/plugins/equipment-calendar/purge', middleware.applyCSRF, handleAdminPurge);
459
+ router.post('/admin/plugins/equipment-calendar/reservations/:rid/approve', middleware.applyCSRF, handleAdminApprove);
460
+ router.post('/admin/plugins/equipment-calendar/reservations/:rid/reject', middleware.applyCSRF, handleAdminReject);
461
+ router.post('/admin/plugins/equipment-calendar/reservations/:rid/delete', middleware.applyCSRF, handleAdminDelete);
317
462
  }
318
463
 
319
464
  // Convenience alias (optional): /calendar -> /equipment/calendar
@@ -394,15 +539,143 @@ plugin.addAdminNavigation = async function (header) {
394
539
  plugin.addAdminRoutes = async function (params) {
395
540
  const { router, middleware: mid } = params;
396
541
  router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
542
+ router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
543
+ router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
397
544
  router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
398
545
  };
399
546
 
547
+
548
+ async function renderAdminReservationsPage(req, res) {
549
+ if (!(await ensureIsAdmin(req, res))) return;
550
+
551
+ const settings = await getSettings();
552
+ const items = await getActiveItems(settings);
553
+ const itemById = {};
554
+ items.forEach(it => { itemById[it.id] = it; });
555
+
556
+ const status = String(req.query.status || ''); // optional filter
557
+ const itemId = String(req.query.itemId || ''); // optional filter
558
+ const q = String(req.query.q || '').trim(); // search rid/user/notes
559
+
560
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
561
+ const perPage = Math.min(100, Math.max(10, parseInt(req.query.perPage, 10) || 50));
562
+
563
+ const allRids = await db.getSortedSetRevRange('equipmentCalendar:reservations', 0, -1);
564
+ const totalAll = allRids.length;
565
+
566
+ const rows = [];
567
+ for (const rid of allRids) {
568
+ // eslint-disable-next-line no-await-in-loop
569
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
570
+ if (!r || !r.rid) continue;
571
+
572
+ if (status && String(r.status) !== status) continue;
573
+ if (itemId && String(r.itemId) !== itemId) continue;
574
+
575
+ const notes = String(r.notesUser || '');
576
+ const ridStr = String(r.rid || rid);
577
+ if (q) {
578
+ const hay = (ridStr + ' ' + String(r.uid || '') + ' ' + notes).toLowerCase();
579
+ if (!hay.includes(q.toLowerCase())) continue;
580
+ }
581
+
582
+ const startMs = parseInt(r.startMs, 10) || 0;
583
+ const endMs = parseInt(r.endMs, 10) || 0;
584
+ const createdAt = parseInt(r.createdAt, 10) || 0;
585
+
586
+ rows.push({
587
+ rid: ridStr,
588
+ itemId: String(r.itemId || ''),
589
+ itemName: (itemById[String(r.itemId || '')] && itemById[String(r.itemId || '')].name) || String(r.itemId || ''),
590
+ uid: String(r.uid || ''),
591
+ status: String(r.status || ''),
592
+ start: startMs ? new Date(startMs).toISOString() : '',
593
+ end: endMs ? new Date(endMs).toISOString() : '',
594
+ createdAt: createdAt ? new Date(createdAt).toISOString() : '',
595
+ notesUser: notes,
596
+ });
597
+ }
598
+
599
+ const total = rows.length;
600
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
601
+ const safePage = Math.min(page, totalPages);
602
+ const startIndex = (safePage - 1) * perPage;
603
+ const pageRows = rows.slice(startIndex, startIndex + perPage);
604
+
605
+ const itemOptions = [{ id: '', name: 'Tous' }].concat(items.map(i => ({ id: i.id, name: i.name })));
606
+ const statusOptions = [
607
+ { id: '', name: 'Tous' },
608
+ { id: 'pending', name: 'pending' },
609
+ { id: 'approved', name: 'approved' },
610
+ { id: 'paid', name: 'paid' },
611
+ { id: 'rejected', name: 'rejected' },
612
+ { id: 'cancelled', name: 'cancelled' },
613
+ ];
614
+
615
+ res.render('admin/plugins/equipment-calendar-reservations', {
616
+ title: 'Equipment Calendar - Réservations',
617
+ settings,
618
+ rows: pageRows,
619
+ hasRows: pageRows.length > 0,
620
+ itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
621
+ statusOptions: statusOptions.map(o => ({ ...o, selected: o.id === status })),
622
+ q,
623
+ page: safePage,
624
+ perPage,
625
+ total,
626
+ totalAll,
627
+ totalPages,
628
+ prevPage: safePage > 1 ? safePage - 1 : 0,
629
+ nextPage: safePage < totalPages ? safePage + 1 : 0,
630
+ actionBase: '/admin/plugins/equipment-calendar/reservations',
631
+ });
632
+ }
633
+
634
+ async function handleAdminApprove(req, res) {
635
+ if (!(await ensureIsAdmin(req, res))) return;
636
+ const rid = String(req.params.rid || '').trim();
637
+ if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
638
+
639
+ const key = `equipmentCalendar:reservation:${rid}`;
640
+ const r = await db.getObject(key);
641
+ if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
642
+
643
+ r.status = 'approved';
644
+ await db.setObject(key, r);
645
+
646
+ return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
647
+ }
648
+
649
+ async function handleAdminReject(req, res) {
650
+ if (!(await ensureIsAdmin(req, res))) return;
651
+ const rid = String(req.params.rid || '').trim();
652
+ if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
653
+
654
+ const key = `equipmentCalendar:reservation:${rid}`;
655
+ const r = await db.getObject(key);
656
+ if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
657
+
658
+ r.status = 'rejected';
659
+ await db.setObject(key, r);
660
+
661
+ return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
662
+ }
663
+
664
+ async function handleAdminDelete(req, res) {
665
+ if (!(await ensureIsAdmin(req, res))) return;
666
+ const rid = String(req.params.rid || '').trim();
667
+ if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
668
+ await deleteReservation(rid);
669
+ return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
670
+ }
671
+
400
672
  async function renderAdminPage(req, res) {
401
673
  const settings = await getSettings();
402
674
  res.render('admin/plugins/equipment-calendar', {
403
675
  title: 'Equipment Calendar',
404
676
  settings,
405
677
  saved: req.query && String(req.query.saved || '') === '1',
678
+ purged: req.query && parseInt(req.query.purged, 10) || 0,
406
679
  view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
407
680
  view_timeGridWeek: (settings.defaultView || '') === 'timeGridWeek',
408
681
  view_timeGridDay: (settings.defaultView || '') === 'timeGridDay',
@@ -413,7 +686,7 @@ async function renderAdminPage(req, res) {
413
686
  // --- Calendar page ---
414
687
  async function renderCalendarPage(req, res) {
415
688
  const settings = await getSettings();
416
- const items = parseItems(settings.itemsJson).filter(i => i.active);
689
+ const items = await getActiveItems(settings);
417
690
 
418
691
  const tz = settings.timezone || 'Europe/Paris';
419
692
 
@@ -494,6 +767,79 @@ async function renderCalendarPage(req, res) {
494
767
 
495
768
 
496
769
  // --- Approvals page ---
770
+
771
+
772
+
773
+ async function notifyApprovers(reservations, settings) {
774
+ const groupName = (settings.notifyGroup || settings.approverGroup || 'administrators').trim();
775
+ if (!groupName) return;
776
+
777
+ const rid = reservations[0] && (reservations[0].rid || reservations[0].id) || '';
778
+ const path = '/equipment/approvals';
779
+
780
+ // In-app notification (NodeBB notifications)
781
+ try {
782
+ const Notifications = require.main.require('./src/notifications');
783
+ const notif = await Notifications.create({
784
+ bodyShort: 'Nouvelle demande de réservation',
785
+ bodyLong: 'Une nouvelle demande de réservation est en attente de validation.',
786
+ nid: 'equipment-calendar:' + rid,
787
+ path,
788
+ });
789
+ if (Notifications.pushGroup) {
790
+ await Notifications.pushGroup(notif, groupName);
791
+ }
792
+ } catch (e) {
793
+ // ignore
794
+ }
795
+
796
+ // Direct email to members of notifyGroup (uses NodeBB Emailer + your SMTP/emailer plugin)
797
+ try {
798
+ const Emailer = require.main.require('./src/emailer');
799
+ const uids = await groups.getMembers(groupName, 0, -1);
800
+ if (!uids || !uids.length) return;
801
+
802
+ // Build a short summary
803
+ const items = await getActiveItems(await getSettings());
804
+ const nameById = {};
805
+ items.forEach(i => { nameById[i.id] = i.name; });
806
+
807
+ const start = new Date(reservations[0].startMs).toISOString();
808
+ const end = new Date(reservations[0].endMs).toISOString();
809
+ const itemList = reservations.map(r => nameById[r.itemId] || r.itemId).join(', ');
810
+
811
+ // Use a core template ('notification') that exists in NodeBB installs
812
+ // Params vary a bit by version; we provide common fields.
813
+ const params = {
814
+ subject: '[Réservation] Nouvelle demande en attente',
815
+ intro: 'Une nouvelle demande de réservation a été créée.',
816
+ body: `Matériel: ${itemList}\nDébut: ${start}\nFin: ${end}\nVoir: ${path}`,
817
+ notification_url: path,
818
+ url: path,
819
+ // Some templates use "site_title" etc but NodeBB will inject globals
820
+ };
821
+
822
+ for (const uid of uids) {
823
+ // eslint-disable-next-line no-await-in-loop
824
+ const email = await user.getUserField(uid, 'email');
825
+ if (!email) continue;
826
+
827
+ // eslint-disable-next-line no-await-in-loop
828
+ const lang = (await user.getUserField(uid, 'language')) || 'fr';
829
+ if (Emailer.sendToEmail) {
830
+ // eslint-disable-next-line no-await-in-loop
831
+ await Emailer.sendToEmail('notification', email, lang, params);
832
+ } else if (Emailer.send) {
833
+ // Some NodeBB versions use Emailer.send(template, uid/email, params)
834
+ // eslint-disable-next-line no-await-in-loop
835
+ await Emailer.send('notification', uid, params);
836
+ }
837
+ }
838
+ } catch (e) {
839
+ // ignore email errors to not block reservation flow
840
+ }
841
+ }
842
+
497
843
  async function renderApprovalsPage(req, res) {
498
844
  const settings = await getSettings();
499
845
  const ok = req.uid ? await canApprove(req.uid, settings) : false;
@@ -542,6 +888,36 @@ async function renderApprovalsPage(req, res) {
542
888
  }
543
889
 
544
890
  // --- Actions ---
891
+
892
+ async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser) {
893
+ const items = await getActiveItems(settings);
894
+ const item = items.find(i => i.id === itemId);
895
+ if (!item) {
896
+ throw new Error('Unknown item: ' + itemId);
897
+ }
898
+ // Reject if overlaps any blocking reservation
899
+ const overlapsBlocking = await hasOverlap(itemId, startMs, endMs);
900
+ if (overlapsBlocking) {
901
+ const err = new Error('Item not available: ' + itemId);
902
+ err.code = 'ITEM_UNAVAILABLE';
903
+ throw err;
904
+ }
905
+ const rid = generateId();
906
+ const data = {
907
+ id: rid,
908
+ rid,
909
+ itemId,
910
+ uid: req.uid,
911
+ startMs,
912
+ endMs,
913
+ status: 'pending',
914
+ notesUser: notesUser || '',
915
+ createdAt: Date.now(),
916
+ };
917
+ await saveReservation(data);
918
+ return data;
919
+ }
920
+
545
921
  async function handleCreateReservation(req, res) {
546
922
  try {
547
923
  const settings = await getSettings();
@@ -549,8 +925,12 @@ async function handleCreateReservation(req, res) {
549
925
  return helpers.notAllowed(req, res);
550
926
  }
551
927
 
552
- const items = parseItems(settings.itemsJson).filter(i => i.active);
553
- const itemId = String(req.body.itemId || '').trim();
928
+ const items = await getActiveItems(settings);
929
+ const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
930
+ const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
931
+ if (!itemIds.length) {
932
+ return res.status(400).send('itemId required');
933
+ }
554
934
  const item = items.find(i => i.id === itemId);
555
935
  if (!item) return res.status(400).send('Invalid item');
556
936
 
@@ -685,6 +1065,86 @@ async function handleRejectReservation(req, res) {
685
1065
  }
686
1066
 
687
1067
 
1068
+
1069
+
1070
+ async function ensureIsAdmin(req, res) {
1071
+ const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
1072
+ if (!isAdmin) {
1073
+ helpers.notAllowed(req, res);
1074
+ return false;
1075
+ }
1076
+ return true;
1077
+ }
1078
+
1079
+ async function deleteReservation(rid) {
1080
+ const key = `equipmentCalendar:reservation:${rid}`;
1081
+ const data = await db.getObject(key);
1082
+ if (data && data.itemId) {
1083
+ await db.sortedSetRemove(`equipmentCalendar:item:${data.itemId}:reservations`, rid);
1084
+ }
1085
+ await db.delete(key);
1086
+ await db.sortedSetRemove('equipmentCalendar:reservations', rid);
1087
+ }
1088
+
1089
+ async function handleAdminPurge(req, res) {
1090
+ try {
1091
+ const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
1092
+ if (!isAdmin) {
1093
+ return helpers.notAllowed(req, res);
1094
+ }
1095
+
1096
+ const mode = String(req.body.mode || 'nonblocking'); // nonblocking|olderThan|all
1097
+ const olderThanDays = parseInt(req.body.olderThanDays, 10);
1098
+
1099
+ // Load all reservation ids
1100
+ const rids = await db.getSortedSetRange('equipmentCalendar:reservations', 0, -1);
1101
+ let deleted = 0;
1102
+
1103
+ const now = Date.now();
1104
+ for (const rid of rids) {
1105
+ // eslint-disable-next-line no-await-in-loop
1106
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
1107
+ if (!r || !r.rid) {
1108
+ // eslint-disable-next-line no-await-in-loop
1109
+ await db.sortedSetRemove('equipmentCalendar:reservations', rid);
1110
+ continue;
1111
+ }
1112
+
1113
+ const status = String(r.status || '');
1114
+ const createdAt = parseInt(r.createdAt, 10) || 0;
1115
+ const startMs = parseInt(r.startMs, 10) || 0;
1116
+ const endMs = parseInt(r.endMs, 10) || 0;
1117
+
1118
+ let shouldDelete = false;
1119
+
1120
+ if (mode === 'all') {
1121
+ shouldDelete = true;
1122
+ } else if (mode === 'olderThan') {
1123
+ const days = Number.isFinite(olderThanDays) ? olderThanDays : 0;
1124
+ if (days > 0) {
1125
+ const cutoff = now - days * 24 * 60 * 60 * 1000;
1126
+ // use createdAt if present, otherwise startMs
1127
+ const t = createdAt || startMs || endMs;
1128
+ shouldDelete = t > 0 && t < cutoff;
1129
+ }
1130
+ } else {
1131
+ // nonblocking cleanup: delete rejected/cancelled only
1132
+ shouldDelete = (status === 'rejected' || status === 'cancelled');
1133
+ }
1134
+
1135
+ if (shouldDelete) {
1136
+ // eslint-disable-next-line no-await-in-loop
1137
+ await deleteReservation(rid);
1138
+ deleted++;
1139
+ }
1140
+ }
1141
+
1142
+ return res.redirect(`/admin/plugins/equipment-calendar?saved=1&purged=${deleted}`);
1143
+ } catch (e) {
1144
+ return res.status(500).send(e.message || 'error');
1145
+ }
1146
+ }
1147
+
688
1148
  async function handleAdminSave(req, res) {
689
1149
  try {
690
1150
  // Simple admin check (avoid relying on client-side require/settings)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
5
  "main": "library.js",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -26,6 +26,6 @@
26
26
  "scripts": [
27
27
  "public/js/client.js"
28
28
  ],
29
- "version": "0.2.5",
29
+ "version": "0.3.1",
30
30
  "minver": "4.7.1"
31
31
  }
@@ -39,7 +39,7 @@
39
39
 
40
40
  const startInput = form.querySelector('input[name="start"]');
41
41
  const endInput = form.querySelector('input[name="end"]');
42
- const itemInput = form.querySelector('input[name="itemId"]');
42
+ const itemInput = form.querySelector('input[name="itemIds"]');
43
43
  const notesInput = form.querySelector('input[name="notesUser"]');
44
44
 
45
45
  if (startInput) startInput.value = startISO;
@@ -84,8 +84,8 @@
84
84
  <input class="form-control" type="text" value="${fmt(endDate)}" readonly>
85
85
  </div>
86
86
  <div class="mb-3">
87
- <label class="form-label">Matériel</label>
88
- <select class="form-select" id="ec-modal-item">
87
+ <label class="form-label">Matériel (Ctrl/Cmd pour multi-sélection)</label>
88
+ <select class="form-select" id="ec-modal-item" multiple size="6">
89
89
  ${optionsHtml}
90
90
  </select>
91
91
  </div>
@@ -116,9 +116,13 @@
116
116
  callback: function () {
117
117
  const itemEl = document.getElementById('ec-modal-item');
118
118
  const notesEl = document.getElementById('ec-modal-notes');
119
- const itemId = itemEl ? itemEl.value : available[0].id;
119
+ let ids = [];
120
+ if (itemEl && itemEl.options) {
121
+ for (const opt of itemEl.options) { if (opt.selected) ids.push(opt.value); }
122
+ }
123
+ if (!ids.length) ids = [available[0].id];
120
124
  const notes = notesEl ? notesEl.value : '';
121
- submitReservation(startDate.toISOString(), endDate.toISOString(), itemId, notes);
125
+ submitReservation(startDate.toISOString(), endDate.toISOString(), ids.join(','), notes);
122
126
  }
123
127
  }
124
128
  }
@@ -0,0 +1,104 @@
1
+ <div class="acp-page-container">
2
+ <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
3
+ <h1 class="mb-0">Equipment Calendar</h1>
4
+ <div class="btn-group">
5
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar">Paramètres</a>
6
+ <a class="btn btn-secondary active" href="/admin/plugins/equipment-calendar/reservations">Réservations</a>
7
+ </div>
8
+ </div>
9
+
10
+ <form method="get" action="/admin/plugins/equipment-calendar/reservations" class="card card-body mt-3 mb-3">
11
+ <div class="row g-2 align-items-end">
12
+ <div class="col-md-3">
13
+ <label class="form-label">Statut</label>
14
+ <select class="form-select" name="status">
15
+ {{{ each statusOptions }}}
16
+ <option value="{statusOptions.id}" {{{ if statusOptions.selected }}}selected{{{ end }}}>{statusOptions.name}</option>
17
+ {{{ end }}}
18
+ </select>
19
+ </div>
20
+ <div class="col-md-4">
21
+ <label class="form-label">Matériel</label>
22
+ <select class="form-select" name="itemId">
23
+ {{{ each itemOptions }}}
24
+ <option value="{itemOptions.id}" {{{ if itemOptions.selected }}}selected{{{ end }}}>{itemOptions.name}</option>
25
+ {{{ end }}}
26
+ </select>
27
+ </div>
28
+ <div class="col-md-3">
29
+ <label class="form-label">Recherche</label>
30
+ <input class="form-control" name="q" value="{q}" placeholder="rid, uid, note">
31
+ </div>
32
+ <div class="col-md-2">
33
+ <button class="btn btn-primary w-100" type="submit">Filtrer</button>
34
+ </div>
35
+ </div>
36
+ </form>
37
+
38
+ <div class="text-muted small mb-2">
39
+ Total (filtré) : <strong>{total}</strong> — Total (global) : <strong>{totalAll}</strong>
40
+ </div>
41
+
42
+ {{{ if hasRows }}}
43
+ <div class="table-responsive">
44
+ <table class="table table-striped align-middle">
45
+ <thead>
46
+ <tr>
47
+ <th>RID</th>
48
+ <th>Matériel</th>
49
+ <th>UID</th>
50
+ <th>Début</th>
51
+ <th>Fin</th>
52
+ <th>Statut</th>
53
+ <th>Créé</th>
54
+ <th>Note</th>
55
+ <th>Actions</th>
56
+ </tr>
57
+ </thead>
58
+ <tbody>
59
+ {{{ each rows }}}
60
+ <tr>
61
+ <td><code>{rows.rid}</code></td>
62
+ <td>{rows.itemName}</td>
63
+ <td>{rows.uid}</td>
64
+ <td><small>{rows.start}</small></td>
65
+ <td><small>{rows.end}</small></td>
66
+ <td><code>{rows.status}</code></td>
67
+ <td><small>{rows.createdAt}</small></td>
68
+ <td><small>{rows.notesUser}</small></td>
69
+ <td class="text-nowrap">
70
+ <form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/approve" class="d-inline">
71
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
72
+ <button class="btn btn-sm btn-success" type="submit">Approve</button>
73
+ </form>
74
+ <form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/reject" class="d-inline ms-1">
75
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
76
+ <button class="btn btn-sm btn-warning" type="submit">Reject</button>
77
+ </form>
78
+ <form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/delete" class="d-inline ms-1" onsubmit="return confirm('Supprimer définitivement ?');">
79
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
80
+ <button class="btn btn-sm btn-danger" type="submit">Delete</button>
81
+ </form>
82
+ </td>
83
+ </tr>
84
+ {{{ end }}}
85
+ </tbody>
86
+ </table>
87
+ </div>
88
+
89
+ <div class="d-flex justify-content-between align-items-center mt-3">
90
+ <div>Page {page} / {totalPages}</div>
91
+ <div class="btn-group">
92
+ {{{ if prevPage }}}
93
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar/reservations?page={prevPage}&perPage={perPage}">Précédent</a>
94
+ {{{ end }}}
95
+ {{{ if nextPage }}}
96
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar/reservations?page={nextPage}&perPage={perPage}">Suivant</a>
97
+ {{{ end }}}
98
+ </div>
99
+ </div>
100
+
101
+ {{{ else }}}
102
+ <div class="alert alert-info">Aucune réservation trouvée.</div>
103
+ {{{ end }}}
104
+ </div>
@@ -1,5 +1,11 @@
1
1
  <div class="acp-page-container">
2
- <h1>Equipment Calendar</h1>
2
+ <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
3
+ <h1 class="mb-0">Equipment Calendar</h1>
4
+ <div class="btn-group">
5
+ <a class="btn btn-secondary active" href="/admin/plugins/equipment-calendar">Paramètres</a>
6
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar/reservations">Réservations</a>
7
+ </div>
8
+ </div>
3
9
 
4
10
  {{{ if saved }}}
5
11
  <div class="alert alert-success">Paramètres enregistrés.</div>
@@ -49,7 +55,33 @@
49
55
 
50
56
  <div class="card card-body mb-3">
51
57
  <h5>Matériel</h5>
52
- <textarea name="itemsJson" class="form-control" rows="10">{settings.itemsJson}</textarea>
58
+ <div class="mb-3">
59
+ <label class="form-label">Source</label>
60
+ <select class="form-select" name="itemsSource">
61
+ <option value="manual">Manuel (JSON)</option>
62
+ <option value="helloasso">HelloAsso (articles d’un formulaire)</option>
63
+ </select>
64
+ <div class="form-text">Si HelloAsso est choisi, la liste du matériel est récupérée via l’API HelloAsso (items d’un formulaire).</div>
65
+ </div>
66
+
67
+ <div class="mb-3">
68
+ <label class="form-label">HelloAsso formType (ex: shop, event, membership, donation)</label>
69
+ <input class="form-control" name="ha_itemsFormType" value="{settings.ha_itemsFormType}">
70
+ </div>
71
+ <div class="mb-3">
72
+ <label class="form-label">HelloAsso formSlug</label>
73
+ <input class="form-control" name="ha_itemsFormSlug" value="{settings.ha_itemsFormSlug}">
74
+ </div>
75
+ <div class="mb-3">
76
+ <label class="form-label">Mapping lieux (JSON) par id d’article HelloAsso</label>
77
+ <textarea class="form-control" rows="4" name="ha_locationMapJson">{settings.ha_locationMapJson}</textarea>
78
+ <div class="form-text">Ex: { "12345": "Local A", "67890": "Local B" }</div>
79
+ </div>
80
+
81
+ <div class="mb-3">
82
+ <label class="form-label">Matériel manuel (JSON) — utilisé uniquement si Source=Manuel</label>
83
+ <textarea name="itemsJson" class="form-control" rows="10">{settings.itemsJson}</textarea>
84
+ </div>
53
85
  </div>
54
86
 
55
87
  <div class="card card-body mb-3">
@@ -74,7 +106,47 @@
74
106
  <div class="mb-3"><label class="form-label">Timezone</label><input name="timezone" class="form-control" value="{settings.timezone}"></div>
75
107
  </div>
76
108
 
109
+
110
+ <div class="card card-body mb-3">
111
+ <h5>Purge</h5>
112
+ <p class="text-muted mb-2">Supprime des réservations de la base (action irréversible).</p>
113
+
114
+ <div class="d-flex flex-wrap gap-2">
115
+ <button type="submit" class="btn btn-outline-danger" formaction="/admin/plugins/equipment-calendar/purge" formmethod="post" name="mode" value="nonblocking"
116
+ onclick="return confirm('Supprimer toutes les réservations refusées/annulées ?');">
117
+ Purger refusées/annulées
118
+ </button>
119
+
120
+ <div class="d-flex align-items-center gap-2">
121
+ <input class="form-control" style="max-width: 140px" type="number" min="1" name="olderThanDays" placeholder="Jours">
122
+ <button type="submit" class="btn btn-outline-danger" formaction="/admin/plugins/equipment-calendar/purge" formmethod="post" name="mode" value="olderThan"
123
+ onclick="return confirm('Supprimer les réservations plus anciennes que N jours ?');">
124
+ Purger plus anciennes que…
125
+ </button>
126
+ </div>
127
+
128
+ <button type="submit" class="btn btn-danger" formaction="/admin/plugins/equipment-calendar/purge" formmethod="post" name="mode" value="all"
129
+ onclick="return confirm('⚠️ Supprimer TOUTES les réservations ?');">
130
+ Tout purger
131
+ </button>
132
+ </div>
133
+
134
+ {{{ if purged }}}
135
+ <div class="alert alert-success mt-3">Purge effectuée : {purged} réservation(s) supprimée(s).</div>
136
+ <script>
137
+ (function () {
138
+ try {
139
+ if (window.app && window.app.alertSuccess) {
140
+ window.app.alertSuccess('Purge effectuée : {purged} réservation(s) supprimée(s).');
141
+ }
142
+ } catch (e) {}
143
+ }());
144
+ </script>
145
+ {{{ end }}}
146
+ </div>
147
+
77
148
  <button id="ec-save" class="btn btn-primary" type="button">Sauvegarder</button>
149
+
78
150
  </form>
79
151
  </div>
80
152
 
@@ -18,7 +18,7 @@
18
18
  <input type="hidden" name="_csrf" value="{config.csrf_token}">
19
19
  <input type="hidden" name="start" value="">
20
20
  <input type="hidden" name="end" value="">
21
- <input type="hidden" name="itemId" value="">
21
+ <input type="hidden" name="itemIds" value="">
22
22
  <input type="hidden" name="notesUser" value="">
23
23
  </form>
24
24
  </div>