nodebb-plugin-equipment-calendar 9.0.14 → 9.1.3
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 +95 -1533
- package/package.json +13 -12
- package/plugin.json +10 -16
- package/public/js/acp.js +40 -0
- package/public/js/client.js +76 -206
- package/scripts/postinstall.js +37 -0
- package/templates/admin/plugins/equipment-calendar.tpl +32 -21
- package/templates/equipment-calendar/calendar.tpl +28 -63
package/library.js
CHANGED
|
@@ -1,791 +1,147 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
let fetchFn = global.fetch;
|
|
5
|
-
try { if (!fetchFn) { fetchFn = require('undici').fetch; } } catch (e) {}
|
|
6
|
-
|
|
7
|
-
const db = require.main.require('./src/database');
|
|
3
|
+
const nconf = require.main.require('nconf');
|
|
8
4
|
const meta = require.main.require('./src/meta');
|
|
9
|
-
const
|
|
5
|
+
const db = require.main.require('./src/database');
|
|
10
6
|
const user = require.main.require('./src/user');
|
|
11
|
-
const
|
|
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
|
-
|
|
7
|
+
const groups = require.main.require('./src/groups');
|
|
18
8
|
const winston = require.main.require('winston');
|
|
19
9
|
|
|
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
10
|
const plugin = {};
|
|
128
11
|
const SETTINGS_KEY = 'equipmentCalendar';
|
|
129
12
|
|
|
130
13
|
const DEFAULT_SETTINGS = {
|
|
131
|
-
creatorGroups: '
|
|
14
|
+
creatorGroups: '',
|
|
132
15
|
approverGroup: 'administrators',
|
|
133
16
|
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
|
|
17
|
+
ha_apiBaseUrl: 'https://api.helloasso.com',
|
|
18
|
+
ha_organizationSlug: '',
|
|
143
19
|
ha_clientId: '',
|
|
144
20
|
ha_clientSecret: '',
|
|
145
|
-
|
|
21
|
+
ha_itemsFormType: 'shop',
|
|
22
|
+
ha_itemsFormSlug: '',
|
|
146
23
|
ha_returnUrl: '',
|
|
147
|
-
|
|
148
|
-
// calendar
|
|
24
|
+
paymentTimeoutMinutes: 10,
|
|
149
25
|
defaultView: 'dayGridMonth',
|
|
150
|
-
timezone: 'Europe/Paris',
|
|
151
|
-
// privacy
|
|
152
|
-
showRequesterToAll: '0', // 0/1
|
|
153
26
|
};
|
|
154
27
|
|
|
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
28
|
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();
|
|
29
|
+
const raw = await meta.settings.get(SETTINGS_KEY);
|
|
30
|
+
const s = Object.assign({}, DEFAULT_SETTINGS, raw || {});
|
|
31
|
+
s.paymentTimeoutMinutes = parseInt(s.paymentTimeoutMinutes, 10) || DEFAULT_SETTINGS.paymentTimeoutMinutes;
|
|
32
|
+
return s;
|
|
269
33
|
}
|
|
270
34
|
|
|
271
|
-
async function
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
throw new Error(`HelloAsso checkout-intents GET error: ${resp.status} ${t}`);
|
|
279
|
-
}
|
|
280
|
-
return await resp.json();
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function isCheckoutPaid(checkout) {
|
|
284
|
-
const payments = (checkout && (checkout.payments || (checkout.order && checkout.order.payments))) || [];
|
|
285
|
-
const list = Array.isArray(payments) ? payments : [];
|
|
286
|
-
const states = list.map(p => String(p.state || p.status || p.paymentState || '').toLowerCase());
|
|
287
|
-
if (states.some(s => ['authorized', 'paid', 'succeeded', 'success'].includes(s))) return true;
|
|
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;
|
|
35
|
+
async function isUserInAnyGroups(uid, groupCsv) {
|
|
36
|
+
if (!uid || !groupCsv) return false;
|
|
37
|
+
const names = String(groupCsv).split(',').map(s => s.trim()).filter(Boolean);
|
|
38
|
+
if (!names.length) return false;
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
const inGroup = await groups.isMember(uid, name);
|
|
41
|
+
if (inGroup) return true;
|
|
294
42
|
}
|
|
295
43
|
return false;
|
|
296
44
|
}
|
|
297
45
|
|
|
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),
|
|
46
|
+
function rid() {
|
|
47
|
+
// UUID v4 (no dependency)
|
|
48
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
49
|
+
const r = Math.random() * 16 | 0;
|
|
50
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
51
|
+
return v.toString(16);
|
|
366
52
|
});
|
|
367
|
-
|
|
368
|
-
return out;
|
|
369
53
|
}
|
|
370
54
|
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
}
|
|
382
|
-
|
|
383
|
-
// --- Data layer ---
|
|
384
|
-
// Keys:
|
|
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
|
-
}
|
|
55
|
+
function reservationKey(rid) { return `equipmentCalendar:reservation:${rid}`; }
|
|
56
|
+
const reservationsZset = 'equipmentCalendar:reservations';
|
|
415
57
|
|
|
416
58
|
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);
|
|
59
|
+
await db.setObject(reservationKey(r.rid), r);
|
|
60
|
+
await db.sortedSetAdd(reservationsZset, r.createdAt || Date.now(), r.rid);
|
|
423
61
|
}
|
|
424
62
|
|
|
425
|
-
async function
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
return
|
|
63
|
+
async function listReservations(limit=500) {
|
|
64
|
+
const rids = await db.getSortedSetRevRange(reservationsZset, 0, limit - 1);
|
|
65
|
+
const objs = await Promise.all(rids.map(id => db.getObject(reservationKey(id))));
|
|
66
|
+
return objs.filter(Boolean);
|
|
429
67
|
}
|
|
430
68
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
r.
|
|
435
|
-
|
|
436
|
-
|
|
69
|
+
function toEvent(r) {
|
|
70
|
+
// FullCalendar allDay uses exclusive end
|
|
71
|
+
const start = String(r.startIso).slice(0,10);
|
|
72
|
+
const end = new Date(String(r.endIso).slice(0,10) + 'T00:00:00Z');
|
|
73
|
+
end.setUTCDate(end.getUTCDate() + 1);
|
|
74
|
+
const endExcl = end.toISOString().slice(0,10);
|
|
437
75
|
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
if (!r) return;
|
|
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));
|
|
447
|
-
}
|
|
76
|
+
const status = r.status || 'pending';
|
|
77
|
+
const title = status === 'paid' ? '[PAID] Reservation' : (status === 'approved' ? '[APPROVED] Reservation' : '[PENDING] Reservation');
|
|
448
78
|
|
|
449
|
-
function normalizeReservation(obj) {
|
|
450
|
-
const startMs = Number(obj.startMs);
|
|
451
|
-
const endMs = Number(obj.endMs);
|
|
452
79
|
return {
|
|
453
|
-
id:
|
|
454
|
-
|
|
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
|
-
}
|
|
470
|
-
|
|
471
|
-
async function listReservationsForRange(itemId, startMs, endMs) {
|
|
472
|
-
// fetch candidates by score range with some padding
|
|
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;
|
|
479
|
-
});
|
|
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;
|
|
519
|
-
|
|
520
|
-
// NodeBB notifications
|
|
521
|
-
try {
|
|
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,
|
|
581
|
-
});
|
|
582
|
-
return resp.data.access_token;
|
|
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));
|
|
593
|
-
|
|
594
|
-
const payload = {
|
|
595
|
-
totalAmount: amountCents,
|
|
596
|
-
initialAmount: amountCents,
|
|
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,
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
// Typical response fields (may vary): id, redirectUrl
|
|
613
|
-
return {
|
|
614
|
-
checkoutIntentId: resp.data.id || resp.data.checkoutIntentId || '',
|
|
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(' '),
|
|
80
|
+
id: r.rid,
|
|
81
|
+
title,
|
|
646
82
|
start,
|
|
647
|
-
end,
|
|
83
|
+
end: endExcl,
|
|
648
84
|
allDay: true,
|
|
649
|
-
className,
|
|
650
|
-
extendedProps: {
|
|
651
|
-
status: res.status,
|
|
652
|
-
itemId: res.itemId,
|
|
653
|
-
},
|
|
654
85
|
};
|
|
655
86
|
}
|
|
656
87
|
|
|
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
88
|
plugin.init = async function (params) {
|
|
669
|
-
|
|
670
|
-
const mid = params.middleware;
|
|
89
|
+
const { router, middleware } = params;
|
|
671
90
|
|
|
672
|
-
// Admin
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
await meta.settings.set('equipmentCalendar', out);
|
|
676
|
-
return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar?saved=1');
|
|
677
|
-
} catch (e) {
|
|
678
|
-
winston.error('[equipment-calendar] admin save error', e);
|
|
679
|
-
return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar?error=1');
|
|
680
|
-
}
|
|
91
|
+
// Admin page
|
|
92
|
+
router.get('/admin/plugins/equipment-calendar', middleware.admin.buildHeader, async (req, res) => {
|
|
93
|
+
res.render('admin/plugins/equipment-calendar', {});
|
|
681
94
|
});
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
|
|
685
|
-
router.get('/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, mid.admin.buildHeader, handleHelloAssoTest);
|
|
686
|
-
router.get('/api/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, handleHelloAssoTest);
|
|
687
|
-
router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
|
|
688
|
-
router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
|
|
689
|
-
router.post('/admin/plugins/equipment-calendar/purge', middleware.applyCSRF, handleAdminPurge);
|
|
690
|
-
router.post('/admin/plugins/equipment-calendar/reservations/:rid/approve', middleware.applyCSRF, handleAdminApprove);
|
|
691
|
-
router.post('/admin/plugins/equipment-calendar/reservations/:rid/reject', middleware.applyCSRF, handleAdminReject);
|
|
692
|
-
router.post('/admin/plugins/equipment-calendar/reservations/:rid/delete', middleware.applyCSRF, handleAdminDelete);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// Convenience alias (optional): /calendar -> /equipment/calendar
|
|
696
|
-
router.get('/calendar', (req, res) => res.redirect('/equipment/calendar'));
|
|
697
|
-
|
|
698
|
-
// To verify webhook signature we need raw body; add a rawBody collector for this route only
|
|
699
|
-
router.post('/equipment/webhook/helloasso',
|
|
700
|
-
require.main.require('body-parser').text({ type: '*/*' }),
|
|
701
|
-
async (req, res) => {
|
|
702
|
-
req.rawBody = req.body || '';
|
|
703
|
-
let json;
|
|
704
|
-
try { json = JSON.parse(req.rawBody || '{}'); } catch (e) { json = {}; }
|
|
705
|
-
|
|
706
|
-
const settings = await getSettings();
|
|
707
|
-
if (!verifyWebhook(req, settings.ha_webhookSecret)) {
|
|
708
|
-
return res.status(401).json({ ok: false, error: 'invalid signature' });
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// Minimal mapping: expect metadata.reservationId and event indicating payment succeeded.
|
|
712
|
-
const reservationId = json?.metadata?.reservationId || json?.data?.metadata?.reservationId || '';
|
|
713
|
-
const paymentStatus = json?.eventType || json?.type || json?.status || 'unknown';
|
|
714
|
-
|
|
715
|
-
if (reservationId) {
|
|
716
|
-
const reservation = await getReservation(reservationId);
|
|
717
|
-
if (reservation) {
|
|
718
|
-
reservation.ha_paymentStatus = String(paymentStatus);
|
|
719
|
-
// Heuristic: if payload includes "OrderPaid" or "Payment" success
|
|
720
|
-
const isPaid = /paid|success|payment/i.test(String(paymentStatus)) || json?.data?.state === 'Paid';
|
|
721
|
-
if (isPaid) {
|
|
722
|
-
reservation.status = 'paid_validated';
|
|
723
|
-
reservation.ha_paidAtMs = Date.now();
|
|
724
|
-
}
|
|
725
|
-
reservation.updatedAtMs = Date.now();
|
|
726
|
-
await saveReservation(reservation);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
return res.json({ ok: true });
|
|
731
|
-
}
|
|
732
|
-
);
|
|
733
|
-
|
|
734
|
-
// Return endpoint (optional)
|
|
735
|
-
router.get('/equipment/payment/return', middleware.buildHeader, async (req, res) => {
|
|
736
|
-
res.render('equipment-calendar/payment-return', {
|
|
737
|
-
title: 'Paiement',
|
|
738
|
-
rid: req.query.rid || '',
|
|
739
|
-
status: req.query.status || 'ok',
|
|
740
|
-
statusError: String(req.query.status || '') === 'error',
|
|
741
|
-
});
|
|
95
|
+
router.get('/api/admin/plugins/equipment-calendar', async (req, res) => {
|
|
96
|
+
res.json({});
|
|
742
97
|
});
|
|
743
98
|
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const action = String(req.body.action || '').trim();
|
|
752
|
-
if (!rid || !action) return res.status(400).json({ error: 'missing' });
|
|
753
|
-
|
|
754
|
-
// Only approvers/admins can moderate
|
|
755
|
-
const allowed = await isApprover(req.uid, settings);
|
|
756
|
-
if (!allowed) return res.status(403).json({ error: 'forbidden' });
|
|
99
|
+
// Calendar page
|
|
100
|
+
router.get('/equipment/calendar', middleware.buildHeader, async (req, res) => {
|
|
101
|
+
res.render('equipment-calendar/calendar', {});
|
|
102
|
+
});
|
|
103
|
+
router.get('/api/equipment/calendar', async (req, res) => {
|
|
104
|
+
res.json({});
|
|
105
|
+
});
|
|
757
106
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
} else if (action === 'delete') {
|
|
763
|
-
await deleteReservation(rid);
|
|
764
|
-
} else {
|
|
765
|
-
return res.status(400).json({ error: 'bad_action' });
|
|
766
|
-
}
|
|
767
|
-
return res.json({ ok: true });
|
|
768
|
-
} catch (e) {
|
|
769
|
-
winston.error('[equipment-calendar] api action error', e);
|
|
770
|
-
return res.status(500).json({ error: 'server' });
|
|
771
|
-
}
|
|
107
|
+
// API: events
|
|
108
|
+
router.get('/api/plugins/equipment-calendar/events', async (req, res) => {
|
|
109
|
+
const list = await listReservations(1000);
|
|
110
|
+
res.json({ events: list.map(toEvent) });
|
|
772
111
|
});
|
|
773
112
|
|
|
774
|
-
|
|
775
|
-
router.
|
|
113
|
+
// API: create reservation
|
|
114
|
+
router.post('/api/plugins/equipment-calendar/reservations', middleware.applyCSRF, async (req, res) => {
|
|
115
|
+
const uid = req.uid;
|
|
116
|
+
if (!uid) return res.status(403).json({ message: 'forbidden' });
|
|
776
117
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
118
|
+
const settings = await getSettings();
|
|
119
|
+
const canCreate = await isUserInAnyGroups(uid, settings.creatorGroups) || await groups.isMember(uid, 'administrators');
|
|
120
|
+
if (!canCreate) return res.status(403).json({ message: 'forbidden' });
|
|
121
|
+
|
|
122
|
+
const startIso = String(req.body.startIso || '').slice(0,10);
|
|
123
|
+
const endIso = String(req.body.endIso || '').slice(0,10);
|
|
124
|
+
const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
|
|
125
|
+
if (!startIso || !endIso) return res.status(400).json({ message: 'dates required' });
|
|
126
|
+
if (!itemIds.length) return res.status(400).json({ message: 'items required' });
|
|
127
|
+
|
|
128
|
+
const r = {
|
|
129
|
+
rid: rid(),
|
|
130
|
+
uid,
|
|
131
|
+
startIso,
|
|
132
|
+
endIso,
|
|
133
|
+
itemIds: itemIds.map(String),
|
|
134
|
+
status: 'pending',
|
|
135
|
+
createdAt: Date.now(),
|
|
136
|
+
};
|
|
137
|
+
await saveReservation(r);
|
|
138
|
+
res.json({ ok: true, rid: r.rid });
|
|
139
|
+
});
|
|
781
140
|
|
|
782
|
-
|
|
783
|
-
// Ensure routes are treated as "page" routes by NodeBB router filter
|
|
784
|
-
// Not strictly necessary when using router.get with buildHeader, but kept for compatibility.
|
|
785
|
-
return data;
|
|
141
|
+
winston.info('[equipment-calendar] loaded (official4x skeleton)');
|
|
786
142
|
};
|
|
787
143
|
|
|
788
|
-
plugin.
|
|
144
|
+
plugin.addAdminMenu = async function (header) {
|
|
789
145
|
header.plugins.push({
|
|
790
146
|
route: '/plugins/equipment-calendar',
|
|
791
147
|
icon: 'fa-calendar',
|
|
@@ -794,805 +150,11 @@ plugin.addAdminNavigation = async function (header) {
|
|
|
794
150
|
return header;
|
|
795
151
|
};
|
|
796
152
|
|
|
797
|
-
//
|
|
798
|
-
plugin.
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
router.get('/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, mid.admin.buildHeader, handleHelloAssoTest);
|
|
803
|
-
router.get('/api/admin/plugins/equipment-calendar/helloasso-test', middleware.applyCSRF, handleHelloAssoTest);
|
|
804
|
-
router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
|
|
805
|
-
router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
|
|
153
|
+
// Optional: allow /calendar -> /equipment/calendar without hard redirect
|
|
154
|
+
plugin.addRouteAliases = async function (hookData) {
|
|
155
|
+
// hookData: { router, middleware, controllers } - but for filter:router.page we can inject page routes elsewhere;
|
|
156
|
+
// Keep empty to avoid breaking installs. Alias handled by theme route config if needed.
|
|
157
|
+
return hookData;
|
|
806
158
|
};
|
|
807
159
|
|
|
808
|
-
async function renderAdminReservationsPage(req, res) {
|
|
809
|
-
if (!(await ensureIsAdmin(req, res))) return;
|
|
810
|
-
|
|
811
|
-
const settings = await getSettings();
|
|
812
|
-
const items = await getActiveItems(settings);
|
|
813
|
-
const itemById = {};
|
|
814
|
-
items.forEach(it => { itemById[it.id] = it; });
|
|
815
|
-
|
|
816
|
-
const status = String(req.query.status || ''); // optional filter
|
|
817
|
-
const itemId = String(req.query.itemId || ''); // optional filter
|
|
818
|
-
const q = String(req.query.q || '').trim(); // search rid/user/notes
|
|
819
|
-
|
|
820
|
-
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
821
|
-
const perPage = Math.min(100, Math.max(10, parseInt(req.query.perPage, 10) || 50));
|
|
822
|
-
|
|
823
|
-
const allRids = await db.getSortedSetRevRange('equipmentCalendar:reservations', 0, -1);
|
|
824
|
-
const totalAll = allRids.length;
|
|
825
|
-
|
|
826
|
-
const rows = [];
|
|
827
|
-
for (const rid of allRids) {
|
|
828
|
-
// eslint-disable-next-line no-await-in-loop
|
|
829
|
-
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
830
|
-
if (!r || !r.rid) continue;
|
|
831
|
-
|
|
832
|
-
if (status && String(r.status) !== status) continue;
|
|
833
|
-
if (itemId && String(r.itemId) !== itemId) continue;
|
|
834
|
-
|
|
835
|
-
const notes = String(r.notesUser || '');
|
|
836
|
-
const ridStr = String(r.rid || rid);
|
|
837
|
-
if (q) {
|
|
838
|
-
const hay = (ridStr + ' ' + String(r.uid || '') + ' ' + notes).toLowerCase();
|
|
839
|
-
if (!hay.includes(q.toLowerCase())) continue;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
const startMs = parseInt(r.startMs, 10) || 0;
|
|
843
|
-
const endMs = parseInt(r.endMs, 10) || 0;
|
|
844
|
-
const createdAt = parseInt(r.createdAt, 10) || 0;
|
|
845
|
-
|
|
846
|
-
rows.push({
|
|
847
|
-
rid: ridStr,
|
|
848
|
-
itemId: String(r.itemId || ''),
|
|
849
|
-
itemName: (itemById[String(r.itemId || '')] && itemById[String(r.itemId || '')].name) || String(r.itemId || ''),
|
|
850
|
-
uid: String(r.uid || ''),
|
|
851
|
-
status: String(r.status || ''),
|
|
852
|
-
start: r.startIso,
|
|
853
|
-
end: addDaysIso(r.endIso, 1),
|
|
854
|
-
createdAt: formatDateTimeFR(r.createdAt || 0),
|
|
855
|
-
notesUser: notes,
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
const total = rows.length;
|
|
860
|
-
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
|
861
|
-
const safePage = Math.min(page, totalPages);
|
|
862
|
-
const startIndex = (safePage - 1) * perPage;
|
|
863
|
-
const pageRows = rows.slice(startIndex, startIndex + perPage);
|
|
864
|
-
|
|
865
|
-
const itemOptions = [{ id: '', name: 'Tous' }].concat(items.map(i => ({ id: i.id, name: i.name })));
|
|
866
|
-
const statusOptions = [
|
|
867
|
-
{ id: '', name: 'Tous' },
|
|
868
|
-
{ id: 'pending', name: 'pending' },
|
|
869
|
-
{ id: 'approved', name: 'approved' },
|
|
870
|
-
{ id: 'paid', name: 'paid' },
|
|
871
|
-
{ id: 'rejected', name: 'rejected' },
|
|
872
|
-
{ id: 'cancelled', name: 'cancelled' },
|
|
873
|
-
];
|
|
874
|
-
|
|
875
|
-
res.render('admin/plugins/equipment-calendar-reservations', {
|
|
876
|
-
title: 'Equipment Calendar - Réservations',
|
|
877
|
-
settings,
|
|
878
|
-
rows: pageRows,
|
|
879
|
-
hasRows: pageRows.length > 0,
|
|
880
|
-
itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
|
|
881
|
-
statusOptions: statusOptions.map(o => ({ ...o, selected: o.id === status })),
|
|
882
|
-
q,
|
|
883
|
-
page: safePage,
|
|
884
|
-
perPage,
|
|
885
|
-
total,
|
|
886
|
-
totalAll,
|
|
887
|
-
totalPages,
|
|
888
|
-
prevPage: safePage > 1 ? safePage - 1 : 0,
|
|
889
|
-
nextPage: safePage < totalPages ? safePage + 1 : 0,
|
|
890
|
-
actionBase: '/admin/plugins/equipment-calendar/reservations',
|
|
891
|
-
});
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
async function handleAdminApprove(req, res) {
|
|
895
|
-
if (!(await ensureIsAdmin(req, res))) return;
|
|
896
|
-
const rid = String(req.params.rid || '').trim();
|
|
897
|
-
if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
898
|
-
|
|
899
|
-
const key = `equipmentCalendar:reservation:${rid}`;
|
|
900
|
-
const r = await db.getObject(key);
|
|
901
|
-
if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
902
|
-
|
|
903
|
-
r.status = 'approved';
|
|
904
|
-
await db.setObject(key, r);
|
|
905
|
-
|
|
906
|
-
return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
async function handleAdminReject(req, res) {
|
|
910
|
-
if (!(await ensureIsAdmin(req, res))) return;
|
|
911
|
-
const rid = String(req.params.rid || '').trim();
|
|
912
|
-
if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
913
|
-
|
|
914
|
-
const key = `equipmentCalendar:reservation:${rid}`;
|
|
915
|
-
const r = await db.getObject(key);
|
|
916
|
-
if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
917
|
-
|
|
918
|
-
r.status = 'rejected';
|
|
919
|
-
await db.setObject(key, r);
|
|
920
|
-
|
|
921
|
-
return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
async function handleAdminDelete(req, res) {
|
|
925
|
-
if (!(await ensureIsAdmin(req, res))) return;
|
|
926
|
-
const rid = String(req.params.rid || '').trim();
|
|
927
|
-
if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
928
|
-
await deleteReservation(rid);
|
|
929
|
-
return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
async function handleHelloAssoTest(req, res) {
|
|
933
|
-
const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
|
|
934
|
-
if (!isAdmin) return helpers.notAllowed(req, res);
|
|
935
|
-
|
|
936
|
-
const settings = await getSettings();
|
|
937
|
-
let sampleItems = [];
|
|
938
|
-
let hasSampleItems = false;
|
|
939
|
-
let ok = false;
|
|
940
|
-
let message = '';
|
|
941
|
-
let count = 0;
|
|
942
|
-
|
|
943
|
-
try {
|
|
944
|
-
const force = String(req.query.force || '') === '1';
|
|
945
|
-
const clear = String(req.query.clear || '') === '1';
|
|
946
|
-
// force=1 skips in-memory cache and refresh_token; clear=1 wipes stored refresh token
|
|
947
|
-
haTokenCache = null;
|
|
948
|
-
await getHelloAssoAccessToken(settings, { force, clearStored: clear });
|
|
949
|
-
const items = await fetchHelloAssoItems(settings);
|
|
950
|
-
const list = Array.isArray(items) ? items : (Array.isArray(items.data) ? items.data : []);
|
|
951
|
-
count = list.length;
|
|
952
|
-
sampleItems = list.slice(0, 10).map(it => ({
|
|
953
|
-
id: String(it.id || it.itemId || it.reference || it.slug || it.name || '').trim(),
|
|
954
|
-
name: String(it.name || it.label || it.title || '').trim(),
|
|
955
|
-
rawName: String(it.name || it.label || it.title || it.id || '').trim(),
|
|
956
|
-
}));
|
|
957
|
-
hasSampleItems = sampleItems && sampleItems.length > 0;
|
|
958
|
-
ok = true;
|
|
959
|
-
message = `OK: token valide. Catalogue récupéré via /public : ${count} item(s).`;
|
|
960
|
-
} catch (e) {
|
|
961
|
-
ok = false;
|
|
962
|
-
message = (e && e.message) ? e.message : String(e);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
res.render('admin/plugins/equipment-calendar-helloasso-test', {
|
|
966
|
-
title: 'Equipment Calendar - Test HelloAsso',
|
|
967
|
-
ok,
|
|
968
|
-
message,
|
|
969
|
-
count,
|
|
970
|
-
settings,
|
|
971
|
-
sampleItems,
|
|
972
|
-
hasSampleItems,
|
|
973
|
-
hasSampleItems: sampleItems && sampleItems.length > 0,
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
async function renderAdminPage(req, res) {
|
|
978
|
-
const settings = await getSettings();
|
|
979
|
-
res.render('admin/plugins/equipment-calendar', {
|
|
980
|
-
title: 'Equipment Calendar',
|
|
981
|
-
settings,
|
|
982
|
-
saved: req.query && String(req.query.saved || '') === '1',
|
|
983
|
-
purged: req.query && parseInt(req.query.purged, 10) || 0,
|
|
984
|
-
view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
|
|
985
|
-
view_timeGridWeek: (settings.defaultView || '') === 'timeGridWeek',
|
|
986
|
-
view_timeGridDay: (settings.defaultView || '') === 'timeGridDay',
|
|
987
|
-
view_showRequesterToAll: String(settings.showRequesterToAll || '0') === '1',
|
|
988
|
-
});
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// --- Calendar page ---
|
|
992
|
-
|
|
993
|
-
async function handleHelloAssoCallback(req, res) {
|
|
994
|
-
const settings = await getSettings();
|
|
995
|
-
|
|
996
|
-
const bookingId = String(req.query.bookingId || '').trim();
|
|
997
|
-
const checkoutIntentId = String(req.query.checkoutIntentId || '').trim();
|
|
998
|
-
const code = String(req.query.code || '').trim();
|
|
999
|
-
const type = String(req.query.type || '').trim();
|
|
1000
|
-
|
|
1001
|
-
let status = 'pending';
|
|
1002
|
-
let message = 'Retour paiement reçu. Vérification en cours…';
|
|
1003
|
-
|
|
1004
|
-
try {
|
|
1005
|
-
if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
|
|
1006
|
-
const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
|
|
1007
|
-
|
|
1008
|
-
const paid = isCheckoutPaid(checkout);
|
|
1009
|
-
if (paid) {
|
|
1010
|
-
status = 'paid';
|
|
1011
|
-
message = 'Paiement confirmé. Merci !';
|
|
1012
|
-
|
|
1013
|
-
if (bookingId) {
|
|
1014
|
-
const rids = await getBookingRids(bookingId);
|
|
1015
|
-
for (const rid of rids) {
|
|
1016
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1017
|
-
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
1018
|
-
if (!r || !r.rid) continue;
|
|
1019
|
-
r.status = 'paid';
|
|
1020
|
-
r.paidAt = Date.now();
|
|
1021
|
-
r.checkoutIntentId = checkoutIntentId;
|
|
1022
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1023
|
-
await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
|
|
1024
|
-
}
|
|
1025
|
-
try {
|
|
1026
|
-
const b = (await getBooking(bookingId)) || { bookingId };
|
|
1027
|
-
b.status = 'paid';
|
|
1028
|
-
b.checkoutIntentId = checkoutIntentId;
|
|
1029
|
-
b.paidAt = Date.now();
|
|
1030
|
-
await saveBooking(b);
|
|
1031
|
-
} catch (e) {}
|
|
1032
|
-
}
|
|
1033
|
-
} else {
|
|
1034
|
-
status = (type === 'error' || code) ? 'error' : 'pending';
|
|
1035
|
-
message = 'Paiement non confirmé. Si vous venez de payer, réessayez dans quelques instants.';
|
|
1036
|
-
}
|
|
1037
|
-
} catch (e) {
|
|
1038
|
-
status = 'error';
|
|
1039
|
-
message = e.message || 'Erreur de vérification paiement';
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
res.render('equipment-calendar/payment-return', {
|
|
1043
|
-
title: 'Retour paiement',
|
|
1044
|
-
status,
|
|
1045
|
-
statusError: status === 'error',
|
|
1046
|
-
statusPaid: status === 'paid',
|
|
1047
|
-
message,
|
|
1048
|
-
});
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
async function renderCalendarPage(req, res) {
|
|
1052
|
-
const settings = await getSettings();
|
|
1053
|
-
const items = await getActiveItems(settings);
|
|
1054
|
-
|
|
1055
|
-
const tz = settings.timezone || 'Europe/Paris';
|
|
1056
|
-
|
|
1057
|
-
// Determine range to render
|
|
1058
|
-
const now = DateTime.now().setZone(tz);
|
|
1059
|
-
const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
|
|
1060
|
-
const startQ = req.query.start;
|
|
1061
|
-
const endQ = req.query.end;
|
|
1062
|
-
|
|
1063
|
-
let start, end;
|
|
1064
|
-
if (startQ && endQ) {
|
|
1065
|
-
const r = clampRange(String(startQ), String(endQ), tz);
|
|
1066
|
-
start = r.start;
|
|
1067
|
-
end = r.end;
|
|
1068
|
-
} else {
|
|
1069
|
-
// Default to current month range
|
|
1070
|
-
start = now.startOf('month');
|
|
1071
|
-
end = now.endOf('month').plus({ days: 1 });
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// Load reservations for ALL items within range (so we can build availability client-side without extra requests)
|
|
1075
|
-
const allReservations = [];
|
|
1076
|
-
for (const it of items) {
|
|
1077
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1078
|
-
const resForItem = await listReservationsForRange(it.id, start.toMillis(), end.toMillis());
|
|
1079
|
-
for (const r of resForItem) allReservations.push(r);
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
const showRequesterToAll = String(settings.showRequesterToAll) === '1';
|
|
1083
|
-
const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
|
|
1084
|
-
|
|
1085
|
-
const requesterUids = Array.from(new Set(allReservations.map(r => r.uid))).filter(Boolean);
|
|
1086
|
-
const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
|
|
1087
|
-
const nameByUid = {};
|
|
1088
|
-
(users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
|
|
1089
|
-
|
|
1090
|
-
const itemById = {};
|
|
1091
|
-
items.forEach(it => { itemById[it.id] = it; });
|
|
1092
|
-
|
|
1093
|
-
const events = allReservations
|
|
1094
|
-
.filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
|
|
1095
|
-
.map(r => {
|
|
1096
|
-
const item = itemById[r.itemId];
|
|
1097
|
-
// Include item name in title so "all items" view is readable
|
|
1098
|
-
const requesterName = nameByUid[r.uid] || '';
|
|
1099
|
-
return toEvent(r, item, requesterName, isApprover || showRequesterToAll);
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
|
|
1103
|
-
|
|
1104
|
-
// We expose minimal reservation data for availability checks in the modal
|
|
1105
|
-
const blocks = allReservations
|
|
1106
|
-
.filter(r => statusBlocksItem(r.status))
|
|
1107
|
-
.map(r => ({
|
|
1108
|
-
itemId: r.itemId,
|
|
1109
|
-
startMs: r.startMs,
|
|
1110
|
-
endMs: r.endMs,
|
|
1111
|
-
status: r.status,
|
|
1112
|
-
}));
|
|
1113
|
-
|
|
1114
|
-
res.render('equipment-calendar/calendar', {
|
|
1115
|
-
title: 'Réservation de matériel',
|
|
1116
|
-
items,
|
|
1117
|
-
view,
|
|
1118
|
-
tz,
|
|
1119
|
-
startISO: start.toISO(),
|
|
1120
|
-
endISO: end.toISO(),
|
|
1121
|
-
initialDateISO: start.toISODate(),
|
|
1122
|
-
canCreate: canUserCreate,
|
|
1123
|
-
canCreateJs: canUserCreate ? 'true' : 'false',
|
|
1124
|
-
isApprover,
|
|
1125
|
-
// events are base64 encoded to avoid template escaping issues
|
|
1126
|
-
eventsB64: Buffer.from(JSON.stringify(events), 'utf8').toString('base64'),
|
|
1127
|
-
blocksB64: Buffer.from(JSON.stringify(blocks), 'utf8').toString('base64'),
|
|
1128
|
-
itemsB64: Buffer.from(JSON.stringify(items.map(i => ({ id: i.id, name: i.name, location: i.location }))), 'utf8').toString('base64'),
|
|
1129
|
-
});
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// --- Approvals page ---
|
|
1133
|
-
|
|
1134
|
-
async function notifyApprovers(reservations, settings) {
|
|
1135
|
-
const groupName = (settings.notifyGroup || settings.approverGroup || 'administrators').trim();
|
|
1136
|
-
if (!groupName) return;
|
|
1137
|
-
|
|
1138
|
-
const rid = reservations[0] && (reservations[0].rid || reservations[0].id) || '';
|
|
1139
|
-
const path = '/equipment/approvals';
|
|
1140
|
-
|
|
1141
|
-
// In-app notification (NodeBB notifications)
|
|
1142
|
-
try {
|
|
1143
|
-
const Notifications = require.main.require('./src/notifications');
|
|
1144
|
-
const notif = await Notifications.create({
|
|
1145
|
-
bodyShort: 'Nouvelle demande de réservation',
|
|
1146
|
-
bodyLong: 'Une nouvelle demande de réservation est en attente de validation.',
|
|
1147
|
-
nid: 'equipment-calendar:' + rid,
|
|
1148
|
-
path,
|
|
1149
|
-
});
|
|
1150
|
-
if (Notifications.pushGroup) {
|
|
1151
|
-
await Notifications.pushGroup(notif, groupName);
|
|
1152
|
-
}
|
|
1153
|
-
} catch (e) {
|
|
1154
|
-
// ignore
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// Direct email to members of notifyGroup (uses NodeBB Emailer + your SMTP/emailer plugin)
|
|
1158
|
-
try {
|
|
1159
|
-
const Emailer = require.main.require('./src/emailer');
|
|
1160
|
-
const uids = await groups.getMembers(groupName, 0, -1);
|
|
1161
|
-
if (!uids || !uids.length) return;
|
|
1162
|
-
|
|
1163
|
-
// Build a short summary
|
|
1164
|
-
const items = await getActiveItems(await getSettings());
|
|
1165
|
-
const nameById = {};
|
|
1166
|
-
items.forEach(i => { nameById[i.id] = i.name; });
|
|
1167
|
-
|
|
1168
|
-
const start = new Date(reservations[0].startMs).toISOString();
|
|
1169
|
-
const end = new Date(reservations[0].endMs).toISOString();
|
|
1170
|
-
const itemList = reservations.map(r => nameById[r.itemId] || r.itemId).join(', ');
|
|
1171
|
-
|
|
1172
|
-
// Use a core template ('notification') that exists in NodeBB installs
|
|
1173
|
-
// Params vary a bit by version; we provide common fields.
|
|
1174
|
-
const params = {
|
|
1175
|
-
subject: '[Réservation] Nouvelle demande en attente',
|
|
1176
|
-
intro: 'Une nouvelle demande de réservation a été créée.',
|
|
1177
|
-
body: `Matériel: ${itemList}\nDébut: ${start}\nFin: ${end}\nVoir: ${path}`,
|
|
1178
|
-
notification_url: path,
|
|
1179
|
-
url: path,
|
|
1180
|
-
// Some templates use "site_title" etc but NodeBB will inject globals
|
|
1181
|
-
};
|
|
1182
|
-
|
|
1183
|
-
for (const uid of uids) {
|
|
1184
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1185
|
-
const email = await user.getUserField(uid, 'email');
|
|
1186
|
-
if (!email) continue;
|
|
1187
|
-
|
|
1188
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1189
|
-
const lang = (await user.getUserField(uid, 'language')) || 'fr';
|
|
1190
|
-
if (Emailer.sendToEmail) {
|
|
1191
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1192
|
-
await Emailer.sendToEmail('notification', email, lang, params);
|
|
1193
|
-
} else if (Emailer.send) {
|
|
1194
|
-
// Some NodeBB versions use Emailer.send(template, uid/email, params)
|
|
1195
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1196
|
-
await Emailer.send('notification', uid, params);
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
} catch (e) {
|
|
1200
|
-
// ignore email errors to not block reservation flow
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
async function renderApprovalsPage(req, res) {
|
|
1205
|
-
const settings = await getSettings();
|
|
1206
|
-
const ok = req.uid ? await canApprove(req.uid, settings) : false;
|
|
1207
|
-
if (!ok) {
|
|
1208
|
-
return helpers.notAllowed(req, res);
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
const items = parseItems(settings.itemsJson);
|
|
1212
|
-
// naive scan: for a real install, store a global sorted set too; for now list per item and merge
|
|
1213
|
-
// We'll pull recent 200 per item and then filter
|
|
1214
|
-
const pending = [];
|
|
1215
|
-
for (const it of items) {
|
|
1216
|
-
const ids = await db.getSortedSetRevRange(itemIndexKey(it.id), 0, 199);
|
|
1217
|
-
if (!ids || !ids.length) continue;
|
|
1218
|
-
const objs = await db.getObjects(ids.map(resKey));
|
|
1219
|
-
const resArr = (objs || []).filter(Boolean).map(normalizeReservation).filter(r => r.status === 'pending' || r.status === 'approved_waiting_payment');
|
|
1220
|
-
for (const r of resArr) pending.push(r);
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// Sort by createdAt desc
|
|
1224
|
-
pending.sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
|
|
1225
|
-
|
|
1226
|
-
const uids = Array.from(new Set(pending.map(r => r.uid))).filter(Boolean);
|
|
1227
|
-
const users = uids.length ? await user.getUsersData(uids) : [];
|
|
1228
|
-
const nameByUid = {};
|
|
1229
|
-
(users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
|
|
1230
|
-
|
|
1231
|
-
const rows = pending.map(r => {
|
|
1232
|
-
const item = items.find(i => i.id === r.itemId);
|
|
1233
|
-
return {
|
|
1234
|
-
id: r.id,
|
|
1235
|
-
itemName: item ? item.name : r.itemId,
|
|
1236
|
-
requester: nameByUid[r.uid] || `uid:${r.uid}`,
|
|
1237
|
-
start: r.startIso,
|
|
1238
|
-
end: addDaysIso(r.endIso, 1),
|
|
1239
|
-
status: r.status,
|
|
1240
|
-
paymentUrl: r.ha_paymentUrl || '',
|
|
1241
|
-
};
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
res.render('equipment-calendar/approvals', {
|
|
1245
|
-
title: 'Validation des réservations',
|
|
1246
|
-
rows,
|
|
1247
|
-
hasRows: Array.isArray(rows) && rows.length > 0,
|
|
1248
|
-
});
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// --- Actions ---
|
|
1252
|
-
|
|
1253
|
-
async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
|
|
1254
|
-
const items = await getActiveItems(settings);
|
|
1255
|
-
const item = items.find(i => i.id === itemId);
|
|
1256
|
-
if (!item) {
|
|
1257
|
-
throw new Error('Unknown item: ' + itemId);
|
|
1258
|
-
}
|
|
1259
|
-
// Reject if overlaps any blocking reservation
|
|
1260
|
-
const overlapsBlocking = await hasOverlap(itemId, startMs, endMs);
|
|
1261
|
-
if (overlapsBlocking) {
|
|
1262
|
-
const err = new Error('Item not available: ' + itemId);
|
|
1263
|
-
err.code = 'ITEM_UNAVAILABLE';
|
|
1264
|
-
throw err;
|
|
1265
|
-
}
|
|
1266
|
-
const rid = generateId();
|
|
1267
|
-
const data = {
|
|
1268
|
-
id: rid,
|
|
1269
|
-
rid,
|
|
1270
|
-
itemId,
|
|
1271
|
-
uid: req.uid,
|
|
1272
|
-
startMs,
|
|
1273
|
-
endMs,
|
|
1274
|
-
status: 'pending',
|
|
1275
|
-
notesUser: notesUser || '',
|
|
1276
|
-
bookingId: bookingId || '',
|
|
1277
|
-
createdAt: formatDateTimeFR(r.createdAt || 0),
|
|
1278
|
-
};
|
|
1279
|
-
await saveReservation(data);
|
|
1280
|
-
if (bookingId) {
|
|
1281
|
-
await addReservationToBooking(bookingId, rid);
|
|
1282
|
-
}
|
|
1283
|
-
return data;
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
async function handleCreateReservation(req, res) {
|
|
1287
|
-
try {
|
|
1288
|
-
const settings = await getSettings();
|
|
1289
|
-
if (!req.uid || !(await canCreate(req.uid, settings))) {
|
|
1290
|
-
return helpers.notAllowed(req, res);
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
const itemIdsRaw = (req.body.itemIds !== undefined ? req.body.itemIds : req.body.itemId);
|
|
1294
|
-
const itemIds = normalizeItemIds(itemIdsRaw);
|
|
1295
|
-
if (!itemIds.length) return res.status(400).send('itemIds required');
|
|
1296
|
-
|
|
1297
|
-
// Dates: accept YYYY-MM-DD or ISO-with-time; take first 10 chars
|
|
1298
|
-
let _startIso = String(req.body.start || req.body.startDate || req.body.startIso || req.body.startStr || '').trim().slice(0, 10);
|
|
1299
|
-
let _endIso = String(req.body.end || req.body.endDate || req.body.endIso || req.body.endStr || '').trim().slice(0, 10);
|
|
1300
|
-
|
|
1301
|
-
// Fallback: timestamps
|
|
1302
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(_startIso)) {
|
|
1303
|
-
const ms = Number(req.body.startMs || req.body.startTime || 0);
|
|
1304
|
-
if (ms) _startIso = new Date(ms).toISOString().slice(0, 10);
|
|
1305
|
-
}
|
|
1306
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(_endIso)) {
|
|
1307
|
-
const ms = Number(req.body.endMs || req.body.endTime || 0);
|
|
1308
|
-
if (ms) _endIso = new Date(ms).toISOString().slice(0, 10);
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(_startIso) || !/^\d{4}-\d{2}-\d{2}$/.test(_endIso)) {
|
|
1312
|
-
return res.status(400).send('dates required');
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
const startMs = Date.parse(_startIso + 'T00:00:00Z');
|
|
1316
|
-
const endMs = Date.parse(addDaysIso(_endIso, 1) + 'T00:00:00Z'); // exclusive end
|
|
1317
|
-
if (!startMs || !endMs || endMs <= startMs) {
|
|
1318
|
-
return res.status(400).send('dates required');
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
const days = Math.max(1, Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)));
|
|
1322
|
-
|
|
1323
|
-
const items = await getActiveItems(settings);
|
|
1324
|
-
const byId = {};
|
|
1325
|
-
items.forEach((it) => { byId[String(it.id)] = it; });
|
|
1326
|
-
|
|
1327
|
-
const validIds = itemIds.map(String).filter((id) => byId[id]);
|
|
1328
|
-
if (!validIds.length) return res.status(400).send('unknown item');
|
|
1329
|
-
|
|
1330
|
-
const notesUser = String(req.body.notesUser || '').trim();
|
|
1331
|
-
|
|
1332
|
-
for (const itemId of validIds) {
|
|
1333
|
-
const it = byId[itemId];
|
|
1334
|
-
const unitPrice = Number(it.price || 0) || 0;
|
|
1335
|
-
const total = unitPrice * days;
|
|
1336
|
-
|
|
1337
|
-
const rid = generateId();
|
|
1338
|
-
const r = {
|
|
1339
|
-
rid,
|
|
1340
|
-
uid: req.uid,
|
|
1341
|
-
itemId,
|
|
1342
|
-
startMs,
|
|
1343
|
-
endMs,
|
|
1344
|
-
startIso: _startIso,
|
|
1345
|
-
endIso: _endIso,
|
|
1346
|
-
days,
|
|
1347
|
-
unitPrice,
|
|
1348
|
-
total,
|
|
1349
|
-
notesUser,
|
|
1350
|
-
status: 'pending',
|
|
1351
|
-
createdAt: Date.now(),
|
|
1352
|
-
};
|
|
1353
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1354
|
-
await saveReservation(r);
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
return res.redirect(nconf.get('relative_path') + '/calendar?created=1');
|
|
1358
|
-
} catch (err) {
|
|
1359
|
-
winston.error('[equipment-calendar] create error: ' + (err && err.message ? err.message : ''), err);
|
|
1360
|
-
return res.status(500).send('error');
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
async function handleApproveReservation(req, res) {
|
|
1365
|
-
try {
|
|
1366
|
-
const settings = await getSettings();
|
|
1367
|
-
if (!req.uid || !(await canApprove(req.uid, settings))) {
|
|
1368
|
-
return helpers.notAllowed(req, res);
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
const id = String(req.params.id || '').trim();
|
|
1372
|
-
const reservation = await getReservation(id);
|
|
1373
|
-
if (!reservation) return res.status(404).send('Not found');
|
|
1374
|
-
if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
|
|
1375
|
-
return res.status(409).send('Invalid status');
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
const items = parseItems(settings.itemsJson);
|
|
1379
|
-
const item = items.find(i => i.id === reservation.itemId);
|
|
1380
|
-
if (!item) return res.status(400).send('Invalid item');
|
|
1381
|
-
|
|
1382
|
-
// Create HelloAsso checkout
|
|
1383
|
-
if (!reservation.ha_paymentUrl) {
|
|
1384
|
-
if (!settings.ha_clientId || !settings.ha_clientSecret) {
|
|
1385
|
-
return res.status(400).send('HelloAsso not configured');
|
|
1386
|
-
}
|
|
1387
|
-
const token = await helloAssoGetAccessToken(settings);
|
|
1388
|
-
const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
|
|
1389
|
-
reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
|
|
1390
|
-
reservation.ha_paymentUrl = checkout.paymentUrl;
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
reservation.status = 'approved_waiting_payment';
|
|
1394
|
-
reservation.validatorUid = req.uid;
|
|
1395
|
-
reservation.updatedAtMs = Date.now();
|
|
1396
|
-
await saveReservation(reservation);
|
|
1397
|
-
|
|
1398
|
-
// Notify requester with payment link (best-effort)
|
|
1399
|
-
try {
|
|
1400
|
-
const u = await user.getUserData(reservation.uid);
|
|
1401
|
-
const subject = 'Réservation approuvée - Paiement requis';
|
|
1402
|
-
const body = `Ta réservation de "${item.name}" a été approuvée.\n\nLien de paiement:\n${reservation.ha_paymentUrl}\n`;
|
|
1403
|
-
// NodeBB Emailer signature may vary; this is best-effort
|
|
1404
|
-
await Emailer.send('equipmentCalendar-payment', {
|
|
1405
|
-
subject,
|
|
1406
|
-
body,
|
|
1407
|
-
to: u.email,
|
|
1408
|
-
});
|
|
1409
|
-
} catch (e) { /* ignore */ }
|
|
1410
|
-
|
|
1411
|
-
return res.redirect('/equipment/approvals');
|
|
1412
|
-
} catch (e) {
|
|
1413
|
-
return res.status(500).send(e.message || 'error');
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
async function handleRejectReservation(req, res) {
|
|
1418
|
-
try {
|
|
1419
|
-
const settings = await getSettings();
|
|
1420
|
-
if (!req.uid || !(await canApprove(req.uid, settings))) {
|
|
1421
|
-
return helpers.notAllowed(req, res);
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
const id = String(req.params.id || '').trim();
|
|
1425
|
-
const reservation = await getReservation(id);
|
|
1426
|
-
if (!reservation) return res.status(404).send('Not found');
|
|
1427
|
-
if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
|
|
1428
|
-
return res.status(409).send('Invalid status');
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
reservation.status = 'rejected';
|
|
1432
|
-
reservation.validatorUid = req.uid;
|
|
1433
|
-
reservation.notesAdmin = String(req.body.notesAdmin || '').slice(0, 2000);
|
|
1434
|
-
reservation.updatedAtMs = Date.now();
|
|
1435
|
-
await saveReservation(reservation);
|
|
1436
|
-
|
|
1437
|
-
return res.redirect('/equipment/approvals');
|
|
1438
|
-
} catch (e) {
|
|
1439
|
-
return res.status(500).send(e.message || 'error');
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
async function ensureIsAdmin(req, res) {
|
|
1444
|
-
const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
|
|
1445
|
-
if (!isAdmin) {
|
|
1446
|
-
helpers.notAllowed(req, res);
|
|
1447
|
-
return false;
|
|
1448
|
-
}
|
|
1449
|
-
return true;
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
async function deleteReservation(rid) {
|
|
1453
|
-
const key = `equipmentCalendar:reservation:${rid}`;
|
|
1454
|
-
const data = await db.getObject(key);
|
|
1455
|
-
if (data && data.itemId) {
|
|
1456
|
-
await db.sortedSetRemove(`equipmentCalendar:item:${data.itemId}:reservations`, rid);
|
|
1457
|
-
}
|
|
1458
|
-
await db.delete(key);
|
|
1459
|
-
await db.sortedSetRemove('equipmentCalendar:reservations', rid);
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
async function handleAdminPurge(req, res) {
|
|
1463
|
-
try {
|
|
1464
|
-
const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
|
|
1465
|
-
if (!isAdmin) {
|
|
1466
|
-
return helpers.notAllowed(req, res);
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
const mode = String(req.body.mode || 'nonblocking'); // nonblocking|olderThan|all
|
|
1470
|
-
const olderThanDays = parseInt(req.body.olderThanDays, 10);
|
|
1471
|
-
|
|
1472
|
-
// Load all reservation ids
|
|
1473
|
-
const rids = await db.getSortedSetRange('equipmentCalendar:reservations', 0, -1);
|
|
1474
|
-
let deleted = 0;
|
|
1475
|
-
|
|
1476
|
-
const now = Date.now();
|
|
1477
|
-
for (const rid of rids) {
|
|
1478
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1479
|
-
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
1480
|
-
if (!r || !r.rid) {
|
|
1481
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1482
|
-
await db.sortedSetRemove('equipmentCalendar:reservations', rid);
|
|
1483
|
-
continue;
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
const status = String(r.status || '');
|
|
1487
|
-
const createdAt = parseInt(r.createdAt, 10) || 0;
|
|
1488
|
-
const startMs = parseInt(r.startMs, 10) || 0;
|
|
1489
|
-
const endMs = parseInt(r.endMs, 10) || 0;
|
|
1490
|
-
|
|
1491
|
-
let shouldDelete = false;
|
|
1492
|
-
|
|
1493
|
-
if (mode === 'all') {
|
|
1494
|
-
shouldDelete = true;
|
|
1495
|
-
} else if (mode === 'olderThan') {
|
|
1496
|
-
const days = Number.isFinite(olderThanDays) ? olderThanDays : 0;
|
|
1497
|
-
if (days > 0) {
|
|
1498
|
-
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
|
1499
|
-
// use createdAt if present, otherwise startMs
|
|
1500
|
-
const t = createdAt || startMs || endMs;
|
|
1501
|
-
shouldDelete = t > 0 && t < cutoff;
|
|
1502
|
-
}
|
|
1503
|
-
} else {
|
|
1504
|
-
// nonblocking cleanup: delete rejected/cancelled only
|
|
1505
|
-
shouldDelete = (status === 'rejected' || status === 'cancelled');
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
if (shouldDelete) {
|
|
1509
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1510
|
-
await deleteReservation(rid);
|
|
1511
|
-
deleted++;
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
return res.redirect(`/admin/plugins/equipment-calendar?saved=1&purged=${deleted}`);
|
|
1516
|
-
} catch (e) {
|
|
1517
|
-
return res.status(500).send(e.message || 'error');
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
let paymentTimeoutInterval = null;
|
|
1522
|
-
|
|
1523
|
-
function startPaymentTimeoutScheduler() {
|
|
1524
|
-
if (paymentTimeoutInterval) return;
|
|
1525
|
-
// Run every minute
|
|
1526
|
-
paymentTimeoutInterval = setInterval(async () => {
|
|
1527
|
-
try {
|
|
1528
|
-
const settings = await getSettings();
|
|
1529
|
-
const timeoutMin = Math.max(1, parseInt(settings.paymentTimeoutMinutes, 10) || 10);
|
|
1530
|
-
const cutoff = Date.now() - timeoutMin * 60 * 1000;
|
|
1531
|
-
|
|
1532
|
-
// Scan bookings keys (we keep an index set)
|
|
1533
|
-
const bookingIds = await db.getSetMembers('equipmentCalendar:bookings') || [];
|
|
1534
|
-
for (const bid of bookingIds) {
|
|
1535
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1536
|
-
const b = await getBooking(bid);
|
|
1537
|
-
if (!b || !b.bookingId) continue;
|
|
1538
|
-
const status = String(b.status || '');
|
|
1539
|
-
if (status !== 'payment_pending') continue;
|
|
1540
|
-
|
|
1541
|
-
const pendingAt = parseInt(b.paymentPendingAt, 10) || parseInt(b.createdAt, 10) || 0;
|
|
1542
|
-
if (!pendingAt || pendingAt > cutoff) continue;
|
|
1543
|
-
|
|
1544
|
-
// Timeout reached -> cancel booking + all reservations
|
|
1545
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1546
|
-
const rids = await getBookingRids(bid);
|
|
1547
|
-
for (const rid of rids) {
|
|
1548
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1549
|
-
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
1550
|
-
if (!r || !r.rid) continue;
|
|
1551
|
-
if (String(r.status) === 'paid') continue; // safety
|
|
1552
|
-
r.status = 'cancelled';
|
|
1553
|
-
r.cancelledAt = Date.now();
|
|
1554
|
-
r.paymentUrl = '';
|
|
1555
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1556
|
-
await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
b.status = 'cancelled';
|
|
1560
|
-
b.cancelledAt = Date.now();
|
|
1561
|
-
b.paymentUrl = '';
|
|
1562
|
-
await saveBooking(b);
|
|
1563
|
-
}
|
|
1564
|
-
} catch (e) {
|
|
1565
|
-
// ignore to keep scheduler alive
|
|
1566
|
-
}
|
|
1567
|
-
}, 60 * 1000);
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
160
|
module.exports = plugin;
|
|
1571
|
-
|
|
1572
|
-
async function handleGetReservation(req, res) {
|
|
1573
|
-
if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
|
|
1574
|
-
const rid = String(req.params.id || '');
|
|
1575
|
-
const r = await db.getObject(resKey(rid));
|
|
1576
|
-
if (!r || !r.id) return res.status(404).json({ error: 'not-found' });
|
|
1577
|
-
|
|
1578
|
-
const settings = await getSettings();
|
|
1579
|
-
const isAdmin = await user.isAdminOrGlobalMod(req.uid);
|
|
1580
|
-
const isOwner = String(r.uid) === String(req.uid);
|
|
1581
|
-
const canSee = isAdmin || isOwner || String(settings.showRequesterToAll || '0') === '1';
|
|
1582
|
-
if (!canSee) return res.status(403).json({ error: 'forbidden' });
|
|
1583
|
-
|
|
1584
|
-
res.json({
|
|
1585
|
-
id: r.id,
|
|
1586
|
-
bookingId: r.bookingId || '',
|
|
1587
|
-
itemId: r.itemId,
|
|
1588
|
-
itemName: r.itemName || '',
|
|
1589
|
-
startMs: parseInt(r.startMs, 10) || 0,
|
|
1590
|
-
endMs: parseInt(r.endMs, 10) || 0,
|
|
1591
|
-
days: parseInt(r.days, 10) || 1,
|
|
1592
|
-
status: r.status || 'pending',
|
|
1593
|
-
total: Number(r.total || 0) || 0,
|
|
1594
|
-
unitPrice: Number(r.unitPrice || 0) || 0,
|
|
1595
|
-
notesUser: r.notesUser || '',
|
|
1596
|
-
uid: r.uid,
|
|
1597
|
-
});
|
|
1598
|
-
}
|