nodebb-plugin-onekite-calendar 2.0.83 → 2.0.85
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 +110 -37
- package/lib/helloasso.js +11 -1
- package/library.js +1 -0
- package/package.json +1 -1
- package/public/admin.js +138 -27
package/lib/admin.js
CHANGED
|
@@ -8,7 +8,6 @@ const nconf = require.main.require('nconf');
|
|
|
8
8
|
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const {
|
|
11
|
-
forumBaseUrl,
|
|
12
11
|
formatFR,
|
|
13
12
|
sendEmail,
|
|
14
13
|
buildCalendarLinks,
|
|
@@ -111,39 +110,67 @@ admin.approveReservation = async function (req, res) {
|
|
|
111
110
|
let paymentUrl = null;
|
|
112
111
|
if (token) {
|
|
113
112
|
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
114
|
-
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
115
|
-
const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
116
|
-
const base = forumBaseUrl();
|
|
117
|
-
const returnUrl = base ? `${base}/calendar` : '';
|
|
118
|
-
const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
|
|
119
113
|
const year = new Date(Number(r.start)).getFullYear();
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (intent && intent.checkoutIntentId) {
|
|
144
|
-
r.checkoutIntentId = intent.checkoutIntentId;
|
|
114
|
+
const formSlug = autoFormSlugForYear(year);
|
|
115
|
+
|
|
116
|
+
// Recompute total from HelloAsso catalog (same logic as api.js approve path)
|
|
117
|
+
// to ensure the checkout amount is always up-to-date with actual item prices.
|
|
118
|
+
let recomputedTotalCents = null;
|
|
119
|
+
try {
|
|
120
|
+
const { items: catalog } = await helloasso.listCatalogItems({
|
|
121
|
+
env,
|
|
122
|
+
token,
|
|
123
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
124
|
+
formType: settings.helloassoFormType,
|
|
125
|
+
formSlug,
|
|
126
|
+
});
|
|
127
|
+
const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
|
|
128
|
+
const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
|
|
129
|
+
const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
|
|
130
|
+
const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(id) || 0), 0);
|
|
131
|
+
if (sumCentsPerDay > 0) {
|
|
132
|
+
recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
|
|
133
|
+
r.total = recomputedTotalCents / 100;
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
// ignore recompute failures; fallback to stored total
|
|
145
137
|
}
|
|
138
|
+
|
|
139
|
+
const totalAmount = (typeof recomputedTotalCents === 'number')
|
|
140
|
+
? recomputedTotalCents
|
|
141
|
+
: Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
142
|
+
|
|
143
|
+
if (!totalAmount) {
|
|
144
|
+
console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve ACP) — skipping checkout intent', { rid, total: r.total });
|
|
145
|
+
} else {
|
|
146
|
+
const returnUrl = normalizeReturnUrl();
|
|
147
|
+
const webhookUrl = normalizeCallbackUrl(settings.helloassoCallbackUrl);
|
|
148
|
+
const intent = await helloasso.createCheckoutIntent({
|
|
149
|
+
env,
|
|
150
|
+
token,
|
|
151
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
152
|
+
formType: settings.helloassoFormType,
|
|
153
|
+
formSlug,
|
|
154
|
+
totalAmount,
|
|
155
|
+
payerEmail: requester && requester.email,
|
|
156
|
+
callbackUrl: returnUrl,
|
|
157
|
+
webhookUrl: webhookUrl,
|
|
158
|
+
itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
|
|
159
|
+
containsDonation: false,
|
|
160
|
+
metadata: {
|
|
161
|
+
reservationId: String(rid),
|
|
162
|
+
items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
|
|
163
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
|
|
167
|
+
? (intent.paymentUrl || intent.redirectUrl)
|
|
168
|
+
: (typeof intent === 'string' ? intent : null);
|
|
169
|
+
if (intent && intent.checkoutIntentId) {
|
|
170
|
+
r.checkoutIntentId = intent.checkoutIntentId;
|
|
171
|
+
}
|
|
146
172
|
}
|
|
173
|
+
}
|
|
147
174
|
|
|
148
175
|
if (paymentUrl) {
|
|
149
176
|
r.paymentUrl = paymentUrl;
|
|
@@ -335,6 +362,20 @@ admin.purgeSpecialEventsByYear = async function (req, res) {
|
|
|
335
362
|
return res.json({ ok: true, removed: count });
|
|
336
363
|
};
|
|
337
364
|
|
|
365
|
+
admin.listGroups = async function (req, res) {
|
|
366
|
+
try {
|
|
367
|
+
const Groups = require.main.require('./src/groups');
|
|
368
|
+
const q = String((req.query && req.query.q) || '').trim().toLowerCase();
|
|
369
|
+
const names = await Groups.getGroups('groups:createtime', 0, 500);
|
|
370
|
+
const filtered = q
|
|
371
|
+
? (names || []).filter(n => n && String(n).toLowerCase().includes(q))
|
|
372
|
+
: (names || []).filter(Boolean);
|
|
373
|
+
res.json(filtered.slice(0, 100));
|
|
374
|
+
} catch (e) {
|
|
375
|
+
res.json([]);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
338
379
|
admin.purgeOutingsByYear = async function (req, res) {
|
|
339
380
|
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
340
381
|
if (!/^\d{4}$/.test(year)) {
|
|
@@ -441,6 +482,44 @@ admin.debugHelloAsso = async function (req, res) {
|
|
|
441
482
|
out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
|
|
442
483
|
}
|
|
443
484
|
|
|
485
|
+
// Test checkout intent creation with a 1-cent amount to validate URL format.
|
|
486
|
+
// This creates a real (but minimal) intent — it will expire quickly and can be ignored.
|
|
487
|
+
out.checkoutIntentTest = { ok: false };
|
|
488
|
+
try {
|
|
489
|
+
const callbackUrl = normalizeReturnUrl();
|
|
490
|
+
const webhookUrl = normalizeCallbackUrl(settings.helloassoCallbackUrl);
|
|
491
|
+
if (!callbackUrl) {
|
|
492
|
+
out.checkoutIntentTest = { ok: false, error: 'callbackUrl empty — forum base URL not configured' };
|
|
493
|
+
} else {
|
|
494
|
+
const intent = await helloasso.createCheckoutIntent({
|
|
495
|
+
env,
|
|
496
|
+
token,
|
|
497
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
498
|
+
formType: settings.helloassoFormType,
|
|
499
|
+
formSlug: `locations-materiel-${new Date().getFullYear()}`,
|
|
500
|
+
totalAmount: 100,
|
|
501
|
+
payerEmail: '',
|
|
502
|
+
callbackUrl,
|
|
503
|
+
webhookUrl,
|
|
504
|
+
itemName: '[TEST] Vérification lien paiement',
|
|
505
|
+
containsDonation: false,
|
|
506
|
+
metadata: { debug: 'true' },
|
|
507
|
+
});
|
|
508
|
+
if (intent && intent.paymentUrl) {
|
|
509
|
+
out.checkoutIntentTest = {
|
|
510
|
+
ok: true,
|
|
511
|
+
checkoutIntentId: intent.checkoutIntentId,
|
|
512
|
+
paymentUrl: intent.paymentUrl,
|
|
513
|
+
note: 'Cet intent de test (1 cent) peut être ignoré — il expirera automatiquement',
|
|
514
|
+
};
|
|
515
|
+
} else {
|
|
516
|
+
out.checkoutIntentTest = { ok: false, error: 'no paymentUrl in response', raw: intent };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} catch (e) {
|
|
520
|
+
out.checkoutIntentTest = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
521
|
+
}
|
|
522
|
+
|
|
444
523
|
return res.json(out);
|
|
445
524
|
} catch (e) {
|
|
446
525
|
out.ok = false;
|
|
@@ -642,12 +721,6 @@ admin.getAccounting = async function (req, res) {
|
|
|
642
721
|
};
|
|
643
722
|
|
|
644
723
|
admin.exportAccountingCsv = async function (req, res) {
|
|
645
|
-
// Reuse the same logic and emit a CSV.
|
|
646
|
-
const fakeRes = { json: (x) => x };
|
|
647
|
-
const data = await admin.getAccounting(req, fakeRes);
|
|
648
|
-
// If getAccounting returned via res.json, data is undefined; rebuild by calling logic directly.
|
|
649
|
-
// Easiest: call getAccounting's internals by fetching the endpoint logic via HTTP is not possible here.
|
|
650
|
-
// So we re-run getAccounting but capture output by monkeypatching.
|
|
651
724
|
let payload;
|
|
652
725
|
await admin.getAccounting(req, { json: (x) => { payload = x; return x; } });
|
|
653
726
|
if (!payload || !payload.ok) {
|
package/lib/helloasso.js
CHANGED
|
@@ -306,7 +306,17 @@ async function createCheckoutIntent({ env, token, organizationSlug, formType, fo
|
|
|
306
306
|
};
|
|
307
307
|
const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
|
|
308
308
|
if (status >= 200 && status < 300 && json) {
|
|
309
|
-
|
|
309
|
+
const redirectUrl = json.redirectUrl || json.checkoutUrl || json.url || null;
|
|
310
|
+
// Always log so the URL format is visible in NodeBB logs (helps diagnose 404s).
|
|
311
|
+
// eslint-disable-next-line no-console
|
|
312
|
+
console.log('[calendar-onekite] HelloAsso checkout-intent created', {
|
|
313
|
+
status,
|
|
314
|
+
checkoutIntentId: json.id || json.checkoutIntentId || null,
|
|
315
|
+
redirectUrl,
|
|
316
|
+
env,
|
|
317
|
+
organizationSlug,
|
|
318
|
+
});
|
|
319
|
+
return { paymentUrl: redirectUrl, checkoutIntentId: (json.id || json.checkoutIntentId || null), raw: json };
|
|
310
320
|
}
|
|
311
321
|
// Log the error payload to help diagnose configuration issues (slug, env, urls, amount, etc.)
|
|
312
322
|
try {
|
package/library.js
CHANGED
|
@@ -107,6 +107,7 @@ Plugin.init = async function (params) {
|
|
|
107
107
|
router.get(`${adminBase}/settings`, ...adminMws, admin.getSettings);
|
|
108
108
|
router.put(`${adminBase}/settings`, ...adminMws, admin.saveSettings);
|
|
109
109
|
|
|
110
|
+
router.get(`${adminBase}/groups`, ...adminMws, admin.listGroups);
|
|
110
111
|
router.get(`${adminBase}/pending`, ...adminMws, admin.listPending);
|
|
111
112
|
router.put(`${adminBase}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
|
|
112
113
|
router.put(`${adminBase}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
|
package/package.json
CHANGED
package/public/admin.js
CHANGED
|
@@ -345,6 +345,129 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
345
345
|
});
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
// Cached list of NodeBB groups (fetched once per ACP session)
|
|
349
|
+
let groupsCache = null;
|
|
350
|
+
|
|
351
|
+
async function loadGroups() {
|
|
352
|
+
if (groupsCache) return groupsCache;
|
|
353
|
+
try {
|
|
354
|
+
const data = await fetchJson('/api/v3/admin/plugins/calendar-onekite/groups');
|
|
355
|
+
groupsCache = Array.isArray(data) ? data : [];
|
|
356
|
+
} catch (e) {
|
|
357
|
+
groupsCache = [];
|
|
358
|
+
}
|
|
359
|
+
return groupsCache;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Autocomplete for CSV group inputs (creatorGroups, validatorGroups, etc.)
|
|
363
|
+
// Reuses the same .onekite-autocomplete-* CSS as address autocomplete.
|
|
364
|
+
function attachGroupCsvAutocomplete(inputEl) {
|
|
365
|
+
if (!inputEl || inputEl.getAttribute('data-onekite-group-ac') === '1') return;
|
|
366
|
+
inputEl.setAttribute('data-onekite-group-ac', '1');
|
|
367
|
+
|
|
368
|
+
const anchor = (inputEl.closest && inputEl.closest('.input-group'))
|
|
369
|
+
? inputEl.closest('.input-group')
|
|
370
|
+
: (inputEl.parentNode || document.body);
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const cs = window.getComputedStyle(anchor);
|
|
374
|
+
if (!cs || cs.position === 'static') anchor.style.position = 'relative';
|
|
375
|
+
} catch (e) {
|
|
376
|
+
anchor.style.position = 'relative';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const menu = document.createElement('div');
|
|
380
|
+
menu.className = 'onekite-autocomplete-menu';
|
|
381
|
+
menu.style.position = 'absolute';
|
|
382
|
+
menu.style.left = '0';
|
|
383
|
+
menu.style.right = '0';
|
|
384
|
+
menu.style.top = '100%';
|
|
385
|
+
menu.style.zIndex = '2000';
|
|
386
|
+
menu.style.borderTop = '0';
|
|
387
|
+
menu.style.maxHeight = '220px';
|
|
388
|
+
menu.style.overflowY = 'auto';
|
|
389
|
+
menu.style.display = 'none';
|
|
390
|
+
menu.style.borderRadius = '0 0 .375rem .375rem';
|
|
391
|
+
anchor.appendChild(menu);
|
|
392
|
+
|
|
393
|
+
function hide() {
|
|
394
|
+
menu.style.display = 'none';
|
|
395
|
+
menu.innerHTML = '';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function getCurrentToken() {
|
|
399
|
+
const val = inputEl.value;
|
|
400
|
+
const cursor = typeof inputEl.selectionStart === 'number' ? inputEl.selectionStart : val.length;
|
|
401
|
+
const before = val.slice(0, cursor);
|
|
402
|
+
const commaIdx = before.lastIndexOf(',');
|
|
403
|
+
return before.slice(commaIdx + 1).trimStart();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function replaceCurrentToken(replacement) {
|
|
407
|
+
const val = inputEl.value;
|
|
408
|
+
const cursor = typeof inputEl.selectionStart === 'number' ? inputEl.selectionStart : val.length;
|
|
409
|
+
const before = val.slice(0, cursor);
|
|
410
|
+
const after = val.slice(cursor);
|
|
411
|
+
const commaIdx = before.lastIndexOf(',');
|
|
412
|
+
const prefix = commaIdx >= 0 ? before.slice(0, commaIdx + 1) + ' ' : '';
|
|
413
|
+
const rest = after.replace(/^[\s,]+/, '');
|
|
414
|
+
inputEl.value = prefix + replacement + (rest ? ', ' + rest : '');
|
|
415
|
+
const newPos = prefix.length + replacement.length;
|
|
416
|
+
try { inputEl.setSelectionRange(newPos, newPos); } catch (e) {}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function showSuggestions() {
|
|
420
|
+
const token = getCurrentToken();
|
|
421
|
+
if (token.length < 1) { hide(); return; }
|
|
422
|
+
const groups = await loadGroups();
|
|
423
|
+
const q = token.toLowerCase();
|
|
424
|
+
const existing = new Set(
|
|
425
|
+
inputEl.value.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
|
426
|
+
);
|
|
427
|
+
const matches = groups
|
|
428
|
+
.filter(g => String(g).toLowerCase().includes(q) && !existing.has(String(g).toLowerCase()))
|
|
429
|
+
.slice(0, 8);
|
|
430
|
+
if (!matches.length) { hide(); return; }
|
|
431
|
+
menu.innerHTML = '';
|
|
432
|
+
matches.forEach(g => {
|
|
433
|
+
const btn = document.createElement('button');
|
|
434
|
+
btn.type = 'button';
|
|
435
|
+
btn.className = 'onekite-autocomplete-item';
|
|
436
|
+
btn.textContent = g;
|
|
437
|
+
btn.style.display = 'block';
|
|
438
|
+
btn.style.width = '100%';
|
|
439
|
+
btn.style.textAlign = 'left';
|
|
440
|
+
btn.style.padding = '.35rem .5rem';
|
|
441
|
+
btn.style.border = '0';
|
|
442
|
+
btn.style.cursor = 'pointer';
|
|
443
|
+
btn.addEventListener('mousedown', (e) => {
|
|
444
|
+
e.preventDefault();
|
|
445
|
+
replaceCurrentToken(g);
|
|
446
|
+
hide();
|
|
447
|
+
inputEl.focus();
|
|
448
|
+
});
|
|
449
|
+
menu.appendChild(btn);
|
|
450
|
+
});
|
|
451
|
+
menu.style.display = 'block';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let timer = null;
|
|
455
|
+
inputEl.addEventListener('input', () => {
|
|
456
|
+
if (timer) clearTimeout(timer);
|
|
457
|
+
timer = setTimeout(showSuggestions, 150);
|
|
458
|
+
});
|
|
459
|
+
inputEl.addEventListener('click', () => {
|
|
460
|
+
if (timer) clearTimeout(timer);
|
|
461
|
+
timer = setTimeout(showSuggestions, 100);
|
|
462
|
+
});
|
|
463
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
464
|
+
if (e.key === 'Escape') hide();
|
|
465
|
+
});
|
|
466
|
+
document.addEventListener('click', (e) => {
|
|
467
|
+
try { if (!anchor.contains(e.target)) hide(); } catch (err) {}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
348
471
|
function formToObject(form) {
|
|
349
472
|
const out = {};
|
|
350
473
|
new FormData(form).forEach((v, k) => {
|
|
@@ -546,31 +669,17 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
546
669
|
}
|
|
547
670
|
|
|
548
671
|
async function purgeSpecialEvents(year) {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
});
|
|
554
|
-
} catch (e) {
|
|
555
|
-
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/special-events/purge', {
|
|
556
|
-
method: 'POST',
|
|
557
|
-
body: JSON.stringify({ year }),
|
|
558
|
-
});
|
|
559
|
-
}
|
|
672
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/special-events/purge', {
|
|
673
|
+
method: 'POST',
|
|
674
|
+
body: JSON.stringify({ year }),
|
|
675
|
+
});
|
|
560
676
|
}
|
|
561
677
|
|
|
562
678
|
async function purgeOutings(year) {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
});
|
|
568
|
-
} catch (e) {
|
|
569
|
-
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/outings/purge', {
|
|
570
|
-
method: 'POST',
|
|
571
|
-
body: JSON.stringify({ year }),
|
|
572
|
-
});
|
|
573
|
-
}
|
|
679
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/outings/purge', {
|
|
680
|
+
method: 'POST',
|
|
681
|
+
body: JSON.stringify({ year }),
|
|
682
|
+
});
|
|
574
683
|
}
|
|
575
684
|
|
|
576
685
|
async function markPaid(rid, payload) {
|
|
@@ -581,11 +690,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
581
690
|
}
|
|
582
691
|
|
|
583
692
|
async function debugHelloAsso() {
|
|
584
|
-
|
|
585
|
-
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
|
|
586
|
-
} catch (e) {
|
|
587
|
-
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
|
|
588
|
-
}
|
|
693
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
|
|
589
694
|
}
|
|
590
695
|
|
|
591
696
|
async function loadAccounting(from, to) {
|
|
@@ -617,6 +722,12 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
617
722
|
document.head.appendChild(style);
|
|
618
723
|
})();
|
|
619
724
|
|
|
725
|
+
// Attach group autocomplete to all CSV group fields (loads groups list lazily on first keystroke)
|
|
726
|
+
['creatorGroups', 'validatorGroups', 'notifyGroups', 'specialCreatorGroups', 'specialDeleterGroups'].forEach(name => {
|
|
727
|
+
const el = form.querySelector(`[name="${name}"]`);
|
|
728
|
+
if (el) attachGroupCsvAutocomplete(el);
|
|
729
|
+
});
|
|
730
|
+
|
|
620
731
|
// Load settings
|
|
621
732
|
try {
|
|
622
733
|
const s = await loadSettings();
|