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 +463 -3
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/js/client.js +9 -5
- package/public/templates/admin/plugins/equipment-calendar-reservations.tpl +104 -0
- package/public/templates/admin/plugins/equipment-calendar.tpl +74 -2
- package/public/templates/equipment-calendar/calendar.tpl +1 -1
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 =
|
|
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 =
|
|
553
|
-
const
|
|
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
package/plugin.json
CHANGED
package/public/js/client.js
CHANGED
|
@@ -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="
|
|
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
|
-
|
|
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(),
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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="
|
|
21
|
+
<input type="hidden" name="itemIds" value="">
|
|
22
22
|
<input type="hidden" name="notesUser" value="">
|
|
23
23
|
</form>
|
|
24
24
|
</div>
|