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.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/admin.js +21 -9
  3. package/lib/api.js +235 -4
  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 +106 -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 +1477 -0
  22. package/pkg/package/public/client.js +2228 -0
  23. package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -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 +205 -4
  34. package/public/client.js +238 -7
  35. 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 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 {
@@ -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
- 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);
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
- 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),
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
- res.json({ ok: true, rid });
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
  };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",