nodebb-plugin-calendar-onekite 11.1.6 → 11.1.7

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/README.md CHANGED
@@ -1,3 +1,38 @@
1
1
  # nodebb-plugin-calendar-onekite
2
2
 
3
- Calendar reservations with admin validation.
3
+ Plugin NodeBB (v4.7.x) : calendrier de réservation de matériel basé sur FullCalendar, workflow de validation via ACP, et génération de lien de paiement HelloAsso.
4
+
5
+ ## Installation
6
+
7
+ Dans le dossier NodeBB :
8
+
9
+ ```bash
10
+ npm install /path/to/nodebb-plugin-calendar-onekite
11
+ # ou si vous avez un zip, dézippez dans node_modules puis :
12
+ ./nodebb build
13
+ ./nodebb restart
14
+ ```
15
+
16
+ Activez ensuite le plugin dans l’ACP.
17
+
18
+ ## Page
19
+
20
+ - URL : `/calendar` (ex: https://www.onekite.com/calendar)
21
+
22
+ ## Paramètres ACP
23
+
24
+ ACP → Plugins → Calendar OneKite
25
+
26
+ - `allowedGroups` : groupes autorisés à créer une demande (séparés par virgules)
27
+ - `notifyGroups` : groupes notifiés par email
28
+ - `pendingHoldMinutes` : durée de blocage d’une demande en attente
29
+ - HelloAsso :
30
+ - `helloassoEnv` : sandbox/prod
31
+ - `helloassoClientId` / `helloassoClientSecret`
32
+ - `helloassoOrganizationSlug` / `helloassoFormType` / `helloassoFormSlug`
33
+ - `helloassoReturnUrl` (optionnel)
34
+
35
+ ## Notes
36
+
37
+ - Les demandes sont créées en statut `pending` puis expirent automatiquement après `pendingHoldMinutes`.
38
+ - La validation admin crée un checkout-intent HelloAsso et envoie le lien de paiement au demandeur.
package/lib/admin.js CHANGED
@@ -1,97 +1,130 @@
1
1
  'use strict';
2
2
 
3
- const Settings = require('./settings');
4
- const DB = require('./db');
5
- const HelloAsso = require('./helloasso');
6
-
7
- const user = require.main.require('./src/user');
8
3
  const emailer = require.main.require('./src/emailer');
4
+ const users = require.main.require('./src/user');
5
+ const { settings, defaults } = require('./settings');
6
+ const db = require('./db');
7
+ const helloasso = require('./helloasso');
8
+ const { parseGroups, sendEmailToGroups } = require('./api');
9
9
 
10
10
  async function renderAdmin(req, res) {
11
- const settings = await Settings.get();
12
11
  res.render('admin/plugins/calendar-onekite', {
13
12
  title: 'Calendar OneKite',
14
- settings,
15
13
  });
16
14
  }
17
15
 
18
16
  async function getSettings(req, res) {
19
- const s = await Settings.get();
20
- res.json({ settings: s });
17
+ const s = await settings.get();
18
+ res.json({ settings: { ...defaults, ...s } });
21
19
  }
22
20
 
23
21
  async function saveSettings(req, res) {
24
- const saved = await Settings.set(req.body || {});
25
- res.json({ settings: saved });
22
+ const body = req.body || {};
23
+ // Basic sanitization
24
+ const clean = {
25
+ allowedGroups: String(body.allowedGroups || '').trim(),
26
+ notifyGroups: String(body.notifyGroups || '').trim(),
27
+ pendingHoldMinutes: Math.max(1, parseInt(body.pendingHoldMinutes, 10) || 5),
28
+ cleanupIntervalSeconds: Math.max(10, parseInt(body.cleanupIntervalSeconds, 10) || 60),
29
+
30
+ helloassoEnv: (body.helloassoEnv === 'prod') ? 'prod' : 'sandbox',
31
+ helloassoClientId: String(body.helloassoClientId || '').trim(),
32
+ helloassoClientSecret: String(body.helloassoClientSecret || '').trim(),
33
+ helloassoOrganizationSlug: String(body.helloassoOrganizationSlug || '').trim(),
34
+ helloassoFormType: String(body.helloassoFormType || 'event').trim(),
35
+ helloassoFormSlug: String(body.helloassoFormSlug || '').trim(),
36
+ helloassoReturnUrl: String(body.helloassoReturnUrl || '').trim(),
37
+
38
+ showUsernamesOnCalendar: !!body.showUsernamesOnCalendar,
39
+ };
40
+
41
+ await settings.set(clean);
42
+ res.json({ ok: true, settings: clean });
26
43
  }
27
44
 
28
- async function listRequests(req, res) {
29
- const events = await DB.getEventsAll();
30
- const pending = events.filter(e => e.status === 'pending');
31
- res.json({ requests: pending });
45
+ async function listPending(req, res) {
46
+ const pending = await db.listPending(200);
47
+ res.json({ pending });
32
48
  }
33
49
 
34
- async function approveRequest(req, res) {
35
- const id = req.params.id;
36
- const ev = await DB.getEvent(id);
37
- if (!ev) return res.status(404).json({ error: 'not-found' });
38
-
39
- ev.status = 'approved';
50
+ async function approveReservation(req, res) {
51
+ const rid = req.params.rid;
52
+ const reservation = await db.getReservation(rid);
53
+ if (!reservation) return res.status(404).json({ error: 'Réservation introuvable' });
54
+ if (reservation.status !== 'pending') return res.status(400).json({ error: 'Statut incompatible' });
40
55
 
41
- // Try create payment url
42
- const payUrl = await HelloAsso.createCheckoutIntent(Number(ev.amountCents || 0), {
43
- eventId: ev.id,
56
+ const buyer = await users.getUserFields(reservation.uid, ['username', 'email', 'fullname']);
57
+ let paymentUrl = '';
58
+ try {
59
+ const checkout = await helloasso.createCheckoutIntent(reservation, {
60
+ username: buyer.username,
61
+ email: buyer.email,
62
+ });
63
+ paymentUrl = checkout.paymentUrl;
64
+ } catch (e) {
65
+ return res.status(500).json({ error: `HelloAsso: ${e.message}` });
66
+ }
67
+
68
+ const updated = await db.updateReservation(rid, {
69
+ status: 'approved',
70
+ paymentUrl,
71
+ adminUid: String(req.uid),
72
+ adminActionAt: String(Date.now()),
44
73
  });
45
- ev.paymentUrl = payUrl || '';
46
74
 
47
- await DB.saveEvent(ev);
48
-
49
- // email requester
50
75
  try {
51
- const u = await user.getUserFields(ev.uid, ['username', 'email']);
52
- await emailer.send('calendar-onekite-approved', u.email, {
53
- username: u.username,
54
- paymentUrl: payUrl || '',
55
- start: new Date(Number(ev.start)).toISOString(),
56
- end: new Date(Number(ev.end)).toISOString(),
76
+ await emailer.send('calendar-onekite-approved', reservation.uid, {
77
+ subject: `[NodeBB] Réservation validée — paiement`,
78
+ reservation: updated,
79
+ paymentUrl,
80
+ url: paymentUrl,
81
+ site_title: (req.app && req.app.locals && req.app.locals.config && req.app.locals.config.title) || 'NodeBB',
57
82
  });
58
- } catch (e) {}
83
+ } catch (e) { /* ignore */ }
59
84
 
60
- res.json({ ok: true, paymentUrl: payUrl || '' });
85
+ res.json({ ok: true, reservation: updated });
61
86
  }
62
87
 
63
- async function refuseRequest(req, res) {
64
- const id = req.params.id;
65
- const ev = await DB.getEvent(id);
66
- if (!ev) return res.status(404).json({ error: 'not-found' });
67
- ev.status = 'refused';
68
- await DB.saveEvent(ev);
88
+ async function refuseReservation(req, res) {
89
+ const rid = req.params.rid;
90
+ const note = String((req.body && req.body.note) || '').trim();
91
+
92
+ const reservation = await db.getReservation(rid);
93
+ if (!reservation) return res.status(404).json({ error: 'Réservation introuvable' });
94
+ if (reservation.status !== 'pending') return res.status(400).json({ error: 'Statut incompatible' });
95
+
96
+ const updated = await db.updateReservation(rid, {
97
+ status: 'refused',
98
+ adminUid: String(req.uid),
99
+ adminActionAt: String(Date.now()),
100
+ adminNote: note,
101
+ });
69
102
 
70
103
  try {
71
- const u = await user.getUserFields(ev.uid, ['username', 'email']);
72
- await emailer.send('calendar-onekite-refused', u.email, {
73
- username: u.username,
74
- start: new Date(Number(ev.start)).toISOString(),
75
- end: new Date(Number(ev.end)).toISOString(),
104
+ await emailer.send('calendar-onekite-refused', reservation.uid, {
105
+ subject: `[NodeBB] Réservation refusée`,
106
+ reservation: updated,
107
+ note,
108
+ site_title: (req.app && req.app.locals && req.app.locals.config && req.app.locals.config.title) || 'NodeBB',
76
109
  });
77
- } catch (e) {}
110
+ } catch (e) { /* ignore */ }
78
111
 
79
- res.json({ ok: true });
112
+ res.json({ ok: true, reservation: updated });
80
113
  }
81
114
 
82
115
  async function purgeByYear(req, res) {
83
- const year = req.body && req.body.year;
84
- if (!year || !/^\d{4}$/.test(String(year))) return res.status(400).json({ error: 'bad-year' });
85
- const n = await DB.purgeYear(year);
86
- res.json({ ok: true, deleted: n });
116
+ const year = String((req.body && req.body.year) || '').trim();
117
+ if (!/^\d{4}$/.test(year)) return res.status(400).json({ error: 'Année invalide (YYYY)' });
118
+ const result = await db.purgeYear(year);
119
+ res.json({ ok: true, result });
87
120
  }
88
121
 
89
122
  module.exports = {
90
123
  renderAdmin,
91
124
  getSettings,
92
125
  saveSettings,
93
- listRequests,
94
- approveRequest,
95
- refuseRequest,
126
+ listPending,
127
+ approveReservation,
128
+ refuseReservation,
96
129
  purgeByYear,
97
130
  };
package/lib/api.js CHANGED
@@ -1,86 +1,154 @@
1
1
  'use strict';
2
2
 
3
- const DB = require('./db');
4
- const Settings = require('./settings');
5
- const Scheduler = require('./scheduler');
6
-
7
3
  const groups = require.main.require('./src/groups');
8
- const user = require.main.require('./src/user');
4
+ const users = require.main.require('./src/user');
9
5
  const emailer = require.main.require('./src/emailer');
6
+ const { settings } = require('./settings');
7
+ const db = require('./db');
8
+ const helloasso = require('./helloasso');
10
9
 
11
- function isInGroup(uid, groupNamesCsv) {
12
- const list = String(groupNamesCsv || '').split(',').map(s => s.trim()).filter(Boolean);
13
- if (!list.length) return Promise.resolve(false);
14
- return Promise.all(list.map(g => groups.isMember(uid, g))).then(arr => arr.some(Boolean));
10
+ function parseGroups(str) {
11
+ return String(str || '')
12
+ .split(',')
13
+ .map(s => s.trim())
14
+ .filter(Boolean);
15
15
  }
16
16
 
17
- async function getEvents(req, res) {
18
- await Scheduler.expirePending();
19
- const settings = await Settings.get();
20
- const visibleMs = Number(settings.pendingVisibleMinutes || 60) * 60 * 1000;
21
- const now = Date.now();
22
-
23
- const events = await DB.getEventsAll();
24
- const filtered = events.filter(ev => {
25
- if (ev.status === 'expired') return false;
26
- if (ev.status === 'pending') {
27
- return (now - Number(ev.createdAt || 0)) <= visibleMs;
17
+ async function isUserInAnyGroup(uid, groupNames) {
18
+ if (!uid || !groupNames.length) return false;
19
+ // groups.isMember exists in core; fall back to getUserGroups if needed
20
+ for (const g of groupNames) {
21
+ try {
22
+ // eslint-disable-next-line no-await-in-loop
23
+ const ok = await groups.isMember(uid, g);
24
+ if (ok) return true;
25
+ } catch (e) { /* ignore */ }
26
+ }
27
+ // fallback
28
+ try {
29
+ const ug = await groups.getUserGroups([uid]);
30
+ const names = (ug && ug[uid] || []).map(x => x && (x.name || x.displayName)).filter(Boolean);
31
+ return names.some(n => groupNames.includes(n));
32
+ } catch (e) {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ async function sendEmailToGroups(groupNames, template, data) {
38
+ const memberUids = new Set();
39
+ for (const g of groupNames) {
40
+ try {
41
+ // eslint-disable-next-line no-await-in-loop
42
+ const uids = await groups.getMembers(g, 0, -1);
43
+ (uids || []).forEach(uid => memberUids.add(uid));
44
+ } catch (e) { /* ignore */ }
45
+ }
46
+ const uids = Array.from(memberUids);
47
+ if (!uids.length) return;
48
+
49
+ // Send one email per user (NodeBB emailer is per-uid)
50
+ // If emailer signature changes, this is the only place to adjust.
51
+ for (const uid of uids) {
52
+ try {
53
+ // eslint-disable-next-line no-await-in-loop
54
+ await emailer.send(template, uid, data);
55
+ } catch (e) {
56
+ // ignore individual failures
28
57
  }
29
- return true;
30
- }).map(ev => ({
31
- id: ev.id,
32
- title: ev.title || ev.itemName || 'Réservation',
33
- start: new Date(Number(ev.start)).toISOString(),
34
- end: new Date(Number(ev.end)).toISOString(),
35
- extendedProps: {
36
- status: ev.status,
37
- },
38
- }));
39
- res.json({ events: filtered });
58
+ }
59
+ }
60
+
61
+ async function getEvents(req, res) {
62
+ const start = Date.parse(req.query.start);
63
+ const end = Date.parse(req.query.end);
64
+ const startMs = Number.isFinite(start) ? start : (Date.now() - 30 * 86400 * 1000);
65
+ const endMs = Number.isFinite(end) ? end : (Date.now() + 365 * 86400 * 1000);
66
+
67
+ const list = await db.getReservationsBetween(startMs, endMs);
68
+
69
+ const events = list.map(r => {
70
+ const status = r.status || 'pending';
71
+ const icon = status === 'approved' ? '✅' : status === 'refused' ? '⛔' : status === 'expired' ? '⌛' : '⏳';
72
+ const title = `${icon} ${r.itemName || 'Matériel'}${r.username ? ` — ${r.username}` : ''}`;
73
+ return {
74
+ id: r.rid,
75
+ title,
76
+ start: new Date(Number(r.start)).toISOString(),
77
+ end: new Date(Number(r.end)).toISOString(),
78
+ extendedProps: {
79
+ status,
80
+ itemId: r.itemId,
81
+ itemName: r.itemName,
82
+ paymentUrl: r.paymentUrl || '',
83
+ },
84
+ };
85
+ });
86
+
87
+ res.json(events);
40
88
  }
41
89
 
42
- async function createRequest(req, res) {
90
+ async function getItems(req, res) {
91
+ try {
92
+ const items = await helloasso.listFormItems();
93
+ res.json({ items });
94
+ } catch (e) {
95
+ res.status(500).json({ error: e.message });
96
+ }
97
+ }
98
+
99
+ async function createReservation(req, res) {
43
100
  const uid = req.uid;
44
- if (!uid) return res.status(401).json({ error: 'not-authorized' });
101
+ const s = await settings.get();
102
+ const allowedGroups = parseGroups(s.allowedGroups);
103
+ const notifyGroups = parseGroups(s.notifyGroups);
45
104
 
46
- const settings = await Settings.get();
47
- const allowed = await isInGroup(uid, settings.allowedGroups);
48
- if (!allowed) return res.status(403).json({ error: 'not-allowed' });
105
+ const can = await isUserInAnyGroup(uid, allowedGroups);
106
+ if (!can) {
107
+ return res.status(403).json({ error: 'Vous n’êtes pas autorisé à réserver du matériel.' });
108
+ }
49
109
 
50
110
  const body = req.body || {};
51
- const start = Number(new Date(body.start).getTime());
52
- const end = Number(new Date(body.end).getTime());
53
- if (!start || !end || end <= start) return res.status(400).json({ error: 'bad-range' });
111
+ const start = Date.parse(body.start);
112
+ const end = Date.parse(body.end);
54
113
 
55
- const ev = await DB.saveEvent({
56
- status: 'pending',
114
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
115
+ return res.status(400).json({ error: 'Plage de dates invalide.' });
116
+ }
117
+
118
+ const holdMin = Math.max(1, parseInt(s.pendingHoldMinutes, 10) || 5);
119
+ const expiresAt = Date.now() + holdMin * 60 * 1000;
120
+
121
+ const user = await users.getUserFields(uid, ['username', 'email', 'fullname']);
122
+ const reservation = await db.createReservation({
123
+ uid,
124
+ username: user.username,
125
+ itemId: body.itemId,
126
+ itemName: body.itemName,
127
+ itemPrice: body.itemPrice,
57
128
  start,
58
129
  end,
59
- createdAt: Date.now(),
60
- uid,
61
- title: body.title || 'Réservation',
62
- itemId: body.itemId || '',
63
- itemName: body.itemName || '',
64
- amountCents: Number(body.amountCents || 0),
130
+ status: 'pending',
131
+ expiresAt,
65
132
  });
66
133
 
67
- // Email notify groups (best-effort)
68
- try {
69
- const notifyGroups = String(settings.notifyGroups || '').split(',').map(s => s.trim()).filter(Boolean);
70
- const uids = (await Promise.all(notifyGroups.map(g => groups.getMembers(g, 0, -1)))).flat();
71
- const unique = Array.from(new Set(uids.map(Number))).filter(n => n && n !== uid);
72
- if (unique.length) {
73
- const fromUser = await user.getUserFields(uid, ['username', 'userslug']);
74
- await emailer.sendToUids('calendar-onekite-pending', unique, {
75
- username: fromUser && fromUser.username,
76
- start: new Date(start).toISOString(),
77
- end: new Date(end).toISOString(),
78
- adminUrl: (process.env.NODEBB_URL || '') + '/admin/plugins/calendar-onekite',
79
- });
80
- }
81
- } catch (e) {}
134
+ // Notify admin group by email (pending)
135
+ await sendEmailToGroups(notifyGroups, 'calendar-onekite-pending', {
136
+ subject: `[NodeBB] Nouvelle demande de réservation`,
137
+ reservation,
138
+ user,
139
+ url: `${req.protocol}://${req.get('host')}/admin/plugins/calendar-onekite`,
140
+ site_title: (req.app && req.app.locals && req.app.locals.config && req.app.locals.config.title) || 'NodeBB',
141
+ });
82
142
 
83
- res.json({ event: { id: ev.id } });
143
+ res.json({ reservation });
84
144
  }
85
145
 
86
- module.exports = { getEvents, createRequest };
146
+ module.exports = {
147
+ getEvents,
148
+ getItems,
149
+ createReservation,
150
+ // exported for admin module reuse
151
+ parseGroups,
152
+ isUserInAnyGroup,
153
+ sendEmailToGroups,
154
+ };
@@ -1,15 +1,17 @@
1
1
  'use strict';
2
2
 
3
- const Settings = require('./settings');
3
+ const { settings } = require('./settings');
4
4
 
5
5
  async function renderCalendar(req, res) {
6
- const settings = await Settings.get();
6
+ const s = await settings.get();
7
7
  res.render('calendar-onekite/calendar', {
8
8
  title: 'Calendrier',
9
- calendarOneKite: {
10
- pendingVisibleMinutes: settings.pendingVisibleMinutes,
9
+ calendarOnekite: {
10
+ showUsernamesOnCalendar: !!s.showUsernamesOnCalendar,
11
11
  },
12
12
  });
13
13
  }
14
14
 
15
- module.exports = { renderCalendar };
15
+ module.exports = {
16
+ renderCalendar,
17
+ };
package/lib/db.js CHANGED
@@ -1,59 +1,120 @@
1
1
  'use strict';
2
2
 
3
3
  const db = require.main.require('./src/database');
4
+ const { v4: uuidv4 } = require('uuid');
4
5
 
5
- const KEY_NEXT = 'calendar-onekite:nextId';
6
- const ZSET_ALL = 'calendar-onekite:events'; // score = start timestamp
7
- const HASH_PREFIX = 'calendar-onekite:event:'; // + id
6
+ const KEY_PREFIX = 'calendar-onekite';
7
+ const RES_KEY = rid => `${KEY_PREFIX}:reservation:${rid}`;
8
+ const ZSET_BY_START = `${KEY_PREFIX}:reservations:byStart`;
9
+ const ZSET_PENDING_BY_EXPIRES = `${KEY_PREFIX}:reservations:pendingByExpires`;
10
+ const YEAR_SET = y => `${KEY_PREFIX}:reservations:year:${y}`;
8
11
 
9
- async function nextId() {
10
- const id = await db.incrObjectField(KEY_NEXT, 'id');
11
- return String(id);
12
+ function yearFromMs(ms) {
13
+ const d = new Date(Number(ms));
14
+ return String(d.getUTCFullYear());
12
15
  }
13
16
 
14
- async function saveEvent(ev) {
15
- const id = ev.id || await nextId();
16
- ev.id = String(id);
17
- const hashKey = HASH_PREFIX + ev.id;
18
- await db.setObject(hashKey, ev);
19
- await db.sortedSetAdd(ZSET_ALL, ev.start, ev.id);
20
- return ev;
17
+ async function createReservation(data) {
18
+ const rid = uuidv4();
19
+ const now = Date.now();
20
+
21
+ const reservation = {
22
+ rid,
23
+ uid: String(data.uid),
24
+ username: data.username || '',
25
+ itemId: String(data.itemId || ''),
26
+ itemName: String(data.itemName || ''),
27
+ itemPrice: String(data.itemPrice || ''),
28
+ start: String(data.start),
29
+ end: String(data.end),
30
+ status: data.status || 'pending', // pending | approved | refused | expired
31
+ createdAt: String(now),
32
+ expiresAt: String(data.expiresAt || (now + 5 * 60 * 1000)),
33
+ paymentUrl: data.paymentUrl || '',
34
+ adminUid: '',
35
+ adminActionAt: '',
36
+ adminNote: '',
37
+ };
38
+
39
+ const y = yearFromMs(reservation.start);
40
+ await db.setObject(RES_KEY(rid), reservation);
41
+ await db.sortedSetAdd(ZSET_BY_START, Number(reservation.start), rid);
42
+ await db.setAdd(YEAR_SET(y), rid);
43
+
44
+ if (reservation.status === 'pending') {
45
+ await db.sortedSetAdd(ZSET_PENDING_BY_EXPIRES, Number(reservation.expiresAt), rid);
46
+ }
47
+
48
+ return reservation;
49
+ }
50
+
51
+ async function getReservation(rid) {
52
+ const obj = await db.getObject(RES_KEY(rid));
53
+ return obj && obj.rid ? obj : null;
54
+ }
55
+
56
+ async function updateReservation(rid, patch) {
57
+ await db.setObject(RES_KEY(rid), patch);
58
+ const res = await getReservation(rid);
59
+
60
+ // Maintain pending index
61
+ if (patch.status || patch.expiresAt) {
62
+ const score = Number(res.expiresAt || 0);
63
+ if (res.status === 'pending') {
64
+ await db.sortedSetAdd(ZSET_PENDING_BY_EXPIRES, score, rid);
65
+ } else {
66
+ await db.sortedSetRemove(ZSET_PENDING_BY_EXPIRES, rid);
67
+ }
68
+ }
69
+ return res;
21
70
  }
22
71
 
23
- async function getEvent(id) {
24
- return await db.getObject(HASH_PREFIX + id);
72
+ async function getReservationsBetween(startMs, endMs) {
73
+ const rids = await db.getSortedSetRangeByScore(ZSET_BY_START, 0, -1, startMs, endMs);
74
+ if (!rids || !rids.length) return [];
75
+ const keys = rids.map(RES_KEY);
76
+ const objs = await db.getObjects(keys);
77
+ return (objs || []).filter(Boolean).filter(o => o && o.rid);
25
78
  }
26
79
 
27
- async function getEventsAll() {
28
- const ids = await db.getSortedSetRange(ZSET_ALL, 0, -1);
29
- if (!ids.length) return [];
30
- const keys = ids.map(id => HASH_PREFIX + id);
31
- const events = await db.getObjects(keys);
32
- return (events || []).filter(Boolean);
80
+ async function getPendingExpired(nowMs, limit = 100) {
81
+ const rids = await db.getSortedSetRangeByScore(ZSET_PENDING_BY_EXPIRES, 0, limit - 1, 0, nowMs);
82
+ return rids || [];
33
83
  }
34
84
 
35
- async function deleteEvent(id) {
36
- await db.sortedSetRemove(ZSET_ALL, id);
37
- await db.delete(HASH_PREFIX + id);
85
+ async function listPending(limit = 200) {
86
+ const now = Date.now();
87
+ // pendingByExpires is sorted by expiresAt; use that as list, but include not yet expired
88
+ const rids = await db.getSortedSetRange(ZSET_PENDING_BY_EXPIRES, 0, limit - 1);
89
+ const keys = (rids || []).map(RES_KEY);
90
+ const objs = await db.getObjects(keys);
91
+ return (objs || []).filter(Boolean).filter(o => o.status === 'pending' && Number(o.expiresAt) > now);
38
92
  }
39
93
 
40
94
  async function purgeYear(year) {
41
- const events = await getEventsAll();
42
- const y = Number(year);
43
- const toDelete = events.filter(ev => {
44
- const d = new Date(Number(ev.start));
45
- return d.getUTCFullYear() === y;
46
- });
47
- for (const ev of toDelete) {
48
- await deleteEvent(ev.id);
49
- }
50
- return toDelete.length;
95
+ const ykey = YEAR_SET(year);
96
+ const rids = await db.getSetMembers(ykey);
97
+ if (!rids || !rids.length) return { year, purged: 0 };
98
+
99
+ // remove from indexes
100
+ await db.sortedSetRemove(ZSET_BY_START, rids);
101
+ await db.sortedSetRemove(ZSET_PENDING_BY_EXPIRES, rids);
102
+
103
+ // delete objects
104
+ await db.deleteAll(rids.map(RES_KEY));
105
+
106
+ // delete year set
107
+ await db.delete(ykey);
108
+
109
+ return { year, purged: rids.length };
51
110
  }
52
111
 
53
112
  module.exports = {
54
- saveEvent,
55
- getEvent,
56
- getEventsAll,
57
- deleteEvent,
113
+ createReservation,
114
+ getReservation,
115
+ updateReservation,
116
+ getReservationsBetween,
117
+ getPendingExpired,
118
+ listPending,
58
119
  purgeYear,
59
120
  };