nodebb-plugin-calendar-onekite 2.2.0 → 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 +466 -740
- 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,858 +1,584 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* NodeBB v4 plugin: Calendar OneKite
|
|
5
|
-
* - Events + booking items (with pickupLocation per item)
|
|
6
|
-
* - Multi-day reservations
|
|
7
|
-
* - Admin validation -> HelloAsso checkout link -> payment webhook -> mark paid
|
|
8
|
-
* - Admin planning view + "My reservations" page
|
|
9
|
-
* - Settings:
|
|
10
|
-
* - Settings key: "calendar-onekite"
|
|
11
|
-
* - Admin page: /admin/plugins/calendar-onekite
|
|
12
|
-
* - Admin API: /api/admin/plugins/calendar-onekite
|
|
13
|
-
*
|
|
14
|
-
* Templates expected:
|
|
15
|
-
* - templates/calendar.tpl
|
|
16
|
-
* - templates/calendar-my-reservations.tpl
|
|
17
|
-
* - templates/admin/plugins/calendar-onekite.tpl
|
|
18
|
-
* - templates/admin/calendar-planning.tpl
|
|
19
|
-
* - templates/widgets/calendar-upcoming.tpl
|
|
20
|
-
* - templates/emails/calendar-reservation-created.tpl
|
|
21
|
-
* - templates/emails/calendar-reservation-approved.tpl
|
|
22
|
-
* - templates/emails/calendar-payment-confirmed.tpl
|
|
23
|
-
*/
|
|
3
|
+
const axios = require('axios');
|
|
24
4
|
|
|
25
5
|
const db = require.main.require('./src/database');
|
|
26
|
-
const user = require.main.require('./src/user');
|
|
27
6
|
const meta = require.main.require('./src/meta');
|
|
7
|
+
const user = require.main.require('./src/user');
|
|
8
|
+
const groups = require.main.require('./src/groups');
|
|
28
9
|
const emailer = require.main.require('./src/emailer');
|
|
29
|
-
const
|
|
10
|
+
const helpers = require.main.require('./src/controllers/helpers');
|
|
11
|
+
const privileges = require.main.require('./src/privileges');
|
|
30
12
|
|
|
31
|
-
const
|
|
32
|
-
const helloAsso = require('./helloasso');
|
|
13
|
+
const plugin = {};
|
|
33
14
|
|
|
34
|
-
const
|
|
35
|
-
|
|
15
|
+
const PLUGIN_NS = 'calendar-onekite';
|
|
16
|
+
const RES_PREFIX = `calendar-onekite:reservation`;
|
|
17
|
+
const RES_ZSET_BY_START = `calendar-onekite:reservations:byStart`;
|
|
36
18
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const EVENT_KEY_PREFIX = 'calendar:event:';
|
|
19
|
+
let helloassoTokenCache = { token: null, expiresAt: 0 };
|
|
20
|
+
let itemsCache = { items: null, expiresAt: 0 };
|
|
40
21
|
|
|
41
|
-
function
|
|
42
|
-
return
|
|
22
|
+
function nowMs() {
|
|
23
|
+
return Date.now();
|
|
43
24
|
}
|
|
44
25
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return String(rid);
|
|
26
|
+
function safeJsonParse(s, fallback) {
|
|
27
|
+
try { return JSON.parse(s); } catch (e) { return fallback; }
|
|
48
28
|
}
|
|
49
29
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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;
|
|
60
50
|
}
|
|
61
51
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
* ---------------------------- */
|
|
65
|
-
|
|
66
|
-
async function createEvent(data, uid) {
|
|
67
|
-
const eid = await db.incrObjectField('global', 'nextCalendarEid');
|
|
68
|
-
const key = getEventKey(eid);
|
|
69
|
-
|
|
70
|
-
const now = Date.now();
|
|
71
|
-
const startTs = Number(new Date(data.start).getTime()) || now;
|
|
72
|
-
const endTs = Number(new Date(data.end).getTime()) || startTs;
|
|
73
|
-
|
|
74
|
-
const bookingItems = Array.isArray(data.bookingItems) ? data.bookingItems : [];
|
|
75
|
-
|
|
76
|
-
const eventObj = {
|
|
77
|
-
eid: String(eid),
|
|
78
|
-
title: String(data.title || ''),
|
|
79
|
-
description: String(data.description || ''),
|
|
80
|
-
start: new Date(startTs).toISOString(),
|
|
81
|
-
end: new Date(endTs).toISOString(),
|
|
82
|
-
allDay: data.allDay ? 1 : 0,
|
|
83
|
-
location: String(data.location || ''),
|
|
84
|
-
createdByUid: String(uid || 0),
|
|
85
|
-
createdAt: String(now),
|
|
86
|
-
updatedAt: String(now),
|
|
87
|
-
|
|
88
|
-
rsvpYes: '[]',
|
|
89
|
-
rsvpMaybe: '[]',
|
|
90
|
-
rsvpNo: '[]',
|
|
91
|
-
|
|
92
|
-
visibility: String(data.visibility || 'public'),
|
|
93
|
-
|
|
94
|
-
bookingEnabled: data.bookingEnabled ? 1 : 0,
|
|
95
|
-
bookingItems: JSON.stringify(bookingItems),
|
|
96
|
-
reservations: JSON.stringify([]),
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
await db.setObject(key, eventObj);
|
|
100
|
-
await db.sortedSetAdd(EVENTS_SET_KEY, startTs, eid);
|
|
101
|
-
return eventObj;
|
|
52
|
+
function apiBase(env) {
|
|
53
|
+
return env === 'prod' ? 'https://api.helloasso.com' : 'https://api.helloasso-sandbox.com';
|
|
102
54
|
}
|
|
103
55
|
|
|
104
|
-
async function
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
}
|
|
107
65
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const bookingItems = Array.isArray(data.bookingItems)
|
|
117
|
-
? data.bookingItems
|
|
118
|
-
: JSON.parse(existing.bookingItems || '[]');
|
|
119
|
-
|
|
120
|
-
const updated = {
|
|
121
|
-
...existing,
|
|
122
|
-
title: data.title !== undefined ? String(data.title) : existing.title,
|
|
123
|
-
description: data.description !== undefined ? String(data.description) : existing.description,
|
|
124
|
-
start: new Date(startTs).toISOString(),
|
|
125
|
-
end: new Date(endTs).toISOString(),
|
|
126
|
-
allDay: data.allDay !== undefined ? (data.allDay ? 1 : 0) : existing.allDay,
|
|
127
|
-
location: data.location !== undefined ? String(data.location) : existing.location,
|
|
128
|
-
visibility: data.visibility !== undefined ? String(data.visibility) : existing.visibility,
|
|
129
|
-
bookingEnabled: data.bookingEnabled !== undefined ? (data.bookingEnabled ? 1 : 0) : (existing.bookingEnabled || 0),
|
|
130
|
-
bookingItems: JSON.stringify(bookingItems),
|
|
131
|
-
updatedAt: String(Date.now()),
|
|
132
|
-
};
|
|
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
|
+
});
|
|
133
73
|
|
|
134
|
-
await
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
74
|
+
const res = await axios.post(url, body.toString(), {
|
|
75
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
76
|
+
timeout: 15_000,
|
|
77
|
+
});
|
|
138
78
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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;
|
|
142
84
|
}
|
|
143
85
|
|
|
144
|
-
async function
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, startTs, endTs);
|
|
148
|
-
if (!eids || !eids.length) return [];
|
|
86
|
+
async function fetchItemsFromHelloAsso(settings) {
|
|
87
|
+
const baseUrl = apiBase(settings.helloassoEnv);
|
|
88
|
+
const token = await getHelloAssoToken(settings);
|
|
149
89
|
|
|
150
|
-
|
|
151
|
-
|
|
90
|
+
if (!settings.helloassoOrganizationSlug || !settings.helloassoFormType || !settings.helloassoFormSlug) {
|
|
91
|
+
throw new Error('HelloAsso organizationSlug/formType/formSlug not configured');
|
|
92
|
+
}
|
|
152
93
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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;
|
|
157
102
|
}
|
|
158
103
|
|
|
159
|
-
async function
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
if (!eids || !eids.length) return [];
|
|
104
|
+
async function getItems(settings) {
|
|
105
|
+
const cacheOk = itemsCache.items && itemsCache.expiresAt > nowMs();
|
|
106
|
+
if (cacheOk) return itemsCache.items;
|
|
163
107
|
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
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;
|
|
167
112
|
}
|
|
168
113
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (!uid || uid === 0) return false;
|
|
175
|
-
|
|
176
|
-
const settings = await Settings.get(SETTINGS_KEY);
|
|
177
|
-
if (!settings || !settings.allowedGroups) return false;
|
|
178
|
-
|
|
179
|
-
const allowedSet = new Set(
|
|
180
|
-
settings.allowedGroups
|
|
181
|
-
.split(',')
|
|
182
|
-
.map(g => g.trim().toLowerCase())
|
|
183
|
-
.filter(Boolean)
|
|
184
|
-
);
|
|
185
|
-
if (!allowedSet.size) return false;
|
|
186
|
-
|
|
187
|
-
const userGroupsArr = await user.getUserGroups([uid]);
|
|
188
|
-
const groups = (userGroupsArr[0] || []).map(g => (g.name || '').toLowerCase());
|
|
189
|
-
|
|
190
|
-
return groups.some(g => allowedSet.has(g));
|
|
114
|
+
function splitGroups(s) {
|
|
115
|
+
return (s || '')
|
|
116
|
+
.split(',')
|
|
117
|
+
.map(x => x.trim())
|
|
118
|
+
.filter(Boolean);
|
|
191
119
|
}
|
|
192
120
|
|
|
193
|
-
async function
|
|
194
|
-
if (!uid
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
.split(',')
|
|
203
|
-
.map(g => g.trim().toLowerCase())
|
|
204
|
-
.filter(Boolean)
|
|
205
|
-
);
|
|
206
|
-
if (!allowedSet.size) return true;
|
|
207
|
-
|
|
208
|
-
const userGroupsArr = await user.getUserGroups([uid]);
|
|
209
|
-
const groups = (userGroupsArr[0] || []).map(g => (g.name || '').toLowerCase());
|
|
210
|
-
|
|
211
|
-
return groups.some(g => allowedSet.has(g));
|
|
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;
|
|
212
130
|
}
|
|
213
131
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
try { return JSON.parse(str || '[]'); } catch (e) { return []; }
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
let yes = parseList(event.rsvpYes);
|
|
228
|
-
let maybe = parseList(event.rsvpMaybe);
|
|
229
|
-
let no = parseList(event.rsvpNo);
|
|
230
|
-
const u = String(uid);
|
|
231
|
-
|
|
232
|
-
yes = yes.filter(id => id !== u);
|
|
233
|
-
maybe = maybe.filter(id => id !== u);
|
|
234
|
-
no = no.filter(id => id !== u);
|
|
235
|
-
|
|
236
|
-
if (status === 'yes') yes.push(u);
|
|
237
|
-
if (status === 'maybe') maybe.push(u);
|
|
238
|
-
if (status === 'no') no.push(u);
|
|
239
|
-
|
|
240
|
-
const updated = {
|
|
241
|
-
...event,
|
|
242
|
-
rsvpYes: JSON.stringify(yes),
|
|
243
|
-
rsvpMaybe: JSON.stringify(maybe),
|
|
244
|
-
rsvpNo: JSON.stringify(no),
|
|
245
|
-
updatedAt: String(Date.now()),
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
await db.setObject(key, updated);
|
|
249
|
-
return updated;
|
|
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;
|
|
138
|
+
}
|
|
139
|
+
const ok = await isUserInAnyGroup(req.uid, requesterGroups);
|
|
140
|
+
if (!ok) throw new Error('not-allowed');
|
|
141
|
+
return true;
|
|
250
142
|
}
|
|
251
143
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const items = JSON.parse(event.bookingItems || '[]');
|
|
258
|
-
const item = items.find(i => i.id === reservation.itemId);
|
|
259
|
-
if (!item) return 0;
|
|
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;
|
|
260
149
|
|
|
261
|
-
const
|
|
262
|
-
const
|
|
263
|
-
|
|
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;
|
|
264
154
|
}
|
|
265
155
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!toDelete.length) return;
|
|
269
172
|
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
}));
|
|
272
177
|
}
|
|
273
178
|
|
|
274
|
-
function
|
|
275
|
-
|
|
179
|
+
async function nextReservationId() {
|
|
180
|
+
const n = await db.incrObjectField(`calendar-onekite:ids`, 'reservation');
|
|
181
|
+
return String(n);
|
|
276
182
|
}
|
|
277
183
|
|
|
278
|
-
function
|
|
279
|
-
|
|
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
|
+
};
|
|
280
202
|
}
|
|
281
203
|
|
|
282
|
-
async function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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);
|
|
210
|
+
|
|
211
|
+
const start = startISO ? new Date(startISO).getTime() : -Infinity;
|
|
212
|
+
const end = endISO ? new Date(endISO).getTime() : Infinity;
|
|
213
|
+
|
|
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);
|
|
295
222
|
}
|
|
223
|
+
return out;
|
|
296
224
|
}
|
|
297
225
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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); });
|
|
231
|
+
}
|
|
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);
|
|
241
|
+
}
|
|
242
|
+
}));
|
|
312
243
|
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
244
|
+
// Send to extra emails
|
|
245
|
+
await Promise.all(extraEmails.map(async (mail) => {
|
|
246
|
+
await emailer.send('default', mail, subject, html);
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
316
249
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
};
|
|
320
286
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (!start || !end) return res.status(400).json({ error: 'Missing start/end' });
|
|
325
|
-
const events = await getEventsBetween(start, end);
|
|
326
|
-
res.json(events);
|
|
327
|
-
} catch (err) {
|
|
328
|
-
res.status(500).json({ error: err.message });
|
|
329
|
-
}
|
|
287
|
+
const res = await axios.post(url, body, {
|
|
288
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
289
|
+
timeout: 15_000,
|
|
330
290
|
});
|
|
331
291
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
} catch (err) {
|
|
339
|
-
res.status(500).json({ error: err.message });
|
|
340
|
-
}
|
|
341
|
-
});
|
|
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
|
+
}
|
|
342
298
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
|
|
347
|
-
const eid = req.params.eid;
|
|
348
|
-
const event = await updateEvent(eid, req.body);
|
|
349
|
-
res.json(event);
|
|
350
|
-
} catch (err) {
|
|
351
|
-
res.status(500).json({ error: err.message });
|
|
352
|
-
}
|
|
353
|
-
});
|
|
299
|
+
plugin.init = async function (params) {
|
|
300
|
+
const { router, middleware } = params;
|
|
301
|
+
const settings = await getSettings();
|
|
354
302
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const eid = req.params.eid;
|
|
360
|
-
await deleteEvent(eid);
|
|
361
|
-
res.json({ status: 'ok' });
|
|
362
|
-
} catch (err) {
|
|
363
|
-
res.status(500).json({ error: err.message });
|
|
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');
|
|
364
307
|
}
|
|
308
|
+
const isLoggedIn = !!req.uid;
|
|
309
|
+
res.render('calendar-onekite/calendar', {
|
|
310
|
+
title: 'Calendrier',
|
|
311
|
+
isLoggedIn,
|
|
312
|
+
uid: req.uid || 0,
|
|
313
|
+
});
|
|
365
314
|
});
|
|
366
315
|
|
|
367
|
-
|
|
316
|
+
// API: events
|
|
317
|
+
router.get('/api/calendar-onekite/events', async (req, res) => {
|
|
368
318
|
try {
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
319
|
+
const s = await getSettings();
|
|
320
|
+
if (!s.enabled) return res.json({ events: [] });
|
|
321
|
+
|
|
322
|
+
await cleanupExpiredPending(s);
|
|
323
|
+
|
|
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 });
|
|
329
|
+
} catch (e) {
|
|
330
|
+
res.status(500).json({ error: e.message });
|
|
377
331
|
}
|
|
378
332
|
});
|
|
379
333
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
* ---------------------------- */
|
|
383
|
-
|
|
384
|
-
router.post('/api/calendar/event/:eid/rsvp', middleware.ensureLoggedIn, async (req, res) => {
|
|
334
|
+
// API: items
|
|
335
|
+
router.get('/api/calendar-onekite/items', async (req, res) => {
|
|
385
336
|
try {
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
res.status(500).json({ error: err.message });
|
|
337
|
+
const s = await getSettings();
|
|
338
|
+
if (!s.enabled) return res.json({ items: [] });
|
|
339
|
+
const items = await getItems(s);
|
|
340
|
+
res.json({ items });
|
|
341
|
+
} catch (e) {
|
|
342
|
+
res.status(500).json({ error: e.message });
|
|
393
343
|
}
|
|
394
344
|
});
|
|
395
345
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
* ---------------------------- */
|
|
399
|
-
|
|
400
|
-
router.get('/api/calendar/permissions/create', async (req, res) => {
|
|
401
|
-
const uid = req.user?.uid || 0;
|
|
402
|
-
const allow = await userCanCreate(uid);
|
|
403
|
-
res.json({ allow });
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
router.get('/api/calendar/permissions/book', async (req, res) => {
|
|
407
|
-
const uid = req.user?.uid || 0;
|
|
408
|
-
const allow = await userCanBook(uid);
|
|
409
|
-
res.json({ allow });
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
/* -----------------------------
|
|
413
|
-
* Multi-day reservation request
|
|
414
|
-
* ---------------------------- */
|
|
415
|
-
|
|
416
|
-
router.post('/api/calendar/event/:eid/book', middleware.ensureLoggedIn, async (req, res) => {
|
|
346
|
+
// API: create reservation (pending)
|
|
347
|
+
router.post('/api/calendar-onekite/reservations', middleware.authenticate, async (req, res) => {
|
|
417
348
|
try {
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
const { itemId, quantity, dateStart, dateEnd } = req.body;
|
|
349
|
+
const s = await getSettings();
|
|
350
|
+
await assertRequesterPrivileges(req, s);
|
|
421
351
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!dateStart || !dateEnd) {
|
|
426
|
-
return res.status(400).json({ error: 'Dates de début et de fin obligatoires.' });
|
|
427
|
-
}
|
|
428
|
-
if (String(dateEnd) < String(dateStart)) {
|
|
429
|
-
return res.status(400).json({ error: 'La date de fin doit être ≥ la date de début.' });
|
|
352
|
+
const { itemId, itemName, start, end, priceCents } = req.body || {};
|
|
353
|
+
if (!itemId || !itemName || !start || !end) {
|
|
354
|
+
return res.status(400).json({ error: 'missing-fields' });
|
|
430
355
|
}
|
|
431
356
|
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
if (!
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const item = items.find(i => i.id === itemId);
|
|
438
|
-
if (!item) return res.status(400).json({ error: 'Matériel introuvable' });
|
|
439
|
-
|
|
440
|
-
const q = Number(quantity);
|
|
441
|
-
if (!q || q <= 0) return res.status(400).json({ error: 'Quantité invalide' });
|
|
442
|
-
|
|
443
|
-
const allRes = JSON.parse(event.reservations || '[]');
|
|
444
|
-
|
|
445
|
-
// Stock check: sum overlapping reservations (pending_admin/awaiting_payment/paid) for the same item
|
|
446
|
-
const overlapping = allRes.filter(r => {
|
|
447
|
-
if (r.itemId !== itemId) return false;
|
|
448
|
-
if (r.status === 'cancelled') return false;
|
|
449
|
-
|
|
450
|
-
const startR = new Date(r.dateStart);
|
|
451
|
-
const endR = new Date(r.dateEnd);
|
|
452
|
-
const startN = new Date(dateStart);
|
|
453
|
-
const endN = new Date(dateEnd);
|
|
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
|
+
}
|
|
454
362
|
|
|
455
|
-
|
|
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;
|
|
456
373
|
});
|
|
374
|
+
if (overlap) {
|
|
375
|
+
return res.status(409).json({ error: 'overlap' });
|
|
376
|
+
}
|
|
457
377
|
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
|
|
378
|
+
const id = await nextReservationId();
|
|
379
|
+
const requester = await user.getUserFields(req.uid, ['username', 'fullname']);
|
|
380
|
+
const requesterName = (requester && (requester.fullname || requester.username)) || 'User';
|
|
461
381
|
|
|
462
|
-
const
|
|
463
|
-
const now = Date.now();
|
|
464
|
-
const nbDays = daysBetween(dateStart, dateEnd);
|
|
382
|
+
const expiresAt = nowMs() + (Math.max(1, s.holdMinutes) * 60_000);
|
|
465
383
|
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
384
|
+
const obj = {
|
|
385
|
+
id,
|
|
386
|
+
uid: String(req.uid),
|
|
387
|
+
requesterName,
|
|
470
388
|
itemId: String(itemId),
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
pickupLocation: String(item.pickupLocation || ''),
|
|
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),
|
|
479
396
|
};
|
|
480
397
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
} catch (e) {
|
|
501
|
-
console.warn('[calendar-onekite] email reservation-created error:', e.message);
|
|
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);
|
|
502
417
|
}
|
|
503
418
|
|
|
504
|
-
res.json({
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
});
|
|
509
|
-
} catch (err) {
|
|
510
|
-
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 });
|
|
511
423
|
}
|
|
512
424
|
});
|
|
513
425
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
* ---------------------------- */
|
|
517
|
-
|
|
518
|
-
router.get('/api/admin/calendar/pending', middleware.admin.checkPrivileges, async (req, res) => {
|
|
426
|
+
// API: approve reservation (creates checkout intent, sends payer link)
|
|
427
|
+
router.post('/api/calendar-onekite/reservations/:id/approve', middleware.authenticate, async (req, res) => {
|
|
519
428
|
try {
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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;
|
|
530
447
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
res.status(500).json({ error:
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
break;
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
if (!reservation) return res.status(404).json({ error: 'Réservation introuvable' });
|
|
564
|
-
if (reservation.status !== 'pending_admin') return res.status(400).json({ error: 'Réservation déjà traitée' });
|
|
565
|
-
|
|
566
|
-
reservation.status = 'awaiting_payment';
|
|
567
|
-
|
|
568
|
-
const resList = JSON.parse(targetEvent.reservations || '[]');
|
|
569
|
-
const idx = resList.findIndex(r => r.rid === rid);
|
|
570
|
-
resList[idx] = reservation;
|
|
571
|
-
targetEvent.reservations = JSON.stringify(resList);
|
|
572
|
-
|
|
573
|
-
await db.setObject(getEventKey(targetEvent.eid), targetEvent);
|
|
574
|
-
|
|
575
|
-
const amount = computePrice(targetEvent, reservation);
|
|
576
|
-
const checkoutUrl = await helloAsso.createHelloAssoCheckoutIntent({
|
|
577
|
-
eid: reservation.eid,
|
|
578
|
-
rid,
|
|
579
|
-
uid: reservation.uid,
|
|
580
|
-
itemId: reservation.itemId,
|
|
581
|
-
quantity: reservation.quantity,
|
|
582
|
-
amount,
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
try {
|
|
586
|
-
await emailer.send('calendar-reservation-approved', reservation.uid, {
|
|
587
|
-
subject: 'Votre réservation a été validée',
|
|
588
|
-
eventTitle: targetEvent.title,
|
|
589
|
-
itemName: reservation.itemId,
|
|
590
|
-
quantity: reservation.quantity,
|
|
591
|
-
checkoutUrl,
|
|
592
|
-
pickupLocation: reservation.pickupLocation || 'Non précisé',
|
|
593
|
-
dateStart: reservation.dateStart,
|
|
594
|
-
dateEnd: reservation.dateEnd,
|
|
595
|
-
days: reservation.days || 1,
|
|
596
|
-
});
|
|
597
|
-
} catch (e) {
|
|
598
|
-
console.warn('[calendar-onekite] email reservation-approved error:', e.message);
|
|
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);
|
|
599
476
|
}
|
|
600
477
|
|
|
601
|
-
res.json({
|
|
602
|
-
} catch (
|
|
603
|
-
|
|
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 });
|
|
604
482
|
}
|
|
605
483
|
});
|
|
606
484
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
* ---------------------------- */
|
|
610
|
-
|
|
611
|
-
router.post('/api/admin/calendar/reservation/:rid/cancel', middleware.admin.checkPrivileges, async (req, res) => {
|
|
485
|
+
// API: reject reservation
|
|
486
|
+
router.post('/api/calendar-onekite/reservations/:id/reject', middleware.authenticate, async (req, res) => {
|
|
612
487
|
try {
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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);
|
|
631
514
|
}
|
|
632
515
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
res.status(
|
|
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 });
|
|
637
520
|
}
|
|
638
521
|
});
|
|
639
522
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
router.post('/api/calendar/helloasso/webhook', async (req, res) => {
|
|
645
|
-
try {
|
|
646
|
-
const payload = req.body;
|
|
647
|
-
const order = payload.order || payload;
|
|
648
|
-
|
|
649
|
-
const orderId = String(order.id || '');
|
|
650
|
-
const state = order.state || order.status || '';
|
|
651
|
-
if (state !== 'Paid') return res.json({ ignored: true });
|
|
652
|
-
|
|
653
|
-
const custom = order.customFields || {};
|
|
654
|
-
const eid = String(custom.eid || '');
|
|
655
|
-
const rid = String(custom.rid || '');
|
|
656
|
-
|
|
657
|
-
if (!eid || !rid) return res.status(400).json({ error: 'Missing eid/rid in customFields' });
|
|
658
|
-
|
|
659
|
-
const event = await getEvent(eid);
|
|
660
|
-
if (!event) throw new Error('Event not found');
|
|
661
|
-
|
|
662
|
-
let reservations = JSON.parse(event.reservations || '[]');
|
|
663
|
-
const rIndex = reservations.findIndex(r => r.rid === rid);
|
|
664
|
-
if (rIndex === -1) throw new Error('Reservation not found');
|
|
665
|
-
|
|
666
|
-
const reservation = reservations[rIndex];
|
|
667
|
-
if (reservation.status === 'paid') return res.json({ ok: true });
|
|
668
|
-
|
|
669
|
-
// Update optional aggregate reserved count
|
|
670
|
-
const items = JSON.parse(event.bookingItems || '[]');
|
|
671
|
-
const itemIndex = items.findIndex(i => i.id === reservation.itemId);
|
|
672
|
-
if (itemIndex !== -1) {
|
|
673
|
-
const it = items[itemIndex];
|
|
674
|
-
it.reserved = (Number(it.reserved || 0) + Number(reservation.quantity || 0));
|
|
675
|
-
items[itemIndex] = it;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
reservation.status = 'paid';
|
|
679
|
-
reservation.helloAssoOrderId = orderId;
|
|
680
|
-
reservations[rIndex] = reservation;
|
|
681
|
-
|
|
682
|
-
event.bookingItems = JSON.stringify(items);
|
|
683
|
-
event.reservations = JSON.stringify(reservations);
|
|
684
|
-
|
|
685
|
-
await db.setObject(getEventKey(eid), event);
|
|
686
|
-
|
|
687
|
-
try {
|
|
688
|
-
await emailer.send('calendar-payment-confirmed', reservation.uid, {
|
|
689
|
-
subject: 'Votre paiement a été confirmé',
|
|
690
|
-
eventTitle: event.title,
|
|
691
|
-
itemName: reservation.itemId,
|
|
692
|
-
quantity: reservation.quantity,
|
|
693
|
-
pickupLocation: reservation.pickupLocation || 'Non précisé',
|
|
694
|
-
dateStart: reservation.dateStart,
|
|
695
|
-
dateEnd: reservation.dateEnd,
|
|
696
|
-
days: reservation.days || 1,
|
|
697
|
-
});
|
|
698
|
-
} catch (e) {
|
|
699
|
-
console.warn('[calendar-onekite] email payment-confirmed error:', e.message);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
res.json({ ok: true });
|
|
703
|
-
} catch (err) {
|
|
704
|
-
console.error('[calendar-onekite] HelloAsso webhook error:', err);
|
|
705
|
-
res.status(500).json({ error: err.message });
|
|
706
|
-
}
|
|
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);
|
|
707
527
|
});
|
|
708
528
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
router.get('/api/admin/plugins/calendar-onekite', renderAdminPage);
|
|
716
|
-
|
|
717
|
-
router.put('/api/admin/plugins/calendar-onekite', middleware.admin.checkPrivileges, async (req, res) => {
|
|
718
|
-
try {
|
|
719
|
-
await Settings.set(SETTINGS_KEY, req.body);
|
|
720
|
-
res.json({ status: 'ok' });
|
|
721
|
-
} catch (err) {
|
|
722
|
-
res.status(500).json({ error: err.message });
|
|
723
|
-
}
|
|
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 });
|
|
724
535
|
});
|
|
725
536
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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' });
|
|
729
541
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const uid = String(req.user?.uid || 0);
|
|
733
|
-
if (!uid || uid === '0') return res.status(403).json({ error: 'Non connecté' });
|
|
734
|
-
|
|
735
|
-
const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
|
|
736
|
-
const result = [];
|
|
737
|
-
|
|
738
|
-
for (const eid of eids) {
|
|
739
|
-
const event = await getEvent(eid);
|
|
740
|
-
if (!event) continue;
|
|
741
|
-
|
|
742
|
-
const items = JSON.parse(event.bookingItems || '[]');
|
|
743
|
-
const reservations = JSON.parse(event.reservations || '[]');
|
|
744
|
-
|
|
745
|
-
reservations
|
|
746
|
-
.filter(r => String(r.uid) === uid)
|
|
747
|
-
.forEach(r => {
|
|
748
|
-
const item = items.find(i => i.id === r.itemId);
|
|
749
|
-
result.push({
|
|
750
|
-
...r,
|
|
751
|
-
eventTitle: event.title,
|
|
752
|
-
eventStart: event.start,
|
|
753
|
-
eventEnd: event.end,
|
|
754
|
-
itemName: item ? item.name : r.itemId,
|
|
755
|
-
});
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
result.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
|
|
760
|
-
res.json(result);
|
|
761
|
-
} catch (err) {
|
|
762
|
-
res.status(500).json({ error: err.message });
|
|
763
|
-
}
|
|
764
|
-
});
|
|
542
|
+
const ids = await db.getSortedSetRange(RES_ZSET_BY_START, 0, 100000);
|
|
543
|
+
if (!ids.length) return res.json({ deleted: 0 });
|
|
765
544
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
* ---------------------------- */
|
|
545
|
+
const keys = ids.map(id => `${RES_PREFIX}:${id}`);
|
|
546
|
+
const objs = await db.getObjects(keys);
|
|
769
547
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
for (const eid of eids) {
|
|
777
|
-
const event = await getEvent(eid);
|
|
778
|
-
if (!event) continue;
|
|
779
|
-
|
|
780
|
-
const items = JSON.parse(event.bookingItems || '[]');
|
|
781
|
-
const reservations = JSON.parse(event.reservations || '[]');
|
|
782
|
-
|
|
783
|
-
reservations
|
|
784
|
-
.filter(r => r.status === 'pending_admin' || r.status === 'awaiting_payment' || r.status === 'paid')
|
|
785
|
-
.forEach(r => {
|
|
786
|
-
const item = items.find(i => i.id === r.itemId);
|
|
787
|
-
rows.push({
|
|
788
|
-
eid: event.eid,
|
|
789
|
-
eventTitle: event.title,
|
|
790
|
-
itemId: r.itemId,
|
|
791
|
-
itemName: item ? item.name : r.itemId,
|
|
792
|
-
uid: r.uid,
|
|
793
|
-
quantity: r.quantity,
|
|
794
|
-
dateStart: r.dateStart,
|
|
795
|
-
dateEnd: r.dateEnd,
|
|
796
|
-
days: r.days || daysBetween(r.dateStart, r.dateEnd),
|
|
797
|
-
status: r.status,
|
|
798
|
-
pickupLocation: r.pickupLocation || 'Non précisé',
|
|
799
|
-
});
|
|
800
|
-
});
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
rows.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
|
|
804
|
-
res.json(rows);
|
|
805
|
-
} catch (err) {
|
|
806
|
-
res.status(500).json({ error: err.message });
|
|
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
|
}
|
|
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
|
+
}));
|
|
558
|
+
|
|
559
|
+
res.json({ deleted: toDelete.length });
|
|
808
560
|
});
|
|
809
561
|
};
|
|
810
562
|
|
|
811
|
-
|
|
812
|
-
* ACP nav entry
|
|
813
|
-
* ---------------------------- */
|
|
814
|
-
|
|
815
|
-
Plugin.addAdminNavigation = function (header) {
|
|
563
|
+
plugin.addAdminNavigation = async function (header) {
|
|
816
564
|
header.plugins.push({
|
|
817
|
-
// NodeBB ACP will resolve this under /admin
|
|
818
565
|
route: '/plugins/calendar-onekite',
|
|
819
|
-
icon: 'fa
|
|
566
|
+
icon: 'fa-calendar',
|
|
820
567
|
name: 'Calendar OneKite',
|
|
821
568
|
});
|
|
822
569
|
return header;
|
|
823
570
|
};
|
|
824
571
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
description: 'Affiche la liste des prochains événements du calendrier.',
|
|
834
|
-
content: '',
|
|
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
|
+
});
|
|
835
580
|
});
|
|
836
|
-
return
|
|
837
|
-
};
|
|
838
|
-
|
|
839
|
-
Plugin.renderUpcomingWidget = async function (widget, callback) {
|
|
840
|
-
try {
|
|
841
|
-
const settings = (await Settings.get(SETTINGS_KEY)) || {};
|
|
842
|
-
const limit = Number((widget && widget.data && widget.data.limit) || settings.limit || 5);
|
|
843
|
-
const events = await getUpcomingEvents(limit);
|
|
844
|
-
const html = await appRef.renderAsync('widgets/calendar-upcoming', { events });
|
|
845
|
-
widget.html = html;
|
|
846
|
-
if (typeof callback === 'function') {
|
|
847
|
-
return callback(null, widget);
|
|
848
|
-
}
|
|
849
|
-
return widget;
|
|
850
|
-
} catch (err) {
|
|
851
|
-
if (typeof callback === 'function') {
|
|
852
|
-
return callback(err);
|
|
853
|
-
}
|
|
854
|
-
throw err;
|
|
855
|
-
}
|
|
581
|
+
return data;
|
|
856
582
|
};
|
|
857
583
|
|
|
858
|
-
module.exports =
|
|
584
|
+
module.exports = plugin;
|