nodebb-plugin-onekite-calendar 2.0.11 → 2.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/lib/admin.js +21 -9
- package/lib/api.js +235 -4
- package/lib/db.js +114 -0
- package/lib/helloassoWebhook.js +28 -0
- package/library.js +7 -0
- package/package.json +1 -1
- package/pkg/package/CHANGELOG.md +106 -0
- package/pkg/package/lib/admin.js +554 -0
- package/pkg/package/lib/api.js +1458 -0
- package/pkg/package/lib/controllers.js +11 -0
- package/pkg/package/lib/db.js +224 -0
- package/pkg/package/lib/discord.js +190 -0
- package/pkg/package/lib/helloasso.js +352 -0
- package/pkg/package/lib/helloassoWebhook.js +389 -0
- package/pkg/package/lib/scheduler.js +201 -0
- package/pkg/package/lib/widgets.js +460 -0
- package/pkg/package/library.js +164 -0
- package/pkg/package/package.json +14 -0
- package/pkg/package/plugin.json +43 -0
- package/pkg/package/public/admin.js +1477 -0
- package/pkg/package/public/client.js +2228 -0
- package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
- package/pkg/package/templates/calendar-onekite.tpl +51 -0
- package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
- package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
- package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
- package/plugin.json +1 -1
- package/public/admin.js +205 -4
- package/public/client.js +238 -7
- package/templates/admin/plugins/calendar-onekite.tpl +74 -0
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const meta = require.main.require('./src/meta');
|
|
6
|
+
const emailer = require.main.require('./src/emailer');
|
|
7
|
+
const nconf = require.main.require('nconf');
|
|
8
|
+
const user = require.main.require('./src/user');
|
|
9
|
+
const groups = require.main.require('./src/groups');
|
|
10
|
+
const db = require.main.require('./src/database');
|
|
11
|
+
const logger = require.main.require('./src/logger');
|
|
12
|
+
|
|
13
|
+
const dbLayer = require('./db');
|
|
14
|
+
|
|
15
|
+
// Resolve group identifiers from ACP.
|
|
16
|
+
// Admins may enter a group *name* ("Test") or a *slug* ("onekite-ffvl-2026").
|
|
17
|
+
// Depending on NodeBB version and group type (system/custom), some core methods
|
|
18
|
+
// accept one or the other. We try both to be tolerant.
|
|
19
|
+
async function getMembersByGroupIdentifier(groupIdentifier) {
|
|
20
|
+
const id = String(groupIdentifier || '').trim();
|
|
21
|
+
if (!id) return [];
|
|
22
|
+
|
|
23
|
+
// First try direct.
|
|
24
|
+
let members = [];
|
|
25
|
+
try {
|
|
26
|
+
members = await groups.getMembers(id, 0, -1);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
members = [];
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(members) && members.length) return members;
|
|
31
|
+
|
|
32
|
+
// Then try slug -> groupName mapping when available.
|
|
33
|
+
if (typeof groups.getGroupNameByGroupSlug === 'function') {
|
|
34
|
+
let groupName = null;
|
|
35
|
+
try {
|
|
36
|
+
if (groups.getGroupNameByGroupSlug.length >= 2) {
|
|
37
|
+
groupName = await new Promise((resolve) => {
|
|
38
|
+
groups.getGroupNameByGroupSlug(id, (err, name) => resolve(err ? null : name));
|
|
39
|
+
});
|
|
40
|
+
} else {
|
|
41
|
+
groupName = await groups.getGroupNameByGroupSlug(id);
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {
|
|
44
|
+
groupName = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (groupName && String(groupName).trim() && String(groupName).trim() !== id) {
|
|
48
|
+
try {
|
|
49
|
+
members = await groups.getMembers(String(groupName).trim(), 0, -1);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
members = [];
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(members) && members.length) return members;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return Array.isArray(members) ? members : [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
function normalizeUids(members) {
|
|
62
|
+
if (!Array.isArray(members)) return [];
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const m of members) {
|
|
65
|
+
if (Number.isInteger(m)) { out.push(m); continue; }
|
|
66
|
+
if (typeof m === 'string' && m.trim() && !Number.isNaN(parseInt(m, 10))) { out.push(parseInt(m, 10)); continue; }
|
|
67
|
+
if (m && typeof m === 'object' && (Number.isInteger(m.uid) || (typeof m.uid === 'string' && m.uid.trim()))) {
|
|
68
|
+
const u = Number.isInteger(m.uid) ? m.uid : parseInt(m.uid, 10);
|
|
69
|
+
if (!Number.isNaN(u)) out.push(u);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// de-dupe
|
|
73
|
+
return Array.from(new Set(out));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fast membership check without N calls to groups.isMember.
|
|
77
|
+
// NodeBB's groups.getUserGroups([uid]) returns an array (per uid) of group objects.
|
|
78
|
+
// We compare against both group slugs and names to be tolerant with older settings.
|
|
79
|
+
async function userInAnyGroup(uid, allowed) {
|
|
80
|
+
if (!uid || !Array.isArray(allowed) || !allowed.length) return false;
|
|
81
|
+
const ug = await groups.getUserGroups([uid]);
|
|
82
|
+
const list = (ug && ug[0]) ? ug[0] : [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const g of list) {
|
|
85
|
+
if (!g) continue;
|
|
86
|
+
if (g.slug) seen.add(String(g.slug));
|
|
87
|
+
if (g.name) seen.add(String(g.name));
|
|
88
|
+
if (g.groupName) seen.add(String(g.groupName));
|
|
89
|
+
if (g.displayName) seen.add(String(g.displayName));
|
|
90
|
+
}
|
|
91
|
+
return allowed.some(v => seen.has(String(v)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
function normalizeAllowedGroups(raw) {
|
|
96
|
+
if (!raw) return [];
|
|
97
|
+
if (Array.isArray(raw)) return raw.map(v => String(v).trim()).filter(Boolean);
|
|
98
|
+
const s = String(raw).trim();
|
|
99
|
+
if (!s) return [];
|
|
100
|
+
// Some admin UIs store JSON arrays as strings
|
|
101
|
+
if ((s.startsWith('[') && s.endsWith(']')) || (s.startsWith('"[') && s.endsWith(']"'))) {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(s.startsWith('"') ? JSON.parse(s) : s);
|
|
104
|
+
if (Array.isArray(parsed)) return parsed.map(v => String(v).trim()).filter(Boolean);
|
|
105
|
+
} catch (e) {}
|
|
106
|
+
}
|
|
107
|
+
return s.split(',').map(v => String(v).trim().replace(/^"+|"+$/g, '')).filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// NOTE: Avoid per-group async checks (groups.isMember) when possible.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
const helloasso = require('./helloasso');
|
|
114
|
+
const discord = require('./discord');
|
|
115
|
+
|
|
116
|
+
// Email helper (NodeBB 4.x): always send by uid.
|
|
117
|
+
// Subject must be provided inside params.subject.
|
|
118
|
+
async function sendEmail(template, uid, subject, data) {
|
|
119
|
+
const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
|
|
120
|
+
if (!Number.isInteger(toUid) || toUid <= 0) return;
|
|
121
|
+
|
|
122
|
+
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
if (typeof emailer.send !== 'function') return;
|
|
126
|
+
// NodeBB 4.x: send(template, uid, params)
|
|
127
|
+
// NOTE: Do NOT branch on function.length: it is unreliable once the function
|
|
128
|
+
// is wrapped/bound (common in production builds) and can lead to params being
|
|
129
|
+
// dropped, resulting in empty email bodies and missing subjects.
|
|
130
|
+
await emailer.send(template, toUid, params);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// eslint-disable-next-line no-console
|
|
133
|
+
console.warn('[calendar-onekite] Failed to send email', { template, uid: toUid, err: String((err && err.message) || err) });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeBaseUrl(meta) {
|
|
138
|
+
// Prefer meta.config.url, fallback to nconf.get('url')
|
|
139
|
+
let base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
140
|
+
if (!base) {
|
|
141
|
+
base = String(nconf.get('url') || '').trim();
|
|
142
|
+
}
|
|
143
|
+
base = String(base || '').trim().replace(/\/$/, '');
|
|
144
|
+
// Ensure absolute with scheme
|
|
145
|
+
if (base && !/^https?:\/\//i.test(base)) {
|
|
146
|
+
base = `https://${base.replace(/^\/\//, '')}`;
|
|
147
|
+
}
|
|
148
|
+
return base;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeCallbackUrl(configured, meta) {
|
|
152
|
+
const base = normalizeBaseUrl(meta);
|
|
153
|
+
let url = (configured || '').trim();
|
|
154
|
+
if (!url) {
|
|
155
|
+
// Default webhook endpoint (recommended): namespaced under /plugins
|
|
156
|
+
url = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
|
|
157
|
+
}
|
|
158
|
+
if (url && url.startsWith('/') && base) {
|
|
159
|
+
url = `${base}${url}`;
|
|
160
|
+
}
|
|
161
|
+
// Ensure scheme for absolute URLs
|
|
162
|
+
if (url && !/^https?:\/\//i.test(url)) {
|
|
163
|
+
url = `https://${url.replace(/^\/\//, '')}`;
|
|
164
|
+
}
|
|
165
|
+
return url;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeReturnUrl(meta) {
|
|
169
|
+
const base = normalizeBaseUrl(meta);
|
|
170
|
+
return base ? `${base}/calendar` : '';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
function overlap(aStart, aEnd, bStart, bEnd) {
|
|
175
|
+
return aStart < bEnd && bStart < aEnd;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
function formatFR(tsOrIso) {
|
|
180
|
+
const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
|
|
181
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
182
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
183
|
+
const yyyy = d.getFullYear();
|
|
184
|
+
return `${dd}/${mm}/${yyyy}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
|
|
188
|
+
const base = String(baseLabel || '').trim();
|
|
189
|
+
const items = Array.isArray(itemNames) ? itemNames.map((s) => String(s || '').trim()).filter(Boolean) : [];
|
|
190
|
+
const range = (start && end) ? ('Du ' + formatFR(start) + ' au ' + formatFR(end)) : '';
|
|
191
|
+
|
|
192
|
+
// IMPORTANT:
|
|
193
|
+
// On the public HelloAsso checkout page, line breaks are not always rendered consistently.
|
|
194
|
+
// Build a single-line label using bullet separators.
|
|
195
|
+
let out = '';
|
|
196
|
+
if (base) out += base;
|
|
197
|
+
if (items.length) out += (out ? ' — ' : '') + items.map((it) => '• ' + it).join(' ');
|
|
198
|
+
if (range) out += (out ? ' — ' : '') + range;
|
|
199
|
+
|
|
200
|
+
out = String(out || '').trim();
|
|
201
|
+
if (!out) out = 'Réservation matériel';
|
|
202
|
+
|
|
203
|
+
// HelloAsso constraint: itemName max 250 chars
|
|
204
|
+
if (out.length > 250) {
|
|
205
|
+
out = out.slice(0, 249).trimEnd() + '…';
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function toTs(v) {
|
|
211
|
+
if (v === undefined || v === null || v === '') return NaN;
|
|
212
|
+
// Accept milliseconds timestamps passed as strings or numbers.
|
|
213
|
+
if (typeof v === 'number') return v;
|
|
214
|
+
const s = String(v).trim();
|
|
215
|
+
if (/^[0-9]+$/.test(s)) return parseInt(s, 10);
|
|
216
|
+
|
|
217
|
+
// IMPORTANT (all-day boundaries / FullCalendar):
|
|
218
|
+
// Depending on FullCalendar config (or how dates are built), all-day range
|
|
219
|
+
// boundaries can arrive as an ISO string at **midnight UTC** (Z or +00:00).
|
|
220
|
+
// Example: 2026-01-11T00:00:00.000Z
|
|
221
|
+
// In Europe/Paris this is 01:00 local, which makes consecutive all-day ranges
|
|
222
|
+
// appear to overlap by 1 hour and wrongly marks the next day as unavailable.
|
|
223
|
+
// When we detect a midnight-UTC boundary, we treat it as a calendar date and
|
|
224
|
+
// convert to **local midnight** for overlap computations.
|
|
225
|
+
const mUtcMidnight = /^((\d{4}-\d{2}-\d{2}))T00:00:00(?:\.\d{1,3})?(?:Z|\+00:00)$/.exec(s);
|
|
226
|
+
if (mUtcMidnight && mUtcMidnight[1]) {
|
|
227
|
+
const dLocal = new Date(mUtcMidnight[1] + 'T00:00:00');
|
|
228
|
+
return dLocal.getTime();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// IMPORTANT: Date-only strings like "2026-01-09" are interpreted as UTC by JS Date(),
|
|
232
|
+
// which can create 1h overlaps in Europe/Paris (and other TZs) between consecutive days.
|
|
233
|
+
// We treat date-only inputs as local-midnight by appending a time component.
|
|
234
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
235
|
+
const dLocal = new Date(s + 'T00:00:00');
|
|
236
|
+
return dLocal.getTime();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const d = new Date(s);
|
|
240
|
+
return d.getTime();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Calendar-day difference (end exclusive) computed purely from Y/M/D.
|
|
244
|
+
// Uses UTC midnights to avoid any dependency on local timezone or DST.
|
|
245
|
+
function calendarDaysExclusiveYmd(startYmd, endYmd) {
|
|
246
|
+
try {
|
|
247
|
+
const m1 = /^\d{4}-\d{2}-\d{2}$/.exec(String(startYmd || '').trim());
|
|
248
|
+
const m2 = /^\d{4}-\d{2}-\d{2}$/.exec(String(endYmd || '').trim());
|
|
249
|
+
if (!m1 || !m2) return null;
|
|
250
|
+
const [sy, sm, sd] = startYmd.split('-').map((x) => parseInt(x, 10));
|
|
251
|
+
const [ey, em, ed] = endYmd.split('-').map((x) => parseInt(x, 10));
|
|
252
|
+
const sUtc = Date.UTC(sy, sm - 1, sd);
|
|
253
|
+
const eUtc = Date.UTC(ey, em - 1, ed);
|
|
254
|
+
const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
|
|
255
|
+
return Math.max(1, diff);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function yearFromTs(ts) {
|
|
262
|
+
const d = new Date(Number(ts));
|
|
263
|
+
return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function autoCreatorGroupForYear(year) {
|
|
267
|
+
return `onekite-ffvl-${year}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
// (removed) group slug/name resolving helpers: we now use userInAnyGroup() which
|
|
272
|
+
// matches both slugs and names and avoids extra DB lookups.
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
function autoFormSlugForYear(year) {
|
|
276
|
+
return `locations-materiel-${year}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function canRequest(uid, settings, startTs) {
|
|
280
|
+
if (!uid) return false;
|
|
281
|
+
|
|
282
|
+
// Always allow administrators to create.
|
|
283
|
+
try {
|
|
284
|
+
if (await groups.isMember(uid, 'administrators')) return true;
|
|
285
|
+
} catch (e) {}
|
|
286
|
+
|
|
287
|
+
const year = yearFromTs(startTs);
|
|
288
|
+
const defaultGroup = autoCreatorGroupForYear(year);
|
|
289
|
+
|
|
290
|
+
// ACP may store group slugs as CSV string or array depending on NodeBB/admin UI.
|
|
291
|
+
// On some installs, the UI stores *names* rather than slugs; we accept both.
|
|
292
|
+
const raw = settings.creatorGroups ?? settings.allowedGroups ?? [];
|
|
293
|
+
const extraGroups = normalizeAllowedGroups(raw);
|
|
294
|
+
|
|
295
|
+
const allowed = [...new Set([defaultGroup, ...extraGroups].filter(Boolean))];
|
|
296
|
+
if (!allowed.length) return false;
|
|
297
|
+
|
|
298
|
+
// Fast path: compare against user's groups (slug + name).
|
|
299
|
+
try {
|
|
300
|
+
if (await userInAnyGroup(uid, allowed)) return true;
|
|
301
|
+
} catch (e) {
|
|
302
|
+
// ignore
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Fallback: try isMember on each allowed entry (slug on most installs)
|
|
306
|
+
for (const g of allowed) {
|
|
307
|
+
try {
|
|
308
|
+
if (await groups.isMember(uid, g)) return true;
|
|
309
|
+
} catch (err) {}
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function canValidate(uid, settings) {
|
|
315
|
+
// Always allow forum administrators (and global moderators) to validate,
|
|
316
|
+
// even if validatorGroups is empty.
|
|
317
|
+
try {
|
|
318
|
+
const isAdmin = await groups.isMember(uid, 'administrators');
|
|
319
|
+
if (isAdmin) return true;
|
|
320
|
+
} catch (e) {}
|
|
321
|
+
|
|
322
|
+
const allowed = normalizeAllowedGroups(settings.validatorGroups || '');
|
|
323
|
+
if (!allowed.length) return false;
|
|
324
|
+
if (await userInAnyGroup(uid, allowed)) return true;
|
|
325
|
+
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function canModerate(uid) {
|
|
330
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
331
|
+
return await canValidate(uid, settings);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function auditLog(action, actorUid, payload) {
|
|
335
|
+
try {
|
|
336
|
+
const uid = actorUid ? parseInt(actorUid, 10) : 0;
|
|
337
|
+
let actorUsername = '';
|
|
338
|
+
if (uid) {
|
|
339
|
+
try {
|
|
340
|
+
const u = await user.getUserFields(uid, ['username']);
|
|
341
|
+
actorUsername = (u && u.username) ? String(u.username) : '';
|
|
342
|
+
} catch (e) {}
|
|
343
|
+
}
|
|
344
|
+
const ts = Date.now();
|
|
345
|
+
const year = new Date(ts).getFullYear();
|
|
346
|
+
await dbLayer.addAuditEntry(Object.assign({
|
|
347
|
+
ts,
|
|
348
|
+
year,
|
|
349
|
+
action: String(action || ''),
|
|
350
|
+
actorUid: uid,
|
|
351
|
+
actorUsername,
|
|
352
|
+
}, payload || {}));
|
|
353
|
+
} catch (e) {
|
|
354
|
+
// never block user flows on audit
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function canCreateSpecial(uid, settings) {
|
|
359
|
+
if (!uid) return false;
|
|
360
|
+
try {
|
|
361
|
+
const isAdmin = await groups.isMember(uid, 'administrators');
|
|
362
|
+
if (isAdmin) return true;
|
|
363
|
+
} catch (e) {}
|
|
364
|
+
const allowed = normalizeAllowedGroups(settings.specialCreatorGroups || '');
|
|
365
|
+
if (!allowed.length) return false;
|
|
366
|
+
if (await userInAnyGroup(uid, allowed)) return true;
|
|
367
|
+
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function canDeleteSpecial(uid, settings) {
|
|
372
|
+
if (!uid) return false;
|
|
373
|
+
try {
|
|
374
|
+
const isAdmin = await groups.isMember(uid, 'administrators');
|
|
375
|
+
if (isAdmin) return true;
|
|
376
|
+
} catch (e) {}
|
|
377
|
+
const allowed = normalizeAllowedGroups(settings.specialDeleterGroups || settings.specialCreatorGroups || '');
|
|
378
|
+
if (!allowed.length) return false;
|
|
379
|
+
if (await userInAnyGroup(uid, allowed)) return true;
|
|
380
|
+
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function eventsFor(resv) {
|
|
385
|
+
const status = resv.status;
|
|
386
|
+
const icons = { pending: '⏳', awaiting_payment: '💳', paid: '✅' };
|
|
387
|
+
const colors = { pending: '#f39c12', awaiting_payment: '#d35400', paid: '#27ae60' };
|
|
388
|
+
// IMPORTANT:
|
|
389
|
+
// Prefer stored date-only strings when available.
|
|
390
|
+
//
|
|
391
|
+
// Rationale: when reservations are stored as local-midnight timestamps (because
|
|
392
|
+
// the client sends YYYY-MM-DD), converting those timestamps with toISOString()
|
|
393
|
+
// shifts the date in Europe/Paris (UTC+1/+2) and can make ranges appear to
|
|
394
|
+
// overlap the following day in some UI flows (notably "Durée rapide").
|
|
395
|
+
//
|
|
396
|
+
// FullCalendar expects `end` to be EXCLUSIVE for all-day ranges, so we keep
|
|
397
|
+
// using the stored endDate as-is.
|
|
398
|
+
const startIsoDate = (resv.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(resv.startDate)))
|
|
399
|
+
? String(resv.startDate)
|
|
400
|
+
: new Date(parseInt(resv.start, 10)).toISOString().slice(0, 10);
|
|
401
|
+
const endIsoDate = (resv.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(resv.endDate)))
|
|
402
|
+
? String(resv.endDate)
|
|
403
|
+
: new Date(parseInt(resv.end, 10)).toISOString().slice(0, 10);
|
|
404
|
+
|
|
405
|
+
const itemIds = Array.isArray(resv.itemIds) ? resv.itemIds : (resv.itemId ? [resv.itemId] : []);
|
|
406
|
+
const itemNames = Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : []);
|
|
407
|
+
|
|
408
|
+
// One line = one material: return one calendar event per item
|
|
409
|
+
const out = [];
|
|
410
|
+
const count = Math.max(itemIds.length, itemNames.length, 1);
|
|
411
|
+
for (let i = 0; i < count; i++) {
|
|
412
|
+
const itemId = String(itemIds[i] || itemIds[0] || resv.itemId || '');
|
|
413
|
+
const itemName = String(itemNames[i] || itemNames[0] || resv.itemName || itemId);
|
|
414
|
+
out.push({
|
|
415
|
+
// keep id unique per item for FullCalendar, but keep the real rid in extendedProps.rid
|
|
416
|
+
id: `${resv.rid}:${itemId || i}`,
|
|
417
|
+
title: `${icons[status] || ''} ${itemName}`.trim(),
|
|
418
|
+
backgroundColor: colors[status] || '#3498db',
|
|
419
|
+
borderColor: colors[status] || '#3498db',
|
|
420
|
+
textColor: '#ffffff',
|
|
421
|
+
allDay: true,
|
|
422
|
+
start: startIsoDate,
|
|
423
|
+
end: endIsoDate,
|
|
424
|
+
extendedProps: {
|
|
425
|
+
rid: resv.rid,
|
|
426
|
+
status,
|
|
427
|
+
uid: resv.uid,
|
|
428
|
+
approvedBy: resv.approvedBy || 0,
|
|
429
|
+
approvedByUsername: resv.approvedByUsername || '',
|
|
430
|
+
itemIds: itemIds.filter(Boolean),
|
|
431
|
+
itemNames: itemNames.filter(Boolean),
|
|
432
|
+
itemIdLine: itemId,
|
|
433
|
+
itemNameLine: itemName,
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function eventsForSpecial(ev) {
|
|
441
|
+
const start = new Date(parseInt(ev.start, 10));
|
|
442
|
+
const end = new Date(parseInt(ev.end, 10));
|
|
443
|
+
const startIso = start.toISOString();
|
|
444
|
+
const endIso = end.toISOString();
|
|
445
|
+
return {
|
|
446
|
+
id: `special:${ev.eid}`,
|
|
447
|
+
title: `${ev.title || 'Évènement'}`.trim(),
|
|
448
|
+
allDay: false,
|
|
449
|
+
start: startIso,
|
|
450
|
+
end: endIso,
|
|
451
|
+
backgroundColor: '#8e44ad',
|
|
452
|
+
borderColor: '#8e44ad',
|
|
453
|
+
textColor: '#ffffff',
|
|
454
|
+
extendedProps: {
|
|
455
|
+
type: 'special',
|
|
456
|
+
eid: ev.eid,
|
|
457
|
+
title: ev.title || '',
|
|
458
|
+
notes: ev.notes || '',
|
|
459
|
+
pickupAddress: ev.address || '',
|
|
460
|
+
pickupLat: ev.lat || '',
|
|
461
|
+
pickupLon: ev.lon || '',
|
|
462
|
+
createdBy: ev.uid || 0,
|
|
463
|
+
username: ev.username || '',
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const api = {};
|
|
469
|
+
|
|
470
|
+
function computeEtag(payload) {
|
|
471
|
+
// Weak ETag is fine here: it is only used to skip identical JSON payloads.
|
|
472
|
+
const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
|
|
473
|
+
return `W/"${hash}"`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
api.getEvents = async function (req, res) {
|
|
477
|
+
const qStartRaw = (req && req.query && req.query.start !== undefined) ? String(req.query.start).trim() : '';
|
|
478
|
+
const qEndRaw = (req && req.query && req.query.end !== undefined) ? String(req.query.end).trim() : '';
|
|
479
|
+
|
|
480
|
+
// If the client provides date-only strings (YYYY-MM-DD), prefer purely calendar-based
|
|
481
|
+
// overlap checks. This avoids any dependency on server timezone, user timezone, DST,
|
|
482
|
+
// or how JS Date() parses inputs.
|
|
483
|
+
const qStartYmd = (/^\d{4}-\d{2}-\d{2}$/.test(qStartRaw)) ? qStartRaw : null;
|
|
484
|
+
const qEndYmd = (/^\d{4}-\d{2}-\d{2}$/.test(qEndRaw)) ? qEndRaw : null;
|
|
485
|
+
|
|
486
|
+
const startTs = toTs(qStartRaw) || 0;
|
|
487
|
+
const endTs = toTs(qEndRaw) || (Date.now() + 365 * 24 * 3600 * 1000);
|
|
488
|
+
|
|
489
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
490
|
+
const canMod = req.uid ? await canValidate(req.uid, settings) : false;
|
|
491
|
+
const widgetMode = String((req.query && req.query.widget) || '') === '1';
|
|
492
|
+
const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
|
|
493
|
+
const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
|
|
494
|
+
|
|
495
|
+
// Fetch a wider window because an event can start before the query range
|
|
496
|
+
// and still overlap.
|
|
497
|
+
const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
|
|
498
|
+
const ids = await dbLayer.listReservationIdsByStartRange(wideStart, endTs, 5000);
|
|
499
|
+
const out = [];
|
|
500
|
+
// Batch fetch = major perf win when there are many reservations.
|
|
501
|
+
const reservations = await dbLayer.getReservations(ids);
|
|
502
|
+
|
|
503
|
+
// Username map (needed for widget tooltip: "Réservée par ...")
|
|
504
|
+
let uidToUsername = {};
|
|
505
|
+
try {
|
|
506
|
+
const uids = Array.from(new Set((reservations || [])
|
|
507
|
+
.map((r) => String((r && r.uid) || ''))
|
|
508
|
+
.filter(Boolean)));
|
|
509
|
+
if (uids.length) {
|
|
510
|
+
let rows = [];
|
|
511
|
+
if (typeof user.getUsersFields === 'function') {
|
|
512
|
+
rows = await user.getUsersFields(uids, ['username']);
|
|
513
|
+
// Some NodeBB versions omit uid in returned rows; re-attach from input order.
|
|
514
|
+
if (Array.isArray(rows)) {
|
|
515
|
+
rows = rows.map((row, idx) => (row ? Object.assign({ uid: uids[idx] }, row) : null));
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
rows = await Promise.all(uids.map(async (uid) => {
|
|
519
|
+
try { return Object.assign({ uid }, await user.getUserFields(uid, ['username'])); } catch (e) { return { uid, username: '' }; }
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
uidToUsername = (rows || []).reduce((acc, row) => {
|
|
523
|
+
if (row && row.uid) acc[String(row.uid)] = String(row.username || '');
|
|
524
|
+
return acc;
|
|
525
|
+
}, {});
|
|
526
|
+
}
|
|
527
|
+
} catch (e) {}
|
|
528
|
+
for (const r of (reservations || [])) {
|
|
529
|
+
if (!r) continue;
|
|
530
|
+
if (!r.username && r.uid && uidToUsername[String(r.uid)]) {
|
|
531
|
+
r.username = uidToUsername[String(r.uid)];
|
|
532
|
+
}
|
|
533
|
+
// Only show active statuses
|
|
534
|
+
if (!['pending', 'awaiting_payment', 'paid'].includes(r.status)) continue;
|
|
535
|
+
// Overlap check
|
|
536
|
+
// Prefer date-only strings (YYYY-MM-DD) for 100% reliable calendar-day logic.
|
|
537
|
+
const rStartYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : null;
|
|
538
|
+
const rEndYmd = (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : null;
|
|
539
|
+
|
|
540
|
+
if (qStartYmd && qEndYmd && rStartYmd && rEndYmd) {
|
|
541
|
+
// endDate is EXCLUSIVE (FullCalendar rule): overlap iff aStart < bEnd && bStart < aEnd
|
|
542
|
+
if (!(rStartYmd < qEndYmd && qStartYmd < rEndYmd)) continue;
|
|
543
|
+
} else {
|
|
544
|
+
const rStart = parseInt(r.start, 10);
|
|
545
|
+
const rEnd = parseInt(r.end, 10);
|
|
546
|
+
if (!(rStart < endTs && startTs < rEnd)) continue;
|
|
547
|
+
}
|
|
548
|
+
const evs = eventsFor(r);
|
|
549
|
+
for (const ev of evs) {
|
|
550
|
+
const p = ev.extendedProps || {};
|
|
551
|
+
const minimal = {
|
|
552
|
+
id: ev.id,
|
|
553
|
+
title: ev.title,
|
|
554
|
+
backgroundColor: ev.backgroundColor,
|
|
555
|
+
borderColor: ev.borderColor,
|
|
556
|
+
textColor: ev.textColor,
|
|
557
|
+
allDay: ev.allDay,
|
|
558
|
+
start: ev.start,
|
|
559
|
+
end: ev.end,
|
|
560
|
+
extendedProps: {
|
|
561
|
+
type: 'reservation',
|
|
562
|
+
rid: p.rid,
|
|
563
|
+
status: p.status,
|
|
564
|
+
uid: p.uid,
|
|
565
|
+
// Needed client-side to gray out unavailable items in the reservation modal.
|
|
566
|
+
// Not sensitive: it is already visible in event titles and prevents double booking.
|
|
567
|
+
itemIds: Array.isArray(p.itemIds) ? p.itemIds.map(String) : [],
|
|
568
|
+
itemIdLine: p.itemIdLine ? String(p.itemIdLine) : '',
|
|
569
|
+
canModerate: canMod,
|
|
570
|
+
...(widgetMode ? { reservedByUsername: String(r.username || '') } : {}),
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
// Only expose username on the event list to owner/moderators.
|
|
574
|
+
if (r.username && ((req.uid && String(req.uid) === String(r.uid)) || canMod)) {
|
|
575
|
+
minimal.extendedProps.username = String(r.username);
|
|
576
|
+
}
|
|
577
|
+
// Let the UI decide if a "Payer" button might exist, without exposing the URL in list.
|
|
578
|
+
if (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl))) {
|
|
579
|
+
if ((req.uid && String(req.uid) === String(r.uid)) || canMod) {
|
|
580
|
+
minimal.extendedProps.hasPayment = true;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
out.push(minimal);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Special events
|
|
588
|
+
try {
|
|
589
|
+
const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
|
|
590
|
+
const specials = await dbLayer.getSpecialEvents(specialIds);
|
|
591
|
+
for (const sev of (specials || [])) {
|
|
592
|
+
if (!sev) continue;
|
|
593
|
+
const sStart = parseInt(sev.start, 10);
|
|
594
|
+
const sEnd = parseInt(sev.end, 10);
|
|
595
|
+
if (!(sStart < endTs && startTs < sEnd)) continue;
|
|
596
|
+
const full = eventsForSpecial(sev);
|
|
597
|
+
const minimal = {
|
|
598
|
+
id: full.id,
|
|
599
|
+
title: full.title,
|
|
600
|
+
allDay: full.allDay,
|
|
601
|
+
start: full.start,
|
|
602
|
+
end: full.end,
|
|
603
|
+
backgroundColor: full.backgroundColor,
|
|
604
|
+
borderColor: full.borderColor,
|
|
605
|
+
textColor: full.textColor,
|
|
606
|
+
extendedProps: {
|
|
607
|
+
type: 'special',
|
|
608
|
+
eid: sev.eid,
|
|
609
|
+
canCreateSpecial: canSpecialCreate,
|
|
610
|
+
canDeleteSpecial: canSpecialDelete,
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
if (sev.username && (canMod || canSpecialDelete || (req.uid && String(req.uid) === String(sev.uid)))) {
|
|
614
|
+
minimal.extendedProps.username = String(sev.username);
|
|
615
|
+
}
|
|
616
|
+
out.push(minimal);
|
|
617
|
+
}
|
|
618
|
+
} catch (e) {
|
|
619
|
+
// ignore
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Stable ordering -> stable ETag
|
|
623
|
+
out.sort((a, b) => {
|
|
624
|
+
const as = String(a.start || '');
|
|
625
|
+
const bs = String(b.start || '');
|
|
626
|
+
if (as !== bs) return as < bs ? -1 : 1;
|
|
627
|
+
const ai = String(a.id || '');
|
|
628
|
+
const bi = String(b.id || '');
|
|
629
|
+
return ai < bi ? -1 : ai > bi ? 1 : 0;
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const etag = computeEtag(out);
|
|
633
|
+
res.setHeader('ETag', etag);
|
|
634
|
+
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
635
|
+
if (String(req.headers['if-none-match'] || '') === etag) {
|
|
636
|
+
return res.status(304).end();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return res.json(out);
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
api.getReservationDetails = async function (req, res) {
|
|
643
|
+
const uid = req.uid;
|
|
644
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
645
|
+
|
|
646
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
647
|
+
const canMod = await canValidate(uid, settings);
|
|
648
|
+
|
|
649
|
+
const rid = String(req.params.rid || '').trim();
|
|
650
|
+
if (!rid) return res.status(400).json({ error: 'missing-rid' });
|
|
651
|
+
const r = await dbLayer.getReservation(rid);
|
|
652
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
653
|
+
|
|
654
|
+
// Idempotence: if already refused, return ok.
|
|
655
|
+
if (String(r.status) === 'refused') {
|
|
656
|
+
return res.json({ ok: true, status: 'refused' });
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const isOwner = String(r.uid) === String(uid);
|
|
660
|
+
if (!isOwner && !canMod) return res.status(403).json({ error: 'not-allowed' });
|
|
661
|
+
|
|
662
|
+
const out = {
|
|
663
|
+
rid: r.rid,
|
|
664
|
+
status: r.status,
|
|
665
|
+
uid: r.uid,
|
|
666
|
+
username: r.username || '',
|
|
667
|
+
itemNames: Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []),
|
|
668
|
+
itemIds: Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : []),
|
|
669
|
+
start: r.start,
|
|
670
|
+
end: r.end,
|
|
671
|
+
approvedByUsername: r.approvedByUsername || '',
|
|
672
|
+
pickupAddress: r.pickupAddress || '',
|
|
673
|
+
pickupTime: r.pickupTime || '',
|
|
674
|
+
pickupLat: r.pickupLat || '',
|
|
675
|
+
pickupLon: r.pickupLon || '',
|
|
676
|
+
notes: r.notes || '',
|
|
677
|
+
refusedReason: r.refusedReason || '',
|
|
678
|
+
total: r.total || 0,
|
|
679
|
+
canModerate: canMod,
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
if (r.status === 'awaiting_payment' && r.paymentUrl && (/^https?:\/\//i).test(String(r.paymentUrl))) {
|
|
683
|
+
out.paymentUrl = String(r.paymentUrl);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return res.json(out);
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
api.getSpecialEventDetails = async function (req, res) {
|
|
690
|
+
const uid = req.uid;
|
|
691
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
692
|
+
|
|
693
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
694
|
+
const canMod = await canValidate(uid, settings);
|
|
695
|
+
const canSpecialDelete = await canDeleteSpecial(uid, settings);
|
|
696
|
+
|
|
697
|
+
const eid = String(req.params.eid || '').trim();
|
|
698
|
+
if (!eid) return res.status(400).json({ error: 'missing-eid' });
|
|
699
|
+
const ev = await dbLayer.getSpecialEvent(eid);
|
|
700
|
+
if (!ev) return res.status(404).json({ error: 'not-found' });
|
|
701
|
+
|
|
702
|
+
// Anyone who can see the calendar can view special events, but creator username
|
|
703
|
+
// is only visible to moderators/allowed users or the creator.
|
|
704
|
+
const out = {
|
|
705
|
+
eid: ev.eid,
|
|
706
|
+
title: ev.title || '',
|
|
707
|
+
start: ev.start,
|
|
708
|
+
end: ev.end,
|
|
709
|
+
address: ev.address || '',
|
|
710
|
+
lat: ev.lat || '',
|
|
711
|
+
lon: ev.lon || '',
|
|
712
|
+
notes: ev.notes || '',
|
|
713
|
+
canDeleteSpecial: canSpecialDelete,
|
|
714
|
+
};
|
|
715
|
+
if (ev.username && (canMod || canSpecialDelete || (uid && String(uid) === String(ev.uid)))) {
|
|
716
|
+
out.username = String(ev.username);
|
|
717
|
+
}
|
|
718
|
+
return res.json(out);
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
api.getCapabilities = async function (req, res) {
|
|
722
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
723
|
+
const uid = req.uid || 0;
|
|
724
|
+
const canMod = uid ? await canValidate(uid, settings) : false;
|
|
725
|
+
res.json({
|
|
726
|
+
canModerate: canMod,
|
|
727
|
+
canCreateSpecial: uid ? await canCreateSpecial(uid, settings) : false,
|
|
728
|
+
canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings) : false,
|
|
729
|
+
});
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
api.createSpecialEvent = async function (req, res) {
|
|
733
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
734
|
+
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
735
|
+
const ok = await canCreateSpecial(req.uid, settings);
|
|
736
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
737
|
+
|
|
738
|
+
const title = String((req.body && req.body.title) || '').trim() || 'Évènement';
|
|
739
|
+
const startTs = toTs(req.body && req.body.start);
|
|
740
|
+
const endTs = toTs(req.body && req.body.end);
|
|
741
|
+
if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || !(startTs < endTs)) {
|
|
742
|
+
return res.status(400).json({ error: 'bad-dates' });
|
|
743
|
+
}
|
|
744
|
+
const address = String((req.body && req.body.address) || '').trim();
|
|
745
|
+
const notes = String((req.body && req.body.notes) || '').trim();
|
|
746
|
+
const lat = String((req.body && req.body.lat) || '').trim();
|
|
747
|
+
const lon = String((req.body && req.body.lon) || '').trim();
|
|
748
|
+
|
|
749
|
+
const u = await user.getUserFields(req.uid, ['username']);
|
|
750
|
+
const eid = crypto.randomUUID();
|
|
751
|
+
const ev = {
|
|
752
|
+
eid,
|
|
753
|
+
title,
|
|
754
|
+
start: String(startTs),
|
|
755
|
+
end: String(endTs),
|
|
756
|
+
address,
|
|
757
|
+
notes,
|
|
758
|
+
lat,
|
|
759
|
+
lon,
|
|
760
|
+
uid: String(req.uid),
|
|
761
|
+
username: u && u.username ? String(u.username) : '',
|
|
762
|
+
createdAt: String(Date.now()),
|
|
763
|
+
};
|
|
764
|
+
await dbLayer.saveSpecialEvent(ev);
|
|
765
|
+
res.json({ ok: true, eid });
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
api.deleteSpecialEvent = async function (req, res) {
|
|
769
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
770
|
+
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
771
|
+
const ok = await canDeleteSpecial(req.uid, settings);
|
|
772
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
773
|
+
const eid = String(req.params.eid || '').replace(/^special:/, '').trim();
|
|
774
|
+
if (!eid) return res.status(400).json({ error: 'bad-id' });
|
|
775
|
+
await dbLayer.removeSpecialEvent(eid);
|
|
776
|
+
res.json({ ok: true });
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
api.getItems = async function (req, res) {
|
|
780
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
781
|
+
|
|
782
|
+
const env = settings.helloassoEnv || 'prod';
|
|
783
|
+
const token = await helloasso.getAccessToken({
|
|
784
|
+
env,
|
|
785
|
+
clientId: settings.helloassoClientId,
|
|
786
|
+
clientSecret: settings.helloassoClientSecret,
|
|
787
|
+
});
|
|
788
|
+
if (!token) {
|
|
789
|
+
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (!token) {
|
|
793
|
+
return res.json([]);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Important: the /items endpoint on HelloAsso lists *sold items*.
|
|
797
|
+
// For a shop catalog, use the /public form endpoint and extract the catalog.
|
|
798
|
+
const year = new Date().getFullYear();
|
|
799
|
+
const { items: catalog } = await helloasso.listCatalogItems({
|
|
800
|
+
env,
|
|
801
|
+
token,
|
|
802
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
803
|
+
formType: settings.helloassoFormType,
|
|
804
|
+
// Form slug is derived from the year
|
|
805
|
+
formSlug: autoFormSlugForYear(year),
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const normalized = (catalog || []).map((it) => ({
|
|
809
|
+
id: it.id,
|
|
810
|
+
name: it.name,
|
|
811
|
+
price: typeof it.price === 'number' ? it.price : 0,
|
|
812
|
+
})).filter(it => it.id && it.name);
|
|
813
|
+
|
|
814
|
+
// Maintenance (simple ON/OFF per item)
|
|
815
|
+
let maint = new Set();
|
|
816
|
+
try {
|
|
817
|
+
const ids = await dbLayer.listMaintenanceItemIds(20000);
|
|
818
|
+
maint = new Set((ids || []).map(String));
|
|
819
|
+
} catch (e) {}
|
|
820
|
+
|
|
821
|
+
const out = normalized.map((it) => Object.assign({}, it, {
|
|
822
|
+
maintenance: maint.has(String(it.id)),
|
|
823
|
+
}));
|
|
824
|
+
|
|
825
|
+
res.json(out);
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
api.createReservation = async function (req, res) {
|
|
829
|
+
const uid = req.uid;
|
|
830
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
831
|
+
|
|
832
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
833
|
+
const startPreview = toTs(req.body.start);
|
|
834
|
+
const ok = await canRequest(uid, settings, startPreview);
|
|
835
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
836
|
+
|
|
837
|
+
const isValidator = await canValidate(uid, settings);
|
|
838
|
+
|
|
839
|
+
const startRaw = req.body.start;
|
|
840
|
+
const endRaw = req.body.end;
|
|
841
|
+
const start = parseInt(toTs(startRaw), 10);
|
|
842
|
+
const end = parseInt(toTs(endRaw), 10);
|
|
843
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
|
844
|
+
return res.status(400).json({ error: "bad-dates" });
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Keep the original date-only strings when available. This allows
|
|
848
|
+
// calendar-day calculations that are 100% independent of hours/timezones/DST.
|
|
849
|
+
const startDate = (typeof startRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(startRaw.trim())) ? startRaw.trim() : null;
|
|
850
|
+
const endDate = (typeof endRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(endRaw.trim())) ? endRaw.trim() : null;
|
|
851
|
+
|
|
852
|
+
// Validators can create "free" reservations that skip the payment workflow.
|
|
853
|
+
// However, long rentals should follow the normal paid workflow.
|
|
854
|
+
// Setting: validatorFreeMaxDays (days, endDate exclusive). If empty/0 => always free.
|
|
855
|
+
let validatorFreeMaxDays = 0;
|
|
856
|
+
try {
|
|
857
|
+
const v = parseInt(String(settings.validatorFreeMaxDays || '').trim(), 10);
|
|
858
|
+
validatorFreeMaxDays = Number.isFinite(v) ? v : 0;
|
|
859
|
+
} catch (e) {
|
|
860
|
+
validatorFreeMaxDays = 0;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Reliable calendar-day count (endDate is EXCLUSIVE)
|
|
864
|
+
const nbDays = (startDate && endDate) ? (calendarDaysExclusiveYmd(startDate, endDate) || 1) : Math.max(1, Math.round((end - start) / (24 * 60 * 60 * 1000)));
|
|
865
|
+
|
|
866
|
+
// A validator is "free" only if the rental duration is within the configured threshold.
|
|
867
|
+
const isValidatorFree = !!isValidator && (validatorFreeMaxDays <= 0 || nbDays <= validatorFreeMaxDays);
|
|
868
|
+
|
|
869
|
+
// Business rule: a reservation cannot start on the current day or in the past.
|
|
870
|
+
// We compare against server-local midnight. (Front-end also prevents it.)
|
|
871
|
+
try {
|
|
872
|
+
const today0 = new Date();
|
|
873
|
+
today0.setHours(0, 0, 0, 0);
|
|
874
|
+
const tomorrow0 = today0.getTime() + 24 * 60 * 60 * 1000;
|
|
875
|
+
if (start < tomorrow0) {
|
|
876
|
+
return res.status(400).json({
|
|
877
|
+
error: 'date-too-soon',
|
|
878
|
+
message: "Impossible de réserver pour aujourd’hui ou une date passée.",
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
} catch (e) {
|
|
882
|
+
// If anything goes wrong, fail safe by allowing the normal flow.
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Support both legacy single itemId and new itemIds[] payload
|
|
886
|
+
const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds.map(String) : ((req.body.itemId ? [String(req.body.itemId)] : []));
|
|
887
|
+
const itemNames = Array.isArray(req.body.itemNames) ? req.body.itemNames.map(String) : (req.body.itemName ? [String(req.body.itemName)] : []);
|
|
888
|
+
|
|
889
|
+
const total = typeof req.body.total === 'number' ? req.body.total : parseFloat(String(req.body.total || '0'));
|
|
890
|
+
|
|
891
|
+
if (!start || !end || !itemIds.length) {
|
|
892
|
+
return res.status(400).json({ error: 'missing-fields' });
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Maintenance block (simple ON/OFF, no dates): reject if any selected item is in maintenance.
|
|
896
|
+
try {
|
|
897
|
+
const maintIds = new Set(((await dbLayer.listMaintenanceItemIds(20000)) || []).map(String));
|
|
898
|
+
const blockedByMaintenance = itemIds.filter((id) => maintIds.has(String(id)));
|
|
899
|
+
if (blockedByMaintenance.length) {
|
|
900
|
+
return res.status(409).json({
|
|
901
|
+
error: 'in-maintenance',
|
|
902
|
+
itemIds: blockedByMaintenance,
|
|
903
|
+
message: "Un ou plusieurs matériels sont en maintenance.",
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
} catch (e) {}
|
|
907
|
+
|
|
908
|
+
// Prevent double booking: block if any selected item overlaps with an active reservation
|
|
909
|
+
const blocking = new Set(['pending', 'awaiting_payment', 'paid']);
|
|
910
|
+
const wideStart2 = Math.max(0, start - 366 * 24 * 3600 * 1000);
|
|
911
|
+
const candidateIds = await dbLayer.listReservationIdsByStartRange(wideStart2, end, 5000);
|
|
912
|
+
const conflicts = [];
|
|
913
|
+
const existingRows = await dbLayer.getReservations(candidateIds);
|
|
914
|
+
for (const existing of (existingRows || [])) {
|
|
915
|
+
if (!existing || !blocking.has(existing.status)) continue;
|
|
916
|
+
const exStartYmd = (existing.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(existing.startDate))) ? String(existing.startDate) : null;
|
|
917
|
+
const exEndYmd = (existing.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(existing.endDate))) ? String(existing.endDate) : null;
|
|
918
|
+
if (startDate && endDate && exStartYmd && exEndYmd) {
|
|
919
|
+
// endDate is EXCLUSIVE: overlap iff aStart < bEnd && bStart < aEnd
|
|
920
|
+
if (!(exStartYmd < endDate && startDate < exEndYmd)) continue;
|
|
921
|
+
} else {
|
|
922
|
+
const exStart = parseInt(existing.start, 10);
|
|
923
|
+
const exEnd = parseInt(existing.end, 10);
|
|
924
|
+
if (!(exStart < end && start < exEnd)) continue;
|
|
925
|
+
}
|
|
926
|
+
const exItemIds = Array.isArray(existing.itemIds) ? existing.itemIds : (existing.itemId ? [existing.itemId] : []);
|
|
927
|
+
const shared = exItemIds.filter(x => itemIds.includes(String(x)));
|
|
928
|
+
if (shared.length) {
|
|
929
|
+
conflicts.push({ rid: existing.rid, itemIds: shared, status: existing.status });
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (conflicts.length) {
|
|
933
|
+
return res.status(409).json({ error: 'conflict', conflicts });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const now = Date.now();
|
|
937
|
+
const rid = crypto.randomUUID();
|
|
938
|
+
|
|
939
|
+
// Snapshot username for display in calendar popups
|
|
940
|
+
let username = null;
|
|
941
|
+
try {
|
|
942
|
+
username = await user.getUserField(uid, 'username');
|
|
943
|
+
} catch (e) {}
|
|
944
|
+
|
|
945
|
+
const resv = {
|
|
946
|
+
rid,
|
|
947
|
+
uid,
|
|
948
|
+
username: username || null,
|
|
949
|
+
itemIds,
|
|
950
|
+
itemNames: itemNames.length ? itemNames : itemIds,
|
|
951
|
+
// keep legacy fields for backward compatibility
|
|
952
|
+
itemId: itemIds[0],
|
|
953
|
+
itemName: (itemNames[0] || itemIds[0]),
|
|
954
|
+
start,
|
|
955
|
+
end,
|
|
956
|
+
startDate,
|
|
957
|
+
endDate,
|
|
958
|
+
status: isValidatorFree ? 'paid' : 'pending',
|
|
959
|
+
createdAt: now,
|
|
960
|
+
paidAt: isValidatorFree ? now : 0,
|
|
961
|
+
approvedBy: isValidatorFree ? uid : 0,
|
|
962
|
+
// total is used for accounting (paid reservations).
|
|
963
|
+
// Validator self-reservations are FREE (no payment required) and must not be
|
|
964
|
+
// counted as revenue.
|
|
965
|
+
isFree: !!isValidatorFree,
|
|
966
|
+
total: isValidatorFree ? 0 : (isNaN(total) ? 0 : total),
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
// NOTE: We intentionally do NOT compute a monetary total for validator self-reservations.
|
|
970
|
+
// Those are free "sorties" and are tracked separately in the accounting view.
|
|
971
|
+
|
|
972
|
+
// Save
|
|
973
|
+
await dbLayer.saveReservation(resv);
|
|
974
|
+
|
|
975
|
+
// Audit
|
|
976
|
+
await auditLog(isValidatorFree ? 'reservation_self_checked' : 'reservation_requested', uid, {
|
|
977
|
+
targetType: 'reservation',
|
|
978
|
+
targetId: String(resv.rid),
|
|
979
|
+
uid: Number(uid) || 0,
|
|
980
|
+
requesterUid: Number(uid) || 0,
|
|
981
|
+
requesterUsername: username || '',
|
|
982
|
+
itemIds: resv.itemIds || [],
|
|
983
|
+
itemNames: resv.itemNames || [],
|
|
984
|
+
startDate: resv.startDate || '',
|
|
985
|
+
endDate: resv.endDate || '',
|
|
986
|
+
status: resv.status,
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
if (!isValidatorFree) {
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
// Notify groups by email (NodeBB emailer config)
|
|
993
|
+
try {
|
|
994
|
+
const notifyGroups = (settings.notifyGroups || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
995
|
+
if (notifyGroups.length) {
|
|
996
|
+
const requester = await user.getUserFields(uid, ['username', 'email']);
|
|
997
|
+
const itemsLabel = (resv.itemNames || []).join(', ');
|
|
998
|
+
for (const g of notifyGroups) {
|
|
999
|
+
const members = await getMembersByGroupIdentifier(g);
|
|
1000
|
+
const uids = normalizeUids(members);
|
|
1001
|
+
|
|
1002
|
+
// Batch fetch user email/username when supported by this NodeBB version.
|
|
1003
|
+
let usersData = [];
|
|
1004
|
+
try {
|
|
1005
|
+
if (typeof user.getUsersFields === 'function') {
|
|
1006
|
+
usersData = await user.getUsersFields(uids, ['username', 'email']);
|
|
1007
|
+
// Some NodeBB versions omit uid in returned rows; re-attach it from input order.
|
|
1008
|
+
if (Array.isArray(usersData)) {
|
|
1009
|
+
usersData = usersData.map((row, idx) => (row ? Object.assign({ uid: uids[idx] }, row) : null));
|
|
1010
|
+
}
|
|
1011
|
+
} else {
|
|
1012
|
+
usersData = await Promise.all(uids.map(async (memberUid) => {
|
|
1013
|
+
try { return await user.getUserFields(memberUid, ['username', 'email']); }
|
|
1014
|
+
catch (e) { return null; }
|
|
1015
|
+
}));
|
|
1016
|
+
}
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
usersData = [];
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
for (const md of (usersData || [])) {
|
|
1022
|
+
const memberUid = md && (md.uid || md.userId || md.userid || md.user_id);
|
|
1023
|
+
const u = parseInt(memberUid, 10);
|
|
1024
|
+
if (Number.isInteger(u) && u > 0) {
|
|
1025
|
+
await sendEmail('calendar-onekite_pending', u, 'Location matériel - Demande de réservation', {
|
|
1026
|
+
uid: u,
|
|
1027
|
+
username: md && md.username ? md.username : '',
|
|
1028
|
+
requester: requester.username,
|
|
1029
|
+
itemName: itemsLabel,
|
|
1030
|
+
itemNames: resv.itemNames || [],
|
|
1031
|
+
dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
|
|
1032
|
+
start: formatFR(start),
|
|
1033
|
+
end: formatFR(end),
|
|
1034
|
+
total: resv.total || 0,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
} catch (e) {
|
|
1041
|
+
console.warn('[calendar-onekite] Failed to send pending email', e && e.message ? e.message : e);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Discord webhook (optional)
|
|
1045
|
+
try {
|
|
1046
|
+
await discord.notifyReservationRequested(settings, {
|
|
1047
|
+
rid: resv.rid,
|
|
1048
|
+
uid: resv.uid,
|
|
1049
|
+
username: resv.username || '',
|
|
1050
|
+
itemIds: resv.itemIds || [],
|
|
1051
|
+
itemNames: resv.itemNames || [],
|
|
1052
|
+
start: resv.start,
|
|
1053
|
+
end: resv.end,
|
|
1054
|
+
status: resv.status,
|
|
1055
|
+
});
|
|
1056
|
+
} catch (e) {}
|
|
1057
|
+
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
res.json({ ok: true, rid, status: resv.status, autoPaid: !!isValidatorFree });
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
// Validator actions (from calendar popup)
|
|
1064
|
+
api.approveReservation = async function (req, res) {
|
|
1065
|
+
const uid = req.uid;
|
|
1066
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
1067
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
1068
|
+
const ok = await canValidate(uid, settings);
|
|
1069
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1070
|
+
|
|
1071
|
+
const rid = req.params.rid;
|
|
1072
|
+
const r = await dbLayer.getReservation(rid);
|
|
1073
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
1074
|
+
// Idempotence: if already approved (awaiting payment or paid), return ok.
|
|
1075
|
+
if (r.status !== 'pending') {
|
|
1076
|
+
if (['awaiting_payment', 'approved', 'paid'].includes(String(r.status))) {
|
|
1077
|
+
return res.json({ ok: true, status: r.status });
|
|
1078
|
+
}
|
|
1079
|
+
return res.status(400).json({ error: 'bad-status' });
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
r.status = 'awaiting_payment';
|
|
1083
|
+
// Backwards compatible: old clients sent `adminNote` to describe the pickup place.
|
|
1084
|
+
r.pickupAddress = String((req.body && (req.body.pickupAddress || req.body.adminNote)) || '').trim();
|
|
1085
|
+
r.notes = String((req.body && req.body.notes) || '').trim();
|
|
1086
|
+
r.pickupTime = String((req.body && req.body.pickupTime) || '').trim();
|
|
1087
|
+
r.pickupLat = String((req.body && req.body.pickupLat) || '').trim();
|
|
1088
|
+
r.pickupLon = String((req.body && req.body.pickupLon) || '').trim();
|
|
1089
|
+
r.approvedAt = Date.now();
|
|
1090
|
+
try {
|
|
1091
|
+
const approver = await user.getUserFields(uid, ['username']);
|
|
1092
|
+
r.approvedBy = uid;
|
|
1093
|
+
r.approvedByUsername = approver && approver.username ? approver.username : '';
|
|
1094
|
+
} catch (e) {
|
|
1095
|
+
r.approvedBy = uid;
|
|
1096
|
+
r.approvedByUsername = '';
|
|
1097
|
+
}
|
|
1098
|
+
// Create HelloAsso payment link on validation
|
|
1099
|
+
try {
|
|
1100
|
+
const settings2 = await meta.settings.get('calendar-onekite');
|
|
1101
|
+
const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
|
|
1102
|
+
const payer = await user.getUserFields(r.uid, ['email']);
|
|
1103
|
+
const year = yearFromTs(r.start);
|
|
1104
|
+
|
|
1105
|
+
// Reliable calendar-day count (end is EXCLUSIVE, FullCalendar rule)
|
|
1106
|
+
const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
|
|
1107
|
+
|
|
1108
|
+
// Recompute total from HelloAsso catalog to avoid any dependency on hours/DST
|
|
1109
|
+
// and to ensure checkout amount is always consistent.
|
|
1110
|
+
let recomputedTotalCents = null;
|
|
1111
|
+
try {
|
|
1112
|
+
const { items: catalog } = await helloasso.listCatalogItems({
|
|
1113
|
+
env: settings2.helloassoEnv,
|
|
1114
|
+
token,
|
|
1115
|
+
organizationSlug: settings2.helloassoOrganizationSlug,
|
|
1116
|
+
formType: settings2.helloassoFormType,
|
|
1117
|
+
formSlug: autoFormSlugForYear(year),
|
|
1118
|
+
});
|
|
1119
|
+
const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
|
|
1120
|
+
const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
|
|
1121
|
+
const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(String(id)) || 0), 0);
|
|
1122
|
+
if (sumCentsPerDay > 0) {
|
|
1123
|
+
recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
|
|
1124
|
+
// Keep stored total in sync (euros) for emails/UX.
|
|
1125
|
+
r.total = recomputedTotalCents / 100;
|
|
1126
|
+
}
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
// ignore recompute failures; fallback to stored total
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const intent = await helloasso.createCheckoutIntent({
|
|
1132
|
+
env: settings2.helloassoEnv,
|
|
1133
|
+
token,
|
|
1134
|
+
organizationSlug: settings2.helloassoOrganizationSlug,
|
|
1135
|
+
formType: settings2.helloassoFormType,
|
|
1136
|
+
// Form slug is derived from the year of the reservation start date
|
|
1137
|
+
formSlug: autoFormSlugForYear(year),
|
|
1138
|
+
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
1139
|
+
totalAmount: (() => {
|
|
1140
|
+
const cents = (typeof recomputedTotalCents === 'number')
|
|
1141
|
+
? recomputedTotalCents
|
|
1142
|
+
: Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
1143
|
+
if (!cents) {
|
|
1144
|
+
console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve API)', { rid, total: r.total });
|
|
1145
|
+
}
|
|
1146
|
+
return cents;
|
|
1147
|
+
})(),
|
|
1148
|
+
payerEmail: payer && payer.email ? payer.email : '',
|
|
1149
|
+
// By default, point to the forum base url so the webhook hits this NodeBB instance.
|
|
1150
|
+
// Can be overridden via ACP setting `helloassoCallbackUrl`.
|
|
1151
|
+
callbackUrl: normalizeReturnUrl(meta),
|
|
1152
|
+
webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl, meta),
|
|
1153
|
+
itemName: buildHelloAssoItemName('', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
|
|
1154
|
+
containsDonation: false,
|
|
1155
|
+
metadata: {
|
|
1156
|
+
reservationId: String(rid),
|
|
1157
|
+
items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
|
|
1158
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
const paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl) ? (intent.paymentUrl || intent.redirectUrl) : (typeof intent === 'string' ? intent : null);
|
|
1162
|
+
const checkoutIntentId = intent && intent.checkoutIntentId ? String(intent.checkoutIntentId) : null;
|
|
1163
|
+
if (paymentUrl) {
|
|
1164
|
+
r.paymentUrl = paymentUrl;
|
|
1165
|
+
if (checkoutIntentId) {
|
|
1166
|
+
r.checkoutIntentId = checkoutIntentId;
|
|
1167
|
+
}
|
|
1168
|
+
} else {
|
|
1169
|
+
console.warn('[calendar-onekite] HelloAsso payment link not created (approve API)', { rid });
|
|
1170
|
+
}
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
// ignore payment link errors, admin can retry
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
await dbLayer.saveReservation(r);
|
|
1176
|
+
|
|
1177
|
+
await auditLog('reservation_approved', uid, {
|
|
1178
|
+
targetType: 'reservation',
|
|
1179
|
+
targetId: String(rid),
|
|
1180
|
+
reservationUid: Number(r.uid) || 0,
|
|
1181
|
+
reservationUsername: String(r.username || ''),
|
|
1182
|
+
itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
|
|
1183
|
+
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
1184
|
+
startDate: r.startDate || '',
|
|
1185
|
+
endDate: r.endDate || '',
|
|
1186
|
+
status: r.status,
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
// Email requester
|
|
1190
|
+
const requesterUid = parseInt(r.uid, 10);
|
|
1191
|
+
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
1192
|
+
if (requesterUid) {
|
|
1193
|
+
const latNum = Number(r.pickupLat);
|
|
1194
|
+
const lonNum = Number(r.pickupLon);
|
|
1195
|
+
const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
|
|
1196
|
+
? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
|
|
1197
|
+
: '';
|
|
1198
|
+
await sendEmail('calendar-onekite_approved', requesterUid, 'Location matériel - Réservation validée', {
|
|
1199
|
+
uid: requesterUid,
|
|
1200
|
+
username: requester && requester.username ? requester.username : '',
|
|
1201
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
1202
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
1203
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
1204
|
+
start: formatFR(r.start),
|
|
1205
|
+
end: formatFR(r.end),
|
|
1206
|
+
pickupAddress: r.pickupAddress || '',
|
|
1207
|
+
notes: r.notes || '',
|
|
1208
|
+
pickupTime: r.pickupTime || '',
|
|
1209
|
+
pickupLat: r.pickupLat || '',
|
|
1210
|
+
pickupLon: r.pickupLon || '',
|
|
1211
|
+
mapUrl,
|
|
1212
|
+
paymentUrl: r.paymentUrl || '',
|
|
1213
|
+
validatedBy: r.approvedByUsername || '',
|
|
1214
|
+
validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
return res.json({ ok: true });
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
api.refuseReservation = async function (req, res) {
|
|
1221
|
+
const uid = req.uid;
|
|
1222
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
1223
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
1224
|
+
const ok = await canValidate(uid, settings);
|
|
1225
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1226
|
+
|
|
1227
|
+
const rid = req.params.rid;
|
|
1228
|
+
const r = await dbLayer.getReservation(rid);
|
|
1229
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
1230
|
+
|
|
1231
|
+
r.status = 'refused';
|
|
1232
|
+
r.refusedAt = Date.now();
|
|
1233
|
+
r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
|
|
1234
|
+
try {
|
|
1235
|
+
const refuser = await user.getUserFields(uid, ['username']);
|
|
1236
|
+
r.refusedBy = uid;
|
|
1237
|
+
r.refusedByUsername = refuser && refuser.username ? refuser.username : '';
|
|
1238
|
+
} catch (e) {
|
|
1239
|
+
r.refusedBy = uid;
|
|
1240
|
+
r.refusedByUsername = '';
|
|
1241
|
+
}
|
|
1242
|
+
await dbLayer.saveReservation(r);
|
|
1243
|
+
|
|
1244
|
+
await auditLog('reservation_refused', uid, {
|
|
1245
|
+
targetType: 'reservation',
|
|
1246
|
+
targetId: String(rid),
|
|
1247
|
+
reservationUid: Number(r.uid) || 0,
|
|
1248
|
+
reservationUsername: String(r.username || ''),
|
|
1249
|
+
itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
|
|
1250
|
+
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
1251
|
+
startDate: r.startDate || '',
|
|
1252
|
+
endDate: r.endDate || '',
|
|
1253
|
+
reason: r.refusedReason || '',
|
|
1254
|
+
status: r.status,
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
const requesterUid2 = parseInt(r.uid, 10);
|
|
1258
|
+
const requester = await user.getUserFields(requesterUid2, ['username']);
|
|
1259
|
+
if (requesterUid2) {
|
|
1260
|
+
await sendEmail('calendar-onekite_refused', requesterUid2, 'Location matériel - Demande de réservation', {
|
|
1261
|
+
uid: requesterUid2,
|
|
1262
|
+
username: requester && requester.username ? requester.username : '',
|
|
1263
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
1264
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
1265
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
1266
|
+
start: formatFR(r.start),
|
|
1267
|
+
end: formatFR(r.end),
|
|
1268
|
+
refusedReason: r.refusedReason || '',
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
return res.json({ ok: true });
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
api.cancelReservation = async function (req, res) {
|
|
1278
|
+
const uid = req.uid;
|
|
1279
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
1280
|
+
|
|
1281
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
1282
|
+
const rid = String(req.params.rid || '').trim();
|
|
1283
|
+
if (!rid) return res.status(400).json({ error: 'missing-rid' });
|
|
1284
|
+
|
|
1285
|
+
const r = await dbLayer.getReservation(rid);
|
|
1286
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
1287
|
+
|
|
1288
|
+
const canMod = await canValidate(uid, settings);
|
|
1289
|
+
const isOwner = String(r.uid) === String(uid);
|
|
1290
|
+
|
|
1291
|
+
// Owner can cancel their own reservation only while payment is not completed.
|
|
1292
|
+
// Validators/admins can cancel any reservation.
|
|
1293
|
+
if (!isOwner && !canMod) return res.status(403).json({ error: 'not-allowed' });
|
|
1294
|
+
|
|
1295
|
+
const isPaid = String(r.status) === 'paid';
|
|
1296
|
+
if (isOwner && isPaid) return res.status(400).json({ error: 'cannot-cancel-paid' });
|
|
1297
|
+
|
|
1298
|
+
if (r.status === 'cancelled') return res.json({ ok: true, status: 'cancelled' });
|
|
1299
|
+
|
|
1300
|
+
r.status = 'cancelled';
|
|
1301
|
+
r.cancelledAt = Date.now();
|
|
1302
|
+
r.cancelledBy = uid;
|
|
1303
|
+
|
|
1304
|
+
try {
|
|
1305
|
+
const canceller = await user.getUserFields(uid, ['username']);
|
|
1306
|
+
r.cancelledByUsername = canceller && canceller.username ? canceller.username : '';
|
|
1307
|
+
} catch (e) {
|
|
1308
|
+
r.cancelledByUsername = '';
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
await dbLayer.saveReservation(r);
|
|
1312
|
+
|
|
1313
|
+
await auditLog('reservation_cancelled', uid, {
|
|
1314
|
+
targetType: 'reservation',
|
|
1315
|
+
targetId: String(rid),
|
|
1316
|
+
reservationUid: Number(r.uid) || 0,
|
|
1317
|
+
reservationUsername: String(r.username || ''),
|
|
1318
|
+
itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
|
|
1319
|
+
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
1320
|
+
startDate: r.startDate || '',
|
|
1321
|
+
endDate: r.endDate || '',
|
|
1322
|
+
status: r.status,
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
// Discord webhook (optional)
|
|
1326
|
+
try {
|
|
1327
|
+
await discord.notifyReservationCancelled(settings, {
|
|
1328
|
+
rid: r.rid,
|
|
1329
|
+
uid: r.uid,
|
|
1330
|
+
username: r.username || '',
|
|
1331
|
+
itemIds: r.itemIds || [],
|
|
1332
|
+
itemNames: r.itemNames || [],
|
|
1333
|
+
start: r.start,
|
|
1334
|
+
end: r.end,
|
|
1335
|
+
status: r.status,
|
|
1336
|
+
cancelledAt: r.cancelledAt,
|
|
1337
|
+
cancelledBy: r.cancelledBy,
|
|
1338
|
+
cancelledByUsername: r.cancelledByUsername || '',
|
|
1339
|
+
});
|
|
1340
|
+
} catch (e) {}
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
// Email requester
|
|
1344
|
+
try {
|
|
1345
|
+
const requesterUid = parseInt(r.uid, 10);
|
|
1346
|
+
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
1347
|
+
const canceller = await user.getUserFields(uid, ['username']);
|
|
1348
|
+
if (requesterUid) {
|
|
1349
|
+
await sendEmail('calendar-onekite_cancelled', requesterUid, 'Location matériel - Réservation annulée', {
|
|
1350
|
+
uid: requesterUid,
|
|
1351
|
+
username: requester && requester.username ? requester.username : '',
|
|
1352
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
1353
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
1354
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
1355
|
+
cancelledBy: (r.cancelledByUsername || (canceller && canceller.username) || ''),
|
|
1356
|
+
cancelledByUrl: ((r.cancelledByUsername || (canceller && canceller.username)) ? `${normalizeBaseUrl(meta)}/user/${encodeURIComponent(String(r.cancelledByUsername || canceller.username))}` : ''),
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
} catch (e) {}
|
|
1360
|
+
|
|
1361
|
+
return res.json({ ok: true, status: 'cancelled' });
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
// --------------------
|
|
1365
|
+
// Maintenance (simple ON/OFF)
|
|
1366
|
+
// --------------------
|
|
1367
|
+
|
|
1368
|
+
api.getMaintenance = async function (req, res) {
|
|
1369
|
+
const uid = req.uid;
|
|
1370
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1371
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1372
|
+
const ids = await dbLayer.listMaintenanceItemIds(20000);
|
|
1373
|
+
return res.json({ itemIds: (ids || []).map(String) });
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
api.setMaintenance = async function (req, res) {
|
|
1377
|
+
const uid = req.uid;
|
|
1378
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1379
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1380
|
+
const itemId = String(req.params.itemId || '').trim();
|
|
1381
|
+
if (!itemId) return res.status(400).json({ error: 'missing-itemId' });
|
|
1382
|
+
const enabled = !!(req.body && (req.body.enabled === true || req.body.enabled === '1' || req.body.enabled === 1));
|
|
1383
|
+
await dbLayer.setItemMaintenance(itemId, enabled);
|
|
1384
|
+
await auditLog(enabled ? 'maintenance_on' : 'maintenance_off', uid, {
|
|
1385
|
+
targetType: 'item',
|
|
1386
|
+
targetId: itemId,
|
|
1387
|
+
});
|
|
1388
|
+
return res.json({ ok: true, itemId, enabled });
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
// Bulk toggle: enable/disable maintenance for ALL catalog items.
|
|
1392
|
+
// Uses the same permission as validate/delete.
|
|
1393
|
+
api.setMaintenanceAll = async function (req, res) {
|
|
1394
|
+
const uid = req.uid;
|
|
1395
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1396
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1397
|
+
|
|
1398
|
+
const enabled = !!(req.body && (req.body.enabled === true || req.body.enabled === '1' || req.body.enabled === 1));
|
|
1399
|
+
|
|
1400
|
+
// When enabling, we need the current catalog IDs (HelloAsso shop)
|
|
1401
|
+
let catalogIds = [];
|
|
1402
|
+
if (enabled) {
|
|
1403
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
1404
|
+
const env = settings.helloassoEnv || 'prod';
|
|
1405
|
+
const token = await helloasso.getAccessToken({
|
|
1406
|
+
env,
|
|
1407
|
+
clientId: settings.helloassoClientId,
|
|
1408
|
+
clientSecret: settings.helloassoClientSecret,
|
|
1409
|
+
});
|
|
1410
|
+
if (!token) {
|
|
1411
|
+
return res.status(400).json({ error: 'helloasso-token-missing' });
|
|
1412
|
+
}
|
|
1413
|
+
const year = new Date().getFullYear();
|
|
1414
|
+
const { items: catalog } = await helloasso.listCatalogItems({
|
|
1415
|
+
env,
|
|
1416
|
+
token,
|
|
1417
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
1418
|
+
formType: settings.helloassoFormType,
|
|
1419
|
+
formSlug: autoFormSlugForYear(year),
|
|
1420
|
+
});
|
|
1421
|
+
catalogIds = (catalog || []).map((it) => it && it.id).filter(Boolean).map(String);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const result = await dbLayer.setAllMaintenance(enabled, catalogIds);
|
|
1425
|
+
await auditLog(enabled ? 'maintenance_all_on' : 'maintenance_all_off', uid, {
|
|
1426
|
+
targetType: 'maintenance',
|
|
1427
|
+
targetId: enabled ? 'all_on' : 'all_off',
|
|
1428
|
+
count: result && typeof result.count === 'number' ? result.count : (enabled ? catalogIds.length : 0),
|
|
1429
|
+
});
|
|
1430
|
+
return res.json(Object.assign({ ok: true, enabled }, result || {}));
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
// --------------------
|
|
1434
|
+
// Audit
|
|
1435
|
+
// --------------------
|
|
1436
|
+
|
|
1437
|
+
api.getAudit = async function (req, res) {
|
|
1438
|
+
const uid = req.uid;
|
|
1439
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1440
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1441
|
+
const year = Number((req.query && req.query.year) || new Date().getFullYear());
|
|
1442
|
+
const limit = Math.min(500, Math.max(1, Number((req.query && req.query.limit) || 200)));
|
|
1443
|
+
const entries = await dbLayer.getAuditEntriesByYear(year, limit);
|
|
1444
|
+
return res.json({ year, entries: entries || [] });
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
api.purgeAudit = async function (req, res) {
|
|
1448
|
+
const uid = req.uid;
|
|
1449
|
+
const ok = uid ? await canModerate(uid) : false;
|
|
1450
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
1451
|
+
const year = Number((req.body && req.body.year) || 0);
|
|
1452
|
+
if (!year) return res.status(400).json({ error: 'missing-year' });
|
|
1453
|
+
const result = await dbLayer.purgeAuditYear(year);
|
|
1454
|
+
await auditLog('audit_purge_year', uid, { targetType: 'audit', targetId: String(year), removed: result && result.removed ? result.removed : 0 });
|
|
1455
|
+
return res.json(result || { ok: true });
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
module.exports = api;
|