nodebb-plugin-calendar-onekite 11.1.23 → 11.1.25

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,76 +1,159 @@
1
1
  'use strict';
2
2
 
3
3
  const meta = require.main.require('./src/meta');
4
- const dbi = require('./db');
5
4
  const api = require('./api');
5
+ const store = require('./db');
6
+ const helloasso = require('./helloasso');
7
+
8
+ const PLUGIN_ID = 'calendar-onekite';
9
+
10
+ function normalizeCsv(v) {
11
+ return String(v || '')
12
+ .split(',')
13
+ .map(s => s.trim())
14
+ .filter(Boolean)
15
+ .join(',');
16
+ }
6
17
 
7
18
  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
+ const settings = await meta.settings.get(PLUGIN_ID) || {};
20
+ if (settings.helloassoClientSecret) settings.helloassoClientSecret = '***';
21
+ res.json({ ok: true, settings });
12
22
  }
13
23
 
14
24
  async function saveSettings(req, res) {
15
25
  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
26
+ const current = await meta.settings.get(PLUGIN_ID) || {};
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,
42
+ };
43
+
44
+ await meta.settings.set(PLUGIN_ID, toSave);
24
45
  res.json({ ok: true });
25
46
  }
26
47
 
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);
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));
33
59
  res.json({ ok: true, pending });
34
60
  }
35
61
 
36
62
  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)' } });
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
+
39
66
  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);
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);
42
69
 
43
- const ids = await dbi.listReservationIdsByStartRange(startTs, endTs, 100000);
44
- for (const id of ids) {
45
- await dbi.deleteReservation(id);
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
+ }
46
82
  }
47
- res.json({ ok: true, deleted: ids.length });
83
+ res.json({ ok: true, purged });
48
84
  }
49
85
 
50
86
  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 };
87
+ const settings = await meta.settings.get(PLUGIN_ID) || {};
88
+ const safe = { ...settings };
89
+ if (safe.helloassoClientSecret) safe.helloassoClientSecret = '***';
90
+
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
+ }
102
+
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);
59
113
  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;
114
+ } else {
115
+ out.catalog = { ok: false, error: pub.body || pub.raw };
116
+ }
117
+
118
+ // optional "items" endpoint (may be empty without sales)
119
+ 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
+ }
63
146
  } catch (e) {
64
- out.token = { ok: false, message: e.message };
65
- out.catalog = { ok: false, count: 0, sample: [], message: e.message };
147
+ // ignore
66
148
  }
67
- res.json(out);
149
+
150
+ return res.json(out);
68
151
  }
69
152
 
70
153
  module.exports = {
71
154
  getSettings,
72
155
  saveSettings,
73
- getPending,
156
+ listPending,
74
157
  purgeByYear,
75
158
  debugHelloAsso,
76
159
  };
package/lib/api.js CHANGED
@@ -1,152 +1,323 @@
1
1
  'use strict';
2
2
 
3
3
  const meta = require.main.require('./src/meta');
4
- const dbi = require('./db');
5
- const hello = require('./helloasso');
6
-
7
- function parseDateParam(s) {
8
- // FullCalendar sends ISO date/time. Accept 'YYYY-MM-DD' too.
9
- const d = new Date(s);
10
- if (!s || Number.isNaN(d.getTime())) return null;
11
- return d;
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');
9
+
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;
12
18
  }
13
19
 
14
20
  function toYMD(ts) {
15
21
  const d = new Date(Number(ts));
16
22
  const y = d.getUTCFullYear();
17
- const m = String(d.getUTCMonth()+1).padStart(2,'0');
18
- const da = String(d.getUTCDate()).padStart(2,'0');
19
- return `${y}-${m}-${da}`;
23
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
24
+ const day = String(d.getUTCDate()).padStart(2, '0');
25
+ return `${y}-${m}-${day}`;
20
26
  }
21
27
 
22
- function daysBetweenInclusive(startYMD, endYMDExclusive) {
23
- // FullCalendar selection end is exclusive. We compute number of days selected.
24
- const s = new Date(startYMD + 'T00:00:00Z');
25
- const e = new Date(endYMDExclusive + 'T00:00:00Z');
26
- const diff = Math.max(0, Math.round((e - s) / 86400000));
27
- return diff || 1;
28
+ function overlap(aStart, aEnd, bStart, bEnd) {
29
+ return aStart < bEnd && bStart < aEnd;
28
30
  }
29
31
 
30
- let catalogCache = { at: 0, items: [], raw: null, ok: false, err: null };
31
-
32
- async function getSettings() {
33
- const settings = await meta.settings.get('calendar-onekite');
34
- return settings || {};
32
+ async function loadSettings() {
33
+ return await meta.settings.get(PLUGIN_ID) || {};
35
34
  }
36
35
 
37
- async function getCatalogItems() {
38
- const settings = await getSettings();
36
+ async function refreshCatalog(settings) {
39
37
  const now = Date.now();
40
- if (catalogCache.ok && (now - catalogCache.at) < 5*60*1000) {
41
- return catalogCache.items;
38
+ if (cachedCatalog.items.length && (now - cachedCatalog.ts) < 5 * 60 * 1000) {
39
+ return cachedCatalog.items;
42
40
  }
43
- if (!settings.helloassoClientId || !settings.helloassoClientSecret || !settings.helloassoOrganizationSlug || !settings.helloassoFormSlug || !settings.helloassoFormType) {
44
- catalogCache = { at: now, items: [], raw: null, ok: true, err: null };
41
+ if (!settings.helloassoClientId || !settings.helloassoClientSecret || !settings.helloassoOrganizationSlug || !settings.helloassoFormSlug) {
42
+ cachedCatalog = { ts: now, items: [] };
45
43
  return [];
46
44
  }
47
- try {
48
- const pub = await hello.getShopCatalog(settings);
49
- const items = hello.extractItemsFromPublic(pub);
50
- catalogCache = { at: now, items, raw: pub, ok: true, err: null };
51
- return items;
52
- } catch (err) {
53
- catalogCache = { at: now, items: [], raw: null, ok: false, err: { message: err.message, statusCode: err.statusCode, body: err.body } };
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: [] };
54
53
  return [];
55
54
  }
55
+ const items = helloasso.extractCatalogItems(pub.body);
56
+ cachedCatalog = { ts: now, items };
57
+ return items;
56
58
  }
57
59
 
58
- async function getCatalog(req, res) {
59
- const items = await getCatalogItems();
60
- res.json({ ok: true, count: items.length, items });
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();
61
93
  }
62
94
 
63
95
  async function getEvents(req, res) {
64
- const start = parseDateParam(req.query.start);
65
- const end = parseDateParam(req.query.end);
66
- const startTs = start ? start.getTime() : Date.now() - 365*86400000;
67
- const endTs = end ? end.getTime() : Date.now() + 365*86400000;
68
-
69
- const ids = await dbi.listReservationIdsByStartRange(startTs, endTs, 5000);
70
- const rows = await dbi.getReservations(ids);
71
-
72
- const events = rows.map(r => {
73
- const itemNames = (r.items || []).map(it => it.name).join(', ');
74
- const title = itemNames || 'Réservation';
75
- const status = r.status || 'pending';
76
- const icon = status === 'approved' ? '✅' : status === 'refused' ? '⛔' : '⏳';
77
- return {
78
- id: r.id,
79
- title: `${icon} ${title}`,
80
- start: toYMD(r.startTs),
81
- end: toYMD(r.endTs), // end exclusive works for allDay
82
- allDay: true,
83
- extendedProps: {
84
- status,
85
- requesterUid: r.uid,
86
- items: r.items || [],
87
- totalCents: r.totalCents || 0,
88
- days: r.days || 1,
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
+ }
132
+
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
+ }
141
+ }
142
+
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 });
89
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,
90
207
  };
91
- });
92
208
 
93
- res.json(events);
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' });
223
+ }
94
224
  }
95
225
 
96
- async function createReservation(req, res) {
97
- const uid = req.uid;
98
- if (!uid) return res.status(403).json({ status: { code: 'forbidden', message: 'Not logged in' } });
99
-
100
- const body = req.body || {};
101
- const startYMD = body.start;
102
- const endYMD = body.end;
103
- const itemIds = Array.isArray(body.itemIds) ? body.itemIds.map(String) : [];
104
- if (!startYMD || !endYMD) return res.status(400).json({ status: { code: 'bad-request', message: 'Missing dates' } });
105
- if (!itemIds.length) return res.status(400).json({ status: { code: 'bad-request', message: 'Missing itemIds' } });
106
-
107
- const days = daysBetweenInclusive(startYMD, endYMD);
108
-
109
- const catalog = await getCatalogItems();
110
- const chosen = [];
111
- let sumPerDay = 0;
112
- for (const id of itemIds) {
113
- const it = catalog.find(x => x.id === id);
114
- if (it) {
115
- chosen.push(it);
116
- sumPerDay += Number(it.priceCents || 0);
117
- } else {
118
- // keep unknown item with 0 price to avoid hard fail
119
- chosen.push({ id, name: `Item ${id}`, priceCents: 0 });
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
+ }
120
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
+ }
285
+
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' });
292
+
293
+ resv.status = 'refused';
294
+ resv.refusedAt = Date.now();
295
+ resv.expiresAt = 0;
296
+ await store.setReservation(rid, resv);
297
+ await store.removeFromAllIndexes(rid);
298
+
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
+ });
305
+
306
+ res.json({ ok: true });
307
+ } catch (e) {
308
+ res.status(500).json({ ok: false, error: 'refuse-error' });
121
309
  }
122
- const totalCents = sumPerDay * days;
123
-
124
- const id = await dbi.nextId();
125
- const startTs = new Date(startYMD + 'T00:00:00Z').getTime();
126
- const endTs = new Date(endYMD + 'T00:00:00Z').getTime();
127
-
128
- const resv = {
129
- id,
130
- uid: String(uid),
131
- startTs,
132
- endTs,
133
- startYMD,
134
- endYMD,
135
- items: chosen,
136
- days,
137
- totalCents,
138
- status: 'pending',
139
- createdAt: Date.now(),
140
- };
141
- await dbi.saveReservation(resv);
142
-
143
- res.json({ ok: true, reservation: resv });
144
310
  }
145
311
 
146
312
  module.exports = {
313
+ startSweeper,
147
314
  getEvents,
148
- getCatalog,
315
+ getCatalogItems,
149
316
  createReservation,
150
- _getCatalogItems: getCatalogItems,
151
- _catalogCache: () => catalogCache,
317
+ approveReservation,
318
+ refuseReservation,
319
+ // exported for admin uses
320
+ sweepExpired,
321
+ refreshCatalog,
322
+ toYMD,
152
323
  };