nodebb-plugin-calendar-onekite 11.1.43 → 11.1.45
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 +40 -23
- package/lib/api.js +14 -65
- package/lib/helloasso.js +1 -0
- package/lib/helloassoWebhook.js +130 -3
- package/lib/scheduler.js +98 -1
- package/library.js +9 -7
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/admin.js +9 -0
- package/public/client.js +9 -16
- package/templates/emails/calendar-onekite_expired.tpl +13 -0
- package/templates/emails/calendar-onekite_paid.tpl +15 -0
- package/templates/emails/calendar-onekite_reminder.tpl +21 -0
- package/templates/emails/calendar-onekite_cancelled.tpl +0 -11
package/lib/admin.js
CHANGED
|
@@ -84,35 +84,52 @@ admin.approveReservation = async function (req, res) {
|
|
|
84
84
|
// Create HelloAsso payment link if configured
|
|
85
85
|
const settings = await meta.settings.get('calendar-onekite');
|
|
86
86
|
const env = settings.helloassoEnv || 'prod';
|
|
87
|
-
const token = await helloasso.getAccessToken({
|
|
88
|
-
env,
|
|
89
|
-
clientId: settings.helloassoClientId,
|
|
90
|
-
clientSecret: settings.helloassoClientSecret,
|
|
91
|
-
});
|
|
92
|
-
|
|
93
87
|
let paymentUrl = null;
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
88
|
+
try {
|
|
89
|
+
const hasHelloAssoConfig = !!(
|
|
90
|
+
settings &&
|
|
91
|
+
settings.helloassoClientId &&
|
|
92
|
+
settings.helloassoClientSecret &&
|
|
93
|
+
settings.helloassoOrganizationSlug
|
|
94
|
+
);
|
|
95
|
+
if (hasHelloAssoConfig) {
|
|
96
|
+
const token = await helloasso.getAccessToken({
|
|
97
|
+
env,
|
|
98
|
+
clientId: settings.helloassoClientId,
|
|
99
|
+
clientSecret: settings.helloassoClientSecret,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (token) {
|
|
103
|
+
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
104
|
+
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
105
|
+
const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
106
|
+
// Default callback: forum base url if available, otherwise empty.
|
|
107
|
+
const callbackUrl =
|
|
108
|
+
(settings.helloassoCallbackUrl || (meta.config && meta.config.url ? `${meta.config.url}/helloasso` : ''));
|
|
109
|
+
paymentUrl = await helloasso.createCheckoutIntent({
|
|
110
|
+
env,
|
|
111
|
+
token,
|
|
112
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
113
|
+
formType: settings.helloassoFormType,
|
|
114
|
+
formSlug: settings.helloassoFormSlug,
|
|
115
|
+
totalAmount,
|
|
116
|
+
payerEmail: requester && requester.email,
|
|
117
|
+
callbackUrl,
|
|
118
|
+
itemName: 'Réservation matériel OneKite',
|
|
119
|
+
containsDonation: false,
|
|
120
|
+
metadata: { reservationId: String(rid) },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// Do not block approval if HelloAsso is not configured or temporarily unavailable.
|
|
126
|
+
console.warn('[calendar-onekite] HelloAsso error during approval (ACP)', { rid, err: String(e && e.message || e) });
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
if (paymentUrl) {
|
|
114
130
|
r.paymentUrl = paymentUrl;
|
|
115
131
|
} else {
|
|
132
|
+
// This is expected when HelloAsso is not configured.
|
|
116
133
|
console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
|
|
117
134
|
}
|
|
118
135
|
|
package/lib/api.js
CHANGED
|
@@ -41,23 +41,6 @@ async function sendEmail(template, toEmail, subject, data) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
async function getGroupEmails(groupNamesCsv) {
|
|
45
|
-
const names = String(groupNamesCsv || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
46
|
-
if (!names.length) return [];
|
|
47
|
-
const emails = new Set();
|
|
48
|
-
for (const g of names) {
|
|
49
|
-
try {
|
|
50
|
-
const members = await groups.getMembers(g, 0, -1);
|
|
51
|
-
for (const memberUid of (members || [])) {
|
|
52
|
-
const md = await user.getUserFields(memberUid, ['email']);
|
|
53
|
-
if (md && md.email) emails.add(md.email);
|
|
54
|
-
}
|
|
55
|
-
} catch (e) {}
|
|
56
|
-
}
|
|
57
|
-
return Array.from(emails);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
44
|
function overlap(aStart, aEnd, bStart, bEnd) {
|
|
62
45
|
return aStart < bEnd && bStart < aEnd;
|
|
63
46
|
}
|
|
@@ -88,6 +71,15 @@ async function canRequest(uid, settings) {
|
|
|
88
71
|
}
|
|
89
72
|
|
|
90
73
|
async function canValidate(uid, settings) {
|
|
74
|
+
// Always allow forum administrators (and global moderators) to validate,
|
|
75
|
+
// even if validatorGroups is empty.
|
|
76
|
+
try {
|
|
77
|
+
const isAdmin = await groups.isMember(uid, 'administrators');
|
|
78
|
+
if (isAdmin) return true;
|
|
79
|
+
const isGlobalMod = await groups.isMember(uid, 'Global Moderators');
|
|
80
|
+
if (isGlobalMod) return true;
|
|
81
|
+
} catch (e) {}
|
|
82
|
+
|
|
91
83
|
const allowed = (settings.validatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
92
84
|
if (!allowed.length) return false;
|
|
93
85
|
for (const g of allowed) {
|
|
@@ -161,8 +153,6 @@ api.getEvents = async function (req, res) {
|
|
|
161
153
|
ev.extendedProps.canModerate = canMod;
|
|
162
154
|
ev.extendedProps.total = r.total || 0;
|
|
163
155
|
ev.extendedProps.createdAt = r.createdAt || null;
|
|
164
|
-
ev.extendedProps.canCancel = !!req.uid && String(req.uid) === String(r.uid) && r.status !== 'paid';
|
|
165
|
-
|
|
166
156
|
out.push(ev);
|
|
167
157
|
}
|
|
168
158
|
}
|
|
@@ -224,7 +214,7 @@ api.createReservation = async function (req, res) {
|
|
|
224
214
|
}
|
|
225
215
|
|
|
226
216
|
// Prevent double booking: block if any selected item overlaps with an active reservation
|
|
227
|
-
const blocking = new Set(['pending', 'awaiting_payment', '
|
|
217
|
+
const blocking = new Set(['pending', 'awaiting_payment', 'paid']);
|
|
228
218
|
const wideStart2 = Math.max(0, start - 366 * 24 * 3600 * 1000);
|
|
229
219
|
const candidateIds = await dbLayer.listReservationIdsByStartRange(wideStart2, end, 5000);
|
|
230
220
|
const conflicts = [];
|
|
@@ -299,49 +289,6 @@ api.createReservation = async function (req, res) {
|
|
|
299
289
|
res.json({ ok: true, rid });
|
|
300
290
|
};
|
|
301
291
|
|
|
302
|
-
api.cancelReservationByUser = async function (req, res) {
|
|
303
|
-
const uid = req.uid;
|
|
304
|
-
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
305
|
-
|
|
306
|
-
const rid = req.params.rid;
|
|
307
|
-
if (!rid) return res.status(400).json({ error: 'missing-rid' });
|
|
308
|
-
|
|
309
|
-
const r = await dbLayer.getReservation(rid);
|
|
310
|
-
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
311
|
-
|
|
312
|
-
if (String(r.uid) !== String(uid)) return res.status(403).json({ error: 'forbidden' });
|
|
313
|
-
if (r.status === 'paid') return res.status(400).json({ error: 'already-paid' });
|
|
314
|
-
|
|
315
|
-
const settings = await meta.settings.get('calendar-onekite');
|
|
316
|
-
await dbLayer.removeReservation(rid);
|
|
317
|
-
|
|
318
|
-
try {
|
|
319
|
-
const requester = await user.getUserFields(uid, ['username', 'email']);
|
|
320
|
-
const itemsLabel = (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).join(', ');
|
|
321
|
-
const validatorEmails = await getGroupEmails(settings.validatorGroups);
|
|
322
|
-
|
|
323
|
-
const data = {
|
|
324
|
-
username: requester?.username || 'Utilisateur',
|
|
325
|
-
startDate: formatFR(r.start),
|
|
326
|
-
endDate: formatFR(r.end),
|
|
327
|
-
itemsLabel,
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
const subject = 'Annulation de réservation de matériel';
|
|
331
|
-
|
|
332
|
-
if (requester?.email) {
|
|
333
|
-
await sendEmail('calendar-onekite_cancelled', requester.email, subject, data);
|
|
334
|
-
}
|
|
335
|
-
for (const email of validatorEmails) {
|
|
336
|
-
await sendEmail('calendar-onekite_cancelled', email, subject, data);
|
|
337
|
-
}
|
|
338
|
-
} catch (e) {
|
|
339
|
-
console.warn('[calendar-onekite] Failed to send cancellation email', e?.message || e);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
return res.json({ ok: true });
|
|
343
|
-
};
|
|
344
|
-
|
|
345
292
|
// Validator actions (from calendar popup)
|
|
346
293
|
api.approveReservation = async function (req, res) {
|
|
347
294
|
const uid = req.uid;
|
|
@@ -373,7 +320,9 @@ api.approveReservation = async function (req, res) {
|
|
|
373
320
|
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
374
321
|
totalAmount: Math.max(0, Math.round((Number(r.total) || 0) * 100)),
|
|
375
322
|
payerEmail: payer && payer.email ? payer.email : '',
|
|
376
|
-
|
|
323
|
+
// By default, point to the forum base url so the webhook hits this NodeBB instance.
|
|
324
|
+
// Can be overridden via ACP setting `helloassoCallbackUrl`.
|
|
325
|
+
callbackUrl: (settings2.helloassoCallbackUrl || (meta.config && meta.config.url ? `${meta.config.url}/helloasso` : '')),
|
|
377
326
|
itemName: 'Réservation matériel OneKite',
|
|
378
327
|
containsDonation: false,
|
|
379
328
|
metadata: { reservationId: String(rid) },
|
|
@@ -422,7 +371,7 @@ api.refuseReservation = async function (req, res) {
|
|
|
422
371
|
|
|
423
372
|
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
424
373
|
if (requester && requester.email) {
|
|
425
|
-
await sendEmail('calendar-onekite_refused', requester.email, 'Demande de réservation matériel
|
|
374
|
+
await sendEmail('calendar-onekite_refused', requester.email, 'Demande de réservation de matériel', {
|
|
426
375
|
username: requester.username,
|
|
427
376
|
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
428
377
|
start: formatFR(r.start),
|
package/lib/helloasso.js
CHANGED
|
@@ -188,6 +188,7 @@ async function createCheckoutIntent({ env, token, organizationSlug, formType, fo
|
|
|
188
188
|
backUrl: callbackUrl || '',
|
|
189
189
|
errorUrl: callbackUrl || '',
|
|
190
190
|
returnUrl: callbackUrl || '',
|
|
191
|
+
notificationUrl: callbackUrl || '',
|
|
191
192
|
};
|
|
192
193
|
const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
|
|
193
194
|
if (status >= 200 && status < 300 && json) {
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -4,12 +4,64 @@ const crypto = require('crypto');
|
|
|
4
4
|
|
|
5
5
|
const db = require.main.require('./src/database');
|
|
6
6
|
const meta = require.main.require('./src/meta');
|
|
7
|
+
const user = require.main.require('./src/user');
|
|
8
|
+
|
|
9
|
+
const dbLayer = require('./db');
|
|
7
10
|
|
|
8
11
|
const SETTINGS_KEY = 'calendar-onekite';
|
|
9
12
|
|
|
10
13
|
// Replay protection: store processed payment ids.
|
|
11
14
|
const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
|
|
12
15
|
|
|
16
|
+
async function sendEmail(template, toEmail, subject, data) {
|
|
17
|
+
if (!toEmail) return;
|
|
18
|
+
const emailer = require.main.require('./src/emailer');
|
|
19
|
+
try {
|
|
20
|
+
if (typeof emailer.sendToEmail === 'function') {
|
|
21
|
+
await emailer.sendToEmail(template, toEmail, subject, data);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (typeof emailer.send === 'function') {
|
|
25
|
+
if (emailer.send.length >= 4) {
|
|
26
|
+
await emailer.send(template, toEmail, subject, data);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (emailer.send.length === 3) {
|
|
30
|
+
await emailer.send(template, toEmail, data);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await emailer.send(template, toEmail, subject, data);
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
// eslint-disable-next-line no-console
|
|
37
|
+
console.warn('[calendar-onekite] Failed to send email (webhook)', { template, toEmail, err: String(err && err.message || err) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatFR(tsOrIso) {
|
|
42
|
+
const d = new Date(tsOrIso);
|
|
43
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
44
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
45
|
+
const yyyy = d.getFullYear();
|
|
46
|
+
return `${dd}/${mm}/${yyyy}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getReservationIdFromPayload(payload) {
|
|
50
|
+
try {
|
|
51
|
+
const metaObj = payload && payload.data ? payload.data.meta : null;
|
|
52
|
+
if (!metaObj) return null;
|
|
53
|
+
if (typeof metaObj === 'object' && metaObj.reservationId) return String(metaObj.reservationId);
|
|
54
|
+
// Some systems send meta as array of key/value pairs
|
|
55
|
+
if (Array.isArray(metaObj)) {
|
|
56
|
+
const found = metaObj.find((x) => x && (x.key === 'reservationId' || x.name === 'reservationId'));
|
|
57
|
+
if (found && (found.value || found.val)) return String(found.value || found.val);
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// ignore
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
13
65
|
function timingSafeHexEqual(aHex, bHex) {
|
|
14
66
|
if (!aHex || !bHex) return false;
|
|
15
67
|
try {
|
|
@@ -121,6 +173,51 @@ function isConfirmedPayment(payload) {
|
|
|
121
173
|
}
|
|
122
174
|
}
|
|
123
175
|
|
|
176
|
+
async function sendEmail(template, toEmail, subject, data) {
|
|
177
|
+
if (!toEmail) return;
|
|
178
|
+
const emailer = require.main.require('./src/emailer');
|
|
179
|
+
try {
|
|
180
|
+
if (typeof emailer.sendToEmail === 'function') {
|
|
181
|
+
await emailer.sendToEmail(template, toEmail, subject, data);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (typeof emailer.send === 'function') {
|
|
185
|
+
if (emailer.send.length >= 4) {
|
|
186
|
+
await emailer.send(template, toEmail, subject, data);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (emailer.send.length === 3) {
|
|
190
|
+
await emailer.send(template, toEmail, data);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await emailer.send(template, toEmail, subject, data);
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// eslint-disable-next-line no-console
|
|
197
|
+
console.warn('[calendar-onekite] Failed to send email (webhook)', { template, toEmail, err: String(err && err.message || err) });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatFR(tsOrIso) {
|
|
202
|
+
const d = new Date(tsOrIso);
|
|
203
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
204
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
205
|
+
const yyyy = d.getFullYear();
|
|
206
|
+
return `${dd}/${mm}/${yyyy}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getReservationIdFromPayload(payload) {
|
|
210
|
+
try {
|
|
211
|
+
const m = payload && payload.data ? payload.data.meta : null;
|
|
212
|
+
if (!m) return null;
|
|
213
|
+
if (typeof m === 'object' && m.reservationId) return String(m.reservationId);
|
|
214
|
+
if (typeof m === 'object' && m.reservationID) return String(m.reservationID);
|
|
215
|
+
return null;
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
124
221
|
/**
|
|
125
222
|
* Hardened HelloAsso webhook handler.
|
|
126
223
|
* - Requires x-ha-signature (HMAC SHA-256) verification.
|
|
@@ -154,10 +251,40 @@ async function handler(req, res, next) {
|
|
|
154
251
|
return res.json({ ok: true, duplicate: true });
|
|
155
252
|
}
|
|
156
253
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
254
|
+
const rid = getReservationIdFromPayload(payload);
|
|
255
|
+
if (!rid) {
|
|
256
|
+
await markProcessed(paymentId);
|
|
257
|
+
return res.json({ ok: true, processed: true, missingReservationId: true });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const r = await dbLayer.getReservation(rid);
|
|
261
|
+
if (!r) {
|
|
262
|
+
await markProcessed(paymentId);
|
|
263
|
+
return res.json({ ok: true, processed: true, reservationNotFound: true });
|
|
264
|
+
}
|
|
160
265
|
|
|
266
|
+
// Mark as paid and persist payment metadata.
|
|
267
|
+
r.status = 'paid';
|
|
268
|
+
r.paidAt = Date.now();
|
|
269
|
+
r.paymentId = paymentId ? String(paymentId) : '';
|
|
270
|
+
if (payload.data && payload.data.paymentReceiptUrl) {
|
|
271
|
+
r.paymentReceiptUrl = String(payload.data.paymentReceiptUrl);
|
|
272
|
+
}
|
|
273
|
+
await dbLayer.saveReservation(r);
|
|
274
|
+
|
|
275
|
+
// Notify requester
|
|
276
|
+
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
277
|
+
if (requester && requester.email) {
|
|
278
|
+
await sendEmail('calendar-onekite_paid', requester.email, 'Demande de réservation de matériel', {
|
|
279
|
+
username: requester.username,
|
|
280
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
281
|
+
start: formatFR(r.start),
|
|
282
|
+
end: formatFR(r.end),
|
|
283
|
+
paymentReceiptUrl: r.paymentReceiptUrl || '',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await markProcessed(paymentId);
|
|
161
288
|
return res.json({ ok: true, processed: true });
|
|
162
289
|
} catch (err) {
|
|
163
290
|
return next(err);
|
package/lib/scheduler.js
CHANGED
|
@@ -5,9 +5,10 @@ const dbLayer = require('./db');
|
|
|
5
5
|
|
|
6
6
|
let timer = null;
|
|
7
7
|
|
|
8
|
+
// Pending holds: short lock after a user creates a request (defaults to 5 minutes)
|
|
8
9
|
async function expirePending() {
|
|
9
10
|
const settings = await meta.settings.get('calendar-onekite');
|
|
10
|
-
const holdMins = parseInt(settings.pendingHoldMinutes ||
|
|
11
|
+
const holdMins = parseInt(settings.pendingHoldMinutes || '5', 10) || 5;
|
|
11
12
|
const now = Date.now();
|
|
12
13
|
|
|
13
14
|
const ids = await dbLayer.listAllReservationIds(5000);
|
|
@@ -29,10 +30,105 @@ async function expirePending() {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// Payment window logic:
|
|
34
|
+
// - When a reservation is validated it becomes awaiting_payment
|
|
35
|
+
// - We send a reminder after `paymentHoldMinutes` (default 60)
|
|
36
|
+
// - We expire (and remove) after `2 * paymentHoldMinutes`
|
|
37
|
+
async function processAwaitingPayment() {
|
|
38
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
39
|
+
const holdMins = parseInt(settings.paymentHoldMinutes || settings.holdMinutes || '60', 10) || 60;
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
|
|
42
|
+
const ids = await dbLayer.listAllReservationIds(5000);
|
|
43
|
+
if (!ids || !ids.length) return;
|
|
44
|
+
|
|
45
|
+
const emailer = require.main.require('./src/emailer');
|
|
46
|
+
const user = require.main.require('./src/user');
|
|
47
|
+
|
|
48
|
+
async function sendEmail(template, toEmail, subject, data) {
|
|
49
|
+
if (!toEmail) return;
|
|
50
|
+
try {
|
|
51
|
+
if (typeof emailer.sendToEmail === 'function') {
|
|
52
|
+
await emailer.sendToEmail(template, toEmail, subject, data);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (typeof emailer.send === 'function') {
|
|
56
|
+
if (emailer.send.length >= 4) {
|
|
57
|
+
await emailer.send(template, toEmail, subject, data);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (emailer.send.length === 3) {
|
|
61
|
+
await emailer.send(template, toEmail, data);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await emailer.send(template, toEmail, subject, data);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// eslint-disable-next-line no-console
|
|
68
|
+
console.warn('[calendar-onekite] Failed to send email (scheduler)', { template, toEmail, err: String(err && err.message || err) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatFR(ts) {
|
|
73
|
+
const d = new Date(ts);
|
|
74
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
75
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
76
|
+
const yyyy = d.getFullYear();
|
|
77
|
+
return `${dd}/${mm}/${yyyy}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const rid of ids) {
|
|
81
|
+
const r = await dbLayer.getReservation(rid);
|
|
82
|
+
if (!r || r.status !== 'awaiting_payment') continue;
|
|
83
|
+
|
|
84
|
+
const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
|
|
85
|
+
if (!approvedAt) continue;
|
|
86
|
+
|
|
87
|
+
const reminderAt = approvedAt + holdMins * 60 * 1000;
|
|
88
|
+
const expireAt = approvedAt + 2 * holdMins * 60 * 1000;
|
|
89
|
+
|
|
90
|
+
if (!r.reminderSent && now >= reminderAt && now < expireAt) {
|
|
91
|
+
// Send reminder once
|
|
92
|
+
const u = await user.getUserFields(r.uid, ['username', 'email']);
|
|
93
|
+
if (u && u.email) {
|
|
94
|
+
await sendEmail('calendar-onekite_reminder', u.email, 'Demande de réservation de matériel', {
|
|
95
|
+
username: u.username,
|
|
96
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
97
|
+
start: formatFR(r.start),
|
|
98
|
+
end: formatFR(r.end),
|
|
99
|
+
paymentUrl: r.paymentUrl || '',
|
|
100
|
+
delayMinutes: holdMins,
|
|
101
|
+
pickupLine: r.pickupTime ? (r.adminNote ? `${r.pickupTime} à ${r.adminNote}` : r.pickupTime) : '',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
r.reminderSent = true;
|
|
105
|
+
r.reminderAt = now;
|
|
106
|
+
await dbLayer.saveReservation(r);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (now >= expireAt) {
|
|
111
|
+
// Expire: remove reservation so it disappears from calendar and frees items
|
|
112
|
+
const u = await user.getUserFields(r.uid, ['username', 'email']);
|
|
113
|
+
if (u && u.email) {
|
|
114
|
+
await sendEmail('calendar-onekite_expired', u.email, 'Demande de réservation de matériel', {
|
|
115
|
+
username: u.username,
|
|
116
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
117
|
+
start: formatFR(r.start),
|
|
118
|
+
end: formatFR(r.end),
|
|
119
|
+
delayMinutes: holdMins,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
await dbLayer.removeReservation(rid);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
32
127
|
function start() {
|
|
33
128
|
if (timer) return;
|
|
34
129
|
timer = setInterval(() => {
|
|
35
130
|
expirePending().catch(() => {});
|
|
131
|
+
processAwaitingPayment().catch(() => {});
|
|
36
132
|
}, 60 * 1000);
|
|
37
133
|
}
|
|
38
134
|
|
|
@@ -47,4 +143,5 @@ module.exports = {
|
|
|
47
143
|
start,
|
|
48
144
|
stop,
|
|
49
145
|
expirePending,
|
|
146
|
+
processAwaitingPayment,
|
|
50
147
|
};
|
package/library.js
CHANGED
|
@@ -12,13 +12,13 @@ const api = require('./lib/api');
|
|
|
12
12
|
const admin = require('./lib/admin');
|
|
13
13
|
const scheduler = require('./lib/scheduler');
|
|
14
14
|
const helloassoWebhook = require('./lib/helloassoWebhook');
|
|
15
|
+
const bodyParser = require('body-parser');
|
|
15
16
|
|
|
16
17
|
const Plugin = {};
|
|
17
18
|
|
|
18
19
|
const isFn = (fn) => typeof fn === 'function';
|
|
19
20
|
const mw = (...fns) => fns.filter(isFn);
|
|
20
21
|
|
|
21
|
-
|
|
22
22
|
Plugin.init = async function (params) {
|
|
23
23
|
const { router, middleware } = params;
|
|
24
24
|
|
|
@@ -68,11 +68,6 @@ Plugin.init = async function (params) {
|
|
|
68
68
|
router.post(p, ...publicAuth, api.createReservation);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
['/api/v3/plugins/calendar-onekite/reservations/:rid', '/api/plugins/calendar-onekite/reservations/:rid'].forEach((p) => {
|
|
72
|
-
router.delete(p, ...publicAuth, api.cancelReservationByUser);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
|
|
76
71
|
// Validator actions from the calendar popup (requires login + validatorGroups)
|
|
77
72
|
['/api/v3/plugins/calendar-onekite/reservations/:rid/approve', '/api/plugins/calendar-onekite/reservations/:rid/approve'].forEach((p) => {
|
|
78
73
|
router.put(p, ...publicAuth, api.approveReservation);
|
|
@@ -100,7 +95,14 @@ Plugin.init = async function (params) {
|
|
|
100
95
|
// - Only accepts POST
|
|
101
96
|
// - Verifies x-ha-signature (HMAC SHA-256) using the configured client secret
|
|
102
97
|
// - Basic replay protection
|
|
103
|
-
|
|
98
|
+
// NOTE: we capture the raw body for signature verification.
|
|
99
|
+
const helloassoJson = bodyParser.json({
|
|
100
|
+
verify: (req, _res, buf) => {
|
|
101
|
+
req.rawBody = buf;
|
|
102
|
+
},
|
|
103
|
+
type: ['application/json', 'application/*+json'],
|
|
104
|
+
});
|
|
105
|
+
router.post('/helloasso', helloassoJson, helloassoWebhook.handler);
|
|
104
106
|
|
|
105
107
|
// Optional: keep GET for simple health-checks without exposing processing.
|
|
106
108
|
router.get('/helloasso', (req, res) => res.json({ ok: true }));
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "Calendar OneKite",
|
|
4
4
|
"description": "Equipment reservation calendar (FullCalendar) with admin approval & HelloAsso checkout",
|
|
5
5
|
"url": "https://www.onekite.com/calendar",
|
|
6
|
-
"
|
|
6
|
+
"library": "./library.js",
|
|
7
7
|
"hooks": [
|
|
8
8
|
{ "hook": "static:app.load", "method": "init" },
|
|
9
9
|
{ "hook": "filter:admin.header.build", "method": "addAdminNavigation" }
|
package/public/admin.js
CHANGED
|
@@ -106,7 +106,16 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
106
106
|
return out;
|
|
107
107
|
})().map(t => `<option value="${t}">${t}</option>`).join('');
|
|
108
108
|
|
|
109
|
+
const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
|
|
110
|
+
? r.itemNames
|
|
111
|
+
: [r.itemName || r.itemId].filter(Boolean);
|
|
112
|
+
const itemsHtml = itemNames.length
|
|
113
|
+
? `<ul class="mb-3">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`
|
|
114
|
+
: '';
|
|
115
|
+
|
|
109
116
|
const html = `
|
|
117
|
+
<div class="mb-2"><strong>Matériels</strong></div>
|
|
118
|
+
${itemsHtml}
|
|
110
119
|
<div class="mb-3">
|
|
111
120
|
<label class="form-label">Note (lieu de récupération)</label>
|
|
112
121
|
<textarea class="form-control" id="onekite-admin-note" placeholder="Matériel à récupérer à l'adresse : ..."></textarea>
|
package/public/client.js
CHANGED
|
@@ -273,7 +273,6 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
273
273
|
const period = `${formatDt(ev.start)} → ${formatDt(ev.end)}`;
|
|
274
274
|
|
|
275
275
|
const canModerate = !!p.canModerate;
|
|
276
|
-
const canCancel = !!p.canCancel;
|
|
277
276
|
const isPending = status === 'pending';
|
|
278
277
|
|
|
279
278
|
const baseHtml = `
|
|
@@ -313,7 +312,16 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
313
312
|
}
|
|
314
313
|
return out;
|
|
315
314
|
})().map(t => `<option value="${t}">${t}</option>`).join('');
|
|
315
|
+
const items = Array.isArray(p.itemNames) && p.itemNames.length
|
|
316
|
+
? p.itemNames
|
|
317
|
+
: (itemsLabel ? String(itemsLabel).split(',').map(s => s.trim()).filter(Boolean) : []);
|
|
318
|
+
const itemsHtml = items.length
|
|
319
|
+
? `<ul class="mb-3">${items.map(n => `<li>${String(n).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}</li>`).join('')}</ul>`
|
|
320
|
+
: '';
|
|
321
|
+
|
|
316
322
|
const html = `
|
|
323
|
+
<div class="mb-2"><strong>Matériels</strong></div>
|
|
324
|
+
${itemsHtml}
|
|
317
325
|
<div class="mb-3">
|
|
318
326
|
<label class="form-label">Note (incluse dans l'email)</label>
|
|
319
327
|
<textarea class="form-control" id="onekite-admin-note" rows="3" placeholder="Matériel à récupérer à l'adresse : ..."></textarea>
|
|
@@ -347,21 +355,6 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
347
355
|
});
|
|
348
356
|
},
|
|
349
357
|
};
|
|
350
|
-
if (canCancel) {
|
|
351
|
-
buttons.cancelUser = {
|
|
352
|
-
label: 'Annuler la réservation',
|
|
353
|
-
className: 'btn-outline-danger',
|
|
354
|
-
callback: async () => {
|
|
355
|
-
const ok = await new Promise((resolve) => bootbox.confirm('Voulez-vous vraiment annuler cette réservation ?', resolve));
|
|
356
|
-
if (!ok) return;
|
|
357
|
-
|
|
358
|
-
await fetchJson(`/api/v3/plugins/calendar-onekite/reservations/${rid}`, { method: 'DELETE' });
|
|
359
|
-
showAlert('success', 'Réservation annulée.');
|
|
360
|
-
calendar.refetchEvents();
|
|
361
|
-
},
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
|
|
365
358
|
}
|
|
366
359
|
|
|
367
360
|
bootbox.dialog({
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<p>Bonjour {username},</p>
|
|
2
|
+
|
|
3
|
+
<p>Paiement non reçu dans le délai de {delayMinutes} minutes après relance.</p>
|
|
4
|
+
|
|
5
|
+
<p>La réservation a été annulée et le matériel est de nouveau réservable.</p>
|
|
6
|
+
|
|
7
|
+
<p>
|
|
8
|
+
<strong>Matériel :</strong> {itemName}<br>
|
|
9
|
+
<strong>Du :</strong> {start}<br>
|
|
10
|
+
<strong>Au :</strong> {end}
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p>Merci,<br>OneKite</p>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<p>Bonjour {username},</p>
|
|
2
|
+
|
|
3
|
+
<p>Votre paiement a bien été reçu. Votre réservation est désormais <strong>payée</strong>.</p>
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
<strong>Matériel :</strong> {itemName}<br>
|
|
7
|
+
<strong>Du :</strong> {start}<br>
|
|
8
|
+
<strong>Au :</strong> {end}
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<!-- IF paymentReceiptUrl -->
|
|
12
|
+
<p>Reçu de paiement : {paymentReceiptUrl}</p>
|
|
13
|
+
<!-- ENDIF paymentReceiptUrl -->
|
|
14
|
+
|
|
15
|
+
<p>Merci,<br>OneKite</p>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<p>Bonjour {username},</p>
|
|
2
|
+
|
|
3
|
+
<p>Rappel : votre réservation a été validée mais le paiement n'a pas encore été reçu.</p>
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
<strong>Matériel :</strong> {itemName}<br>
|
|
7
|
+
<strong>Du :</strong> {start}<br>
|
|
8
|
+
<strong>Au :</strong> {end}
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<!-- IF pickupLine -->
|
|
12
|
+
<p><strong>Heure de récupération :</strong> {pickupLine}</p>
|
|
13
|
+
<!-- ENDIF pickupLine -->
|
|
14
|
+
|
|
15
|
+
<!-- IF paymentUrl -->
|
|
16
|
+
<p>Lien de paiement : {paymentUrl}</p>
|
|
17
|
+
<!-- ENDIF paymentUrl -->
|
|
18
|
+
|
|
19
|
+
<p>Merci d'effectuer le paiement sous {delayMinutes} minutes, sinon la réservation sera annulée.</p>
|
|
20
|
+
|
|
21
|
+
<p>Merci,<br>OneKite</p>
|