nodebb-plugin-onekite-calendar 2.0.31 → 2.0.33
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/CHANGELOG.md +0 -5
- package/lib/api.js +0 -60
- package/lib/scheduler.js +6 -41
- package/lib/widgets.js +1 -23
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/client.js +1 -28
- package/templates/emails/calendar-onekite_pending.tpl +2 -0
- package/templates/emails/calendar-onekite_validator_expired.tpl +0 -2
- package/pkg/package/CHANGELOG.md +0 -161
- package/pkg/package/lib/admin.js +0 -698
- package/pkg/package/lib/api.js +0 -1468
- package/pkg/package/lib/controllers.js +0 -11
- package/pkg/package/lib/db.js +0 -224
- package/pkg/package/lib/discord.js +0 -190
- package/pkg/package/lib/helloasso.js +0 -352
- package/pkg/package/lib/helloassoWebhook.js +0 -389
- package/pkg/package/lib/scheduler.js +0 -201
- package/pkg/package/lib/widgets.js +0 -468
- package/pkg/package/library.js +0 -164
- package/pkg/package/package.json +0 -14
- package/pkg/package/plugin.json +0 -43
- package/pkg/package/public/admin.js +0 -1493
- package/pkg/package/public/client.js +0 -2406
- package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +0 -307
- package/pkg/package/templates/calendar-onekite.tpl +0 -51
- package/pkg/package/templates/emails/calendar-onekite_approved.tpl +0 -40
- package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +0 -15
- package/pkg/package/templates/emails/calendar-onekite_expired.tpl +0 -11
- package/pkg/package/templates/emails/calendar-onekite_paid.tpl +0 -15
- package/pkg/package/templates/emails/calendar-onekite_pending.tpl +0 -15
- package/pkg/package/templates/emails/calendar-onekite_refused.tpl +0 -15
- package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +0 -20
package/pkg/package/lib/admin.js
DELETED
|
@@ -1,698 +0,0 @@
|
|
|
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, uid, subject, data) {
|
|
36
|
-
const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
|
|
37
|
-
if (!Number.isInteger(toUid) || toUid <= 0) return;
|
|
38
|
-
|
|
39
|
-
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
if (typeof emailer.send !== 'function') return;
|
|
43
|
-
// NodeBB 4.x: send(template, uid, params)
|
|
44
|
-
// Do NOT branch on function.length (unreliable once wrapped/bound).
|
|
45
|
-
await emailer.send(template, toUid, params);
|
|
46
|
-
} catch (err) {
|
|
47
|
-
console.warn('[calendar-onekite] Failed to send email', {
|
|
48
|
-
template,
|
|
49
|
-
uid: toUid,
|
|
50
|
-
err: err && err.message ? err.message : String(err),
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function normalizeCallbackUrl(configured, meta) {
|
|
56
|
-
const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
57
|
-
let url = (configured || '').trim();
|
|
58
|
-
if (!url) {
|
|
59
|
-
url = base ? `${base}/helloasso` : '';
|
|
60
|
-
}
|
|
61
|
-
if (url && url.startsWith('/') && base) {
|
|
62
|
-
url = `${base}${url}`;
|
|
63
|
-
}
|
|
64
|
-
return url;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function normalizeReturnUrl(meta) {
|
|
68
|
-
const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
69
|
-
const b = String(base || '').trim().replace(/\/$/, '');
|
|
70
|
-
if (!b) return '';
|
|
71
|
-
return `${b}/calendar`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const dbLayer = require('./db');
|
|
76
|
-
const helloasso = require('./helloasso');
|
|
77
|
-
|
|
78
|
-
const ADMIN_PRIV = 'admin:settings';
|
|
79
|
-
|
|
80
|
-
const admin = {};
|
|
81
|
-
|
|
82
|
-
admin.renderAdmin = async function (req, res) {
|
|
83
|
-
res.render('admin/plugins/calendar-onekite', {
|
|
84
|
-
title: 'Calendar Onekite',
|
|
85
|
-
});
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
admin.getSettings = async function (req, res) {
|
|
89
|
-
const settings = await meta.settings.get('calendar-onekite');
|
|
90
|
-
res.json(settings || {});
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
admin.saveSettings = async function (req, res) {
|
|
94
|
-
await meta.settings.set('calendar-onekite', req.body || {});
|
|
95
|
-
res.json({ ok: true });
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
admin.listPending = async function (req, res) {
|
|
99
|
-
const ids = await dbLayer.listAllReservationIds(5000);
|
|
100
|
-
// Batch fetch to avoid N DB round-trips.
|
|
101
|
-
const rows = await dbLayer.getReservations(ids);
|
|
102
|
-
const pending = (rows || []).filter(r => r && r.status === 'pending');
|
|
103
|
-
pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
104
|
-
|
|
105
|
-
// Enrich with user info for ACP display (username + userslug)
|
|
106
|
-
try {
|
|
107
|
-
const uids = Array.from(new Set(pending
|
|
108
|
-
.map(r => r && r.uid)
|
|
109
|
-
.map(v => (v ? parseInt(v, 10) : NaN))
|
|
110
|
-
.filter(v => Number.isInteger(v) && v > 0)));
|
|
111
|
-
|
|
112
|
-
if (uids.length) {
|
|
113
|
-
// user.getUsersFields exists in NodeBB 4.x
|
|
114
|
-
const users = await user.getUsersFields(uids, ['uid', 'username', 'userslug']);
|
|
115
|
-
const byUid = new Map((users || []).filter(Boolean).map(u => [String(u.uid), u]));
|
|
116
|
-
for (const r of pending) {
|
|
117
|
-
const u = byUid.get(String(r.uid));
|
|
118
|
-
if (u) {
|
|
119
|
-
r.username = u.username || r.username || '';
|
|
120
|
-
r.userslug = u.userslug || r.userslug || '';
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
} catch (e) {
|
|
125
|
-
// Best-effort only; keep API working even if user lookups fail
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
res.json(pending);
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
admin.approveReservation = async function (req, res) {
|
|
132
|
-
const rid = req.params.rid;
|
|
133
|
-
const r = await dbLayer.getReservation(rid);
|
|
134
|
-
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
135
|
-
|
|
136
|
-
r.status = 'awaiting_payment';
|
|
137
|
-
r.pickupAddress = String((req.body && (req.body.pickupAddress || req.body.adminNote || req.body.note)) || '').trim();
|
|
138
|
-
r.notes = String((req.body && req.body.notes) || '').trim();
|
|
139
|
-
r.pickupTime = String((req.body && (req.body.pickupTime || req.body.pickup)) || '').trim();
|
|
140
|
-
r.pickupLat = String((req.body && req.body.pickupLat) || '').trim();
|
|
141
|
-
r.pickupLon = String((req.body && req.body.pickupLon) || '').trim();
|
|
142
|
-
r.approvedAt = Date.now();
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
const approver = await user.getUserFields(req.uid, ['username']);
|
|
146
|
-
r.approvedBy = req.uid;
|
|
147
|
-
r.approvedByUsername = approver && approver.username ? approver.username : '';
|
|
148
|
-
} catch (e) {
|
|
149
|
-
r.approvedBy = req.uid;
|
|
150
|
-
r.approvedByUsername = '';
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Create HelloAsso payment link if configured
|
|
154
|
-
const settings = await meta.settings.get('calendar-onekite');
|
|
155
|
-
const env = settings.helloassoEnv || 'prod';
|
|
156
|
-
const token = await helloasso.getAccessToken({
|
|
157
|
-
env,
|
|
158
|
-
clientId: settings.helloassoClientId,
|
|
159
|
-
clientSecret: settings.helloassoClientSecret,
|
|
160
|
-
});
|
|
161
|
-
if (!token) {
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
let paymentUrl = null;
|
|
166
|
-
if (token) {
|
|
167
|
-
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
168
|
-
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
169
|
-
const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
170
|
-
const base = forumBaseUrl();
|
|
171
|
-
const returnUrl = base ? `${base}/calendar` : '';
|
|
172
|
-
const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
|
|
173
|
-
const year = new Date(Number(r.start)).getFullYear();
|
|
174
|
-
const intent = await helloasso.createCheckoutIntent({
|
|
175
|
-
env,
|
|
176
|
-
token,
|
|
177
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
178
|
-
formType: settings.helloassoFormType,
|
|
179
|
-
// Form slug is derived from the year
|
|
180
|
-
formSlug: `locations-materiel-${year}`,
|
|
181
|
-
totalAmount,
|
|
182
|
-
payerEmail: requester && requester.email,
|
|
183
|
-
// User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
|
|
184
|
-
callbackUrl: returnUrl,
|
|
185
|
-
webhookUrl: webhookUrl,
|
|
186
|
-
itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
|
|
187
|
-
containsDonation: false,
|
|
188
|
-
metadata: {
|
|
189
|
-
reservationId: String(rid),
|
|
190
|
-
items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
|
|
191
|
-
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
192
|
-
},
|
|
193
|
-
});
|
|
194
|
-
paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
|
|
195
|
-
? (intent.paymentUrl || intent.redirectUrl)
|
|
196
|
-
: (typeof intent === 'string' ? intent : null);
|
|
197
|
-
if (intent && intent.checkoutIntentId) {
|
|
198
|
-
r.checkoutIntentId = intent.checkoutIntentId;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (paymentUrl) {
|
|
203
|
-
r.paymentUrl = paymentUrl;
|
|
204
|
-
} else {
|
|
205
|
-
console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
await dbLayer.saveReservation(r);
|
|
209
|
-
|
|
210
|
-
// Email requester
|
|
211
|
-
try {
|
|
212
|
-
const requesterUid = parseInt(r.uid, 10);
|
|
213
|
-
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
214
|
-
if (requesterUid) {
|
|
215
|
-
const latNum = Number(r.pickupLat);
|
|
216
|
-
const lonNum = Number(r.pickupLon);
|
|
217
|
-
const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
|
|
218
|
-
? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
|
|
219
|
-
: '';
|
|
220
|
-
await sendEmail('calendar-onekite_approved', requesterUid, 'Location matériel - Réservation validée', {
|
|
221
|
-
uid: requesterUid,
|
|
222
|
-
username: requester && requester.username ? requester.username : '',
|
|
223
|
-
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
224
|
-
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
225
|
-
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
226
|
-
paymentUrl: paymentUrl || '',
|
|
227
|
-
pickupAddress: r.pickupAddress || '',
|
|
228
|
-
notes: r.notes || '',
|
|
229
|
-
pickupTime: r.pickupTime || '',
|
|
230
|
-
pickupLat: r.pickupLat || '',
|
|
231
|
-
pickupLon: r.pickupLon || '',
|
|
232
|
-
mapUrl,
|
|
233
|
-
validatedBy: r.approvedByUsername || '',
|
|
234
|
-
validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
} catch (e) {}
|
|
238
|
-
|
|
239
|
-
res.json({ ok: true, paymentUrl: paymentUrl || null });
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
admin.refuseReservation = async function (req, res) {
|
|
243
|
-
const rid = req.params.rid;
|
|
244
|
-
const r = await dbLayer.getReservation(rid);
|
|
245
|
-
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
246
|
-
|
|
247
|
-
r.status = 'refused';
|
|
248
|
-
r.refusedAt = Date.now();
|
|
249
|
-
r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
|
|
250
|
-
await dbLayer.saveReservation(r);
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
const requesterUid = parseInt(r.uid, 10);
|
|
254
|
-
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
255
|
-
if (requesterUid) {
|
|
256
|
-
await sendEmail('calendar-onekite_refused', requesterUid, 'Location matériel - Réservation refusée', {
|
|
257
|
-
uid: requesterUid,
|
|
258
|
-
username: requester && requester.username ? requester.username : '',
|
|
259
|
-
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
260
|
-
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
261
|
-
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
262
|
-
start: formatFR(r.start),
|
|
263
|
-
end: formatFR(r.end),
|
|
264
|
-
refusedReason: r.refusedReason || '',
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
} catch (e) {}
|
|
268
|
-
|
|
269
|
-
res.json({ ok: true });
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
admin.purgeByYear = async function (req, res) {
|
|
273
|
-
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
274
|
-
if (!/^\d{4}$/.test(year)) {
|
|
275
|
-
return res.status(400).json({ error: 'invalid-year' });
|
|
276
|
-
}
|
|
277
|
-
const y = parseInt(year, 10);
|
|
278
|
-
const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
|
|
279
|
-
const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
|
|
280
|
-
|
|
281
|
-
const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
|
|
282
|
-
let removed = 0;
|
|
283
|
-
for (const rid of ids) {
|
|
284
|
-
const r = await dbLayer.getReservation(rid);
|
|
285
|
-
if (!r) continue;
|
|
286
|
-
|
|
287
|
-
await dbLayer.removeReservation(rid);
|
|
288
|
-
removed++;
|
|
289
|
-
}
|
|
290
|
-
res.json({ ok: true, removed });
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
admin.purgeSpecialEventsByYear = async function (req, res) {
|
|
294
|
-
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
295
|
-
if (!/^\d{4}$/.test(year)) {
|
|
296
|
-
return res.status(400).json({ error: 'invalid-year' });
|
|
297
|
-
}
|
|
298
|
-
const y = parseInt(year, 10);
|
|
299
|
-
const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
|
|
300
|
-
const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
|
|
301
|
-
|
|
302
|
-
const ids = await dbLayer.listSpecialIdsByStartRange(startTs, endTs, 100000);
|
|
303
|
-
let count = 0;
|
|
304
|
-
for (const eid of ids) {
|
|
305
|
-
await dbLayer.removeSpecialEvent(eid);
|
|
306
|
-
count++;
|
|
307
|
-
}
|
|
308
|
-
return res.json({ ok: true, removed: count });
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
// Debug endpoint to validate HelloAsso connectivity and item loading
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
admin.debugHelloAsso = async function (req, res) {
|
|
315
|
-
const settings = await meta.settings.get('calendar-onekite');
|
|
316
|
-
const env = (settings && settings.helloassoEnv) || 'prod';
|
|
317
|
-
|
|
318
|
-
// Never expose secrets in debug output
|
|
319
|
-
const safeSettings = {
|
|
320
|
-
helloassoEnv: env,
|
|
321
|
-
helloassoClientId: settings && settings.helloassoClientId ? String(settings.helloassoClientId) : '',
|
|
322
|
-
helloassoClientSecret: settings && settings.helloassoClientSecret ? '***' : '',
|
|
323
|
-
helloassoOrganizationSlug: settings && settings.helloassoOrganizationSlug ? String(settings.helloassoOrganizationSlug) : '',
|
|
324
|
-
helloassoFormType: settings && settings.helloassoFormType ? String(settings.helloassoFormType) : '',
|
|
325
|
-
helloassoFormSlug: settings && settings.helloassoFormSlug ? String(settings.helloassoFormSlug) : '',
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
const out = {
|
|
329
|
-
ok: true,
|
|
330
|
-
settings: safeSettings,
|
|
331
|
-
token: { ok: false },
|
|
332
|
-
// Catalog = what you actually want for a shop (available products/material)
|
|
333
|
-
catalog: { ok: false, count: 0, sample: [], keys: [] },
|
|
334
|
-
// Sold items = items present in orders (can be 0 if no sales yet)
|
|
335
|
-
soldItems: { ok: false, count: 0, sample: [] },
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
try {
|
|
339
|
-
const token = await helloasso.getAccessToken({
|
|
340
|
-
env,
|
|
341
|
-
clientId: settings.helloassoClientId,
|
|
342
|
-
clientSecret: settings.helloassoClientSecret,
|
|
343
|
-
});
|
|
344
|
-
if (!token) {
|
|
345
|
-
out.token = { ok: false, error: 'token-null' };
|
|
346
|
-
return res.json(out);
|
|
347
|
-
}
|
|
348
|
-
out.token = { ok: true };
|
|
349
|
-
|
|
350
|
-
// Catalog items (via /public)
|
|
351
|
-
try {
|
|
352
|
-
const y = new Date().getFullYear();
|
|
353
|
-
const { publicForm, items } = await helloasso.listCatalogItems({
|
|
354
|
-
env,
|
|
355
|
-
token,
|
|
356
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
357
|
-
formType: settings.helloassoFormType,
|
|
358
|
-
formSlug: `locations-materiel-${y}`,
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const arr = Array.isArray(items) ? items : [];
|
|
362
|
-
out.catalog.ok = true;
|
|
363
|
-
out.catalog.count = arr.length;
|
|
364
|
-
out.catalog.keys = publicForm && typeof publicForm === 'object' ? Object.keys(publicForm) : [];
|
|
365
|
-
out.catalog.sample = arr.slice(0, 10).map((it) => ({
|
|
366
|
-
id: it.id,
|
|
367
|
-
name: it.name,
|
|
368
|
-
price: it.price ?? null,
|
|
369
|
-
}));
|
|
370
|
-
} catch (e) {
|
|
371
|
-
out.catalog = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [], keys: [] };
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Sold items
|
|
375
|
-
try {
|
|
376
|
-
const y2 = new Date().getFullYear();
|
|
377
|
-
const items = await helloasso.listItems({
|
|
378
|
-
env,
|
|
379
|
-
token,
|
|
380
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
381
|
-
formType: settings.helloassoFormType,
|
|
382
|
-
formSlug: `locations-materiel-${y2}`,
|
|
383
|
-
});
|
|
384
|
-
const arr = Array.isArray(items) ? items : [];
|
|
385
|
-
out.soldItems.ok = true;
|
|
386
|
-
out.soldItems.count = arr.length;
|
|
387
|
-
out.soldItems.sample = arr.slice(0, 10).map((it) => ({
|
|
388
|
-
id: it.id || it.itemId || it.reference || it.name,
|
|
389
|
-
name: it.name || it.label || it.itemName,
|
|
390
|
-
price: it.price || it.amount || it.unitPrice || null,
|
|
391
|
-
}));
|
|
392
|
-
} catch (e) {
|
|
393
|
-
out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
return res.json(out);
|
|
397
|
-
} catch (e) {
|
|
398
|
-
out.ok = false;
|
|
399
|
-
out.token = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
400
|
-
return res.json(out);
|
|
401
|
-
}
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
// Accounting endpoint: aggregates paid reservations so you can contabilize what was rented.
|
|
405
|
-
// Query params:
|
|
406
|
-
// from=YYYY-MM-DD (inclusive, based on reservation.start)
|
|
407
|
-
// to=YYYY-MM-DD (exclusive, based on reservation.start)
|
|
408
|
-
admin.getAccounting = async function (req, res) {
|
|
409
|
-
const qFrom = String((req.query && req.query.from) || '').trim();
|
|
410
|
-
const qTo = String((req.query && req.query.to) || '').trim();
|
|
411
|
-
const parseDay = (s) => {
|
|
412
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
413
|
-
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
|
|
414
|
-
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
415
|
-
const ts = dt.getTime();
|
|
416
|
-
return Number.isFinite(ts) ? ts : null;
|
|
417
|
-
};
|
|
418
|
-
const fromTs = parseDay(qFrom);
|
|
419
|
-
const toTs = parseDay(qTo);
|
|
420
|
-
|
|
421
|
-
// Default: last 12 months (UTC)
|
|
422
|
-
const now = new Date();
|
|
423
|
-
const defaultTo = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
424
|
-
const defaultFrom = Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1);
|
|
425
|
-
const minTs = fromTs ?? defaultFrom;
|
|
426
|
-
const maxTs = toTs ?? defaultTo;
|
|
427
|
-
|
|
428
|
-
const ids = await dbLayer.listAllReservationIds(100000);
|
|
429
|
-
const rows = [];
|
|
430
|
-
const byItem = new Map();
|
|
431
|
-
let freeCount = 0;
|
|
432
|
-
let paidCount = 0;
|
|
433
|
-
let grandTotal = 0;
|
|
434
|
-
|
|
435
|
-
// Helper: calendar-day difference (end exclusive) from YYYY-MM-DD strings (UTC midnights).
|
|
436
|
-
const calendarDaysExclusiveYmd = (startYmd, endYmd) => {
|
|
437
|
-
try {
|
|
438
|
-
const s = String(startYmd || '').trim();
|
|
439
|
-
const e = String(endYmd || '').trim();
|
|
440
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || !/^\d{4}-\d{2}-\d{2}$/.test(e)) return null;
|
|
441
|
-
const [sy, sm, sd] = s.split('-').map((x) => parseInt(x, 10));
|
|
442
|
-
const [ey, em, ed] = e.split('-').map((x) => parseInt(x, 10));
|
|
443
|
-
const sUtc = Date.UTC(sy, sm - 1, sd);
|
|
444
|
-
const eUtc = Date.UTC(ey, em - 1, ed);
|
|
445
|
-
const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
|
|
446
|
-
return Math.max(1, diff);
|
|
447
|
-
} catch (e) {
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
const yearFromTs = (ts) => {
|
|
453
|
-
const d = new Date(Number(ts));
|
|
454
|
-
return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
|
|
455
|
-
};
|
|
456
|
-
const formSlugForYear = (y) => `locations-materiel-${y}`;
|
|
457
|
-
|
|
458
|
-
// Cache HelloAsso catalog price lookups by year to compute *per-item* totals.
|
|
459
|
-
// This prevents a bug where the full reservation total was previously counted on every item line.
|
|
460
|
-
let settingsCache = null;
|
|
461
|
-
let tokenCache = null;
|
|
462
|
-
const catalogByYear = new Map(); // year -> Map(itemId -> priceCents)
|
|
463
|
-
const getCatalogPriceMapForYear = async (year) => {
|
|
464
|
-
const y = Number(year);
|
|
465
|
-
if (!Number.isFinite(y)) return null;
|
|
466
|
-
if (catalogByYear.has(y)) return catalogByYear.get(y);
|
|
467
|
-
try {
|
|
468
|
-
if (!settingsCache) settingsCache = await meta.settings.get('calendar-onekite');
|
|
469
|
-
if (!settingsCache || !settingsCache.helloassoClientId || !settingsCache.helloassoClientSecret || !settingsCache.helloassoOrganizationSlug || !settingsCache.helloassoFormType) {
|
|
470
|
-
catalogByYear.set(y, null);
|
|
471
|
-
return null;
|
|
472
|
-
}
|
|
473
|
-
if (!tokenCache) {
|
|
474
|
-
tokenCache = await helloasso.getAccessToken({
|
|
475
|
-
env: settingsCache.helloassoEnv || 'prod',
|
|
476
|
-
clientId: settingsCache.helloassoClientId,
|
|
477
|
-
clientSecret: settingsCache.helloassoClientSecret,
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
if (!tokenCache) {
|
|
481
|
-
catalogByYear.set(y, null);
|
|
482
|
-
return null;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const { items: catalog } = await helloasso.listCatalogItems({
|
|
486
|
-
env: settingsCache.helloassoEnv,
|
|
487
|
-
token: tokenCache,
|
|
488
|
-
organizationSlug: settingsCache.helloassoOrganizationSlug,
|
|
489
|
-
formType: settingsCache.helloassoFormType,
|
|
490
|
-
formSlug: formSlugForYear(y),
|
|
491
|
-
});
|
|
492
|
-
const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
|
|
493
|
-
catalogByYear.set(y, byId);
|
|
494
|
-
return byId;
|
|
495
|
-
} catch (e) {
|
|
496
|
-
catalogByYear.set(y, null);
|
|
497
|
-
return null;
|
|
498
|
-
}
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
for (const rid of ids) {
|
|
502
|
-
const r = await dbLayer.getReservation(rid);
|
|
503
|
-
if (!r) continue;
|
|
504
|
-
if (String(r.status) !== 'paid') continue;
|
|
505
|
-
// Defensive: if a reservation was cancelled but its status remained 'paid'
|
|
506
|
-
// (legacy data / edge cases), never count it in accounting.
|
|
507
|
-
if (r.cancelledAt) continue;
|
|
508
|
-
if (r.accPurgedAt) continue;
|
|
509
|
-
const start = parseInt(r.start, 10);
|
|
510
|
-
if (!Number.isFinite(start)) continue;
|
|
511
|
-
if (start < minTs || start >= maxTs) continue;
|
|
512
|
-
|
|
513
|
-
const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
|
|
514
|
-
? r.itemNames
|
|
515
|
-
: (r.itemName ? [r.itemName] : []);
|
|
516
|
-
|
|
517
|
-
const isFree = !!r.isFree;
|
|
518
|
-
const total = isFree ? 0 : (Number(r.total) || 0);
|
|
519
|
-
const startDate = formatFR(r.start);
|
|
520
|
-
const endDate = formatFR(r.end);
|
|
521
|
-
|
|
522
|
-
if (isFree) {
|
|
523
|
-
freeCount += 1;
|
|
524
|
-
} else {
|
|
525
|
-
paidCount += 1;
|
|
526
|
-
grandTotal += total;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
rows.push({
|
|
530
|
-
rid: r.rid,
|
|
531
|
-
uid: r.uid,
|
|
532
|
-
username: r.username || '',
|
|
533
|
-
start: r.start,
|
|
534
|
-
end: r.end,
|
|
535
|
-
startDate,
|
|
536
|
-
endDate,
|
|
537
|
-
items: itemNames,
|
|
538
|
-
total,
|
|
539
|
-
paidAt: r.paidAt || '',
|
|
540
|
-
isFree,
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
// Only count paid (non-free) reservations into revenue by item.
|
|
544
|
-
if (!isFree) {
|
|
545
|
-
// Compute per-item totals (price_per_day * calendar_days) when possible.
|
|
546
|
-
// Fallback: split reservation total equally across items.
|
|
547
|
-
const idsForRes = (Array.isArray(r.itemIds) && r.itemIds.length) ? r.itemIds.map(String) : (r.itemId ? [String(r.itemId)] : []);
|
|
548
|
-
const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
|
|
549
|
-
const year = yearFromTs(start);
|
|
550
|
-
|
|
551
|
-
let perItemTotals = null; // Map(itemId -> euros)
|
|
552
|
-
const priceMap = await getCatalogPriceMapForYear(year);
|
|
553
|
-
if (priceMap && idsForRes.length) {
|
|
554
|
-
const m = new Map();
|
|
555
|
-
for (const id of idsForRes) {
|
|
556
|
-
const cents = priceMap.get(String(id)) || 0;
|
|
557
|
-
if (cents > 0) {
|
|
558
|
-
m.set(String(id), (cents * days) / 100);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
// Use catalog-based totals only if we got at least one valid price.
|
|
562
|
-
if (m.size) perItemTotals = m;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
const nItems = Math.max(1, itemNames.length || idsForRes.length || 1);
|
|
566
|
-
const fallbackEach = (Number(total) || 0) / nItems;
|
|
567
|
-
|
|
568
|
-
// Aggregate by *display name* (stable for admins), using the computed per-item totals.
|
|
569
|
-
// If we have ids and names, we try to align by index; otherwise we apply fallback.
|
|
570
|
-
for (let i = 0; i < itemNames.length; i++) {
|
|
571
|
-
const name = itemNames[i];
|
|
572
|
-
const key = String(name || '').trim();
|
|
573
|
-
if (!key) continue;
|
|
574
|
-
const id = idsForRes[i] != null ? String(idsForRes[i]) : null;
|
|
575
|
-
const per = (id && perItemTotals && perItemTotals.has(String(id))) ? perItemTotals.get(String(id)) : fallbackEach;
|
|
576
|
-
const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
|
|
577
|
-
cur.count += 1;
|
|
578
|
-
cur.total += (Number(per) || 0);
|
|
579
|
-
byItem.set(key, cur);
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// If we have ids but no names (edge cases), keep a reasonable summary by id.
|
|
583
|
-
if (!itemNames.length && idsForRes.length) {
|
|
584
|
-
for (const id of idsForRes) {
|
|
585
|
-
const key = String(id || '').trim();
|
|
586
|
-
if (!key) continue;
|
|
587
|
-
const per = (perItemTotals && perItemTotals.has(String(id))) ? perItemTotals.get(String(id)) : fallbackEach;
|
|
588
|
-
const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
|
|
589
|
-
cur.count += 1;
|
|
590
|
-
cur.total += (Number(per) || 0);
|
|
591
|
-
byItem.set(key, cur);
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const summary = Array.from(byItem.values()).sort((a, b) => b.count - a.count);
|
|
598
|
-
if (freeCount > 0) {
|
|
599
|
-
summary.push({ item: 'Sorties gratuites', count: freeCount, total: 0, isFree: true });
|
|
600
|
-
}
|
|
601
|
-
rows.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
602
|
-
|
|
603
|
-
return res.json({
|
|
604
|
-
ok: true,
|
|
605
|
-
from: new Date(minTs).toISOString().slice(0, 10),
|
|
606
|
-
to: new Date(maxTs).toISOString().slice(0, 10),
|
|
607
|
-
paidCount,
|
|
608
|
-
freeCount,
|
|
609
|
-
grandTotal,
|
|
610
|
-
summary,
|
|
611
|
-
rows,
|
|
612
|
-
});
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
admin.exportAccountingCsv = async function (req, res) {
|
|
616
|
-
// Reuse the same logic and emit a CSV.
|
|
617
|
-
const fakeRes = { json: (x) => x };
|
|
618
|
-
const data = await admin.getAccounting(req, fakeRes);
|
|
619
|
-
// If getAccounting returned via res.json, data is undefined; rebuild by calling logic directly.
|
|
620
|
-
// Easiest: call getAccounting's internals by fetching the endpoint logic via HTTP is not possible here.
|
|
621
|
-
// So we re-run getAccounting but capture output by monkeypatching.
|
|
622
|
-
let payload;
|
|
623
|
-
await admin.getAccounting(req, { json: (x) => { payload = x; return x; } });
|
|
624
|
-
if (!payload || !payload.ok) {
|
|
625
|
-
return res.status(500).send('error');
|
|
626
|
-
}
|
|
627
|
-
const escape = (v) => {
|
|
628
|
-
const s = String(v ?? '');
|
|
629
|
-
if (/[\n\r,\"]/g.test(s)) {
|
|
630
|
-
return '"' + s.replace(/"/g, '""') + '"';
|
|
631
|
-
}
|
|
632
|
-
return s;
|
|
633
|
-
};
|
|
634
|
-
const lines = [];
|
|
635
|
-
lines.push(['rid', 'username', 'uid', 'start', 'end', 'items', 'total', 'paidAt', 'isFree'].map(escape).join(','));
|
|
636
|
-
for (const r of payload.rows || []) {
|
|
637
|
-
lines.push([
|
|
638
|
-
r.rid,
|
|
639
|
-
r.username,
|
|
640
|
-
r.uid,
|
|
641
|
-
r.startDate,
|
|
642
|
-
r.endDate,
|
|
643
|
-
(Array.isArray(r.items) ? r.items.join(' | ') : ''),
|
|
644
|
-
(Number(r.total) || 0).toFixed(2),
|
|
645
|
-
r.paidAt ? new Date(parseInt(r.paidAt, 10)).toISOString() : '',
|
|
646
|
-
r.isFree ? '1' : '0',
|
|
647
|
-
].map(escape).join(','));
|
|
648
|
-
}
|
|
649
|
-
const csv = lines.join('\n');
|
|
650
|
-
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
651
|
-
res.setHeader('Content-Disposition', 'attachment; filename="calendar-onekite-accounting.csv"');
|
|
652
|
-
return res.send(csv);
|
|
653
|
-
};
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
admin.purgeAccounting = async function (req, res) {
|
|
657
|
-
const qFrom = String((req.query && req.query.from) || '').trim();
|
|
658
|
-
const qTo = String((req.query && req.query.to) || '').trim();
|
|
659
|
-
const parseDay = (s) => {
|
|
660
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
661
|
-
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
|
|
662
|
-
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
663
|
-
const ts = dt.getTime();
|
|
664
|
-
return Number.isFinite(ts) ? ts : null;
|
|
665
|
-
};
|
|
666
|
-
const fromTs = parseDay(qFrom);
|
|
667
|
-
const toTs = parseDay(qTo);
|
|
668
|
-
|
|
669
|
-
const now = new Date();
|
|
670
|
-
const defaultTo = Date.UTC(now.getUTCFullYear() + 100, 0, 1); // far future
|
|
671
|
-
const defaultFrom = Date.UTC(1970, 0, 1);
|
|
672
|
-
const minTs = fromTs ?? defaultFrom;
|
|
673
|
-
const maxTs = toTs ?? defaultTo;
|
|
674
|
-
|
|
675
|
-
const ids = await dbLayer.listAllReservationIds(200000);
|
|
676
|
-
let purged = 0;
|
|
677
|
-
const ts = Date.now();
|
|
678
|
-
|
|
679
|
-
for (const rid of ids) {
|
|
680
|
-
const r = await dbLayer.getReservation(rid);
|
|
681
|
-
if (!r) continue;
|
|
682
|
-
if (String(r.status) !== 'paid') continue;
|
|
683
|
-
// Already purged from accounting
|
|
684
|
-
if (r.accPurgedAt) continue;
|
|
685
|
-
const start = parseInt(r.start, 10);
|
|
686
|
-
if (!Number.isFinite(start)) continue;
|
|
687
|
-
if (start < minTs || start >= maxTs) continue;
|
|
688
|
-
|
|
689
|
-
r.accPurgedAt = ts;
|
|
690
|
-
await dbLayer.saveReservation(r);
|
|
691
|
-
purged++;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
return res.json({ ok: true, purged });
|
|
695
|
-
};
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
module.exports = admin;
|