nodebb-plugin-onekite-calendar 2.0.43 → 2.0.45
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 +52 -1
- package/lib/api.js +278 -0
- package/lib/discord.js +0 -1
- package/lib/helloassoWebhook.js +40 -0
- package/library.js +4 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/client.js +166 -48
- package/templates/admin/plugins/calendar-onekite.tpl +22 -12
- package/templates/emails/calendar-onekite_approved.tpl +10 -0
- package/templates/emails/calendar-onekite_paid.tpl +10 -0
package/lib/admin.js
CHANGED
|
@@ -4,6 +4,41 @@ const meta = require.main.require('./src/meta');
|
|
|
4
4
|
const user = require.main.require('./src/user');
|
|
5
5
|
const emailer = require.main.require('./src/emailer');
|
|
6
6
|
const { forumBaseUrl, formatFR } = require('./utils');
|
|
7
|
+
const realtime = require('./realtime');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const nconf = require.main.require('nconf');
|
|
10
|
+
|
|
11
|
+
function baseUrl() {
|
|
12
|
+
try { return String(nconf.get('url') || '').replace(/\/$/, ''); } catch (e) { return forumBaseUrl || ''; }
|
|
13
|
+
}
|
|
14
|
+
function hmacSecret() {
|
|
15
|
+
try {
|
|
16
|
+
const s = String(nconf.get('secret') || '').trim();
|
|
17
|
+
if (s) return s;
|
|
18
|
+
} catch (e) {}
|
|
19
|
+
return 'calendar-onekite';
|
|
20
|
+
}
|
|
21
|
+
function signCalendarLink(type, id, uid) {
|
|
22
|
+
try {
|
|
23
|
+
return crypto.createHmac('sha256', hmacSecret()).update(`${String(type)}:${String(id)}:${String(uid || 0)}`).digest('hex');
|
|
24
|
+
} catch (e) { return ''; }
|
|
25
|
+
}
|
|
26
|
+
function ymdToCompact(ymd) { return String(ymd || '').replace(/-/g, ''); }
|
|
27
|
+
function dtToGCalUtc(dt) {
|
|
28
|
+
const d = (dt instanceof Date) ? dt : new Date(dt);
|
|
29
|
+
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
30
|
+
}
|
|
31
|
+
function buildCalendarLinks({ rid, uid, itemNames, pickupAddress, startYmd, endYmd }) {
|
|
32
|
+
const sig = signCalendarLink('reservation', String(rid), Number(uid) || 0);
|
|
33
|
+
const icsUrl = `${baseUrl()}/plugins/calendar-onekite/ics/reservation/${encodeURIComponent(String(rid))}?uid=${encodeURIComponent(String(uid || 0))}&sig=${encodeURIComponent(sig)}`;
|
|
34
|
+
const title = (Array.isArray(itemNames) && itemNames.length) ? `Location - ${itemNames.join(', ')}` : 'Location';
|
|
35
|
+
const gcal = new URL('https://calendar.google.com/calendar/render');
|
|
36
|
+
gcal.searchParams.set('action', 'TEMPLATE');
|
|
37
|
+
gcal.searchParams.set('text', title);
|
|
38
|
+
gcal.searchParams.set('dates', `${ymdToCompact(startYmd)}/${ymdToCompact(endYmd)}`);
|
|
39
|
+
if (pickupAddress) gcal.searchParams.set('location', String(pickupAddress));
|
|
40
|
+
return { icsUrl, googleCalUrl: gcal.toString() };
|
|
41
|
+
}
|
|
7
42
|
|
|
8
43
|
function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
|
|
9
44
|
const base = String(baseLabel || 'Réservation matériel Onekite').trim();
|
|
@@ -60,7 +95,6 @@ function normalizeReturnUrl(meta) {
|
|
|
60
95
|
|
|
61
96
|
|
|
62
97
|
const dbLayer = require('./db');
|
|
63
|
-
const realtime = require('./realtime');
|
|
64
98
|
const helloasso = require('./helloasso');
|
|
65
99
|
|
|
66
100
|
const ADMIN_PRIV = 'admin:settings';
|
|
@@ -224,6 +258,14 @@ admin.approveReservation = async function (req, res) {
|
|
|
224
258
|
pickupLat: r.pickupLat || '',
|
|
225
259
|
pickupLon: r.pickupLon || '',
|
|
226
260
|
mapUrl,
|
|
261
|
+
...(buildCalendarLinks({
|
|
262
|
+
rid: String(r.rid),
|
|
263
|
+
uid: requesterUid,
|
|
264
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
265
|
+
pickupAddress: r.pickupAddress || '',
|
|
266
|
+
startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
|
|
267
|
+
endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
|
|
268
|
+
})),
|
|
227
269
|
validatedBy: r.approvedByUsername || '',
|
|
228
270
|
validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
|
|
229
271
|
});
|
|
@@ -284,6 +326,9 @@ admin.purgeByYear = async function (req, res) {
|
|
|
284
326
|
await dbLayer.removeReservation(rid);
|
|
285
327
|
removed++;
|
|
286
328
|
}
|
|
329
|
+
try {
|
|
330
|
+
realtime.emitCalendarUpdated({ kind: 'reservation', action: 'purged', year: y, removed });
|
|
331
|
+
} catch (e) {}
|
|
287
332
|
res.json({ ok: true, removed });
|
|
288
333
|
};
|
|
289
334
|
|
|
@@ -302,6 +347,9 @@ admin.purgeSpecialEventsByYear = async function (req, res) {
|
|
|
302
347
|
await dbLayer.removeSpecialEvent(eid);
|
|
303
348
|
count++;
|
|
304
349
|
}
|
|
350
|
+
try {
|
|
351
|
+
realtime.emitCalendarUpdated({ kind: 'special', action: 'purged', year: y, removed: count });
|
|
352
|
+
} catch (e) {}
|
|
305
353
|
return res.json({ ok: true, removed: count });
|
|
306
354
|
};
|
|
307
355
|
|
|
@@ -320,6 +368,9 @@ admin.purgeOutingsByYear = async function (req, res) {
|
|
|
320
368
|
await dbLayer.removeOuting(oid);
|
|
321
369
|
count++;
|
|
322
370
|
}
|
|
371
|
+
try {
|
|
372
|
+
realtime.emitCalendarUpdated({ kind: 'outing', action: 'purged', year: y, removed: count });
|
|
373
|
+
} catch (e) {}
|
|
323
374
|
return res.json({ ok: true, removed: count });
|
|
324
375
|
};
|
|
325
376
|
|
package/lib/api.js
CHANGED
|
@@ -409,6 +409,19 @@ function eventsFor(resv) {
|
|
|
409
409
|
const itemIds = Array.isArray(resv.itemIds) ? resv.itemIds : (resv.itemId ? [resv.itemId] : []);
|
|
410
410
|
const itemNames = Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : []);
|
|
411
411
|
|
|
412
|
+
// Calendar export links (only really useful to the reservation creator, but
|
|
413
|
+
// harmless to include for everyone; access is protected by signature).
|
|
414
|
+
const links = buildCalendarLinks({
|
|
415
|
+
type: 'reservation',
|
|
416
|
+
id: String(resv.rid),
|
|
417
|
+
uid: Number(resv.uid) || 0,
|
|
418
|
+
title: (Array.isArray(itemNames) && itemNames.length) ? `Location - ${String(itemNames[0])}` : 'Location',
|
|
419
|
+
details: (Array.isArray(itemNames) && itemNames.length) ? `Matériel: ${itemNames.join(', ')}` : '',
|
|
420
|
+
allDay: true,
|
|
421
|
+
startYmd: startIsoDate,
|
|
422
|
+
endYmd: endIsoDate,
|
|
423
|
+
});
|
|
424
|
+
|
|
412
425
|
// One line = one material: return one calendar event per item
|
|
413
426
|
const out = [];
|
|
414
427
|
const count = Math.max(itemIds.length, itemNames.length, 1);
|
|
@@ -435,6 +448,8 @@ function eventsFor(resv) {
|
|
|
435
448
|
itemNames: itemNames.filter(Boolean),
|
|
436
449
|
itemIdLine: itemId,
|
|
437
450
|
itemNameLine: itemName,
|
|
451
|
+
icsUrl: links.icsUrl,
|
|
452
|
+
googleCalUrl: links.googleCalUrl,
|
|
438
453
|
},
|
|
439
454
|
});
|
|
440
455
|
}
|
|
@@ -446,6 +461,17 @@ function eventsForSpecial(ev) {
|
|
|
446
461
|
const end = new Date(parseInt(ev.end, 10));
|
|
447
462
|
const startIso = start.toISOString();
|
|
448
463
|
const endIso = end.toISOString();
|
|
464
|
+
const links = buildCalendarLinks({
|
|
465
|
+
type: 'special',
|
|
466
|
+
id: String(ev.eid),
|
|
467
|
+
uid: Number(ev.uid) || 0,
|
|
468
|
+
title: String(ev.title || 'Évènement'),
|
|
469
|
+
details: String(ev.notes || ''),
|
|
470
|
+
location: String(ev.address || ''),
|
|
471
|
+
allDay: false,
|
|
472
|
+
start,
|
|
473
|
+
end,
|
|
474
|
+
});
|
|
449
475
|
return {
|
|
450
476
|
id: `special:${ev.eid}`,
|
|
451
477
|
title: `${ev.title || 'Évènement'}`.trim(),
|
|
@@ -465,6 +491,8 @@ function eventsForSpecial(ev) {
|
|
|
465
491
|
pickupLon: ev.lon || '',
|
|
466
492
|
createdBy: ev.uid || 0,
|
|
467
493
|
username: ev.username || '',
|
|
494
|
+
icsUrl: links.icsUrl,
|
|
495
|
+
googleCalUrl: links.googleCalUrl,
|
|
468
496
|
},
|
|
469
497
|
};
|
|
470
498
|
}
|
|
@@ -475,6 +503,17 @@ function eventsForOuting(o) {
|
|
|
475
503
|
const end = new Date(parseInt(o.end, 10));
|
|
476
504
|
const startIso = start.toISOString();
|
|
477
505
|
const endIso = end.toISOString();
|
|
506
|
+
const links = buildCalendarLinks({
|
|
507
|
+
type: 'outing',
|
|
508
|
+
id: String(o.oid),
|
|
509
|
+
uid: Number(o.uid) || 0,
|
|
510
|
+
title: String(o.title || 'Prévision de sortie'),
|
|
511
|
+
details: String(o.notes || ''),
|
|
512
|
+
location: String(o.address || ''),
|
|
513
|
+
allDay: false,
|
|
514
|
+
start,
|
|
515
|
+
end,
|
|
516
|
+
});
|
|
478
517
|
return {
|
|
479
518
|
id: `outing:${o.oid}`,
|
|
480
519
|
title: `${o.title || 'Prévision de sortie'}`.trim(),
|
|
@@ -494,12 +533,89 @@ function eventsForOuting(o) {
|
|
|
494
533
|
pickupLon: o.lon || '',
|
|
495
534
|
createdBy: o.uid || 0,
|
|
496
535
|
username: o.username || '',
|
|
536
|
+
icsUrl: links.icsUrl,
|
|
537
|
+
googleCalUrl: links.googleCalUrl,
|
|
497
538
|
},
|
|
498
539
|
};
|
|
499
540
|
}
|
|
500
541
|
|
|
501
542
|
const api = {};
|
|
502
543
|
|
|
544
|
+
// --------------------
|
|
545
|
+
// Calendar export helpers (ICS / Google Calendar template link)
|
|
546
|
+
// --------------------
|
|
547
|
+
const nconf = require.main.require('nconf');
|
|
548
|
+
function baseUrl() {
|
|
549
|
+
try {
|
|
550
|
+
return String(nconf.get('url') || '').replace(/\/$/, '');
|
|
551
|
+
} catch (e) {
|
|
552
|
+
return '';
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function hmacSecret() {
|
|
557
|
+
// Prefer NodeBB's instance secret if present.
|
|
558
|
+
try {
|
|
559
|
+
const s = String(nconf.get('secret') || '').trim();
|
|
560
|
+
if (s) return s;
|
|
561
|
+
} catch (e) {}
|
|
562
|
+
// Fallback: still deterministic per instance.
|
|
563
|
+
return 'calendar-onekite';
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function signCalendarLink(type, id, uid) {
|
|
567
|
+
try {
|
|
568
|
+
const msg = `${String(type)}:${String(id)}:${String(uid || 0)}`;
|
|
569
|
+
return crypto.createHmac('sha256', hmacSecret()).update(msg).digest('hex');
|
|
570
|
+
} catch (e) {
|
|
571
|
+
return '';
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function ymdToCompact(ymd) {
|
|
576
|
+
return String(ymd || '').replace(/-/g, '');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function dtToGCalUtc(dt) {
|
|
580
|
+
const d = (dt instanceof Date) ? dt : new Date(dt);
|
|
581
|
+
const iso = d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
582
|
+
// 20260116T120000Z style
|
|
583
|
+
return iso;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function buildCalendarLinks(opts) {
|
|
587
|
+
const type = String(opts.type || '').trim();
|
|
588
|
+
const id = String(opts.id || '').trim();
|
|
589
|
+
const uid = Number(opts.uid) || 0;
|
|
590
|
+
const title = String(opts.title || '').trim() || 'Évènement';
|
|
591
|
+
const details = String(opts.details || '').trim();
|
|
592
|
+
const location = String(opts.location || '').trim();
|
|
593
|
+
const isAllDay = !!opts.allDay;
|
|
594
|
+
|
|
595
|
+
// Signed ICS URL (public, but protected by signature for reservations).
|
|
596
|
+
const sig = signCalendarLink(type, id, uid);
|
|
597
|
+
const icsPath = `/plugins/calendar-onekite/ics/${encodeURIComponent(type)}/${encodeURIComponent(id)}?uid=${encodeURIComponent(String(uid))}&sig=${encodeURIComponent(sig)}`;
|
|
598
|
+
const icsUrl = `${baseUrl()}${icsPath}`;
|
|
599
|
+
|
|
600
|
+
// Google Calendar template URL.
|
|
601
|
+
// docs: action=TEMPLATE&text=...&dates=...&details=...&location=...
|
|
602
|
+
let dates = '';
|
|
603
|
+
if (isAllDay) {
|
|
604
|
+
// Google expects exclusive end date for all-day ranges.
|
|
605
|
+
dates = `${ymdToCompact(opts.startYmd)}/${ymdToCompact(opts.endYmd)}`;
|
|
606
|
+
} else {
|
|
607
|
+
dates = `${dtToGCalUtc(opts.start)}/${dtToGCalUtc(opts.end)}`;
|
|
608
|
+
}
|
|
609
|
+
const gcal = new URL('https://calendar.google.com/calendar/render');
|
|
610
|
+
gcal.searchParams.set('action', 'TEMPLATE');
|
|
611
|
+
gcal.searchParams.set('text', title);
|
|
612
|
+
if (dates) gcal.searchParams.set('dates', dates);
|
|
613
|
+
if (details) gcal.searchParams.set('details', details);
|
|
614
|
+
if (location) gcal.searchParams.set('location', location);
|
|
615
|
+
|
|
616
|
+
return { icsUrl, googleCalUrl: gcal.toString() };
|
|
617
|
+
}
|
|
618
|
+
|
|
503
619
|
function computeEtag(payload) {
|
|
504
620
|
// Weak ETag is fine here: it is only used to skip identical JSON payloads.
|
|
505
621
|
const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
|
|
@@ -1424,6 +1540,28 @@ api.approveReservation = async function (req, res) {
|
|
|
1424
1540
|
pickupLon: r.pickupLon || '',
|
|
1425
1541
|
mapUrl,
|
|
1426
1542
|
paymentUrl: r.paymentUrl || '',
|
|
1543
|
+
icsUrl: (buildCalendarLinks({
|
|
1544
|
+
type: 'reservation',
|
|
1545
|
+
id: String(r.rid),
|
|
1546
|
+
uid: requesterUid,
|
|
1547
|
+
title: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Location - ${r.itemNames.join(', ')}` : 'Location',
|
|
1548
|
+
details: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Matériel: ${r.itemNames.join(', ')}` : (r.itemName ? `Matériel: ${r.itemName}` : ''),
|
|
1549
|
+
location: r.pickupAddress || '',
|
|
1550
|
+
allDay: true,
|
|
1551
|
+
startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
|
|
1552
|
+
endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
|
|
1553
|
+
})).icsUrl,
|
|
1554
|
+
googleCalUrl: (buildCalendarLinks({
|
|
1555
|
+
type: 'reservation',
|
|
1556
|
+
id: String(r.rid),
|
|
1557
|
+
uid: requesterUid,
|
|
1558
|
+
title: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Location - ${r.itemNames.join(', ')}` : 'Location',
|
|
1559
|
+
details: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Matériel: ${r.itemNames.join(', ')}` : (r.itemName ? `Matériel: ${r.itemName}` : ''),
|
|
1560
|
+
location: r.pickupAddress || '',
|
|
1561
|
+
allDay: true,
|
|
1562
|
+
startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
|
|
1563
|
+
endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
|
|
1564
|
+
})).googleCalUrl,
|
|
1427
1565
|
validatedBy: r.approvedByUsername || '',
|
|
1428
1566
|
validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
|
|
1429
1567
|
});
|
|
@@ -1677,4 +1815,144 @@ api.purgeAudit = async function (req, res) {
|
|
|
1677
1815
|
return res.json(result || { ok: true });
|
|
1678
1816
|
};
|
|
1679
1817
|
|
|
1818
|
+
// --------------------
|
|
1819
|
+
// ICS export (for Google/Apple/etc.)
|
|
1820
|
+
// --------------------
|
|
1821
|
+
|
|
1822
|
+
function icsEscape(str) {
|
|
1823
|
+
return String(str || '')
|
|
1824
|
+
.replace(/\\/g, '\\\\')
|
|
1825
|
+
.replace(/\n/g, '\\n')
|
|
1826
|
+
.replace(/\r/g, '')
|
|
1827
|
+
.replace(/;/g, '\\;')
|
|
1828
|
+
.replace(/,/g, '\\,');
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function dtstampUtc() {
|
|
1832
|
+
return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function toIcsUtc(dt) {
|
|
1836
|
+
return new Date(dt).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function buildIcs({ uid, summary, description, location, allDay, startYmd, endYmd, start, end, url }) {
|
|
1840
|
+
const lines = [];
|
|
1841
|
+
lines.push('BEGIN:VCALENDAR');
|
|
1842
|
+
lines.push('VERSION:2.0');
|
|
1843
|
+
lines.push('PRODID:-//Calendar Onekite//EN');
|
|
1844
|
+
lines.push('CALSCALE:GREGORIAN');
|
|
1845
|
+
lines.push('METHOD:PUBLISH');
|
|
1846
|
+
lines.push('BEGIN:VEVENT');
|
|
1847
|
+
lines.push(`UID:${icsEscape(uid)}`);
|
|
1848
|
+
lines.push(`DTSTAMP:${dtstampUtc()}`);
|
|
1849
|
+
if (allDay) {
|
|
1850
|
+
lines.push(`DTSTART;VALUE=DATE:${ymdToCompact(startYmd)}`);
|
|
1851
|
+
lines.push(`DTEND;VALUE=DATE:${ymdToCompact(endYmd)}`);
|
|
1852
|
+
} else {
|
|
1853
|
+
lines.push(`DTSTART:${toIcsUtc(start)}`);
|
|
1854
|
+
lines.push(`DTEND:${toIcsUtc(end)}`);
|
|
1855
|
+
}
|
|
1856
|
+
lines.push(`SUMMARY:${icsEscape(summary)}`);
|
|
1857
|
+
if (description) lines.push(`DESCRIPTION:${icsEscape(description)}`);
|
|
1858
|
+
if (location) lines.push(`LOCATION:${icsEscape(location)}`);
|
|
1859
|
+
if (url) lines.push(`URL:${icsEscape(url)}`);
|
|
1860
|
+
lines.push('END:VEVENT');
|
|
1861
|
+
lines.push('END:VCALENDAR');
|
|
1862
|
+
return lines.join('\r\n') + '\r\n';
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
api.getIcs = async function (req, res) {
|
|
1866
|
+
try {
|
|
1867
|
+
const type = String((req.params && req.params.type) || '').trim();
|
|
1868
|
+
const id = String((req.params && req.params.id) || '').trim();
|
|
1869
|
+
const uidQ = Number((req.query && req.query.uid) || 0) || 0;
|
|
1870
|
+
const sigQ = String((req.query && req.query.sig) || '').trim();
|
|
1871
|
+
|
|
1872
|
+
if (!type || !id) {
|
|
1873
|
+
return res.status(400).send('missing');
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// Validate signature when present. For reservations we require it.
|
|
1877
|
+
const expected = signCalendarLink(type, id, uidQ);
|
|
1878
|
+
const hasValidSig = !!(sigQ && expected && sigQ === expected);
|
|
1879
|
+
|
|
1880
|
+
let ics = '';
|
|
1881
|
+
let filename = `onekite-${type}-${id}.ics`;
|
|
1882
|
+
|
|
1883
|
+
if (type === 'reservation') {
|
|
1884
|
+
if (!hasValidSig) return res.status(403).send('not-authorized');
|
|
1885
|
+
const r = await dbLayer.getReservation(id);
|
|
1886
|
+
if (!r) return res.status(404).send('not-found');
|
|
1887
|
+
// Extra guard: signature uid must match the actual owner uid.
|
|
1888
|
+
if (Number(r.uid) !== Number(uidQ)) return res.status(403).send('not-authorized');
|
|
1889
|
+
|
|
1890
|
+
const startYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate)))
|
|
1891
|
+
? String(r.startDate)
|
|
1892
|
+
: new Date(parseInt(r.start, 10)).toISOString().slice(0, 10);
|
|
1893
|
+
const endYmd = (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate)))
|
|
1894
|
+
? String(r.endDate)
|
|
1895
|
+
: new Date(parseInt(r.end, 10)).toISOString().slice(0, 10);
|
|
1896
|
+
|
|
1897
|
+
const itemNames = Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []);
|
|
1898
|
+
const summary = itemNames.length ? `Location - ${itemNames.join(', ')}` : 'Location matériel';
|
|
1899
|
+
const description = [
|
|
1900
|
+
itemNames.length ? `Matériel: ${itemNames.join(', ')}` : '',
|
|
1901
|
+
r.pickupTime ? `Heure de récupération: ${r.pickupTime}` : '',
|
|
1902
|
+
r.pickupAddress ? `Adresse: ${r.pickupAddress}` : '',
|
|
1903
|
+
r.notes ? `Notes: ${r.notes}` : '',
|
|
1904
|
+
].filter(Boolean).join('\n');
|
|
1905
|
+
|
|
1906
|
+
ics = buildIcs({
|
|
1907
|
+
uid: `reservation-${id}@onekite`,
|
|
1908
|
+
summary,
|
|
1909
|
+
description,
|
|
1910
|
+
location: String(r.pickupAddress || ''),
|
|
1911
|
+
allDay: true,
|
|
1912
|
+
startYmd,
|
|
1913
|
+
endYmd,
|
|
1914
|
+
url: `${baseUrl()}/calendar`,
|
|
1915
|
+
});
|
|
1916
|
+
} else if (type === 'special') {
|
|
1917
|
+
const ev = await dbLayer.getSpecialEvent(id);
|
|
1918
|
+
if (!ev) return res.status(404).send('not-found');
|
|
1919
|
+
const start = new Date(parseInt(ev.start, 10));
|
|
1920
|
+
const end = new Date(parseInt(ev.end, 10));
|
|
1921
|
+
ics = buildIcs({
|
|
1922
|
+
uid: `special-${id}@onekite`,
|
|
1923
|
+
summary: String(ev.title || 'Évènement'),
|
|
1924
|
+
description: String(ev.notes || ''),
|
|
1925
|
+
location: String(ev.address || ''),
|
|
1926
|
+
allDay: false,
|
|
1927
|
+
start,
|
|
1928
|
+
end,
|
|
1929
|
+
url: `${baseUrl()}/calendar`,
|
|
1930
|
+
});
|
|
1931
|
+
} else if (type === 'outing') {
|
|
1932
|
+
const o = await dbLayer.getOuting(id);
|
|
1933
|
+
if (!o) return res.status(404).send('not-found');
|
|
1934
|
+
const start = new Date(parseInt(o.start, 10));
|
|
1935
|
+
const end = new Date(parseInt(o.end, 10));
|
|
1936
|
+
ics = buildIcs({
|
|
1937
|
+
uid: `outing-${id}@onekite`,
|
|
1938
|
+
summary: String(o.title || 'Prévision de sortie'),
|
|
1939
|
+
description: String(o.notes || ''),
|
|
1940
|
+
location: String(o.address || ''),
|
|
1941
|
+
allDay: false,
|
|
1942
|
+
start,
|
|
1943
|
+
end,
|
|
1944
|
+
url: `${baseUrl()}/calendar`,
|
|
1945
|
+
});
|
|
1946
|
+
} else {
|
|
1947
|
+
return res.status(400).send('unknown-type');
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
res.set('Content-Type', 'text/calendar; charset=utf-8');
|
|
1951
|
+
res.set('Content-Disposition', `attachment; filename="${filename.replace(/[^a-zA-Z0-9_.-]/g, '_')}"`);
|
|
1952
|
+
return res.status(200).send(ics);
|
|
1953
|
+
} catch (err) {
|
|
1954
|
+
return res.status(500).send('error');
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1680
1958
|
module.exports = api;
|
package/lib/discord.js
CHANGED
|
@@ -216,7 +216,6 @@ function buildSimpleCalendarPayload(kind, label, entity, opts) {
|
|
|
216
216
|
{
|
|
217
217
|
title: embedTitle,
|
|
218
218
|
url: calUrl,
|
|
219
|
-
description: kind === 'deleted' ? 'Une entrée a été annulée.' : 'Une nouvelle entrée a été créée.',
|
|
220
219
|
fields,
|
|
221
220
|
footer: { text: 'Onekite • Calendrier' },
|
|
222
221
|
timestamp: new Date().toISOString(),
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -5,6 +5,7 @@ const crypto = require('crypto');
|
|
|
5
5
|
const db = require.main.require('./src/database');
|
|
6
6
|
const meta = require.main.require('./src/meta');
|
|
7
7
|
const user = require.main.require('./src/user');
|
|
8
|
+
const nconf = require.main.require('nconf');
|
|
8
9
|
|
|
9
10
|
const dbLayer = require('./db');
|
|
10
11
|
const helloasso = require('./helloasso');
|
|
@@ -12,6 +13,44 @@ const discord = require('./discord');
|
|
|
12
13
|
const realtime = require('./realtime');
|
|
13
14
|
const { formatFR } = require('./utils');
|
|
14
15
|
|
|
16
|
+
function baseUrl() {
|
|
17
|
+
try { return String(nconf.get('url') || '').replace(/\/$/, ''); } catch (e) { return ''; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hmacSecret() {
|
|
21
|
+
try {
|
|
22
|
+
const s = String(nconf.get('secret') || '').trim();
|
|
23
|
+
if (s) return s;
|
|
24
|
+
} catch (e) {}
|
|
25
|
+
return 'calendar-onekite';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function signCalendarLink(type, id, uid) {
|
|
29
|
+
try {
|
|
30
|
+
return crypto.createHmac('sha256', hmacSecret()).update(`${String(type)}:${String(id)}:${String(uid || 0)}`).digest('hex');
|
|
31
|
+
} catch (e) { return ''; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ymdToCompact(ymd) { return String(ymd || '').replace(/-/g, ''); }
|
|
35
|
+
|
|
36
|
+
function buildReservationCalendarLinks(r) {
|
|
37
|
+
const rid = String(r && r.rid ? r.rid : '');
|
|
38
|
+
const uid = Number(r && r.uid) || 0;
|
|
39
|
+
const sig = signCalendarLink('reservation', rid, uid);
|
|
40
|
+
const icsUrl = `${baseUrl()}/plugins/calendar-onekite/ics/reservation/${encodeURIComponent(rid)}?uid=${encodeURIComponent(String(uid))}&sig=${encodeURIComponent(sig)}`;
|
|
41
|
+
const startYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10);
|
|
42
|
+
const endYmd = (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10);
|
|
43
|
+
const itemNames = Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []);
|
|
44
|
+
const title = itemNames.length ? `Location - ${itemNames.join(', ')}` : 'Location';
|
|
45
|
+
const gcal = new URL('https://calendar.google.com/calendar/render');
|
|
46
|
+
gcal.searchParams.set('action', 'TEMPLATE');
|
|
47
|
+
gcal.searchParams.set('text', title);
|
|
48
|
+
gcal.searchParams.set('dates', `${ymdToCompact(startYmd)}/${ymdToCompact(endYmd)}`);
|
|
49
|
+
if (r.pickupAddress) gcal.searchParams.set('location', String(r.pickupAddress));
|
|
50
|
+
if (r.notes) gcal.searchParams.set('details', String(r.notes));
|
|
51
|
+
return { icsUrl, googleCalUrl: gcal.toString() };
|
|
52
|
+
}
|
|
53
|
+
|
|
15
54
|
async function auditLog(action, actorUid, payload) {
|
|
16
55
|
try {
|
|
17
56
|
const uid = actorUid ? parseInt(actorUid, 10) : 0;
|
|
@@ -329,6 +368,7 @@ async function handler(req, res, next) {
|
|
|
329
368
|
pickupTime: r.pickupTime || '',
|
|
330
369
|
pickupAddress: r.pickupAddress || '',
|
|
331
370
|
mapUrl,
|
|
371
|
+
...(buildReservationCalendarLinks(r)),
|
|
332
372
|
});
|
|
333
373
|
}
|
|
334
374
|
|
package/library.js
CHANGED
|
@@ -88,6 +88,10 @@ Plugin.init = async function (params) {
|
|
|
88
88
|
router.get('/api/v3/plugins/calendar-onekite/outings/:oid', ...publicExpose, api.getOutingDetails);
|
|
89
89
|
router.delete('/api/v3/plugins/calendar-onekite/outings/:oid', ...publicExpose, api.deleteOuting);
|
|
90
90
|
|
|
91
|
+
// Calendar export (ICS)
|
|
92
|
+
// Note: reservations are protected by a signature in the querystring.
|
|
93
|
+
router.get('/plugins/calendar-onekite/ics/:type/:id', api.getIcs);
|
|
94
|
+
|
|
91
95
|
// Admin API (JSON)
|
|
92
96
|
const adminBases = ['/api/v3/admin/plugins/calendar-onekite'];
|
|
93
97
|
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/public/client.js
CHANGED
|
@@ -62,6 +62,35 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/* Calendar item action buttons (match calendar colors) */
|
|
66
|
+
.btn-onekite-special {
|
|
67
|
+
background: #8e44ad;
|
|
68
|
+
border-color: #8e44ad;
|
|
69
|
+
color: #fff;
|
|
70
|
+
}
|
|
71
|
+
.btn-onekite-special:hover {
|
|
72
|
+
filter: brightness(0.95);
|
|
73
|
+
color: #fff;
|
|
74
|
+
}
|
|
75
|
+
.btn-onekite-outing {
|
|
76
|
+
background: #2980b9;
|
|
77
|
+
border-color: #2980b9;
|
|
78
|
+
color: #fff;
|
|
79
|
+
}
|
|
80
|
+
.btn-onekite-outing:hover {
|
|
81
|
+
filter: brightness(0.95);
|
|
82
|
+
color: #fff;
|
|
83
|
+
}
|
|
84
|
+
.btn-onekite-location {
|
|
85
|
+
background: #27ae60;
|
|
86
|
+
border-color: #27ae60;
|
|
87
|
+
color: #fff;
|
|
88
|
+
}
|
|
89
|
+
.btn-onekite-location:hover {
|
|
90
|
+
filter: brightness(0.95);
|
|
91
|
+
color: #fff;
|
|
92
|
+
}
|
|
93
|
+
|
|
65
94
|
/* Mobile FAB date range picker (single calendar) */
|
|
66
95
|
.onekite-range-picker { user-select: none; }
|
|
67
96
|
.onekite-range-header { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
|
|
@@ -110,6 +139,11 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
110
139
|
// Current FullCalendar instance (for refresh after actions)
|
|
111
140
|
let currentCalendar = null;
|
|
112
141
|
|
|
142
|
+
// The creation chooser handler is created inside init() (it needs the
|
|
143
|
+
// calendar instance and capability flags). The mobile FAB is mounted outside
|
|
144
|
+
// init(), so we store the handler here.
|
|
145
|
+
let createFromSelectionHandler = null;
|
|
146
|
+
|
|
113
147
|
// Mobile FAB (mounted only on the calendar page)
|
|
114
148
|
let fabEl = null;
|
|
115
149
|
let fabHandler = null;
|
|
@@ -208,7 +242,8 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
208
242
|
return opts.join('');
|
|
209
243
|
}
|
|
210
244
|
|
|
211
|
-
async function openSpecialEventDialog(selectionInfo) {
|
|
245
|
+
async function openSpecialEventDialog(selectionInfo, opts) {
|
|
246
|
+
const kind = (opts && opts.kind) ? String(opts.kind) : 'special';
|
|
212
247
|
const start = selectionInfo.start;
|
|
213
248
|
// FullCalendar can omit `end` for certain interactions. Also, for all-day
|
|
214
249
|
// selections, `end` is exclusive (next day at 00:00). We normalise below
|
|
@@ -259,10 +294,13 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
259
294
|
|
|
260
295
|
const seStartTime = timeString(seStart);
|
|
261
296
|
const seEndTime = timeString(seEnd);
|
|
297
|
+
const defaultTitlePlaceholder = kind === 'outing' ? 'Prévision de sortie' : 'Ex: ...';
|
|
298
|
+
const defaultTitleValue = kind === 'outing' ? 'Prévision de sortie' : '';
|
|
299
|
+
|
|
262
300
|
const html = `
|
|
263
301
|
<div class="mb-3">
|
|
264
302
|
<label class="form-label">Titre</label>
|
|
265
|
-
<input type="text" class="form-control" id="onekite-se-title"
|
|
303
|
+
<input type="text" class="form-control" id="onekite-se-title" value="${escapeHtml(defaultTitleValue)}" placeholder="${escapeHtml(defaultTitlePlaceholder)}" />
|
|
266
304
|
</div>
|
|
267
305
|
<div class="row g-2">
|
|
268
306
|
<div class="col-12 col-md-6">
|
|
@@ -307,7 +345,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
307
345
|
return await new Promise((resolve) => {
|
|
308
346
|
let resolved = false;
|
|
309
347
|
const dialog = bootbox.dialog({
|
|
310
|
-
title: 'Créer un évènement',
|
|
348
|
+
title: kind === 'outing' ? 'Créer une prévision de sortie' : 'Créer un évènement',
|
|
311
349
|
message: html,
|
|
312
350
|
buttons: {
|
|
313
351
|
cancel: {
|
|
@@ -430,8 +468,9 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
430
468
|
|
|
431
469
|
|
|
432
470
|
async function openOutingDialog(selectionInfo) {
|
|
433
|
-
// Same UI as special events, but saved as "Sorties" (prévisions)
|
|
434
|
-
|
|
471
|
+
// Same UI as special events, but saved as "Sorties" (prévisions) and with
|
|
472
|
+
// a different title.
|
|
473
|
+
const payload = await openSpecialEventDialog(selectionInfo, { kind: 'outing' });
|
|
435
474
|
if (!payload) return null;
|
|
436
475
|
// Default title if empty
|
|
437
476
|
if (!payload.title) payload.title = 'Prévision de sortie';
|
|
@@ -1009,6 +1048,10 @@ function toDatetimeLocalValue(date) {
|
|
|
1009
1048
|
const endDisplay = endInclusiveForDisplay(start, end);
|
|
1010
1049
|
|
|
1011
1050
|
const messageHtml = `
|
|
1051
|
+
<div class="mb-2">
|
|
1052
|
+
<label class="form-label">Titre (facultatif)</label>
|
|
1053
|
+
<input type="text" class="form-control" id="onekite-res-title" placeholder="Ex: ..." />
|
|
1054
|
+
</div>
|
|
1012
1055
|
<div class="mb-2" id="onekite-period"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(endDisplay)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span></div>
|
|
1013
1056
|
${shortcutsHtml}
|
|
1014
1057
|
<div class="mb-2"><strong>Matériel</strong></div>
|
|
@@ -1044,6 +1087,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1044
1087
|
label: 'Envoyer',
|
|
1045
1088
|
className: 'btn-primary',
|
|
1046
1089
|
callback: function () {
|
|
1090
|
+
const title = (document.getElementById('onekite-res-title')?.value || '').trim();
|
|
1047
1091
|
const cbs = Array.from(document.querySelectorAll('.onekite-item-cb')).filter(cb => cb.checked);
|
|
1048
1092
|
if (!cbs.length) {
|
|
1049
1093
|
showAlert('error', 'Choisis au moins un matériel.');
|
|
@@ -1055,7 +1099,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1055
1099
|
const total = (sum / 100) * days;
|
|
1056
1100
|
// Return the effective end date (exclusive) because duration shortcuts can
|
|
1057
1101
|
// change the range without updating the original FullCalendar selection.
|
|
1058
|
-
resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
|
|
1102
|
+
resolve({ title, itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
|
|
1059
1103
|
},
|
|
1060
1104
|
},
|
|
1061
1105
|
},
|
|
@@ -1287,12 +1331,13 @@ function toDatetimeLocalValue(date) {
|
|
|
1287
1331
|
}
|
|
1288
1332
|
}
|
|
1289
1333
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1334
|
+
// Buttons order matters for UX: put "Annuler" at bottom-right (last).
|
|
1335
|
+
const buttons = {};
|
|
1336
|
+
|
|
1337
|
+
buttons.location = {
|
|
1338
|
+
label: 'Location',
|
|
1339
|
+
className: 'btn-onekite-location',
|
|
1340
|
+
callback: async () => {
|
|
1296
1341
|
try {
|
|
1297
1342
|
isDialogOpen = true;
|
|
1298
1343
|
if (!items || !items.length) {
|
|
@@ -1304,6 +1349,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1304
1349
|
const startDate = toLocalYmd(info.start);
|
|
1305
1350
|
const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
|
|
1306
1351
|
const resp = await requestReservation({
|
|
1352
|
+
title: (chosen && chosen.title) ? String(chosen.title) : '',
|
|
1307
1353
|
start: startDate,
|
|
1308
1354
|
end: endDate,
|
|
1309
1355
|
itemIds: chosen.itemIds,
|
|
@@ -1324,12 +1370,13 @@ function toDatetimeLocalValue(date) {
|
|
|
1324
1370
|
isDialogOpen = false;
|
|
1325
1371
|
}
|
|
1326
1372
|
return true;
|
|
1327
|
-
},
|
|
1328
1373
|
},
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
buttons.outing = {
|
|
1377
|
+
label: 'Prévision de sortie',
|
|
1378
|
+
className: 'btn-onekite-outing',
|
|
1379
|
+
callback: async () => {
|
|
1333
1380
|
try {
|
|
1334
1381
|
isDialogOpen = true;
|
|
1335
1382
|
const payload = await openOutingDialog(info);
|
|
@@ -1348,14 +1395,13 @@ function toDatetimeLocalValue(date) {
|
|
|
1348
1395
|
isDialogOpen = false;
|
|
1349
1396
|
}
|
|
1350
1397
|
return true;
|
|
1351
|
-
},
|
|
1352
1398
|
},
|
|
1353
1399
|
};
|
|
1354
1400
|
|
|
1355
1401
|
if (canCreateSpecial) {
|
|
1356
1402
|
buttons.special = {
|
|
1357
1403
|
label: 'Évènement',
|
|
1358
|
-
className: 'btn-
|
|
1404
|
+
className: 'btn-onekite-special',
|
|
1359
1405
|
callback: async () => {
|
|
1360
1406
|
try {
|
|
1361
1407
|
isDialogOpen = true;
|
|
@@ -1384,6 +1430,8 @@ function toDatetimeLocalValue(date) {
|
|
|
1384
1430
|
};
|
|
1385
1431
|
}
|
|
1386
1432
|
|
|
1433
|
+
buttons.cancel = { label: 'Annuler', className: 'btn-danger' };
|
|
1434
|
+
|
|
1387
1435
|
bootbox.dialog({
|
|
1388
1436
|
title: 'Créer',
|
|
1389
1437
|
message: '<div class="text-muted">Que veux-tu créer sur ces dates ?</div>',
|
|
@@ -1391,6 +1439,9 @@ function toDatetimeLocalValue(date) {
|
|
|
1391
1439
|
});
|
|
1392
1440
|
}
|
|
1393
1441
|
|
|
1442
|
+
// Expose to the mobile FAB (mounted outside init).
|
|
1443
|
+
createFromSelectionHandler = handleCreateFromSelection;
|
|
1444
|
+
|
|
1394
1445
|
calendar = new FullCalendar.Calendar(el, {
|
|
1395
1446
|
initialView: 'dayGridMonth',
|
|
1396
1447
|
height: 'auto',
|
|
@@ -1528,13 +1579,19 @@ function toDatetimeLocalValue(date) {
|
|
|
1528
1579
|
pickupLat: details.lat || details.pickupLat || p0.pickupLat,
|
|
1529
1580
|
pickupLon: details.lon || details.pickupLon || p0.pickupLon,
|
|
1530
1581
|
});
|
|
1531
|
-
} else if (p0.type === 'outing'
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1582
|
+
} else if (p0.type === 'outing') {
|
|
1583
|
+
// After a create+refetch cycle, FullCalendar can briefly provide an
|
|
1584
|
+
// event object without the full extendedProps we expect. Always
|
|
1585
|
+
// attempt to resolve the oid from either extendedProps or the event id.
|
|
1586
|
+
const oidGuess = String(p0.oid || ev.id || '').replace(/^outing:/, '').trim();
|
|
1587
|
+
if (oidGuess) {
|
|
1588
|
+
const details = await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(oidGuess)}`);
|
|
1589
|
+
p = Object.assign({}, p0, details, {
|
|
1590
|
+
pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
|
|
1591
|
+
pickupLat: details.lat || details.pickupLat || p0.pickupLat,
|
|
1592
|
+
pickupLon: details.lon || details.pickupLon || p0.pickupLon,
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1538
1595
|
}
|
|
1539
1596
|
} catch (e) {
|
|
1540
1597
|
// ignore detail fetch errors; fall back to minimal props
|
|
@@ -1552,6 +1609,14 @@ function toDatetimeLocalValue(date) {
|
|
|
1552
1609
|
const lon = Number(p.pickupLon || p.lon);
|
|
1553
1610
|
const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
|
|
1554
1611
|
const notes = String(p.notes || '').trim();
|
|
1612
|
+
const icsUrl = String(p.icsUrl || (p.extendedProps && p.extendedProps.icsUrl) || '').trim();
|
|
1613
|
+
const googleCalUrl = String(p.googleCalUrl || (p.extendedProps && p.extendedProps.googleCalUrl) || '').trim();
|
|
1614
|
+
const calHtml = (icsUrl || googleCalUrl)
|
|
1615
|
+
? `<div class="mb-2"><strong>Calendrier</strong><br>
|
|
1616
|
+
${icsUrl ? `<a href="${escapeHtml(icsUrl)}" target="_blank" rel="noopener" class="me-3"><i class="fa fa-calendar-plus"></i> Ajouter (ICS)</a>` : ''}
|
|
1617
|
+
${googleCalUrl ? `<a href="${escapeHtml(googleCalUrl)}" target="_blank" rel="noopener"><i class="fa fa-calendar"></i> Google Calendar</a>` : ''}
|
|
1618
|
+
</div>`
|
|
1619
|
+
: '';
|
|
1555
1620
|
const addrHtml = addr
|
|
1556
1621
|
? (hasCoords
|
|
1557
1622
|
? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
|
|
@@ -1561,18 +1626,19 @@ function toDatetimeLocalValue(date) {
|
|
|
1561
1626
|
<div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
|
|
1562
1627
|
${userLine}
|
|
1563
1628
|
<div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
|
|
1629
|
+
${calHtml}
|
|
1564
1630
|
${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
|
|
1565
1631
|
${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
|
|
1566
1632
|
`;
|
|
1567
1633
|
const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
|
|
1568
|
-
bootbox.dialog({
|
|
1634
|
+
const dlg = bootbox.dialog({
|
|
1569
1635
|
title: 'Évènement',
|
|
1570
1636
|
message: html,
|
|
1571
1637
|
buttons: {
|
|
1572
1638
|
close: { label: 'Fermer', className: 'btn-secondary' },
|
|
1573
1639
|
...(canDel ? {
|
|
1574
1640
|
del: {
|
|
1575
|
-
label: '
|
|
1641
|
+
label: 'Annuler',
|
|
1576
1642
|
className: 'btn-danger',
|
|
1577
1643
|
callback: async () => {
|
|
1578
1644
|
try {
|
|
@@ -1588,6 +1654,12 @@ function toDatetimeLocalValue(date) {
|
|
|
1588
1654
|
} : {}),
|
|
1589
1655
|
},
|
|
1590
1656
|
});
|
|
1657
|
+
try {
|
|
1658
|
+
dlg.on('hidden.bs.modal', function () { isDialogOpen = false; });
|
|
1659
|
+
} catch (e) {
|
|
1660
|
+
// Fallback: never leave the dialog lock stuck.
|
|
1661
|
+
isDialogOpen = false;
|
|
1662
|
+
}
|
|
1591
1663
|
return;
|
|
1592
1664
|
}
|
|
1593
1665
|
|
|
@@ -1601,6 +1673,14 @@ function toDatetimeLocalValue(date) {
|
|
|
1601
1673
|
const lon = Number(p.lon || p.pickupLon);
|
|
1602
1674
|
const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
|
|
1603
1675
|
const notes = String(p.notes || '').trim();
|
|
1676
|
+
const icsUrl = String(p.icsUrl || (p.extendedProps && p.extendedProps.icsUrl) || '').trim();
|
|
1677
|
+
const googleCalUrl = String(p.googleCalUrl || (p.extendedProps && p.extendedProps.googleCalUrl) || '').trim();
|
|
1678
|
+
const calHtml = (icsUrl || googleCalUrl)
|
|
1679
|
+
? `<div class="mb-2"><strong>Calendrier</strong><br>
|
|
1680
|
+
${icsUrl ? `<a href="${escapeHtml(icsUrl)}" target="_blank" rel="noopener" class="me-3"><i class="fa fa-calendar-plus"></i> Ajouter (ICS)</a>` : ''}
|
|
1681
|
+
${googleCalUrl ? `<a href="${escapeHtml(googleCalUrl)}" target="_blank" rel="noopener"><i class="fa fa-calendar"></i> Google Calendar</a>` : ''}
|
|
1682
|
+
</div>`
|
|
1683
|
+
: '';
|
|
1604
1684
|
const addrHtml = addr
|
|
1605
1685
|
? (hasCoords
|
|
1606
1686
|
? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
|
|
@@ -1610,11 +1690,12 @@ function toDatetimeLocalValue(date) {
|
|
|
1610
1690
|
<div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
|
|
1611
1691
|
${userLine}
|
|
1612
1692
|
<div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
|
|
1693
|
+
${calHtml}
|
|
1613
1694
|
${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
|
|
1614
1695
|
${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
|
|
1615
1696
|
`;
|
|
1616
1697
|
const canDel = !!(p.canDeleteOuting);
|
|
1617
|
-
bootbox.dialog({
|
|
1698
|
+
const dlg = bootbox.dialog({
|
|
1618
1699
|
title: 'Prévision de sortie',
|
|
1619
1700
|
message: html,
|
|
1620
1701
|
buttons: {
|
|
@@ -1637,6 +1718,11 @@ function toDatetimeLocalValue(date) {
|
|
|
1637
1718
|
} : {}),
|
|
1638
1719
|
},
|
|
1639
1720
|
});
|
|
1721
|
+
try {
|
|
1722
|
+
dlg.on('hidden.bs.modal', function () { isDialogOpen = false; });
|
|
1723
|
+
} catch (e) {
|
|
1724
|
+
isDialogOpen = false;
|
|
1725
|
+
}
|
|
1640
1726
|
return;
|
|
1641
1727
|
}
|
|
1642
1728
|
} catch (e) {
|
|
@@ -1660,6 +1746,9 @@ function toDatetimeLocalValue(date) {
|
|
|
1660
1746
|
})();
|
|
1661
1747
|
const period = `${formatDt(ev.start)} → ${formatDt(ev.end)}`;
|
|
1662
1748
|
|
|
1749
|
+
const icsUrl = String(p.icsUrl || (ev.extendedProps && ev.extendedProps.icsUrl) || '').trim();
|
|
1750
|
+
const googleCalUrl = String(p.googleCalUrl || (ev.extendedProps && ev.extendedProps.googleCalUrl) || '').trim();
|
|
1751
|
+
|
|
1663
1752
|
const approvedBy = String(p.approvedByUsername || '').trim();
|
|
1664
1753
|
const validatedByHtml = approvedBy
|
|
1665
1754
|
? `<div class=\"mb-2\"><strong>Validée par</strong><br><a href=\"https://www.onekite.com/user/${encodeURIComponent(approvedBy)}\">${escapeHtml(approvedBy)}</a></div>`
|
|
@@ -1699,6 +1788,10 @@ function toDatetimeLocalValue(date) {
|
|
|
1699
1788
|
${userLine}
|
|
1700
1789
|
<div class="mb-2"><strong>Matériel</strong><br>${itemsHtml}</div>
|
|
1701
1790
|
<div class="mb-2"><strong>Période</strong><br>${period}</div>
|
|
1791
|
+
${ (icsUrl || googleCalUrl) ? `<div class="mb-2" id="onekite-cal-links" style="display:none;"><strong>Calendrier</strong><br>
|
|
1792
|
+
${icsUrl ? `<a href="${escapeHtml(icsUrl)}" target="_blank" rel="noopener" class="me-3"><i class="fa fa-calendar-plus"></i> Ajouter (ICS)</a>` : ''}
|
|
1793
|
+
${googleCalUrl ? `<a href="${escapeHtml(googleCalUrl)}" target="_blank" rel="noopener"><i class="fa fa-calendar"></i> Google Calendar</a>` : ''}
|
|
1794
|
+
</div>` : '' }
|
|
1702
1795
|
${validatedByHtml}
|
|
1703
1796
|
${pickupHtml}
|
|
1704
1797
|
${refusedReasonHtml}
|
|
@@ -1721,6 +1814,22 @@ function toDatetimeLocalValue(date) {
|
|
|
1721
1814
|
const buttons = {
|
|
1722
1815
|
close: { label: 'Fermer', className: 'btn-secondary' },
|
|
1723
1816
|
};
|
|
1817
|
+
|
|
1818
|
+
const cancelBtn = showCancel ? {
|
|
1819
|
+
label: 'Annuler',
|
|
1820
|
+
className: 'btn-danger',
|
|
1821
|
+
callback: async () => {
|
|
1822
|
+
try {
|
|
1823
|
+
if (!lockAction(`cancel:${rid}`, 1200)) return false;
|
|
1824
|
+
await cancelReservation(rid);
|
|
1825
|
+
showAlert('success', 'Réservation annulée.');
|
|
1826
|
+
invalidateEventsCache();
|
|
1827
|
+
scheduleRefetch(calendar);
|
|
1828
|
+
} catch (e) {
|
|
1829
|
+
showAlert('error', 'Annulation impossible.');
|
|
1830
|
+
}
|
|
1831
|
+
},
|
|
1832
|
+
} : null;
|
|
1724
1833
|
if (showPay) {
|
|
1725
1834
|
buttons.pay = {
|
|
1726
1835
|
label: 'Payer maintenant',
|
|
@@ -1733,23 +1842,6 @@ function toDatetimeLocalValue(date) {
|
|
|
1733
1842
|
},
|
|
1734
1843
|
};
|
|
1735
1844
|
}
|
|
1736
|
-
if (showCancel) {
|
|
1737
|
-
buttons.cancel = {
|
|
1738
|
-
label: 'Annuler',
|
|
1739
|
-
className: 'btn-outline-warning',
|
|
1740
|
-
callback: async () => {
|
|
1741
|
-
try {
|
|
1742
|
-
if (!lockAction(`cancel:${rid}`, 1200)) return false;
|
|
1743
|
-
await cancelReservation(rid);
|
|
1744
|
-
showAlert('success', 'Réservation annulée.');
|
|
1745
|
-
invalidateEventsCache();
|
|
1746
|
-
scheduleRefetch(calendar);
|
|
1747
|
-
} catch (e) {
|
|
1748
|
-
showAlert('error', 'Annulation impossible.');
|
|
1749
|
-
}
|
|
1750
|
-
},
|
|
1751
|
-
};
|
|
1752
|
-
}
|
|
1753
1845
|
if (showModeration) {
|
|
1754
1846
|
buttons.refuse = {
|
|
1755
1847
|
label: 'Refuser',
|
|
@@ -1949,11 +2041,32 @@ function toDatetimeLocalValue(date) {
|
|
|
1949
2041
|
};
|
|
1950
2042
|
}
|
|
1951
2043
|
|
|
1952
|
-
|
|
2044
|
+
// Put the cancellation action at the bottom-right (last button).
|
|
2045
|
+
if (cancelBtn) {
|
|
2046
|
+
buttons.cancel = cancelBtn;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
const dlg = bootbox.dialog({
|
|
1953
2050
|
title: 'Réservation',
|
|
1954
2051
|
message: baseHtml,
|
|
1955
2052
|
buttons,
|
|
1956
2053
|
});
|
|
2054
|
+
|
|
2055
|
+
// Only the creator should see the "Ajouter à mon calendrier" links for reservations.
|
|
2056
|
+
try {
|
|
2057
|
+
dlg.on('shown.bs.modal', () => {
|
|
2058
|
+
try {
|
|
2059
|
+
const cal = document.getElementById('onekite-cal-links');
|
|
2060
|
+
if (cal) {
|
|
2061
|
+
cal.style.display = isOwner ? 'block' : 'none';
|
|
2062
|
+
}
|
|
2063
|
+
} catch (e) {}
|
|
2064
|
+
|
|
2065
|
+
});
|
|
2066
|
+
dlg.on('hidden.bs.modal', () => { isDialogOpen = false; });
|
|
2067
|
+
} catch (e) {
|
|
2068
|
+
isDialogOpen = false;
|
|
2069
|
+
}
|
|
1957
2070
|
},
|
|
1958
2071
|
});
|
|
1959
2072
|
|
|
@@ -2368,7 +2481,12 @@ function parseYmdDate(ymdStr) {
|
|
|
2368
2481
|
fabEl.setAttribute('aria-label', 'Nouvelle réservation');
|
|
2369
2482
|
fabEl.innerHTML = '<i class="fa fa-plus"></i>';
|
|
2370
2483
|
|
|
2371
|
-
fabHandler = () =>
|
|
2484
|
+
fabHandler = () => {
|
|
2485
|
+
// init() sets createFromSelectionHandler. If the calendar has not
|
|
2486
|
+
// finished initialising, do nothing.
|
|
2487
|
+
if (typeof createFromSelectionHandler !== 'function') return;
|
|
2488
|
+
openFabDatePicker(createFromSelectionHandler);
|
|
2489
|
+
};
|
|
2372
2490
|
fabEl.addEventListener('click', fabHandler);
|
|
2373
2491
|
|
|
2374
2492
|
document.body.appendChild(fabEl);
|
|
@@ -83,18 +83,6 @@
|
|
|
83
83
|
<div class="form-text">Si vide, aucune notification Discord n'est envoyée.</div>
|
|
84
84
|
</div>
|
|
85
85
|
|
|
86
|
-
<div class="mb-3">
|
|
87
|
-
<label class="form-label">Webhook URL — Évènements</label>
|
|
88
|
-
<input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
|
|
89
|
-
<div class="form-text">Canal Discord dédié aux notifications d'évènements (création / annulation).</div>
|
|
90
|
-
</div>
|
|
91
|
-
|
|
92
|
-
<div class="mb-3">
|
|
93
|
-
<label class="form-label">Webhook URL — Sorties</label>
|
|
94
|
-
<input class="form-control" name="discordWebhookUrlOutings" placeholder="https://discord.com/api/webhooks/...">
|
|
95
|
-
<div class="form-text">Canal Discord dédié aux notifications de sorties (création / annulation).</div>
|
|
96
|
-
</div>
|
|
97
|
-
|
|
98
86
|
<div class="mb-3">
|
|
99
87
|
<label class="form-label">Envoyer une notification à la demande</label>
|
|
100
88
|
<select class="form-select" name="discordNotifyOnRequest">
|
|
@@ -173,6 +161,13 @@
|
|
|
173
161
|
<h4>Évènements (autre couleur)</h4>
|
|
174
162
|
<div class="form-text mb-3">Permet de créer des évènements horaires (début/fin) avec adresse (Leaflet) et notes.</div>
|
|
175
163
|
|
|
164
|
+
<h4 class="mt-3">Discord</h4>
|
|
165
|
+
<div class="mb-3">
|
|
166
|
+
<label class="form-label">Webhook URL — Évènements</label>
|
|
167
|
+
<input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
|
|
168
|
+
<div class="form-text">Canal Discord dédié aux notifications d'évènements (création / annulation).</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
176
171
|
<div class="mb-3">
|
|
177
172
|
<label class="form-label">Groupes autorisés à créer des évènements (csv)</label>
|
|
178
173
|
<input class="form-control" name="specialCreatorGroups" placeholder="ex: staff,instructors">
|
|
@@ -183,6 +178,14 @@
|
|
|
183
178
|
<input class="form-control" name="specialDeleterGroups" placeholder="Si vide: même liste que la création">
|
|
184
179
|
</div>
|
|
185
180
|
|
|
181
|
+
<hr class="my-4" />
|
|
182
|
+
<h4>Discord</h4>
|
|
183
|
+
<div class="mb-3">
|
|
184
|
+
<label class="form-label">Webhook URL — Évènements</label>
|
|
185
|
+
<input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
|
|
186
|
+
<div class="form-text">Canal Discord dédié aux notifications d'évènements (création / annulation).</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
186
189
|
<hr class="my-4" />
|
|
187
190
|
<h4>Purge des évènements</h4>
|
|
188
191
|
<div class="d-flex gap-2 align-items-center">
|
|
@@ -196,6 +199,13 @@
|
|
|
196
199
|
<h4>Sorties (prévisions)</h4>
|
|
197
200
|
<div class="form-text mb-3">Prévisions de sortie (autre couleur) créées par les utilisateurs autorisés à faire une demande de location. Les utilisateurs peuvent annuler leurs propres prévisions. Les droits sont ceux des locations.</div>
|
|
198
201
|
|
|
202
|
+
<h4 class="mt-3">Discord</h4>
|
|
203
|
+
<div class="mb-3">
|
|
204
|
+
<label class="form-label">Webhook URL — Sorties</label>
|
|
205
|
+
<input class="form-control" name="discordWebhookUrlOutings" placeholder="https://discord.com/api/webhooks/...">
|
|
206
|
+
<div class="form-text">Canal Discord dédié aux notifications de sorties (création / annulation).</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
199
209
|
<hr class="my-4" />
|
|
200
210
|
<h4>Purge des sorties</h4>
|
|
201
211
|
<div class="d-flex gap-2 align-items-center">
|
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
|
|
15
15
|
<p>{dateRange}</p>
|
|
16
16
|
|
|
17
|
+
<!-- IF icsUrl -->
|
|
18
|
+
<p>
|
|
19
|
+
📅 <strong>Ajouter à mon calendrier :</strong>
|
|
20
|
+
<a href="{icsUrl}">ICS</a>
|
|
21
|
+
<!-- IF googleCalUrl -->
|
|
22
|
+
| <a href="{googleCalUrl}">Google Calendar</a>
|
|
23
|
+
<!-- ENDIF googleCalUrl -->
|
|
24
|
+
</p>
|
|
25
|
+
<!-- ENDIF icsUrl -->
|
|
26
|
+
|
|
17
27
|
<!-- IF pickupTime -->
|
|
18
28
|
<p><strong>Heure de récupération :</strong> {pickupTime}</p>
|
|
19
29
|
<!-- ENDIF pickupTime -->
|
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
<p>{dateRange}</p>
|
|
12
12
|
|
|
13
|
+
<!-- IF icsUrl -->
|
|
14
|
+
<p>
|
|
15
|
+
📅 <strong>Ajouter à mon calendrier :</strong>
|
|
16
|
+
<a href="{icsUrl}">ICS</a>
|
|
17
|
+
<!-- IF googleCalUrl -->
|
|
18
|
+
| <a href="{googleCalUrl}">Google Calendar</a>
|
|
19
|
+
<!-- ENDIF googleCalUrl -->
|
|
20
|
+
</p>
|
|
21
|
+
<!-- ENDIF icsUrl -->
|
|
22
|
+
|
|
13
23
|
<!-- IF pickupTime -->
|
|
14
24
|
<p><strong>Heure de récupération :</strong> {pickupTime}</p>
|
|
15
25
|
<!-- ENDIF pickupTime -->
|