nodebb-plugin-calendar-onekite 11.1.29 → 11.1.30

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/lib/admin.js CHANGED
@@ -1,109 +1,76 @@
1
1
  'use strict';
2
2
 
3
- const Groups = require.main.require('./src/groups');
4
3
  const meta = require.main.require('./src/meta');
5
- const logic = require('./logic');
6
- const helloasso = require('./helloasso');
7
- const settings = require('./settings');
8
- const db = require('./db');
4
+ const dbi = require('./db');
5
+ const api = require('./api');
9
6
 
10
- async function ensureAdmin(req, res, next) {
11
- try {
12
- if (!req.uid) return res.status(401).json({ ok:false, error:'not-logged-in' });
13
- const ok = await Groups.isMember(req.uid, 'administrators');
14
- if (!ok) return res.status(403).json({ ok:false, error:'not-admin' });
15
- next();
16
- } catch (e) {
17
- res.status(500).json({ ok:false, error:'server' });
18
- }
7
+ async function getSettings(req, res) {
8
+ const settings = await meta.settings.get('calendar-onekite');
9
+ // mask secret
10
+ if (settings && settings.helloassoClientSecret) settings.helloassoClientSecret = '***';
11
+ res.json({ ok: true, settings: settings || {} });
19
12
  }
20
13
 
21
- function mount(router, prefix, baseMws) {
22
- const mws = Array.isArray(baseMws) ? baseMws : [];
23
-
24
- // settings get
25
- router.get(`${prefix}/admin/plugins/calendar-onekite/settings`, ...mws, ensureAdmin, async (req, res) => {
26
- const s = await meta.settings.get(settings.PLUGIN_ID);
27
- const out = s || {};
28
- if (out.helloassoClientSecret) out.helloassoClientSecret = '***';
29
- res.json({ ok:true, settings: out });
30
- });
31
-
32
- // settings put
33
- router.put(`${prefix}/admin/plugins/calendar-onekite/settings`, ...mws, ensureAdmin, async (req, res) => {
34
- const p = req.body || {};
35
- const toSave = {
36
- helloassoEnv: p.helloassoEnv || 'sandbox',
37
- helloassoClientId: p.helloassoClientId || '',
38
- helloassoClientSecret: p.helloassoClientSecret && p.helloassoClientSecret !== '***' ? p.helloassoClientSecret : (await meta.settings.getOne(settings.PLUGIN_ID, 'helloassoClientSecret')) || '',
39
- helloassoOrganizationSlug: p.helloassoOrganizationSlug || '',
40
- helloassoFormType: p.helloassoFormType || 'shop',
41
- helloassoFormSlug: p.helloassoFormSlug || '',
42
- creatorGroups: settings.normalizeCsv(p.creatorGroups),
43
- validatorGroups: settings.normalizeCsv(p.validatorGroups),
44
- notifyGroups: settings.normalizeCsv(p.notifyGroups),
45
- holdMinutes: parseInt(p.holdMinutes, 10) || 5,
46
- };
47
- await meta.settings.set(settings.PLUGIN_ID, toSave);
48
- res.json({ ok:true });
49
- });
14
+ async function saveSettings(req, res) {
15
+ const body = req.body || {};
16
+ // If secret is '***', do not overwrite.
17
+ const current = await meta.settings.get('calendar-onekite') || {};
18
+ const next = { ...current, ...body };
19
+ if (body.helloassoClientSecret === '***') {
20
+ next.helloassoClientSecret = current.helloassoClientSecret;
21
+ }
22
+ await meta.settings.set('calendar-onekite', next);
23
+ // invalidate catalog cache
24
+ res.json({ ok: true });
25
+ }
50
26
 
51
- // pending list (simple: scan by start zset window)
52
- router.get(`${prefix}/admin/plugins/calendar-onekite/pending`, ...mws, ensureAdmin, async (req, res) => {
53
- const start = '2000-01-01';
54
- const end = '2100-01-01';
55
- const events = await logic.listEvents(start, end);
56
- const pending = events.filter(r => r.status === 'pending' || r.status === 'awaiting_payment');
57
- res.json({ ok:true, pending });
58
- });
27
+ async function getPending(req, res) {
28
+ const ids = await dbi.listAllIds(5000);
29
+ const rows = await dbi.getReservations(ids);
30
+ const pending = rows.filter(r => (r.status || 'pending') === 'pending')
31
+ .sort((a,b)=> (b.createdAt||0)-(a.createdAt||0))
32
+ .slice(0, 200);
33
+ res.json({ ok: true, pending });
34
+ }
59
35
 
60
- // approve/refuse from ACP
61
- router.post(`${prefix}/admin/plugins/calendar-onekite/reservations/:rid/approve`, ...mws, ensureAdmin, async (req, res) => {
62
- const rid = parseInt(req.params.rid, 10);
63
- const r = await logic.setStatus(rid, 'awaiting_payment');
64
- if (!r) return res.status(404).json({ ok:false, error:'not-found' });
65
- res.json({ ok:true });
66
- });
67
- router.post(`${prefix}/admin/plugins/calendar-onekite/reservations/:rid/refuse`, ...mws, ensureAdmin, async (req, res) => {
68
- const rid = parseInt(req.params.rid, 10);
69
- const r = await logic.setStatus(rid, 'refused');
70
- if (!r) return res.status(404).json({ ok:false, error:'not-found' });
71
- res.json({ ok:true });
72
- });
36
+ async function purgeByYear(req, res) {
37
+ const year = String((req.body||{}).year || '').trim();
38
+ if (!/^\d{4}$/.test(year)) return res.status(400).json({ status: { code: 'bad-request', message: 'Année invalide (YYYY)' } });
39
+ const y = Number(year);
40
+ const startTs = Date.UTC(y,0,1,0,0,0);
41
+ const endTs = Date.UTC(y+1,0,1,0,0,0);
73
42
 
74
- // purge year
75
- router.post(`${prefix}/admin/plugins/calendar-onekite/purge`, ...mws, ensureAdmin, async (req, res) => {
76
- const year = String((req.body && req.body.year) || '').trim();
77
- if (!/^\d{4}$/.test(year)) return res.status(400).json({ ok:false, error:'invalid-year' });
78
- const y = parseInt(year, 10);
79
- const startDate = `${year}-01-01`;
80
- const endDate = `${year}-12-31`;
81
- const startTs = Date.parse(`${startDate}T00:00:00.000Z`);
82
- const endTs = Date.parse(`${endDate}T23:59:59.999Z`);
83
- const ids = await db.listReservationIdsByStartRange(startTs, endTs, 5000);
84
- for (const id of ids) {
85
- await db.removeReservation(id);
86
- }
87
- res.json({ ok:true, removed: ids.length });
88
- });
43
+ const ids = await dbi.listReservationIdsByStartRange(startTs, endTs, 100000);
44
+ for (const id of ids) {
45
+ await dbi.deleteReservation(id);
46
+ }
47
+ res.json({ ok: true, deleted: ids.length });
48
+ }
89
49
 
90
- // debug helloasso
91
- router.get(`${prefix}/admin/plugins/calendar-onekite/debug`, ...mws, ensureAdmin, async (req, res) => {
92
- const s = await meta.settings.get(settings.PLUGIN_ID);
93
- const safe = Object.assign({}, s || {});
94
- if (safe.helloassoClientSecret) safe.helloassoClientSecret = '***';
95
- let tokenOk = false;
96
- let count = 0;
97
- let sample = [];
98
- try {
99
- const token = await helloasso.getToken();
100
- tokenOk = !!token;
101
- const { items } = await helloasso.getCatalogItems();
102
- count = items.length;
103
- sample = items.slice(0, 10);
104
- } catch (e) {}
105
- res.json({ ok:true, settings: safe, token: { ok: tokenOk }, items: { ok: true, count, sample } });
106
- });
50
+ async function debugHelloAsso(req, res) {
51
+ const settings = await meta.settings.get('calendar-onekite') || {};
52
+ const masked = { ...settings };
53
+ if (masked.helloassoClientSecret) masked.helloassoClientSecret = '***';
54
+ const out = { ok: true, settings: masked };
55
+ try {
56
+ // token + catalog
57
+ const items = await api._getCatalogItems();
58
+ out.token = { ok: true };
59
+ out.catalog = { ok: true, count: items.length, sample: items.slice(0, 10) };
60
+ // also expose cache error if any
61
+ const cache = api._catalogCache();
62
+ if (cache && cache.err) out.catalog.err = cache.err;
63
+ } catch (e) {
64
+ out.token = { ok: false, message: e.message };
65
+ out.catalog = { ok: false, count: 0, sample: [], message: e.message };
66
+ }
67
+ res.json(out);
107
68
  }
108
69
 
109
- module.exports = { mount, ensureAdmin };
70
+ module.exports = {
71
+ getSettings,
72
+ saveSettings,
73
+ getPending,
74
+ purgeByYear,
75
+ debugHelloAsso,
76
+ };
package/lib/api.js CHANGED
@@ -1,124 +1,228 @@
1
1
  'use strict';
2
2
 
3
- const logic = require('./logic');
4
- const perms = require('./perms');
5
- const helloasso = require('./helloasso');
6
- const email = require('./email');
7
- const User = require.main.require('./src/user');
8
- const settings = require('./settings');
9
-
10
- function mount(router, prefix, baseMws) {
11
- const mws = Array.isArray(baseMws) ? baseMws : [];
12
- const json = (res, obj) => res.json(obj);
13
-
14
- // events
15
- router.get(`${prefix}/plugins/calendar-onekite/events`, ...mws, async (req, res) => {
16
- const start = String(req.query.start || '').slice(0,10);
17
- const end = String(req.query.end || '').slice(0,10);
18
- if (!start || !end) return res.status(400).json({ ok:false, error:'missing-range' });
19
- const events = await logic.listEvents(start, end);
20
- const canVal = await perms.canValidate(req.uid);
21
-
22
- const fc = events.map(r => ({
23
- id: String(r.rid),
24
- title: r.title,
25
- start: r.startDate,
26
- end: r.endDate,
3
+ const meta = require.main.require('./src/meta');
4
+ const dbi = require('./db');
5
+ const hello = require('./helloasso');
6
+
7
+ const BLOCKING_STATUSES = new Set(['pending', 'awaiting_payment', 'approved', 'paid']);
8
+
9
+ function overlaps(aStart, aEnd, bStart, bEnd) {
10
+ return Number(aStart) < Number(bEnd) && Number(bStart) < Number(aEnd);
11
+ }
12
+
13
+ function parseDateParam(s) {
14
+ // FullCalendar sends ISO date/time. Accept 'YYYY-MM-DD' too.
15
+ const d = new Date(s);
16
+ if (!s || Number.isNaN(d.getTime())) return null;
17
+ return d;
18
+ }
19
+
20
+ function toYMD(ts) {
21
+ const d = new Date(Number(ts));
22
+ const y = d.getUTCFullYear();
23
+ const m = String(d.getUTCMonth()+1).padStart(2,'0');
24
+ const da = String(d.getUTCDate()).padStart(2,'0');
25
+ return `${y}-${m}-${da}`;
26
+ }
27
+
28
+ function daysBetweenInclusive(startYMD, endYMDExclusive) {
29
+ // FullCalendar selection end is exclusive. We compute number of days selected.
30
+ const s = new Date(startYMD + 'T00:00:00Z');
31
+ const e = new Date(endYMDExclusive + 'T00:00:00Z');
32
+ const diff = Math.max(0, Math.round((e - s) / 86400000));
33
+ return diff || 1;
34
+ }
35
+
36
+ let catalogCache = { at: 0, items: [], raw: null, ok: false, err: null };
37
+
38
+ async function sweepExpiredPending() {
39
+ const now = Date.now();
40
+ const ids = await dbi.listExpiredIds(now, 5000);
41
+ if (!ids.length) return 0;
42
+ const rows = await dbi.getReservations(ids);
43
+ let expiredCount = 0;
44
+ for (const r of rows) {
45
+ if (!r) continue;
46
+ const status = r.status || 'pending';
47
+ const expiresAt = Number(r.expiresAt || 0);
48
+ if (status === 'pending' && expiresAt && expiresAt <= now) {
49
+ r.status = 'expired';
50
+ r.expiredAt = now;
51
+ r.expiresAt = '';
52
+ await dbi.saveReservation(r);
53
+ await dbi.clearExpiryIndex(r.id);
54
+ expiredCount++;
55
+ } else {
56
+ // Not actually expired, remove from expiry index to avoid repeated scans
57
+ if (expiresAt && expiresAt > now) {
58
+ // keep
59
+ } else {
60
+ await dbi.clearExpiryIndex(r.id);
61
+ }
62
+ }
63
+ }
64
+ return expiredCount;
65
+ }
66
+
67
+ async function getSettings() {
68
+ const settings = await meta.settings.get('calendar-onekite');
69
+ return settings || {};
70
+ }
71
+
72
+ async function getCatalogItems() {
73
+ const settings = await getSettings();
74
+ const now = Date.now();
75
+ if (catalogCache.ok && (now - catalogCache.at) < 5*60*1000) {
76
+ return catalogCache.items;
77
+ }
78
+ if (!settings.helloassoClientId || !settings.helloassoClientSecret || !settings.helloassoOrganizationSlug || !settings.helloassoFormSlug || !settings.helloassoFormType) {
79
+ catalogCache = { at: now, items: [], raw: null, ok: true, err: null };
80
+ return [];
81
+ }
82
+ try {
83
+ const pub = await hello.getShopCatalog(settings);
84
+ const items = hello.extractItemsFromPublic(pub);
85
+ catalogCache = { at: now, items, raw: pub, ok: true, err: null };
86
+ return items;
87
+ } catch (err) {
88
+ catalogCache = { at: now, items: [], raw: null, ok: false, err: { message: err.message, statusCode: err.statusCode, body: err.body } };
89
+ return [];
90
+ }
91
+ }
92
+
93
+ async function getCatalog(req, res) {
94
+ const items = await getCatalogItems();
95
+ res.json({ ok: true, count: items.length, items });
96
+ }
97
+
98
+ async function getEvents(req, res) {
99
+ await sweepExpiredPending();
100
+ const start = parseDateParam(req.query.start);
101
+ const end = parseDateParam(req.query.end);
102
+ const startTs = start ? start.getTime() : Date.now() - 365*86400000;
103
+ const endTs = end ? end.getTime() : Date.now() + 365*86400000;
104
+
105
+ const ids = await dbi.listReservationIdsByStartRange(startTs, endTs, 5000);
106
+ const rows = await dbi.getReservations(ids);
107
+
108
+ const events = rows
109
+ .filter(r => {
110
+ const status = r.status || 'pending';
111
+ // Hide expired requests from calendar
112
+ return status !== 'expired';
113
+ })
114
+ .map(r => {
115
+ const itemNames = (r.items || []).map(it => it.name).join(', ');
116
+ const title = itemNames || 'Réservation';
117
+ const status = r.status || 'pending';
118
+ const icon = status === 'approved' ? '✅' : status === 'awaiting_payment' ? '💳' : status === 'refused' ? '⛔' : '⏳';
119
+ return {
120
+ id: r.id,
121
+ title: `${icon} ${title}`,
122
+ start: toYMD(r.startTs),
123
+ end: toYMD(r.endTs), // end exclusive works for allDay
27
124
  allDay: true,
28
125
  extendedProps: {
29
- rid: r.rid,
30
- status: r.status,
31
- itemIds: r.itemIds,
32
- days: r.days,
33
- totalCents: r.totalCents,
34
- canValidate: canVal,
126
+ status,
127
+ requesterUid: r.uid,
128
+ items: r.items || [],
129
+ totalCents: r.totalCents || 0,
130
+ days: r.days || 1,
35
131
  }
36
- }));
37
- json(res, { ok:true, events: fc });
132
+ };
38
133
  });
39
134
 
40
- // catalog items
41
- router.get(`${prefix}/plugins/calendar-onekite/items`, ...mws, async (req, res) => {
42
- try {
43
- const { items } = await helloasso.getCatalogItems();
44
- json(res, { ok:true, items });
45
- } catch (e) {
46
- json(res, { ok:false, items: [], error:'helloasso' });
47
- }
48
- });
135
+ res.json(events);
136
+ }
49
137
 
50
- // create reservation
51
- router.post(`${prefix}/plugins/calendar-onekite/reservations`, ...mws, async (req, res) => {
52
- const uid = req.uid;
53
- if (!uid) return res.status(401).json({ ok:false, error:'not-logged-in' });
54
- if (!(await perms.canCreate(uid))) return res.status(403).json({ ok:false, error:'not-allowed' });
55
-
56
- const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
57
- const startDate = String(req.body.startDate || '').slice(0,10);
58
- const endDate = String(req.body.endDate || '').slice(0,10);
59
- const note = String(req.body.note || '').slice(0,500);
60
-
61
- if (!itemIds.length) return res.status(400).json({ ok:false, error:'no-items' });
62
- if (!startDate || !endDate) return res.status(400).json({ ok:false, error:'missing-dates' });
63
-
64
- try {
65
- const r = await logic.createReservation(uid, itemIds, startDate, endDate, note);
66
-
67
- // email notify groups
68
- const notifyUids = await perms.listNotifyUids();
69
- const user = await User.getUserFields(uid, ['username']);
70
- for (const nu of notifyUids) {
71
- await email.sendToUid('calendar-onekite-pending', nu, {
72
- subject: 'Nouvelle demande de réservation',
73
- username: user.username,
74
- startDate: r.startDate,
75
- endDate: r.endDate,
76
- rid: r.rid,
77
- });
78
- }
138
+ async function createReservation(req, res) {
139
+ const uid = req.uid;
140
+ if (!uid) return res.status(403).json({ status: { code: 'forbidden', message: 'Not logged in' } });
79
141
 
80
- json(res, { ok:true, reservation: r });
81
- } catch (e) {
82
- if (e.code === 'conflict') {
83
- return res.status(409).json({ ok:false, error:'conflict', conflicts: e.conflicts });
84
- }
85
- res.status(500).json({ ok:false, error:'server' });
142
+ // Expire old pending holds before checking availability
143
+ await sweepExpiredPending();
144
+
145
+ const body = req.body || {};
146
+ const startYMD = body.start;
147
+ const endYMD = body.end;
148
+ const itemIds = Array.isArray(body.itemIds) ? body.itemIds.map(String) : [];
149
+ if (!startYMD || !endYMD) return res.status(400).json({ status: { code: 'bad-request', message: 'Missing dates' } });
150
+ if (!itemIds.length) return res.status(400).json({ status: { code: 'bad-request', message: 'Missing itemIds' } });
151
+
152
+ const days = daysBetweenInclusive(startYMD, endYMD);
153
+
154
+ const catalog = await getCatalogItems();
155
+ const chosen = [];
156
+ let sumPerDay = 0;
157
+ for (const id of itemIds) {
158
+ const it = catalog.find(x => x.id === id);
159
+ if (it) {
160
+ chosen.push(it);
161
+ sumPerDay += Number(it.priceCents || 0);
162
+ } else {
163
+ // keep unknown item with 0 price to avoid hard fail
164
+ chosen.push({ id, name: `Item ${id}`, priceCents: 0 });
86
165
  }
87
- });
166
+ }
167
+ const totalCents = sumPerDay * days;
88
168
 
89
- // validator actions from calendar (approve/refuse)
90
- router.post(`${prefix}/plugins/calendar-onekite/reservations/:rid/approve`, ...mws, async (req, res) => {
91
- const uid = req.uid;
92
- if (!uid) return res.status(401).json({ ok:false, error:'not-logged-in' });
93
- if (!(await perms.canValidate(uid))) return res.status(403).json({ ok:false, error:'not-validator' });
94
- const rid = parseInt(req.params.rid, 10);
95
- const r = await logic.setStatus(rid, 'awaiting_payment');
96
- if (!r) return res.status(404).json({ ok:false, error:'not-found' });
97
- // notify requester
98
- await email.sendToUid('calendar-onekite-approved', r.uid, {
99
- subject: 'Réservation validée',
100
- startDate: r.startDate,
101
- endDate: r.endDate,
102
- rid: r.rid,
103
- });
104
- json(res, { ok:true });
105
- });
169
+ const id = await dbi.nextId();
170
+ const startTs = new Date(startYMD + 'T00:00:00Z').getTime();
171
+ const endTs = new Date(endYMD + 'T00:00:00Z').getTime();
106
172
 
107
- router.post(`${prefix}/plugins/calendar-onekite/reservations/:rid/refuse`, ...mws, async (req, res) => {
108
- const uid = req.uid;
109
- if (!uid) return res.status(401).json({ ok:false, error:'not-logged-in' });
110
- if (!(await perms.canValidate(uid))) return res.status(403).json({ ok:false, error:'not-validator' });
111
- const rid = parseInt(req.params.rid, 10);
112
- const r = await logic.setStatus(rid, 'refused');
113
- if (!r) return res.status(404).json({ ok:false, error:'not-found' });
114
- await email.sendToUid('calendar-onekite-refused', r.uid, {
115
- subject: 'Réservation refusée',
116
- startDate: r.startDate,
117
- endDate: r.endDate,
118
- rid: r.rid,
173
+ // Prevent double-booking (reserved OR awaiting payment OR approved) for any selected item
174
+ // Query candidates by start range; then filter by overlap and item intersection
175
+ const candidateIds = await dbi.listReservationIdsByStartRange(startTs - 86400000, endTs + 86400000, 10000);
176
+ const candidates = await dbi.getReservations(candidateIds);
177
+ const wanted = new Set(itemIds);
178
+ const conflicts = [];
179
+ for (const r of candidates) {
180
+ if (!r) continue;
181
+ const status = r.status || 'pending';
182
+ if (!BLOCKING_STATUSES.has(status)) continue;
183
+ if (status === 'pending' && r.expiresAt && Number(r.expiresAt) <= Date.now()) continue;
184
+ if (!overlaps(startTs, endTs, Number(r.startTs), Number(r.endTs))) continue;
185
+ const rItemIds = (r.items || []).map(it => String(it.id));
186
+ const hit = rItemIds.filter(x => wanted.has(x));
187
+ if (hit.length) {
188
+ conflicts.push({ id: r.id, status, itemIds: hit, startYMD: r.startYMD, endYMD: r.endYMD });
189
+ }
190
+ }
191
+ if (conflicts.length) {
192
+ return res.status(409).json({
193
+ status: { code: 'conflict', message: 'Matériel déjà réservé ou en attente de paiement.' },
194
+ conflicts,
119
195
  });
120
- json(res, { ok:true });
121
- });
196
+ }
197
+
198
+ const settings = await getSettings();
199
+ const holdMinutes = Math.max(1, parseInt(settings.holdMinutes, 10) || 5);
200
+ const expiresAt = Date.now() + holdMinutes * 60 * 1000;
201
+
202
+ const resv = {
203
+ id,
204
+ uid: String(uid),
205
+ startTs,
206
+ endTs,
207
+ startYMD,
208
+ endYMD,
209
+ items: chosen,
210
+ days,
211
+ totalCents,
212
+ status: 'pending',
213
+ createdAt: Date.now(),
214
+ expiresAt,
215
+ };
216
+ await dbi.saveReservation(resv);
217
+
218
+ res.json({ ok: true, reservation: resv });
122
219
  }
123
220
 
124
- module.exports = { mount };
221
+ module.exports = {
222
+ getEvents,
223
+ getCatalog,
224
+ createReservation,
225
+ _sweepExpiredPending: sweepExpiredPending,
226
+ _getCatalogItems: getCatalogItems,
227
+ _catalogCache: () => catalogCache,
228
+ };
package/lib/db.js CHANGED
@@ -3,86 +3,75 @@
3
3
  const db = require.main.require('./src/database');
4
4
 
5
5
  const KEYS = {
6
- nextRid: 'calendar-onekite:nextRid',
7
- reservation: (rid) => `calendar-onekite:reservation:${rid}`,
8
- zsetStart: 'calendar-onekite:reservationsByStart',
9
- zsetExpires: 'calendar-onekite:reservationsByExpires',
6
+ Z_BY_START: 'calendar-onekite:reservations:byStart',
7
+ Z_BY_EXPIRES: 'calendar-onekite:reservations:byExpires',
8
+ HASH: (id) => `calendar-onekite:reservation:${id}`,
9
+ NEXT_ID: 'calendar-onekite:reservation:nextId',
10
10
  };
11
11
 
12
12
  async function nextId() {
13
- const rid = await db.incrObjectField('global', KEYS.nextRid);
14
- return rid;
15
- }
16
-
17
- function safeJson(v, fallback) {
18
- try { return JSON.parse(v); } catch { return fallback; }
19
- }
20
-
21
- function hydrate(obj) {
22
- return {
23
- rid: parseInt(obj.rid, 10),
24
- uid: parseInt(obj.uid, 10),
25
- startDate: obj.startDate,
26
- endDate: obj.endDate,
27
- startTs: parseInt(obj.startTs, 10),
28
- endTs: parseInt(obj.endTs, 10),
29
- status: obj.status,
30
- createdAt: parseInt(obj.createdAt, 10),
31
- expiresAt: parseInt(obj.expiresAt || '0', 10),
32
- itemIds: safeJson(obj.itemIds, []),
33
- days: parseInt(obj.days || '0', 10),
34
- totalCents: parseInt(obj.totalCents || '0', 10),
35
- title: obj.title || '',
36
- note: obj.note || '',
37
- };
13
+ const id = await db.incrObjectField('calendar-onekite:meta', 'nextId');
14
+ return String(id);
38
15
  }
39
16
 
40
17
  async function saveReservation(resv) {
41
- const key = KEYS.reservation(resv.rid);
42
- await db.setObject(key, {
43
- rid: String(resv.rid),
44
- uid: String(resv.uid),
45
- startDate: String(resv.startDate),
46
- endDate: String(resv.endDate),
47
- startTs: String(resv.startTs),
48
- endTs: String(resv.endTs),
49
- status: String(resv.status),
50
- createdAt: String(resv.createdAt),
51
- expiresAt: String(resv.expiresAt || 0),
52
- itemIds: JSON.stringify(resv.itemIds || []),
53
- days: String(resv.days || 0),
54
- totalCents: String(resv.totalCents || 0),
55
- title: String(resv.title || ''),
56
- note: String(resv.note || ''),
57
- });
58
- await db.sortedSetAdd(KEYS.zsetStart, resv.startTs, String(resv.rid));
18
+ await db.setObject(KEYS.HASH(resv.id), resv);
19
+ await db.sortedSetAdd(KEYS.Z_BY_START, resv.startTs, resv.id);
20
+
21
+ // Track expiration for pending reservations
59
22
  if (resv.expiresAt) {
60
- await db.sortedSetAdd(KEYS.zsetExpires, resv.expiresAt, String(resv.rid));
23
+ await db.sortedSetAdd(KEYS.Z_BY_EXPIRES, Number(resv.expiresAt), resv.id);
61
24
  } else {
62
- await db.sortedSetRemove(KEYS.zsetExpires, String(resv.rid));
25
+ await db.sortedSetRemove(KEYS.Z_BY_EXPIRES, resv.id);
63
26
  }
64
27
  }
65
28
 
66
- async function getReservation(rid) {
67
- const obj = await db.getObject(KEYS.reservation(rid));
68
- if (!obj || !obj.rid) return null;
69
- return hydrate(obj);
29
+ async function getReservation(id) {
30
+ return await db.getObject(KEYS.HASH(id));
31
+ }
32
+
33
+ async function getReservations(ids) {
34
+ if (!ids.length) return [];
35
+ const keys = ids.map(KEYS.HASH);
36
+ const objects = await db.getObjects(keys);
37
+ return objects.filter(Boolean);
38
+ }
39
+
40
+ async function listReservationIdsByStartRange(startTs, endTs, limit = 2000) {
41
+ // NodeBB db API: getSortedSetRangeByScore(set, start, stop, min, max)
42
+ const ids = await db.getSortedSetRangeByScore(KEYS.Z_BY_START, 0, limit - 1, startTs, endTs);
43
+ return ids || [];
70
44
  }
71
45
 
72
- async function listReservationIdsByStartRange(startTs, endTs, limit = 500) {
73
- const ids = await db.getSortedSetRangeByScore(KEYS.zsetStart, 0, limit - 1, startTs, endTs);
74
- return (ids || []).map(String);
46
+ async function deleteReservation(id) {
47
+ await db.delete(KEYS.HASH(id));
48
+ await db.sortedSetRemove(KEYS.Z_BY_START, id);
49
+ await db.sortedSetRemove(KEYS.Z_BY_EXPIRES, id);
75
50
  }
76
51
 
77
- async function listExpiredIds(nowTs, limit = 500) {
78
- const ids = await db.getSortedSetRangeByScore(KEYS.zsetExpires, 0, limit - 1, 0, nowTs);
79
- return (ids || []).map(String);
52
+ async function listExpiredIds(nowTs, limit = 5000) {
53
+ // NodeBB db API: getSortedSetRangeByScore(set, start, stop, min, max)
54
+ const ids = await db.getSortedSetRangeByScore(KEYS.Z_BY_EXPIRES, 0, limit - 1, 0, nowTs);
55
+ return ids || [];
80
56
  }
81
57
 
82
- async function removeReservation(rid) {
83
- await db.delete(KEYS.reservation(rid));
84
- await db.sortedSetRemove(KEYS.zsetStart, String(rid));
85
- await db.sortedSetRemove(KEYS.zsetExpires, String(rid));
58
+ async function clearExpiryIndex(id) {
59
+ await db.sortedSetRemove(KEYS.Z_BY_EXPIRES, id);
86
60
  }
87
61
 
88
- module.exports = { nextId, saveReservation, getReservation, listReservationIdsByStartRange, listExpiredIds, removeReservation };
62
+ async function listAllIds(limit=100000) {
63
+ return await db.getSortedSetRange(KEYS.Z_BY_START, 0, limit-1);
64
+ }
65
+
66
+ module.exports = {
67
+ KEYS,
68
+ nextId,
69
+ saveReservation,
70
+ getReservation,
71
+ getReservations,
72
+ listReservationIdsByStartRange,
73
+ deleteReservation,
74
+ listExpiredIds,
75
+ clearExpiryIndex,
76
+ listAllIds,
77
+ };