nodebb-plugin-calendar-onekite 2.1.2 → 10.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/language/en-GB/calendar-onekite.json +4 -0
- package/library.js +479 -741
- package/package.json +4 -17
- package/plugin.json +22 -18
- package/public/css/calendar-onekite.css +14 -0
- package/public/js/admin/calendar-onekite-admin.js +81 -0
- package/public/js/calendar-onekite.js +186 -0
- package/templates/admin/plugins/calendar-onekite.tpl +104 -65
- package/templates/calendar-onekite/calendar.tpl +18 -0
- package/.drone.yml +0 -62
- package/README.md +0 -28
- package/helloasso.js +0 -129
- package/static/css/calendar-onekite.css +0 -4
- package/static/js/admin-planning.bundle.js +0 -26
- package/static/js/admin-planning.js +0 -133
- package/static/js/admin.bundle.js +0 -26
- package/static/js/admin.js +0 -193
- package/static/js/calendar-my-reservations.js +0 -55
- package/static/js/calendar.bundle.js +0 -63
- package/static/js/calendar.js +0 -454
- package/static/js/my-reservations.bundle.js +0 -42
- package/templates/admin/calendar-planning.tpl +0 -33
- package/templates/calendar-my-reservations.tpl +0 -31
- package/templates/calendar.tpl +0 -104
- package/templates/emails/calendar-payment-confirmed.tpl +0 -1
- package/templates/emails/calendar-reservation-approved.tpl +0 -1
- package/templates/emails/calendar-reservation-created.tpl +0 -1
- package/templates/widgets/calendar-upcoming.tpl +0 -14
package/library.js
CHANGED
|
@@ -1,846 +1,584 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
|
|
3
5
|
const db = require.main.require('./src/database');
|
|
4
|
-
const user = require.main.require('./src/user');
|
|
5
6
|
const meta = require.main.require('./src/meta');
|
|
7
|
+
const user = require.main.require('./src/user');
|
|
8
|
+
const groups = require.main.require('./src/groups');
|
|
6
9
|
const emailer = require.main.require('./src/emailer');
|
|
10
|
+
const helpers = require.main.require('./src/controllers/helpers');
|
|
11
|
+
const privileges = require.main.require('./src/privileges');
|
|
7
12
|
|
|
8
|
-
const
|
|
9
|
-
const helloAsso = require('./helloasso');
|
|
10
|
-
|
|
11
|
-
const Plugin = {};
|
|
12
|
-
let appRef = null;
|
|
13
|
-
|
|
14
|
-
const SETTINGS_KEY = 'calendar-onekite';
|
|
15
|
-
const EVENTS_SET_KEY = 'calendar:events:start';
|
|
16
|
-
const EVENT_KEY_PREFIX = 'calendar:event:';
|
|
17
|
-
|
|
18
|
-
function getEventKey(eid) {
|
|
19
|
-
return EVENT_KEY_PREFIX + eid;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function mountApiBoth(router, method, path, ...handlers) {
|
|
23
|
-
const m = String(method).toLowerCase();
|
|
24
|
-
router[m]('/api' + path, ...handlers);
|
|
25
|
-
router[m]('/api/v3' + path, ...handlers);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function getParsedSettings() {
|
|
29
|
-
const s = (await Settings.get(SETTINGS_KEY)) || {};
|
|
30
|
-
const parseJson = (str, fallback) => {
|
|
31
|
-
try { return JSON.parse(str || ''); } catch { return fallback; }
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const locations = parseJson(s.locationsJson, [
|
|
35
|
-
{ id: 'arnaud', name: 'Chez Arnaud' },
|
|
36
|
-
{ id: 'siege', name: 'Siège Onekite' },
|
|
37
|
-
]);
|
|
13
|
+
const plugin = {};
|
|
38
14
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
15
|
+
const PLUGIN_NS = 'calendar-onekite';
|
|
16
|
+
const RES_PREFIX = `calendar-onekite:reservation`;
|
|
17
|
+
const RES_ZSET_BY_START = `calendar-onekite:reservations:byStart`;
|
|
42
18
|
|
|
43
|
-
|
|
44
|
-
}
|
|
19
|
+
let helloassoTokenCache = { token: null, expiresAt: 0 };
|
|
20
|
+
let itemsCache = { items: null, expiresAt: 0 };
|
|
45
21
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return String(rid);
|
|
22
|
+
function nowMs() {
|
|
23
|
+
return Date.now();
|
|
49
24
|
}
|
|
50
25
|
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
const d2 = new Date(end);
|
|
54
|
-
if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return 1;
|
|
55
|
-
const t1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate());
|
|
56
|
-
const t2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate());
|
|
57
|
-
const diff = Math.round((t2 - t1) / (1000 * 60 * 60 * 24));
|
|
58
|
-
return Math.max(1, diff + 1);
|
|
26
|
+
function safeJsonParse(s, fallback) {
|
|
27
|
+
try { return JSON.parse(s); } catch (e) { return fallback; }
|
|
59
28
|
}
|
|
60
29
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
allDay: data.allDay ? 1 : 0,
|
|
82
|
-
location: String(data.location || ''),
|
|
83
|
-
createdByUid: String(uid || 0),
|
|
84
|
-
createdAt: String(now),
|
|
85
|
-
updatedAt: String(now),
|
|
86
|
-
|
|
87
|
-
rsvpYes: '[]',
|
|
88
|
-
rsvpMaybe: '[]',
|
|
89
|
-
rsvpNo: '[]',
|
|
90
|
-
|
|
91
|
-
visibility: String(data.visibility || 'public'),
|
|
92
|
-
|
|
93
|
-
bookingEnabled: data.bookingEnabled ? 1 : 0,
|
|
94
|
-
bookingItems: JSON.stringify(bookingItemIds), // stores IDs only
|
|
95
|
-
reservations: JSON.stringify([]),
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
await db.setObject(key, eventObj);
|
|
99
|
-
await db.sortedSetAdd(EVENTS_SET_KEY, startTs, eid);
|
|
100
|
-
return eventObj;
|
|
30
|
+
async function getSettings() {
|
|
31
|
+
const settings = await meta.settings.get(PLUGIN_NS);
|
|
32
|
+
// defaults
|
|
33
|
+
settings.enabled = settings.enabled === 'on' || settings.enabled === true;
|
|
34
|
+
settings.holdMinutes = parseInt(settings.holdMinutes || '60', 10);
|
|
35
|
+
settings.readerGroups = (settings.readerGroups || '').trim(); // currently unused (calendar page is public; NodeBB category perms cover it)
|
|
36
|
+
settings.requesterGroups = (settings.requesterGroups || '').trim(); // who can create requests
|
|
37
|
+
settings.approverGroups = (settings.approverGroups || '').trim(); // who can approve/reject
|
|
38
|
+
settings.notifyEmails = (settings.notifyEmails || '').trim(); // optional extra comma-separated emails
|
|
39
|
+
settings.helloassoEnv = (settings.helloassoEnv || 'sandbox').trim(); // sandbox|prod
|
|
40
|
+
settings.helloassoClientId = (settings.helloassoClientId || '').trim();
|
|
41
|
+
settings.helloassoClientSecret = (settings.helloassoClientSecret || '').trim();
|
|
42
|
+
settings.helloassoOrganizationSlug = (settings.helloassoOrganizationSlug || '').trim();
|
|
43
|
+
settings.helloassoFormType = (settings.helloassoFormType || 'event').trim();
|
|
44
|
+
settings.helloassoFormSlug = (settings.helloassoFormSlug || '').trim();
|
|
45
|
+
settings.helloassoBackUrl = (settings.helloassoBackUrl || '').trim();
|
|
46
|
+
settings.helloassoErrorUrl = (settings.helloassoErrorUrl || '').trim();
|
|
47
|
+
settings.helloassoReturnUrl = (settings.helloassoReturnUrl || '').trim();
|
|
48
|
+
settings.itemsCacheMinutes = parseInt(settings.itemsCacheMinutes || '360', 10);
|
|
49
|
+
return settings;
|
|
101
50
|
}
|
|
102
51
|
|
|
103
|
-
|
|
104
|
-
return
|
|
52
|
+
function apiBase(env) {
|
|
53
|
+
return env === 'prod' ? 'https://api.helloasso.com' : 'https://api.helloasso-sandbox.com';
|
|
105
54
|
}
|
|
106
55
|
|
|
107
|
-
async function
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
56
|
+
async function getHelloAssoToken(settings) {
|
|
57
|
+
const baseUrl = apiBase(settings.helloassoEnv);
|
|
58
|
+
const cacheOk = helloassoTokenCache.token && helloassoTokenCache.expiresAt > nowMs() + 30_000;
|
|
59
|
+
if (cacheOk) {
|
|
60
|
+
return helloassoTokenCache.token;
|
|
61
|
+
}
|
|
62
|
+
if (!settings.helloassoClientId || !settings.helloassoClientSecret) {
|
|
63
|
+
throw new Error('HelloAsso clientId/clientSecret not configured');
|
|
64
|
+
}
|
|
114
65
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
})();
|
|
123
|
-
|
|
124
|
-
const bookingItemIds = Array.isArray(data.bookingItemIds)
|
|
125
|
-
? data.bookingItemIds.map(String)
|
|
126
|
-
: existingIds;
|
|
127
|
-
|
|
128
|
-
const updated = {
|
|
129
|
-
...existing,
|
|
130
|
-
title: data.title !== undefined ? String(data.title) : existing.title,
|
|
131
|
-
description: data.description !== undefined ? String(data.description) : existing.description,
|
|
132
|
-
start: new Date(startTs).toISOString(),
|
|
133
|
-
end: new Date(endTs).toISOString(),
|
|
134
|
-
allDay: data.allDay !== undefined ? (data.allDay ? 1 : 0) : existing.allDay,
|
|
135
|
-
location: data.location !== undefined ? String(data.location) : existing.location,
|
|
136
|
-
visibility: data.visibility !== undefined ? String(data.visibility) : existing.visibility,
|
|
137
|
-
bookingEnabled: data.bookingEnabled !== undefined ? (data.bookingEnabled ? 1 : 0) : (existing.bookingEnabled || 0),
|
|
138
|
-
bookingItems: JSON.stringify(bookingItemIds),
|
|
139
|
-
updatedAt: String(Date.now()),
|
|
140
|
-
};
|
|
66
|
+
const url = `${baseUrl}/oauth2/token`;
|
|
67
|
+
// client_credentials
|
|
68
|
+
const body = new URLSearchParams({
|
|
69
|
+
grant_type: 'client_credentials',
|
|
70
|
+
client_id: settings.helloassoClientId,
|
|
71
|
+
client_secret: settings.helloassoClientSecret,
|
|
72
|
+
});
|
|
141
73
|
|
|
142
|
-
await
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
74
|
+
const res = await axios.post(url, body.toString(), {
|
|
75
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
76
|
+
timeout: 15_000,
|
|
77
|
+
});
|
|
146
78
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
79
|
+
const token = res.data && res.data.access_token;
|
|
80
|
+
const expiresIn = (res.data && res.data.expires_in) ? parseInt(res.data.expires_in, 10) : 1800;
|
|
81
|
+
if (!token) throw new Error('HelloAsso token response missing access_token');
|
|
82
|
+
helloassoTokenCache = { token, expiresAt: nowMs() + (expiresIn * 1000) };
|
|
83
|
+
return token;
|
|
150
84
|
}
|
|
151
85
|
|
|
152
|
-
async function
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, startTs, endTs);
|
|
156
|
-
if (!eids || !eids.length) return [];
|
|
86
|
+
async function fetchItemsFromHelloAsso(settings) {
|
|
87
|
+
const baseUrl = apiBase(settings.helloassoEnv);
|
|
88
|
+
const token = await getHelloAssoToken(settings);
|
|
157
89
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return (events || []).filter(Boolean);
|
|
162
|
-
}
|
|
90
|
+
if (!settings.helloassoOrganizationSlug || !settings.helloassoFormType || !settings.helloassoFormSlug) {
|
|
91
|
+
throw new Error('HelloAsso organizationSlug/formType/formSlug not configured');
|
|
92
|
+
}
|
|
163
93
|
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
94
|
+
const url = `${baseUrl}/v5/organizations/${encodeURIComponent(settings.helloassoOrganizationSlug)}/forms/${encodeURIComponent(settings.helloassoFormType)}/${encodeURIComponent(settings.helloassoFormSlug)}/items`;
|
|
95
|
+
const res = await axios.get(url, {
|
|
96
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
97
|
+
timeout: 15_000,
|
|
98
|
+
});
|
|
99
|
+
// API returns array of items (docs), sometimes wrapped; we normalize
|
|
100
|
+
const items = Array.isArray(res.data) ? res.data : (res.data && (res.data.data || res.data.items) ? (res.data.data || res.data.items) : []);
|
|
101
|
+
return items;
|
|
171
102
|
}
|
|
172
103
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (!uid || uid === 0) return false;
|
|
177
|
-
const settings = await Settings.get(SETTINGS_KEY);
|
|
178
|
-
if (!settings || !settings.allowedGroups) return false;
|
|
179
|
-
|
|
180
|
-
const allowedSet = new Set(
|
|
181
|
-
settings.allowedGroups.split(',').map(g => g.trim().toLowerCase()).filter(Boolean)
|
|
182
|
-
);
|
|
183
|
-
if (!allowedSet.size) return false;
|
|
104
|
+
async function getItems(settings) {
|
|
105
|
+
const cacheOk = itemsCache.items && itemsCache.expiresAt > nowMs();
|
|
106
|
+
if (cacheOk) return itemsCache.items;
|
|
184
107
|
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
108
|
+
const items = await fetchItemsFromHelloAsso(settings);
|
|
109
|
+
const ttl = Math.max(1, settings.itemsCacheMinutes) * 60_000;
|
|
110
|
+
itemsCache = { items, expiresAt: nowMs() + ttl };
|
|
111
|
+
return items;
|
|
188
112
|
}
|
|
189
113
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const allowedSet = new Set(
|
|
197
|
-
settings.allowedBookingGroups.split(',').map(g => g.trim().toLowerCase()).filter(Boolean)
|
|
198
|
-
);
|
|
199
|
-
if (!allowedSet.size) return true;
|
|
200
|
-
|
|
201
|
-
const userGroupsArr = await user.getUserGroups([uid]);
|
|
202
|
-
const groups = (userGroupsArr[0] || []).map(g => (g.name || '').toLowerCase());
|
|
203
|
-
return groups.some(g => allowedSet.has(g));
|
|
114
|
+
function splitGroups(s) {
|
|
115
|
+
return (s || '')
|
|
116
|
+
.split(',')
|
|
117
|
+
.map(x => x.trim())
|
|
118
|
+
.filter(Boolean);
|
|
204
119
|
}
|
|
205
120
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
let yes = parseList(event.rsvpYes);
|
|
218
|
-
let maybe = parseList(event.rsvpMaybe);
|
|
219
|
-
let no = parseList(event.rsvpNo);
|
|
220
|
-
const u = String(uid);
|
|
221
|
-
|
|
222
|
-
yes = yes.filter(id => id !== u);
|
|
223
|
-
maybe = maybe.filter(id => id !== u);
|
|
224
|
-
no = no.filter(id => id !== u);
|
|
225
|
-
|
|
226
|
-
if (status === 'yes') yes.push(u);
|
|
227
|
-
if (status === 'maybe') maybe.push(u);
|
|
228
|
-
if (status === 'no') no.push(u);
|
|
229
|
-
|
|
230
|
-
const updated = {
|
|
231
|
-
...event,
|
|
232
|
-
rsvpYes: JSON.stringify(yes),
|
|
233
|
-
rsvpMaybe: JSON.stringify(maybe),
|
|
234
|
-
rsvpNo: JSON.stringify(no),
|
|
235
|
-
updatedAt: String(Date.now()),
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
await db.setObject(key, updated);
|
|
239
|
-
return updated;
|
|
121
|
+
async function isUserInAnyGroup(uid, groupNames) {
|
|
122
|
+
if (!uid) return false;
|
|
123
|
+
if (!groupNames || !groupNames.length) return false;
|
|
124
|
+
for (const g of groupNames) {
|
|
125
|
+
// allows special names like 'administrators'
|
|
126
|
+
const isMember = await groups.isMember(uid, g);
|
|
127
|
+
if (isMember) return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
240
130
|
}
|
|
241
131
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const ev = await getEvent(eid);
|
|
249
|
-
if (!ev) continue;
|
|
250
|
-
const resList = (() => {
|
|
251
|
-
try { return JSON.parse(ev.reservations || '[]'); } catch { return []; }
|
|
252
|
-
})();
|
|
253
|
-
for (const r of resList) {
|
|
254
|
-
out.push({ event: ev, reservation: r });
|
|
255
|
-
}
|
|
132
|
+
async function assertRequesterPrivileges(req, settings) {
|
|
133
|
+
if (!req.uid) throw new Error('not-authenticated');
|
|
134
|
+
const requesterGroups = splitGroups(settings.requesterGroups);
|
|
135
|
+
if (!requesterGroups.length) {
|
|
136
|
+
// if not set, fallback: any logged-in user can request
|
|
137
|
+
return true;
|
|
256
138
|
}
|
|
257
|
-
|
|
139
|
+
const ok = await isUserInAnyGroup(req.uid, requesterGroups);
|
|
140
|
+
if (!ok) throw new Error('not-allowed');
|
|
141
|
+
return true;
|
|
258
142
|
}
|
|
259
143
|
|
|
260
|
-
|
|
144
|
+
async function assertApproverPrivileges(req, settings) {
|
|
145
|
+
if (!req.uid) throw new Error('not-authenticated');
|
|
146
|
+
// admins always ok
|
|
147
|
+
const isAdmin = await user.isAdministrator(req.uid);
|
|
148
|
+
if (isAdmin) return true;
|
|
261
149
|
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
return
|
|
150
|
+
const approverGroups = splitGroups(settings.approverGroups);
|
|
151
|
+
const ok = await isUserInAnyGroup(req.uid, approverGroups);
|
|
152
|
+
if (!ok) throw new Error('not-allowed');
|
|
153
|
+
return true;
|
|
266
154
|
}
|
|
267
155
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (!
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
{ id: 'siege', name: 'Siège Onekite' },
|
|
282
|
-
], null, 2);
|
|
283
|
-
}
|
|
284
|
-
if (!settings.inventoryJson) {
|
|
285
|
-
settings.inventoryJson = JSON.stringify([
|
|
286
|
-
{ id: 'wing', name: 'Aile Wing', price: 5, stockByLocation: { arnaud: 1, siege: 0 } },
|
|
287
|
-
], null, 2);
|
|
156
|
+
async function cleanupExpiredPending(settings) {
|
|
157
|
+
const now = nowMs();
|
|
158
|
+
// scan upcoming + recent; since we don't have range query in db zset, take last 2000
|
|
159
|
+
const ids = await db.getSortedSetRevRange(RES_ZSET_BY_START, 0, 2000);
|
|
160
|
+
if (!ids.length) return;
|
|
161
|
+
|
|
162
|
+
const keys = ids.map(id => `${RES_PREFIX}:${id}`);
|
|
163
|
+
const objs = await db.getObjects(keys);
|
|
164
|
+
const toDelete = [];
|
|
165
|
+
for (const obj of (objs || [])) {
|
|
166
|
+
if (!obj) continue;
|
|
167
|
+
if (obj.status === 'pending' && obj.expiresAt && parseInt(obj.expiresAt, 10) < now) {
|
|
168
|
+
toDelete.push(obj.id);
|
|
288
169
|
}
|
|
289
|
-
|
|
290
|
-
res.render('admin/plugins/calendar-onekite', { title: 'Calendar OneKite', settings });
|
|
291
|
-
} catch (err) {
|
|
292
|
-
res.status(500).json({ error: err.message });
|
|
293
170
|
}
|
|
294
|
-
|
|
171
|
+
if (!toDelete.length) return;
|
|
295
172
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const result = [];
|
|
301
|
-
const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
|
|
302
|
-
for (const eid of eids) {
|
|
303
|
-
const event = await getEvent(eid);
|
|
304
|
-
if (!event) continue;
|
|
305
|
-
const reservations = JSON.parse(event.reservations || '[]');
|
|
306
|
-
const pending = reservations.filter(r => r.status === 'pending_admin');
|
|
307
|
-
if (pending.length) result.push({ event, reservations: pending });
|
|
308
|
-
}
|
|
309
|
-
res.json(result);
|
|
310
|
-
} catch (err) {
|
|
311
|
-
res.status(500).json({ error: err.message });
|
|
312
|
-
}
|
|
173
|
+
await Promise.all(toDelete.map(async (id) => {
|
|
174
|
+
await db.delete(`${RES_PREFIX}:${id}`);
|
|
175
|
+
await db.sortedSetRemove(RES_ZSET_BY_START, id);
|
|
176
|
+
}));
|
|
313
177
|
}
|
|
314
178
|
|
|
315
|
-
async function
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
res.json({ status: 'ok' });
|
|
319
|
-
} catch (err) {
|
|
320
|
-
res.status(500).json({ error: err.message });
|
|
321
|
-
}
|
|
179
|
+
async function nextReservationId() {
|
|
180
|
+
const n = await db.incrObjectField(`calendar-onekite:ids`, 'reservation');
|
|
181
|
+
return String(n);
|
|
322
182
|
}
|
|
323
183
|
|
|
324
|
-
async function
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const resList = JSON.parse(event.reservations || '[]');
|
|
343
|
-
const idx = resList.findIndex(r => String(r.rid) === String(rid));
|
|
344
|
-
resList[idx] = reservation;
|
|
345
|
-
event.reservations = JSON.stringify(resList);
|
|
346
|
-
await db.setObject(getEventKey(event.eid), event);
|
|
347
|
-
|
|
348
|
-
const settings = await getParsedSettings();
|
|
349
|
-
const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
|
|
350
|
-
const amount = computePrice(invItem, reservation);
|
|
351
|
-
|
|
352
|
-
const checkoutUrl = await helloAsso.createHelloAssoCheckoutIntent({
|
|
353
|
-
eid: reservation.eid,
|
|
354
|
-
rid,
|
|
355
|
-
uid: reservation.uid,
|
|
356
|
-
itemId: reservation.itemId,
|
|
357
|
-
quantity: reservation.quantity,
|
|
358
|
-
amount,
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
try {
|
|
362
|
-
await emailer.send('calendar-reservation-approved', reservation.uid, {
|
|
363
|
-
subject: 'Votre réservation a été validée',
|
|
364
|
-
eventTitle: event.title,
|
|
365
|
-
itemName: invItem?.name || reservation.itemId,
|
|
366
|
-
quantity: reservation.quantity,
|
|
367
|
-
checkoutUrl,
|
|
368
|
-
pickupLocation: reservation.pickupLocationName || reservation.locationId || 'Non précisé',
|
|
369
|
-
dateStart: reservation.dateStart,
|
|
370
|
-
dateEnd: reservation.dateEnd,
|
|
371
|
-
days: reservation.days || 1,
|
|
372
|
-
});
|
|
373
|
-
} catch (e) {
|
|
374
|
-
console.warn('[calendar-onekite] email reservation-approved error:', e.message);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
res.json({ success: true, checkoutUrl });
|
|
378
|
-
} catch (err) {
|
|
379
|
-
res.status(500).json({ error: err.message });
|
|
380
|
-
}
|
|
184
|
+
async function reservationToEvent(obj) {
|
|
185
|
+
const status = obj.status || 'pending';
|
|
186
|
+
const icon = status === 'approved' ? '✅' : (status === 'rejected' ? '❌' : '⏳');
|
|
187
|
+
const title = `${icon} ${obj.itemName || 'Matériel'}${obj.requesterName ? ' — ' + obj.requesterName : ''}`;
|
|
188
|
+
return {
|
|
189
|
+
id: obj.id,
|
|
190
|
+
title,
|
|
191
|
+
start: obj.start,
|
|
192
|
+
end: obj.end,
|
|
193
|
+
extendedProps: {
|
|
194
|
+
status,
|
|
195
|
+
itemId: obj.itemId,
|
|
196
|
+
itemName: obj.itemName,
|
|
197
|
+
requesterUid: obj.uid,
|
|
198
|
+
requesterName: obj.requesterName,
|
|
199
|
+
paymentUrl: obj.paymentUrl || '',
|
|
200
|
+
},
|
|
201
|
+
};
|
|
381
202
|
}
|
|
382
203
|
|
|
383
|
-
async function
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
for (const eid of eids) {
|
|
390
|
-
const event = await getEvent(eid);
|
|
391
|
-
if (!event) continue;
|
|
392
|
-
const reservations = JSON.parse(event.reservations || '[]');
|
|
393
|
-
const idx = reservations.findIndex(r => String(r.rid) === String(rid));
|
|
394
|
-
if (idx !== -1) {
|
|
395
|
-
reservations[idx].status = 'cancelled';
|
|
396
|
-
event.reservations = JSON.stringify(reservations);
|
|
397
|
-
await db.setObject(getEventKey(event.eid), event);
|
|
398
|
-
found = true;
|
|
399
|
-
break;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
204
|
+
async function getReservationsInRange(startISO, endISO) {
|
|
205
|
+
// naive: get latest N and filter
|
|
206
|
+
const ids = await db.getSortedSetRange(RES_ZSET_BY_START, 0, 5000);
|
|
207
|
+
if (!ids.length) return [];
|
|
208
|
+
const keys = ids.map(id => `${RES_PREFIX}:${id}`);
|
|
209
|
+
const objs = await db.getObjects(keys);
|
|
402
210
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
} catch (err) {
|
|
406
|
-
res.status(500).json({ error: err.message });
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
async function adminGetPlanning(req, res) {
|
|
411
|
-
try {
|
|
412
|
-
const settings = await getParsedSettings();
|
|
413
|
-
const locMap = new Map(settings.locations.map(l => [String(l.id), l.name || l.id]));
|
|
414
|
-
const invMap = new Map(settings.inventory.map(i => [String(i.id), i.name || i.id]));
|
|
415
|
-
|
|
416
|
-
const all = await getAllReservations();
|
|
417
|
-
const rows = [];
|
|
418
|
-
|
|
419
|
-
for (const row of all) {
|
|
420
|
-
const event = row.event;
|
|
421
|
-
const r = row.reservation;
|
|
422
|
-
if (!r) continue;
|
|
423
|
-
if (!['pending_admin', 'awaiting_payment', 'paid'].includes(String(r.status))) continue;
|
|
424
|
-
|
|
425
|
-
rows.push({
|
|
426
|
-
rid: r.rid,
|
|
427
|
-
eid: event.eid,
|
|
428
|
-
eventTitle: event.title,
|
|
429
|
-
itemId: r.itemId,
|
|
430
|
-
itemName: invMap.get(String(r.itemId)) || r.itemId,
|
|
431
|
-
uid: r.uid,
|
|
432
|
-
quantity: r.quantity,
|
|
433
|
-
dateStart: r.dateStart,
|
|
434
|
-
dateEnd: r.dateEnd,
|
|
435
|
-
days: r.days || daysBetween(r.dateStart, r.dateEnd),
|
|
436
|
-
status: r.status,
|
|
437
|
-
locationId: r.locationId,
|
|
438
|
-
pickupLocation: r.pickupLocationName || locMap.get(String(r.locationId)) || 'Non précisé',
|
|
439
|
-
});
|
|
440
|
-
}
|
|
211
|
+
const start = startISO ? new Date(startISO).getTime() : -Infinity;
|
|
212
|
+
const end = endISO ? new Date(endISO).getTime() : Infinity;
|
|
441
213
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const obj of (objs || [])) {
|
|
216
|
+
if (!obj) continue;
|
|
217
|
+
const s = new Date(obj.start).getTime();
|
|
218
|
+
const e = new Date(obj.end).getTime();
|
|
219
|
+
if (isNaN(s) || isNaN(e)) continue;
|
|
220
|
+
// overlap
|
|
221
|
+
if (s < end && e > start) out.push(obj);
|
|
446
222
|
}
|
|
223
|
+
return out;
|
|
447
224
|
}
|
|
448
225
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
res.json({
|
|
455
|
-
locations: settings.locations || [],
|
|
456
|
-
inventory: settings.inventory || [],
|
|
457
|
-
});
|
|
458
|
-
} catch (err) {
|
|
459
|
-
res.status(500).json({ error: err.message });
|
|
226
|
+
async function sendEmailToGroups(settings, subject, html, groupNames) {
|
|
227
|
+
const all = new Set();
|
|
228
|
+
for (const g of (groupNames || [])) {
|
|
229
|
+
const members = await groups.getMembers(g, 0, 2000);
|
|
230
|
+
(members || []).forEach(m => { if (m && m.uid) all.add(m.uid); });
|
|
460
231
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const invMap = new Map(settings.inventory.map(i => [String(i.id), i.name || i.id]));
|
|
471
|
-
|
|
472
|
-
const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
|
|
473
|
-
const result = [];
|
|
474
|
-
|
|
475
|
-
for (const eid of eids) {
|
|
476
|
-
const event = await getEvent(eid);
|
|
477
|
-
if (!event) continue;
|
|
478
|
-
|
|
479
|
-
const reservations = JSON.parse(event.reservations || '[]');
|
|
480
|
-
reservations
|
|
481
|
-
.filter(r => String(r.uid) === uid)
|
|
482
|
-
.forEach(r => {
|
|
483
|
-
result.push({
|
|
484
|
-
...r,
|
|
485
|
-
eventTitle: event.title,
|
|
486
|
-
itemName: invMap.get(String(r.itemId)) || r.itemId,
|
|
487
|
-
pickupLocation: r.pickupLocationName || locMap.get(String(r.locationId)) || '',
|
|
488
|
-
});
|
|
489
|
-
});
|
|
232
|
+
// Also allow extra direct emails (optional)
|
|
233
|
+
const extraEmails = (settings.notifyEmails || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
234
|
+
|
|
235
|
+
const uids = Array.from(all);
|
|
236
|
+
// Send to uids
|
|
237
|
+
await Promise.all(uids.map(async (uid) => {
|
|
238
|
+
const u = await user.getUserFields(uid, ['email', 'username']);
|
|
239
|
+
if (u && u.email) {
|
|
240
|
+
await emailer.send('default', u.email, subject, html);
|
|
490
241
|
}
|
|
242
|
+
}));
|
|
491
243
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
244
|
+
// Send to extra emails
|
|
245
|
+
await Promise.all(extraEmails.map(async (mail) => {
|
|
246
|
+
await emailer.send('default', mail, subject, html);
|
|
247
|
+
}));
|
|
497
248
|
}
|
|
498
249
|
|
|
499
|
-
async function
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
250
|
+
async function createCheckoutIntent(settings, reservationObj, amountCents) {
|
|
251
|
+
const baseUrl = apiBase(settings.helloassoEnv);
|
|
252
|
+
const token = await getHelloAssoToken(settings);
|
|
253
|
+
|
|
254
|
+
if (!settings.helloassoOrganizationSlug) throw new Error('HelloAsso organizationSlug missing');
|
|
255
|
+
const url = `${baseUrl}/v5/organizations/${encodeURIComponent(settings.helloassoOrganizationSlug)}/checkout-intents`;
|
|
256
|
+
|
|
257
|
+
const requester = await user.getUserFields(reservationObj.uid, ['username', 'fullname', 'email']);
|
|
258
|
+
const payerName = (requester && (requester.fullname || requester.username) || '').trim();
|
|
259
|
+
const parts = payerName.split(/\s+/).filter(Boolean);
|
|
260
|
+
const firstName = parts[0] || 'User';
|
|
261
|
+
const lastName = parts.slice(1).join(' ') || 'NodeBB';
|
|
262
|
+
|
|
263
|
+
// Required by HelloAsso: totalAmount, initialAmount, itemName, backUrl, errorUrl, returnUrl, containsDonation (bool)
|
|
264
|
+
const body = {
|
|
265
|
+
totalAmount: amountCents,
|
|
266
|
+
initialAmount: amountCents,
|
|
267
|
+
itemName: reservationObj.itemName || 'Reservation',
|
|
268
|
+
backUrl: settings.helloassoBackUrl || settings.helloassoReturnUrl || settings.helloassoErrorUrl,
|
|
269
|
+
errorUrl: settings.helloassoErrorUrl || settings.helloassoReturnUrl || settings.helloassoBackUrl,
|
|
270
|
+
returnUrl: settings.helloassoReturnUrl || settings.helloassoBackUrl || settings.helloassoErrorUrl,
|
|
271
|
+
containsDonation: false,
|
|
272
|
+
payer: {
|
|
273
|
+
firstName,
|
|
274
|
+
lastName,
|
|
275
|
+
email: (requester && requester.email) || undefined,
|
|
276
|
+
},
|
|
277
|
+
metadata: {
|
|
278
|
+
plugin: 'calendar-onekite',
|
|
279
|
+
reservationId: reservationObj.id,
|
|
280
|
+
uid: reservationObj.uid,
|
|
281
|
+
itemId: reservationObj.itemId,
|
|
282
|
+
start: reservationObj.start,
|
|
283
|
+
end: reservationObj.end,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
521
286
|
|
|
522
|
-
|
|
523
|
-
|
|
287
|
+
const res = await axios.post(url, body, {
|
|
288
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
289
|
+
timeout: 15_000,
|
|
290
|
+
});
|
|
524
291
|
|
|
525
|
-
|
|
526
|
-
|
|
292
|
+
// docs: returns checkoutIntentId and redirectUrl (naming may vary)
|
|
293
|
+
const data = res.data || {};
|
|
294
|
+
const redirectUrl = data.redirectUrl || data.url || data.redirectURL || '';
|
|
295
|
+
const checkoutIntentId = data.checkoutIntentId || data.id || data.checkoutIntentID || '';
|
|
296
|
+
return { redirectUrl, checkoutIntentId, raw: data };
|
|
297
|
+
}
|
|
527
298
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
299
|
+
plugin.init = async function (params) {
|
|
300
|
+
const { router, middleware } = params;
|
|
301
|
+
const settings = await getSettings();
|
|
531
302
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return Array.isArray(arr) ? arr.map(String) : [];
|
|
537
|
-
} catch {
|
|
538
|
-
return [];
|
|
539
|
-
}
|
|
540
|
-
})();
|
|
541
|
-
if (!allowedIds.includes(String(itemId))) {
|
|
542
|
-
return res.status(400).json({ error: 'Ce matériel n’est pas autorisé pour cet événement.' });
|
|
303
|
+
// Page route: /calendar
|
|
304
|
+
router.get('/calendar', middleware.buildHeader, async (req, res) => {
|
|
305
|
+
if (!settings.enabled) {
|
|
306
|
+
return res.status(404).send('Calendar plugin disabled');
|
|
543
307
|
}
|
|
308
|
+
const isLoggedIn = !!req.uid;
|
|
309
|
+
res.render('calendar-onekite/calendar', {
|
|
310
|
+
title: 'Calendrier',
|
|
311
|
+
isLoggedIn,
|
|
312
|
+
uid: req.uid || 0,
|
|
313
|
+
});
|
|
314
|
+
});
|
|
544
315
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
.filter(r => String(r.locationId) === String(locationId))
|
|
551
|
-
.filter(r => String(r.status) !== 'cancelled')
|
|
552
|
-
.filter(r => {
|
|
553
|
-
const startR = new Date(r.dateStart);
|
|
554
|
-
const endR = new Date(r.dateEnd);
|
|
555
|
-
const startN = new Date(dateStart);
|
|
556
|
-
const endN = new Date(dateEnd);
|
|
557
|
-
return !(endN < startR || startN > endR);
|
|
558
|
-
});
|
|
316
|
+
// API: events
|
|
317
|
+
router.get('/api/calendar-onekite/events', async (req, res) => {
|
|
318
|
+
try {
|
|
319
|
+
const s = await getSettings();
|
|
320
|
+
if (!s.enabled) return res.json({ events: [] });
|
|
559
321
|
|
|
560
|
-
|
|
561
|
-
const available = stockTotal - used;
|
|
562
|
-
if (q > available) return res.status(400).json({ error: `Stock insuffisant sur ce lieu (dispo: ${available}).` });
|
|
563
|
-
|
|
564
|
-
const rid = await nextReservationId();
|
|
565
|
-
const nbDays = daysBetween(dateStart, dateEnd);
|
|
566
|
-
|
|
567
|
-
const reservation = {
|
|
568
|
-
rid,
|
|
569
|
-
eid: String(eid),
|
|
570
|
-
uid: String(uid),
|
|
571
|
-
itemId: String(itemId),
|
|
572
|
-
locationId: String(locationId),
|
|
573
|
-
pickupLocationName: String(loc.name || loc.id),
|
|
574
|
-
quantity: q,
|
|
575
|
-
dateStart,
|
|
576
|
-
dateEnd,
|
|
577
|
-
days: nbDays,
|
|
578
|
-
status: 'pending_admin',
|
|
579
|
-
helloAssoOrderId: null,
|
|
580
|
-
createdAt: Date.now(),
|
|
581
|
-
};
|
|
582
|
-
|
|
583
|
-
const resList = JSON.parse(event.reservations || '[]');
|
|
584
|
-
resList.push(reservation);
|
|
585
|
-
event.reservations = JSON.stringify(resList);
|
|
586
|
-
await db.setObject(getEventKey(eid), event);
|
|
322
|
+
await cleanupExpiredPending(s);
|
|
587
323
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
quantity: reservation.quantity,
|
|
594
|
-
dateStart,
|
|
595
|
-
dateEnd,
|
|
596
|
-
days: nbDays,
|
|
597
|
-
pickupLocation: reservation.pickupLocationName,
|
|
598
|
-
});
|
|
324
|
+
const start = req.query.start;
|
|
325
|
+
const end = req.query.end;
|
|
326
|
+
const objs = await getReservationsInRange(start, end);
|
|
327
|
+
const events = await Promise.all(objs.map(reservationToEvent));
|
|
328
|
+
res.json({ events });
|
|
599
329
|
} catch (e) {
|
|
600
|
-
|
|
330
|
+
res.status(500).json({ error: e.message });
|
|
601
331
|
}
|
|
332
|
+
});
|
|
602
333
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
status: 'pending_admin',
|
|
606
|
-
message: 'Votre demande de réservation a été envoyée. Elle doit être validée par un administrateur.',
|
|
607
|
-
});
|
|
608
|
-
} catch (err) {
|
|
609
|
-
res.status(500).json({ error: err.message });
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/* ---------------- HelloAsso webhook ---------------- */
|
|
614
|
-
|
|
615
|
-
async function helloAssoWebhook(req, res) {
|
|
616
|
-
try {
|
|
617
|
-
const payload = req.body;
|
|
618
|
-
const order = payload.order || payload;
|
|
619
|
-
|
|
620
|
-
const orderId = String(order.id || '');
|
|
621
|
-
const state = order.state || order.status || '';
|
|
622
|
-
if (state !== 'Paid') return res.json({ ignored: true });
|
|
623
|
-
|
|
624
|
-
const custom = order.metadata || order.customFields || {};
|
|
625
|
-
const eid = String(custom.eid || '');
|
|
626
|
-
const rid = String(custom.rid || '');
|
|
627
|
-
if (!eid || !rid) return res.status(400).json({ error: 'Missing eid/rid in metadata' });
|
|
628
|
-
|
|
629
|
-
const event = await getEvent(eid);
|
|
630
|
-
if (!event) throw new Error('Event not found');
|
|
631
|
-
|
|
632
|
-
const reservations = JSON.parse(event.reservations || '[]');
|
|
633
|
-
const idx = reservations.findIndex(r => String(r.rid) === String(rid));
|
|
634
|
-
if (idx === -1) throw new Error('Reservation not found');
|
|
635
|
-
|
|
636
|
-
const reservation = reservations[idx];
|
|
637
|
-
if (reservation.status === 'paid') return res.json({ ok: true });
|
|
638
|
-
|
|
639
|
-
reservation.status = 'paid';
|
|
640
|
-
reservation.helloAssoOrderId = orderId;
|
|
641
|
-
reservations[idx] = reservation;
|
|
642
|
-
|
|
643
|
-
event.reservations = JSON.stringify(reservations);
|
|
644
|
-
await db.setObject(getEventKey(eid), event);
|
|
645
|
-
|
|
646
|
-
const settings = await getParsedSettings();
|
|
647
|
-
const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
|
|
648
|
-
|
|
334
|
+
// API: items
|
|
335
|
+
router.get('/api/calendar-onekite/items', async (req, res) => {
|
|
649
336
|
try {
|
|
650
|
-
await
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
quantity: reservation.quantity,
|
|
655
|
-
pickupLocation: reservation.pickupLocationName || reservation.locationId || 'Non précisé',
|
|
656
|
-
dateStart: reservation.dateStart,
|
|
657
|
-
dateEnd: reservation.dateEnd,
|
|
658
|
-
days: reservation.days || 1,
|
|
659
|
-
});
|
|
337
|
+
const s = await getSettings();
|
|
338
|
+
if (!s.enabled) return res.json({ items: [] });
|
|
339
|
+
const items = await getItems(s);
|
|
340
|
+
res.json({ items });
|
|
660
341
|
} catch (e) {
|
|
661
|
-
|
|
342
|
+
res.status(500).json({ error: e.message });
|
|
662
343
|
}
|
|
663
|
-
|
|
664
|
-
res.json({ ok: true });
|
|
665
|
-
} catch (err) {
|
|
666
|
-
console.error('[calendar-onekite] HelloAsso webhook error:', err);
|
|
667
|
-
res.status(500).json({ error: err.message });
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/* ---------------- Widget ---------------- */
|
|
672
|
-
|
|
673
|
-
Plugin.defineWidgets = async function (widgets) {
|
|
674
|
-
widgets.push({
|
|
675
|
-
widget: 'calendarUpcoming',
|
|
676
|
-
name: 'Prochains événements',
|
|
677
|
-
description: 'Affiche la liste des prochains événements du calendrier.',
|
|
678
|
-
content: '',
|
|
679
344
|
});
|
|
680
|
-
return widgets;
|
|
681
|
-
};
|
|
682
|
-
|
|
683
|
-
Plugin.renderUpcomingWidget = async function (widget, callback) {
|
|
684
|
-
try {
|
|
685
|
-
const settings = (await Settings.get(SETTINGS_KEY)) || {};
|
|
686
|
-
const limit = Number(widget?.data?.limit || settings.limit || 5);
|
|
687
|
-
const events = await getUpcomingEvents(limit);
|
|
688
|
-
const html = await appRef.renderAsync('widgets/calendar-upcoming', { events });
|
|
689
|
-
widget.html = html;
|
|
690
|
-
|
|
691
|
-
if (typeof callback === 'function') return callback(null, widget);
|
|
692
|
-
return widget;
|
|
693
|
-
} catch (err) {
|
|
694
|
-
if (typeof callback === 'function') return callback(err);
|
|
695
|
-
throw err;
|
|
696
|
-
}
|
|
697
|
-
};
|
|
698
345
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
});
|
|
705
|
-
return header;
|
|
706
|
-
};
|
|
707
|
-
|
|
708
|
-
/* ---------------- Init ---------------- */
|
|
709
|
-
|
|
710
|
-
Plugin.init = async function (params) {
|
|
711
|
-
const { router, middleware } = params;
|
|
712
|
-
appRef = params.app;
|
|
713
|
-
|
|
714
|
-
// Pages
|
|
715
|
-
router.get('/calendar', middleware.buildHeader, (req, res) => res.render('calendar', { title: 'Calendrier' }));
|
|
716
|
-
router.get('/calendar/my-reservations', middleware.buildHeader, (req, res) => res.render('calendar-my-reservations', { title: 'Mes réservations' }));
|
|
717
|
-
|
|
718
|
-
router.get('/admin/calendar/planning', middleware.admin.buildHeader, (req, res) => res.render('admin/calendar-planning', { title: 'Planning' }));
|
|
719
|
-
router.get('/admin/plugins/calendar-onekite', middleware.admin.buildHeader, renderAdminPage);
|
|
346
|
+
// API: create reservation (pending)
|
|
347
|
+
router.post('/api/calendar-onekite/reservations', middleware.authenticate, async (req, res) => {
|
|
348
|
+
try {
|
|
349
|
+
const s = await getSettings();
|
|
350
|
+
await assertRequesterPrivileges(req, s);
|
|
720
351
|
|
|
721
|
-
|
|
722
|
-
|
|
352
|
+
const { itemId, itemName, start, end, priceCents } = req.body || {};
|
|
353
|
+
if (!itemId || !itemName || !start || !end) {
|
|
354
|
+
return res.status(400).json({ error: 'missing-fields' });
|
|
355
|
+
}
|
|
723
356
|
|
|
724
|
-
|
|
725
|
-
|
|
357
|
+
const startMs = new Date(start).getTime();
|
|
358
|
+
const endMs = new Date(end).getTime();
|
|
359
|
+
if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) {
|
|
360
|
+
return res.status(400).json({ error: 'invalid-dates' });
|
|
361
|
+
}
|
|
726
362
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
363
|
+
// Check overlap with approved OR pending (not expired) for same item
|
|
364
|
+
await cleanupExpiredPending(s);
|
|
365
|
+
const rangeObjs = await getReservationsInRange(new Date(startMs - 365*24*3600*1000).toISOString(), new Date(endMs + 365*24*3600*1000).toISOString());
|
|
366
|
+
const overlap = rangeObjs.some(o => {
|
|
367
|
+
if (!o) return false;
|
|
368
|
+
if (o.itemId !== String(itemId)) return false;
|
|
369
|
+
if (o.status !== 'approved' && o.status !== 'pending') return false;
|
|
370
|
+
const os = new Date(o.start).getTime();
|
|
371
|
+
const oe = new Date(o.end).getTime();
|
|
372
|
+
return os < endMs && oe > startMs;
|
|
373
|
+
});
|
|
374
|
+
if (overlap) {
|
|
375
|
+
return res.status(409).json({ error: 'overlap' });
|
|
376
|
+
}
|
|
732
377
|
|
|
733
|
-
|
|
734
|
-
|
|
378
|
+
const id = await nextReservationId();
|
|
379
|
+
const requester = await user.getUserFields(req.uid, ['username', 'fullname']);
|
|
380
|
+
const requesterName = (requester && (requester.fullname || requester.username)) || 'User';
|
|
381
|
+
|
|
382
|
+
const expiresAt = nowMs() + (Math.max(1, s.holdMinutes) * 60_000);
|
|
383
|
+
|
|
384
|
+
const obj = {
|
|
385
|
+
id,
|
|
386
|
+
uid: String(req.uid),
|
|
387
|
+
requesterName,
|
|
388
|
+
itemId: String(itemId),
|
|
389
|
+
itemName: String(itemName),
|
|
390
|
+
start: new Date(startMs).toISOString(),
|
|
391
|
+
end: new Date(endMs).toISOString(),
|
|
392
|
+
status: 'pending',
|
|
393
|
+
createdAt: String(nowMs()),
|
|
394
|
+
expiresAt: String(expiresAt),
|
|
395
|
+
priceCents: String(parseInt(priceCents || '0', 10) || 0),
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
await db.setObject(`${RES_PREFIX}:${id}`, obj);
|
|
399
|
+
await db.sortedSetAdd(RES_ZSET_BY_START, startMs, id);
|
|
400
|
+
|
|
401
|
+
// Notify approvers
|
|
402
|
+
const approverGroups = splitGroups(s.approverGroups);
|
|
403
|
+
if (approverGroups.length) {
|
|
404
|
+
const subject = `[OneKite] Nouvelle réservation à valider (#${id})`;
|
|
405
|
+
const html = `
|
|
406
|
+
<p>Une nouvelle réservation est en attente de validation.</p>
|
|
407
|
+
<ul>
|
|
408
|
+
<li>ID: ${id}</li>
|
|
409
|
+
<li>Matériel: ${obj.itemName}</li>
|
|
410
|
+
<li>Début: ${obj.start}</li>
|
|
411
|
+
<li>Fin: ${obj.end}</li>
|
|
412
|
+
<li>Demandeur: ${requesterName}</li>
|
|
413
|
+
</ul>
|
|
414
|
+
<p>Connectez-vous à NodeBB et ouvrez le calendrier pour valider/refuser.</p>
|
|
415
|
+
`;
|
|
416
|
+
await sendEmailToGroups(s, subject, html, approverGroups);
|
|
417
|
+
}
|
|
735
418
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
if (!start || !end) return res.status(400).json({ error: 'Missing start/end' });
|
|
741
|
-
const events = await getEventsBetween(start, end);
|
|
742
|
-
res.json(events);
|
|
743
|
-
} catch (err) {
|
|
744
|
-
res.status(500).json({ error: err.message });
|
|
419
|
+
res.json({ reservation: obj });
|
|
420
|
+
} catch (e) {
|
|
421
|
+
const status = e.message === 'not-allowed' ? 403 : 500;
|
|
422
|
+
res.status(status).json({ error: e.message });
|
|
745
423
|
}
|
|
746
424
|
});
|
|
747
425
|
|
|
748
|
-
//
|
|
749
|
-
|
|
426
|
+
// API: approve reservation (creates checkout intent, sends payer link)
|
|
427
|
+
router.post('/api/calendar-onekite/reservations/:id/approve', middleware.authenticate, async (req, res) => {
|
|
750
428
|
try {
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
429
|
+
const s = await getSettings();
|
|
430
|
+
await assertApproverPrivileges(req, s);
|
|
431
|
+
|
|
432
|
+
const id = req.params.id;
|
|
433
|
+
const key = `${RES_PREFIX}:${id}`;
|
|
434
|
+
const obj = await db.getObject(key);
|
|
435
|
+
if (!obj) return res.status(404).json({ error: 'not-found' });
|
|
436
|
+
if (obj.status !== 'pending') return res.status(400).json({ error: 'not-pending' });
|
|
437
|
+
|
|
438
|
+
// figure amount: from stored priceCents or from items list
|
|
439
|
+
let amountCents = parseInt(obj.priceCents || '0', 10) || 0;
|
|
440
|
+
if (!amountCents) {
|
|
441
|
+
const items = await getItems(s);
|
|
442
|
+
const match = (items || []).find(it => String(it.id || it.itemId || it.itemID) === String(obj.itemId));
|
|
443
|
+
// Try common fields for price
|
|
444
|
+
const price = match && (match.price || match.amount || match.unitPrice || match.totalAmount || match.publicAmount);
|
|
445
|
+
if (typeof price === 'number') amountCents = price;
|
|
446
|
+
if (!amountCents && match && match.price && typeof match.price.value === 'number') amountCents = match.price.value;
|
|
447
|
+
}
|
|
448
|
+
if (!amountCents) return res.status(400).json({ error: 'missing-amount' });
|
|
449
|
+
|
|
450
|
+
const { redirectUrl, checkoutIntentId } = await createCheckoutIntent(s, obj, amountCents);
|
|
451
|
+
if (!redirectUrl) return res.status(500).json({ error: 'helloasso-missing-redirectUrl' });
|
|
452
|
+
|
|
453
|
+
obj.status = 'approved';
|
|
454
|
+
obj.paymentUrl = redirectUrl;
|
|
455
|
+
obj.checkoutIntentId = checkoutIntentId ? String(checkoutIntentId) : '';
|
|
456
|
+
obj.approvedAt = String(nowMs());
|
|
457
|
+
obj.approvedBy = String(req.uid);
|
|
458
|
+
|
|
459
|
+
await db.setObject(key, obj);
|
|
460
|
+
|
|
461
|
+
// notify requester
|
|
462
|
+
const requester = await user.getUserFields(parseInt(obj.uid, 10), ['email', 'username', 'fullname']);
|
|
463
|
+
if (requester && requester.email) {
|
|
464
|
+
const subject = `[OneKite] Réservation validée (#${id}) - Paiement`;
|
|
465
|
+
const html = `
|
|
466
|
+
<p>Votre réservation a été validée ✅</p>
|
|
467
|
+
<ul>
|
|
468
|
+
<li>Matériel: ${obj.itemName}</li>
|
|
469
|
+
<li>Début: ${obj.start}</li>
|
|
470
|
+
<li>Fin: ${obj.end}</li>
|
|
471
|
+
</ul>
|
|
472
|
+
<p>Merci d'effectuer le paiement via ce lien :</p>
|
|
473
|
+
<p><a href="${redirectUrl}">${redirectUrl}</a></p>
|
|
474
|
+
`;
|
|
475
|
+
await emailer.send('default', requester.email, subject, html);
|
|
476
|
+
}
|
|
759
477
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
const event = await updateEvent(req.params.eid, req.body);
|
|
765
|
-
res.json(event);
|
|
766
|
-
} catch (err) {
|
|
767
|
-
res.status(500).json({ error: err.message });
|
|
478
|
+
res.json({ reservation: obj });
|
|
479
|
+
} catch (e) {
|
|
480
|
+
const status = e.message === 'not-allowed' ? 403 : 500;
|
|
481
|
+
res.status(status).json({ error: e.message });
|
|
768
482
|
}
|
|
769
483
|
});
|
|
770
484
|
|
|
771
|
-
|
|
485
|
+
// API: reject reservation
|
|
486
|
+
router.post('/api/calendar-onekite/reservations/:id/reject', middleware.authenticate, async (req, res) => {
|
|
772
487
|
try {
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
488
|
+
const s = await getSettings();
|
|
489
|
+
await assertApproverPrivileges(req, s);
|
|
490
|
+
|
|
491
|
+
const id = req.params.id;
|
|
492
|
+
const key = `${RES_PREFIX}:${id}`;
|
|
493
|
+
const obj = await db.getObject(key);
|
|
494
|
+
if (!obj) return res.status(404).json({ error: 'not-found' });
|
|
495
|
+
if (obj.status !== 'pending') return res.status(400).json({ error: 'not-pending' });
|
|
496
|
+
|
|
497
|
+
obj.status = 'rejected';
|
|
498
|
+
obj.rejectedAt = String(nowMs());
|
|
499
|
+
obj.rejectedBy = String(req.uid);
|
|
500
|
+
await db.setObject(key, obj);
|
|
501
|
+
|
|
502
|
+
const requester = await user.getUserFields(parseInt(obj.uid, 10), ['email', 'username', 'fullname']);
|
|
503
|
+
if (requester && requester.email) {
|
|
504
|
+
const subject = `[OneKite] Réservation refusée (#${id})`;
|
|
505
|
+
const html = `
|
|
506
|
+
<p>Votre réservation a été refusée ❌</p>
|
|
507
|
+
<ul>
|
|
508
|
+
<li>Matériel: ${obj.itemName}</li>
|
|
509
|
+
<li>Début: ${obj.start}</li>
|
|
510
|
+
<li>Fin: ${obj.end}</li>
|
|
511
|
+
</ul>
|
|
512
|
+
`;
|
|
513
|
+
await emailer.send('default', requester.email, subject, html);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
res.json({ reservation: obj });
|
|
517
|
+
} catch (e) {
|
|
518
|
+
const status = e.message === 'not-allowed' ? 403 : 500;
|
|
519
|
+
res.status(status).json({ error: e.message });
|
|
779
520
|
}
|
|
780
521
|
});
|
|
781
522
|
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
523
|
+
// Admin API: get/save settings
|
|
524
|
+
router.get('/api/admin/plugins/calendar-onekite', middleware.authenticate, middleware.adminsOnly, async (req, res) => {
|
|
525
|
+
const settings = await meta.settings.get(PLUGIN_NS);
|
|
526
|
+
res.json(settings);
|
|
527
|
+
});
|
|
787
528
|
|
|
788
|
-
|
|
789
|
-
|
|
529
|
+
router.post('/api/admin/plugins/calendar-onekite', middleware.authenticate, middleware.adminsOnly, async (req, res) => {
|
|
530
|
+
await meta.settings.set(PLUGIN_NS, req.body);
|
|
531
|
+
// invalidate caches when settings change
|
|
532
|
+
helloassoTokenCache = { token: null, expiresAt: 0 };
|
|
533
|
+
itemsCache = { items: null, expiresAt: 0 };
|
|
534
|
+
res.json({ ok: true });
|
|
535
|
+
});
|
|
790
536
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
} catch { return []; }
|
|
796
|
-
})();
|
|
537
|
+
// Admin API: purge by year
|
|
538
|
+
router.post('/api/admin/plugins/calendar-onekite/purge', middleware.authenticate, middleware.adminsOnly, async (req, res) => {
|
|
539
|
+
const year = parseInt((req.body && req.body.year) || '0', 10);
|
|
540
|
+
if (!year || year < 1970 || year > 3000) return res.status(400).json({ error: 'invalid-year' });
|
|
797
541
|
|
|
798
|
-
|
|
542
|
+
const ids = await db.getSortedSetRange(RES_ZSET_BY_START, 0, 100000);
|
|
543
|
+
if (!ids.length) return res.json({ deleted: 0 });
|
|
799
544
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
})();
|
|
545
|
+
const keys = ids.map(id => `${RES_PREFIX}:${id}`);
|
|
546
|
+
const objs = await db.getObjects(keys);
|
|
803
547
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
548
|
+
const toDelete = [];
|
|
549
|
+
for (const obj of (objs || [])) {
|
|
550
|
+
if (!obj) continue;
|
|
551
|
+
const s = new Date(obj.start).getUTCFullYear();
|
|
552
|
+
if (s === year) toDelete.push(obj.id);
|
|
807
553
|
}
|
|
808
|
-
|
|
554
|
+
await Promise.all(toDelete.map(async (id) => {
|
|
555
|
+
await db.delete(`${RES_PREFIX}:${id}`);
|
|
556
|
+
await db.sortedSetRemove(RES_ZSET_BY_START, id);
|
|
557
|
+
}));
|
|
809
558
|
|
|
810
|
-
|
|
811
|
-
mountApiBoth(router, 'post', '/calendar/event/:eid/rsvp', middleware.ensureLoggedIn, async (req, res) => {
|
|
812
|
-
try {
|
|
813
|
-
const uid = req.user?.uid || 0;
|
|
814
|
-
const status = req.body.status;
|
|
815
|
-
const updated = await setRsvp(req.params.eid, uid, status);
|
|
816
|
-
res.json(updated);
|
|
817
|
-
} catch (err) {
|
|
818
|
-
res.status(500).json({ error: err.message });
|
|
819
|
-
}
|
|
559
|
+
res.json({ deleted: toDelete.length });
|
|
820
560
|
});
|
|
561
|
+
};
|
|
821
562
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
563
|
+
plugin.addAdminNavigation = async function (header) {
|
|
564
|
+
header.plugins.push({
|
|
565
|
+
route: '/plugins/calendar-onekite',
|
|
566
|
+
icon: 'fa-calendar',
|
|
567
|
+
name: 'Calendar OneKite',
|
|
827
568
|
});
|
|
569
|
+
return header;
|
|
570
|
+
};
|
|
828
571
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
res.
|
|
572
|
+
plugin.addPageRoute = async function (data) {
|
|
573
|
+
// ensure /calendar works as "page route" for theme router filter edge cases
|
|
574
|
+
data.router.get('/calendar', data.middleware.buildHeader, async (req, res) => {
|
|
575
|
+
res.render('calendar-onekite/calendar', {
|
|
576
|
+
title: 'Calendrier',
|
|
577
|
+
isLoggedIn: !!req.uid,
|
|
578
|
+
uid: req.uid || 0,
|
|
579
|
+
});
|
|
833
580
|
});
|
|
834
|
-
|
|
835
|
-
// Booking
|
|
836
|
-
mountApiBoth(router, 'post', '/calendar/event/:eid/book', middleware.ensureLoggedIn, bookReservation);
|
|
837
|
-
|
|
838
|
-
// My reservations
|
|
839
|
-
mountApiBoth(router, 'get', '/calendar/my-reservations', middleware.ensureLoggedIn, myReservations);
|
|
840
|
-
|
|
841
|
-
// HelloAsso webhook
|
|
842
|
-
router.post('/api/calendar/helloasso/webhook', helloAssoWebhook);
|
|
843
|
-
router.post('/api/v3/calendar/helloasso/webhook', helloAssoWebhook);
|
|
581
|
+
return data;
|
|
844
582
|
};
|
|
845
583
|
|
|
846
|
-
module.exports =
|
|
584
|
+
module.exports = plugin;
|