nodebb-plugin-calendar-onekite 12.0.1 → 12.0.2

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,85 +4,18 @@ 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 nconf = require.main.require('nconf');
7
+ const { sendTemplateEmail } = require('./utils/mailer');
8
+ const { formatFR } = require('./utils/time');
9
+ const { normalizeReturnUrl, normalizeCallbackUrl } = require('./utils/url');
7
10
 
8
11
  function forumBaseUrl() {
9
12
  const base = String(nconf.get('url') || '').trim().replace(/\/$/, '');
10
13
  return base;
11
14
  }
12
15
 
13
- function formatFR(tsOrIso) {
14
- const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
15
- const dd = String(d.getDate()).padStart(2, '0');
16
- const mm = String(d.getMonth() + 1).padStart(2, '0');
17
- const yyyy = d.getFullYear();
18
- return `${dd}/${mm}/${yyyy}`;
19
- }
20
-
21
16
  async function sendEmail(template, toEmail, subject, data) {
22
- // Prefer sending by uid (NodeBB core expects uid in various places)
23
17
  const uid = data && Number.isInteger(data.uid) ? data.uid : null;
24
- if (!toEmail && !uid) return;
25
-
26
- const settings = await meta.settings.get('calendar-onekite').catch(() => ({}));
27
- const lang = (settings && settings.defaultLang) || (meta && meta.config && meta.config.defaultLang) || 'fr';
28
- const params = Object.assign({}, data || {}, subject ? { subject } : {});
29
-
30
- // If we have a uid, use the native uid-based sender first.
31
- try {
32
- if (uid && typeof emailer.send === 'function') {
33
- // NodeBB: send(template, uid, params)
34
- if (emailer.send.length >= 3) {
35
- await emailer.send(template, uid, params);
36
- } else {
37
- await emailer.send(template, uid, params);
38
- }
39
- return;
40
- }
41
- } catch (err) {
42
- console.warn('[calendar-onekite] Failed to send email', {
43
- template,
44
- toEmail,
45
- err: err && err.message ? err.message : String(err),
46
- });
47
- }
48
-
49
- try {
50
- if (typeof emailer.sendToEmail === 'function') {
51
- // NodeBB: sendToEmail(template, email, language, params)
52
- if (emailer.sendToEmail.length >= 4) {
53
- await emailer.sendToEmail(template, toEmail, lang, params);
54
- } else {
55
- // Older signature: sendToEmail(template, email, params)
56
- await emailer.sendToEmail(template, toEmail, params);
57
- }
58
- return;
59
- }
60
- } catch (err) {
61
- console.warn('[calendar-onekite] Failed to send email', {
62
- template,
63
- toEmail,
64
- err: err && err.message ? err.message : String(err),
65
- });
66
- }
67
- }
68
-
69
- function normalizeCallbackUrl(configured, meta) {
70
- const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
71
- let url = (configured || '').trim();
72
- if (!url) {
73
- url = base ? `${base}/helloasso` : '';
74
- }
75
- if (url && url.startsWith('/') && base) {
76
- url = `${base}${url}`;
77
- }
78
- return url;
79
- }
80
-
81
- function normalizeReturnUrl(meta) {
82
- const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
83
- const b = String(base || '').trim().replace(/\/$/, '');
84
- if (!b) return '';
85
- return `${b}/calendar`;
18
+ await sendTemplateEmail({ template, uid, toEmail, subject, data });
86
19
  }
87
20
 
88
21
 
@@ -112,10 +45,13 @@ admin.saveSettings = async function (req, res) {
112
45
  admin.listPending = async function (req, res) {
113
46
  const ids = await dbLayer.listAllReservationIds(5000);
114
47
  const pending = [];
115
- for (const rid of ids) {
116
- const r = await dbLayer.getReservation(rid);
117
- if (r && r.status === 'pending') {
118
- pending.push(r);
48
+ // Bulk fetch in chunks to avoid long key lists
49
+ const chunkSize = 500;
50
+ for (let i = 0; i < ids.length; i += chunkSize) {
51
+ const slice = ids.slice(i, i + chunkSize);
52
+ const rows = await dbLayer.getReservations(slice);
53
+ for (const r of rows) {
54
+ if (r && r.status === 'pending') pending.push(r);
119
55
  }
120
56
  }
121
57
  pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
package/lib/api.js CHANGED
@@ -10,73 +10,22 @@ const groups = require.main.require('./src/groups');
10
10
 
11
11
  const dbLayer = require('./db');
12
12
  const helloasso = require('./helloasso');
13
+ const { sendTemplateEmail } = require('./utils/mailer');
14
+ const urlUtils = require('./utils/url');
15
+ const timeUtils = require('./utils/time');
16
+ const permUtils = require('./utils/permissions');
13
17
 
14
18
  // Email helper: NodeBB's Emailer signature differs across versions.
15
19
  // We try the common forms. Any failure is logged for debugging.
16
20
  async function sendEmail(template, toEmail, subject, data) {
17
- if (!toEmail) return;
18
- try {
19
- // NodeBB core signature (historically):
20
- // Emailer.sendToEmail(template, email, language, params[, callback])
21
- // Subject is not a positional arg; it must be injected (either by NodeBB itself
22
- // or via filter:email.modify). We always pass it in params.subject.
23
- const language = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
24
- const params = Object.assign({}, data || {}, subject ? { subject } : {});
25
- if (typeof emailer.sendToEmail === 'function') {
26
- await emailer.sendToEmail(template, toEmail, language, params);
27
- return;
28
- }
29
- // Fallback for older/unusual builds (rare)
30
- if (typeof emailer.send === 'function') {
31
- // Some builds accept (template, email, language, params)
32
- if (emailer.send.length >= 4) {
33
- await emailer.send(template, toEmail, language, params);
34
- return;
35
- }
36
- // Some builds accept (template, email, params)
37
- await emailer.send(template, toEmail, params);
38
- }
39
- } catch (err) {
40
- // eslint-disable-next-line no-console
41
- console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String(err) });
42
- }
21
+ await sendTemplateEmail({ template, toEmail, subject, data });
43
22
  }
44
23
 
45
- function normalizeBaseUrl(meta) {
46
- // Prefer meta.config.url, fallback to nconf.get('url')
47
- let base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
48
- if (!base) {
49
- base = String(nconf.get('url') || '').trim();
50
- }
51
- base = String(base || '').trim().replace(/\/$/, '');
52
- // Ensure absolute with scheme
53
- if (base && !/^https?:\/\//i.test(base)) {
54
- base = `https://${base.replace(/^\/\//, '')}`;
55
- }
56
- return base;
57
- }
24
+ function normalizeBaseUrl(meta) { return urlUtils.normalizeBaseUrl(meta); }
58
25
 
59
- function normalizeCallbackUrl(configured, meta) {
60
- const base = normalizeBaseUrl(meta);
61
- let url = (configured || '').trim();
62
- if (!url) {
63
- // Default webhook endpoint (recommended): namespaced under /plugins
64
- url = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
65
- }
66
- if (url && url.startsWith('/') && base) {
67
- url = `${base}${url}`;
68
- }
69
- // Ensure scheme for absolute URLs
70
- if (url && !/^https?:\/\//i.test(url)) {
71
- url = `https://${url.replace(/^\/\//, '')}`;
72
- }
73
- return url;
74
- }
26
+ function normalizeCallbackUrl(configured, meta) { return urlUtils.normalizeCallbackUrl(configured, meta); }
75
27
 
76
- function normalizeReturnUrl(meta) {
77
- const base = normalizeBaseUrl(meta);
78
- return base ? `${base}/calendar` : '';
79
- }
28
+ function normalizeReturnUrl(meta) { return urlUtils.normalizeReturnUrl(meta); }
80
29
 
81
30
 
82
31
  function overlap(aStart, aEnd, bStart, bEnd) {
@@ -84,100 +33,32 @@ function overlap(aStart, aEnd, bStart, bEnd) {
84
33
  }
85
34
 
86
35
 
87
- function formatFR(tsOrIso) {
88
- const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
89
- const dd = String(d.getDate()).padStart(2, '0');
90
- const mm = String(d.getMonth() + 1).padStart(2, '0');
91
- const yyyy = d.getFullYear();
92
- return `${dd}/${mm}/${yyyy}`;
93
- }
36
+ function formatFR(tsOrIso) { return timeUtils.formatFR(tsOrIso); }
94
37
 
95
- function toTs(v) {
96
- if (v === undefined || v === null || v === '') return NaN;
97
- // Accept milliseconds timestamps passed as strings or numbers.
98
- if (typeof v === 'number') return v;
99
- const s = String(v).trim();
100
- if (/^[0-9]+$/.test(s)) return parseInt(s, 10);
101
- const d = new Date(s);
102
- return d.getTime();
103
- }
38
+ function toTs(v) { return timeUtils.toTs(v); }
104
39
 
105
- function yearFromTs(ts) {
106
- const d = new Date(Number(ts));
107
- return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
108
- }
40
+ function yearFromTs(ts) { return timeUtils.yearFromTs(ts); }
109
41
 
110
- function autoCreatorGroupForYear(year) {
111
- return `onekite-ffvl-${year}`;
112
- }
42
+ function autoCreatorGroupForYear(year) { return permUtils.autoCreatorGroupForYear(year); }
113
43
 
114
44
  function autoFormSlugForYear(year) {
115
45
  return `locations-materiel-${year}`;
116
46
  }
117
47
 
118
48
  async function canRequest(uid, settings, startTs) {
119
- const year = yearFromTs(startTs);
120
- const defaultGroup = autoCreatorGroupForYear(year);
121
- const extras = (settings.creatorGroups || settings.allowedGroups || '').split(',').map(s => s.trim()).filter(Boolean);
122
- const allowed = [defaultGroup, ...extras].filter(Boolean);
123
- // If only the default group exists, enforce membership (do not open access to all).
124
- if (!allowed.length) return true;
125
- for (const g of allowed) {
126
- const isMember = await groups.isMember(uid, g);
127
- if (isMember) return true;
128
- }
129
- return false;
49
+ return await permUtils.canRequest(uid, settings, startTs);
130
50
  }
131
51
 
132
52
  async function canValidate(uid, settings) {
133
- // Always allow forum administrators (and global moderators) to validate,
134
- // even if validatorGroups is empty.
135
- try {
136
- const isAdmin = await groups.isMember(uid, 'administrators');
137
- if (isAdmin) return true;
138
- const isGlobalMod = await groups.isMember(uid, 'Global Moderators');
139
- if (isGlobalMod) return true;
140
- } catch (e) {}
141
-
142
- const allowed = (settings.validatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
143
- if (!allowed.length) return false;
144
- for (const g of allowed) {
145
- const isMember = await groups.isMember(uid, g);
146
- if (isMember) return true;
147
- }
148
- return false;
53
+ return await permUtils.canValidate(uid, settings);
149
54
  }
150
55
 
151
56
  async function canCreateSpecial(uid, settings) {
152
- if (!uid) return false;
153
- try {
154
- const isAdmin = await groups.isMember(uid, 'administrators');
155
- if (isAdmin) return true;
156
- const isGlobalMod = await groups.isMember(uid, 'Global Moderators');
157
- if (isGlobalMod) return true;
158
- } catch (e) {}
159
- const allowed = (settings.specialCreatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
160
- if (!allowed.length) return false;
161
- for (const g of allowed) {
162
- if (await groups.isMember(uid, g)) return true;
163
- }
164
- return false;
57
+ return await permUtils.canCreateSpecial(uid, settings);
165
58
  }
166
59
 
167
60
  async function canDeleteSpecial(uid, settings) {
168
- if (!uid) return false;
169
- try {
170
- const isAdmin = await groups.isMember(uid, 'administrators');
171
- if (isAdmin) return true;
172
- const isGlobalMod = await groups.isMember(uid, 'Global Moderators');
173
- if (isGlobalMod) return true;
174
- } catch (e) {}
175
- const allowed = (settings.specialDeleterGroups || settings.specialCreatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
176
- if (!allowed.length) return false;
177
- for (const g of allowed) {
178
- if (await groups.isMember(uid, g)) return true;
179
- }
180
- return false;
61
+ return await permUtils.canDeleteSpecial(uid, settings);
181
62
  }
182
63
 
183
64
  function eventsFor(resv) {
package/lib/db.js CHANGED
@@ -14,6 +14,19 @@ async function getReservation(rid) {
14
14
  return await db.getObject(KEY_OBJ(rid));
15
15
  }
16
16
 
17
+
18
+ async function getReservations(rids) {
19
+ if (!Array.isArray(rids) || !rids.length) return [];
20
+ const keys = rids.map((rid) => KEY_OBJ(rid));
21
+ return await db.getObjects(keys);
22
+ }
23
+
24
+ async function getSpecialEvents(eids) {
25
+ if (!Array.isArray(eids) || !eids.length) return [];
26
+ const keys = eids.map((eid) => KEY_SPECIAL_OBJ(eid));
27
+ return await db.getObjects(keys);
28
+ }
29
+
17
30
  async function saveReservation(resv) {
18
31
  await db.setObject(KEY_OBJ(resv.rid), resv);
19
32
  // score = start timestamp
@@ -50,6 +63,8 @@ module.exports = {
50
63
  KEY_SPECIAL_ZSET,
51
64
  KEY_CHECKOUT_INTENT_TO_RID,
52
65
  getReservation,
66
+ getReservations,
67
+ getSpecialEvents,
53
68
  saveReservation,
54
69
  removeReservation,
55
70
  // Special events
package/lib/scheduler.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  const meta = require.main.require('./src/meta');
4
4
  const dbLayer = require('./db');
5
+ const { sendTemplateEmail } = require('./utils/mailer');
6
+ const { formatFR } = require('./utils/time');
5
7
 
6
8
  let timer = null;
7
9
 
@@ -23,8 +25,12 @@ async function expirePending() {
23
25
  return;
24
26
  }
25
27
 
26
- for (const rid of ids) {
27
- const resv = await dbLayer.getReservation(rid);
28
+ const chunkSize = 500;
29
+ for (let i = 0; i < ids.length; i += chunkSize) {
30
+ const slice = ids.slice(i, i + chunkSize);
31
+ const rows = await dbLayer.getReservations(slice);
32
+ for (const resv of rows) {
33
+ const rid = resv && resv.rid;
28
34
  if (!resv || resv.status !== 'pending') {
29
35
  continue;
30
36
  }
@@ -34,6 +40,7 @@ async function expirePending() {
34
40
  // Expire (remove from calendar)
35
41
  await dbLayer.removeReservation(rid);
36
42
  }
43
+ }
37
44
  }
38
45
  }
39
46
 
@@ -51,55 +58,12 @@ async function processAwaitingPayment() {
51
58
 
52
59
  const ids = await dbLayer.listAllReservationIds(5000);
53
60
  if (!ids || !ids.length) return;
54
-
55
- const emailer = require.main.require('./src/emailer');
56
- const user = require.main.require('./src/user');
57
-
58
- async function sendEmail(template, toEmail, subject, data) {
59
- if (!toEmail) return;
60
- try {
61
- // NodeBB core signature:
62
- // Emailer.sendToEmail(template, email, language, params[, callback])
63
- // Subject is NOT a positional argument; it must be provided in params.subject
64
- // and optionally copied into the final email by filter:email.modify.
65
- const language = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
66
- const params = Object.assign({}, data || {}, subject ? { subject } : {});
67
-
68
- if (typeof emailer.sendToEmail === 'function') {
69
- await emailer.sendToEmail(template, toEmail, language, params);
70
- return;
71
- }
72
-
73
- // Fallbacks for older/unusual builds
74
- if (typeof emailer.send === 'function') {
75
- // Some builds accept (template, email, language, params)
76
- if (emailer.send.length >= 4) {
77
- await emailer.send(template, toEmail, language, params);
78
- return;
79
- }
80
- // Some builds accept (template, email, params)
81
- await emailer.send(template, toEmail, params);
82
- }
83
- } catch (err) {
84
- // eslint-disable-next-line no-console
85
- console.warn('[calendar-onekite] Failed to send email (scheduler)', {
86
- template,
87
- toEmail,
88
- err: String((err && err.message) || err),
89
- });
90
- }
91
- }
92
-
93
- function formatFR(ts) {
94
- const d = new Date(ts);
95
- const dd = String(d.getDate()).padStart(2, '0');
96
- const mm = String(d.getMonth() + 1).padStart(2, '0');
97
- const yyyy = d.getFullYear();
98
- return `${dd}/${mm}/${yyyy}`;
99
- }
100
-
101
- for (const rid of ids) {
102
- const r = await dbLayer.getReservation(rid);
61
+ const user = require.main.require('./src/user'); const chunkSize = 500;
62
+ for (let i = 0; i < ids.length; i += chunkSize) {
63
+ const slice = ids.slice(i, i + chunkSize);
64
+ const rows = await dbLayer.getReservations(slice);
65
+ for (const r of rows) {
66
+ const rid = r && r.rid;
103
67
  if (!r || r.status !== 'awaiting_payment') continue;
104
68
 
105
69
  const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
@@ -112,7 +76,7 @@ async function processAwaitingPayment() {
112
76
  // Send reminder once
113
77
  const u = await user.getUserFields(r.uid, ['username', 'email']);
114
78
  if (u && u.email) {
115
- await sendEmail('calendar-onekite_reminder', u.email, 'Location matériel - Rappel', {
79
+ await sendTemplateEmail({ template: 'calendar-onekite_reminder', toEmail: u.email, subject: 'Location matériel - Rappel', data: {
116
80
  username: u.username,
117
81
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
118
82
  itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
@@ -120,7 +84,7 @@ async function processAwaitingPayment() {
120
84
  paymentUrl: r.paymentUrl || '',
121
85
  delayMinutes: holdMins,
122
86
  pickupLine: r.pickupTime ? (r.adminNote ? `${r.pickupTime} à ${r.adminNote}` : r.pickupTime) : '',
123
- });
87
+ }});
124
88
  }
125
89
  r.reminderSent = true;
126
90
  r.reminderAt = now;
@@ -132,16 +96,17 @@ async function processAwaitingPayment() {
132
96
  // Expire: remove reservation so it disappears from calendar and frees items
133
97
  const u = await user.getUserFields(r.uid, ['username', 'email']);
134
98
  if (u && u.email) {
135
- await sendEmail('calendar-onekite_expired', u.email, 'Location matériel - Rappel', {
99
+ await sendTemplateEmail({ template: 'calendar-onekite_expired', toEmail: u.email, subject: 'Location matériel - Rappel', data: {
136
100
  username: u.username,
137
101
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
138
102
  itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
139
103
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
140
104
  delayMinutes: holdMins,
141
- });
105
+ }});
142
106
  }
143
107
  await dbLayer.removeReservation(rid);
144
108
  }
109
+ }
145
110
  }
146
111
  }
147
112
 
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const meta = require.main.require('./src/meta');
4
+ const emailer = require.main.require('./src/emailer');
5
+
6
+ /**
7
+ * Send NodeBB email templates in a way that survives slight core signature differences.
8
+ * - Prefer uid-based sending when a uid is available.
9
+ * - Otherwise use sendToEmail.
10
+ *
11
+ * @param {object} opts
12
+ * @param {string} opts.template
13
+ * @param {number} [opts.uid]
14
+ * @param {string} [opts.toEmail]
15
+ * @param {string} [opts.subject]
16
+ * @param {object} [opts.data]
17
+ */
18
+ async function sendTemplateEmail(opts) {
19
+ const template = opts && opts.template;
20
+ const uid = opts && Number.isInteger(opts.uid) ? opts.uid : null;
21
+ const toEmail = opts && opts.toEmail ? String(opts.toEmail) : '';
22
+ const subject = opts && opts.subject ? String(opts.subject) : '';
23
+ const data = (opts && opts.data) || {};
24
+
25
+ if (!template) return;
26
+ if (!uid && !toEmail) return;
27
+
28
+ const language = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
29
+ const params = Object.assign({}, data, subject ? { subject } : {});
30
+
31
+ // uid-first
32
+ if (uid && typeof emailer.send === 'function') {
33
+ try {
34
+ // NodeBB: send(template, uid, params)
35
+ await emailer.send(template, uid, params);
36
+ return;
37
+ } catch (err) {
38
+ // fall through to email
39
+ }
40
+ }
41
+
42
+ if (toEmail && typeof emailer.sendToEmail === 'function') {
43
+ try {
44
+ // NodeBB commonly: sendToEmail(template, email, language, params)
45
+ await emailer.sendToEmail(template, toEmail, language, params);
46
+ return;
47
+ } catch (err) {
48
+ // eslint-disable-next-line no-console
49
+ console.warn('[calendar-onekite] Failed to send email', {
50
+ template,
51
+ toEmail,
52
+ uid,
53
+ err: err && err.message ? err.message : String(err),
54
+ });
55
+ }
56
+ }
57
+ }
58
+
59
+ module.exports = { sendTemplateEmail };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const groups = require.main.require('./src/groups');
4
+ const { yearFromTs } = require('./time');
5
+
6
+ function autoCreatorGroupForYear(year) {
7
+ return `onekite-ffvl-${year}`;
8
+ }
9
+
10
+ async function isPrivileged(uid) {
11
+ try {
12
+ if (await groups.isMember(uid, 'administrators')) return true;
13
+ if (await groups.isMember(uid, 'Global Moderators')) return true;
14
+ } catch (e) {}
15
+ return false;
16
+ }
17
+
18
+ function parseGroupList(s) {
19
+ return String(s || '')
20
+ .split(',')
21
+ .map((x) => x.trim())
22
+ .filter(Boolean);
23
+ }
24
+
25
+ async function canRequest(uid, settings, startTs) {
26
+ const year = yearFromTs(startTs);
27
+ const defaultGroup = autoCreatorGroupForYear(year);
28
+ const extras = parseGroupList(settings && (settings.creatorGroups || settings.allowedGroups));
29
+ const allowed = [defaultGroup, ...extras].filter(Boolean);
30
+ if (!allowed.length) return true;
31
+ for (const g of allowed) {
32
+ if (await groups.isMember(uid, g)) return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ async function canValidate(uid, settings) {
38
+ if (!uid) return false;
39
+ if (await isPrivileged(uid)) return true;
40
+ const allowed = parseGroupList(settings && settings.validatorGroups);
41
+ for (const g of allowed) {
42
+ if (await groups.isMember(uid, g)) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ async function canCreateSpecial(uid, settings) {
48
+ if (!uid) return false;
49
+ if (await isPrivileged(uid)) return true;
50
+ const allowed = parseGroupList(settings && settings.specialCreatorGroups);
51
+ for (const g of allowed) {
52
+ if (await groups.isMember(uid, g)) return true;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ async function canDeleteSpecial(uid, settings) {
58
+ if (!uid) return false;
59
+ if (await isPrivileged(uid)) return true;
60
+ const allowed = parseGroupList(settings && (settings.specialDeleterGroups || settings.specialCreatorGroups));
61
+ for (const g of allowed) {
62
+ if (await groups.isMember(uid, g)) return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ module.exports = {
68
+ autoCreatorGroupForYear,
69
+ canRequest,
70
+ canValidate,
71
+ canCreateSpecial,
72
+ canDeleteSpecial,
73
+ };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ function toTs(v) {
4
+ if (v === undefined || v === null || v === '') return NaN;
5
+ // Accept milliseconds timestamps passed as strings or numbers.
6
+ if (typeof v === 'number') return v;
7
+ const s = String(v).trim();
8
+ if (/^[0-9]+$/.test(s)) return parseInt(s, 10);
9
+ const d = new Date(s);
10
+ return d.getTime();
11
+ }
12
+
13
+ function formatFR(tsOrIso) {
14
+ const d = new Date(
15
+ typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso
16
+ );
17
+ const dd = String(d.getDate()).padStart(2, '0');
18
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
19
+ const yyyy = d.getFullYear();
20
+ return `${dd}/${mm}/${yyyy}`;
21
+ }
22
+
23
+ function yearFromTs(ts) {
24
+ const d = new Date(Number(ts));
25
+ return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
26
+ }
27
+
28
+ module.exports = {
29
+ toTs,
30
+ formatFR,
31
+ yearFromTs,
32
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const nconf = require.main.require('nconf');
4
+
5
+ function normalizeBaseUrl(meta) {
6
+ // Prefer meta.config.url, fallback to nconf.get('url')
7
+ let base = (meta && meta.config && (meta.config.url || meta.config['url']))
8
+ ? (meta.config.url || meta.config['url'])
9
+ : '';
10
+ if (!base) base = String(nconf.get('url') || '').trim();
11
+ base = String(base || '').trim().replace(/\/$/, '');
12
+ if (base && !/^https?:\/\//i.test(base)) {
13
+ base = `https://${base.replace(/^\/\//, '')}`;
14
+ }
15
+ return base;
16
+ }
17
+
18
+ function normalizeCallbackUrl(configured, meta, fallbackPath = '/plugins/calendar-onekite/helloasso') {
19
+ const base = normalizeBaseUrl(meta);
20
+ let url = String(configured || '').trim();
21
+ if (!url) url = base ? `${base}${fallbackPath}` : '';
22
+ if (url && url.startsWith('/') && base) url = `${base}${url}`;
23
+ if (url && !/^https?:\/\//i.test(url)) url = `https://${url.replace(/^\/\//, '')}`;
24
+ return url;
25
+ }
26
+
27
+ function normalizeReturnUrl(meta, path = '/calendar') {
28
+ const base = normalizeBaseUrl(meta);
29
+ return base ? `${base}${path}` : '';
30
+ }
31
+
32
+ module.exports = {
33
+ normalizeBaseUrl,
34
+ normalizeCallbackUrl,
35
+ normalizeReturnUrl,
36
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "12.0.1",
3
+ "version": "12.0.2",
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/public/client.js CHANGED
@@ -886,6 +886,25 @@ function toDatetimeLocalValue(date) {
886
886
  }
887
887
 
888
888
  async function init(selector) {
889
+
890
+ const container = typeof selector === 'string' ? document.querySelector(selector) : selector;
891
+ if (!container) return;
892
+
893
+ // Prevent double-init (ajaxify + initial load, or return from payment)
894
+ if (container.dataset && container.dataset.onekiteCalendarInit === '1') {
895
+ // Still refresh the custom button label in case FC rerendered
896
+ try { refreshDesktopModeButton(); } catch (e) {}
897
+ return;
898
+ }
899
+ if (container.dataset) container.dataset.onekiteCalendarInit = '1';
900
+
901
+ // If a previous instance exists (shouldn't, but happens in some navigation flows), destroy it.
902
+ try {
903
+ if (window.oneKiteCalendar && typeof window.oneKiteCalendar.destroy === 'function') {
904
+ window.oneKiteCalendar.destroy();
905
+ }
906
+ window.oneKiteCalendar = null;
907
+ } catch (e) {}
889
908
  const el = document.querySelector(selector);
890
909
  if (!el) {
891
910
  return;
@@ -1063,6 +1082,7 @@ function toDatetimeLocalValue(date) {
1063
1082
 
1064
1083
  const calendar = new FullCalendar.Calendar(el, {
1065
1084
  initialView: 'dayGridMonth',
1085
+ windowResize: function () { try { refreshDesktopModeButton(); } catch (e) {} },
1066
1086
  height: 'auto',
1067
1087
  contentHeight: 'auto',
1068
1088
  aspectRatio: computeAspectRatio(),
@@ -1671,6 +1691,21 @@ function toDatetimeLocalValue(date) {
1671
1691
 
1672
1692
  calendar.render();
1673
1693
 
1694
+ // Keep the custom button label stable even if FullCalendar rerenders the toolbar
1695
+ try {
1696
+ const toolbar = container.querySelector('.fc-toolbar');
1697
+ if (toolbar && !toolbar.__oneKiteObserved) {
1698
+ toolbar.__oneKiteObserved = true;
1699
+ const mo = new MutationObserver(() => {
1700
+ try { refreshDesktopModeButton(); } catch (e) {}
1701
+ });
1702
+ mo.observe(toolbar, { childList: true, subtree: true, characterData: true });
1703
+ // Disconnect after a while to avoid permanent overhead
1704
+ setTimeout(() => { try { mo.disconnect(); } catch (e) {} }, 15000);
1705
+ }
1706
+ } catch (e) {}
1707
+
1708
+
1674
1709
  refreshDesktopModeButton();
1675
1710
 
1676
1711
 
@@ -1765,7 +1800,7 @@ function toDatetimeLocalValue(date) {
1765
1800
  // Auto-init on /calendar when ajaxify finishes rendering.
1766
1801
  function autoInit(data) {
1767
1802
  try {
1768
- const tpl = data && data.template ? data.template.name : (ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '');
1803
+ const tpl = (data && data.template && data.template.name) || (ajaxify && ajaxify.data && ajaxify.data.template && ajaxify.data.template.name) || '';
1769
1804
  if (tpl === 'calendar-onekite') {
1770
1805
  init('#onekite-calendar');
1771
1806
  }