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 +11 -75
- package/lib/api.js +16 -135
- package/lib/db.js +15 -0
- package/lib/scheduler.js +20 -55
- package/lib/utils/mailer.js +59 -0
- package/lib/utils/permissions.js +73 -0
- package/lib/utils/time.js +32 -0
- package/lib/utils/url.js +36 -0
- package/package.json +1 -1
- package/public/client.js +36 -1
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
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
|
+
};
|
package/lib/utils/url.js
ADDED
|
@@ -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
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
|
|
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
|
}
|