nodebb-plugin-onekite-calendar 2.0.11 → 2.0.13
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 +29 -0
- package/lib/admin.js +21 -9
- package/lib/api.js +235 -4
- 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 +106 -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 +1477 -0
- package/pkg/package/public/client.js +2228 -0
- package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -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 +205 -4
- package/public/client.js +238 -7
- package/templates/admin/plugins/calendar-onekite.tpl +74 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog – calendar-onekite
|
|
2
2
|
|
|
3
|
+
## 1.3.6
|
|
4
|
+
- ACP Comptabilisation : ajout d’un tableau séparé « Détails des sorties gratuites » (avec le nom du matériel).
|
|
5
|
+
- Mobile FAB : la modale utilise maintenant des champs date avec calendrier (type="date") et empêche toute réservation pour le jour même ou dans le passé.
|
|
6
|
+
|
|
7
|
+
## 1.3.5
|
|
8
|
+
- 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 ».
|
|
9
|
+
|
|
10
|
+
## 1.3.4
|
|
11
|
+
- 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).
|
|
12
|
+
|
|
13
|
+
## 1.3.3
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
## 1.3.2
|
|
17
|
+
- 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).
|
|
18
|
+
- UI : message de succès adapté (réservation confirmée).
|
|
19
|
+
|
|
20
|
+
## 1.3.1
|
|
21
|
+
- ACP : ajout de 2 actions rapides dans l’onglet Maintenance : « Tout mettre en maintenance » et « Tout enlever de maintenance » (avec audit).
|
|
22
|
+
|
|
23
|
+
## 1.3.0
|
|
24
|
+
- 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).
|
|
25
|
+
- Audit : journal des actions (demande, validation, refus, annulation, maintenance) avec consultation et purge par année (ACP + API).
|
|
26
|
+
|
|
3
27
|
## 1.2.18
|
|
4
28
|
- 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 ».
|
|
5
29
|
|
|
@@ -75,3 +99,8 @@
|
|
|
75
99
|
- Validation / refus des demandes depuis l’ACP
|
|
76
100
|
- Notifications Discord
|
|
77
101
|
- Intégration paiements HelloAsso
|
|
102
|
+
|
|
103
|
+
## 1.2.19
|
|
104
|
+
- Mobile: ajout d’un bouton flottant (FAB) sur la page calendrier uniquement.
|
|
105
|
+
- Le FAB ouvre une mini-modale de sélection de dates (dd/mm/yyyy) puis ouvre la modale standard de réservation.
|
|
106
|
+
- 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 {
|
|
@@ -782,7 +811,18 @@ api.getItems = async function (req, res) {
|
|
|
782
811
|
price: typeof it.price === 'number' ? it.price : 0,
|
|
783
812
|
})).filter(it => it.id && it.name);
|
|
784
813
|
|
|
785
|
-
|
|
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);
|
|
786
826
|
};
|
|
787
827
|
|
|
788
828
|
api.createReservation = async function (req, res) {
|
|
@@ -794,6 +834,8 @@ api.createReservation = async function (req, res) {
|
|
|
794
834
|
const ok = await canRequest(uid, settings, startPreview);
|
|
795
835
|
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
796
836
|
|
|
837
|
+
const isValidator = await canValidate(uid, settings);
|
|
838
|
+
|
|
797
839
|
const startRaw = req.body.start;
|
|
798
840
|
const endRaw = req.body.end;
|
|
799
841
|
const start = parseInt(toTs(startRaw), 10);
|
|
@@ -807,6 +849,23 @@ api.createReservation = async function (req, res) {
|
|
|
807
849
|
const startDate = (typeof startRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(startRaw.trim())) ? startRaw.trim() : null;
|
|
808
850
|
const endDate = (typeof endRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(endRaw.trim())) ? endRaw.trim() : null;
|
|
809
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
|
+
|
|
810
869
|
// Business rule: a reservation cannot start on the current day or in the past.
|
|
811
870
|
// We compare against server-local midnight. (Front-end also prevents it.)
|
|
812
871
|
try {
|
|
@@ -833,6 +892,19 @@ api.createReservation = async function (req, res) {
|
|
|
833
892
|
return res.status(400).json({ error: 'missing-fields' });
|
|
834
893
|
}
|
|
835
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
|
+
|
|
836
908
|
// Prevent double booking: block if any selected item overlaps with an active reservation
|
|
837
909
|
const blocking = new Set(['pending', 'awaiting_payment', 'paid']);
|
|
838
910
|
const wideStart2 = Math.max(0, start - 366 * 24 * 3600 * 1000);
|
|
@@ -883,14 +955,40 @@ api.createReservation = async function (req, res) {
|
|
|
883
955
|
end,
|
|
884
956
|
startDate,
|
|
885
957
|
endDate,
|
|
886
|
-
status: 'pending',
|
|
958
|
+
status: isValidatorFree ? 'paid' : 'pending',
|
|
887
959
|
createdAt: now,
|
|
888
|
-
|
|
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),
|
|
889
967
|
};
|
|
890
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
|
+
|
|
891
972
|
// Save
|
|
892
973
|
await dbLayer.saveReservation(resv);
|
|
893
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
|
+
|
|
894
992
|
// Notify groups by email (NodeBB emailer config)
|
|
895
993
|
try {
|
|
896
994
|
const notifyGroups = (settings.notifyGroups || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
@@ -957,7 +1055,9 @@ api.createReservation = async function (req, res) {
|
|
|
957
1055
|
});
|
|
958
1056
|
} catch (e) {}
|
|
959
1057
|
|
|
960
|
-
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
res.json({ ok: true, rid, status: resv.status, autoPaid: !!isValidatorFree });
|
|
961
1061
|
};
|
|
962
1062
|
|
|
963
1063
|
// Validator actions (from calendar popup)
|
|
@@ -1074,6 +1174,18 @@ api.approveReservation = async function (req, res) {
|
|
|
1074
1174
|
|
|
1075
1175
|
await dbLayer.saveReservation(r);
|
|
1076
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
|
+
|
|
1077
1189
|
// Email requester
|
|
1078
1190
|
const requesterUid = parseInt(r.uid, 10);
|
|
1079
1191
|
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
@@ -1129,6 +1241,19 @@ api.refuseReservation = async function (req, res) {
|
|
|
1129
1241
|
}
|
|
1130
1242
|
await dbLayer.saveReservation(r);
|
|
1131
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
|
+
|
|
1132
1257
|
const requesterUid2 = parseInt(r.uid, 10);
|
|
1133
1258
|
const requester = await user.getUserFields(requesterUid2, ['username']);
|
|
1134
1259
|
if (requesterUid2) {
|
|
@@ -1185,6 +1310,18 @@ api.cancelReservation = async function (req, res) {
|
|
|
1185
1310
|
|
|
1186
1311
|
await dbLayer.saveReservation(r);
|
|
1187
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
|
+
|
|
1188
1325
|
// Discord webhook (optional)
|
|
1189
1326
|
try {
|
|
1190
1327
|
await discord.notifyReservationCancelled(settings, {
|
|
@@ -1224,4 +1361,98 @@ api.cancelReservation = async function (req, res) {
|
|
|
1224
1361
|
return res.json({ ok: true, status: 'cancelled' });
|
|
1225
1362
|
};
|
|
1226
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
|
+
|
|
1227
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);
|
package/package.json
CHANGED