nodebb-plugin-equipment-calendar 9.0.15 → 9.1.4
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/library.js +65 -1538
- package/package.json +6 -16
- package/plugin.json +8 -19
- package/public/js/acp.js +44 -0
- package/public/js/client.js +66 -206
- package/scripts/postinstall.js +25 -0
- package/templates/admin/plugins/equipment-calendar.tpl +32 -21
- package/templates/equipment-calendar/calendar.tpl +21 -64
package/library.js
CHANGED
|
@@ -1,1593 +1,120 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Node 18+ has global fetch; fallback to undici if needed
|
|
4
|
-
let fetchFn = global.fetch;
|
|
5
|
-
try { if (!fetchFn) { fetchFn = require('undici').fetch; } } catch (e) {}
|
|
6
|
-
|
|
7
|
-
const db = require.main.require('./src/database');
|
|
8
3
|
const meta = require.main.require('./src/meta');
|
|
4
|
+
const db = require.main.require('./src/database');
|
|
9
5
|
const groups = require.main.require('./src/groups');
|
|
10
|
-
const user = require.main.require('./src/user');
|
|
11
|
-
const notifications = require.main.require('./src/notifications');
|
|
12
|
-
const Emailer = require.main.require('./src/emailer');
|
|
13
|
-
const routeHelpers = require.main.require('./src/routes/helpers');
|
|
14
|
-
const middleware = require.main.require('./src/middleware');
|
|
15
|
-
const helpers = require.main.require('./src/controllers/helpers');
|
|
16
|
-
const nconf = require.main.require('nconf');
|
|
17
|
-
|
|
18
6
|
const winston = require.main.require('winston');
|
|
19
7
|
|
|
20
|
-
function generateId() {
|
|
21
|
-
try {
|
|
22
|
-
// Node 14+ / modern: crypto.randomUUID
|
|
23
|
-
const crypto = require('crypto');
|
|
24
|
-
if (crypto.randomUUID) return crypto.randomUUID();
|
|
25
|
-
return crypto.randomBytes(16).toString('hex');
|
|
26
|
-
} catch (e) {
|
|
27
|
-
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function normalizeItemIds(itemIdsRaw) {
|
|
32
|
-
if (!itemIdsRaw) return [];
|
|
33
|
-
if (Array.isArray(itemIdsRaw)) {
|
|
34
|
-
return itemIdsRaw.map(String).flatMap((v) => String(v).split(',')).map(s => s.trim()).filter(Boolean);
|
|
35
|
-
}
|
|
36
|
-
if (typeof itemIdsRaw === 'string') {
|
|
37
|
-
return itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
38
|
-
}
|
|
39
|
-
// fallback: single value (number/object/etc.)
|
|
40
|
-
try {
|
|
41
|
-
return [String(itemIdsRaw).trim()].filter(Boolean);
|
|
42
|
-
} catch (e) {
|
|
43
|
-
return [];
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function parseDateInput(v) {
|
|
48
|
-
if (v === null || v === undefined) return null;
|
|
49
|
-
if (typeof v === 'number' || (typeof v === 'string' && v.trim() && /^\d+$/.test(v.trim()))) {
|
|
50
|
-
const ms = typeof v === 'number' ? v : parseInt(v.trim(), 10);
|
|
51
|
-
const d = new Date(ms);
|
|
52
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
53
|
-
}
|
|
54
|
-
if (typeof v === 'string') {
|
|
55
|
-
const s = v.trim();
|
|
56
|
-
if (!s) return null;
|
|
57
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
58
|
-
const d = new Date(s + 'T00:00:00Z');
|
|
59
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
60
|
-
}
|
|
61
|
-
const d = new Date(s);
|
|
62
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
63
|
-
}
|
|
64
|
-
try {
|
|
65
|
-
const d = new Date(String(v));
|
|
66
|
-
return Number.isNaN(d.getTime()) ? null : d;
|
|
67
|
-
} catch (e) {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function tzOffsetMs(date, timeZone) {
|
|
73
|
-
const asTz = new Date(date.toLocaleString('en-US', { timeZone }));
|
|
74
|
-
return asTz.getTime() - date.getTime();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function tzMidnightMsFromIso(iso, timeZone) {
|
|
78
|
-
const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
79
|
-
if (!m) return null;
|
|
80
|
-
const y = parseInt(m[1], 10);
|
|
81
|
-
const mo = parseInt(m[2], 10) - 1;
|
|
82
|
-
const d = parseInt(m[3], 10);
|
|
83
|
-
const utcMidnight = new Date(Date.UTC(y, mo, d, 0, 0, 0));
|
|
84
|
-
const offset = tzOffsetMs(utcMidnight, timeZone);
|
|
85
|
-
return utcMidnight.getTime() - offset;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function addDaysIso(iso, days) {
|
|
89
|
-
const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
90
|
-
if (!m) return iso;
|
|
91
|
-
const y = parseInt(m[1], 10);
|
|
92
|
-
const mo = parseInt(m[2], 10) - 1;
|
|
93
|
-
const d = parseInt(m[3], 10);
|
|
94
|
-
const dt = new Date(Date.UTC(y, mo, d));
|
|
95
|
-
dt.setUTCDate(dt.getUTCDate() + (days || 0));
|
|
96
|
-
const pad = (x) => String(x).padStart(2, '0');
|
|
97
|
-
return dt.getUTCFullYear() + '-' + pad(dt.getUTCMonth() + 1) + '-' + pad(dt.getUTCDate());
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function formatDateTimeFR(ms) {
|
|
101
|
-
const n = Number(ms || 0);
|
|
102
|
-
if (!n) return '';
|
|
103
|
-
try {
|
|
104
|
-
const parts = new Intl.DateTimeFormat('fr-FR', {
|
|
105
|
-
timeZone: 'Europe/Paris',
|
|
106
|
-
year: 'numeric',
|
|
107
|
-
month: '2-digit',
|
|
108
|
-
day: '2-digit',
|
|
109
|
-
hour: '2-digit',
|
|
110
|
-
minute: '2-digit',
|
|
111
|
-
hour12: false,
|
|
112
|
-
}).formatToParts(new Date(n));
|
|
113
|
-
const get = (t) => (parts.find(p => p.type === t) || {}).value || '';
|
|
114
|
-
return `${get('day')}/${get('month')}/${get('year')} ${get('hour')}:${get('minute')}`;
|
|
115
|
-
} catch (e) {
|
|
116
|
-
const d = new Date(n);
|
|
117
|
-
const pad = (x) => String(x).padStart(2,'0');
|
|
118
|
-
return `${pad(d.getDate())}/${pad(d.getMonth()+1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const axios = require('axios');
|
|
123
|
-
const { DateTime } = require('luxon');
|
|
124
|
-
const { v4: uuidv4 } = require('uuid');
|
|
125
|
-
const crypto = require('crypto');
|
|
126
|
-
|
|
127
8
|
const plugin = {};
|
|
128
9
|
const SETTINGS_KEY = 'equipmentCalendar';
|
|
129
10
|
|
|
130
11
|
const DEFAULT_SETTINGS = {
|
|
131
|
-
creatorGroups: '
|
|
12
|
+
creatorGroups: '',
|
|
132
13
|
approverGroup: 'administrators',
|
|
133
14
|
notifyGroup: 'administrators',
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
itemsSource: 'manual',
|
|
137
|
-
ha_itemsFormType: '',
|
|
138
|
-
ha_itemsFormSlug: '',
|
|
139
|
-
ha_locationMapJson: '{}',
|
|
140
|
-
ha_calendarItemNamePrefix: 'Location matériel',
|
|
141
|
-
paymentTimeoutMinutes: 10,
|
|
142
|
-
// HelloAsso
|
|
15
|
+
ha_apiBaseUrl: 'https://api.helloasso.com',
|
|
16
|
+
ha_organizationSlug: '',
|
|
143
17
|
ha_clientId: '',
|
|
144
18
|
ha_clientSecret: '',
|
|
145
|
-
|
|
19
|
+
ha_itemsFormType: 'shop',
|
|
20
|
+
ha_itemsFormSlug: '',
|
|
146
21
|
ha_returnUrl: '',
|
|
147
|
-
|
|
148
|
-
// calendar
|
|
22
|
+
paymentTimeoutMinutes: 10,
|
|
149
23
|
defaultView: 'dayGridMonth',
|
|
150
|
-
timezone: 'Europe/Paris',
|
|
151
|
-
// privacy
|
|
152
|
-
showRequesterToAll: '0', // 0/1
|
|
153
24
|
};
|
|
154
25
|
|
|
155
|
-
function normalizeSettings(raw) {
|
|
156
|
-
const out = {};
|
|
157
|
-
const keys = Object.keys(DEFAULT_SETTINGS);
|
|
158
|
-
keys.forEach((k) => {
|
|
159
|
-
const def = DEFAULT_SETTINGS[k];
|
|
160
|
-
const str = raw && raw[k];
|
|
161
|
-
if (typeof str !== 'string') {
|
|
162
|
-
out[k] = def;
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
const v = JSON.parse(str);
|
|
167
|
-
out[k] = (typeof v === typeof def) ? v : def;
|
|
168
|
-
} catch (e) {
|
|
169
|
-
out[k] = def;
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
return out;
|
|
173
|
-
}
|
|
174
26
|
async function getSettings() {
|
|
175
|
-
const raw = await meta.settings.get(
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return merged;
|
|
180
|
-
}
|
|
181
|
-
async function setSettings(payload) {
|
|
182
|
-
const sets = {};
|
|
183
|
-
Object.keys(payload || {}).forEach((k) => {
|
|
184
|
-
sets[k] = JSON.stringify(payload[k]);
|
|
185
|
-
});
|
|
186
|
-
if (!resp.ok) {
|
|
187
|
-
const t = await resp.text();
|
|
188
|
-
throw new Error(`HelloAsso token error: ${resp.status} ${t}`);
|
|
189
|
-
}
|
|
190
|
-
const json = await resp.json();
|
|
191
|
-
const accessToken = json.access_token;
|
|
192
|
-
const refreshToken = json.refresh_token;
|
|
193
|
-
|
|
194
|
-
const expiresIn = parseInt(json.expires_in, 10) || 0;
|
|
195
|
-
const expMs = now + (expiresIn * 1000);
|
|
196
|
-
|
|
197
|
-
// Per docs, refresh token is valid ~30 days and rotates; keep a conservative expiry (29 days)
|
|
198
|
-
const refreshExpMs = now + (29 * 24 * 60 * 60 * 1000);
|
|
199
|
-
|
|
200
|
-
haTokenCache = { accessToken, refreshToken, expMs };
|
|
201
|
-
|
|
202
|
-
try {
|
|
203
|
-
await db.setObject(tokenKey, {
|
|
204
|
-
refresh_token: refreshToken || stored && stored.refresh_token || '',
|
|
205
|
-
refresh_expires_at: String(refreshExpMs),
|
|
206
|
-
});
|
|
207
|
-
} catch (e) {}
|
|
208
|
-
|
|
209
|
-
return accessToken;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
async function createHelloAssoCheckoutIntent(settings, bookingId, reservations) {
|
|
213
|
-
const org = String(settings.ha_organizationSlug || '').trim();
|
|
214
|
-
if (!org) throw new Error('HelloAsso organization slug missing');
|
|
215
|
-
|
|
216
|
-
const baseUrl = (meta && meta.config && meta.config.url) ? meta.config.url.replace(/\/$/, '') : '';
|
|
217
|
-
const backUrl = baseUrl + '/equipment/calendar';
|
|
218
|
-
const returnUrl = baseUrl + `/equipment/helloasso/callback?type=return&bookingId=${encodeURIComponent(bookingId)}`;
|
|
219
|
-
const errorUrl = baseUrl + `/equipment/helloasso/callback?type=error&bookingId=${encodeURIComponent(bookingId)}`;
|
|
220
|
-
|
|
221
|
-
const items = await getActiveItems(settings);
|
|
222
|
-
const byId = {};
|
|
223
|
-
items.forEach(i => { byId[i.id] = i; });
|
|
224
|
-
|
|
225
|
-
const names = reservations.map(r => (byId[r.itemId] && byId[r.itemId].name) || r.itemId);
|
|
226
|
-
const totalAmount = reservations.reduce((sum, r) => {
|
|
227
|
-
const price = (byId[r.itemId] && parseInt(byId[r.itemId].priceCents, 10)) || 0;
|
|
228
|
-
return sum + (Number.isFinite(price) ? price : 0);
|
|
229
|
-
}, 0);
|
|
230
|
-
|
|
231
|
-
if (!totalAmount || totalAmount <= 0) {
|
|
232
|
-
throw new Error('Montant total invalide (prix manquant sur les items).');
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const body = {
|
|
236
|
-
totalAmount,
|
|
237
|
-
initialAmount: totalAmount,
|
|
238
|
-
itemName: `${String(settings.ha_calendarItemNamePrefix || 'Location matériel')}: ${names.join(', ')}`.slice(0, 250),
|
|
239
|
-
backUrl,
|
|
240
|
-
errorUrl,
|
|
241
|
-
returnUrl,
|
|
242
|
-
containsDonation: false,
|
|
243
|
-
metadata: {
|
|
244
|
-
bookingId,
|
|
245
|
-
rids: reservations.map(r => r.rid || r.id),
|
|
246
|
-
uid: reservations[0] && reservations[0].uid,
|
|
247
|
-
startMs: reservations[0] && reservations[0].startMs,
|
|
248
|
-
endMs: reservations[0] && reservations[0].endMs,
|
|
249
|
-
},
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
const token = await getHelloAssoAccessToken(settings);
|
|
253
|
-
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
|
|
254
|
-
const resp = await fetchFn(url, {
|
|
255
|
-
method: 'POST',
|
|
256
|
-
headers: {
|
|
257
|
-
accept: 'application/json',
|
|
258
|
-
'content-type': 'application/json',
|
|
259
|
-
authorization: `Bearer ${token}`,
|
|
260
|
-
},
|
|
261
|
-
body: JSON.stringify(body),
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
if (!resp.ok) {
|
|
265
|
-
const t = await resp.text();
|
|
266
|
-
throw new Error(`HelloAsso checkout-intents error: ${resp.status} ${t}`);
|
|
267
|
-
}
|
|
268
|
-
return await resp.json();
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async function fetchHelloAssoCheckoutIntent(settings, checkoutIntentId) {
|
|
272
|
-
const org = String(settings.ha_organizationSlug || '').trim();
|
|
273
|
-
const token = await getHelloAssoAccessToken(settings);
|
|
274
|
-
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents/${encodeURIComponent(checkoutIntentId)}`;
|
|
275
|
-
const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
|
|
276
|
-
if (!resp.ok) {
|
|
277
|
-
const t = await resp.text();
|
|
278
|
-
throw new Error(`HelloAsso checkout-intents GET error: ${resp.status} ${t}`);
|
|
279
|
-
}
|
|
280
|
-
return await resp.json();
|
|
27
|
+
const raw = await meta.settings.get(SETTINGS_KEY);
|
|
28
|
+
const s = Object.assign({}, DEFAULT_SETTINGS, raw || {});
|
|
29
|
+
s.paymentTimeoutMinutes = parseInt(s.paymentTimeoutMinutes, 10) || DEFAULT_SETTINGS.paymentTimeoutMinutes;
|
|
30
|
+
return s;
|
|
281
31
|
}
|
|
282
32
|
|
|
283
|
-
function
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const op = String(checkout && (checkout.state || checkout.operationState || '')).toLowerCase();
|
|
290
|
-
if (['authorized', 'paid', 'succeeded'].includes(op)) return true;
|
|
291
|
-
|
|
292
|
-
if (checkout && (checkout.order || checkout.orderId)) {
|
|
293
|
-
if (list.length) return true;
|
|
33
|
+
async function isUserInAnyGroups(uid, groupCsv) {
|
|
34
|
+
if (!uid || !groupCsv) return false;
|
|
35
|
+
const names = String(groupCsv).split(',').map(s => s.trim()).filter(Boolean);
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
if (await groups.isMember(uid, name)) return true;
|
|
294
38
|
}
|
|
295
39
|
return false;
|
|
296
40
|
}
|
|
297
41
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const token = await getHelloAssoAccessToken(settings);
|
|
305
|
-
const base = (String(settings.ha_apiBaseUrl || '').trim() || 'https://api.helloasso.com').replace(/\/$/, '');
|
|
306
|
-
const cacheKey = `equipmentCalendar:ha:items:${org}:${formType}:${formSlug}`;
|
|
307
|
-
|
|
308
|
-
// Cache 10 minutes
|
|
309
|
-
const cached = await db.getObject(cacheKey);
|
|
310
|
-
const now = Date.now();
|
|
311
|
-
if (cached && cached.itemsJson && cached.expMs && now < parseInt(cached.expMs, 10)) {
|
|
312
|
-
try { return JSON.parse(cached.itemsJson); } catch (e) {}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// IMPORTANT:
|
|
316
|
-
// - /forms/.../items = "articles vendus" (liés aux commandes) => peut renvoyer 0 si aucune commande
|
|
317
|
-
// - /forms/.../public = données publiques détaillées => contient la structure (tiers / products) du formulaire
|
|
318
|
-
const publicUrl = `${base}/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/public`;
|
|
319
|
-
const resp = await fetchFn(publicUrl, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
|
|
320
|
-
if (!resp.ok) {
|
|
321
|
-
const t = await resp.text();
|
|
322
|
-
throw new Error(`HelloAsso public form error: ${resp.status} ${t}`);
|
|
323
|
-
}
|
|
324
|
-
const data = await resp.json();
|
|
325
|
-
|
|
326
|
-
// Extract catalog items:
|
|
327
|
-
// structure differs by formType; common pattern: data.tiers[] or data.tiers[].products[] / items[]
|
|
328
|
-
const out = [];
|
|
329
|
-
const tiers = (data && (data.tiers || data.tiersList || data.prices || data.priceCategories)) || [];
|
|
330
|
-
const tierArr = Array.isArray(tiers) ? tiers : [];
|
|
331
|
-
|
|
332
|
-
function pushItem(it, tierName) {
|
|
333
|
-
if (!it) return;
|
|
334
|
-
const id = String(it.id || it.itemId || it.reference || it.slug || it.code || it.name || '').trim();
|
|
335
|
-
const name = String(it.name || it.label || it.title || '').trim() || (tierName ? String(tierName) : id);
|
|
336
|
-
if (!id || !name) return;
|
|
337
|
-
const amount = it.amount || it.price || it.unitPrice || it.totalAmount || it.initialAmount;
|
|
338
|
-
const priceCents = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
|
|
339
|
-
const raw = (typeof priceCents === 'number' ? priceCents : parseInt(priceCents, 10)) || 0;
|
|
340
|
-
// Heuristic: HelloAsso amounts are often in cents; convert when it looks like cents.
|
|
341
|
-
const price = (raw >= 1000 && raw % 100 === 0) ? (raw / 100) : raw;
|
|
342
|
-
out.push({ id, name, price, priceRaw: raw });
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Try a few known layouts
|
|
346
|
-
for (const t of tierArr) {
|
|
347
|
-
const tierName = t && (t.name || t.label || t.title);
|
|
348
|
-
const products = (t && (t.items || t.products || t.prices || t.options)) || [];
|
|
349
|
-
const arr = Array.isArray(products) ? products : [];
|
|
350
|
-
if (arr.length) {
|
|
351
|
-
arr.forEach(p => pushItem(p, tierName));
|
|
352
|
-
} else {
|
|
353
|
-
// sometimes tier itself is the product
|
|
354
|
-
pushItem(t, tierName);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Fallback: some forms expose items directly
|
|
359
|
-
if (!out.length && data && Array.isArray(data.items)) {
|
|
360
|
-
data.items.forEach(p => pushItem(p, ''));
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
await db.setObject(cacheKey, {
|
|
364
|
-
itemsJson: JSON.stringify(out),
|
|
365
|
-
expMs: String(Date.now() + 10 * 60 * 1000),
|
|
42
|
+
function genRid() {
|
|
43
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
44
|
+
const r = Math.random() * 16 | 0;
|
|
45
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
46
|
+
return v.toString(16);
|
|
366
47
|
});
|
|
367
|
-
|
|
368
|
-
return out;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
async function getActiveItems() {
|
|
372
|
-
const settings = await getSettings();
|
|
373
|
-
const rawItems = await fetchHelloAssoItems(settings);
|
|
374
|
-
const items = (Array.isArray(rawItems) ? rawItems : []).map((it) => ({
|
|
375
|
-
id: String(it.id || '').trim(),
|
|
376
|
-
name: String(it.name || '').trim(),
|
|
377
|
-
price: (Number(it.price || 0) || ((Number(it.priceCents || 0) || 0) >= 1000 && (Number(it.priceCents||0)%100===0) ? (Number(it.priceCents||0)/100) : (Number(it.priceCents||0)||0)) || 0),
|
|
378
|
-
active: true,
|
|
379
|
-
})).filter(it => it.id && it.name);
|
|
380
|
-
return items;
|
|
381
48
|
}
|
|
382
49
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
// item hash: equipmentCalendar:items (stored in settings as JSON)
|
|
386
|
-
// reservations stored as objects in db, indexed by id, and by itemId
|
|
387
|
-
// reservation object key: equipmentCalendar:reservation:<rid>
|
|
388
|
-
// index by item: equipmentCalendar:item:<itemId>:res (sorted set score=startMillis, value=resId)
|
|
389
|
-
|
|
390
|
-
function resKey(rid) { return `equipmentCalendar:reservation:${rid}`; }
|
|
391
|
-
function itemIndexKey(itemId) { return `equipmentCalendar:item:${itemId}:res`; }
|
|
392
|
-
|
|
393
|
-
function statusBlocksItem(status) {
|
|
394
|
-
const s = String(status || '');
|
|
395
|
-
if (s === 'rejected' || s === 'cancelled') return false;
|
|
396
|
-
return (s === 'pending' || s === 'approved' || s === 'payment_pending' || s === 'paid');
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async function saveBooking(booking) {
|
|
400
|
-
const bid = booking.bookingId;
|
|
401
|
-
await db.setObject(`equipmentCalendar:booking:${bid}`, booking);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
async function getBooking(bookingId) {
|
|
405
|
-
return await db.getObject(`equipmentCalendar:booking:${bookingId}`);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
async function addReservationToBooking(bookingId, rid) {
|
|
409
|
-
await db.setAdd(`equipmentCalendar:booking:${bookingId}:rids`, rid);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
async function getBookingRids(bookingId) {
|
|
413
|
-
return await db.getSetMembers(`equipmentCalendar:booking:${bookingId}:rids`) || [];
|
|
414
|
-
}
|
|
50
|
+
function reservationKey(rid) { return `equipmentCalendar:reservation:${rid}`; }
|
|
51
|
+
const reservationsZset = 'equipmentCalendar:reservations';
|
|
415
52
|
|
|
416
53
|
async function saveReservation(r) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
r.rid = rid;
|
|
420
|
-
await db.setObject(resKey(rid), r);
|
|
421
|
-
await db.sortedSetAdd(itemIndexKey(r.itemId), r.startMs, rid);
|
|
422
|
-
await db.sortedSetAdd('equipmentCalendar:reservations', r.startMs, rid);
|
|
54
|
+
await db.setObject(reservationKey(r.rid), r);
|
|
55
|
+
await db.sortedSetAdd(reservationsZset, r.createdAt || Date.now(), r.rid);
|
|
423
56
|
}
|
|
424
57
|
|
|
425
|
-
async function
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
return
|
|
58
|
+
async function listReservations(limit=1000) {
|
|
59
|
+
const rids = await db.getSortedSetRevRange(reservationsZset, 0, limit - 1);
|
|
60
|
+
const objs = await Promise.all(rids.map(id => db.getObject(reservationKey(id))));
|
|
61
|
+
return objs.filter(Boolean);
|
|
429
62
|
}
|
|
430
63
|
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
64
|
+
function toEvent(r) {
|
|
65
|
+
const start = String(r.startIso).slice(0,10);
|
|
66
|
+
const end = new Date(String(r.endIso).slice(0,10) + 'T00:00:00Z');
|
|
67
|
+
end.setUTCDate(end.getUTCDate() + 1);
|
|
68
|
+
const endExcl = end.toISOString().slice(0,10);
|
|
437
69
|
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
// remove from global index
|
|
442
|
-
try { await db.sortedSetRemove('equipmentCalendar:reservations', rid); } catch (e) {}
|
|
443
|
-
// remove from item index
|
|
444
|
-
try { await db.sortedSetRemove(itemIndexKey(r.itemId), rid); } catch (e) {}
|
|
445
|
-
// delete object
|
|
446
|
-
await db.delete(resKey(rid));
|
|
70
|
+
const status = r.status || 'pending';
|
|
71
|
+
const title = status === 'paid' ? '[PAID] Reservation' : (status === 'approved' ? '[APPROVED] Reservation' : '[PENDING] Reservation');
|
|
72
|
+
return { id: r.rid, title, start, end: endExcl, allDay: true };
|
|
447
73
|
}
|
|
448
74
|
|
|
449
|
-
function
|
|
450
|
-
const
|
|
451
|
-
const endMs = Number(obj.endMs);
|
|
452
|
-
return {
|
|
453
|
-
id: obj.id,
|
|
454
|
-
itemId: obj.itemId,
|
|
455
|
-
uid: Number(obj.uid),
|
|
456
|
-
startMs,
|
|
457
|
-
endMs,
|
|
458
|
-
status: obj.status,
|
|
459
|
-
createdAtMs: Number(obj.createdAtMs || 0),
|
|
460
|
-
updatedAtMs: Number(obj.updatedAtMs || 0),
|
|
461
|
-
validatorUid: obj.validatorUid ? Number(obj.validatorUid) : 0,
|
|
462
|
-
notesUser: obj.notesUser || '',
|
|
463
|
-
notesAdmin: obj.notesAdmin || '',
|
|
464
|
-
ha_checkoutIntentId: obj.ha_checkoutIntentId || '',
|
|
465
|
-
ha_paymentUrl: obj.ha_paymentUrl || '',
|
|
466
|
-
ha_paymentStatus: obj.ha_paymentStatus || '',
|
|
467
|
-
ha_paidAtMs: Number(obj.ha_paidAtMs || 0),
|
|
468
|
-
};
|
|
469
|
-
}
|
|
75
|
+
plugin.init = async function (params) {
|
|
76
|
+
const { router, middleware } = params;
|
|
470
77
|
|
|
471
|
-
async
|
|
472
|
-
|
|
473
|
-
const ids = await db.getSortedSetRangeByScore(itemIndexKey(itemId), 0, -1, startMs - 86400000, endMs + 86400000);
|
|
474
|
-
if (!ids || !ids.length) return [];
|
|
475
|
-
const objs = await db.getObjects(ids.map(resKey));
|
|
476
|
-
return (objs || []).filter(Boolean).map(normalizeReservation).filter(r => {
|
|
477
|
-
// overlap check
|
|
478
|
-
return r.startMs < endMs && r.endMs > startMs;
|
|
78
|
+
router.get('/admin/plugins/equipment-calendar', middleware.admin.buildHeader, async (req, res) => {
|
|
79
|
+
res.render('admin/plugins/equipment-calendar', {});
|
|
479
80
|
});
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
async function hasOverlap(itemId, startMs, endMs) {
|
|
483
|
-
const existing = await listReservationsForRange(itemId, startMs, endMs);
|
|
484
|
-
return existing.some(r => statusBlocksItem(r.status) && r.startMs < endMs && r.endMs > startMs);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// --- Permissions helpers ---
|
|
488
|
-
async function isInAnyGroup(uid, groupNamesCsv) {
|
|
489
|
-
if (!uid) return false;
|
|
490
|
-
const groupNames = String(groupNamesCsv || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
491
|
-
if (!groupNames.length) return false;
|
|
492
|
-
for (const g of groupNames) {
|
|
493
|
-
const isMember = await groups.isMember(uid, g);
|
|
494
|
-
if (isMember) return true;
|
|
495
|
-
}
|
|
496
|
-
return false;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
async function canCreate(uid, settings) {
|
|
500
|
-
return isInAnyGroup(uid, settings.creatorGroups);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
async function canApprove(uid, settings) {
|
|
504
|
-
return groups.isMember(uid, settings.approverGroup);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
async function listGroupUids(groupName) {
|
|
508
|
-
try {
|
|
509
|
-
const members = await groups.getMembers(groupName, 0, 9999);
|
|
510
|
-
return (members && members.users) ? members.users.map(u => u.uid) : [];
|
|
511
|
-
} catch (e) {
|
|
512
|
-
return [];
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
async function sendGroupNotificationAndEmail(groupName, notifTitle, notifBody, link) {
|
|
517
|
-
const uids = await listGroupUids(groupName);
|
|
518
|
-
if (!uids.length) return;
|
|
81
|
+
router.get('/api/admin/plugins/equipment-calendar', async (req, res) => res.json({}));
|
|
519
82
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
await notifications.create({
|
|
523
|
-
bodyShort: notifTitle,
|
|
524
|
-
bodyLong: notifBody,
|
|
525
|
-
nid: `equipmentCalendar:${uuidv4()}`,
|
|
526
|
-
from: 0,
|
|
527
|
-
path: link,
|
|
528
|
-
});
|
|
529
|
-
} catch (e) { /* ignore */ }
|
|
530
|
-
|
|
531
|
-
try {
|
|
532
|
-
// push notification to each user
|
|
533
|
-
for (const uid of uids) {
|
|
534
|
-
try {
|
|
535
|
-
await notifications.push({
|
|
536
|
-
uid,
|
|
537
|
-
bodyShort: notifTitle,
|
|
538
|
-
bodyLong: notifBody,
|
|
539
|
-
nid: `equipmentCalendar:${uuidv4()}`,
|
|
540
|
-
from: 0,
|
|
541
|
-
path: link,
|
|
542
|
-
});
|
|
543
|
-
} catch (e) { /* ignore per user */ }
|
|
544
|
-
}
|
|
545
|
-
} catch (e) { /* ignore */ }
|
|
546
|
-
|
|
547
|
-
// Emails (best-effort)
|
|
548
|
-
try {
|
|
549
|
-
const users = await user.getUsersData(uids);
|
|
550
|
-
const emails = (users || []).map(u => u.email).filter(Boolean);
|
|
551
|
-
if (emails.length) {
|
|
552
|
-
await Emailer.send('equipmentCalendar', {
|
|
553
|
-
// Emailer templates: we provide a minimal HTML via meta.template? NodeBB typically uses email templates
|
|
554
|
-
// Here we rely on Emailer plugin config; this is best-effort and may vary per installation.
|
|
555
|
-
// Fallback: send each email individually with raw subject/text if supported.
|
|
556
|
-
subject: notifTitle,
|
|
557
|
-
body: `${notifBody}\n\n${link}`,
|
|
558
|
-
to: emails.join(','),
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
} catch (e) {
|
|
562
|
-
// Many NodeBB installs do not support ad-hoc Emailer.send signatures; ignore gracefully.
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// --- HelloAsso helpers ---
|
|
567
|
-
// This is a minimal integration skeleton.
|
|
568
|
-
// See HelloAsso API v5 docs for exact endpoints and payloads.
|
|
569
|
-
async function helloAssoGetAccessToken(settings) {
|
|
570
|
-
// HelloAsso uses OAuth2 client credentials.
|
|
571
|
-
// Endpoint: https://api.helloasso.com/oauth2/token
|
|
572
|
-
const url = 'https://api.helloasso.com/oauth2/token';
|
|
573
|
-
const params = new URLSearchParams();
|
|
574
|
-
params.append('grant_type', 'client_credentials');
|
|
575
|
-
params.append('client_id', settings.ha_clientId);
|
|
576
|
-
params.append('client_secret', settings.ha_clientSecret);
|
|
577
|
-
|
|
578
|
-
const resp = await axios.post(url, params.toString(), {
|
|
579
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
580
|
-
timeout: 15000,
|
|
83
|
+
router.get('/equipment/calendar', middleware.buildHeader, async (req, res) => {
|
|
84
|
+
res.render('equipment-calendar/calendar', {});
|
|
581
85
|
});
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
async function helloAssoCreateCheckout(settings, token, reservation, item) {
|
|
586
|
-
// Minimal: create a checkout intent and return redirectUrl
|
|
587
|
-
// This endpoint/payload may need adaptation depending on your HelloAsso setup.
|
|
588
|
-
const org = settings.ha_organizationSlug;
|
|
589
|
-
if (!org) throw new Error('HelloAsso organizationSlug missing');
|
|
590
|
-
|
|
591
|
-
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
|
|
592
|
-
const amountCents = Math.max(0, Number(item.priceCents || 0));
|
|
86
|
+
router.get('/api/equipment/calendar', async (req, res) => res.json({}));
|
|
593
87
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
itemName: item.name,
|
|
598
|
-
backUrl: settings.ha_returnUrl || `${nconf.get('url')}/equipment/payment/return?rid=${encodeURIComponent(reservation.id)}`,
|
|
599
|
-
errorUrl: settings.ha_returnUrl || `${nconf.get('url')}/equipment/payment/return?rid=${encodeURIComponent(reservation.id)}&status=error`,
|
|
600
|
-
metadata: {
|
|
601
|
-
reservationId: reservation.id,
|
|
602
|
-
itemId: reservation.itemId,
|
|
603
|
-
uid: String(reservation.uid),
|
|
604
|
-
},
|
|
605
|
-
};
|
|
606
|
-
|
|
607
|
-
const resp = await axios.post(url, payload, {
|
|
608
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
609
|
-
timeout: 15000,
|
|
88
|
+
router.get('/api/plugins/equipment-calendar/events', async (req, res) => {
|
|
89
|
+
const list = await listReservations(1000);
|
|
90
|
+
res.json({ events: list.map(toEvent) });
|
|
610
91
|
});
|
|
611
92
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
paymentUrl: resp.data.redirectUrl || resp.data.paymentUrl || '',
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
// Webhook signature check (simple HMAC SHA256 over raw body)
|
|
620
|
-
function verifyWebhook(req, secret) {
|
|
621
|
-
if (!secret) return true;
|
|
622
|
-
const sig = req.headers['x-helloasso-signature'] || req.headers['x-helloasso-signature-hmac-sha256'];
|
|
623
|
-
if (!sig) return false;
|
|
624
|
-
const raw = req.rawBody || '';
|
|
625
|
-
const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
|
|
626
|
-
// Some providers prefix, some base64; accept hex only in this skeleton
|
|
627
|
-
return String(sig).toLowerCase() === expected.toLowerCase();
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// --- Rendering helpers ---
|
|
631
|
-
function toEvent(res, item, requesterName, canSeeRequester) {
|
|
632
|
-
const start = DateTime.fromMillis(res.startMs).toISO();
|
|
633
|
-
const end = DateTime.fromMillis(res.endMs).toISO();
|
|
634
|
-
|
|
635
|
-
let icon = '⏳';
|
|
636
|
-
let className = 'ec-status-pending';
|
|
637
|
-
if (res.status === 'approved_waiting_payment') { icon = '💳'; className = 'ec-status-awaitpay'; }
|
|
638
|
-
if (res.status === 'paid_validated') { icon = '✅'; className = 'ec-status-valid'; }
|
|
639
|
-
if (res.status === 'rejected' || res.status === 'cancelled') { icon = '❌'; className = 'ec-status-cancel'; }
|
|
640
|
-
|
|
641
|
-
const titleParts = [icon, item ? item.name : res.itemId];
|
|
642
|
-
if (canSeeRequester && requesterName) titleParts.push(`- ${requesterName}`);
|
|
643
|
-
return {
|
|
644
|
-
id: res.id,
|
|
645
|
-
title: titleParts.join(' '),
|
|
646
|
-
start,
|
|
647
|
-
end,
|
|
648
|
-
allDay: true,
|
|
649
|
-
className,
|
|
650
|
-
extendedProps: {
|
|
651
|
-
status: res.status,
|
|
652
|
-
itemId: res.itemId,
|
|
653
|
-
},
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function clampRange(startStr, endStr, tz) {
|
|
658
|
-
const start = DateTime.fromISO(startStr, { zone: tz }).isValid ? DateTime.fromISO(startStr, { zone: tz }) : DateTime.now().setZone(tz).startOf('month');
|
|
659
|
-
const end = DateTime.fromISO(endStr, { zone: tz }).isValid ? DateTime.fromISO(endStr, { zone: tz }) : start.plus({ months: 1 });
|
|
660
|
-
// prevent crazy ranges
|
|
661
|
-
const maxDays = 62;
|
|
662
|
-
const diffDays = Math.abs(end.diff(start, 'days').days);
|
|
663
|
-
const safeEnd = diffDays > maxDays ? start.plus({ days: maxDays }) : end;
|
|
664
|
-
return { start, end: safeEnd };
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// --- Routes ---
|
|
668
|
-
plugin.init = async function (params) {
|
|
669
|
-
const { router } = params;
|
|
670
|
-
const mid = params.middleware;
|
|
671
|
-
|
|
672
|
-
// Admin (ACP) routes
|
|
673
|
-
if (mid && mid.admin) {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
|
|
679
|
-
router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
|
|
680
|
-
router.get('/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, mid.admin.buildHeader, handleHelloAssoTest);
|
|
681
|
-
router.get('/api/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, handleHelloAssoTest);
|
|
682
|
-
router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
|
|
683
|
-
router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
|
|
684
|
-
router.post('/admin/plugins/equipment-calendar/purge', middleware.applyCSRF, handleAdminPurge);
|
|
685
|
-
router.post('/admin/plugins/equipment-calendar/reservations/:rid/approve', middleware.applyCSRF, handleAdminApprove);
|
|
686
|
-
router.post('/admin/plugins/equipment-calendar/reservations/:rid/reject', middleware.applyCSRF, handleAdminReject);
|
|
687
|
-
router.post('/admin/plugins/equipment-calendar/reservations/:rid/delete', middleware.applyCSRF, handleAdminDelete);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Convenience alias (optional): /calendar -> /equipment/calendar
|
|
691
|
-
router.get('/calendar', (req, res) => res.redirect('/equipment/calendar'));
|
|
692
|
-
|
|
693
|
-
// To verify webhook signature we need raw body; add a rawBody collector for this route only
|
|
694
|
-
router.post('/equipment/webhook/helloasso',
|
|
695
|
-
require.main.require('body-parser').text({ type: '*/*' }),
|
|
696
|
-
async (req, res) => {
|
|
697
|
-
req.rawBody = req.body || '';
|
|
698
|
-
let json;
|
|
699
|
-
try { json = JSON.parse(req.rawBody || '{}'); } catch (e) { json = {}; }
|
|
700
|
-
|
|
701
|
-
const settings = await getSettings();
|
|
702
|
-
if (!verifyWebhook(req, settings.ha_webhookSecret)) {
|
|
703
|
-
return res.status(401).json({ ok: false, error: 'invalid signature' });
|
|
704
|
-
}
|
|
93
|
+
router.post('/api/plugins/equipment-calendar/reservations', middleware.applyCSRF, async (req, res) => {
|
|
94
|
+
const uid = req.uid;
|
|
95
|
+
if (!uid) return res.status(403).json({ message: 'forbidden' });
|
|
705
96
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
if (reservationId) {
|
|
711
|
-
const reservation = await getReservation(reservationId);
|
|
712
|
-
if (reservation) {
|
|
713
|
-
reservation.ha_paymentStatus = String(paymentStatus);
|
|
714
|
-
// Heuristic: if payload includes "OrderPaid" or "Payment" success
|
|
715
|
-
const isPaid = /paid|success|payment/i.test(String(paymentStatus)) || json?.data?.state === 'Paid';
|
|
716
|
-
if (isPaid) {
|
|
717
|
-
reservation.status = 'paid_validated';
|
|
718
|
-
reservation.ha_paidAtMs = Date.now();
|
|
719
|
-
}
|
|
720
|
-
reservation.updatedAtMs = Date.now();
|
|
721
|
-
await saveReservation(reservation);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
return res.json({ ok: true });
|
|
726
|
-
}
|
|
727
|
-
);
|
|
728
|
-
|
|
729
|
-
// Return endpoint (optional)
|
|
730
|
-
router.get('/equipment/payment/return', middleware.buildHeader, async (req, res) => {
|
|
731
|
-
res.render('equipment-calendar/payment-return', {
|
|
732
|
-
title: 'Paiement',
|
|
733
|
-
rid: req.query.rid || '',
|
|
734
|
-
status: req.query.status || 'ok',
|
|
735
|
-
statusError: String(req.query.status || '') === 'error',
|
|
736
|
-
});
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
// Page routes are attached via filter:router.page as well, but we also add directly to be safe:
|
|
740
|
-
|
|
741
|
-
// Calendar inline actions (approve/reject/delete) via API
|
|
742
|
-
router.post('/api/equipment/reservations/:rid/action', middleware.applyCSRF, async (req, res) => {
|
|
743
|
-
try {
|
|
744
|
-
const settings = await getSettings();
|
|
745
|
-
const rid = String(req.params.rid || '').trim();
|
|
746
|
-
const action = String(req.body.action || '').trim();
|
|
747
|
-
if (!rid || !action) return res.status(400).json({ error: 'missing' });
|
|
97
|
+
const settings = await getSettings();
|
|
98
|
+
const canCreate = await isUserInAnyGroups(uid, settings.creatorGroups) || await groups.isMember(uid, 'administrators');
|
|
99
|
+
if (!canCreate) return res.status(403).json({ message: 'forbidden' });
|
|
748
100
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
101
|
+
const startIso = String(req.body.startIso || '').slice(0,10);
|
|
102
|
+
const endIso = String(req.body.endIso || '').slice(0,10);
|
|
103
|
+
const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
|
|
104
|
+
if (!startIso || !endIso) return res.status(400).json({ message: 'dates required' });
|
|
105
|
+
if (!itemIds.length) return res.status(400).json({ message: 'items required' });
|
|
752
106
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
await setReservationStatus(rid, 'rejected');
|
|
757
|
-
} else if (action === 'delete') {
|
|
758
|
-
await deleteReservation(rid);
|
|
759
|
-
} else {
|
|
760
|
-
return res.status(400).json({ error: 'bad_action' });
|
|
761
|
-
}
|
|
762
|
-
return res.json({ ok: true });
|
|
763
|
-
} catch (e) {
|
|
764
|
-
winston.error('[equipment-calendar] api action error', e);
|
|
765
|
-
return res.status(500).json({ error: 'server' });
|
|
766
|
-
}
|
|
107
|
+
const r = { rid: genRid(), uid, startIso, endIso, itemIds: itemIds.map(String), status: 'pending', createdAt: Date.now() };
|
|
108
|
+
await saveReservation(r);
|
|
109
|
+
res.json({ ok: true, rid: r.rid });
|
|
767
110
|
});
|
|
768
111
|
|
|
769
|
-
|
|
770
|
-
router.get('/equipment/approvals', middleware.buildHeader, renderApprovalsPage);
|
|
771
|
-
|
|
772
|
-
router.post('/equipment/reservations/create', middleware.applyCSRF, handleCreateReservation);
|
|
773
|
-
router.post('/equipment/reservations/:id/approve', middleware.applyCSRF, handleApproveReservation);
|
|
774
|
-
router.post('/equipment/reservations/:id/reject', middleware.applyCSRF, handleRejectReservation);
|
|
775
|
-
};
|
|
776
|
-
|
|
777
|
-
plugin.addPageRoutes = async function (data) {
|
|
778
|
-
// Ensure routes are treated as "page" routes by NodeBB router filter
|
|
779
|
-
// Not strictly necessary when using router.get with buildHeader, but kept for compatibility.
|
|
780
|
-
return data;
|
|
112
|
+
winston.info('[equipment-calendar] loaded (official4x standard bundle)');
|
|
781
113
|
};
|
|
782
114
|
|
|
783
|
-
plugin.
|
|
784
|
-
header.plugins.push({
|
|
785
|
-
route: '/plugins/equipment-calendar',
|
|
786
|
-
icon: 'fa-calendar',
|
|
787
|
-
name: 'Equipment Calendar',
|
|
788
|
-
});
|
|
115
|
+
plugin.addAdminMenu = async function (header) {
|
|
116
|
+
header.plugins.push({ route: '/plugins/equipment-calendar', icon: 'fa-calendar', name: 'Equipment Calendar' });
|
|
789
117
|
return header;
|
|
790
118
|
};
|
|
791
119
|
|
|
792
|
-
// --- Admin page routes (ACP) ---
|
|
793
|
-
plugin.addAdminRoutes = async function (params) {
|
|
794
|
-
const { router, middleware: mid } = params;
|
|
795
|
-
router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
|
|
796
|
-
router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
|
|
797
|
-
router.get('/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, mid.admin.buildHeader, handleHelloAssoTest);
|
|
798
|
-
router.get('/api/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, handleHelloAssoTest);
|
|
799
|
-
router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
|
|
800
|
-
router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
|
|
801
|
-
};
|
|
802
|
-
|
|
803
|
-
async function renderAdminReservationsPage(req, res) {
|
|
804
|
-
if (!(await ensureIsAdmin(req, res))) return;
|
|
805
|
-
|
|
806
|
-
const settings = await getSettings();
|
|
807
|
-
const items = await getActiveItems(settings);
|
|
808
|
-
const itemById = {};
|
|
809
|
-
items.forEach(it => { itemById[it.id] = it; });
|
|
810
|
-
|
|
811
|
-
const status = String(req.query.status || ''); // optional filter
|
|
812
|
-
const itemId = String(req.query.itemId || ''); // optional filter
|
|
813
|
-
const q = String(req.query.q || '').trim(); // search rid/user/notes
|
|
814
|
-
|
|
815
|
-
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
816
|
-
const perPage = Math.min(100, Math.max(10, parseInt(req.query.perPage, 10) || 50));
|
|
817
|
-
|
|
818
|
-
const allRids = await db.getSortedSetRevRange('equipmentCalendar:reservations', 0, -1);
|
|
819
|
-
const totalAll = allRids.length;
|
|
820
|
-
|
|
821
|
-
const rows = [];
|
|
822
|
-
for (const rid of allRids) {
|
|
823
|
-
// eslint-disable-next-line no-await-in-loop
|
|
824
|
-
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
825
|
-
if (!r || !r.rid) continue;
|
|
826
|
-
|
|
827
|
-
if (status && String(r.status) !== status) continue;
|
|
828
|
-
if (itemId && String(r.itemId) !== itemId) continue;
|
|
829
|
-
|
|
830
|
-
const notes = String(r.notesUser || '');
|
|
831
|
-
const ridStr = String(r.rid || rid);
|
|
832
|
-
if (q) {
|
|
833
|
-
const hay = (ridStr + ' ' + String(r.uid || '') + ' ' + notes).toLowerCase();
|
|
834
|
-
if (!hay.includes(q.toLowerCase())) continue;
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
const startMs = parseInt(r.startMs, 10) || 0;
|
|
838
|
-
const endMs = parseInt(r.endMs, 10) || 0;
|
|
839
|
-
const createdAt = parseInt(r.createdAt, 10) || 0;
|
|
840
|
-
|
|
841
|
-
rows.push({
|
|
842
|
-
rid: ridStr,
|
|
843
|
-
itemId: String(r.itemId || ''),
|
|
844
|
-
itemName: (itemById[String(r.itemId || '')] && itemById[String(r.itemId || '')].name) || String(r.itemId || ''),
|
|
845
|
-
uid: String(r.uid || ''),
|
|
846
|
-
status: String(r.status || ''),
|
|
847
|
-
start: r.startIso,
|
|
848
|
-
end: addDaysIso(r.endIso, 1),
|
|
849
|
-
createdAt: formatDateTimeFR(r.createdAt || 0),
|
|
850
|
-
notesUser: notes,
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
const total = rows.length;
|
|
855
|
-
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
|
856
|
-
const safePage = Math.min(page, totalPages);
|
|
857
|
-
const startIndex = (safePage - 1) * perPage;
|
|
858
|
-
const pageRows = rows.slice(startIndex, startIndex + perPage);
|
|
859
|
-
|
|
860
|
-
const itemOptions = [{ id: '', name: 'Tous' }].concat(items.map(i => ({ id: i.id, name: i.name })));
|
|
861
|
-
const statusOptions = [
|
|
862
|
-
{ id: '', name: 'Tous' },
|
|
863
|
-
{ id: 'pending', name: 'pending' },
|
|
864
|
-
{ id: 'approved', name: 'approved' },
|
|
865
|
-
{ id: 'paid', name: 'paid' },
|
|
866
|
-
{ id: 'rejected', name: 'rejected' },
|
|
867
|
-
{ id: 'cancelled', name: 'cancelled' },
|
|
868
|
-
];
|
|
869
|
-
|
|
870
|
-
res.render('admin/plugins/equipment-calendar-reservations', {
|
|
871
|
-
title: 'Equipment Calendar - Réservations',
|
|
872
|
-
settings,
|
|
873
|
-
rows: pageRows,
|
|
874
|
-
hasRows: pageRows.length > 0,
|
|
875
|
-
itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
|
|
876
|
-
statusOptions: statusOptions.map(o => ({ ...o, selected: o.id === status })),
|
|
877
|
-
q,
|
|
878
|
-
page: safePage,
|
|
879
|
-
perPage,
|
|
880
|
-
total,
|
|
881
|
-
totalAll,
|
|
882
|
-
totalPages,
|
|
883
|
-
prevPage: safePage > 1 ? safePage - 1 : 0,
|
|
884
|
-
nextPage: safePage < totalPages ? safePage + 1 : 0,
|
|
885
|
-
actionBase: '/admin/plugins/equipment-calendar/reservations',
|
|
886
|
-
});
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
async function handleAdminApprove(req, res) {
|
|
890
|
-
if (!(await ensureIsAdmin(req, res))) return;
|
|
891
|
-
const rid = String(req.params.rid || '').trim();
|
|
892
|
-
if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
893
|
-
|
|
894
|
-
const key = `equipmentCalendar:reservation:${rid}`;
|
|
895
|
-
const r = await db.getObject(key);
|
|
896
|
-
if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
897
|
-
|
|
898
|
-
r.status = 'approved';
|
|
899
|
-
await db.setObject(key, r);
|
|
900
|
-
|
|
901
|
-
return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
async function handleAdminReject(req, res) {
|
|
905
|
-
if (!(await ensureIsAdmin(req, res))) return;
|
|
906
|
-
const rid = String(req.params.rid || '').trim();
|
|
907
|
-
if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
908
|
-
|
|
909
|
-
const key = `equipmentCalendar:reservation:${rid}`;
|
|
910
|
-
const r = await db.getObject(key);
|
|
911
|
-
if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
912
|
-
|
|
913
|
-
r.status = 'rejected';
|
|
914
|
-
await db.setObject(key, r);
|
|
915
|
-
|
|
916
|
-
return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
async function handleAdminDelete(req, res) {
|
|
920
|
-
if (!(await ensureIsAdmin(req, res))) return;
|
|
921
|
-
const rid = String(req.params.rid || '').trim();
|
|
922
|
-
if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
923
|
-
await deleteReservation(rid);
|
|
924
|
-
return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
async function handleHelloAssoTest(req, res) {
|
|
928
|
-
const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
|
|
929
|
-
if (!isAdmin) return helpers.notAllowed(req, res);
|
|
930
|
-
|
|
931
|
-
const settings = await getSettings();
|
|
932
|
-
let sampleItems = [];
|
|
933
|
-
let hasSampleItems = false;
|
|
934
|
-
let ok = false;
|
|
935
|
-
let message = '';
|
|
936
|
-
let count = 0;
|
|
937
|
-
|
|
938
|
-
try {
|
|
939
|
-
const force = String(req.query.force || '') === '1';
|
|
940
|
-
const clear = String(req.query.clear || '') === '1';
|
|
941
|
-
// force=1 skips in-memory cache and refresh_token; clear=1 wipes stored refresh token
|
|
942
|
-
haTokenCache = null;
|
|
943
|
-
await getHelloAssoAccessToken(settings, { force, clearStored: clear });
|
|
944
|
-
const items = await fetchHelloAssoItems(settings);
|
|
945
|
-
const list = Array.isArray(items) ? items : (Array.isArray(items.data) ? items.data : []);
|
|
946
|
-
count = list.length;
|
|
947
|
-
sampleItems = list.slice(0, 10).map(it => ({
|
|
948
|
-
id: String(it.id || it.itemId || it.reference || it.slug || it.name || '').trim(),
|
|
949
|
-
name: String(it.name || it.label || it.title || '').trim(),
|
|
950
|
-
rawName: String(it.name || it.label || it.title || it.id || '').trim(),
|
|
951
|
-
}));
|
|
952
|
-
hasSampleItems = sampleItems && sampleItems.length > 0;
|
|
953
|
-
ok = true;
|
|
954
|
-
message = `OK: token valide. Catalogue récupéré via /public : ${count} item(s).`;
|
|
955
|
-
} catch (e) {
|
|
956
|
-
ok = false;
|
|
957
|
-
message = (e && e.message) ? e.message : String(e);
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
res.render('admin/plugins/equipment-calendar-helloasso-test', {
|
|
961
|
-
title: 'Equipment Calendar - Test HelloAsso',
|
|
962
|
-
ok,
|
|
963
|
-
message,
|
|
964
|
-
count,
|
|
965
|
-
settings,
|
|
966
|
-
sampleItems,
|
|
967
|
-
hasSampleItems,
|
|
968
|
-
hasSampleItems: sampleItems && sampleItems.length > 0,
|
|
969
|
-
});
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
async function renderAdminPage(req, res) {
|
|
973
|
-
const settings = await getSettings();
|
|
974
|
-
res.render('admin/plugins/equipment-calendar', {
|
|
975
|
-
title: 'Equipment Calendar',
|
|
976
|
-
settings,
|
|
977
|
-
saved: req.query && String(req.query.saved || '') === '1',
|
|
978
|
-
purged: req.query && parseInt(req.query.purged, 10) || 0,
|
|
979
|
-
view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
|
|
980
|
-
view_timeGridWeek: (settings.defaultView || '') === 'timeGridWeek',
|
|
981
|
-
view_timeGridDay: (settings.defaultView || '') === 'timeGridDay',
|
|
982
|
-
view_showRequesterToAll: String(settings.showRequesterToAll || '0') === '1',
|
|
983
|
-
});
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
// --- Calendar page ---
|
|
987
|
-
|
|
988
|
-
async function handleHelloAssoCallback(req, res) {
|
|
989
|
-
const settings = await getSettings();
|
|
990
|
-
|
|
991
|
-
const bookingId = String(req.query.bookingId || '').trim();
|
|
992
|
-
const checkoutIntentId = String(req.query.checkoutIntentId || '').trim();
|
|
993
|
-
const code = String(req.query.code || '').trim();
|
|
994
|
-
const type = String(req.query.type || '').trim();
|
|
995
|
-
|
|
996
|
-
let status = 'pending';
|
|
997
|
-
let message = 'Retour paiement reçu. Vérification en cours…';
|
|
998
|
-
|
|
999
|
-
try {
|
|
1000
|
-
if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
|
|
1001
|
-
const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
|
|
1002
|
-
|
|
1003
|
-
const paid = isCheckoutPaid(checkout);
|
|
1004
|
-
if (paid) {
|
|
1005
|
-
status = 'paid';
|
|
1006
|
-
message = 'Paiement confirmé. Merci !';
|
|
1007
|
-
|
|
1008
|
-
if (bookingId) {
|
|
1009
|
-
const rids = await getBookingRids(bookingId);
|
|
1010
|
-
for (const rid of rids) {
|
|
1011
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1012
|
-
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
1013
|
-
if (!r || !r.rid) continue;
|
|
1014
|
-
r.status = 'paid';
|
|
1015
|
-
r.paidAt = Date.now();
|
|
1016
|
-
r.checkoutIntentId = checkoutIntentId;
|
|
1017
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1018
|
-
await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
|
|
1019
|
-
}
|
|
1020
|
-
try {
|
|
1021
|
-
const b = (await getBooking(bookingId)) || { bookingId };
|
|
1022
|
-
b.status = 'paid';
|
|
1023
|
-
b.checkoutIntentId = checkoutIntentId;
|
|
1024
|
-
b.paidAt = Date.now();
|
|
1025
|
-
await saveBooking(b);
|
|
1026
|
-
} catch (e) {}
|
|
1027
|
-
}
|
|
1028
|
-
} else {
|
|
1029
|
-
status = (type === 'error' || code) ? 'error' : 'pending';
|
|
1030
|
-
message = 'Paiement non confirmé. Si vous venez de payer, réessayez dans quelques instants.';
|
|
1031
|
-
}
|
|
1032
|
-
} catch (e) {
|
|
1033
|
-
status = 'error';
|
|
1034
|
-
message = e.message || 'Erreur de vérification paiement';
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
res.render('equipment-calendar/payment-return', {
|
|
1038
|
-
title: 'Retour paiement',
|
|
1039
|
-
status,
|
|
1040
|
-
statusError: status === 'error',
|
|
1041
|
-
statusPaid: status === 'paid',
|
|
1042
|
-
message,
|
|
1043
|
-
});
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
async function renderCalendarPage(req, res) {
|
|
1047
|
-
const settings = await getSettings();
|
|
1048
|
-
const items = await getActiveItems(settings);
|
|
1049
|
-
|
|
1050
|
-
const tz = settings.timezone || 'Europe/Paris';
|
|
1051
|
-
|
|
1052
|
-
// Determine range to render
|
|
1053
|
-
const now = DateTime.now().setZone(tz);
|
|
1054
|
-
const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
|
|
1055
|
-
const startQ = req.query.start;
|
|
1056
|
-
const endQ = req.query.end;
|
|
1057
|
-
|
|
1058
|
-
let start, end;
|
|
1059
|
-
if (startQ && endQ) {
|
|
1060
|
-
const r = clampRange(String(startQ), String(endQ), tz);
|
|
1061
|
-
start = r.start;
|
|
1062
|
-
end = r.end;
|
|
1063
|
-
} else {
|
|
1064
|
-
// Default to current month range
|
|
1065
|
-
start = now.startOf('month');
|
|
1066
|
-
end = now.endOf('month').plus({ days: 1 });
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// Load reservations for ALL items within range (so we can build availability client-side without extra requests)
|
|
1070
|
-
const allReservations = [];
|
|
1071
|
-
for (const it of items) {
|
|
1072
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1073
|
-
const resForItem = await listReservationsForRange(it.id, start.toMillis(), end.toMillis());
|
|
1074
|
-
for (const r of resForItem) allReservations.push(r);
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
const showRequesterToAll = String(settings.showRequesterToAll) === '1';
|
|
1078
|
-
const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
|
|
1079
|
-
|
|
1080
|
-
const requesterUids = Array.from(new Set(allReservations.map(r => r.uid))).filter(Boolean);
|
|
1081
|
-
const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
|
|
1082
|
-
const nameByUid = {};
|
|
1083
|
-
(users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
|
|
1084
|
-
|
|
1085
|
-
const itemById = {};
|
|
1086
|
-
items.forEach(it => { itemById[it.id] = it; });
|
|
1087
|
-
|
|
1088
|
-
const events = allReservations
|
|
1089
|
-
.filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
|
|
1090
|
-
.map(r => {
|
|
1091
|
-
const item = itemById[r.itemId];
|
|
1092
|
-
// Include item name in title so "all items" view is readable
|
|
1093
|
-
const requesterName = nameByUid[r.uid] || '';
|
|
1094
|
-
return toEvent(r, item, requesterName, isApprover || showRequesterToAll);
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
|
|
1098
|
-
|
|
1099
|
-
// We expose minimal reservation data for availability checks in the modal
|
|
1100
|
-
const blocks = allReservations
|
|
1101
|
-
.filter(r => statusBlocksItem(r.status))
|
|
1102
|
-
.map(r => ({
|
|
1103
|
-
itemId: r.itemId,
|
|
1104
|
-
startMs: r.startMs,
|
|
1105
|
-
endMs: r.endMs,
|
|
1106
|
-
status: r.status,
|
|
1107
|
-
}));
|
|
1108
|
-
|
|
1109
|
-
res.render('equipment-calendar/calendar', {
|
|
1110
|
-
title: 'Réservation de matériel',
|
|
1111
|
-
items,
|
|
1112
|
-
view,
|
|
1113
|
-
tz,
|
|
1114
|
-
startISO: start.toISO(),
|
|
1115
|
-
endISO: end.toISO(),
|
|
1116
|
-
initialDateISO: start.toISODate(),
|
|
1117
|
-
canCreate: canUserCreate,
|
|
1118
|
-
canCreateJs: canUserCreate ? 'true' : 'false',
|
|
1119
|
-
isApprover,
|
|
1120
|
-
// events are base64 encoded to avoid template escaping issues
|
|
1121
|
-
eventsB64: Buffer.from(JSON.stringify(events), 'utf8').toString('base64'),
|
|
1122
|
-
blocksB64: Buffer.from(JSON.stringify(blocks), 'utf8').toString('base64'),
|
|
1123
|
-
itemsB64: Buffer.from(JSON.stringify(items.map(i => ({ id: i.id, name: i.name, location: i.location }))), 'utf8').toString('base64'),
|
|
1124
|
-
});
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// --- Approvals page ---
|
|
1128
|
-
|
|
1129
|
-
async function notifyApprovers(reservations, settings) {
|
|
1130
|
-
const groupName = (settings.notifyGroup || settings.approverGroup || 'administrators').trim();
|
|
1131
|
-
if (!groupName) return;
|
|
1132
|
-
|
|
1133
|
-
const rid = reservations[0] && (reservations[0].rid || reservations[0].id) || '';
|
|
1134
|
-
const path = '/equipment/approvals';
|
|
1135
|
-
|
|
1136
|
-
// In-app notification (NodeBB notifications)
|
|
1137
|
-
try {
|
|
1138
|
-
const Notifications = require.main.require('./src/notifications');
|
|
1139
|
-
const notif = await Notifications.create({
|
|
1140
|
-
bodyShort: 'Nouvelle demande de réservation',
|
|
1141
|
-
bodyLong: 'Une nouvelle demande de réservation est en attente de validation.',
|
|
1142
|
-
nid: 'equipment-calendar:' + rid,
|
|
1143
|
-
path,
|
|
1144
|
-
});
|
|
1145
|
-
if (Notifications.pushGroup) {
|
|
1146
|
-
await Notifications.pushGroup(notif, groupName);
|
|
1147
|
-
}
|
|
1148
|
-
} catch (e) {
|
|
1149
|
-
// ignore
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
// Direct email to members of notifyGroup (uses NodeBB Emailer + your SMTP/emailer plugin)
|
|
1153
|
-
try {
|
|
1154
|
-
const Emailer = require.main.require('./src/emailer');
|
|
1155
|
-
const uids = await groups.getMembers(groupName, 0, -1);
|
|
1156
|
-
if (!uids || !uids.length) return;
|
|
1157
|
-
|
|
1158
|
-
// Build a short summary
|
|
1159
|
-
const items = await getActiveItems(await getSettings());
|
|
1160
|
-
const nameById = {};
|
|
1161
|
-
items.forEach(i => { nameById[i.id] = i.name; });
|
|
1162
|
-
|
|
1163
|
-
const start = new Date(reservations[0].startMs).toISOString();
|
|
1164
|
-
const end = new Date(reservations[0].endMs).toISOString();
|
|
1165
|
-
const itemList = reservations.map(r => nameById[r.itemId] || r.itemId).join(', ');
|
|
1166
|
-
|
|
1167
|
-
// Use a core template ('notification') that exists in NodeBB installs
|
|
1168
|
-
// Params vary a bit by version; we provide common fields.
|
|
1169
|
-
const params = {
|
|
1170
|
-
subject: '[Réservation] Nouvelle demande en attente',
|
|
1171
|
-
intro: 'Une nouvelle demande de réservation a été créée.',
|
|
1172
|
-
body: `Matériel: ${itemList}\nDébut: ${start}\nFin: ${end}\nVoir: ${path}`,
|
|
1173
|
-
notification_url: path,
|
|
1174
|
-
url: path,
|
|
1175
|
-
// Some templates use "site_title" etc but NodeBB will inject globals
|
|
1176
|
-
};
|
|
1177
|
-
|
|
1178
|
-
for (const uid of uids) {
|
|
1179
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1180
|
-
const email = await user.getUserField(uid, 'email');
|
|
1181
|
-
if (!email) continue;
|
|
1182
|
-
|
|
1183
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1184
|
-
const lang = (await user.getUserField(uid, 'language')) || 'fr';
|
|
1185
|
-
if (Emailer.sendToEmail) {
|
|
1186
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1187
|
-
await Emailer.sendToEmail('notification', email, lang, params);
|
|
1188
|
-
} else if (Emailer.send) {
|
|
1189
|
-
// Some NodeBB versions use Emailer.send(template, uid/email, params)
|
|
1190
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1191
|
-
await Emailer.send('notification', uid, params);
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
} catch (e) {
|
|
1195
|
-
// ignore email errors to not block reservation flow
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
async function renderApprovalsPage(req, res) {
|
|
1200
|
-
const settings = await getSettings();
|
|
1201
|
-
const ok = req.uid ? await canApprove(req.uid, settings) : false;
|
|
1202
|
-
if (!ok) {
|
|
1203
|
-
return helpers.notAllowed(req, res);
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
const items = parseItems(settings.itemsJson);
|
|
1207
|
-
// naive scan: for a real install, store a global sorted set too; for now list per item and merge
|
|
1208
|
-
// We'll pull recent 200 per item and then filter
|
|
1209
|
-
const pending = [];
|
|
1210
|
-
for (const it of items) {
|
|
1211
|
-
const ids = await db.getSortedSetRevRange(itemIndexKey(it.id), 0, 199);
|
|
1212
|
-
if (!ids || !ids.length) continue;
|
|
1213
|
-
const objs = await db.getObjects(ids.map(resKey));
|
|
1214
|
-
const resArr = (objs || []).filter(Boolean).map(normalizeReservation).filter(r => r.status === 'pending' || r.status === 'approved_waiting_payment');
|
|
1215
|
-
for (const r of resArr) pending.push(r);
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
// Sort by createdAt desc
|
|
1219
|
-
pending.sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
|
|
1220
|
-
|
|
1221
|
-
const uids = Array.from(new Set(pending.map(r => r.uid))).filter(Boolean);
|
|
1222
|
-
const users = uids.length ? await user.getUsersData(uids) : [];
|
|
1223
|
-
const nameByUid = {};
|
|
1224
|
-
(users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
|
|
1225
|
-
|
|
1226
|
-
const rows = pending.map(r => {
|
|
1227
|
-
const item = items.find(i => i.id === r.itemId);
|
|
1228
|
-
return {
|
|
1229
|
-
id: r.id,
|
|
1230
|
-
itemName: item ? item.name : r.itemId,
|
|
1231
|
-
requester: nameByUid[r.uid] || `uid:${r.uid}`,
|
|
1232
|
-
start: r.startIso,
|
|
1233
|
-
end: addDaysIso(r.endIso, 1),
|
|
1234
|
-
status: r.status,
|
|
1235
|
-
paymentUrl: r.ha_paymentUrl || '',
|
|
1236
|
-
};
|
|
1237
|
-
});
|
|
1238
|
-
|
|
1239
|
-
res.render('equipment-calendar/approvals', {
|
|
1240
|
-
title: 'Validation des réservations',
|
|
1241
|
-
rows,
|
|
1242
|
-
hasRows: Array.isArray(rows) && rows.length > 0,
|
|
1243
|
-
});
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
// --- Actions ---
|
|
1247
|
-
|
|
1248
|
-
async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
|
|
1249
|
-
const items = await getActiveItems(settings);
|
|
1250
|
-
const item = items.find(i => i.id === itemId);
|
|
1251
|
-
if (!item) {
|
|
1252
|
-
throw new Error('Unknown item: ' + itemId);
|
|
1253
|
-
}
|
|
1254
|
-
// Reject if overlaps any blocking reservation
|
|
1255
|
-
const overlapsBlocking = await hasOverlap(itemId, startMs, endMs);
|
|
1256
|
-
if (overlapsBlocking) {
|
|
1257
|
-
const err = new Error('Item not available: ' + itemId);
|
|
1258
|
-
err.code = 'ITEM_UNAVAILABLE';
|
|
1259
|
-
throw err;
|
|
1260
|
-
}
|
|
1261
|
-
const rid = generateId();
|
|
1262
|
-
const data = {
|
|
1263
|
-
id: rid,
|
|
1264
|
-
rid,
|
|
1265
|
-
itemId,
|
|
1266
|
-
uid: req.uid,
|
|
1267
|
-
startMs,
|
|
1268
|
-
endMs,
|
|
1269
|
-
status: 'pending',
|
|
1270
|
-
notesUser: notesUser || '',
|
|
1271
|
-
bookingId: bookingId || '',
|
|
1272
|
-
createdAt: formatDateTimeFR(r.createdAt || 0),
|
|
1273
|
-
};
|
|
1274
|
-
await saveReservation(data);
|
|
1275
|
-
if (bookingId) {
|
|
1276
|
-
await addReservationToBooking(bookingId, rid);
|
|
1277
|
-
}
|
|
1278
|
-
return data;
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
async function handleCreateReservation(req, res) {
|
|
1282
|
-
try {
|
|
1283
|
-
const settings = await getSettings();
|
|
1284
|
-
if (!req.uid || !(await canCreate(req.uid, settings))) {
|
|
1285
|
-
return helpers.notAllowed(req, res);
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
const itemIdsRaw = (req.body.itemIds !== undefined ? req.body.itemIds : req.body.itemId);
|
|
1289
|
-
const itemIds = normalizeItemIds(itemIdsRaw);
|
|
1290
|
-
if (!itemIds.length) return res.status(400).send('itemIds required');
|
|
1291
|
-
|
|
1292
|
-
// Dates: accept YYYY-MM-DD or ISO-with-time; take first 10 chars
|
|
1293
|
-
let _startIso = String(req.body.start || req.body.startDate || req.body.startIso || req.body.startStr || '').trim().slice(0, 10);
|
|
1294
|
-
let _endIso = String(req.body.end || req.body.endDate || req.body.endIso || req.body.endStr || '').trim().slice(0, 10);
|
|
1295
|
-
|
|
1296
|
-
// Fallback: timestamps
|
|
1297
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(_startIso)) {
|
|
1298
|
-
const ms = Number(req.body.startMs || req.body.startTime || 0);
|
|
1299
|
-
if (ms) _startIso = new Date(ms).toISOString().slice(0, 10);
|
|
1300
|
-
}
|
|
1301
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(_endIso)) {
|
|
1302
|
-
const ms = Number(req.body.endMs || req.body.endTime || 0);
|
|
1303
|
-
if (ms) _endIso = new Date(ms).toISOString().slice(0, 10);
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(_startIso) || !/^\d{4}-\d{2}-\d{2}$/.test(_endIso)) {
|
|
1307
|
-
return res.status(400).send('dates required');
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
const startMs = Date.parse(_startIso + 'T00:00:00Z');
|
|
1311
|
-
const endMs = Date.parse(addDaysIso(_endIso, 1) + 'T00:00:00Z'); // exclusive end
|
|
1312
|
-
if (!startMs || !endMs || endMs <= startMs) {
|
|
1313
|
-
return res.status(400).send('dates required');
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
const days = Math.max(1, Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)));
|
|
1317
|
-
|
|
1318
|
-
const items = await getActiveItems(settings);
|
|
1319
|
-
const byId = {};
|
|
1320
|
-
items.forEach((it) => { byId[String(it.id)] = it; });
|
|
1321
|
-
|
|
1322
|
-
const validIds = itemIds.map(String).filter((id) => byId[id]);
|
|
1323
|
-
if (!validIds.length) return res.status(400).send('unknown item');
|
|
1324
|
-
|
|
1325
|
-
const notesUser = String(req.body.notesUser || '').trim();
|
|
1326
|
-
|
|
1327
|
-
for (const itemId of validIds) {
|
|
1328
|
-
const it = byId[itemId];
|
|
1329
|
-
const unitPrice = Number(it.price || 0) || 0;
|
|
1330
|
-
const total = unitPrice * days;
|
|
1331
|
-
|
|
1332
|
-
const rid = generateId();
|
|
1333
|
-
const r = {
|
|
1334
|
-
rid,
|
|
1335
|
-
uid: req.uid,
|
|
1336
|
-
itemId,
|
|
1337
|
-
startMs,
|
|
1338
|
-
endMs,
|
|
1339
|
-
startIso: _startIso,
|
|
1340
|
-
endIso: _endIso,
|
|
1341
|
-
days,
|
|
1342
|
-
unitPrice,
|
|
1343
|
-
total,
|
|
1344
|
-
notesUser,
|
|
1345
|
-
status: 'pending',
|
|
1346
|
-
createdAt: Date.now(),
|
|
1347
|
-
};
|
|
1348
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1349
|
-
await saveReservation(r);
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
return res.redirect(nconf.get('relative_path') + '/calendar?created=1');
|
|
1353
|
-
} catch (err) {
|
|
1354
|
-
winston.error('[equipment-calendar] create error: ' + (err && err.message ? err.message : ''), err);
|
|
1355
|
-
return res.status(500).send('error');
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
async function handleApproveReservation(req, res) {
|
|
1360
|
-
try {
|
|
1361
|
-
const settings = await getSettings();
|
|
1362
|
-
if (!req.uid || !(await canApprove(req.uid, settings))) {
|
|
1363
|
-
return helpers.notAllowed(req, res);
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
const id = String(req.params.id || '').trim();
|
|
1367
|
-
const reservation = await getReservation(id);
|
|
1368
|
-
if (!reservation) return res.status(404).send('Not found');
|
|
1369
|
-
if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
|
|
1370
|
-
return res.status(409).send('Invalid status');
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
const items = parseItems(settings.itemsJson);
|
|
1374
|
-
const item = items.find(i => i.id === reservation.itemId);
|
|
1375
|
-
if (!item) return res.status(400).send('Invalid item');
|
|
1376
|
-
|
|
1377
|
-
// Create HelloAsso checkout
|
|
1378
|
-
if (!reservation.ha_paymentUrl) {
|
|
1379
|
-
if (!settings.ha_clientId || !settings.ha_clientSecret) {
|
|
1380
|
-
return res.status(400).send('HelloAsso not configured');
|
|
1381
|
-
}
|
|
1382
|
-
const token = await helloAssoGetAccessToken(settings);
|
|
1383
|
-
const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
|
|
1384
|
-
reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
|
|
1385
|
-
reservation.ha_paymentUrl = checkout.paymentUrl;
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
reservation.status = 'approved_waiting_payment';
|
|
1389
|
-
reservation.validatorUid = req.uid;
|
|
1390
|
-
reservation.updatedAtMs = Date.now();
|
|
1391
|
-
await saveReservation(reservation);
|
|
1392
|
-
|
|
1393
|
-
// Notify requester with payment link (best-effort)
|
|
1394
|
-
try {
|
|
1395
|
-
const u = await user.getUserData(reservation.uid);
|
|
1396
|
-
const subject = 'Réservation approuvée - Paiement requis';
|
|
1397
|
-
const body = `Ta réservation de "${item.name}" a été approuvée.\n\nLien de paiement:\n${reservation.ha_paymentUrl}\n`;
|
|
1398
|
-
// NodeBB Emailer signature may vary; this is best-effort
|
|
1399
|
-
await Emailer.send('equipmentCalendar-payment', {
|
|
1400
|
-
subject,
|
|
1401
|
-
body,
|
|
1402
|
-
to: u.email,
|
|
1403
|
-
});
|
|
1404
|
-
} catch (e) { /* ignore */ }
|
|
1405
|
-
|
|
1406
|
-
return res.redirect('/equipment/approvals');
|
|
1407
|
-
} catch (e) {
|
|
1408
|
-
return res.status(500).send(e.message || 'error');
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
async function handleRejectReservation(req, res) {
|
|
1413
|
-
try {
|
|
1414
|
-
const settings = await getSettings();
|
|
1415
|
-
if (!req.uid || !(await canApprove(req.uid, settings))) {
|
|
1416
|
-
return helpers.notAllowed(req, res);
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
const id = String(req.params.id || '').trim();
|
|
1420
|
-
const reservation = await getReservation(id);
|
|
1421
|
-
if (!reservation) return res.status(404).send('Not found');
|
|
1422
|
-
if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
|
|
1423
|
-
return res.status(409).send('Invalid status');
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
reservation.status = 'rejected';
|
|
1427
|
-
reservation.validatorUid = req.uid;
|
|
1428
|
-
reservation.notesAdmin = String(req.body.notesAdmin || '').slice(0, 2000);
|
|
1429
|
-
reservation.updatedAtMs = Date.now();
|
|
1430
|
-
await saveReservation(reservation);
|
|
1431
|
-
|
|
1432
|
-
return res.redirect('/equipment/approvals');
|
|
1433
|
-
} catch (e) {
|
|
1434
|
-
return res.status(500).send(e.message || 'error');
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
async function ensureIsAdmin(req, res) {
|
|
1439
|
-
const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
|
|
1440
|
-
if (!isAdmin) {
|
|
1441
|
-
helpers.notAllowed(req, res);
|
|
1442
|
-
return false;
|
|
1443
|
-
}
|
|
1444
|
-
return true;
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
async function deleteReservation(rid) {
|
|
1448
|
-
const key = `equipmentCalendar:reservation:${rid}`;
|
|
1449
|
-
const data = await db.getObject(key);
|
|
1450
|
-
if (data && data.itemId) {
|
|
1451
|
-
await db.sortedSetRemove(`equipmentCalendar:item:${data.itemId}:reservations`, rid);
|
|
1452
|
-
}
|
|
1453
|
-
await db.delete(key);
|
|
1454
|
-
await db.sortedSetRemove('equipmentCalendar:reservations', rid);
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
async function handleAdminPurge(req, res) {
|
|
1458
|
-
try {
|
|
1459
|
-
const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
|
|
1460
|
-
if (!isAdmin) {
|
|
1461
|
-
return helpers.notAllowed(req, res);
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
const mode = String(req.body.mode || 'nonblocking'); // nonblocking|olderThan|all
|
|
1465
|
-
const olderThanDays = parseInt(req.body.olderThanDays, 10);
|
|
1466
|
-
|
|
1467
|
-
// Load all reservation ids
|
|
1468
|
-
const rids = await db.getSortedSetRange('equipmentCalendar:reservations', 0, -1);
|
|
1469
|
-
let deleted = 0;
|
|
1470
|
-
|
|
1471
|
-
const now = Date.now();
|
|
1472
|
-
for (const rid of rids) {
|
|
1473
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1474
|
-
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
1475
|
-
if (!r || !r.rid) {
|
|
1476
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1477
|
-
await db.sortedSetRemove('equipmentCalendar:reservations', rid);
|
|
1478
|
-
continue;
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
const status = String(r.status || '');
|
|
1482
|
-
const createdAt = parseInt(r.createdAt, 10) || 0;
|
|
1483
|
-
const startMs = parseInt(r.startMs, 10) || 0;
|
|
1484
|
-
const endMs = parseInt(r.endMs, 10) || 0;
|
|
1485
|
-
|
|
1486
|
-
let shouldDelete = false;
|
|
1487
|
-
|
|
1488
|
-
if (mode === 'all') {
|
|
1489
|
-
shouldDelete = true;
|
|
1490
|
-
} else if (mode === 'olderThan') {
|
|
1491
|
-
const days = Number.isFinite(olderThanDays) ? olderThanDays : 0;
|
|
1492
|
-
if (days > 0) {
|
|
1493
|
-
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
|
1494
|
-
// use createdAt if present, otherwise startMs
|
|
1495
|
-
const t = createdAt || startMs || endMs;
|
|
1496
|
-
shouldDelete = t > 0 && t < cutoff;
|
|
1497
|
-
}
|
|
1498
|
-
} else {
|
|
1499
|
-
// nonblocking cleanup: delete rejected/cancelled only
|
|
1500
|
-
shouldDelete = (status === 'rejected' || status === 'cancelled');
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
if (shouldDelete) {
|
|
1504
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1505
|
-
await deleteReservation(rid);
|
|
1506
|
-
deleted++;
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
return res.redirect(`/admin/plugins/equipment-calendar?saved=1&purged=${deleted}`);
|
|
1511
|
-
} catch (e) {
|
|
1512
|
-
return res.status(500).send(e.message || 'error');
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
let paymentTimeoutInterval = null;
|
|
1517
|
-
|
|
1518
|
-
function startPaymentTimeoutScheduler() {
|
|
1519
|
-
if (paymentTimeoutInterval) return;
|
|
1520
|
-
// Run every minute
|
|
1521
|
-
paymentTimeoutInterval = setInterval(async () => {
|
|
1522
|
-
try {
|
|
1523
|
-
const settings = await getSettings();
|
|
1524
|
-
const timeoutMin = Math.max(1, parseInt(settings.paymentTimeoutMinutes, 10) || 10);
|
|
1525
|
-
const cutoff = Date.now() - timeoutMin * 60 * 1000;
|
|
1526
|
-
|
|
1527
|
-
// Scan bookings keys (we keep an index set)
|
|
1528
|
-
const bookingIds = await db.getSetMembers('equipmentCalendar:bookings') || [];
|
|
1529
|
-
for (const bid of bookingIds) {
|
|
1530
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1531
|
-
const b = await getBooking(bid);
|
|
1532
|
-
if (!b || !b.bookingId) continue;
|
|
1533
|
-
const status = String(b.status || '');
|
|
1534
|
-
if (status !== 'payment_pending') continue;
|
|
1535
|
-
|
|
1536
|
-
const pendingAt = parseInt(b.paymentPendingAt, 10) || parseInt(b.createdAt, 10) || 0;
|
|
1537
|
-
if (!pendingAt || pendingAt > cutoff) continue;
|
|
1538
|
-
|
|
1539
|
-
// Timeout reached -> cancel booking + all reservations
|
|
1540
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1541
|
-
const rids = await getBookingRids(bid);
|
|
1542
|
-
for (const rid of rids) {
|
|
1543
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1544
|
-
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
1545
|
-
if (!r || !r.rid) continue;
|
|
1546
|
-
if (String(r.status) === 'paid') continue; // safety
|
|
1547
|
-
r.status = 'cancelled';
|
|
1548
|
-
r.cancelledAt = Date.now();
|
|
1549
|
-
r.paymentUrl = '';
|
|
1550
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1551
|
-
await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
b.status = 'cancelled';
|
|
1555
|
-
b.cancelledAt = Date.now();
|
|
1556
|
-
b.paymentUrl = '';
|
|
1557
|
-
await saveBooking(b);
|
|
1558
|
-
}
|
|
1559
|
-
} catch (e) {
|
|
1560
|
-
// ignore to keep scheduler alive
|
|
1561
|
-
}
|
|
1562
|
-
}, 60 * 1000);
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
120
|
module.exports = plugin;
|
|
1566
|
-
|
|
1567
|
-
async function handleGetReservation(req, res) {
|
|
1568
|
-
if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
|
|
1569
|
-
const rid = String(req.params.id || '');
|
|
1570
|
-
const r = await db.getObject(resKey(rid));
|
|
1571
|
-
if (!r || !r.id) return res.status(404).json({ error: 'not-found' });
|
|
1572
|
-
|
|
1573
|
-
const settings = await getSettings();
|
|
1574
|
-
const isAdmin = await user.isAdminOrGlobalMod(req.uid);
|
|
1575
|
-
const isOwner = String(r.uid) === String(req.uid);
|
|
1576
|
-
const canSee = isAdmin || isOwner || String(settings.showRequesterToAll || '0') === '1';
|
|
1577
|
-
if (!canSee) return res.status(403).json({ error: 'forbidden' });
|
|
1578
|
-
|
|
1579
|
-
res.json({
|
|
1580
|
-
id: r.id,
|
|
1581
|
-
bookingId: r.bookingId || '',
|
|
1582
|
-
itemId: r.itemId,
|
|
1583
|
-
itemName: r.itemName || '',
|
|
1584
|
-
startMs: parseInt(r.startMs, 10) || 0,
|
|
1585
|
-
endMs: parseInt(r.endMs, 10) || 0,
|
|
1586
|
-
days: parseInt(r.days, 10) || 1,
|
|
1587
|
-
status: r.status || 'pending',
|
|
1588
|
-
total: Number(r.total || 0) || 0,
|
|
1589
|
-
unitPrice: Number(r.unitPrice || 0) || 0,
|
|
1590
|
-
notesUser: r.notesUser || '',
|
|
1591
|
-
uid: r.uid,
|
|
1592
|
-
});
|
|
1593
|
-
}
|