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 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(),
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.44",
3
+ "version": "2.0.45",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "2.0.44"
42
+ "version": "2.0.45"
43
43
  }
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
- const buttons = {
1301
- close: { label: 'Annuler', className: 'btn-secondary' },
1302
- location: {
1303
- label: 'Location',
1304
- className: 'btn-primary',
1305
- callback: async () => {
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
- outing: {
1340
- label: 'Prévision de sortie',
1341
- className: 'btn-outline-primary',
1342
- callback: async () => {
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-outline-secondary',
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' && p0.oid) {
1545
- const details = await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(String(p0.oid))}`);
1546
- p = Object.assign({}, p0, details, {
1547
- pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1548
- pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1549
- pickupLon: details.lon || details.pickupLon || p0.pickupLon,
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: 'Supprimer',
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
- bootbox.dialog({
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 -->