nodebb-plugin-onekite-calendar 2.0.10 → 2.0.12
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/CHANGELOG.md +28 -0
- package/lib/admin.js +21 -9
- package/lib/api.js +269 -12
- package/lib/db.js +114 -0
- package/lib/helloassoWebhook.js +28 -0
- package/library.js +7 -0
- package/package.json +1 -1
- package/pkg/package/CHANGELOG.md +102 -0
- package/pkg/package/lib/admin.js +554 -0
- package/pkg/package/lib/api.js +1458 -0
- package/pkg/package/lib/controllers.js +11 -0
- package/pkg/package/lib/db.js +224 -0
- package/pkg/package/lib/discord.js +190 -0
- package/pkg/package/lib/helloasso.js +352 -0
- package/pkg/package/lib/helloassoWebhook.js +389 -0
- package/pkg/package/lib/scheduler.js +201 -0
- package/pkg/package/lib/widgets.js +460 -0
- package/pkg/package/library.js +164 -0
- package/pkg/package/package.json +14 -0
- package/pkg/package/plugin.json +43 -0
- package/pkg/package/public/admin.js +1470 -0
- package/pkg/package/public/client.js +2185 -0
- package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +287 -0
- package/pkg/package/templates/calendar-onekite.tpl +51 -0
- package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
- package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
- package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
- package/plugin.json +1 -1
- package/public/admin.js +197 -3
- package/public/client.js +195 -7
- package/templates/admin/plugins/calendar-onekite.tpl +63 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog – calendar-onekite
|
|
2
2
|
|
|
3
|
+
## 1.3.5
|
|
4
|
+
- ACP : ajout d’un paramètre « Location longue durée (jours) pour validateurs ». Si une réservation faite par un validateur dépasse ce nombre de jours, elle redevient payante et suit le workflow normal (demande → validation → paiement HelloAsso). Mets 0 pour conserver le comportement « toujours gratuit ».
|
|
5
|
+
|
|
6
|
+
## 1.3.4
|
|
7
|
+
- Réservations gratuites (validateurs) : ne sont plus comptées comme chiffre d’affaires dans la comptabilisation. Elles apparaissent désormais sur une ligne séparée « Sorties gratuites » (et sont marquées (gratuit) dans le détail).
|
|
8
|
+
|
|
9
|
+
## 1.3.3
|
|
10
|
+
- Comptabilité : les réservations « auto-checkées » (réservations faites par un validateur) recalculent désormais le total côté serveur (catalogue HelloAsso × nb jours calendaires) afin d’être comptabilisées correctement.
|
|
11
|
+
|
|
12
|
+
## 1.3.2
|
|
13
|
+
- Les membres validateurs (ceux qui peuvent valider/supprimer une demande) voient leurs propres réservations passer directement en statut payé/checked (pas de workflow paiement).
|
|
14
|
+
- UI : message de succès adapté (réservation confirmée).
|
|
15
|
+
|
|
16
|
+
## 1.3.1
|
|
17
|
+
- ACP : ajout de 2 actions rapides dans l’onglet Maintenance : « Tout mettre en maintenance » et « Tout enlever de maintenance » (avec audit).
|
|
18
|
+
|
|
19
|
+
## 1.3.0
|
|
20
|
+
- Maintenance (sans dates) : blocage manuel ON/OFF par matériel (ACP + API). Les matériels en maintenance sont grisés et affichés comme : 🔧 Nom (en maintenance).
|
|
21
|
+
- Audit : journal des actions (demande, validation, refus, annulation, maintenance) avec consultation et purge par année (ACP + API).
|
|
22
|
+
|
|
23
|
+
## 1.2.18
|
|
24
|
+
- API events + anti double booking : les tests de chevauchement utilisent désormais en priorité startDate/endDate (YYYY-MM-DD) quand disponibles (logique calendaire pure, endDate exclusive). Cela supprime définitivement les faux chevauchements liés aux timestamps/fuseaux/DST, notamment sur mobile et « Durée rapide ».
|
|
25
|
+
|
|
3
26
|
## 1.2.17
|
|
4
27
|
- Modale réservation : la requête de disponibilité initiale utilise aussi des dates calendaires (YYYY-MM-DD) au lieu de startStr/endStr/toISOString(), ce qui corrige le grisé erroné (mobile + durée rapide).
|
|
5
28
|
|
|
@@ -72,3 +95,8 @@
|
|
|
72
95
|
- Validation / refus des demandes depuis l’ACP
|
|
73
96
|
- Notifications Discord
|
|
74
97
|
- Intégration paiements HelloAsso
|
|
98
|
+
|
|
99
|
+
## 1.2.19
|
|
100
|
+
- Mobile: ajout d’un bouton flottant (FAB) sur la page calendrier uniquement.
|
|
101
|
+
- Le FAB ouvre une mini-modale de sélection de dates (dd/mm/yyyy) puis ouvre la modale standard de réservation.
|
|
102
|
+
- Le FAB est automatiquement retiré quand on navigue hors de la page calendrier.
|
package/lib/admin.js
CHANGED
|
@@ -404,6 +404,7 @@ admin.getAccounting = async function (req, res) {
|
|
|
404
404
|
const ids = await dbLayer.listAllReservationIds(100000);
|
|
405
405
|
const rows = [];
|
|
406
406
|
const byItem = new Map();
|
|
407
|
+
let freeCount = 0;
|
|
407
408
|
|
|
408
409
|
for (const rid of ids) {
|
|
409
410
|
const r = await dbLayer.getReservation(rid);
|
|
@@ -418,10 +419,13 @@ admin.getAccounting = async function (req, res) {
|
|
|
418
419
|
? r.itemNames
|
|
419
420
|
: (r.itemName ? [r.itemName] : []);
|
|
420
421
|
|
|
421
|
-
const
|
|
422
|
+
const isFree = !!r.isFree;
|
|
423
|
+
const total = isFree ? 0 : (Number(r.total) || 0);
|
|
422
424
|
const startDate = formatFR(r.start);
|
|
423
425
|
const endDate = formatFR(r.end);
|
|
424
426
|
|
|
427
|
+
if (isFree) freeCount += 1;
|
|
428
|
+
|
|
425
429
|
rows.push({
|
|
426
430
|
rid: r.rid,
|
|
427
431
|
uid: r.uid,
|
|
@@ -433,19 +437,26 @@ admin.getAccounting = async function (req, res) {
|
|
|
433
437
|
items: itemNames,
|
|
434
438
|
total,
|
|
435
439
|
paidAt: r.paidAt || '',
|
|
440
|
+
isFree,
|
|
436
441
|
});
|
|
437
442
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
// Only count paid (non-free) reservations into revenue by item.
|
|
444
|
+
if (!isFree) {
|
|
445
|
+
for (const name of itemNames) {
|
|
446
|
+
const key = String(name || '').trim();
|
|
447
|
+
if (!key) continue;
|
|
448
|
+
const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
|
|
449
|
+
cur.count += 1;
|
|
450
|
+
cur.total += total;
|
|
451
|
+
byItem.set(key, cur);
|
|
452
|
+
}
|
|
445
453
|
}
|
|
446
454
|
}
|
|
447
455
|
|
|
448
456
|
const summary = Array.from(byItem.values()).sort((a, b) => b.count - a.count);
|
|
457
|
+
if (freeCount > 0) {
|
|
458
|
+
summary.push({ item: 'Sorties gratuites', count: freeCount, total: 0, isFree: true });
|
|
459
|
+
}
|
|
449
460
|
rows.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
450
461
|
|
|
451
462
|
return res.json({
|
|
@@ -477,7 +488,7 @@ admin.exportAccountingCsv = async function (req, res) {
|
|
|
477
488
|
return s;
|
|
478
489
|
};
|
|
479
490
|
const lines = [];
|
|
480
|
-
lines.push(['rid', 'username', 'uid', 'start', 'end', 'items', 'total', 'paidAt'].map(escape).join(','));
|
|
491
|
+
lines.push(['rid', 'username', 'uid', 'start', 'end', 'items', 'total', 'paidAt', 'isFree'].map(escape).join(','));
|
|
481
492
|
for (const r of payload.rows || []) {
|
|
482
493
|
lines.push([
|
|
483
494
|
r.rid,
|
|
@@ -488,6 +499,7 @@ admin.exportAccountingCsv = async function (req, res) {
|
|
|
488
499
|
(Array.isArray(r.items) ? r.items.join(' | ') : ''),
|
|
489
500
|
(Number(r.total) || 0).toFixed(2),
|
|
490
501
|
r.paidAt ? new Date(parseInt(r.paidAt, 10)).toISOString() : '',
|
|
502
|
+
r.isFree ? '1' : '0',
|
|
491
503
|
].map(escape).join(','));
|
|
492
504
|
}
|
|
493
505
|
const csv = lines.join('\n');
|
package/lib/api.js
CHANGED
|
@@ -326,6 +326,35 @@ async function canValidate(uid, settings) {
|
|
|
326
326
|
return false;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
async function canModerate(uid) {
|
|
330
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
331
|
+
return await canValidate(uid, settings);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function auditLog(action, actorUid, payload) {
|
|
335
|
+
try {
|
|
336
|
+
const uid = actorUid ? parseInt(actorUid, 10) : 0;
|
|
337
|
+
let actorUsername = '';
|
|
338
|
+
if (uid) {
|
|
339
|
+
try {
|
|
340
|
+
const u = await user.getUserFields(uid, ['username']);
|
|
341
|
+
actorUsername = (u && u.username) ? String(u.username) : '';
|
|
342
|
+
} catch (e) {}
|
|
343
|
+
}
|
|
344
|
+
const ts = Date.now();
|
|
345
|
+
const year = new Date(ts).getFullYear();
|
|
346
|
+
await dbLayer.addAuditEntry(Object.assign({
|
|
347
|
+
ts,
|
|
348
|
+
year,
|
|
349
|
+
action: String(action || ''),
|
|
350
|
+
actorUid: uid,
|
|
351
|
+
actorUsername,
|
|
352
|
+
}, payload || {}));
|
|
353
|
+
} catch (e) {
|
|
354
|
+
// never block user flows on audit
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
329
358
|
async function canCreateSpecial(uid, settings) {
|
|
330
359
|
if (!uid) return false;
|
|
331
360
|
try {
|
|
@@ -445,8 +474,17 @@ function computeEtag(payload) {
|
|
|
445
474
|
}
|
|
446
475
|
|
|
447
476
|
api.getEvents = async function (req, res) {
|
|
448
|
-
const
|
|
449
|
-
const
|
|
477
|
+
const qStartRaw = (req && req.query && req.query.start !== undefined) ? String(req.query.start).trim() : '';
|
|
478
|
+
const qEndRaw = (req && req.query && req.query.end !== undefined) ? String(req.query.end).trim() : '';
|
|
479
|
+
|
|
480
|
+
// If the client provides date-only strings (YYYY-MM-DD), prefer purely calendar-based
|
|
481
|
+
// overlap checks. This avoids any dependency on server timezone, user timezone, DST,
|
|
482
|
+
// or how JS Date() parses inputs.
|
|
483
|
+
const qStartYmd = (/^\d{4}-\d{2}-\d{2}$/.test(qStartRaw)) ? qStartRaw : null;
|
|
484
|
+
const qEndYmd = (/^\d{4}-\d{2}-\d{2}$/.test(qEndRaw)) ? qEndRaw : null;
|
|
485
|
+
|
|
486
|
+
const startTs = toTs(qStartRaw) || 0;
|
|
487
|
+
const endTs = toTs(qEndRaw) || (Date.now() + 365 * 24 * 3600 * 1000);
|
|
450
488
|
|
|
451
489
|
const settings = await meta.settings.get('calendar-onekite');
|
|
452
490
|
const canMod = req.uid ? await canValidate(req.uid, settings) : false;
|
|
@@ -494,9 +532,19 @@ api.getEvents = async function (req, res) {
|
|
|
494
532
|
}
|
|
495
533
|
// Only show active statuses
|
|
496
534
|
if (!['pending', 'awaiting_payment', 'paid'].includes(r.status)) continue;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
535
|
+
// Overlap check
|
|
536
|
+
// Prefer date-only strings (YYYY-MM-DD) for 100% reliable calendar-day logic.
|
|
537
|
+
const rStartYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : null;
|
|
538
|
+
const rEndYmd = (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : null;
|
|
539
|
+
|
|
540
|
+
if (qStartYmd && qEndYmd && rStartYmd && rEndYmd) {
|
|
541
|
+
// endDate is EXCLUSIVE (FullCalendar rule): overlap iff aStart < bEnd && bStart < aEnd
|
|
542
|
+
if (!(rStartYmd < qEndYmd && qStartYmd < rEndYmd)) continue;
|
|
543
|
+
} else {
|
|
544
|
+
const rStart = parseInt(r.start, 10);
|
|
545
|
+
const rEnd = parseInt(r.end, 10);
|
|
546
|
+
if (!(rStart < endTs && startTs < rEnd)) continue;
|
|
547
|
+
}
|
|
500
548
|
const evs = eventsFor(r);
|
|
501
549
|
for (const ev of evs) {
|
|
502
550
|
const p = ev.extendedProps || {};
|
|
@@ -763,7 +811,18 @@ api.getItems = async function (req, res) {
|
|
|
763
811
|
price: typeof it.price === 'number' ? it.price : 0,
|
|
764
812
|
})).filter(it => it.id && it.name);
|
|
765
813
|
|
|
766
|
-
|
|
814
|
+
// Maintenance (simple ON/OFF per item)
|
|
815
|
+
let maint = new Set();
|
|
816
|
+
try {
|
|
817
|
+
const ids = await dbLayer.listMaintenanceItemIds(20000);
|
|
818
|
+
maint = new Set((ids || []).map(String));
|
|
819
|
+
} catch (e) {}
|
|
820
|
+
|
|
821
|
+
const out = normalized.map((it) => Object.assign({}, it, {
|
|
822
|
+
maintenance: maint.has(String(it.id)),
|
|
823
|
+
}));
|
|
824
|
+
|
|
825
|
+
res.json(out);
|
|
767
826
|
};
|
|
768
827
|
|
|
769
828
|
api.createReservation = async function (req, res) {
|
|
@@ -775,6 +834,8 @@ api.createReservation = async function (req, res) {
|
|
|
775
834
|
const ok = await canRequest(uid, settings, startPreview);
|
|
776
835
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
777
836
|
|
|
837
|
+
const isValidator = await canValidate(uid, settings);
|
|
838
|
+
|
|
778
839
|
const startRaw = req.body.start;
|
|
779
840
|
const endRaw = req.body.end;
|
|
780
841
|
const start = parseInt(toTs(startRaw), 10);
|
|
@@ -788,6 +849,23 @@ api.createReservation = async function (req, res) {
|
|
|
788
849
|
const startDate = (typeof startRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(startRaw.trim())) ? startRaw.trim() : null;
|
|
789
850
|
const endDate = (typeof endRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(endRaw.trim())) ? endRaw.trim() : null;
|
|
790
851
|
|
|
852
|
+
// Validators can create "free" reservations that skip the payment workflow.
|
|
853
|
+
// However, long rentals should follow the normal paid workflow.
|
|
854
|
+
// Setting: validatorFreeMaxDays (days, endDate exclusive). If empty/0 => always free.
|
|
855
|
+
let validatorFreeMaxDays = 0;
|
|
856
|
+
try {
|
|
857
|
+
const v = parseInt(String(settings.validatorFreeMaxDays || '').trim(), 10);
|
|
858
|
+
validatorFreeMaxDays = Number.isFinite(v) ? v : 0;
|
|
859
|
+
} catch (e) {
|
|
860
|
+
validatorFreeMaxDays = 0;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Reliable calendar-day count (endDate is EXCLUSIVE)
|
|
864
|
+
const nbDays = (startDate && endDate) ? (calendarDaysExclusiveYmd(startDate, endDate) || 1) : Math.max(1, Math.round((end - start) / (24 * 60 * 60 * 1000)));
|
|
865
|
+
|
|
866
|
+
// A validator is "free" only if the rental duration is within the configured threshold.
|
|
867
|
+
const isValidatorFree = !!isValidator && (validatorFreeMaxDays <= 0 || nbDays <= validatorFreeMaxDays);
|
|
868
|
+
|
|
791
869
|
// Business rule: a reservation cannot start on the current day or in the past.
|
|
792
870
|
// We compare against server-local midnight. (Front-end also prevents it.)
|
|
793
871
|
try {
|
|
@@ -814,6 +892,19 @@ api.createReservation = async function (req, res) {
|
|
|
814
892
|
return res.status(400).json({ error: 'missing-fields' });
|
|
815
893
|
}
|
|
816
894
|
|
|
895
|
+
// Maintenance block (simple ON/OFF, no dates): reject if any selected item is in maintenance.
|
|
896
|
+
try {
|
|
897
|
+
const maintIds = new Set(((await dbLayer.listMaintenanceItemIds(20000)) || []).map(String));
|
|
898
|
+
const blockedByMaintenance = itemIds.filter((id) => maintIds.has(String(id)));
|
|
899
|
+
if (blockedByMaintenance.length) {
|
|
900
|
+
return res.status(409).json({
|
|
901
|
+
error: 'in-maintenance',
|
|
902
|
+
itemIds: blockedByMaintenance,
|
|
903
|
+
message: "Un ou plusieurs matériels sont en maintenance.",
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
} catch (e) {}
|
|
907
|
+
|
|
817
908
|
// Prevent double booking: block if any selected item overlaps with an active reservation
|
|
818
909
|
const blocking = new Set(['pending', 'awaiting_payment', 'paid']);
|
|
819
910
|
const wideStart2 = Math.max(0, start - 366 * 24 * 3600 * 1000);
|
|
@@ -822,9 +913,16 @@ api.createReservation = async function (req, res) {
|
|
|
822
913
|
const existingRows = await dbLayer.getReservations(candidateIds);
|
|
823
914
|
for (const existing of (existingRows || [])) {
|
|
824
915
|
if (!existing || !blocking.has(existing.status)) continue;
|
|
825
|
-
const
|
|
826
|
-
const
|
|
827
|
-
if (
|
|
916
|
+
const exStartYmd = (existing.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(existing.startDate))) ? String(existing.startDate) : null;
|
|
917
|
+
const exEndYmd = (existing.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(existing.endDate))) ? String(existing.endDate) : null;
|
|
918
|
+
if (startDate && endDate && exStartYmd && exEndYmd) {
|
|
919
|
+
// endDate is EXCLUSIVE: overlap iff aStart < bEnd && bStart < aEnd
|
|
920
|
+
if (!(exStartYmd < endDate && startDate < exEndYmd)) continue;
|
|
921
|
+
} else {
|
|
922
|
+
const exStart = parseInt(existing.start, 10);
|
|
923
|
+
const exEnd = parseInt(existing.end, 10);
|
|
924
|
+
if (!(exStart < end && start < exEnd)) continue;
|
|
925
|
+
}
|
|
828
926
|
const exItemIds = Array.isArray(existing.itemIds) ? existing.itemIds : (existing.itemId ? [existing.itemId] : []);
|
|
829
927
|
const shared = exItemIds.filter(x => itemIds.includes(String(x)));
|
|
830
928
|
if (shared.length) {
|
|
@@ -857,14 +955,40 @@ api.createReservation = async function (req, res) {
|
|
|
857
955
|
end,
|
|
858
956
|
startDate,
|
|
859
957
|
endDate,
|
|
860
|
-
status: 'pending',
|
|
958
|
+
status: isValidatorFree ? 'paid' : 'pending',
|
|
861
959
|
createdAt: now,
|
|
862
|
-
|
|
960
|
+
paidAt: isValidatorFree ? now : 0,
|
|
961
|
+
approvedBy: isValidatorFree ? uid : 0,
|
|
962
|
+
// total is used for accounting (paid reservations).
|
|
963
|
+
// Validator self-reservations are FREE (no payment required) and must not be
|
|
964
|
+
// counted as revenue.
|
|
965
|
+
isFree: !!isValidatorFree,
|
|
966
|
+
total: isValidatorFree ? 0 : (isNaN(total) ? 0 : total),
|
|
863
967
|
};
|
|
864
968
|
|
|
969
|
+
// NOTE: We intentionally do NOT compute a monetary total for validator self-reservations.
|
|
970
|
+
// Those are free "sorties" and are tracked separately in the accounting view.
|
|
971
|
+
|
|
865
972
|
// Save
|
|
866
973
|
await dbLayer.saveReservation(resv);
|
|
867
974
|
|
|
975
|
+
// Audit
|
|
976
|
+
await auditLog(isValidatorFree ? 'reservation_self_checked' : 'reservation_requested', uid, {
|
|
977
|
+
targetType: 'reservation',
|
|
978
|
+
targetId: String(resv.rid),
|
|
979
|
+
uid: Number(uid) || 0,
|
|
980
|
+
requesterUid: Number(uid) || 0,
|
|
981
|
+
requesterUsername: username || '',
|
|
982
|
+
itemIds: resv.itemIds || [],
|
|
983
|
+
itemNames: resv.itemNames || [],
|
|
984
|
+
startDate: resv.startDate || '',
|
|
985
|
+
endDate: resv.endDate || '',
|
|
986
|
+
status: resv.status,
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
if (!isValidatorFree) {
|
|
990
|
+
|
|
991
|
+
|
|
868
992
|
// Notify groups by email (NodeBB emailer config)
|
|
869
993
|
try {
|
|
870
994
|
const notifyGroups = (settings.notifyGroups || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
@@ -931,7 +1055,9 @@ api.createReservation = async function (req, res) {
|
|
|
931
1055
|
});
|
|
932
1056
|
} catch (e) {}
|
|
933
1057
|
|
|
934
|
-
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
res.json({ ok: true, rid, status: resv.status, autoPaid: !!isValidatorFree });
|
|
935
1061
|
};
|
|
936
1062
|
|
|
937
1063
|
// Validator actions (from calendar popup)
|
|
@@ -1048,6 +1174,18 @@ api.approveReservation = async function (req, res) {
|
|
|
1048
1174
|
|
|
1049
1175
|
await dbLayer.saveReservation(r);
|
|
1050
1176
|
|
|
1177
|
+
await auditLog('reservation_approved', uid, {
|
|
1178
|
+
targetType: 'reservation',
|
|
1179
|
+
targetId: String(rid),
|
|
1180
|
+
reservationUid: Number(r.uid) || 0,
|
|
1181
|
+
reservationUsername: String(r.username || ''),
|
|
1182
|
+
itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
|
|
1183
|
+
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
1184
|
+
startDate: r.startDate || '',
|
|
1185
|
+
endDate: r.endDate || '',
|
|
1186
|
+
status: r.status,
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1051
1189
|
// Email requester
|
|
1052
1190
|
const requesterUid = parseInt(r.uid, 10);
|
|
1053
1191
|
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
@@ -1103,6 +1241,19 @@ api.refuseReservation = async function (req, res) {
|
|
|
1103
1241
|
}
|
|
1104
1242
|
await dbLayer.saveReservation(r);
|
|
1105
1243
|
|
|
1244
|
+
await auditLog('reservation_refused', uid, {
|
|
1245
|
+
targetType: 'reservation',
|
|
1246
|
+
targetId: String(rid),
|
|
1247
|
+
reservationUid: Number(r.uid) || 0,
|
|
1248
|
+
reservationUsername: String(r.username || ''),
|
|
1249
|
+
itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
|
|
1250
|
+
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
1251
|
+
startDate: r.startDate || '',
|
|
1252
|
+
endDate: r.endDate || '',
|
|
1253
|
+
reason: r.refusedReason || '',
|
|
1254
|
+
status: r.status,
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1106
1257
|
const requesterUid2 = parseInt(r.uid, 10);
|
|
1107
1258
|
const requester = await user.getUserFields(requesterUid2, ['username']);
|
|
1108
1259
|
if (requesterUid2) {
|
|
@@ -1159,6 +1310,18 @@ api.cancelReservation = async function (req, res) {
|
|
|
1159
1310
|
|
|
1160
1311
|
await dbLayer.saveReservation(r);
|
|
1161
1312
|
|
|
1313
|
+
await auditLog('reservation_cancelled', uid, {
|
|
1314
|
+
targetType: 'reservation',
|
|
1315
|
+
targetId: String(rid),
|
|
1316
|
+
reservationUid: Number(r.uid) || 0,
|
|
1317
|
+
reservationUsername: String(r.username || ''),
|
|
1318
|
+
itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
|
|
1319
|
+
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
1320
|
+
startDate: r.startDate || '',
|
|
1321
|
+
endDate: r.endDate || '',
|
|
1322
|
+
status: r.status,
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1162
1325
|
// Discord webhook (optional)
|
|
1163
1326
|
try {
|
|
1164
1327
|
await discord.notifyReservationCancelled(settings, {
|
|
@@ -1198,4 +1361,98 @@ api.cancelReservation = async function (req, res) {
|
|
|
1198
1361
|
return res.json({ ok: true, status: 'cancelled' });
|
|
1199
1362
|
};
|
|
1200
1363
|
|
|
1364
|
+
// --------------------
|
|
1365
|
+
// Maintenance (simple ON/OFF)
|
|
1366
|
+
// --------------------
|
|
1367
|
+
|
|
1368
|
+
api.getMaintenance = async function (req, res) {
|
|
1369
|
+
const uid = req.uid;
|
|
1370
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1371
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1372
|
+
const ids = await dbLayer.listMaintenanceItemIds(20000);
|
|
1373
|
+
return res.json({ itemIds: (ids || []).map(String) });
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
api.setMaintenance = async function (req, res) {
|
|
1377
|
+
const uid = req.uid;
|
|
1378
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1379
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1380
|
+
const itemId = String(req.params.itemId || '').trim();
|
|
1381
|
+
if (!itemId) return res.status(400).json({ error: 'missing-itemId' });
|
|
1382
|
+
const enabled = !!(req.body && (req.body.enabled === true || req.body.enabled === '1' || req.body.enabled === 1));
|
|
1383
|
+
await dbLayer.setItemMaintenance(itemId, enabled);
|
|
1384
|
+
await auditLog(enabled ? 'maintenance_on' : 'maintenance_off', uid, {
|
|
1385
|
+
targetType: 'item',
|
|
1386
|
+
targetId: itemId,
|
|
1387
|
+
});
|
|
1388
|
+
return res.json({ ok: true, itemId, enabled });
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
// Bulk toggle: enable/disable maintenance for ALL catalog items.
|
|
1392
|
+
// Uses the same permission as validate/delete.
|
|
1393
|
+
api.setMaintenanceAll = async function (req, res) {
|
|
1394
|
+
const uid = req.uid;
|
|
1395
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1396
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1397
|
+
|
|
1398
|
+
const enabled = !!(req.body && (req.body.enabled === true || req.body.enabled === '1' || req.body.enabled === 1));
|
|
1399
|
+
|
|
1400
|
+
// When enabling, we need the current catalog IDs (HelloAsso shop)
|
|
1401
|
+
let catalogIds = [];
|
|
1402
|
+
if (enabled) {
|
|
1403
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
1404
|
+
const env = settings.helloassoEnv || 'prod';
|
|
1405
|
+
const token = await helloasso.getAccessToken({
|
|
1406
|
+
env,
|
|
1407
|
+
clientId: settings.helloassoClientId,
|
|
1408
|
+
clientSecret: settings.helloassoClientSecret,
|
|
1409
|
+
});
|
|
1410
|
+
if (!token) {
|
|
1411
|
+
return res.status(400).json({ error: 'helloasso-token-missing' });
|
|
1412
|
+
}
|
|
1413
|
+
const year = new Date().getFullYear();
|
|
1414
|
+
const { items: catalog } = await helloasso.listCatalogItems({
|
|
1415
|
+
env,
|
|
1416
|
+
token,
|
|
1417
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
1418
|
+
formType: settings.helloassoFormType,
|
|
1419
|
+
formSlug: autoFormSlugForYear(year),
|
|
1420
|
+
});
|
|
1421
|
+
catalogIds = (catalog || []).map((it) => it && it.id).filter(Boolean).map(String);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const result = await dbLayer.setAllMaintenance(enabled, catalogIds);
|
|
1425
|
+
await auditLog(enabled ? 'maintenance_all_on' : 'maintenance_all_off', uid, {
|
|
1426
|
+
targetType: 'maintenance',
|
|
1427
|
+
targetId: enabled ? 'all_on' : 'all_off',
|
|
1428
|
+
count: result && typeof result.count === 'number' ? result.count : (enabled ? catalogIds.length : 0),
|
|
1429
|
+
});
|
|
1430
|
+
return res.json(Object.assign({ ok: true, enabled }, result || {}));
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
// --------------------
|
|
1434
|
+
// Audit
|
|
1435
|
+
// --------------------
|
|
1436
|
+
|
|
1437
|
+
api.getAudit = async function (req, res) {
|
|
1438
|
+
const uid = req.uid;
|
|
1439
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1440
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1441
|
+
const year = Number((req.query && req.query.year) || new Date().getFullYear());
|
|
1442
|
+
const limit = Math.min(500, Math.max(1, Number((req.query && req.query.limit) || 200)));
|
|
1443
|
+
const entries = await dbLayer.getAuditEntriesByYear(year, limit);
|
|
1444
|
+
return res.json({ year, entries: entries || [] });
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
api.purgeAudit = async function (req, res) {
|
|
1448
|
+
const uid = req.uid;
|
|
1449
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1450
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1451
|
+
const year = Number((req.body && req.body.year) || 0);
|
|
1452
|
+
if (!year) return res.status(400).json({ error: 'missing-year' });
|
|
1453
|
+
const result = await dbLayer.purgeAuditYear(year);
|
|
1454
|
+
await auditLog('audit_purge_year', uid, { targetType: 'audit', targetId: String(year), removed: result && result.removed ? result.removed : 0 });
|
|
1455
|
+
return res.json(result || { ok: true });
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1201
1458
|
module.exports = api;
|
package/lib/db.js
CHANGED
|
@@ -10,6 +10,13 @@ const KEY_CHECKOUT_INTENT_TO_RID = 'calendar-onekite:helloasso:checkoutIntentToR
|
|
|
10
10
|
const KEY_SPECIAL_ZSET = 'calendar-onekite:special';
|
|
11
11
|
const KEY_SPECIAL_OBJ = (eid) => `calendar-onekite:special:${eid}`;
|
|
12
12
|
|
|
13
|
+
// Maintenance (simple ON/OFF per item, no dates)
|
|
14
|
+
const KEY_MAINTENANCE_ZSET = 'calendar-onekite:maintenance:itemIds';
|
|
15
|
+
|
|
16
|
+
// Audit log (partitioned by year)
|
|
17
|
+
const KEY_AUDIT_ZSET = (year) => `calendar-onekite:audit:${year}`;
|
|
18
|
+
const KEY_AUDIT_OBJ = (id) => `calendar-onekite:audit:entry:${id}`;
|
|
19
|
+
|
|
13
20
|
// Helpers
|
|
14
21
|
function reservationKey(rid) {
|
|
15
22
|
return KEY_OBJ(rid);
|
|
@@ -71,10 +78,106 @@ async function listAllReservationIds(limit = 5000) {
|
|
|
71
78
|
return await db.getSortedSetRange(KEY_ZSET, 0, limit - 1);
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
// --------------------
|
|
82
|
+
// Maintenance
|
|
83
|
+
// --------------------
|
|
84
|
+
|
|
85
|
+
async function listMaintenanceItemIds(limit = 10000) {
|
|
86
|
+
return await db.getSortedSetRange(KEY_MAINTENANCE_ZSET, 0, Math.max(0, limit - 1));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function isItemInMaintenance(itemId) {
|
|
90
|
+
if (!itemId) return false;
|
|
91
|
+
try {
|
|
92
|
+
// NodeBB DB returns 1/0 for isSortedSetMember
|
|
93
|
+
if (typeof db.isSortedSetMember === 'function') {
|
|
94
|
+
return !!(await db.isSortedSetMember(KEY_MAINTENANCE_ZSET, String(itemId)));
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {}
|
|
97
|
+
const ids = await listMaintenanceItemIds(20000);
|
|
98
|
+
return Array.isArray(ids) ? ids.map(String).includes(String(itemId)) : false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function setItemMaintenance(itemId, enabled) {
|
|
102
|
+
const id = String(itemId || '').trim();
|
|
103
|
+
if (!id) return;
|
|
104
|
+
if (enabled) {
|
|
105
|
+
await db.sortedSetAdd(KEY_MAINTENANCE_ZSET, 0, id);
|
|
106
|
+
} else {
|
|
107
|
+
await db.sortedSetRemove(KEY_MAINTENANCE_ZSET, id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function setAllMaintenance(enabled, itemIds) {
|
|
112
|
+
// Clear set first (fast)
|
|
113
|
+
await db.delete(KEY_MAINTENANCE_ZSET);
|
|
114
|
+
if (!enabled) {
|
|
115
|
+
return { count: 0 };
|
|
116
|
+
}
|
|
117
|
+
const ids = Array.isArray(itemIds) ? itemIds.map(String).filter(Boolean) : [];
|
|
118
|
+
// Add back all ids
|
|
119
|
+
for (const id of ids) {
|
|
120
|
+
await db.sortedSetAdd(KEY_MAINTENANCE_ZSET, 0, id);
|
|
121
|
+
}
|
|
122
|
+
return { count: ids.length };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --------------------
|
|
126
|
+
// Audit
|
|
127
|
+
// --------------------
|
|
128
|
+
|
|
129
|
+
async function addAuditEntry(entry) {
|
|
130
|
+
const e = Object.assign({}, entry || {});
|
|
131
|
+
const ts = Number(e.ts) || Date.now();
|
|
132
|
+
const year = Number(e.year) || new Date(ts).getFullYear();
|
|
133
|
+
e.ts = ts;
|
|
134
|
+
e.year = year;
|
|
135
|
+
const id = e.id ? String(e.id) : `${ts}-${Math.random().toString(16).slice(2)}`;
|
|
136
|
+
e.id = id;
|
|
137
|
+
await db.setObject(KEY_AUDIT_OBJ(id), e);
|
|
138
|
+
await db.sortedSetAdd(KEY_AUDIT_ZSET(year), ts, id);
|
|
139
|
+
return e;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function listAuditEntryIdsByYear(year, start = 0, stop = 200) {
|
|
143
|
+
const y = Number(year) || new Date().getFullYear();
|
|
144
|
+
return await db.getSortedSetRevRange(KEY_AUDIT_ZSET(y), start, stop);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function getAuditEntriesByYear(year, limit = 200) {
|
|
148
|
+
const ids = await listAuditEntryIdsByYear(year, 0, Math.max(0, (Number(limit) || 200) - 1));
|
|
149
|
+
if (!ids || !ids.length) return [];
|
|
150
|
+
const keys = ids.map((id) => KEY_AUDIT_OBJ(id));
|
|
151
|
+
const rows = await db.getObjects(keys);
|
|
152
|
+
// Align with ids order
|
|
153
|
+
return (rows || []).map((row, idx) => row ? Object.assign({ id: String(ids[idx]) }, row) : null).filter(Boolean);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function purgeAuditYear(year) {
|
|
157
|
+
const y = Number(year);
|
|
158
|
+
if (!y) return { ok: false, removed: 0 };
|
|
159
|
+
const ids = await db.getSortedSetRange(KEY_AUDIT_ZSET(y), 0, -1);
|
|
160
|
+
const keys = (ids || []).map((id) => KEY_AUDIT_OBJ(id));
|
|
161
|
+
if (keys.length) {
|
|
162
|
+
// Batch delete objects when possible
|
|
163
|
+
if (typeof db.deleteAll === 'function') {
|
|
164
|
+
await db.deleteAll(keys);
|
|
165
|
+
} else {
|
|
166
|
+
for (const k of keys) {
|
|
167
|
+
// eslint-disable-next-line no-await-in-loop
|
|
168
|
+
await db.delete(k);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await db.delete(KEY_AUDIT_ZSET(y));
|
|
173
|
+
return { ok: true, removed: (ids || []).length };
|
|
174
|
+
}
|
|
175
|
+
|
|
74
176
|
module.exports = {
|
|
75
177
|
KEY_ZSET,
|
|
76
178
|
KEY_SPECIAL_ZSET,
|
|
77
179
|
KEY_CHECKOUT_INTENT_TO_RID,
|
|
180
|
+
KEY_MAINTENANCE_ZSET,
|
|
78
181
|
getReservation,
|
|
79
182
|
getReservations,
|
|
80
183
|
saveReservation,
|
|
@@ -107,4 +210,15 @@ module.exports = {
|
|
|
107
210
|
},
|
|
108
211
|
listReservationIdsByStartRange,
|
|
109
212
|
listAllReservationIds,
|
|
213
|
+
|
|
214
|
+
// Maintenance
|
|
215
|
+
listMaintenanceItemIds,
|
|
216
|
+
isItemInMaintenance,
|
|
217
|
+
setItemMaintenance,
|
|
218
|
+
setAllMaintenance,
|
|
219
|
+
|
|
220
|
+
// Audit
|
|
221
|
+
addAuditEntry,
|
|
222
|
+
getAuditEntriesByYear,
|
|
223
|
+
purgeAuditYear,
|
|
110
224
|
};
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -19,6 +19,22 @@ const dbLayer = require('./db');
|
|
|
19
19
|
const helloasso = require('./helloasso');
|
|
20
20
|
const discord = require('./discord');
|
|
21
21
|
|
|
22
|
+
async function auditLog(action, actorUid, payload) {
|
|
23
|
+
try {
|
|
24
|
+
const uid = actorUid ? parseInt(actorUid, 10) : 0;
|
|
25
|
+
let actorUsername = '';
|
|
26
|
+
if (uid) {
|
|
27
|
+
try {
|
|
28
|
+
const u = await user.getUserFields(uid, ['username']);
|
|
29
|
+
actorUsername = (u && u.username) ? String(u.username) : '';
|
|
30
|
+
} catch (e) {}
|
|
31
|
+
}
|
|
32
|
+
const ts = Date.now();
|
|
33
|
+
const year = new Date(ts).getFullYear();
|
|
34
|
+
await dbLayer.addAuditEntry(Object.assign({ ts, year, action, actorUid: uid, actorUsername }, payload || {}));
|
|
35
|
+
} catch (e) {}
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
const SETTINGS_KEY = 'calendar-onekite';
|
|
23
39
|
|
|
24
40
|
// Replay protection: store processed payment ids.
|
|
@@ -314,6 +330,18 @@ async function handler(req, res, next) {
|
|
|
314
330
|
}
|
|
315
331
|
await dbLayer.saveReservation(r);
|
|
316
332
|
|
|
333
|
+
await auditLog('reservation_paid', 0, {
|
|
334
|
+
targetType: 'reservation',
|
|
335
|
+
targetId: String(r.rid),
|
|
336
|
+
reservationUid: Number(r.uid) || 0,
|
|
337
|
+
reservationUsername: String(r.username || ''),
|
|
338
|
+
itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
|
|
339
|
+
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
340
|
+
startDate: r.startDate || '',
|
|
341
|
+
endDate: r.endDate || '',
|
|
342
|
+
paymentId: r.paymentId || '',
|
|
343
|
+
});
|
|
344
|
+
|
|
317
345
|
// Real-time notify: refresh calendars for all viewers (owner + validators/admins)
|
|
318
346
|
try {
|
|
319
347
|
if (io && io.sockets && typeof io.sockets.emit === 'function') {
|
package/library.js
CHANGED
|
@@ -66,6 +66,13 @@ Plugin.init = async function (params) {
|
|
|
66
66
|
router.get('/api/v3/plugins/calendar-onekite/items', ...publicExpose, api.getItems);
|
|
67
67
|
router.get('/api/v3/plugins/calendar-onekite/capabilities', ...publicExpose, api.getCapabilities);
|
|
68
68
|
|
|
69
|
+
// Maintenance / audit (restricted to validator groups)
|
|
70
|
+
router.get('/api/v3/plugins/calendar-onekite/maintenance', ...publicExpose, api.getMaintenance);
|
|
71
|
+
router.put('/api/v3/plugins/calendar-onekite/maintenance', ...publicExpose, api.setMaintenanceAll);
|
|
72
|
+
router.put('/api/v3/plugins/calendar-onekite/maintenance/:itemId', ...publicExpose, api.setMaintenance);
|
|
73
|
+
router.get('/api/v3/plugins/calendar-onekite/audit', ...publicExpose, api.getAudit);
|
|
74
|
+
router.post('/api/v3/plugins/calendar-onekite/audit/purge', ...publicExpose, api.purgeAudit);
|
|
75
|
+
|
|
69
76
|
router.post('/api/v3/plugins/calendar-onekite/reservations', ...publicExpose, api.createReservation);
|
|
70
77
|
router.get('/api/v3/plugins/calendar-onekite/reservations/:rid', ...publicExpose, api.getReservationDetails);
|
|
71
78
|
router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/approve', ...publicExpose, api.approveReservation);
|