nodebb-plugin-onekite-calendar 2.0.65 → 2.0.67
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 +17 -115
- package/lib/api.js +90 -191
- package/lib/db.js +6 -3
- package/lib/discord.js +2 -7
- package/lib/helloassoWebhook.js +20 -49
- package/lib/nodebb-helpers.js +11 -174
- package/lib/scheduler.js +33 -22
- package/lib/shared.js +427 -0
- package/lib/utils.js +10 -65
- package/lib/widgets.js +2 -6
- package/package.json +1 -1
- package/plugin.json +1 -1
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
113
|
-
//
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
442
|
-
// --------------------
|
|
364
|
+
// baseUrl, hmacSecret, signCalendarLink, ymdToCompact, dtToGCalUtc,
|
|
365
|
+
// buildCalendarLinks are imported from shared.js
|
|
443
366
|
function baseUrl() {
|
|
444
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -765,13 +626,27 @@ api.getReservationDetails = async function (req, res) {
|
|
|
765
626
|
return res.json(out);
|
|
766
627
|
};
|
|
767
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Get detailed information about a special event.
|
|
631
|
+
*
|
|
632
|
+
* This endpoint is publicly accessible (no authentication required).
|
|
633
|
+
* Guests can view all event details including participants, but cannot join (canJoin will be false).
|
|
634
|
+
*
|
|
635
|
+
* @route GET /api/v3/plugins/calendar-onekite/special-events/:eid
|
|
636
|
+
* @param {Object} req - Express request object with req.params.eid and optionally req.uid
|
|
637
|
+
* @param {Object} res - Express response object
|
|
638
|
+
* @returns {Object} Event details including participants list, calendar export links, and permission flags
|
|
639
|
+
*
|
|
640
|
+
* @since 1.0.0 Modified to allow unauthenticated access (guests can view)
|
|
641
|
+
*/
|
|
768
642
|
api.getSpecialEventDetails = async function (req, res) {
|
|
769
643
|
const uid = req.uid;
|
|
770
|
-
|
|
644
|
+
// Guests (uid = null/undefined/0) can view event details but cannot join.
|
|
645
|
+
// Authenticated users get canJoin=true if they meet participation requirements.
|
|
771
646
|
|
|
772
647
|
const settings = await getSettings();
|
|
773
|
-
const canMod = await canValidate(uid, settings);
|
|
774
|
-
const canSpecialDelete = await canDeleteSpecial(uid, settings);
|
|
648
|
+
const canMod = uid ? await canValidate(uid, settings) : false;
|
|
649
|
+
const canSpecialDelete = uid ? await canDeleteSpecial(uid, settings) : false;
|
|
775
650
|
|
|
776
651
|
const eid = String(req.params.eid || '').trim();
|
|
777
652
|
if (!eid) return res.status(400).json({ error: 'missing-eid' });
|
|
@@ -886,14 +761,28 @@ api.leaveSpecialEvent = async function (req, res) {
|
|
|
886
761
|
api.getCapabilities = async function (req, res) {
|
|
887
762
|
const settings = await getSettings();
|
|
888
763
|
const uid = req.uid || 0;
|
|
889
|
-
|
|
764
|
+
if (!uid) {
|
|
765
|
+
return res.json({
|
|
766
|
+
canModerate: false,
|
|
767
|
+
canCreateSpecial: false,
|
|
768
|
+
canDeleteSpecial: false,
|
|
769
|
+
canCreateOuting: false,
|
|
770
|
+
canCreateReservation: false,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
const [canMod, canSpecialC, canSpecialD, canOuting, canRes] = await Promise.all([
|
|
774
|
+
canValidate(uid, settings),
|
|
775
|
+
canCreateSpecial(uid, settings),
|
|
776
|
+
canDeleteSpecial(uid, settings),
|
|
777
|
+
canRequest(uid, settings, Date.now()),
|
|
778
|
+
canRequest(uid, settings, Date.now()),
|
|
779
|
+
]);
|
|
890
780
|
res.json({
|
|
891
781
|
canModerate: canMod,
|
|
892
|
-
canCreateSpecial:
|
|
893
|
-
canDeleteSpecial:
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
canCreateReservation: uid ? await canRequest(uid, settings, Date.now()) : false,
|
|
782
|
+
canCreateSpecial: canSpecialC,
|
|
783
|
+
canDeleteSpecial: canSpecialD,
|
|
784
|
+
canCreateOuting: canOuting,
|
|
785
|
+
canCreateReservation: canRes,
|
|
897
786
|
});
|
|
898
787
|
};
|
|
899
788
|
|
|
@@ -977,12 +866,26 @@ api.deleteSpecialEvent = async function (req, res) {
|
|
|
977
866
|
res.json({ ok: true });
|
|
978
867
|
};
|
|
979
868
|
|
|
869
|
+
/**
|
|
870
|
+
* Get detailed information about an outing (prévision de sortie).
|
|
871
|
+
*
|
|
872
|
+
* This endpoint is publicly accessible (no authentication required).
|
|
873
|
+
* Guests can view all outing details including participants, but cannot join (canJoin will be false).
|
|
874
|
+
*
|
|
875
|
+
* @route GET /api/v3/plugins/calendar-onekite/outings/:oid
|
|
876
|
+
* @param {Object} req - Express request object with req.params.oid and optionally req.uid
|
|
877
|
+
* @param {Object} res - Express response object
|
|
878
|
+
* @returns {Object} Outing details including participants list, calendar export links, and permission flags
|
|
879
|
+
*
|
|
880
|
+
* @since 1.0.0 Modified to allow unauthenticated access (guests can view)
|
|
881
|
+
*/
|
|
980
882
|
api.getOutingDetails = async function (req, res) {
|
|
981
883
|
const uid = req.uid;
|
|
982
|
-
|
|
884
|
+
// Guests (uid = null/undefined/0) can view outing details but cannot join.
|
|
885
|
+
// Only authenticated users in authorized groups can join outings (canRequest).
|
|
983
886
|
|
|
984
887
|
const settings = await getSettings();
|
|
985
|
-
const canMod = await canValidate(uid, settings);
|
|
888
|
+
const canMod = uid ? await canValidate(uid, settings) : false;
|
|
986
889
|
|
|
987
890
|
const oid = String(req.params.oid || '').trim();
|
|
988
891
|
if (!oid) return res.status(400).json({ error: 'missing-oid' });
|
|
@@ -1191,10 +1094,6 @@ api.getItems = async function (req, res) {
|
|
|
1191
1094
|
clientId: settings.helloassoClientId,
|
|
1192
1095
|
clientSecret: settings.helloassoClientSecret,
|
|
1193
1096
|
});
|
|
1194
|
-
if (!token) {
|
|
1195
|
-
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
1097
|
if (!token) {
|
|
1199
1098
|
return res.json([]);
|
|
1200
1099
|
}
|
|
@@ -1566,8 +1465,8 @@ api.approveReservation = async function (req, res) {
|
|
|
1566
1465
|
payerEmail: payer && payer.email ? payer.email : '',
|
|
1567
1466
|
// By default, point to the forum base url so the webhook hits this NodeBB instance.
|
|
1568
1467
|
// Can be overridden via ACP setting `helloassoCallbackUrl`.
|
|
1569
|
-
callbackUrl: normalizeReturnUrl(
|
|
1570
|
-
webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl
|
|
1468
|
+
callbackUrl: normalizeReturnUrl(),
|
|
1469
|
+
webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl),
|
|
1571
1470
|
itemName: buildHelloAssoItemName('', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
|
|
1572
1471
|
containsDonation: false,
|
|
1573
1472
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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) => {
|