nodebb-plugin-calendar-onekite 11.1.26 → 11.1.28

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,159 +1,87 @@
1
1
  'use strict';
2
2
 
3
3
  const meta = require.main.require('./src/meta');
4
- const api = require('./api');
5
- const store = require('./db');
6
- const helloasso = require('./helloasso');
4
+ const User = require.main.require('./src/user');
5
+
6
+ const HelloAsso = require('./helloasso');
7
+ const Store = require('./db');
7
8
 
8
9
  const PLUGIN_ID = 'calendar-onekite';
9
10
 
10
11
  function normalizeCsv(v) {
11
- return String(v || '')
12
- .split(',')
13
- .map(s => s.trim())
14
- .filter(Boolean)
15
- .join(',');
12
+ return String(v || '').split(',').map(s => s.trim()).filter(Boolean).join(',');
16
13
  }
17
14
 
18
- async function getSettings(req, res) {
15
+ exports.getSettings = async (req, res) => {
19
16
  const settings = await meta.settings.get(PLUGIN_ID) || {};
20
- if (settings.helloassoClientSecret) settings.helloassoClientSecret = '***';
21
- res.json({ ok: true, settings });
22
- }
17
+ // never expose secret
18
+ const safe = { ...settings };
19
+ if (safe.helloassoClientSecret) safe.helloassoClientSecret = '***';
20
+ res.json({ ok: true, settings: safe });
21
+ };
23
22
 
24
- async function saveSettings(req, res) {
23
+ exports.putSettings = async (req, res) => {
25
24
  const body = req.body || {};
26
- const current = await meta.settings.get(PLUGIN_ID) || {};
25
+ const existing = await meta.settings.get(PLUGIN_ID) || {};
26
+
27
27
  const toSave = {
28
- helloassoEnv: body.helloassoEnv || current.helloassoEnv || 'sandbox',
29
- helloassoClientId: body.helloassoClientId || '',
30
- helloassoClientSecret: body.helloassoClientSecret && body.helloassoClientSecret !== '***'
31
- ? body.helloassoClientSecret
32
- : (current.helloassoClientSecret || ''),
33
- helloassoOrganizationSlug: body.helloassoOrganizationSlug || '',
34
- helloassoFormType: body.helloassoFormType || 'shop',
35
- helloassoFormSlug: body.helloassoFormSlug || '',
36
-
37
- creatorGroups: normalizeCsv(body.creatorGroups),
38
- validatorGroups: normalizeCsv(body.validatorGroups),
39
- notifyGroups: normalizeCsv(body.notifyGroups),
40
-
41
- holdMinutes: parseInt(body.holdMinutes, 10) || 5,
28
+ helloassoEnv: body.helloassoEnv || existing.helloassoEnv || 'sandbox',
29
+ helloassoClientId: body.helloassoClientId ?? existing.helloassoClientId ?? '',
30
+ // allow setting secret only if provided and not "***"
31
+ helloassoClientSecret: (body.helloassoClientSecret && body.helloassoClientSecret !== '***') ? body.helloassoClientSecret : (existing.helloassoClientSecret || ''),
32
+ helloassoOrganizationSlug: body.helloassoOrganizationSlug ?? existing.helloassoOrganizationSlug ?? '',
33
+ helloassoFormType: body.helloassoFormType || existing.helloassoFormType || 'shop',
34
+ helloassoFormSlug: body.helloassoFormSlug ?? existing.helloassoFormSlug ?? '',
35
+
36
+ creatorGroups: normalizeCsv(body.creatorGroups ?? existing.creatorGroups),
37
+ validatorGroups: normalizeCsv(body.validatorGroups ?? existing.validatorGroups),
38
+ notifyGroups: normalizeCsv(body.notifyGroups ?? existing.notifyGroups),
39
+
40
+ holdMinutes: parseInt(body.holdMinutes, 10) || parseInt(existing.holdMinutes, 10) || 5,
42
41
  };
43
42
 
44
43
  await meta.settings.set(PLUGIN_ID, toSave);
45
44
  res.json({ ok: true });
46
- }
45
+ };
47
46
 
48
- async function listPending(req, res) {
49
- await api.sweepExpired();
50
- const now = Date.now();
51
- const ids = await store.listIdsByStartMax(now + 365 * 24 * 3600 * 1000, 5000);
52
- const pending = [];
53
- for (const rid of ids) {
54
- // eslint-disable-next-line no-await-in-loop
55
- const r = await store.getReservation(rid);
56
- if (r && String(r.status) === 'pending') pending.push(r);
57
- }
58
- pending.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
47
+ exports.getPending = async (req, res) => {
48
+ const pending = await Store.listPending();
59
49
  res.json({ ok: true, pending });
60
- }
50
+ };
61
51
 
62
- async function purgeByYear(req, res) {
63
- const year = String((req.body && req.body.year) || '').trim();
64
- if (!/^\d{4}$/.test(year)) return res.status(400).json({ ok: false, error: 'invalid-year' });
65
-
66
- const y = Number(year);
67
- const start = Date.UTC(y, 0, 1, 0, 0, 0, 0);
68
- const end = Date.UTC(y + 1, 0, 1, 0, 0, 0, 0);
69
-
70
- const ids = await store.listIdsByStartMax(end, 100000);
71
- let purged = 0;
72
- for (const rid of ids) {
73
- // eslint-disable-next-line no-await-in-loop
74
- const r = await store.getReservation(rid);
75
- if (!r || !r.startTs) continue;
76
- const s = Number(r.startTs);
77
- if (s >= start && s < end) {
78
- // eslint-disable-next-line no-await-in-loop
79
- await store.deleteReservation(rid);
80
- purged++;
81
- }
52
+ exports.purge = async (req, res) => {
53
+ const year = String((req.body || {}).year || '').trim();
54
+ if (!/^\d{4}$/.test(year)) {
55
+ return res.status(400).json({ ok: false, error: 'invalid-year' });
82
56
  }
83
- res.json({ ok: true, purged });
84
- }
57
+ const removed = await Store.purgeByYear(parseInt(year, 10));
58
+ res.json({ ok: true, removed });
59
+ };
85
60
 
86
- async function debugHelloAsso(req, res) {
61
+ exports.debugHelloAsso = async (req, res) => {
87
62
  const settings = await meta.settings.get(PLUGIN_ID) || {};
88
63
  const safe = { ...settings };
89
64
  if (safe.helloassoClientSecret) safe.helloassoClientSecret = '***';
90
65
 
91
- const out = {
92
- ok: true,
93
- settings: safe,
94
- token: { ok: false },
95
- catalog: { ok: false, count: 0, sample: [] },
96
- soldItems: { ok: false, count: 0, sample: [] },
97
- };
98
-
99
- if (!settings.helloassoClientId || !settings.helloassoClientSecret) {
100
- return res.json(out);
101
- }
66
+ const out = { ok: true, settings: safe, token: { ok: false }, catalog: { ok: false, count: 0, sample: [] } };
102
67
 
103
- const tok = await helloasso.getToken(settings);
104
- out.token = { ok: tok.ok };
105
- if (!tok.ok) {
106
- out.token.error = tok.error;
107
- return res.json(out);
108
- }
109
-
110
- const pub = await helloasso.getPublicForm(settings, tok.token);
111
- if (!pub.error) {
112
- const items = helloasso.extractCatalogItems(pub.body);
113
- out.catalog = { ok: true, count: items.length, sample: items.slice(0, 10) };
114
- } else {
115
- out.catalog = { ok: false, error: pub.body || pub.raw };
68
+ try {
69
+ const token = await HelloAsso.getToken(settings);
70
+ out.token.ok = !!token;
71
+ } catch (e) {
72
+ out.token.ok = false;
73
+ out.token.error = e.message;
116
74
  }
117
75
 
118
- // optional "items" endpoint (may be empty without sales)
119
76
  try {
120
- const https = require('https');
121
- const host = (settings.helloassoEnv === 'sandbox') ? 'api.helloasso-sandbox.com' : 'api.helloasso.com';
122
- const path = `/v5/organizations/${encodeURIComponent(settings.helloassoOrganizationSlug)}/forms/${encodeURIComponent(settings.helloassoFormType)}/${encodeURIComponent(settings.helloassoFormSlug)}/items?pageIndex=1&pageSize=50`;
123
- const r = await new Promise((resolve) => {
124
- const req2 = https.request({ method: 'GET', host, path, headers: { Authorization: `Bearer ${tok.token}` } }, (resp) => {
125
- let data = '';
126
- resp.on('data', (d) => { data += d; });
127
- resp.on('end', () => {
128
- let parsed = null;
129
- try { parsed = data ? JSON.parse(data) : null; } catch (e) { /* ignore */ }
130
- resolve({ statusCode: resp.statusCode, body: parsed });
131
- });
132
- });
133
- req2.on('error', () => resolve({ statusCode: 0, body: null }));
134
- req2.end();
135
- });
136
-
137
- if (r.statusCode >= 200 && r.statusCode < 300 && r.body) {
138
- const arr = Array.isArray(r.body.data) ? r.body.data : (Array.isArray(r.body) ? r.body : []);
139
- const sample = arr.slice(0, 10).map(x => ({
140
- id: String(x.id || x.itemId || ''),
141
- name: x.name || x.label || '',
142
- priceCents: x.price || x.amount || 0,
143
- }));
144
- out.soldItems = { ok: true, count: arr.length, sample };
145
- }
77
+ const catalog = await HelloAsso.getCatalog(settings);
78
+ out.catalog.ok = true;
79
+ out.catalog.count = catalog.length;
80
+ out.catalog.sample = catalog.slice(0, 10);
146
81
  } catch (e) {
147
- // ignore
82
+ out.catalog.ok = false;
83
+ out.catalog.error = e.message;
148
84
  }
149
85
 
150
- return res.json(out);
151
- }
152
-
153
- module.exports = {
154
- getSettings,
155
- saveSettings,
156
- listPending,
157
- purgeByYear,
158
- debugHelloAsso,
86
+ res.json(out);
159
87
  };
package/lib/api.js CHANGED
@@ -1,323 +1,60 @@
1
1
  'use strict';
2
2
 
3
3
  const meta = require.main.require('./src/meta');
4
- const User = (() => { try { return require.main.require('./src/user'); } catch (e) { return null; }})();
5
- const { isInAnyGroup } = require('./middlewares');
6
- const store = require('./db');
7
- const helloasso = require('./helloasso');
8
- const mailer = require('./mailer');
4
+ const groups = require.main.require('./src/groups');
9
5
 
10
- const PLUGIN_ID = 'calendar-onekite';
11
-
12
- let sweeperStarted = false;
13
- let cachedCatalog = { ts: 0, items: [] };
14
-
15
- function parseIntSafe(v, def) {
16
- const n = parseInt(v, 10);
17
- return Number.isFinite(n) ? n : def;
18
- }
6
+ const HelloAsso = require('./helloasso');
7
+ const Store = require('./db');
19
8
 
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 day = String(d.getUTCDate()).padStart(2, '0');
25
- return `${y}-${m}-${day}`;
26
- }
27
-
28
- function overlap(aStart, aEnd, bStart, bEnd) {
29
- return aStart < bEnd && bStart < aEnd;
30
- }
31
-
32
- async function loadSettings() {
33
- return await meta.settings.get(PLUGIN_ID) || {};
34
- }
35
-
36
- async function refreshCatalog(settings) {
37
- const now = Date.now();
38
- if (cachedCatalog.items.length && (now - cachedCatalog.ts) < 5 * 60 * 1000) {
39
- return cachedCatalog.items;
40
- }
41
- if (!settings.helloassoClientId || !settings.helloassoClientSecret || !settings.helloassoOrganizationSlug || !settings.helloassoFormSlug) {
42
- cachedCatalog = { ts: now, items: [] };
43
- return [];
44
- }
45
- const tok = await helloasso.getToken(settings);
46
- if (!tok.ok) {
47
- cachedCatalog = { ts: now, items: [] };
48
- return [];
49
- }
50
- const pub = await helloasso.getPublicForm(settings, tok.token);
51
- if (pub.error) {
52
- cachedCatalog = { ts: now, items: [] };
53
- return [];
54
- }
55
- const items = helloasso.extractCatalogItems(pub.body);
56
- cachedCatalog = { ts: now, items };
57
- return items;
58
- }
59
-
60
- async function sweepExpired() {
61
- const settings = await loadSettings();
62
- const now = Date.now();
63
- const ids = await store.listExpiredIds(now, 5000);
64
- for (const ridStr of ids) {
65
- // eslint-disable-next-line no-await-in-loop
66
- const resv = await store.getReservation(ridStr);
67
- if (!resv || !resv.status) {
68
- // eslint-disable-next-line no-await-in-loop
69
- await store.removeFromAllIndexes(ridStr);
70
- continue;
71
- }
72
- const status = String(resv.status);
73
- if (['pending', 'awaiting_payment'].includes(status)) {
74
- resv.status = 'expired';
75
- // eslint-disable-next-line no-await-in-loop
76
- await store.setReservation(ridStr, resv);
77
- // stop blocking
78
- // eslint-disable-next-line no-await-in-loop
79
- await store.removeFromAllIndexes(ridStr);
80
- } else {
81
- // eslint-disable-next-line no-await-in-loop
82
- await store.removeFromAllIndexes(ridStr);
83
- }
84
- }
85
- }
86
-
87
- function startSweeper() {
88
- if (sweeperStarted) return;
89
- sweeperStarted = true;
90
- setInterval(() => {
91
- sweepExpired().catch(() => {});
92
- }, 60 * 1000).unref();
93
- }
94
-
95
- async function getEvents(req, res) {
96
- try {
97
- await sweepExpired();
98
- const start = parseIntSafe(req.query.startTs, 0);
99
- const end = parseIntSafe(req.query.endTs, Date.now() + 365 * 24 * 3600 * 1000);
100
-
101
- const ids = await store.listIdsByStartMax(end, 5000);
102
- const events = [];
103
- for (const rid of ids) {
104
- // eslint-disable-next-line no-await-in-loop
105
- const r = await store.getReservation(rid);
106
- if (!r || !r.startTs || !r.endTs) continue;
107
- if (!overlap(Number(r.startTs), Number(r.endTs), start, end)) continue;
108
-
109
- const status = String(r.status || 'pending');
110
- const title = (Array.isArray(r.items) ? r.items.map(it => it.name).join(', ') : (r.itemName || 'Réservation'));
111
- events.push({
112
- id: String(r.rid),
113
- title,
114
- start: toYMD(r.startTs),
115
- end: toYMD(r.endTs), // end is exclusive, ok for allDay
116
- allDay: true,
117
- extendedProps: {
118
- status,
119
- items: r.items || [],
120
- requestedByUid: r.uid,
121
- totalCents: r.totalCents || 0,
122
- days: r.days || 0,
123
- },
124
- });
125
- }
126
-
127
- res.json({ ok: true, events });
128
- } catch (e) {
129
- res.status(500).json({ ok: false, error: 'events-error' });
130
- }
131
- }
9
+ const PLUGIN_ID = 'calendar-onekite';
132
10
 
133
- async function getCatalogItems(req, res) {
134
- try {
135
- const settings = await loadSettings();
136
- const items = await refreshCatalog(settings);
137
- res.json({ ok: true, items });
138
- } catch (e) {
139
- res.status(500).json({ ok: false, error: 'items-error' });
140
- }
11
+ function csvToSet(v) {
12
+ return new Set(String(v || '').split(',').map(s => s.trim()).filter(Boolean));
141
13
  }
142
14
 
143
- async function createReservation(req, res) {
144
- try {
145
- await sweepExpired();
146
- const settings = await loadSettings();
147
- const uid = Number(req.uid) || 0;
148
-
149
- // Check creator group
150
- const creatorGroups = settings.creatorGroups || '';
151
- if (creatorGroups) {
152
- const ok = await isInAnyGroup(uid, creatorGroups);
153
- if (!ok) return res.status(403).json({ ok: false, error: 'not-allowed-to-create' });
154
- }
155
-
156
- const body = req.body || {};
157
- const startTs = parseIntSafe(body.startTs, 0);
158
- const endTs = parseIntSafe(body.endTs, 0);
159
- const itemIds = Array.isArray(body.itemIds) ? body.itemIds.map(String).filter(Boolean) : [];
160
- if (!startTs || !endTs || endTs <= startTs) return res.status(400).json({ ok: false, error: 'invalid-dates' });
161
- if (!itemIds.length) return res.status(400).json({ ok: false, error: 'no-items' });
162
-
163
- const dayMs = 24 * 3600 * 1000;
164
- const days = Math.max(1, Math.round((endTs - startTs) / dayMs));
165
- const catalog = await refreshCatalog(settings);
166
- if (!catalog.length) return res.status(400).json({ ok: false, error: 'no-catalog' });
167
-
168
- const selected = catalog.filter(it => itemIds.includes(String(it.id)));
169
- if (!selected.length) return res.status(400).json({ ok: false, error: 'items-not-found' });
170
-
171
- // Conflict check: fetch all reservations starting before endTs and check overlap + item intersection
172
- const ids = await store.listIdsByStartMax(endTs, 5000);
173
- const blockingStatuses = new Set(['pending', 'awaiting_payment', 'approved', 'paid']);
174
- const conflicts = [];
175
- for (const rid of ids) {
176
- // eslint-disable-next-line no-await-in-loop
177
- const r = await store.getReservation(rid);
178
- if (!r || !r.startTs || !r.endTs) continue;
179
- if (!blockingStatuses.has(String(r.status))) continue;
180
- if (!overlap(Number(r.startTs), Number(r.endTs), startTs, endTs)) continue;
181
- const rItemIds = (Array.isArray(r.items) ? r.items.map(x => String(x.id)) : (r.itemId ? [String(r.itemId)] : []));
182
- const overlapItems = rItemIds.filter(id => itemIds.includes(id));
183
- if (overlapItems.length) {
184
- conflicts.push({ rid: r.rid, itemIds: overlapItems, startTs: r.startTs, endTs: r.endTs, status: r.status });
185
- }
186
- }
187
- if (conflicts.length) {
188
- return res.status(409).json({ ok: false, error: 'conflict', conflicts });
189
- }
190
-
191
- const totalPerDay = selected.reduce((s, it) => s + Number(it.priceCents || 0), 0);
192
- const totalCents = totalPerDay * days;
193
-
194
- const holdMinutes = parseIntSafe(settings.holdMinutes, 5);
195
- const expiresAt = Date.now() + (holdMinutes * 60 * 1000);
196
-
197
- const resv = {
198
- uid,
199
- startTs,
200
- endTs,
201
- days,
202
- items: selected,
203
- totalCents,
204
- status: 'pending',
205
- createdAt: Date.now(),
206
- expiresAt,
207
- };
208
-
209
- const rid = await store.createReservation(resv);
210
-
211
- // Notify validators/admins group(s) by email
212
- await mailer.notifyGroups('calendar-onekite-pending', settings.notifyGroups || '', {
213
- rid,
214
- start: toYMD(startTs),
215
- end: toYMD(endTs),
216
- items: selected.map(i => i.name).join(', '),
217
- total: (totalCents / 100).toFixed(2),
218
- });
219
-
220
- res.json({ ok: true, rid });
221
- } catch (e) {
222
- res.status(500).json({ ok: false, error: 'create-error' });
15
+ async function isMemberOfAny(uid, csv) {
16
+ const set = csvToSet(csv);
17
+ if (!uid || set.size === 0) return true; // if empty => allow
18
+ for (const g of set) {
19
+ try {
20
+ const ok = await groups.isMember(uid, g);
21
+ if (ok) return true;
22
+ } catch {}
223
23
  }
24
+ return false;
224
25
  }
225
26
 
226
- async function approveReservation(req, res) {
227
- try {
228
- await sweepExpired();
229
- const settings = await loadSettings();
230
- const rid = String(req.params.rid);
231
- const resv = await store.getReservation(rid);
232
- if (!resv || String(resv.status) !== 'pending') return res.status(404).json({ ok: false, error: 'not-found' });
233
-
234
- // Create payment link via HelloAsso
235
- let paymentUrl = '';
236
- const catalog = await refreshCatalog(settings);
237
- // ensure items have latest price
238
- const map = new Map(catalog.map(i => [String(i.id), i]));
239
- const selected = (resv.items || []).map(it => map.get(String(it.id)) || it);
240
- const totalPerDay = selected.reduce((s, it) => s + Number(it.priceCents || 0), 0);
241
- const totalCents = totalPerDay * Number(resv.days || 1);
242
- resv.items = selected;
243
- resv.totalCents = totalCents;
244
-
245
- if (settings.helloassoClientId && settings.helloassoClientSecret) {
246
- const tok = await helloasso.getToken(settings);
247
- if (tok.ok) {
248
- const payer = {};
249
- if (User && typeof User.getUserField === 'function') {
250
- try {
251
- const email = await User.getUserField(resv.uid, 'email');
252
- if (email) payer.email = email;
253
- } catch (e) { /* ignore */ }
254
- }
255
- const checkout = await helloasso.createCheckoutIntent(settings, tok.token, resv, payer);
256
- if (!checkout.error && checkout.body) {
257
- paymentUrl = checkout.body.redirectUrl || checkout.body.checkoutUrl || '';
258
- }
259
- }
260
- }
261
-
262
- resv.status = paymentUrl ? 'awaiting_payment' : 'approved';
263
- resv.paymentUrl = paymentUrl;
264
- resv.approvedAt = Date.now();
265
- resv.expiresAt = 0;
266
- await store.setReservation(rid, resv);
267
- await store.clearExpireIndex(rid);
268
- await store.ensureStartIndex(rid, resv.startTs);
269
-
270
- // Email requester
271
- await mailer.notifyUser('calendar-onekite-approved', Number(resv.uid), {
272
- rid,
273
- start: toYMD(resv.startTs),
274
- end: toYMD(resv.endTs),
275
- items: (resv.items || []).map(i => i.name).join(', '),
276
- total: (resv.totalCents / 100).toFixed(2),
277
- paymentUrl: paymentUrl || '',
278
- });
279
-
280
- res.json({ ok: true, paymentUrl });
281
- } catch (e) {
282
- res.status(500).json({ ok: false, error: 'approve-error' });
283
- }
284
- }
27
+ exports.getEvents = async (req, res) => {
28
+ const start = req.query.start;
29
+ const end = req.query.end;
30
+ const events = await Store.listEvents(start, end);
31
+ res.json(events);
32
+ };
285
33
 
286
- async function refuseReservation(req, res) {
287
- try {
288
- await sweepExpired();
289
- const rid = String(req.params.rid);
290
- const resv = await store.getReservation(rid);
291
- if (!resv || String(resv.status) !== 'pending') return res.status(404).json({ ok: false, error: 'not-found' });
34
+ exports.getCatalog = async (req, res) => {
35
+ const settings = await meta.settings.get(PLUGIN_ID) || {};
36
+ const catalog = await HelloAsso.getCatalog(settings);
37
+ res.json({ ok: true, catalog });
38
+ };
292
39
 
293
- resv.status = 'refused';
294
- resv.refusedAt = Date.now();
295
- resv.expiresAt = 0;
296
- await store.setReservation(rid, resv);
297
- await store.removeFromAllIndexes(rid);
40
+ exports.createReservation = async (req, res) => {
41
+ const uid = parseInt(req.uid, 10) || 0;
42
+ if (!uid) return res.status(401).json({ ok: false, error: 'not-logged-in' });
298
43
 
299
- await mailer.notifyUser('calendar-onekite-refused', Number(resv.uid), {
300
- rid,
301
- start: toYMD(resv.startTs),
302
- end: toYMD(resv.endTs),
303
- items: (resv.items || []).map(i => i.name).join(', '),
304
- });
44
+ const settings = await meta.settings.get(PLUGIN_ID) || {};
45
+ const canCreate = await isMemberOfAny(uid, settings.creatorGroups);
46
+ if (!canCreate) return res.status(403).json({ ok: false, error: 'not-allowed' });
305
47
 
306
- res.json({ ok: true });
307
- } catch (e) {
308
- res.status(500).json({ ok: false, error: 'refuse-error' });
48
+ const body = req.body || {};
49
+ const start = String(body.start || '').slice(0, 10);
50
+ const end = String(body.end || '').slice(0, 10);
51
+ const itemIds = Array.isArray(body.itemIds) ? body.itemIds.map(String) : [];
52
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(start) || !/^\d{4}-\d{2}-\d{2}$/.test(end) || itemIds.length === 0) {
53
+ return res.status(400).json({ ok: false, error: 'invalid-payload' });
309
54
  }
310
- }
311
55
 
312
- module.exports = {
313
- startSweeper,
314
- getEvents,
315
- getCatalogItems,
316
- createReservation,
317
- approveReservation,
318
- refuseReservation,
319
- // exported for admin uses
320
- sweepExpired,
321
- refreshCatalog,
322
- toYMD,
56
+ // Save as pending with expiration
57
+ const holdMinutes = parseInt(settings.holdMinutes, 10) || 5;
58
+ const reservation = await Store.createPending(uid, start, end, itemIds, holdMinutes);
59
+ res.json({ ok: true, reservation });
323
60
  };