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.
Files changed (35) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/lib/admin.js +21 -9
  3. package/lib/api.js +269 -12
  4. package/lib/db.js +114 -0
  5. package/lib/helloassoWebhook.js +28 -0
  6. package/library.js +7 -0
  7. package/package.json +1 -1
  8. package/pkg/package/CHANGELOG.md +102 -0
  9. package/pkg/package/lib/admin.js +554 -0
  10. package/pkg/package/lib/api.js +1458 -0
  11. package/pkg/package/lib/controllers.js +11 -0
  12. package/pkg/package/lib/db.js +224 -0
  13. package/pkg/package/lib/discord.js +190 -0
  14. package/pkg/package/lib/helloasso.js +352 -0
  15. package/pkg/package/lib/helloassoWebhook.js +389 -0
  16. package/pkg/package/lib/scheduler.js +201 -0
  17. package/pkg/package/lib/widgets.js +460 -0
  18. package/pkg/package/library.js +164 -0
  19. package/pkg/package/package.json +14 -0
  20. package/pkg/package/plugin.json +43 -0
  21. package/pkg/package/public/admin.js +1470 -0
  22. package/pkg/package/public/client.js +2185 -0
  23. package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +287 -0
  24. package/pkg/package/templates/calendar-onekite.tpl +51 -0
  25. package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
  26. package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
  27. package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
  28. package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
  29. package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
  30. package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
  31. package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
  32. package/plugin.json +1 -1
  33. package/public/admin.js +197 -3
  34. package/public/client.js +195 -7
  35. 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 total = Number(r.total) || 0;
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
- for (const name of itemNames) {
439
- const key = String(name || '').trim();
440
- if (!key) continue;
441
- const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
442
- cur.count += 1;
443
- cur.total += total;
444
- byItem.set(key, cur);
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 startTs = toTs(req.query.start) || 0;
449
- const endTs = toTs(req.query.end) || (Date.now() + 365 * 24 * 3600 * 1000);
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
- const rStart = parseInt(r.start, 10);
498
- const rEnd = parseInt(r.end, 10);
499
- if (!(rStart < endTs && startTs < rEnd)) continue; // overlap check
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
- res.json(normalized);
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 exStart = parseInt(existing.start, 10);
826
- const exEnd = parseInt(existing.end, 10);
827
- if (!(exStart < end && start < exEnd)) continue;
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
- total: isNaN(total) ? 0 : total,
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
- res.json({ ok: true, rid });
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
  };
@@ -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);