nodebb-plugin-onekite-calendar 1.0.12 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/admin.js ADDED
@@ -0,0 +1,568 @@
1
+ 'use strict';
2
+
3
+ const meta = require.main.require('./src/meta');
4
+ const user = require.main.require('./src/user');
5
+ const emailer = require.main.require('./src/emailer');
6
+ const nconf = require.main.require('nconf');
7
+
8
+ function forumBaseUrl() {
9
+ const base = String(nconf.get('url') || '').trim().replace(/\/$/, '');
10
+ return base;
11
+ }
12
+
13
+ function formatFR(tsOrIso) {
14
+ const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
15
+ const dd = String(d.getDate()).padStart(2, '0');
16
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
17
+ const yyyy = d.getFullYear();
18
+ return `${dd}/${mm}/${yyyy}`;
19
+ }
20
+
21
+ function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
22
+ const base = String(baseLabel || 'Réservation matériel Onekite').trim();
23
+ const items = Array.isArray(itemNames) ? itemNames.map((s) => String(s || '').trim()).filter(Boolean) : [];
24
+ const range = (start && end) ? `Du ${formatFR(start)} au ${formatFR(end)}` : '';
25
+ const lines = [base];
26
+ items.forEach((it) => lines.push(`• ${it}`));
27
+ if (range) lines.push(range);
28
+ let out = lines.join('\n').trim();
29
+ if (out.length > 250) {
30
+ out = out.slice(0, 249).trimEnd() + '…';
31
+ }
32
+ return out;
33
+ }
34
+
35
+ async function sendEmail(template, toEmail, subject, data) {
36
+ // Prefer sending by uid (NodeBB core expects uid in various places)
37
+ const uid = data && Number.isInteger(data.uid) ? data.uid : null;
38
+ if (!toEmail && !uid) return;
39
+
40
+ const settings = await meta.settings.get('calendar-onekite').catch(() => ({}));
41
+ const lang = (settings && settings.defaultLang) || (meta && meta.config && meta.config.defaultLang) || 'fr';
42
+ const params = Object.assign({}, data || {}, subject ? { subject } : {});
43
+
44
+ // If we have a uid, use the native uid-based sender first.
45
+ try {
46
+ if (uid && typeof emailer.send === 'function') {
47
+ // NodeBB: send(template, uid, params)
48
+ if (emailer.send.length >= 3) {
49
+ await emailer.send(template, uid, params);
50
+ } else {
51
+ await emailer.send(template, uid, params);
52
+ }
53
+ return;
54
+ }
55
+ } catch (err) {
56
+ console.warn('[calendar-onekite] Failed to send email', {
57
+ template,
58
+ toEmail,
59
+ err: err && err.message ? err.message : String(err),
60
+ });
61
+ }
62
+
63
+ try {
64
+ if (typeof emailer.sendToEmail === 'function') {
65
+ // NodeBB: sendToEmail(template, email, language, params)
66
+ if (emailer.sendToEmail.length >= 4) {
67
+ await emailer.sendToEmail(template, toEmail, lang, params);
68
+ } else {
69
+ // Older signature: sendToEmail(template, email, params)
70
+ await emailer.sendToEmail(template, toEmail, params);
71
+ }
72
+ return;
73
+ }
74
+ } catch (err) {
75
+ console.warn('[calendar-onekite] Failed to send email', {
76
+ template,
77
+ toEmail,
78
+ err: err && err.message ? err.message : String(err),
79
+ });
80
+ }
81
+ }
82
+
83
+ function normalizeCallbackUrl(configured, meta) {
84
+ const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
85
+ let url = (configured || '').trim();
86
+ if (!url) {
87
+ url = base ? `${base}/helloasso` : '';
88
+ }
89
+ if (url && url.startsWith('/') && base) {
90
+ url = `${base}${url}`;
91
+ }
92
+ return url;
93
+ }
94
+
95
+ function normalizeReturnUrl(meta) {
96
+ const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
97
+ const b = String(base || '').trim().replace(/\/$/, '');
98
+ if (!b) return '';
99
+ return `${b}/calendar`;
100
+ }
101
+
102
+
103
+ const dbLayer = require('./db');
104
+ const helloasso = require('./helloasso');
105
+
106
+ const ADMIN_PRIV = 'admin:settings';
107
+
108
+ const admin = {};
109
+
110
+ admin.renderAdmin = async function (req, res) {
111
+ res.render('admin/plugins/calendar-onekite', {
112
+ title: 'Calendar Onekite',
113
+ });
114
+ };
115
+
116
+ admin.getSettings = async function (req, res) {
117
+ const settings = await meta.settings.get('calendar-onekite');
118
+ res.json(settings || {});
119
+ };
120
+
121
+ admin.saveSettings = async function (req, res) {
122
+ await meta.settings.set('calendar-onekite', req.body || {});
123
+ res.json({ ok: true });
124
+ };
125
+
126
+ admin.listPending = async function (req, res) {
127
+ const ids = await dbLayer.listAllReservationIds(5000);
128
+ // Batch fetch to avoid N DB round-trips.
129
+ const rows = await dbLayer.getReservations(ids);
130
+ const pending = (rows || []).filter(r => r && r.status === 'pending');
131
+ pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
132
+ res.json(pending);
133
+ };
134
+
135
+ admin.approveReservation = async function (req, res) {
136
+ const rid = req.params.rid;
137
+ const r = await dbLayer.getReservation(rid);
138
+ if (!r) return res.status(404).json({ error: 'not-found' });
139
+
140
+ r.status = 'awaiting_payment';
141
+ r.pickupAddress = String((req.body && (req.body.pickupAddress || req.body.adminNote || req.body.note)) || '').trim();
142
+ r.notes = String((req.body && req.body.notes) || '').trim();
143
+ r.pickupTime = String((req.body && (req.body.pickupTime || req.body.pickup)) || '').trim();
144
+ r.pickupLat = String((req.body && req.body.pickupLat) || '').trim();
145
+ r.pickupLon = String((req.body && req.body.pickupLon) || '').trim();
146
+ r.approvedAt = Date.now();
147
+
148
+ try {
149
+ const approver = await user.getUserFields(req.uid, ['username']);
150
+ r.approvedBy = req.uid;
151
+ r.approvedByUsername = approver && approver.username ? approver.username : '';
152
+ } catch (e) {
153
+ r.approvedBy = req.uid;
154
+ r.approvedByUsername = '';
155
+ }
156
+
157
+ // Create HelloAsso payment link if configured
158
+ const settings = await meta.settings.get('calendar-onekite');
159
+ const env = settings.helloassoEnv || 'prod';
160
+ const token = await helloasso.getAccessToken({
161
+ env,
162
+ clientId: settings.helloassoClientId,
163
+ clientSecret: settings.helloassoClientSecret,
164
+ });
165
+ if (!token) {
166
+
167
+ }
168
+
169
+ let paymentUrl = null;
170
+ if (token) {
171
+ const requester = await user.getUserFields(r.uid, ['username', 'email']);
172
+ // r.total is stored as an estimated total in euros; HelloAsso expects cents.
173
+ const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
174
+ const base = forumBaseUrl();
175
+ const returnUrl = base ? `${base}/calendar` : '';
176
+ const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
177
+ const year = new Date(Number(r.start)).getFullYear();
178
+ const intent = await helloasso.createCheckoutIntent({
179
+ env,
180
+ token,
181
+ organizationSlug: settings.helloassoOrganizationSlug,
182
+ formType: settings.helloassoFormType,
183
+ // Form slug is derived from the year
184
+ formSlug: `locations-materiel-${year}`,
185
+ totalAmount,
186
+ payerEmail: requester && requester.email,
187
+ // User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
188
+ callbackUrl: returnUrl,
189
+ webhookUrl: webhookUrl,
190
+ itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
191
+ containsDonation: false,
192
+ metadata: {
193
+ reservationId: String(rid),
194
+ items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
195
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
196
+ },
197
+ });
198
+ paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
199
+ ? (intent.paymentUrl || intent.redirectUrl)
200
+ : (typeof intent === 'string' ? intent : null);
201
+ if (intent && intent.checkoutIntentId) {
202
+ r.checkoutIntentId = intent.checkoutIntentId;
203
+ }
204
+ }
205
+
206
+ if (paymentUrl) {
207
+ r.paymentUrl = paymentUrl;
208
+ } else {
209
+ console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
210
+ }
211
+
212
+ await dbLayer.saveReservation(r);
213
+
214
+ // Email requester
215
+ try {
216
+ const requester = await user.getUserFields(r.uid, ['username', 'email']);
217
+ if (requester && requester.email) {
218
+ const latNum = Number(r.pickupLat);
219
+ const lonNum = Number(r.pickupLon);
220
+ const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
221
+ ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
222
+ : '';
223
+ await sendEmail('calendar-onekite_approved', requester.email, 'Location matériel - Réservation validée', {
224
+ uid: parseInt(r.uid, 10),
225
+ username: requester.username,
226
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
227
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
228
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
229
+ paymentUrl: paymentUrl || '',
230
+ pickupAddress: r.pickupAddress || '',
231
+ notes: r.notes || '',
232
+ pickupTime: r.pickupTime || '',
233
+ pickupLat: r.pickupLat || '',
234
+ pickupLon: r.pickupLon || '',
235
+ mapUrl,
236
+ validatedBy: r.approvedByUsername || '',
237
+ validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
238
+ });
239
+ }
240
+ } catch (e) {}
241
+
242
+ res.json({ ok: true, paymentUrl: paymentUrl || null });
243
+ };
244
+
245
+ admin.refuseReservation = async function (req, res) {
246
+ const rid = req.params.rid;
247
+ const r = await dbLayer.getReservation(rid);
248
+ if (!r) return res.status(404).json({ error: 'not-found' });
249
+
250
+ r.status = 'refused';
251
+ r.refusedAt = Date.now();
252
+ r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
253
+ await dbLayer.saveReservation(r);
254
+
255
+ try {
256
+ const requester = await user.getUserFields(r.uid, ['username', 'email']);
257
+ if (requester && requester.email) {
258
+ await sendEmail('calendar-onekite_refused', requester.email, 'Location matériel - Réservation refusée', {
259
+ uid: parseInt(r.uid, 10),
260
+ username: requester.username,
261
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
262
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
263
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
264
+ start: formatFR(r.start),
265
+ end: formatFR(r.end),
266
+ refusedReason: r.refusedReason || '',
267
+ });
268
+ }
269
+ } catch (e) {}
270
+
271
+ res.json({ ok: true });
272
+ };
273
+
274
+ admin.purgeByYear = async function (req, res) {
275
+ const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
276
+ if (!/^\d{4}$/.test(year)) {
277
+ return res.status(400).json({ error: 'invalid-year' });
278
+ }
279
+ const y = parseInt(year, 10);
280
+ const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
281
+ const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
282
+
283
+ const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
284
+ let removed = 0;
285
+ for (const rid of ids) {
286
+ const r = await dbLayer.getReservation(rid);
287
+ if (!r) continue;
288
+
289
+ await dbLayer.removeReservation(rid);
290
+ removed++;
291
+ }
292
+ res.json({ ok: true, removed });
293
+ };
294
+
295
+ admin.purgeSpecialEventsByYear = async function (req, res) {
296
+ const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
297
+ if (!/^\d{4}$/.test(year)) {
298
+ return res.status(400).json({ error: 'invalid-year' });
299
+ }
300
+ const y = parseInt(year, 10);
301
+ const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
302
+ const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
303
+
304
+ const ids = await dbLayer.listSpecialIdsByStartRange(startTs, endTs, 100000);
305
+ let count = 0;
306
+ for (const eid of ids) {
307
+ await dbLayer.removeSpecialEvent(eid);
308
+ count++;
309
+ }
310
+ return res.json({ ok: true, removed: count });
311
+ };
312
+
313
+ // Debug endpoint to validate HelloAsso connectivity and item loading
314
+
315
+
316
+ admin.debugHelloAsso = async function (req, res) {
317
+ const settings = await meta.settings.get('calendar-onekite');
318
+ const env = (settings && settings.helloassoEnv) || 'prod';
319
+
320
+ // Never expose secrets in debug output
321
+ const safeSettings = {
322
+ helloassoEnv: env,
323
+ helloassoClientId: settings && settings.helloassoClientId ? String(settings.helloassoClientId) : '',
324
+ helloassoClientSecret: settings && settings.helloassoClientSecret ? '***' : '',
325
+ helloassoOrganizationSlug: settings && settings.helloassoOrganizationSlug ? String(settings.helloassoOrganizationSlug) : '',
326
+ helloassoFormType: settings && settings.helloassoFormType ? String(settings.helloassoFormType) : '',
327
+ helloassoFormSlug: settings && settings.helloassoFormSlug ? String(settings.helloassoFormSlug) : '',
328
+ };
329
+
330
+ const out = {
331
+ ok: true,
332
+ settings: safeSettings,
333
+ token: { ok: false },
334
+ // Catalog = what you actually want for a shop (available products/material)
335
+ catalog: { ok: false, count: 0, sample: [], keys: [] },
336
+ // Sold items = items present in orders (can be 0 if no sales yet)
337
+ soldItems: { ok: false, count: 0, sample: [] },
338
+ };
339
+
340
+ try {
341
+ const token = await helloasso.getAccessToken({
342
+ env,
343
+ clientId: settings.helloassoClientId,
344
+ clientSecret: settings.helloassoClientSecret,
345
+ });
346
+ if (!token) {
347
+ out.token = { ok: false, error: 'token-null' };
348
+ return res.json(out);
349
+ }
350
+ out.token = { ok: true };
351
+
352
+ // Catalog items (via /public)
353
+ try {
354
+ const y = new Date().getFullYear();
355
+ const { publicForm, items } = await helloasso.listCatalogItems({
356
+ env,
357
+ token,
358
+ organizationSlug: settings.helloassoOrganizationSlug,
359
+ formType: settings.helloassoFormType,
360
+ formSlug: `locations-materiel-${y}`,
361
+ });
362
+
363
+ const arr = Array.isArray(items) ? items : [];
364
+ out.catalog.ok = true;
365
+ out.catalog.count = arr.length;
366
+ out.catalog.keys = publicForm && typeof publicForm === 'object' ? Object.keys(publicForm) : [];
367
+ out.catalog.sample = arr.slice(0, 10).map((it) => ({
368
+ id: it.id,
369
+ name: it.name,
370
+ price: it.price ?? null,
371
+ }));
372
+ } catch (e) {
373
+ out.catalog = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [], keys: [] };
374
+ }
375
+
376
+ // Sold items
377
+ try {
378
+ const y2 = new Date().getFullYear();
379
+ const items = await helloasso.listItems({
380
+ env,
381
+ token,
382
+ organizationSlug: settings.helloassoOrganizationSlug,
383
+ formType: settings.helloassoFormType,
384
+ formSlug: `locations-materiel-${y2}`,
385
+ });
386
+ const arr = Array.isArray(items) ? items : [];
387
+ out.soldItems.ok = true;
388
+ out.soldItems.count = arr.length;
389
+ out.soldItems.sample = arr.slice(0, 10).map((it) => ({
390
+ id: it.id || it.itemId || it.reference || it.name,
391
+ name: it.name || it.label || it.itemName,
392
+ price: it.price || it.amount || it.unitPrice || null,
393
+ }));
394
+ } catch (e) {
395
+ out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
396
+ }
397
+
398
+ return res.json(out);
399
+ } catch (e) {
400
+ out.ok = false;
401
+ out.token = { ok: false, error: String(e && e.message ? e.message : e) };
402
+ return res.json(out);
403
+ }
404
+ };
405
+
406
+ // Accounting endpoint: aggregates paid reservations so you can contabilize what was rented.
407
+ // Query params:
408
+ // from=YYYY-MM-DD (inclusive, based on reservation.start)
409
+ // to=YYYY-MM-DD (exclusive, based on reservation.start)
410
+ admin.getAccounting = async function (req, res) {
411
+ const qFrom = String((req.query && req.query.from) || '').trim();
412
+ const qTo = String((req.query && req.query.to) || '').trim();
413
+ const parseDay = (s) => {
414
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
415
+ const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
416
+ const dt = new Date(Date.UTC(y, m - 1, d));
417
+ const ts = dt.getTime();
418
+ return Number.isFinite(ts) ? ts : null;
419
+ };
420
+ const fromTs = parseDay(qFrom);
421
+ const toTs = parseDay(qTo);
422
+
423
+ // Default: last 12 months (UTC)
424
+ const now = new Date();
425
+ const defaultTo = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
426
+ const defaultFrom = Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1);
427
+ const minTs = fromTs ?? defaultFrom;
428
+ const maxTs = toTs ?? defaultTo;
429
+
430
+ const ids = await dbLayer.listAllReservationIds(100000);
431
+ const rows = [];
432
+ const byItem = new Map();
433
+
434
+ for (const rid of ids) {
435
+ const r = await dbLayer.getReservation(rid);
436
+ if (!r) continue;
437
+ if (String(r.status) !== 'paid') continue;
438
+ if (r.accPurgedAt) continue;
439
+ const start = parseInt(r.start, 10);
440
+ if (!Number.isFinite(start)) continue;
441
+ if (start < minTs || start >= maxTs) continue;
442
+
443
+ const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
444
+ ? r.itemNames
445
+ : (r.itemName ? [r.itemName] : []);
446
+
447
+ const total = Number(r.total) || 0;
448
+ const startDate = formatFR(r.start);
449
+ const endDate = formatFR(r.end);
450
+
451
+ rows.push({
452
+ rid: r.rid,
453
+ uid: r.uid,
454
+ username: r.username || '',
455
+ start: r.start,
456
+ end: r.end,
457
+ startDate,
458
+ endDate,
459
+ items: itemNames,
460
+ total,
461
+ paidAt: r.paidAt || '',
462
+ });
463
+
464
+ for (const name of itemNames) {
465
+ const key = String(name || '').trim();
466
+ if (!key) continue;
467
+ const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
468
+ cur.count += 1;
469
+ cur.total += total;
470
+ byItem.set(key, cur);
471
+ }
472
+ }
473
+
474
+ const summary = Array.from(byItem.values()).sort((a, b) => b.count - a.count);
475
+ rows.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
476
+
477
+ return res.json({
478
+ ok: true,
479
+ from: new Date(minTs).toISOString().slice(0, 10),
480
+ to: new Date(maxTs).toISOString().slice(0, 10),
481
+ summary,
482
+ rows,
483
+ });
484
+ };
485
+
486
+ admin.exportAccountingCsv = async function (req, res) {
487
+ // Reuse the same logic and emit a CSV.
488
+ const fakeRes = { json: (x) => x };
489
+ const data = await admin.getAccounting(req, fakeRes);
490
+ // If getAccounting returned via res.json, data is undefined; rebuild by calling logic directly.
491
+ // Easiest: call getAccounting's internals by fetching the endpoint logic via HTTP is not possible here.
492
+ // So we re-run getAccounting but capture output by monkeypatching.
493
+ let payload;
494
+ await admin.getAccounting(req, { json: (x) => { payload = x; return x; } });
495
+ if (!payload || !payload.ok) {
496
+ return res.status(500).send('error');
497
+ }
498
+ const escape = (v) => {
499
+ const s = String(v ?? '');
500
+ if (/[\n\r,\"]/g.test(s)) {
501
+ return '"' + s.replace(/"/g, '""') + '"';
502
+ }
503
+ return s;
504
+ };
505
+ const lines = [];
506
+ lines.push(['rid', 'username', 'uid', 'start', 'end', 'items', 'total', 'paidAt'].map(escape).join(','));
507
+ for (const r of payload.rows || []) {
508
+ lines.push([
509
+ r.rid,
510
+ r.username,
511
+ r.uid,
512
+ r.startDate,
513
+ r.endDate,
514
+ (Array.isArray(r.items) ? r.items.join(' | ') : ''),
515
+ (Number(r.total) || 0).toFixed(2),
516
+ r.paidAt ? new Date(parseInt(r.paidAt, 10)).toISOString() : '',
517
+ ].map(escape).join(','));
518
+ }
519
+ const csv = lines.join('\n');
520
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
521
+ res.setHeader('Content-Disposition', 'attachment; filename="calendar-onekite-accounting.csv"');
522
+ return res.send(csv);
523
+ };
524
+
525
+
526
+ admin.purgeAccounting = async function (req, res) {
527
+ const qFrom = String((req.query && req.query.from) || '').trim();
528
+ const qTo = String((req.query && req.query.to) || '').trim();
529
+ const parseDay = (s) => {
530
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
531
+ const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
532
+ const dt = new Date(Date.UTC(y, m - 1, d));
533
+ const ts = dt.getTime();
534
+ return Number.isFinite(ts) ? ts : null;
535
+ };
536
+ const fromTs = parseDay(qFrom);
537
+ const toTs = parseDay(qTo);
538
+
539
+ const now = new Date();
540
+ const defaultTo = Date.UTC(now.getUTCFullYear() + 100, 0, 1); // far future
541
+ const defaultFrom = Date.UTC(1970, 0, 1);
542
+ const minTs = fromTs ?? defaultFrom;
543
+ const maxTs = toTs ?? defaultTo;
544
+
545
+ const ids = await dbLayer.listAllReservationIds(200000);
546
+ let purged = 0;
547
+ const ts = Date.now();
548
+
549
+ for (const rid of ids) {
550
+ const r = await dbLayer.getReservation(rid);
551
+ if (!r) continue;
552
+ if (String(r.status) !== 'paid') continue;
553
+ // Already purged from accounting
554
+ if (r.accPurgedAt) continue;
555
+ const start = parseInt(r.start, 10);
556
+ if (!Number.isFinite(start)) continue;
557
+ if (start < minTs || start >= maxTs) continue;
558
+
559
+ r.accPurgedAt = ts;
560
+ await dbLayer.saveReservation(r);
561
+ purged++;
562
+ }
563
+
564
+ return res.json({ ok: true, purged });
565
+ };
566
+
567
+
568
+ module.exports = admin;