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 +17 -115
- package/lib/api.js +63 -187
- 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/public/admin.js +26 -0
- package/public/client.js +31 -5
- package/templates/admin/plugins/calendar-onekite.tpl +6 -4
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.
|
|
@@ -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
|
-
|
|
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:
|
|
907
|
-
canDeleteSpecial:
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
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(
|
|
1598
|
-
webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl
|
|
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
|
-
|
|
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) => {
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -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
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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 {
|