nodebb-plugin-calendar-onekite 11.1.95 → 11.1.97
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 +15 -483
- package/package.json +1 -1
- package/public/client.js +85 -8
package/lib/admin.js
CHANGED
|
@@ -20,500 +20,32 @@ function formatFR(tsOrIso) {
|
|
|
20
20
|
|
|
21
21
|
async function sendEmail(template, toEmail, subject, data) {
|
|
22
22
|
if (!toEmail) return;
|
|
23
|
+
const settings = await meta.settings.get('calendar-onekite').catch(() => ({}));
|
|
24
|
+
const lang = (settings && settings.defaultLang) || (meta && meta.config && meta.config.defaultLang) || 'fr';
|
|
25
|
+
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
23
26
|
try {
|
|
24
27
|
if (typeof emailer.sendToEmail === 'function') {
|
|
28
|
+
// NodeBB: sendToEmail(template, email, language, params)
|
|
25
29
|
if (emailer.sendToEmail.length >= 4) {
|
|
26
|
-
await emailer.sendToEmail(template, toEmail,
|
|
30
|
+
await emailer.sendToEmail(template, toEmail, lang, params);
|
|
27
31
|
} else {
|
|
28
|
-
|
|
29
|
-
await emailer.sendToEmail(template, toEmail,
|
|
32
|
+
// Older signature: sendToEmail(template, email, params)
|
|
33
|
+
await emailer.sendToEmail(template, toEmail, params);
|
|
30
34
|
}
|
|
31
35
|
return;
|
|
32
36
|
}
|
|
33
37
|
if (typeof emailer.send === 'function') {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
if (emailer.send.length === 3) {
|
|
39
|
-
const dataWithSubject = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
40
|
-
await emailer.send(template, toEmail, dataWithSubject);
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
await emailer.send(template, toEmail, subject, data);
|
|
38
|
+
// Fallback: send(template, uid, params) - not usable without uid
|
|
39
|
+
// Best effort: do nothing rather than crash
|
|
40
|
+
return;
|
|
44
41
|
}
|
|
45
42
|
} catch (err) {
|
|
46
|
-
console.warn('[calendar-onekite] Failed to send email', {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
function normalizeCallbackUrl(configured, meta) {
|
|
51
|
-
const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
52
|
-
let url = (configured || '').trim();
|
|
53
|
-
if (!url) {
|
|
54
|
-
url = base ? `${base}/helloasso` : '';
|
|
55
|
-
}
|
|
56
|
-
if (url && url.startsWith('/') && base) {
|
|
57
|
-
url = `${base}${url}`;
|
|
58
|
-
}
|
|
59
|
-
return url;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function normalizeReturnUrl(meta) {
|
|
63
|
-
const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
64
|
-
const b = String(base || '').trim().replace(/\/$/, '');
|
|
65
|
-
if (!b) return '';
|
|
66
|
-
return `${b}/calendar`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const dbLayer = require('./db');
|
|
71
|
-
const helloasso = require('./helloasso');
|
|
72
|
-
|
|
73
|
-
const ADMIN_PRIV = 'admin:settings';
|
|
74
|
-
|
|
75
|
-
const admin = {};
|
|
76
|
-
|
|
77
|
-
admin.renderAdmin = async function (req, res) {
|
|
78
|
-
res.render('admin/plugins/calendar-onekite', {
|
|
79
|
-
title: 'Calendar OneKite',
|
|
80
|
-
});
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
admin.getSettings = async function (req, res) {
|
|
84
|
-
const settings = await meta.settings.get('calendar-onekite');
|
|
85
|
-
res.json(settings || {});
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
admin.saveSettings = async function (req, res) {
|
|
89
|
-
await meta.settings.set('calendar-onekite', req.body || {});
|
|
90
|
-
res.json({ ok: true });
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
admin.listPending = async function (req, res) {
|
|
94
|
-
const ids = await dbLayer.listAllReservationIds(5000);
|
|
95
|
-
const pending = [];
|
|
96
|
-
for (const rid of ids) {
|
|
97
|
-
const r = await dbLayer.getReservation(rid);
|
|
98
|
-
if (r && r.status === 'pending') {
|
|
99
|
-
pending.push(r);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
103
|
-
res.json(pending);
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
admin.approveReservation = async function (req, res) {
|
|
107
|
-
const rid = req.params.rid;
|
|
108
|
-
const r = await dbLayer.getReservation(rid);
|
|
109
|
-
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
110
|
-
|
|
111
|
-
r.status = 'awaiting_payment';
|
|
112
|
-
r.pickupAddress = String((req.body && (req.body.pickupAddress || req.body.adminNote || req.body.note)) || '').trim();
|
|
113
|
-
r.notes = String((req.body && req.body.notes) || '').trim();
|
|
114
|
-
r.pickupTime = String((req.body && (req.body.pickupTime || req.body.pickup)) || '').trim();
|
|
115
|
-
r.pickupLat = String((req.body && req.body.pickupLat) || '').trim();
|
|
116
|
-
r.pickupLon = String((req.body && req.body.pickupLon) || '').trim();
|
|
117
|
-
r.approvedAt = Date.now();
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
const approver = await user.getUserFields(req.uid, ['username']);
|
|
121
|
-
r.approvedBy = req.uid;
|
|
122
|
-
r.approvedByUsername = approver && approver.username ? approver.username : '';
|
|
123
|
-
} catch (e) {
|
|
124
|
-
r.approvedBy = req.uid;
|
|
125
|
-
r.approvedByUsername = '';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Create HelloAsso payment link if configured
|
|
129
|
-
const settings = await meta.settings.get('calendar-onekite');
|
|
130
|
-
const env = settings.helloassoEnv || 'prod';
|
|
131
|
-
const token = await helloasso.getAccessToken({
|
|
132
|
-
env,
|
|
133
|
-
clientId: settings.helloassoClientId,
|
|
134
|
-
clientSecret: settings.helloassoClientSecret,
|
|
135
|
-
});
|
|
136
|
-
if (!token) {
|
|
137
|
-
console.warn('[calendar-onekite] HelloAsso access token not obtained (approve ACP)', { rid: r && r.rid });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
let paymentUrl = null;
|
|
141
|
-
if (token) {
|
|
142
|
-
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
143
|
-
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
144
|
-
const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
145
|
-
const base = forumBaseUrl();
|
|
146
|
-
const returnUrl = base ? `${base}/calendar` : '';
|
|
147
|
-
const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
|
|
148
|
-
const year = new Date(Number(r.start)).getFullYear();
|
|
149
|
-
paymentUrl = await helloasso.createCheckoutIntent({
|
|
150
|
-
env,
|
|
151
|
-
token,
|
|
152
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
153
|
-
formType: settings.helloassoFormType,
|
|
154
|
-
// Form slug is derived from the year
|
|
155
|
-
formSlug: `locations-materiel-${year}`,
|
|
156
|
-
totalAmount,
|
|
157
|
-
payerEmail: requester && requester.email,
|
|
158
|
-
// User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
|
|
159
|
-
callbackUrl: returnUrl,
|
|
160
|
-
webhookUrl: webhookUrl,
|
|
161
|
-
itemName: 'Réservation matériel OneKite',
|
|
162
|
-
containsDonation: false,
|
|
163
|
-
metadata: { reservationId: String(rid) },
|
|
43
|
+
console.warn('[calendar-onekite] Failed to send email', {
|
|
44
|
+
template,
|
|
45
|
+
toEmail,
|
|
46
|
+
err: err && err.message ? err.message : String(err),
|
|
164
47
|
});
|
|
165
48
|
}
|
|
166
|
-
|
|
167
|
-
if (paymentUrl) {
|
|
168
|
-
r.paymentUrl = paymentUrl;
|
|
169
|
-
} else {
|
|
170
|
-
console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
await dbLayer.saveReservation(r);
|
|
174
|
-
|
|
175
|
-
// Email requester
|
|
176
|
-
try {
|
|
177
|
-
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
178
|
-
if (requester && requester.email) {
|
|
179
|
-
const latNum = Number(r.pickupLat);
|
|
180
|
-
const lonNum = Number(r.pickupLon);
|
|
181
|
-
const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
|
|
182
|
-
? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
|
|
183
|
-
: '';
|
|
184
|
-
await sendEmail('calendar-onekite_approved', requester.email, 'Location matériel - Réservation validée', {
|
|
185
|
-
username: requester.username,
|
|
186
|
-
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
187
|
-
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
188
|
-
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
189
|
-
paymentUrl: paymentUrl || '',
|
|
190
|
-
pickupAddress: r.pickupAddress || '',
|
|
191
|
-
notes: r.notes || '',
|
|
192
|
-
pickupTime: r.pickupTime || '',
|
|
193
|
-
pickupLat: r.pickupLat || '',
|
|
194
|
-
pickupLon: r.pickupLon || '',
|
|
195
|
-
mapUrl,
|
|
196
|
-
validatedBy: r.approvedByUsername || '',
|
|
197
|
-
validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
} catch (e) {}
|
|
201
|
-
|
|
202
|
-
res.json({ ok: true, paymentUrl: paymentUrl || null });
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
admin.refuseReservation = async function (req, res) {
|
|
206
|
-
const rid = req.params.rid;
|
|
207
|
-
const r = await dbLayer.getReservation(rid);
|
|
208
|
-
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
209
|
-
|
|
210
|
-
r.status = 'refused';
|
|
211
|
-
await dbLayer.saveReservation(r);
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
215
|
-
if (requester && requester.email) {
|
|
216
|
-
await sendEmail('calendar-onekite_refused', requester.email, 'Location matériel - Réservation refusée', {
|
|
217
|
-
username: requester.username,
|
|
218
|
-
itemName: r.itemName,
|
|
219
|
-
start: formatFR(r.start),
|
|
220
|
-
end: formatFR(r.end),
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
} catch (e) {}
|
|
224
|
-
|
|
225
|
-
res.json({ ok: true });
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
admin.purgeByYear = async function (req, res) {
|
|
229
|
-
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
230
|
-
if (!/^\d{4}$/.test(year)) {
|
|
231
|
-
return res.status(400).json({ error: 'invalid-year' });
|
|
232
|
-
}
|
|
233
|
-
const y = parseInt(year, 10);
|
|
234
|
-
const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
|
|
235
|
-
const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
|
|
236
|
-
|
|
237
|
-
const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
|
|
238
|
-
let count = 0;
|
|
239
|
-
for (const rid of ids) {
|
|
240
|
-
await dbLayer.removeReservation(rid);
|
|
241
|
-
count++;
|
|
242
|
-
}
|
|
243
|
-
res.json({ ok: true, removed: count });
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
admin.purgeSpecialEventsByYear = async function (req, res) {
|
|
247
|
-
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
248
|
-
if (!/^\d{4}$/.test(year)) {
|
|
249
|
-
return res.status(400).json({ error: 'invalid-year' });
|
|
250
|
-
}
|
|
251
|
-
const y = parseInt(year, 10);
|
|
252
|
-
const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
|
|
253
|
-
const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
|
|
254
|
-
|
|
255
|
-
const ids = await dbLayer.listSpecialIdsByStartRange(startTs, endTs, 100000);
|
|
256
|
-
let count = 0;
|
|
257
|
-
for (const eid of ids) {
|
|
258
|
-
await dbLayer.removeSpecialEvent(eid);
|
|
259
|
-
count++;
|
|
260
|
-
}
|
|
261
|
-
return res.json({ ok: true, removed: count });
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
// Debug endpoint to validate HelloAsso connectivity and item loading
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
admin.debugHelloAsso = async function (req, res) {
|
|
268
|
-
const settings = await meta.settings.get('calendar-onekite');
|
|
269
|
-
const env = (settings && settings.helloassoEnv) || 'prod';
|
|
270
|
-
|
|
271
|
-
// Never expose secrets in debug output
|
|
272
|
-
const safeSettings = {
|
|
273
|
-
helloassoEnv: env,
|
|
274
|
-
helloassoClientId: settings && settings.helloassoClientId ? String(settings.helloassoClientId) : '',
|
|
275
|
-
helloassoClientSecret: settings && settings.helloassoClientSecret ? '***' : '',
|
|
276
|
-
helloassoOrganizationSlug: settings && settings.helloassoOrganizationSlug ? String(settings.helloassoOrganizationSlug) : '',
|
|
277
|
-
helloassoFormType: settings && settings.helloassoFormType ? String(settings.helloassoFormType) : '',
|
|
278
|
-
helloassoFormSlug: settings && settings.helloassoFormSlug ? String(settings.helloassoFormSlug) : '',
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const out = {
|
|
282
|
-
ok: true,
|
|
283
|
-
settings: safeSettings,
|
|
284
|
-
token: { ok: false },
|
|
285
|
-
// Catalog = what you actually want for a shop (available products/material)
|
|
286
|
-
catalog: { ok: false, count: 0, sample: [], keys: [] },
|
|
287
|
-
// Sold items = items present in orders (can be 0 if no sales yet)
|
|
288
|
-
soldItems: { ok: false, count: 0, sample: [] },
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
try {
|
|
292
|
-
const token = await helloasso.getAccessToken({
|
|
293
|
-
env,
|
|
294
|
-
clientId: settings.helloassoClientId,
|
|
295
|
-
clientSecret: settings.helloassoClientSecret,
|
|
296
|
-
});
|
|
297
|
-
if (!token) {
|
|
298
|
-
out.token = { ok: false, error: 'token-null' };
|
|
299
|
-
return res.json(out);
|
|
300
|
-
}
|
|
301
|
-
out.token = { ok: true };
|
|
302
|
-
|
|
303
|
-
// Catalog items (via /public)
|
|
304
|
-
try {
|
|
305
|
-
const y = new Date().getFullYear();
|
|
306
|
-
const { publicForm, items } = await helloasso.listCatalogItems({
|
|
307
|
-
env,
|
|
308
|
-
token,
|
|
309
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
310
|
-
formType: settings.helloassoFormType,
|
|
311
|
-
formSlug: `locations-materiel-${y}`,
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
const arr = Array.isArray(items) ? items : [];
|
|
315
|
-
out.catalog.ok = true;
|
|
316
|
-
out.catalog.count = arr.length;
|
|
317
|
-
out.catalog.keys = publicForm && typeof publicForm === 'object' ? Object.keys(publicForm) : [];
|
|
318
|
-
out.catalog.sample = arr.slice(0, 10).map((it) => ({
|
|
319
|
-
id: it.id,
|
|
320
|
-
name: it.name,
|
|
321
|
-
price: it.price ?? null,
|
|
322
|
-
}));
|
|
323
|
-
} catch (e) {
|
|
324
|
-
out.catalog = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [], keys: [] };
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Sold items
|
|
328
|
-
try {
|
|
329
|
-
const y2 = new Date().getFullYear();
|
|
330
|
-
const items = await helloasso.listItems({
|
|
331
|
-
env,
|
|
332
|
-
token,
|
|
333
|
-
organizationSlug: settings.helloassoOrganizationSlug,
|
|
334
|
-
formType: settings.helloassoFormType,
|
|
335
|
-
formSlug: `locations-materiel-${y2}`,
|
|
336
|
-
});
|
|
337
|
-
const arr = Array.isArray(items) ? items : [];
|
|
338
|
-
out.soldItems.ok = true;
|
|
339
|
-
out.soldItems.count = arr.length;
|
|
340
|
-
out.soldItems.sample = arr.slice(0, 10).map((it) => ({
|
|
341
|
-
id: it.id || it.itemId || it.reference || it.name,
|
|
342
|
-
name: it.name || it.label || it.itemName,
|
|
343
|
-
price: it.price || it.amount || it.unitPrice || null,
|
|
344
|
-
}));
|
|
345
|
-
} catch (e) {
|
|
346
|
-
out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
return res.json(out);
|
|
350
|
-
} catch (e) {
|
|
351
|
-
out.ok = false;
|
|
352
|
-
out.token = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
353
|
-
return res.json(out);
|
|
354
|
-
}
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
// Accounting endpoint: aggregates paid reservations so you can contabilize what was rented.
|
|
358
|
-
// Query params:
|
|
359
|
-
// from=YYYY-MM-DD (inclusive, based on reservation.start)
|
|
360
|
-
// to=YYYY-MM-DD (exclusive, based on reservation.start)
|
|
361
|
-
admin.getAccounting = async function (req, res) {
|
|
362
|
-
const qFrom = String((req.query && req.query.from) || '').trim();
|
|
363
|
-
const qTo = String((req.query && req.query.to) || '').trim();
|
|
364
|
-
const parseDay = (s) => {
|
|
365
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
366
|
-
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
|
|
367
|
-
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
368
|
-
const ts = dt.getTime();
|
|
369
|
-
return Number.isFinite(ts) ? ts : null;
|
|
370
|
-
};
|
|
371
|
-
const fromTs = parseDay(qFrom);
|
|
372
|
-
const toTs = parseDay(qTo);
|
|
373
|
-
|
|
374
|
-
// Default: last 12 months (UTC)
|
|
375
|
-
const now = new Date();
|
|
376
|
-
const defaultTo = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
377
|
-
const defaultFrom = Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1);
|
|
378
|
-
const minTs = fromTs ?? defaultFrom;
|
|
379
|
-
const maxTs = toTs ?? defaultTo;
|
|
380
|
-
|
|
381
|
-
const ids = await dbLayer.listAllReservationIds(100000);
|
|
382
|
-
const rows = [];
|
|
383
|
-
const byItem = new Map();
|
|
384
|
-
|
|
385
|
-
for (const rid of ids) {
|
|
386
|
-
const r = await dbLayer.getReservation(rid);
|
|
387
|
-
if (!r) continue;
|
|
388
|
-
if (String(r.status) !== 'paid') continue;
|
|
389
|
-
if (r.accPurgedAt) continue;
|
|
390
|
-
const start = parseInt(r.start, 10);
|
|
391
|
-
if (!Number.isFinite(start)) continue;
|
|
392
|
-
if (start < minTs || start >= maxTs) continue;
|
|
393
|
-
|
|
394
|
-
const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
|
|
395
|
-
? r.itemNames
|
|
396
|
-
: (r.itemName ? [r.itemName] : []);
|
|
397
|
-
|
|
398
|
-
const total = Number(r.total) || 0;
|
|
399
|
-
const startDate = formatFR(r.start);
|
|
400
|
-
const endDate = formatFR(r.end);
|
|
401
|
-
|
|
402
|
-
rows.push({
|
|
403
|
-
rid: r.rid,
|
|
404
|
-
uid: r.uid,
|
|
405
|
-
username: r.username || '',
|
|
406
|
-
start: r.start,
|
|
407
|
-
end: r.end,
|
|
408
|
-
startDate,
|
|
409
|
-
endDate,
|
|
410
|
-
items: itemNames,
|
|
411
|
-
total,
|
|
412
|
-
paidAt: r.paidAt || '',
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
for (const name of itemNames) {
|
|
416
|
-
const key = String(name || '').trim();
|
|
417
|
-
if (!key) continue;
|
|
418
|
-
const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
|
|
419
|
-
cur.count += 1;
|
|
420
|
-
cur.total += total;
|
|
421
|
-
byItem.set(key, cur);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const summary = Array.from(byItem.values()).sort((a, b) => b.count - a.count);
|
|
426
|
-
rows.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
427
|
-
|
|
428
|
-
return res.json({
|
|
429
|
-
ok: true,
|
|
430
|
-
from: new Date(minTs).toISOString().slice(0, 10),
|
|
431
|
-
to: new Date(maxTs).toISOString().slice(0, 10),
|
|
432
|
-
summary,
|
|
433
|
-
rows,
|
|
434
|
-
});
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
admin.exportAccountingCsv = async function (req, res) {
|
|
438
|
-
// Reuse the same logic and emit a CSV.
|
|
439
|
-
const fakeRes = { json: (x) => x };
|
|
440
|
-
const data = await admin.getAccounting(req, fakeRes);
|
|
441
|
-
// If getAccounting returned via res.json, data is undefined; rebuild by calling logic directly.
|
|
442
|
-
// Easiest: call getAccounting's internals by fetching the endpoint logic via HTTP is not possible here.
|
|
443
|
-
// So we re-run getAccounting but capture output by monkeypatching.
|
|
444
|
-
let payload;
|
|
445
|
-
await admin.getAccounting(req, { json: (x) => { payload = x; return x; } });
|
|
446
|
-
if (!payload || !payload.ok) {
|
|
447
|
-
return res.status(500).send('error');
|
|
448
|
-
}
|
|
449
|
-
const escape = (v) => {
|
|
450
|
-
const s = String(v ?? '');
|
|
451
|
-
if (/[\n\r,\"]/g.test(s)) {
|
|
452
|
-
return '"' + s.replace(/"/g, '""') + '"';
|
|
453
|
-
}
|
|
454
|
-
return s;
|
|
455
|
-
};
|
|
456
|
-
const lines = [];
|
|
457
|
-
lines.push(['rid', 'username', 'uid', 'start', 'end', 'items', 'total', 'paidAt'].map(escape).join(','));
|
|
458
|
-
for (const r of payload.rows || []) {
|
|
459
|
-
lines.push([
|
|
460
|
-
r.rid,
|
|
461
|
-
r.username,
|
|
462
|
-
r.uid,
|
|
463
|
-
r.startDate,
|
|
464
|
-
r.endDate,
|
|
465
|
-
(Array.isArray(r.items) ? r.items.join(' | ') : ''),
|
|
466
|
-
(Number(r.total) || 0).toFixed(2),
|
|
467
|
-
r.paidAt ? new Date(parseInt(r.paidAt, 10)).toISOString() : '',
|
|
468
|
-
].map(escape).join(','));
|
|
469
|
-
}
|
|
470
|
-
const csv = lines.join('\n');
|
|
471
|
-
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
472
|
-
res.setHeader('Content-Disposition', 'attachment; filename="calendar-onekite-accounting.csv"');
|
|
473
|
-
return res.send(csv);
|
|
474
|
-
};
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
admin.purgeAccounting = async function (req, res) {
|
|
478
|
-
const qFrom = String((req.query && req.query.from) || '').trim();
|
|
479
|
-
const qTo = String((req.query && req.query.to) || '').trim();
|
|
480
|
-
const parseDay = (s) => {
|
|
481
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
482
|
-
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
|
|
483
|
-
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
484
|
-
const ts = dt.getTime();
|
|
485
|
-
return Number.isFinite(ts) ? ts : null;
|
|
486
|
-
};
|
|
487
|
-
const fromTs = parseDay(qFrom);
|
|
488
|
-
const toTs = parseDay(qTo);
|
|
489
|
-
|
|
490
|
-
const now = new Date();
|
|
491
|
-
const defaultTo = Date.UTC(now.getUTCFullYear() + 100, 0, 1); // far future
|
|
492
|
-
const defaultFrom = Date.UTC(1970, 0, 1);
|
|
493
|
-
const minTs = fromTs ?? defaultFrom;
|
|
494
|
-
const maxTs = toTs ?? defaultTo;
|
|
495
|
-
|
|
496
|
-
const ids = await dbLayer.listAllReservationIds(200000);
|
|
497
|
-
let purged = 0;
|
|
498
|
-
const ts = Date.now();
|
|
499
|
-
|
|
500
|
-
for (const rid of ids) {
|
|
501
|
-
const r = await dbLayer.getReservation(rid);
|
|
502
|
-
if (!r) continue;
|
|
503
|
-
if (String(r.status) !== 'paid') continue;
|
|
504
|
-
// Already purged from accounting
|
|
505
|
-
if (r.accPurgedAt) continue;
|
|
506
|
-
const start = parseInt(r.start, 10);
|
|
507
|
-
if (!Number.isFinite(start)) continue;
|
|
508
|
-
if (start < minTs || start >= maxTs) continue;
|
|
509
|
-
|
|
510
|
-
r.accPurgedAt = ts;
|
|
511
|
-
await dbLayer.saveReservation(r);
|
|
512
|
-
purged++;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
return res.json({ ok: true, purged });
|
|
516
|
-
};
|
|
49
|
+
}
|
|
517
50
|
|
|
518
51
|
|
|
519
|
-
module.exports = admin;
|
package/package.json
CHANGED
package/public/client.js
CHANGED
|
@@ -189,6 +189,33 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
189
189
|
openMapViewer('Adresse', addr, lat, lon);
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
|
|
193
|
+
// Close any open Bootbox modal when navigating to a user profile from within a modal
|
|
194
|
+
document.addEventListener('click', (ev) => {
|
|
195
|
+
const a = ev.target && ev.target.closest ? ev.target.closest('a.onekite-user-link') : null;
|
|
196
|
+
if (!a) return;
|
|
197
|
+
const inModal = !!(a.closest && a.closest('.modal, .bootbox'));
|
|
198
|
+
if (!inModal) return;
|
|
199
|
+
const href = a.getAttribute('href');
|
|
200
|
+
if (!href) return;
|
|
201
|
+
ev.preventDefault();
|
|
202
|
+
try { bootbox.hideAll(); } catch (e) {}
|
|
203
|
+
setTimeout(() => { window.location.href = href; }, 50);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Close any open Bootbox modal when navigating to a user profile from within a modal
|
|
207
|
+
document.addEventListener('click', (ev) => {
|
|
208
|
+
const a = ev.target && ev.target.closest ? ev.target.closest('a.onekite-user-link') : null;
|
|
209
|
+
if (!a) return;
|
|
210
|
+
const inModal = !!(a.closest && a.closest('.modal, .bootbox'));
|
|
211
|
+
if (!inModal) return;
|
|
212
|
+
const href = a.getAttribute('href');
|
|
213
|
+
if (!href) return;
|
|
214
|
+
ev.preventDefault();
|
|
215
|
+
try { bootbox.hideAll(); } catch (e) {}
|
|
216
|
+
setTimeout(() => { window.location.href = href; }, 50);
|
|
217
|
+
});
|
|
218
|
+
|
|
192
219
|
function statusLabel(s) {
|
|
193
220
|
const map = {
|
|
194
221
|
pending: 'En attente',
|
|
@@ -228,7 +255,14 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
228
255
|
...opts,
|
|
229
256
|
});
|
|
230
257
|
if (!res.ok) {
|
|
231
|
-
|
|
258
|
+
let payload = null;
|
|
259
|
+
try {
|
|
260
|
+
payload = await res.json();
|
|
261
|
+
} catch (e) {}
|
|
262
|
+
const err = new Error(`${res.status}`);
|
|
263
|
+
err.status = res.status;
|
|
264
|
+
err.payload = payload;
|
|
265
|
+
throw err;
|
|
232
266
|
}
|
|
233
267
|
return await res.json();
|
|
234
268
|
}
|
|
@@ -634,7 +668,8 @@ function toDatetimeLocalValue(date) {
|
|
|
634
668
|
const canCreateSpecial = !!caps.canCreateSpecial;
|
|
635
669
|
const canDeleteSpecial = !!caps.canDeleteSpecial;
|
|
636
670
|
|
|
637
|
-
|
|
671
|
+
// Current creation mode: reservation (location) or special (event)
|
|
672
|
+
let mode = 'reservation';
|
|
638
673
|
function refreshDesktopModeButton() {
|
|
639
674
|
try {
|
|
640
675
|
const btn = document.querySelector('#onekite-calendar .fc-newSpecial-button');
|
|
@@ -818,13 +853,22 @@ function toDatetimeLocalValue(date) {
|
|
|
818
853
|
calendar.unselect();
|
|
819
854
|
isDialogOpen = false;
|
|
820
855
|
} catch (e) {
|
|
821
|
-
const code = String(e && e.message || '');
|
|
856
|
+
const code = String((e && (e.status || e.message)) || '');
|
|
857
|
+
const payload = e && e.payload ? e.payload : null;
|
|
858
|
+
|
|
822
859
|
if (code === '403') {
|
|
823
|
-
|
|
860
|
+
const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
|
|
861
|
+
const c = payload && payload.code ? String(payload.code) : '';
|
|
862
|
+
if (c === 'NOT_MEMBER' || /adh(é|e)rent/i.test(msg) || /membership/i.test(msg)) {
|
|
863
|
+
showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer une réservation.');
|
|
864
|
+
} else {
|
|
865
|
+
showAlert('error', msg || 'Impossible de créer la demande : droits insuffisants (groupe).');
|
|
866
|
+
}
|
|
824
867
|
} else if (code === '409') {
|
|
825
868
|
showAlert('error', 'Impossible : au moins un matériel est déjà réservé ou en attente sur cette période.');
|
|
826
869
|
} else {
|
|
827
|
-
|
|
870
|
+
const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
|
|
871
|
+
showAlert('error', msg || 'Erreur lors de la création de la demande.');
|
|
828
872
|
}
|
|
829
873
|
calendar.unselect();
|
|
830
874
|
isDialogOpen = false;
|
|
@@ -833,6 +877,39 @@ function toDatetimeLocalValue(date) {
|
|
|
833
877
|
dateClick: async function (info) {
|
|
834
878
|
// One-day selection convenience
|
|
835
879
|
const start = info.date;
|
|
880
|
+
|
|
881
|
+
// In "special event" mode, a simple click should propose a one-day event (not two days in the modal)
|
|
882
|
+
if (mode === 'special' && canCreateSpecial) {
|
|
883
|
+
if (isDialogOpen) {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
isDialogOpen = true;
|
|
887
|
+
try {
|
|
888
|
+
// Default to a same-day event: 00:00 -> 23:59
|
|
889
|
+
const end = new Date(start.getTime() + (23 * 60 + 59) * 60 * 1000);
|
|
890
|
+
const payload = await openSpecialEventDialog({ start, end, allDay: true });
|
|
891
|
+
setMode('reservation');
|
|
892
|
+
if (!payload) {
|
|
893
|
+
isDialogOpen = false;
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
|
|
897
|
+
method: 'POST',
|
|
898
|
+
body: JSON.stringify(payload),
|
|
899
|
+
}).catch(async () => {
|
|
900
|
+
return await fetchJson('/api/plugins/calendar-onekite/special-events', {
|
|
901
|
+
method: 'POST',
|
|
902
|
+
body: JSON.stringify(payload),
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
showAlert('success', 'Évènement créé.');
|
|
906
|
+
calendar.refetchEvents();
|
|
907
|
+
} finally {
|
|
908
|
+
isDialogOpen = false;
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
836
913
|
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
|
|
837
914
|
calendar.select(start, end);
|
|
838
915
|
},
|
|
@@ -843,7 +920,7 @@ function toDatetimeLocalValue(date) {
|
|
|
843
920
|
if (p.type === 'special') {
|
|
844
921
|
const username = String(p.username || '').trim();
|
|
845
922
|
const userLine = username
|
|
846
|
-
? `<div class="mb-2"><strong>Créé par</strong><br><a href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
|
|
923
|
+
? `<div class="mb-2"><strong>Créé par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
|
|
847
924
|
: '';
|
|
848
925
|
const addr = String(p.pickupAddress || '').trim();
|
|
849
926
|
const lat = Number(p.pickupLat);
|
|
@@ -895,7 +972,7 @@ function toDatetimeLocalValue(date) {
|
|
|
895
972
|
// Reserved-by line (user profile link)
|
|
896
973
|
const username = String(p.username || p.user || p.reservedBy || p.ownerUsername || '').trim();
|
|
897
974
|
const userLine = username
|
|
898
|
-
? `<div class="mb-2"><strong>Réservée par</strong><br><a href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
|
|
975
|
+
? `<div class="mb-2"><strong>Réservée par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
|
|
899
976
|
: '';
|
|
900
977
|
const itemsHtml = (() => {
|
|
901
978
|
const names = Array.isArray(p.itemNames) ? p.itemNames : (typeof p.itemNames === 'string' && p.itemNames.trim() ? p.itemNames.split(',').map(s=>s.trim()).filter(Boolean) : (p.itemName ? [p.itemName] : []));
|
|
@@ -908,7 +985,7 @@ function toDatetimeLocalValue(date) {
|
|
|
908
985
|
|
|
909
986
|
const approvedBy = String(p.approvedByUsername || '').trim();
|
|
910
987
|
const validatedByHtml = approvedBy
|
|
911
|
-
? `<div class=\"mb-2\"><strong>Validée par</strong><br><a href=\"
|
|
988
|
+
? `<div class=\"mb-2\"><strong>Validée par</strong><br><a class=\"onekite-user-link\" href=\"${window.location.origin}/user/${encodeURIComponent(approvedBy)}\">${escapeHtml(approvedBy)}</a></div>`
|
|
912
989
|
: '';
|
|
913
990
|
|
|
914
991
|
// Pickup details (address / time / notes) shown once validated
|