nodebb-plugin-onekite-calendar 2.0.44 → 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 +142 -42
- 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; }
|
|
@@ -1019,6 +1048,10 @@ function toDatetimeLocalValue(date) {
|
|
|
1019
1048
|
const endDisplay = endInclusiveForDisplay(start, end);
|
|
1020
1049
|
|
|
1021
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>
|
|
1022
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>
|
|
1023
1056
|
${shortcutsHtml}
|
|
1024
1057
|
<div class="mb-2"><strong>Matériel</strong></div>
|
|
@@ -1054,6 +1087,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1054
1087
|
label: 'Envoyer',
|
|
1055
1088
|
className: 'btn-primary',
|
|
1056
1089
|
callback: function () {
|
|
1090
|
+
const title = (document.getElementById('onekite-res-title')?.value || '').trim();
|
|
1057
1091
|
const cbs = Array.from(document.querySelectorAll('.onekite-item-cb')).filter(cb => cb.checked);
|
|
1058
1092
|
if (!cbs.length) {
|
|
1059
1093
|
showAlert('error', 'Choisis au moins un matériel.');
|
|
@@ -1065,7 +1099,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1065
1099
|
const total = (sum / 100) * days;
|
|
1066
1100
|
// Return the effective end date (exclusive) because duration shortcuts can
|
|
1067
1101
|
// change the range without updating the original FullCalendar selection.
|
|
1068
|
-
resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
|
|
1102
|
+
resolve({ title, itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
|
|
1069
1103
|
},
|
|
1070
1104
|
},
|
|
1071
1105
|
},
|
|
@@ -1297,12 +1331,13 @@ function toDatetimeLocalValue(date) {
|
|
|
1297
1331
|
}
|
|
1298
1332
|
}
|
|
1299
1333
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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 () => {
|
|
1306
1341
|
try {
|
|
1307
1342
|
isDialogOpen = true;
|
|
1308
1343
|
if (!items || !items.length) {
|
|
@@ -1314,6 +1349,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1314
1349
|
const startDate = toLocalYmd(info.start);
|
|
1315
1350
|
const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
|
|
1316
1351
|
const resp = await requestReservation({
|
|
1352
|
+
title: (chosen && chosen.title) ? String(chosen.title) : '',
|
|
1317
1353
|
start: startDate,
|
|
1318
1354
|
end: endDate,
|
|
1319
1355
|
itemIds: chosen.itemIds,
|
|
@@ -1334,12 +1370,13 @@ function toDatetimeLocalValue(date) {
|
|
|
1334
1370
|
isDialogOpen = false;
|
|
1335
1371
|
}
|
|
1336
1372
|
return true;
|
|
1337
|
-
},
|
|
1338
1373
|
},
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
buttons.outing = {
|
|
1377
|
+
label: 'Prévision de sortie',
|
|
1378
|
+
className: 'btn-onekite-outing',
|
|
1379
|
+
callback: async () => {
|
|
1343
1380
|
try {
|
|
1344
1381
|
isDialogOpen = true;
|
|
1345
1382
|
const payload = await openOutingDialog(info);
|
|
@@ -1358,14 +1395,13 @@ function toDatetimeLocalValue(date) {
|
|
|
1358
1395
|
isDialogOpen = false;
|
|
1359
1396
|
}
|
|
1360
1397
|
return true;
|
|
1361
|
-
},
|
|
1362
1398
|
},
|
|
1363
1399
|
};
|
|
1364
1400
|
|
|
1365
1401
|
if (canCreateSpecial) {
|
|
1366
1402
|
buttons.special = {
|
|
1367
1403
|
label: 'Évènement',
|
|
1368
|
-
className: 'btn-
|
|
1404
|
+
className: 'btn-onekite-special',
|
|
1369
1405
|
callback: async () => {
|
|
1370
1406
|
try {
|
|
1371
1407
|
isDialogOpen = true;
|
|
@@ -1394,6 +1430,8 @@ function toDatetimeLocalValue(date) {
|
|
|
1394
1430
|
};
|
|
1395
1431
|
}
|
|
1396
1432
|
|
|
1433
|
+
buttons.cancel = { label: 'Annuler', className: 'btn-danger' };
|
|
1434
|
+
|
|
1397
1435
|
bootbox.dialog({
|
|
1398
1436
|
title: 'Créer',
|
|
1399
1437
|
message: '<div class="text-muted">Que veux-tu créer sur ces dates ?</div>',
|
|
@@ -1541,13 +1579,19 @@ function toDatetimeLocalValue(date) {
|
|
|
1541
1579
|
pickupLat: details.lat || details.pickupLat || p0.pickupLat,
|
|
1542
1580
|
pickupLon: details.lon || details.pickupLon || p0.pickupLon,
|
|
1543
1581
|
});
|
|
1544
|
-
} else if (p0.type === 'outing'
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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
|
+
}
|
|
1551
1595
|
}
|
|
1552
1596
|
} catch (e) {
|
|
1553
1597
|
// ignore detail fetch errors; fall back to minimal props
|
|
@@ -1565,6 +1609,14 @@ function toDatetimeLocalValue(date) {
|
|
|
1565
1609
|
const lon = Number(p.pickupLon || p.lon);
|
|
1566
1610
|
const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
|
|
1567
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
|
+
: '';
|
|
1568
1620
|
const addrHtml = addr
|
|
1569
1621
|
? (hasCoords
|
|
1570
1622
|
? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
|
|
@@ -1574,18 +1626,19 @@ function toDatetimeLocalValue(date) {
|
|
|
1574
1626
|
<div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
|
|
1575
1627
|
${userLine}
|
|
1576
1628
|
<div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
|
|
1629
|
+
${calHtml}
|
|
1577
1630
|
${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
|
|
1578
1631
|
${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
|
|
1579
1632
|
`;
|
|
1580
1633
|
const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
|
|
1581
|
-
bootbox.dialog({
|
|
1634
|
+
const dlg = bootbox.dialog({
|
|
1582
1635
|
title: 'Évènement',
|
|
1583
1636
|
message: html,
|
|
1584
1637
|
buttons: {
|
|
1585
1638
|
close: { label: 'Fermer', className: 'btn-secondary' },
|
|
1586
1639
|
...(canDel ? {
|
|
1587
1640
|
del: {
|
|
1588
|
-
label: '
|
|
1641
|
+
label: 'Annuler',
|
|
1589
1642
|
className: 'btn-danger',
|
|
1590
1643
|
callback: async () => {
|
|
1591
1644
|
try {
|
|
@@ -1601,6 +1654,12 @@ function toDatetimeLocalValue(date) {
|
|
|
1601
1654
|
} : {}),
|
|
1602
1655
|
},
|
|
1603
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
|
+
}
|
|
1604
1663
|
return;
|
|
1605
1664
|
}
|
|
1606
1665
|
|
|
@@ -1614,6 +1673,14 @@ function toDatetimeLocalValue(date) {
|
|
|
1614
1673
|
const lon = Number(p.lon || p.pickupLon);
|
|
1615
1674
|
const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
|
|
1616
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
|
+
: '';
|
|
1617
1684
|
const addrHtml = addr
|
|
1618
1685
|
? (hasCoords
|
|
1619
1686
|
? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
|
|
@@ -1623,11 +1690,12 @@ function toDatetimeLocalValue(date) {
|
|
|
1623
1690
|
<div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
|
|
1624
1691
|
${userLine}
|
|
1625
1692
|
<div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
|
|
1693
|
+
${calHtml}
|
|
1626
1694
|
${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
|
|
1627
1695
|
${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
|
|
1628
1696
|
`;
|
|
1629
1697
|
const canDel = !!(p.canDeleteOuting);
|
|
1630
|
-
bootbox.dialog({
|
|
1698
|
+
const dlg = bootbox.dialog({
|
|
1631
1699
|
title: 'Prévision de sortie',
|
|
1632
1700
|
message: html,
|
|
1633
1701
|
buttons: {
|
|
@@ -1650,6 +1718,11 @@ function toDatetimeLocalValue(date) {
|
|
|
1650
1718
|
} : {}),
|
|
1651
1719
|
},
|
|
1652
1720
|
});
|
|
1721
|
+
try {
|
|
1722
|
+
dlg.on('hidden.bs.modal', function () { isDialogOpen = false; });
|
|
1723
|
+
} catch (e) {
|
|
1724
|
+
isDialogOpen = false;
|
|
1725
|
+
}
|
|
1653
1726
|
return;
|
|
1654
1727
|
}
|
|
1655
1728
|
} catch (e) {
|
|
@@ -1673,6 +1746,9 @@ function toDatetimeLocalValue(date) {
|
|
|
1673
1746
|
})();
|
|
1674
1747
|
const period = `${formatDt(ev.start)} → ${formatDt(ev.end)}`;
|
|
1675
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
|
+
|
|
1676
1752
|
const approvedBy = String(p.approvedByUsername || '').trim();
|
|
1677
1753
|
const validatedByHtml = approvedBy
|
|
1678
1754
|
? `<div class=\"mb-2\"><strong>Validée par</strong><br><a href=\"https://www.onekite.com/user/${encodeURIComponent(approvedBy)}\">${escapeHtml(approvedBy)}</a></div>`
|
|
@@ -1712,6 +1788,10 @@ function toDatetimeLocalValue(date) {
|
|
|
1712
1788
|
${userLine}
|
|
1713
1789
|
<div class="mb-2"><strong>Matériel</strong><br>${itemsHtml}</div>
|
|
1714
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>` : '' }
|
|
1715
1795
|
${validatedByHtml}
|
|
1716
1796
|
${pickupHtml}
|
|
1717
1797
|
${refusedReasonHtml}
|
|
@@ -1734,6 +1814,22 @@ function toDatetimeLocalValue(date) {
|
|
|
1734
1814
|
const buttons = {
|
|
1735
1815
|
close: { label: 'Fermer', className: 'btn-secondary' },
|
|
1736
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;
|
|
1737
1833
|
if (showPay) {
|
|
1738
1834
|
buttons.pay = {
|
|
1739
1835
|
label: 'Payer maintenant',
|
|
@@ -1746,23 +1842,6 @@ function toDatetimeLocalValue(date) {
|
|
|
1746
1842
|
},
|
|
1747
1843
|
};
|
|
1748
1844
|
}
|
|
1749
|
-
if (showCancel) {
|
|
1750
|
-
buttons.cancel = {
|
|
1751
|
-
label: 'Annuler',
|
|
1752
|
-
className: 'btn-outline-warning',
|
|
1753
|
-
callback: async () => {
|
|
1754
|
-
try {
|
|
1755
|
-
if (!lockAction(`cancel:${rid}`, 1200)) return false;
|
|
1756
|
-
await cancelReservation(rid);
|
|
1757
|
-
showAlert('success', 'Réservation annulée.');
|
|
1758
|
-
invalidateEventsCache();
|
|
1759
|
-
scheduleRefetch(calendar);
|
|
1760
|
-
} catch (e) {
|
|
1761
|
-
showAlert('error', 'Annulation impossible.');
|
|
1762
|
-
}
|
|
1763
|
-
},
|
|
1764
|
-
};
|
|
1765
|
-
}
|
|
1766
1845
|
if (showModeration) {
|
|
1767
1846
|
buttons.refuse = {
|
|
1768
1847
|
label: 'Refuser',
|
|
@@ -1962,11 +2041,32 @@ function toDatetimeLocalValue(date) {
|
|
|
1962
2041
|
};
|
|
1963
2042
|
}
|
|
1964
2043
|
|
|
1965
|
-
|
|
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({
|
|
1966
2050
|
title: 'Réservation',
|
|
1967
2051
|
message: baseHtml,
|
|
1968
2052
|
buttons,
|
|
1969
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
|
+
}
|
|
1970
2070
|
},
|
|
1971
2071
|
});
|
|
1972
2072
|
|
|
@@ -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 -->
|