nodebb-plugin-onekite-calendar 2.0.66 → 2.0.68

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
@@ -2,97 +2,26 @@
2
2
 
3
3
  const meta = require.main.require('./src/meta');
4
4
  const user = require.main.require('./src/user');
5
- const emailer = require.main.require('./src/emailer');
6
- const { forumBaseUrl, formatFR } = require('./utils');
7
5
  const { getSettings, invalidateSettings } = require('./settings');
8
6
  const realtime = require('./realtime');
9
- const crypto = require('crypto');
10
7
  const nconf = require.main.require('nconf');
11
8
 
12
- function baseUrl() {
13
- try { return String(nconf.get('url') || '').replace(/\/$/, ''); } catch (e) { return forumBaseUrl || ''; }
14
- }
15
- function hmacSecret() {
16
- try {
17
- const s = String(nconf.get('secret') || '').trim();
18
- if (s) return s;
19
- } catch (e) {}
20
- return 'calendar-onekite';
21
- }
22
- function signCalendarLink(type, id, uid) {
23
- try {
24
- return crypto.createHmac('sha256', hmacSecret()).update(`${String(type)}:${String(id)}:${String(uid || 0)}`).digest('hex');
25
- } catch (e) { return ''; }
26
- }
27
- function ymdToCompact(ymd) { return String(ymd || '').replace(/-/g, ''); }
28
- function dtToGCalUtc(dt) {
29
- const d = (dt instanceof Date) ? dt : new Date(dt);
30
- return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
31
- }
32
- function buildCalendarLinks({ rid, uid, itemNames, pickupAddress, startYmd, endYmd }) {
33
- const sig = signCalendarLink('reservation', String(rid), Number(uid) || 0);
34
- const icsUrl = `${baseUrl()}/plugins/calendar-onekite/ics/reservation/${encodeURIComponent(String(rid))}?uid=${encodeURIComponent(String(uid || 0))}&sig=${encodeURIComponent(sig)}`;
35
- const title = (Array.isArray(itemNames) && itemNames.length) ? `Location - ${itemNames.join(', ')}` : 'Location';
36
- const gcal = new URL('https://calendar.google.com/calendar/render');
37
- gcal.searchParams.set('action', 'TEMPLATE');
38
- gcal.searchParams.set('text', title);
39
- gcal.searchParams.set('dates', `${ymdToCompact(startYmd)}/${ymdToCompact(endYmd)}`);
40
- if (pickupAddress) gcal.searchParams.set('location', String(pickupAddress));
41
- return { icsUrl, googleCalUrl: gcal.toString() };
42
- }
43
-
44
- function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
45
- const base = String(baseLabel || 'Réservation matériel Onekite').trim();
46
- const items = Array.isArray(itemNames) ? itemNames.map((s) => String(s || '').trim()).filter(Boolean) : [];
47
- const range = (start && end) ? `Du ${formatFR(start)} au ${formatFR(end)}` : '';
48
- const lines = [base];
49
- items.forEach((it) => lines.push(`• ${it}`));
50
- if (range) lines.push(range);
51
- let out = lines.join('\n').trim();
52
- if (out.length > 250) {
53
- out = out.slice(0, 249).trimEnd() + '…';
54
- }
55
- return out;
56
- }
57
-
58
- async function sendEmail(template, uid, subject, data) {
59
- const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
60
- if (!Number.isInteger(toUid) || toUid <= 0) return;
61
-
62
- const params = Object.assign({}, data || {}, subject ? { subject } : {});
63
-
64
- try {
65
- if (typeof emailer.send !== 'function') return;
66
- // NodeBB 4.x: send(template, uid, params)
67
- // Do NOT branch on function.length (unreliable once wrapped/bound).
68
- await emailer.send(template, toUid, params);
69
- } catch (err) {
70
- console.warn('[calendar-onekite] Failed to send email', {
71
- template,
72
- uid: toUid,
73
- err: err && err.message ? err.message : String(err),
74
- });
75
- }
76
- }
77
-
78
- function normalizeCallbackUrl(configured, meta) {
79
- const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
80
- let url = (configured || '').trim();
81
- if (!url) {
82
- url = base ? `${base}/helloasso` : '';
83
- }
84
- if (url && url.startsWith('/') && base) {
85
- url = `${base}${url}`;
86
- }
87
- return url;
88
- }
9
+ const shared = require('./shared');
10
+ const {
11
+ forumBaseUrl,
12
+ formatFR,
13
+ sendEmail,
14
+ buildCalendarLinks,
15
+ buildHelloAssoItemName,
16
+ normalizeCallbackUrl,
17
+ normalizeReturnUrl,
18
+ calendarDaysExclusiveYmd,
19
+ yearFromTs,
20
+ autoFormSlugForYear,
21
+ } = shared;
89
22
 
90
- function normalizeReturnUrl(meta) {
91
- const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
92
- const b = String(base || '').trim().replace(/\/$/, '');
93
- if (!b) return '';
94
- return `${b}/calendar`;
95
- }
23
+ // Kept for local compatibility in accounting helper
24
+ function baseUrl() { return forumBaseUrl(); }
96
25
 
97
26
 
98
27
  const dbLayer = require('./db');
@@ -182,9 +111,6 @@ admin.approveReservation = async function (req, res) {
182
111
  clientId: settings.helloassoClientId,
183
112
  clientSecret: settings.helloassoClientSecret,
184
113
  });
185
- if (!token) {
186
-
187
- }
188
114
 
189
115
  let paymentUrl = null;
190
116
  if (token) {
@@ -231,9 +157,6 @@ admin.approveReservation = async function (req, res) {
231
157
 
232
158
  await dbLayer.saveReservation(r);
233
159
 
234
- // Real-time refresh for all viewers
235
- realtime.emitCalendarUpdated({ kind: 'reservation', action: 'refused', rid: String(rid), status: r.status });
236
-
237
160
  // Real-time refresh for all viewers
238
161
  realtime.emitCalendarUpdated({ kind: 'reservation', action: 'approved', rid: String(rid), status: r.status });
239
162
 
@@ -500,28 +423,7 @@ admin.getAccounting = async function (req, res) {
500
423
  let paidCount = 0;
501
424
  let grandTotal = 0;
502
425
 
503
- // Helper: calendar-day difference (end exclusive) from YYYY-MM-DD strings (UTC midnights).
504
- const calendarDaysExclusiveYmd = (startYmd, endYmd) => {
505
- try {
506
- const s = String(startYmd || '').trim();
507
- const e = String(endYmd || '').trim();
508
- if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || !/^\d{4}-\d{2}-\d{2}$/.test(e)) return null;
509
- const [sy, sm, sd] = s.split('-').map((x) => parseInt(x, 10));
510
- const [ey, em, ed] = e.split('-').map((x) => parseInt(x, 10));
511
- const sUtc = Date.UTC(sy, sm - 1, sd);
512
- const eUtc = Date.UTC(ey, em - 1, ed);
513
- const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
514
- return Math.max(1, diff);
515
- } catch (e) {
516
- return null;
517
- }
518
- };
519
-
520
- const yearFromTs = (ts) => {
521
- const d = new Date(Number(ts));
522
- return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
523
- };
524
- const formSlugForYear = (y) => `locations-materiel-${y}`;
426
+ // calendarDaysExclusiveYmd, yearFromTs, autoFormSlugForYear imported from shared.js
525
427
 
526
428
  // Cache HelloAsso catalog price lookups by year to compute *per-item* totals.
527
429
  // This prevents a bug where the full reservation total was previously counted on every item line.
@@ -555,7 +457,7 @@ admin.getAccounting = async function (req, res) {
555
457
  token: tokenCache,
556
458
  organizationSlug: settingsCache.helloassoOrganizationSlug,
557
459
  formType: settingsCache.helloassoFormType,
558
- formSlug: formSlugForYear(y),
460
+ formSlug: autoFormSlugForYear(y),
559
461
  });
560
462
  const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
561
463
  catalogByYear.set(y, byId);
package/lib/api.js CHANGED
@@ -9,7 +9,7 @@ const groups = require.main.require('./src/groups');
9
9
  const dbLayer = require('./db');
10
10
  const { getSettings } = require('./settings');
11
11
 
12
- const nb = require("./nodebb-helpers");
12
+ const shared = require('./shared');
13
13
  const {
14
14
  normalizeAllowedGroups,
15
15
  normalizeUids,
@@ -19,33 +19,25 @@ const {
19
19
  sendEmail,
20
20
  normalizeUidList,
21
21
  usernamesByUids,
22
- } = nb;
22
+ formatFR,
23
+ calendarDaysExclusiveYmd,
24
+ yearFromTs,
25
+ autoCreatorGroupForYear,
26
+ autoFormSlugForYear,
27
+ normalizeCallbackUrl,
28
+ normalizeReturnUrl,
29
+ buildHelloAssoItemName,
30
+ buildCalendarLinks,
31
+ signCalendarLink,
32
+ ymdToCompact,
33
+ dtToGCalUtc,
34
+ } = shared;
23
35
 
24
36
  const helloasso = require('./helloasso');
25
37
  const discord = require('./discord');
26
38
  const realtime = require('./realtime');
27
39
 
28
- function normalizeCallbackUrl(configured, meta) {
29
- const base = forumBaseUrl();
30
- let url = (configured || '').trim();
31
- if (!url) {
32
- // Default webhook endpoint (recommended): namespaced under /plugins
33
- url = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
34
- }
35
- if (url && url.startsWith('/') && base) {
36
- url = `${base}${url}`;
37
- }
38
- // Ensure scheme for absolute URLs
39
- if (url && !/^https?:\/\//i.test(url)) {
40
- url = `https://${url.replace(/^\/\//, '')}`;
41
- }
42
- return url;
43
- }
44
-
45
- function normalizeReturnUrl(meta) {
46
- const base = forumBaseUrl();
47
- return base ? `${base}/calendar` : '';
48
- }
40
+ // normalizeCallbackUrl and normalizeReturnUrl are imported from shared.js
49
41
 
50
42
 
51
43
  function overlap(aStart, aEnd, bStart, bEnd) {
@@ -53,28 +45,7 @@ function overlap(aStart, aEnd, bStart, bEnd) {
53
45
  }
54
46
 
55
47
 
56
- function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
57
- const base = String(baseLabel || '').trim();
58
- const items = Array.isArray(itemNames) ? itemNames.map((s) => String(s || '').trim()).filter(Boolean) : [];
59
- const range = (start && end) ? ('Du ' + formatFR(start) + ' au ' + formatFR(end)) : '';
60
-
61
- // IMPORTANT:
62
- // On the public HelloAsso checkout page, line breaks are not always rendered consistently.
63
- // Build a single-line label using bullet separators.
64
- let out = '';
65
- if (base) out += base;
66
- if (items.length) out += (out ? ' — ' : '') + items.map((it) => '• ' + it).join(' ');
67
- if (range) out += (out ? ' — ' : '') + range;
68
-
69
- out = String(out || '').trim();
70
- if (!out) out = 'Réservation matériel';
71
-
72
- // HelloAsso constraint: itemName max 250 chars
73
- if (out.length > 250) {
74
- out = out.slice(0, 249).trimEnd() + '…';
75
- }
76
- return out;
77
- }
48
+ // buildHelloAssoItemName is imported from shared.js
78
49
 
79
50
  function toTs(v) {
80
51
  if (v === undefined || v === null || v === '') return NaN;
@@ -109,41 +80,8 @@ function toTs(v) {
109
80
  return d.getTime();
110
81
  }
111
82
 
112
- // Calendar-day difference (end exclusive) computed purely from Y/M/D.
113
- // Uses UTC midnights to avoid any dependency on local timezone or DST.
114
- function calendarDaysExclusiveYmd(startYmd, endYmd) {
115
- try {
116
- const m1 = /^\d{4}-\d{2}-\d{2}$/.exec(String(startYmd || '').trim());
117
- const m2 = /^\d{4}-\d{2}-\d{2}$/.exec(String(endYmd || '').trim());
118
- if (!m1 || !m2) return null;
119
- const [sy, sm, sd] = startYmd.split('-').map((x) => parseInt(x, 10));
120
- const [ey, em, ed] = endYmd.split('-').map((x) => parseInt(x, 10));
121
- const sUtc = Date.UTC(sy, sm - 1, sd);
122
- const eUtc = Date.UTC(ey, em - 1, ed);
123
- const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
124
- return Math.max(1, diff);
125
- } catch (e) {
126
- return null;
127
- }
128
- }
129
-
130
- function yearFromTs(ts) {
131
- const d = new Date(Number(ts));
132
- return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
133
- }
134
-
135
- function autoCreatorGroupForYear(year) {
136
- return `onekite-ffvl-${year}`;
137
- }
138
-
139
-
140
- // (removed) group slug/name resolving helpers: we now use userInAnyGroup() which
141
- // matches both slugs and names and avoids extra DB lookups.
142
-
143
-
144
- function autoFormSlugForYear(year) {
145
- return `locations-materiel-${year}`;
146
- }
83
+ // calendarDaysExclusiveYmd, yearFromTs, autoCreatorGroupForYear, autoFormSlugForYear
84
+ // are imported from shared.js
147
85
 
148
86
  async function canRequest(uid, settings, startTs) {
149
87
  if (!uid) return false;
@@ -188,14 +126,11 @@ async function canValidate(uid, settings) {
188
126
  if (isAdmin) return true;
189
127
 
190
128
  // NodeBB has a built-in "Global Moderators" group (slug can vary by install/version).
191
- // Allow them as validators as well.
192
129
  const globalModeratorCandidates = ['global-moderators', 'Global Moderators', 'global moderators'];
193
- for (const g of globalModeratorCandidates) {
194
- try {
195
- // eslint-disable-next-line no-await-in-loop
196
- if (await groups.isMember(uid, g)) return true;
197
- } catch (e) {}
198
- }
130
+ const checks = await Promise.all(
131
+ globalModeratorCandidates.map((g) => groups.isMember(uid, g).catch(() => false))
132
+ );
133
+ if (checks.some(Boolean)) return true;
199
134
  } catch (e) {}
200
135
 
201
136
  const allowed = normalizeAllowedGroups(settings.validatorGroups || '');
@@ -250,20 +185,9 @@ async function canCreateSpecial(uid, settings) {
250
185
 
251
186
  /**
252
187
  * Determines if a user can join or leave special events as a participant.
253
- *
254
- * This permission is intentionally permissive: any authenticated user can participate
255
- * in special events, regardless of group membership. This differs from creation/deletion
256
- * permissions which remain restricted to specific groups.
257
- *
258
- * @param {number|string} uid - The user ID. Falsy values (0, null, undefined, '') indicate guest/unauthenticated users.
259
- * @param {Object} settings - Plugin settings (unused but kept for API consistency with other permission functions).
260
- * @returns {boolean} True if the user can join/leave special events (i.e., is authenticated).
261
- *
262
- * @since 1.0.0 Modified to allow all authenticated users (previously restricted to specific groups)
188
+ * Any authenticated user can participate in special events.
263
189
  */
264
- async function canJoinSpecial(uid, settings) {
265
- // Any authenticated user (non-zero uid) can participate in special events.
266
- // The !! coercion converts truthy values to true, falsy to false.
190
+ function canJoinSpecial(uid, _settings) {
267
191
  return !!uid;
268
192
  }
269
193
 
@@ -437,78 +361,10 @@ function eventsForOuting(o) {
437
361
 
438
362
  const api = {};
439
363
 
440
- // --------------------
441
- // Calendar export helpers (ICS / Google Calendar template link)
442
- // --------------------
364
+ // baseUrl, hmacSecret, signCalendarLink, ymdToCompact, dtToGCalUtc,
365
+ // buildCalendarLinks are imported from shared.js
443
366
  function baseUrl() {
444
- try {
445
- return String(nconf.get('url') || '').replace(/\/$/, '');
446
- } catch (e) {
447
- return '';
448
- }
449
- }
450
-
451
- function hmacSecret() {
452
- // Prefer NodeBB's instance secret if present.
453
- try {
454
- const s = String(nconf.get('secret') || '').trim();
455
- if (s) return s;
456
- } catch (e) {}
457
- // Fallback: still deterministic per instance.
458
- return 'calendar-onekite';
459
- }
460
-
461
- function signCalendarLink(type, id, uid) {
462
- try {
463
- const msg = `${String(type)}:${String(id)}:${String(uid || 0)}`;
464
- return crypto.createHmac('sha256', hmacSecret()).update(msg).digest('hex');
465
- } catch (e) {
466
- return '';
467
- }
468
- }
469
-
470
- function ymdToCompact(ymd) {
471
- return String(ymd || '').replace(/-/g, '');
472
- }
473
-
474
- function dtToGCalUtc(dt) {
475
- const d = (dt instanceof Date) ? dt : new Date(dt);
476
- const iso = d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
477
- // 20260116T120000Z style
478
- return iso;
479
- }
480
-
481
- function buildCalendarLinks(opts) {
482
- const type = String(opts.type || '').trim();
483
- const id = String(opts.id || '').trim();
484
- const uid = Number(opts.uid) || 0;
485
- const title = String(opts.title || '').trim() || 'Évènement';
486
- const details = String(opts.details || '').trim();
487
- const location = String(opts.location || '').trim();
488
- const isAllDay = !!opts.allDay;
489
-
490
- // Signed ICS URL (public, but protected by signature for reservations).
491
- const sig = signCalendarLink(type, id, uid);
492
- const icsPath = `/plugins/calendar-onekite/ics/${encodeURIComponent(type)}/${encodeURIComponent(id)}?uid=${encodeURIComponent(String(uid))}&sig=${encodeURIComponent(sig)}`;
493
- const icsUrl = `${baseUrl()}${icsPath}`;
494
-
495
- // Google Calendar template URL.
496
- // docs: action=TEMPLATE&text=...&dates=...&details=...&location=...
497
- let dates = '';
498
- if (isAllDay) {
499
- // Google expects exclusive end date for all-day ranges.
500
- dates = `${ymdToCompact(opts.startYmd)}/${ymdToCompact(opts.endYmd)}`;
501
- } else {
502
- dates = `${dtToGCalUtc(opts.start)}/${dtToGCalUtc(opts.end)}`;
503
- }
504
- const gcal = new URL('https://calendar.google.com/calendar/render');
505
- gcal.searchParams.set('action', 'TEMPLATE');
506
- gcal.searchParams.set('text', title);
507
- if (dates) gcal.searchParams.set('dates', dates);
508
- if (details) gcal.searchParams.set('details', details);
509
- if (location) gcal.searchParams.set('location', location);
510
-
511
- return { icsUrl, googleCalUrl: gcal.toString() };
367
+ return forumBaseUrl();
512
368
  }
513
369
 
514
370
  function computeEtag(payload) {
@@ -531,10 +387,15 @@ api.getEvents = async function (req, res) {
531
387
  const endTs = toTs(qEndRaw) || (Date.now() + 365 * 24 * 3600 * 1000);
532
388
 
533
389
  const settings = await getSettings();
534
- const canMod = req.uid ? await canValidate(req.uid, settings) : false;
390
+ // Parallelize independent permission checks
391
+ const [canMod, canSpecialCreate, canSpecialDelete] = req.uid
392
+ ? await Promise.all([
393
+ canValidate(req.uid, settings),
394
+ canCreateSpecial(req.uid, settings),
395
+ canDeleteSpecial(req.uid, settings),
396
+ ])
397
+ : [false, false, false];
535
398
  const widgetMode = String((req.query && req.query.widget) || '') === '1';
536
- const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
537
- const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
538
399
 
539
400
  // Fetch a wider window because an event can start before the query range
540
401
  // and still overlap.
@@ -900,14 +761,30 @@ api.leaveSpecialEvent = async function (req, res) {
900
761
  api.getCapabilities = async function (req, res) {
901
762
  const settings = await getSettings();
902
763
  const uid = req.uid || 0;
903
- const canMod = uid ? await canValidate(uid, settings) : false;
764
+ if (!uid) {
765
+ return res.json({
766
+ canModerate: false,
767
+ canCreateSpecial: false,
768
+ canDeleteSpecial: false,
769
+ canCreateOuting: false,
770
+ canCreateReservation: false,
771
+ specialEventCategoryCid: 0,
772
+ });
773
+ }
774
+ const [canMod, canSpecialC, canSpecialD, canOuting, canRes] = await Promise.all([
775
+ canValidate(uid, settings),
776
+ canCreateSpecial(uid, settings),
777
+ canDeleteSpecial(uid, settings),
778
+ canRequest(uid, settings, Date.now()),
779
+ canRequest(uid, settings, Date.now()),
780
+ ]);
904
781
  res.json({
905
782
  canModerate: canMod,
906
- canCreateSpecial: uid ? await canCreateSpecial(uid, settings) : false,
907
- canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings) : false,
908
- // Outings share the same rights as reservations/locations.
909
- canCreateOuting: uid ? await canRequest(uid, settings, Date.now()) : false,
910
- canCreateReservation: uid ? await canRequest(uid, settings, Date.now()) : false,
783
+ canCreateSpecial: canSpecialC,
784
+ canDeleteSpecial: canSpecialD,
785
+ canCreateOuting: canOuting,
786
+ canCreateReservation: canRes,
787
+ specialEventCategoryCid: parseInt(settings && settings.specialEventCategoryId, 10) || 0,
911
788
  });
912
789
  };
913
790
 
@@ -940,6 +817,7 @@ api.createSpecialEvent = async function (req, res) {
940
817
  const notes = String((req.body && req.body.notes) || '').trim();
941
818
  const lat = String((req.body && req.body.lat) || '').trim();
942
819
  const lon = String((req.body && req.body.lon) || '').trim();
820
+ const content = String((req.body && req.body.content) || '').trim();
943
821
 
944
822
  const u = await user.getUserFields(req.uid, ['username']);
945
823
  const eid = crypto.randomUUID();
@@ -952,6 +830,7 @@ api.createSpecialEvent = async function (req, res) {
952
830
  notes,
953
831
  lat,
954
832
  lon,
833
+ content,
955
834
  uid: String(req.uid),
956
835
  username: u && u.username ? String(u.username) : '',
957
836
  createdAt: String(Date.now()),
@@ -966,7 +845,8 @@ api.createSpecialEvent = async function (req, res) {
966
845
 
967
846
  // Real-time refresh for all viewers
968
847
  realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'created', eid: ev.eid });
969
- res.json({ ok: true, eid });
848
+ const categoryCid = parseInt(settings && settings.specialEventCategoryId, 10) || 0;
849
+ res.json({ ok: true, eid, categoryCid });
970
850
  };
971
851
 
972
852
  api.deleteSpecialEvent = async function (req, res) {
@@ -1219,10 +1099,6 @@ api.getItems = async function (req, res) {
1219
1099
  clientId: settings.helloassoClientId,
1220
1100
  clientSecret: settings.helloassoClientSecret,
1221
1101
  });
1222
- if (!token) {
1223
-
1224
- }
1225
-
1226
1102
  if (!token) {
1227
1103
  return res.json([]);
1228
1104
  }
@@ -1594,8 +1470,8 @@ api.approveReservation = async function (req, res) {
1594
1470
  payerEmail: payer && payer.email ? payer.email : '',
1595
1471
  // By default, point to the forum base url so the webhook hits this NodeBB instance.
1596
1472
  // Can be overridden via ACP setting `helloassoCallbackUrl`.
1597
- callbackUrl: normalizeReturnUrl(meta),
1598
- webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl, meta),
1473
+ callbackUrl: normalizeReturnUrl(),
1474
+ webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl),
1599
1475
  itemName: buildHelloAssoItemName('', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
1600
1476
  containsDonation: false,
1601
1477
  metadata: {
package/lib/db.js CHANGED
@@ -123,9 +123,12 @@ async function setAllMaintenance(enabled, itemIds) {
123
123
  return { count: 0 };
124
124
  }
125
125
  const ids = Array.isArray(itemIds) ? itemIds.map(String).filter(Boolean) : [];
126
- // Add back all ids
127
- for (const id of ids) {
128
- await db.sortedSetAdd(KEY_MAINTENANCE_ZSET, 0, id);
126
+ if (!ids.length) return { count: 0 };
127
+ // Batch add: use sortedSetsAdd if available, otherwise Promise.all
128
+ if (typeof db.sortedSetAddBulk === 'function') {
129
+ await db.sortedSetAddBulk(ids.map((id) => [KEY_MAINTENANCE_ZSET, 0, id]));
130
+ } else {
131
+ await Promise.all(ids.map((id) => db.sortedSetAdd(KEY_MAINTENANCE_ZSET, 0, id)));
129
132
  }
130
133
  return { count: ids.length };
131
134
  }
package/lib/discord.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const https = require('https');
4
4
  const { URL } = require('url');
5
+ const { formatFRShort } = require('./shared');
5
6
 
6
7
  function isEnabled(v, defaultValue) {
7
8
  if (v === undefined || v === null || v === '') return defaultValue !== false;
@@ -11,13 +12,7 @@ function isEnabled(v, defaultValue) {
11
12
  return defaultValue !== false;
12
13
  }
13
14
 
14
- function formatFRShort(ts) {
15
- const d = new Date(ts);
16
- const dd = String(d.getDate()).padStart(2, '0');
17
- const mm = String(d.getMonth() + 1).padStart(2, '0');
18
- const yy = String(d.getFullYear()).slice(-2);
19
- return `${dd}/${mm}/${yy}`;
20
- }
15
+ // formatFRShort imported from shared.js
21
16
 
22
17
  function postWebhook(webhookUrl, payload) {
23
18
  return new Promise((resolve, reject) => {
@@ -11,44 +11,30 @@ const dbLayer = require('./db');
11
11
  const helloasso = require('./helloasso');
12
12
  const discord = require('./discord');
13
13
  const realtime = require('./realtime');
14
- const { formatFR } = require('./utils');
14
+ const shared = require('./shared');
15
+ const { formatFR, forumBaseUrl, sendEmail, buildCalendarLinks, signCalendarLink, ymdToCompact } = shared;
15
16
 
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
- }
17
+ function baseUrl() { return forumBaseUrl(); }
33
18
 
34
- function ymdToCompact(ymd) { return String(ymd || '').replace(/-/g, ''); }
35
19
 
36
20
  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);
21
+ const startYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate)))
22
+ ? String(r.startDate)
23
+ : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10);
24
+ const endYmd = (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate)))
25
+ ? String(r.endDate)
26
+ : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10);
43
27
  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() };
28
+ return buildCalendarLinks({
29
+ type: 'reservation',
30
+ id: String(r.rid || ''),
31
+ uid: Number(r.uid) || 0,
32
+ itemNames,
33
+ pickupAddress: r.pickupAddress || '',
34
+ allDay: true,
35
+ startYmd,
36
+ endYmd,
37
+ });
52
38
  }
53
39
 
54
40
  async function auditLog(action, actorUid, payload) {
@@ -72,22 +58,7 @@ const SETTINGS_KEY = 'calendar-onekite';
72
58
  // Replay protection: store processed payment ids.
73
59
  const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
74
60
 
75
- async function sendEmail(template, uid, subject, data) {
76
- const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
77
- if (!Number.isInteger(toUid) || toUid <= 0) return;
78
- try {
79
- const emailer = require.main.require('./src/emailer');
80
- if (typeof emailer.send !== 'function') return;
81
- const params = Object.assign({}, data || {}, subject ? { subject } : {});
82
- // NodeBB 4.x: send(template, uid, params)
83
- // Do NOT branch on function.length: it is unreliable once wrapped/bound and
84
- // can lead to params being dropped (empty email bodies / missing subjects).
85
- await emailer.send(template, toUid, params);
86
- } catch (err) {
87
- // eslint-disable-next-line no-console
88
- console.warn('[calendar-onekite] Failed to send email (webhook)', { template, uid: toUid, err: String((err && err.message) || err) });
89
- }
90
- }
61
+ // sendEmail imported from shared.js
91
62
 
92
63
  function getReservationIdFromPayload(payload) {
93
64
  try {