nodebb-plugin-calendar-onekite 11.1.69 → 11.1.71
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/lib/admin.js +7 -3
- package/lib/api.js +168 -6
- package/lib/db.js +20 -0
- package/lib/scheduler.js +12 -2
- package/library.js +12 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/admin.js +77 -3
- package/public/client.js +206 -4
- package/templates/admin/plugins/calendar-onekite.tpl +0 -1
package/lib/admin.js
CHANGED
|
@@ -131,12 +131,14 @@ admin.approveReservation = async function (req, res) {
|
|
|
131
131
|
const base = forumBaseUrl();
|
|
132
132
|
const returnUrl = base ? `${base}/calendar` : '';
|
|
133
133
|
const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
|
|
134
|
+
const year = new Date(Number(r.start)).getFullYear();
|
|
134
135
|
paymentUrl = await helloasso.createCheckoutIntent({
|
|
135
136
|
env,
|
|
136
137
|
token,
|
|
137
138
|
organizationSlug: settings.helloassoOrganizationSlug,
|
|
138
139
|
formType: settings.helloassoFormType,
|
|
139
|
-
|
|
140
|
+
// Form slug is derived from the year
|
|
141
|
+
formSlug: `locations-materiel-${year}`,
|
|
140
142
|
totalAmount,
|
|
141
143
|
payerEmail: requester && requester.email,
|
|
142
144
|
// User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
|
|
@@ -266,12 +268,13 @@ admin.debugHelloAsso = async function (req, res) {
|
|
|
266
268
|
|
|
267
269
|
// Catalog items (via /public)
|
|
268
270
|
try {
|
|
271
|
+
const y = new Date().getFullYear();
|
|
269
272
|
const { publicForm, items } = await helloasso.listCatalogItems({
|
|
270
273
|
env,
|
|
271
274
|
token,
|
|
272
275
|
organizationSlug: settings.helloassoOrganizationSlug,
|
|
273
276
|
formType: settings.helloassoFormType,
|
|
274
|
-
formSlug:
|
|
277
|
+
formSlug: `locations-materiel-${y}`,
|
|
275
278
|
});
|
|
276
279
|
|
|
277
280
|
const arr = Array.isArray(items) ? items : [];
|
|
@@ -289,12 +292,13 @@ admin.debugHelloAsso = async function (req, res) {
|
|
|
289
292
|
|
|
290
293
|
// Sold items
|
|
291
294
|
try {
|
|
295
|
+
const y2 = new Date().getFullYear();
|
|
292
296
|
const items = await helloasso.listItems({
|
|
293
297
|
env,
|
|
294
298
|
token,
|
|
295
299
|
organizationSlug: settings.helloassoOrganizationSlug,
|
|
296
300
|
formType: settings.helloassoFormType,
|
|
297
|
-
formSlug:
|
|
301
|
+
formSlug: `locations-materiel-${y2}`,
|
|
298
302
|
});
|
|
299
303
|
const arr = Array.isArray(items) ? items : [];
|
|
300
304
|
out.soldItems.ok = true;
|
package/lib/api.js
CHANGED
|
@@ -114,9 +114,26 @@ function toTs(v) {
|
|
|
114
114
|
return d.getTime();
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
117
|
+
function yearFromTs(ts) {
|
|
118
|
+
const d = new Date(Number(ts));
|
|
119
|
+
return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function autoCreatorGroupForYear(year) {
|
|
123
|
+
return `onekite-ffvl-${year}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function autoFormSlugForYear(year) {
|
|
127
|
+
return `locations-materiel-${year}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function canRequest(uid, settings, startTs) {
|
|
131
|
+
const year = yearFromTs(startTs);
|
|
132
|
+
const defaultGroup = autoCreatorGroupForYear(year);
|
|
133
|
+
const extras = (settings.creatorGroups || settings.allowedGroups || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
134
|
+
const allowed = [defaultGroup, ...extras].filter(Boolean);
|
|
135
|
+
// If only the default group exists, enforce membership (do not open access to all).
|
|
136
|
+
if (!allowed.length) return true;
|
|
120
137
|
for (const g of allowed) {
|
|
121
138
|
const isMember = await groups.isMember(uid, g);
|
|
122
139
|
if (isMember) return true;
|
|
@@ -143,6 +160,38 @@ async function canValidate(uid, settings) {
|
|
|
143
160
|
return false;
|
|
144
161
|
}
|
|
145
162
|
|
|
163
|
+
async function canCreateSpecial(uid, settings) {
|
|
164
|
+
if (!uid) return false;
|
|
165
|
+
try {
|
|
166
|
+
const isAdmin = await groups.isMember(uid, 'administrators');
|
|
167
|
+
if (isAdmin) return true;
|
|
168
|
+
const isGlobalMod = await groups.isMember(uid, 'Global Moderators');
|
|
169
|
+
if (isGlobalMod) return true;
|
|
170
|
+
} catch (e) {}
|
|
171
|
+
const allowed = (settings.specialCreatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
172
|
+
if (!allowed.length) return false;
|
|
173
|
+
for (const g of allowed) {
|
|
174
|
+
if (await groups.isMember(uid, g)) return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function canDeleteSpecial(uid, settings) {
|
|
180
|
+
if (!uid) return false;
|
|
181
|
+
try {
|
|
182
|
+
const isAdmin = await groups.isMember(uid, 'administrators');
|
|
183
|
+
if (isAdmin) return true;
|
|
184
|
+
const isGlobalMod = await groups.isMember(uid, 'Global Moderators');
|
|
185
|
+
if (isGlobalMod) return true;
|
|
186
|
+
} catch (e) {}
|
|
187
|
+
const allowed = (settings.specialDeleterGroups || settings.specialCreatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
188
|
+
if (!allowed.length) return false;
|
|
189
|
+
for (const g of allowed) {
|
|
190
|
+
if (await groups.isMember(uid, g)) return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
146
195
|
function eventsFor(resv) {
|
|
147
196
|
const status = resv.status;
|
|
148
197
|
const icons = { pending: '⏳', awaiting_payment: '💳', paid: '✅' };
|
|
@@ -179,6 +228,32 @@ function eventsFor(resv) {
|
|
|
179
228
|
return out;
|
|
180
229
|
}
|
|
181
230
|
|
|
231
|
+
function eventsForSpecial(ev) {
|
|
232
|
+
const start = new Date(parseInt(ev.start, 10));
|
|
233
|
+
const end = new Date(parseInt(ev.end, 10));
|
|
234
|
+
const startIso = start.toISOString();
|
|
235
|
+
const endIso = end.toISOString();
|
|
236
|
+
return {
|
|
237
|
+
id: `special:${ev.eid}`,
|
|
238
|
+
title: `📌 ${ev.title || 'Évènement'}`.trim(),
|
|
239
|
+
allDay: false,
|
|
240
|
+
start: startIso,
|
|
241
|
+
end: endIso,
|
|
242
|
+
color: '#8e44ad',
|
|
243
|
+
extendedProps: {
|
|
244
|
+
type: 'special',
|
|
245
|
+
eid: ev.eid,
|
|
246
|
+
title: ev.title || '',
|
|
247
|
+
notes: ev.notes || '',
|
|
248
|
+
pickupAddress: ev.address || '',
|
|
249
|
+
pickupLat: ev.lat || '',
|
|
250
|
+
pickupLon: ev.lon || '',
|
|
251
|
+
createdBy: ev.uid || 0,
|
|
252
|
+
username: ev.username || '',
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
182
257
|
const api = {};
|
|
183
258
|
|
|
184
259
|
api.getEvents = async function (req, res) {
|
|
@@ -187,6 +262,8 @@ api.getEvents = async function (req, res) {
|
|
|
187
262
|
|
|
188
263
|
const settings = await meta.settings.get('calendar-onekite');
|
|
189
264
|
const canMod = req.uid ? await canValidate(req.uid, settings) : false;
|
|
265
|
+
const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
|
|
266
|
+
const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
|
|
190
267
|
|
|
191
268
|
// Fetch a wider window because an event can start before the query range
|
|
192
269
|
// and still overlap.
|
|
@@ -221,9 +298,89 @@ api.getEvents = async function (req, res) {
|
|
|
221
298
|
out.push(ev);
|
|
222
299
|
}
|
|
223
300
|
}
|
|
301
|
+
|
|
302
|
+
// Special events
|
|
303
|
+
try {
|
|
304
|
+
const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
|
|
305
|
+
for (const eid of specialIds) {
|
|
306
|
+
const sev = await dbLayer.getSpecialEvent(eid);
|
|
307
|
+
if (!sev) continue;
|
|
308
|
+
const sStart = parseInt(sev.start, 10);
|
|
309
|
+
const sEnd = parseInt(sev.end, 10);
|
|
310
|
+
if (!(sStart < endTs && startTs < sEnd)) continue;
|
|
311
|
+
const ev = eventsForSpecial(sev);
|
|
312
|
+
ev.extendedProps.canCreateSpecial = canSpecialCreate;
|
|
313
|
+
ev.extendedProps.canDeleteSpecial = canSpecialDelete;
|
|
314
|
+
// Show creator username only to moderators/allowed users
|
|
315
|
+
if (sev.username && (canMod || canSpecialDelete || (req.uid && String(req.uid) === String(sev.uid)))) {
|
|
316
|
+
ev.extendedProps.username = String(sev.username);
|
|
317
|
+
}
|
|
318
|
+
out.push(ev);
|
|
319
|
+
}
|
|
320
|
+
} catch (e) {
|
|
321
|
+
// ignore
|
|
322
|
+
}
|
|
224
323
|
res.json(out);
|
|
225
324
|
};
|
|
226
325
|
|
|
326
|
+
api.getCapabilities = async function (req, res) {
|
|
327
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
328
|
+
const uid = req.uid || 0;
|
|
329
|
+
const canMod = uid ? await canValidate(uid, settings) : false;
|
|
330
|
+
res.json({
|
|
331
|
+
canModerate: canMod,
|
|
332
|
+
canCreateSpecial: uid ? await canCreateSpecial(uid, settings) : false,
|
|
333
|
+
canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings) : false,
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
api.createSpecialEvent = async function (req, res) {
|
|
338
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
339
|
+
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
340
|
+
const ok = await canCreateSpecial(req.uid, settings);
|
|
341
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
342
|
+
|
|
343
|
+
const title = String((req.body && req.body.title) || '').trim() || 'Évènement';
|
|
344
|
+
const startTs = toTs(req.body && req.body.start);
|
|
345
|
+
const endTs = toTs(req.body && req.body.end);
|
|
346
|
+
if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || !(startTs < endTs)) {
|
|
347
|
+
return res.status(400).json({ error: 'bad-dates' });
|
|
348
|
+
}
|
|
349
|
+
const address = String((req.body && req.body.address) || '').trim();
|
|
350
|
+
const notes = String((req.body && req.body.notes) || '').trim();
|
|
351
|
+
const lat = String((req.body && req.body.lat) || '').trim();
|
|
352
|
+
const lon = String((req.body && req.body.lon) || '').trim();
|
|
353
|
+
|
|
354
|
+
const u = await user.getUserFields(req.uid, ['username']);
|
|
355
|
+
const eid = crypto.randomUUID();
|
|
356
|
+
const ev = {
|
|
357
|
+
eid,
|
|
358
|
+
title,
|
|
359
|
+
start: String(startTs),
|
|
360
|
+
end: String(endTs),
|
|
361
|
+
address,
|
|
362
|
+
notes,
|
|
363
|
+
lat,
|
|
364
|
+
lon,
|
|
365
|
+
uid: String(req.uid),
|
|
366
|
+
username: u && u.username ? String(u.username) : '',
|
|
367
|
+
createdAt: String(Date.now()),
|
|
368
|
+
};
|
|
369
|
+
await dbLayer.saveSpecialEvent(ev);
|
|
370
|
+
res.json({ ok: true, eid });
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
api.deleteSpecialEvent = async function (req, res) {
|
|
374
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
375
|
+
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
376
|
+
const ok = await canDeleteSpecial(req.uid, settings);
|
|
377
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
378
|
+
const eid = String(req.params.eid || '').replace(/^special:/, '').trim();
|
|
379
|
+
if (!eid) return res.status(400).json({ error: 'bad-id' });
|
|
380
|
+
await dbLayer.removeSpecialEvent(eid);
|
|
381
|
+
res.json({ ok: true });
|
|
382
|
+
};
|
|
383
|
+
|
|
227
384
|
api.getItems = async function (req, res) {
|
|
228
385
|
const settings = await meta.settings.get('calendar-onekite');
|
|
229
386
|
|
|
@@ -243,12 +400,14 @@ api.getItems = async function (req, res) {
|
|
|
243
400
|
|
|
244
401
|
// Important: the /items endpoint on HelloAsso lists *sold items*.
|
|
245
402
|
// For a shop catalog, use the /public form endpoint and extract the catalog.
|
|
403
|
+
const year = new Date().getFullYear();
|
|
246
404
|
const { items: catalog } = await helloasso.listCatalogItems({
|
|
247
405
|
env,
|
|
248
406
|
token,
|
|
249
407
|
organizationSlug: settings.helloassoOrganizationSlug,
|
|
250
408
|
formType: settings.helloassoFormType,
|
|
251
|
-
|
|
409
|
+
// Form slug is derived from the year
|
|
410
|
+
formSlug: autoFormSlugForYear(year),
|
|
252
411
|
});
|
|
253
412
|
|
|
254
413
|
const normalized = (catalog || []).map((it) => ({
|
|
@@ -265,7 +424,8 @@ api.createReservation = async function (req, res) {
|
|
|
265
424
|
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
266
425
|
|
|
267
426
|
const settings = await meta.settings.get('calendar-onekite');
|
|
268
|
-
const
|
|
427
|
+
const startPreview = toTs(req.body.start);
|
|
428
|
+
const ok = await canRequest(uid, settings, startPreview);
|
|
269
429
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
270
430
|
|
|
271
431
|
const start = parseInt(toTs(req.body.start), 10);
|
|
@@ -396,12 +556,14 @@ api.approveReservation = async function (req, res) {
|
|
|
396
556
|
const settings2 = await meta.settings.get('calendar-onekite');
|
|
397
557
|
const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
|
|
398
558
|
const payer = await user.getUserFields(r.uid, ['email']);
|
|
559
|
+
const year = yearFromTs(r.start);
|
|
399
560
|
const intent = await helloasso.createCheckoutIntent({
|
|
400
561
|
env: settings2.helloassoEnv,
|
|
401
562
|
token,
|
|
402
563
|
organizationSlug: settings2.helloassoOrganizationSlug,
|
|
403
564
|
formType: settings2.helloassoFormType,
|
|
404
|
-
|
|
565
|
+
// Form slug is derived from the year of the reservation start date
|
|
566
|
+
formSlug: autoFormSlugForYear(year),
|
|
405
567
|
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
406
568
|
totalAmount: (() => {
|
|
407
569
|
const cents = Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
package/lib/db.js
CHANGED
|
@@ -6,6 +6,10 @@ const KEY_ZSET = 'calendar-onekite:reservations';
|
|
|
6
6
|
const KEY_OBJ = (rid) => `calendar-onekite:reservation:${rid}`;
|
|
7
7
|
const KEY_CHECKOUT_INTENT_TO_RID = 'calendar-onekite:helloasso:checkoutIntentToRid';
|
|
8
8
|
|
|
9
|
+
// Special events (non-reservation events shown in a different colour)
|
|
10
|
+
const KEY_SPECIAL_ZSET = 'calendar-onekite:special';
|
|
11
|
+
const KEY_SPECIAL_OBJ = (eid) => `calendar-onekite:special:${eid}`;
|
|
12
|
+
|
|
9
13
|
async function getReservation(rid) {
|
|
10
14
|
return await db.getObject(KEY_OBJ(rid));
|
|
11
15
|
}
|
|
@@ -43,10 +47,26 @@ async function listAllReservationIds(limit = 5000) {
|
|
|
43
47
|
|
|
44
48
|
module.exports = {
|
|
45
49
|
KEY_ZSET,
|
|
50
|
+
KEY_SPECIAL_ZSET,
|
|
46
51
|
KEY_CHECKOUT_INTENT_TO_RID,
|
|
47
52
|
getReservation,
|
|
48
53
|
saveReservation,
|
|
49
54
|
removeReservation,
|
|
55
|
+
// Special events
|
|
56
|
+
getSpecialEvent: async (eid) => await db.getObject(KEY_SPECIAL_OBJ(eid)),
|
|
57
|
+
saveSpecialEvent: async (ev) => {
|
|
58
|
+
await db.setObject(KEY_SPECIAL_OBJ(ev.eid), ev);
|
|
59
|
+
await db.sortedSetAdd(KEY_SPECIAL_ZSET, ev.start, ev.eid);
|
|
60
|
+
},
|
|
61
|
+
removeSpecialEvent: async (eid) => {
|
|
62
|
+
await db.sortedSetRemove(KEY_SPECIAL_ZSET, eid);
|
|
63
|
+
await db.delete(KEY_SPECIAL_OBJ(eid));
|
|
64
|
+
},
|
|
65
|
+
listSpecialIdsByStartRange: async (startTs, endTs, limit = 2000) => {
|
|
66
|
+
const start = 0;
|
|
67
|
+
const stop = Math.max(0, (parseInt(limit, 10) || 2000) - 1);
|
|
68
|
+
return await db.getSortedSetRangeByScore(KEY_SPECIAL_ZSET, start, stop, startTs, endTs);
|
|
69
|
+
},
|
|
50
70
|
listReservationIdsByStartRange,
|
|
51
71
|
listAllReservationIds,
|
|
52
72
|
};
|
package/lib/scheduler.js
CHANGED
|
@@ -5,10 +5,17 @@ const dbLayer = require('./db');
|
|
|
5
5
|
|
|
6
6
|
let timer = null;
|
|
7
7
|
|
|
8
|
+
function getSetting(settings, key, fallback) {
|
|
9
|
+
const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
|
|
10
|
+
if (v == null || v === '') return fallback;
|
|
11
|
+
if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
// Pending holds: short lock after a user creates a request (defaults to 5 minutes)
|
|
9
16
|
async function expirePending() {
|
|
10
17
|
const settings = await meta.settings.get('calendar-onekite');
|
|
11
|
-
const holdMins = parseInt(settings
|
|
18
|
+
const holdMins = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
|
|
12
19
|
const now = Date.now();
|
|
13
20
|
|
|
14
21
|
const ids = await dbLayer.listAllReservationIds(5000);
|
|
@@ -36,7 +43,10 @@ async function expirePending() {
|
|
|
36
43
|
// - We expire (and remove) after `2 * paymentHoldMinutes`
|
|
37
44
|
async function processAwaitingPayment() {
|
|
38
45
|
const settings = await meta.settings.get('calendar-onekite');
|
|
39
|
-
const holdMins = parseInt(
|
|
46
|
+
const holdMins = parseInt(
|
|
47
|
+
getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')),
|
|
48
|
+
10
|
|
49
|
+
) || 60;
|
|
40
50
|
const now = Date.now();
|
|
41
51
|
|
|
42
52
|
const ids = await dbLayer.listAllReservationIds(5000);
|
package/library.js
CHANGED
|
@@ -64,10 +64,22 @@ Plugin.init = async function (params) {
|
|
|
64
64
|
router.get(p, ...publicExpose, api.getItems);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
['/api/v3/plugins/calendar-onekite/capabilities', '/api/plugins/calendar-onekite/capabilities'].forEach((p) => {
|
|
68
|
+
router.get(p, ...publicExpose, api.getCapabilities);
|
|
69
|
+
});
|
|
70
|
+
|
|
67
71
|
['/api/v3/plugins/calendar-onekite/reservations', '/api/plugins/calendar-onekite/reservations'].forEach((p) => {
|
|
68
72
|
router.post(p, ...publicAuth, api.createReservation);
|
|
69
73
|
});
|
|
70
74
|
|
|
75
|
+
// Special events (other colour) - created/deleted by configured groups
|
|
76
|
+
['/api/v3/plugins/calendar-onekite/special-events', '/api/plugins/calendar-onekite/special-events'].forEach((p) => {
|
|
77
|
+
router.post(p, ...publicAuth, api.createSpecialEvent);
|
|
78
|
+
});
|
|
79
|
+
['/api/v3/plugins/calendar-onekite/special-events/:eid', '/api/plugins/calendar-onekite/special-events/:eid'].forEach((p) => {
|
|
80
|
+
router.delete(p, ...publicAuth, api.deleteSpecialEvent);
|
|
81
|
+
});
|
|
82
|
+
|
|
71
83
|
// Validator actions from the calendar popup (requires login + validatorGroups)
|
|
72
84
|
['/api/v3/plugins/calendar-onekite/reservations/:rid/approve', '/api/plugins/calendar-onekite/reservations/:rid/approve'].forEach((p) => {
|
|
73
85
|
router.put(p, ...publicAuth, api.approveReservation);
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/public/admin.js
CHANGED
|
@@ -138,6 +138,49 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
138
138
|
});
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
function normalizeCsvGroupsWithDefault(csv, defaultGroup) {
|
|
142
|
+
const extras = String(csv || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
143
|
+
const set = new Set();
|
|
144
|
+
const out = [];
|
|
145
|
+
if (defaultGroup) {
|
|
146
|
+
const dg = String(defaultGroup).trim();
|
|
147
|
+
if (dg) {
|
|
148
|
+
set.add(dg);
|
|
149
|
+
out.push(dg);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const g of extras) {
|
|
153
|
+
if (!set.has(g)) {
|
|
154
|
+
set.add(g);
|
|
155
|
+
out.push(g);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out.join(', ');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ensureSpecialFieldsExist(form) {
|
|
162
|
+
// If the ACP template didn't include these fields (older installs), inject them.
|
|
163
|
+
if (!form) return;
|
|
164
|
+
const hasCreator = form.querySelector('[name="specialCreatorGroups"]');
|
|
165
|
+
const hasDeleter = form.querySelector('[name="specialDeleterGroups"]');
|
|
166
|
+
if (hasCreator && hasDeleter) return;
|
|
167
|
+
const wrap = document.createElement('div');
|
|
168
|
+
wrap.innerHTML = `
|
|
169
|
+
<hr />
|
|
170
|
+
<h4>Évènements (autre couleur)</h4>
|
|
171
|
+
<p class="text-muted" style="max-width: 900px;">Permet de créer des évènements non liés aux locations (autre couleur), avec date/heure, adresse (OpenStreetMap) et notes.</p>
|
|
172
|
+
<div class="mb-3">
|
|
173
|
+
<label class="form-label">Groupes autorisés à créer ces évènements (CSV)</label>
|
|
174
|
+
<input type="text" class="form-control" name="specialCreatorGroups" placeholder="ex: staff, instructors" />
|
|
175
|
+
</div>
|
|
176
|
+
<div class="mb-3">
|
|
177
|
+
<label class="form-label">Groupes autorisés à supprimer ces évènements (CSV)</label>
|
|
178
|
+
<input type="text" class="form-control" name="specialDeleterGroups" placeholder="ex: administrators" />
|
|
179
|
+
</div>
|
|
180
|
+
`;
|
|
181
|
+
form.appendChild(wrap);
|
|
182
|
+
}
|
|
183
|
+
|
|
141
184
|
|
|
142
185
|
function renderPending(list) {
|
|
143
186
|
const wrap = document.getElementById('onekite-pending');
|
|
@@ -396,10 +439,34 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
396
439
|
const form = document.getElementById('onekite-settings-form');
|
|
397
440
|
if (!form) return;
|
|
398
441
|
|
|
442
|
+
// Inject missing settings fields if the template is older
|
|
443
|
+
function ensureTextInput(name, label, help) {
|
|
444
|
+
if (form.querySelector(`[name="${name}"]`)) return;
|
|
445
|
+
const div = document.createElement('div');
|
|
446
|
+
div.className = 'mb-3';
|
|
447
|
+
div.innerHTML = `
|
|
448
|
+
<label class="form-label">${label}</label>
|
|
449
|
+
<input type="text" class="form-control" name="${name}" placeholder="" />
|
|
450
|
+
${help ? `<div class="form-text">${help}</div>` : ''}
|
|
451
|
+
`;
|
|
452
|
+
form.appendChild(div);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
ensureTextInput('specialCreatorGroups', 'Groupes autorisés à créer des évènements (csv)', 'Ex: groupA,groupB');
|
|
456
|
+
ensureTextInput('specialDeleterGroups', 'Groupes autorisés à supprimer des évènements (csv)', 'Par défaut, si vide : même liste que la création');
|
|
457
|
+
|
|
399
458
|
// Load settings
|
|
400
459
|
try {
|
|
401
460
|
const s = await loadSettings();
|
|
402
461
|
fillForm(form, s || {});
|
|
462
|
+
|
|
463
|
+
// Ensure default creator group prefix appears in the ACP field
|
|
464
|
+
const y = new Date().getFullYear();
|
|
465
|
+
const defaultGroup = `onekite-ffvl-${y}`;
|
|
466
|
+
const cgEl = form.querySelector('[name="creatorGroups"]');
|
|
467
|
+
if (cgEl) {
|
|
468
|
+
cgEl.value = normalizeCsvGroupsWithDefault(cgEl.value, defaultGroup);
|
|
469
|
+
}
|
|
403
470
|
} catch (e) {
|
|
404
471
|
showAlert('error', 'Impossible de charger les paramètres.');
|
|
405
472
|
}
|
|
@@ -426,7 +493,14 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
426
493
|
doSave._inFlight = true;
|
|
427
494
|
try {
|
|
428
495
|
if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
|
|
429
|
-
|
|
496
|
+
const payload = formToObject(form);
|
|
497
|
+
// Always prefix with default yearly group
|
|
498
|
+
const y = new Date().getFullYear();
|
|
499
|
+
const defaultGroup = `onekite-ffvl-${y}`;
|
|
500
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'creatorGroups')) {
|
|
501
|
+
payload.creatorGroups = normalizeCsvGroupsWithDefault(payload.creatorGroups, defaultGroup);
|
|
502
|
+
}
|
|
503
|
+
await saveSettings(payload);
|
|
430
504
|
showAlert('success', 'Paramètres enregistrés.');
|
|
431
505
|
} catch (e) {
|
|
432
506
|
showAlert('error', 'Échec de l\'enregistrement.');
|
|
@@ -435,9 +509,9 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
435
509
|
}
|
|
436
510
|
}
|
|
437
511
|
|
|
438
|
-
// Save buttons (
|
|
512
|
+
// Save buttons (NodeBB header/footer "Enregistrer" + floppy icon)
|
|
439
513
|
// Use ONE delegated listener to avoid double submissions.
|
|
440
|
-
const SAVE_SELECTOR = '#
|
|
514
|
+
const SAVE_SELECTOR = '#save, .save, [data-action="save"], .settings-save, .floating-save, .btn[data-action="save"]';
|
|
441
515
|
document.addEventListener('click', (ev) => {
|
|
442
516
|
const btn = ev.target && ev.target.closest && ev.target.closest(SAVE_SELECTOR);
|
|
443
517
|
if (!btn) return;
|
package/public/client.js
CHANGED
|
@@ -16,6 +16,108 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
16
16
|
.replace(/'/g, ''');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
async function openSpecialEventDialog(selectionInfo) {
|
|
20
|
+
const start = selectionInfo.start;
|
|
21
|
+
const end = selectionInfo.end;
|
|
22
|
+
const html = `
|
|
23
|
+
<div class="mb-3">
|
|
24
|
+
<label class="form-label">Titre</label>
|
|
25
|
+
<input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
|
|
26
|
+
</div>
|
|
27
|
+
<div class="row g-2">
|
|
28
|
+
<div class="col-12 col-md-6">
|
|
29
|
+
<label class="form-label">Début</label>
|
|
30
|
+
<input type="datetime-local" class="form-control" id="onekite-se-start" value="${escapeHtml(toDatetimeLocalValue(start))}" />
|
|
31
|
+
</div>
|
|
32
|
+
<div class="col-12 col-md-6">
|
|
33
|
+
<label class="form-label">Fin</label>
|
|
34
|
+
<input type="datetime-local" class="form-control" id="onekite-se-end" value="${escapeHtml(toDatetimeLocalValue(end))}" />
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="mt-3">
|
|
38
|
+
<label class="form-label">Adresse</label>
|
|
39
|
+
<div class="input-group">
|
|
40
|
+
<input type="text" class="form-control" id="onekite-se-address" placeholder="Adresse complète" />
|
|
41
|
+
<button class="btn btn-outline-secondary" type="button" id="onekite-se-geocode">Rechercher</button>
|
|
42
|
+
</div>
|
|
43
|
+
<div id="onekite-se-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
|
|
44
|
+
<input type="hidden" id="onekite-se-lat" />
|
|
45
|
+
<input type="hidden" id="onekite-se-lon" />
|
|
46
|
+
</div>
|
|
47
|
+
<div class="mt-3">
|
|
48
|
+
<label class="form-label">Notes (facultatif)</label>
|
|
49
|
+
<textarea class="form-control" id="onekite-se-notes" rows="3" placeholder="..."></textarea>
|
|
50
|
+
</div>
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
return await new Promise((resolve) => {
|
|
54
|
+
bootbox.dialog({
|
|
55
|
+
title: 'Créer un évènement',
|
|
56
|
+
message: html,
|
|
57
|
+
buttons: {
|
|
58
|
+
cancel: {
|
|
59
|
+
label: 'Annuler',
|
|
60
|
+
className: 'btn-secondary',
|
|
61
|
+
callback: () => resolve(null),
|
|
62
|
+
},
|
|
63
|
+
ok: {
|
|
64
|
+
label: 'Créer',
|
|
65
|
+
className: 'btn-primary',
|
|
66
|
+
callback: () => {
|
|
67
|
+
const title = (document.getElementById('onekite-se-title')?.value || '').trim();
|
|
68
|
+
const startVal = (document.getElementById('onekite-se-start')?.value || '').trim();
|
|
69
|
+
const endVal = (document.getElementById('onekite-se-end')?.value || '').trim();
|
|
70
|
+
const address = (document.getElementById('onekite-se-address')?.value || '').trim();
|
|
71
|
+
const notes = (document.getElementById('onekite-se-notes')?.value || '').trim();
|
|
72
|
+
const lat = (document.getElementById('onekite-se-lat')?.value || '').trim();
|
|
73
|
+
const lon = (document.getElementById('onekite-se-lon')?.value || '').trim();
|
|
74
|
+
resolve({ title, start: startVal, end: endVal, address, notes, lat, lon });
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// init leaflet
|
|
82
|
+
setTimeout(async () => {
|
|
83
|
+
try {
|
|
84
|
+
const mapEl = document.getElementById('onekite-se-map');
|
|
85
|
+
if (!mapEl) return;
|
|
86
|
+
const L = await loadLeaflet();
|
|
87
|
+
const map = L.map(mapEl).setView([46.5, 2.5], 5);
|
|
88
|
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(map);
|
|
89
|
+
let marker = null;
|
|
90
|
+
function setMarker(lat, lon) {
|
|
91
|
+
if (marker) map.removeLayer(marker);
|
|
92
|
+
marker = L.marker([lat, lon], { draggable: true }).addTo(map);
|
|
93
|
+
marker.on('dragend', () => {
|
|
94
|
+
const p = marker.getLatLng();
|
|
95
|
+
document.getElementById('onekite-se-lat').value = String(p.lat);
|
|
96
|
+
document.getElementById('onekite-se-lon').value = String(p.lng);
|
|
97
|
+
});
|
|
98
|
+
document.getElementById('onekite-se-lat').value = String(lat);
|
|
99
|
+
document.getElementById('onekite-se-lon').value = String(lon);
|
|
100
|
+
map.setView([lat, lon], 14);
|
|
101
|
+
}
|
|
102
|
+
const geocodeBtn = document.getElementById('onekite-se-geocode');
|
|
103
|
+
const addrInput = document.getElementById('onekite-se-address');
|
|
104
|
+
geocodeBtn?.addEventListener('click', async () => {
|
|
105
|
+
const q = (addrInput?.value || '').trim();
|
|
106
|
+
if (!q) return;
|
|
107
|
+
const hit = await geocodeAddress(q);
|
|
108
|
+
if (hit && hit.lat && hit.lon) {
|
|
109
|
+
setMarker(hit.lat, hit.lon);
|
|
110
|
+
} else {
|
|
111
|
+
showAlert('error', 'Adresse introuvable.');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// ignore leaflet errors
|
|
116
|
+
}
|
|
117
|
+
}, 0);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
19
121
|
function statusLabel(s) {
|
|
20
122
|
const map = {
|
|
21
123
|
pending: 'En attente',
|
|
@@ -60,6 +162,14 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
60
162
|
return await res.json();
|
|
61
163
|
}
|
|
62
164
|
|
|
165
|
+
async function loadCapabilities() {
|
|
166
|
+
try {
|
|
167
|
+
return await fetchJson('/api/v3/plugins/calendar-onekite/capabilities');
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return await fetchJson('/api/plugins/calendar-onekite/capabilities');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
63
173
|
// Leaflet (OpenStreetMap) helpers - loaded lazily only when needed.
|
|
64
174
|
let leafletPromise = null;
|
|
65
175
|
function loadLeaflet() {
|
|
@@ -167,6 +277,12 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
167
277
|
}
|
|
168
278
|
}
|
|
169
279
|
|
|
280
|
+
function toDatetimeLocalValue(date) {
|
|
281
|
+
const d = new Date(date);
|
|
282
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
283
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
170
286
|
async function openReservationDialog(selectionInfo, items) {
|
|
171
287
|
const start = selectionInfo.start;
|
|
172
288
|
const end = selectionInfo.end;
|
|
@@ -287,12 +403,31 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
287
403
|
}
|
|
288
404
|
|
|
289
405
|
const items = await loadItems();
|
|
406
|
+
const caps = await loadCapabilities().catch(() => ({}));
|
|
407
|
+
const canCreateSpecial = !!caps.canCreateSpecial;
|
|
408
|
+
const canDeleteSpecial = !!caps.canDeleteSpecial;
|
|
409
|
+
|
|
410
|
+
let mode = 'reservation'; // or 'special'
|
|
290
411
|
|
|
291
412
|
const calendar = new FullCalendar.Calendar(el, {
|
|
292
413
|
initialView: 'dayGridMonth',
|
|
293
414
|
locale: 'fr',
|
|
294
|
-
|
|
295
|
-
|
|
415
|
+
headerToolbar: {
|
|
416
|
+
left: 'prev,next today',
|
|
417
|
+
center: 'title',
|
|
418
|
+
right: (canCreateSpecial ? 'newSpecial ' : '') + 'dayGridMonth,timeGridWeek,timeGridDay',
|
|
419
|
+
},
|
|
420
|
+
customButtons: canCreateSpecial ? {
|
|
421
|
+
newSpecial: {
|
|
422
|
+
text: 'Évènement',
|
|
423
|
+
click: () => {
|
|
424
|
+
mode = 'special';
|
|
425
|
+
showAlert('success', 'Mode évènement : sélectionne une plage (date/heure) sur le calendrier.');
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
} : {},
|
|
429
|
+
// Display time for special events, but keep reservations as all-day events
|
|
430
|
+
displayEventTime: true,
|
|
296
431
|
selectable: true,
|
|
297
432
|
selectMirror: true,
|
|
298
433
|
events: async function (info, successCallback, failureCallback) {
|
|
@@ -310,6 +445,30 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
310
445
|
}
|
|
311
446
|
isDialogOpen = true;
|
|
312
447
|
try {
|
|
448
|
+
if (mode === 'special' && canCreateSpecial) {
|
|
449
|
+
const payload = await openSpecialEventDialog(info);
|
|
450
|
+
mode = 'reservation';
|
|
451
|
+
if (!payload) {
|
|
452
|
+
calendar.unselect();
|
|
453
|
+
isDialogOpen = false;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
body: JSON.stringify(payload),
|
|
459
|
+
}).catch(async () => {
|
|
460
|
+
return await fetchJson('/api/plugins/calendar-onekite/special-events', {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
body: JSON.stringify(payload),
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
showAlert('success', 'Évènement créé.');
|
|
466
|
+
calendar.refetchEvents();
|
|
467
|
+
calendar.unselect();
|
|
468
|
+
isDialogOpen = false;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
313
472
|
if (!items || !items.length) {
|
|
314
473
|
showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
|
|
315
474
|
calendar.unselect();
|
|
@@ -323,9 +482,11 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
323
482
|
return;
|
|
324
483
|
}
|
|
325
484
|
// Send date strings (no hours) so reservations are day-based.
|
|
485
|
+
const startDate = new Date(info.start).toISOString().slice(0, 10);
|
|
486
|
+
const endDate = new Date(info.end).toISOString().slice(0, 10);
|
|
326
487
|
await requestReservation({
|
|
327
|
-
start:
|
|
328
|
-
end:
|
|
488
|
+
start: startDate,
|
|
489
|
+
end: endDate,
|
|
329
490
|
itemIds: chosen.itemIds,
|
|
330
491
|
itemNames: chosen.itemNames,
|
|
331
492
|
total: chosen.total,
|
|
@@ -357,6 +518,47 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
357
518
|
eventClick: async function (info) {
|
|
358
519
|
const ev = info.event;
|
|
359
520
|
const p = ev.extendedProps || {};
|
|
521
|
+
if (p.type === 'special') {
|
|
522
|
+
const username = String(p.username || '').trim();
|
|
523
|
+
const userLine = username
|
|
524
|
+
? `<div class="mb-2"><strong>Créé par</strong><br><a href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
|
|
525
|
+
: '';
|
|
526
|
+
const addr = String(p.pickupAddress || '').trim();
|
|
527
|
+
const notes = String(p.notes || '').trim();
|
|
528
|
+
const html = `
|
|
529
|
+
<div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
|
|
530
|
+
${userLine}
|
|
531
|
+
<div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDt(ev.start))} → ${escapeHtml(formatDt(ev.end))}</div>
|
|
532
|
+
${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${escapeHtml(addr)}</div>` : ''}
|
|
533
|
+
${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
|
|
534
|
+
`;
|
|
535
|
+
const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
|
|
536
|
+
bootbox.dialog({
|
|
537
|
+
title: 'Évènement',
|
|
538
|
+
message: html,
|
|
539
|
+
buttons: {
|
|
540
|
+
close: { label: 'Fermer', className: 'btn-secondary' },
|
|
541
|
+
...(canDel ? {
|
|
542
|
+
del: {
|
|
543
|
+
label: 'Supprimer',
|
|
544
|
+
className: 'btn-danger',
|
|
545
|
+
callback: async () => {
|
|
546
|
+
try {
|
|
547
|
+
const eid = String(p.eid || ev.id).replace(/^special:/, '');
|
|
548
|
+
await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' })
|
|
549
|
+
.catch(() => fetchJson(`/api/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' }));
|
|
550
|
+
showAlert('success', 'Évènement supprimé.');
|
|
551
|
+
calendar.refetchEvents();
|
|
552
|
+
} catch (e) {
|
|
553
|
+
showAlert('error', 'Suppression impossible.');
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
} : {}),
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
360
562
|
const rid = p.rid || ev.id;
|
|
361
563
|
const status = p.status || '';
|
|
362
564
|
|