nodebb-plugin-onekite-calendar 2.0.72 → 2.0.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin.js +61 -0
- package/lib/api.js +86 -46
- package/lib/helloassoWebhook.js +130 -156
- package/library.js +2 -0
- package/package.json +1 -1
- package/public/admin.js +35 -0
- package/public/client.js +148 -25
- package/templates/admin/plugins/calendar-onekite.tpl +13 -2
package/lib/admin.js
CHANGED
|
@@ -196,6 +196,67 @@ admin.approveReservation = async function (req, res) {
|
|
|
196
196
|
res.json({ ok: true, paymentUrl: paymentUrl || null });
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
+
admin.markReservationPaid = async function (req, res) {
|
|
200
|
+
const rid = req.params.rid;
|
|
201
|
+
const r = await dbLayer.getReservation(rid);
|
|
202
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
203
|
+
|
|
204
|
+
const note = String((req.body && req.body.note) || '').trim();
|
|
205
|
+
const manualPaymentId = String((req.body && req.body.paymentId) || '').trim();
|
|
206
|
+
|
|
207
|
+
r.status = 'paid';
|
|
208
|
+
r.paidAt = Date.now();
|
|
209
|
+
r.manuallyPaid = true;
|
|
210
|
+
r.manuallyPaidBy = req.uid;
|
|
211
|
+
r.manuallyPaidNote = note;
|
|
212
|
+
if (manualPaymentId) r.paymentId = manualPaymentId;
|
|
213
|
+
await dbLayer.saveReservation(r);
|
|
214
|
+
|
|
215
|
+
// Audit
|
|
216
|
+
try {
|
|
217
|
+
const year = new Date().getFullYear();
|
|
218
|
+
await dbLayer.addAuditEntry({
|
|
219
|
+
ts: Date.now(), year, action: 'reservation_manually_paid',
|
|
220
|
+
targetType: 'reservation', targetId: String(r.rid),
|
|
221
|
+
reservationUid: Number(r.uid) || 0,
|
|
222
|
+
reservationUsername: String(r.username || ''),
|
|
223
|
+
actorUid: req.uid || 0,
|
|
224
|
+
note,
|
|
225
|
+
});
|
|
226
|
+
} catch (e) {}
|
|
227
|
+
|
|
228
|
+
realtime.emitCalendarUpdated({ kind: 'reservation', action: 'paid', rid: String(rid), status: 'paid' });
|
|
229
|
+
|
|
230
|
+
// Email requester
|
|
231
|
+
try {
|
|
232
|
+
const requesterUid = parseInt(r.uid, 10);
|
|
233
|
+
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
234
|
+
if (requesterUid) {
|
|
235
|
+
const { sendEmail, formatFR, buildCalendarLinks } = shared;
|
|
236
|
+
await sendEmail('calendar-onekite_paid', requesterUid, 'Location matériel - Paiement reçu', {
|
|
237
|
+
uid: requesterUid,
|
|
238
|
+
username: requester && requester.username ? requester.username : '',
|
|
239
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
240
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
241
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
242
|
+
paymentReceiptUrl: '',
|
|
243
|
+
pickupTime: r.pickupTime || '',
|
|
244
|
+
pickupAddress: r.pickupAddress || '',
|
|
245
|
+
mapUrl: '',
|
|
246
|
+
...(buildCalendarLinks({
|
|
247
|
+
type: 'reservation', id: String(r.rid || ''), uid: Number(r.uid) || 0,
|
|
248
|
+
itemNames: Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []),
|
|
249
|
+
pickupAddress: r.pickupAddress || '', allDay: true,
|
|
250
|
+
startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
|
|
251
|
+
endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
|
|
252
|
+
})),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
} catch (e) {}
|
|
256
|
+
|
|
257
|
+
res.json({ ok: true });
|
|
258
|
+
};
|
|
259
|
+
|
|
199
260
|
admin.refuseReservation = async function (req, res) {
|
|
200
261
|
const rid = req.params.rid;
|
|
201
262
|
const r = await dbLayer.getReservation(rid);
|
package/lib/api.js
CHANGED
|
@@ -775,6 +775,7 @@ api.getCapabilities = async function (req, res) {
|
|
|
775
775
|
]);
|
|
776
776
|
res.json({
|
|
777
777
|
canModerate: canMod,
|
|
778
|
+
isValidator: canMod,
|
|
778
779
|
canCreateSpecial: canSpecialC,
|
|
779
780
|
canDeleteSpecial: canSpecialD,
|
|
780
781
|
canCreateOuting: canReq,
|
|
@@ -1224,37 +1225,20 @@ api.createReservation = async function (req, res) {
|
|
|
1224
1225
|
const startDate = (typeof startRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(startRaw.trim())) ? startRaw.trim() : null;
|
|
1225
1226
|
const endDate = (typeof endRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(endRaw.trim())) ? endRaw.trim() : null;
|
|
1226
1227
|
|
|
1227
|
-
// Validators can create "free" reservations that skip the payment workflow.
|
|
1228
|
-
// However, long rentals should follow the normal paid workflow.
|
|
1229
|
-
// Setting: validatorFreeMaxDays (days, endDate exclusive). If empty/0 => always free.
|
|
1230
|
-
let validatorFreeMaxDays = 0;
|
|
1231
|
-
try {
|
|
1232
|
-
const v = parseInt(String(settings.validatorFreeMaxDays || '').trim(), 10);
|
|
1233
|
-
validatorFreeMaxDays = Number.isFinite(v) ? v : 0;
|
|
1234
|
-
} catch (e) {
|
|
1235
|
-
validatorFreeMaxDays = 0;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
1228
|
// Reliable calendar-day count (endDate is EXCLUSIVE)
|
|
1239
1229
|
const nbDays = (startDate && endDate) ? (calendarDaysExclusiveYmd(startDate, endDate) || 1) : Math.max(1, Math.round((end - start) / (24 * 60 * 60 * 1000)));
|
|
1240
1230
|
|
|
1241
|
-
//
|
|
1242
|
-
const
|
|
1231
|
+
// Detect past dates once (server-local midnight)
|
|
1232
|
+
const _today0 = new Date();
|
|
1233
|
+
_today0.setHours(0, 0, 0, 0);
|
|
1234
|
+
const isPastDate = start < _today0.getTime();
|
|
1243
1235
|
|
|
1244
|
-
//
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
if (start < today0ts) {
|
|
1251
|
-
return res.status(400).json({
|
|
1252
|
-
error: 'date-too-soon',
|
|
1253
|
-
message: "Impossible de réserver pour une date passée.",
|
|
1254
|
-
});
|
|
1255
|
-
}
|
|
1256
|
-
} catch (e) {
|
|
1257
|
-
// If anything goes wrong, fail safe by allowing the normal flow.
|
|
1236
|
+
// Block past dates for non-validators
|
|
1237
|
+
if (!isValidator && isPastDate) {
|
|
1238
|
+
return res.status(400).json({
|
|
1239
|
+
error: 'date-too-soon',
|
|
1240
|
+
message: "Impossible de réserver pour une date passée.",
|
|
1241
|
+
});
|
|
1258
1242
|
}
|
|
1259
1243
|
|
|
1260
1244
|
// Support both legacy single itemId and new itemIds[] payload
|
|
@@ -1311,16 +1295,53 @@ api.createReservation = async function (req, res) {
|
|
|
1311
1295
|
const now = Date.now();
|
|
1312
1296
|
const rid = crypto.randomUUID();
|
|
1313
1297
|
|
|
1314
|
-
// Snapshot username for
|
|
1315
|
-
let
|
|
1298
|
+
// Snapshot creator (validator) username for audit trail
|
|
1299
|
+
let creatorUsername = null;
|
|
1316
1300
|
try {
|
|
1317
|
-
|
|
1301
|
+
creatorUsername = await user.getUserField(uid, 'username');
|
|
1318
1302
|
} catch (e) {}
|
|
1319
1303
|
|
|
1304
|
+
// Validators can create on behalf of another user
|
|
1305
|
+
let targetUid = uid;
|
|
1306
|
+
let targetUsername = creatorUsername;
|
|
1307
|
+
if (isValidator && req.body.targetUsername) {
|
|
1308
|
+
const tName = String(req.body.targetUsername).trim();
|
|
1309
|
+
if (tName) {
|
|
1310
|
+
try {
|
|
1311
|
+
const tUid = await user.getUidByUsername(tName);
|
|
1312
|
+
if (tUid) {
|
|
1313
|
+
targetUid = parseInt(tUid, 10);
|
|
1314
|
+
const tu = await user.getUserFields(targetUid, ['username']);
|
|
1315
|
+
targetUsername = (tu && tu.username) ? tu.username : tName;
|
|
1316
|
+
} else {
|
|
1317
|
+
return res.status(400).json({ error: 'user-not-found', message: `Utilisateur "${tName}" introuvable.` });
|
|
1318
|
+
}
|
|
1319
|
+
} catch (e) {
|
|
1320
|
+
return res.status(400).json({ error: 'user-not-found', message: "Impossible de trouver l'utilisateur." });
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const isForSelf = (targetUid === uid);
|
|
1326
|
+
|
|
1327
|
+
// Free only when validator creates for themselves with a future date (within configured maxDays)
|
|
1328
|
+
const validatorFreeMaxDays = (() => {
|
|
1329
|
+
try {
|
|
1330
|
+
const v = parseInt(String(settings.validatorFreeMaxDays || '').trim(), 10);
|
|
1331
|
+
return Number.isFinite(v) ? v : 0;
|
|
1332
|
+
} catch (e) { return 0; }
|
|
1333
|
+
})();
|
|
1334
|
+
const isValidatorFree = !!isValidator && isForSelf && !isPastDate && (validatorFreeMaxDays <= 0 || nbDays <= validatorFreeMaxDays);
|
|
1335
|
+
|
|
1336
|
+
// Regularization: past-dated reservation created by a validator → paid immediately with amount
|
|
1337
|
+
const isRegularization = !!isValidator && isPastDate;
|
|
1338
|
+
|
|
1320
1339
|
const resv = {
|
|
1321
1340
|
rid,
|
|
1322
|
-
uid,
|
|
1323
|
-
username:
|
|
1341
|
+
uid: targetUid,
|
|
1342
|
+
username: targetUsername || null,
|
|
1343
|
+
createdBy: uid,
|
|
1344
|
+
createdByUsername: creatorUsername || null,
|
|
1324
1345
|
itemIds,
|
|
1325
1346
|
itemNames: itemNames.length ? itemNames : itemIds,
|
|
1326
1347
|
// keep legacy fields for backward compatibility
|
|
@@ -1330,19 +1351,17 @@ api.createReservation = async function (req, res) {
|
|
|
1330
1351
|
end,
|
|
1331
1352
|
startDate,
|
|
1332
1353
|
endDate,
|
|
1333
|
-
status: isValidatorFree ? 'paid' : 'pending',
|
|
1354
|
+
status: (isValidatorFree || isRegularization) ? 'paid' : 'pending',
|
|
1334
1355
|
createdAt: now,
|
|
1335
|
-
paidAt: isValidatorFree ? now : 0,
|
|
1336
|
-
approvedBy: isValidatorFree ? uid : 0,
|
|
1337
|
-
|
|
1338
|
-
// Validator self-reservations are FREE (no payment required) and must not be
|
|
1339
|
-
// counted as revenue.
|
|
1356
|
+
paidAt: (isValidatorFree || isRegularization) ? now : 0,
|
|
1357
|
+
approvedBy: (isValidatorFree || isRegularization) ? uid : 0,
|
|
1358
|
+
approvedByUsername: (isValidatorFree || isRegularization) ? (creatorUsername || '') : '',
|
|
1340
1359
|
isFree: !!isValidatorFree,
|
|
1341
1360
|
total: isValidatorFree ? 0 : (isNaN(total) ? 0 : total),
|
|
1361
|
+
manuallyPaid: isRegularization ? true : undefined,
|
|
1342
1362
|
};
|
|
1343
1363
|
|
|
1344
|
-
//
|
|
1345
|
-
// Those are free "sorties" and are tracked separately in the accounting view.
|
|
1364
|
+
// Validator self-reservations are FREE (no payment required) and tracked separately in accounting.
|
|
1346
1365
|
|
|
1347
1366
|
// Save
|
|
1348
1367
|
await dbLayer.saveReservation(resv);
|
|
@@ -1351,12 +1370,17 @@ api.createReservation = async function (req, res) {
|
|
|
1351
1370
|
realtime.emitCalendarUpdated({ kind: 'reservation', action: 'created', rid: resv.rid, status: resv.status });
|
|
1352
1371
|
|
|
1353
1372
|
// Audit
|
|
1354
|
-
|
|
1373
|
+
const auditAction = isValidatorFree ? 'reservation_self_checked'
|
|
1374
|
+
: isRegularization ? 'reservation_manually_paid'
|
|
1375
|
+
: 'reservation_requested';
|
|
1376
|
+
await auditLog(auditAction, uid, {
|
|
1355
1377
|
targetType: 'reservation',
|
|
1356
1378
|
targetId: String(resv.rid),
|
|
1357
|
-
uid: Number(
|
|
1358
|
-
requesterUid: Number(
|
|
1359
|
-
requesterUsername:
|
|
1379
|
+
uid: Number(targetUid) || 0,
|
|
1380
|
+
requesterUid: Number(targetUid) || 0,
|
|
1381
|
+
requesterUsername: targetUsername || '',
|
|
1382
|
+
createdBy: Number(uid) || 0,
|
|
1383
|
+
createdByUsername: creatorUsername || '',
|
|
1360
1384
|
itemIds: resv.itemIds || [],
|
|
1361
1385
|
itemNames: resv.itemNames || [],
|
|
1362
1386
|
startDate: resv.startDate || '',
|
|
@@ -1364,7 +1388,7 @@ api.createReservation = async function (req, res) {
|
|
|
1364
1388
|
status: resv.status,
|
|
1365
1389
|
});
|
|
1366
1390
|
|
|
1367
|
-
if (!isValidatorFree) {
|
|
1391
|
+
if (!isValidatorFree && !isRegularization) {
|
|
1368
1392
|
|
|
1369
1393
|
|
|
1370
1394
|
// Notify groups by email (NodeBB emailer config)
|
|
@@ -1438,7 +1462,23 @@ api.createReservation = async function (req, res) {
|
|
|
1438
1462
|
|
|
1439
1463
|
}
|
|
1440
1464
|
|
|
1441
|
-
res.json({ ok: true, rid, status: resv.status, autoPaid: !!isValidatorFree });
|
|
1465
|
+
res.json({ ok: true, rid, status: resv.status, autoPaid: !!(isValidatorFree || isRegularization) });
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
api.searchUsers = async function (req, res) {
|
|
1469
|
+
const uid = req.uid;
|
|
1470
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
1471
|
+
const settings = await getSettings();
|
|
1472
|
+
if (!(await canValidate(uid, settings))) return res.status(403).json({ error: 'not-allowed' });
|
|
1473
|
+
const q = String(req.query.q || '').trim();
|
|
1474
|
+
if (q.length < 2) return res.json([]);
|
|
1475
|
+
try {
|
|
1476
|
+
const result = await user.search({ query: q, searchBy: 'username', resultsPerPage: 8, uid });
|
|
1477
|
+
const list = (result && result.users) ? result.users : [];
|
|
1478
|
+
return res.json(list.map(u => ({ uid: u.uid, username: u.username })));
|
|
1479
|
+
} catch (e) {
|
|
1480
|
+
return res.json([]);
|
|
1481
|
+
}
|
|
1442
1482
|
};
|
|
1443
1483
|
|
|
1444
1484
|
// Validator actions (from calendar popup)
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const crypto = require('crypto');
|
|
4
|
-
|
|
5
3
|
const db = require.main.require('./src/database');
|
|
6
4
|
const meta = require.main.require('./src/meta');
|
|
7
5
|
const user = require.main.require('./src/user');
|
|
8
|
-
const nconf = require.main.require('nconf');
|
|
9
6
|
|
|
10
7
|
const dbLayer = require('./db');
|
|
11
8
|
const helloasso = require('./helloasso');
|
|
12
9
|
const discord = require('./discord');
|
|
13
10
|
const realtime = require('./realtime');
|
|
14
11
|
const shared = require('./shared');
|
|
15
|
-
const { formatFR,
|
|
12
|
+
const { formatFR, sendEmail, buildCalendarLinks } = shared;
|
|
13
|
+
|
|
14
|
+
const SETTINGS_KEY = 'calendar-onekite';
|
|
15
|
+
const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
16
20
|
|
|
17
21
|
function buildReservationCalendarLinks(r) {
|
|
18
22
|
const startYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate)))
|
|
@@ -50,19 +54,21 @@ async function auditLog(action, actorUid, payload) {
|
|
|
50
54
|
} catch (e) {}
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
function getClientIp(req) {
|
|
58
|
+
const cf = req.headers['cf-connecting-ip'];
|
|
59
|
+
if (cf) return String(cf).trim();
|
|
60
|
+
const xff = req.headers['x-forwarded-for'];
|
|
61
|
+
if (xff) return String(xff).split(',')[0].trim();
|
|
62
|
+
return String(req.ip || '').trim();
|
|
63
|
+
}
|
|
59
64
|
|
|
65
|
+
// Extract reservationId from the metadata field of a webhook payload.
|
|
66
|
+
// HelloAsso uses several payload shapes depending on event type and API version.
|
|
60
67
|
function getReservationIdFromPayload(payload) {
|
|
61
68
|
try {
|
|
62
69
|
const data = payload && payload.data ? payload.data : null;
|
|
63
70
|
if (!data) return null;
|
|
64
71
|
|
|
65
|
-
// HelloAsso commonly uses "metadata" for checkout-intents/payments.
|
|
66
72
|
const metaCandidates = [
|
|
67
73
|
data.meta,
|
|
68
74
|
data.metadata,
|
|
@@ -74,56 +80,81 @@ function getReservationIdFromPayload(payload) {
|
|
|
74
80
|
if (typeof metaObj === 'object' && !Array.isArray(metaObj) && metaObj.reservationId) {
|
|
75
81
|
return String(metaObj.reservationId);
|
|
76
82
|
}
|
|
77
|
-
// Some
|
|
83
|
+
// Some implementations send metadata as an array of { key, value } pairs
|
|
78
84
|
if (Array.isArray(metaObj)) {
|
|
79
85
|
const found = metaObj.find((x) => x && (x.key === 'reservationId' || x.name === 'reservationId'));
|
|
80
86
|
if (found && (found.value || found.val)) return String(found.value || found.val);
|
|
81
87
|
}
|
|
82
88
|
}
|
|
89
|
+
} catch (e) {}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getCheckoutIntentIdFromPayload(payload) {
|
|
94
|
+
try {
|
|
95
|
+
const data = payload && payload.data ? payload.data : null;
|
|
96
|
+
if (!data) return null;
|
|
97
|
+
const candidates = [
|
|
98
|
+
data.checkoutIntentId,
|
|
99
|
+
data.checkoutIntent && (data.checkoutIntent.id || data.checkoutIntent.checkoutIntentId),
|
|
100
|
+
data.order && (data.order.checkoutIntentId || (data.order.checkoutIntent && data.order.checkoutIntent.id)),
|
|
101
|
+
].filter(Boolean);
|
|
102
|
+
if (candidates.length) return String(candidates[0]);
|
|
103
|
+
} catch (e) {}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Returns true for event types and states that represent a completed payment.
|
|
108
|
+
function isConfirmedPayment(payload) {
|
|
109
|
+
try {
|
|
110
|
+
if (!payload || !payload.data) return false;
|
|
111
|
+
const eventType = String(payload.eventType || '').toLowerCase();
|
|
112
|
+
const state = String(payload.data.state || payload.data.status || payload.data.paymentState || '').toLowerCase();
|
|
113
|
+
const okState = ['confirmed', 'authorized', 'paid', 'processed', 'succeeded', 'success'].includes(state);
|
|
114
|
+
|
|
115
|
+
if (eventType === 'payment') return okState;
|
|
116
|
+
if (eventType === 'order') {
|
|
117
|
+
// Order payloads sometimes omit the state field; accept when missing.
|
|
118
|
+
return !state || okState;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
83
121
|
} catch (e) {
|
|
84
|
-
|
|
122
|
+
return false;
|
|
85
123
|
}
|
|
86
|
-
return null;
|
|
87
124
|
}
|
|
88
125
|
|
|
89
|
-
async function
|
|
90
|
-
if (!paymentId) return
|
|
126
|
+
async function alreadyProcessed(paymentId) {
|
|
127
|
+
if (!paymentId) return false;
|
|
91
128
|
try {
|
|
92
|
-
|
|
93
|
-
env: settings.helloassoEnv || 'live',
|
|
94
|
-
clientId: settings.helloassoClientId,
|
|
95
|
-
clientSecret: settings.helloassoClientSecret,
|
|
96
|
-
});
|
|
97
|
-
if (!token) return null;
|
|
98
|
-
const details = await helloasso.getPaymentDetails({
|
|
99
|
-
env: settings.helloassoEnv || 'live',
|
|
100
|
-
token,
|
|
101
|
-
paymentId,
|
|
102
|
-
});
|
|
103
|
-
if (!details) return null;
|
|
104
|
-
// Reuse the same extraction logic on the payment details object.
|
|
105
|
-
return getReservationIdFromPayload({ data: details });
|
|
129
|
+
return await db.isSetMember(PROCESSED_KEY, String(paymentId));
|
|
106
130
|
} catch (e) {
|
|
107
|
-
return
|
|
131
|
+
return false;
|
|
108
132
|
}
|
|
109
133
|
}
|
|
110
134
|
|
|
111
|
-
async function
|
|
135
|
+
async function markProcessed(paymentId) {
|
|
136
|
+
if (!paymentId) return;
|
|
137
|
+
try {
|
|
138
|
+
await db.setAdd(PROCESSED_KEY, String(paymentId));
|
|
139
|
+
} catch (e) {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Try to find the reservationId via the checkoutIntentId → rid mapping stored at approval time.
|
|
143
|
+
// Falls back to querying HelloAsso's API when the mapping is missing (older reservations).
|
|
144
|
+
async function tryRecoverRidFromCheckoutIntent(settings, checkoutIntentId) {
|
|
112
145
|
if (!checkoutIntentId) return null;
|
|
113
146
|
try {
|
|
114
|
-
// First, try a direct mapping stored when the checkout intent was created.
|
|
115
147
|
const mapped = await db.getObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId));
|
|
116
148
|
if (mapped) return String(mapped);
|
|
117
149
|
|
|
118
|
-
// If no mapping exists (older reservations), query HelloAsso for intent details to read metadata.
|
|
119
150
|
const token = await helloasso.getAccessToken({
|
|
120
|
-
env: settings.helloassoEnv || '
|
|
151
|
+
env: settings.helloassoEnv || 'prod',
|
|
121
152
|
clientId: settings.helloassoClientId,
|
|
122
153
|
clientSecret: settings.helloassoClientSecret,
|
|
123
154
|
});
|
|
124
155
|
if (!token) return null;
|
|
125
156
|
const details = await helloasso.getCheckoutIntentDetails({
|
|
126
|
-
env: settings.helloassoEnv || '
|
|
157
|
+
env: settings.helloassoEnv || 'prod',
|
|
127
158
|
token,
|
|
128
159
|
organizationSlug: settings.helloassoOrganizationSlug,
|
|
129
160
|
checkoutIntentId,
|
|
@@ -131,171 +162,119 @@ async function tryRecoverReservationIdFromCheckoutIntent(settings, checkoutInten
|
|
|
131
162
|
if (!details) return null;
|
|
132
163
|
const rid = getReservationIdFromPayload({ data: details });
|
|
133
164
|
if (rid) {
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid));
|
|
137
|
-
} catch (e) {}
|
|
165
|
+
try { await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid)); } catch (e) {}
|
|
138
166
|
return String(rid);
|
|
139
167
|
}
|
|
140
|
-
} catch (e) {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
168
|
+
} catch (e) {}
|
|
143
169
|
return null;
|
|
144
170
|
}
|
|
145
171
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return await db.isSetMember(PROCESSED_KEY, String(paymentId));
|
|
150
|
-
} catch (e) {
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function markProcessed(paymentId) {
|
|
156
|
-
if (!paymentId) return;
|
|
157
|
-
try {
|
|
158
|
-
await db.setAdd(PROCESSED_KEY, String(paymentId));
|
|
159
|
-
} catch (e) {
|
|
160
|
-
// ignore
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function isConfirmedPayment(payload) {
|
|
172
|
+
// Last-resort: fetch the payment from HelloAsso's API and read metadata from there.
|
|
173
|
+
async function tryRecoverRidFromPayment(settings, paymentId) {
|
|
174
|
+
if (!paymentId) return null;
|
|
165
175
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// Order payloads may not carry a payment-like "state" field; accept if missing,
|
|
180
|
-
// but still require a recognizable "paid" state when provided.
|
|
181
|
-
return !state || okState;
|
|
182
|
-
}
|
|
183
|
-
return false;
|
|
176
|
+
const token = await helloasso.getAccessToken({
|
|
177
|
+
env: settings.helloassoEnv || 'prod',
|
|
178
|
+
clientId: settings.helloassoClientId,
|
|
179
|
+
clientSecret: settings.helloassoClientSecret,
|
|
180
|
+
});
|
|
181
|
+
if (!token) return null;
|
|
182
|
+
const details = await helloasso.getPaymentDetails({
|
|
183
|
+
env: settings.helloassoEnv || 'prod',
|
|
184
|
+
token,
|
|
185
|
+
paymentId,
|
|
186
|
+
});
|
|
187
|
+
if (!details) return null;
|
|
188
|
+
return getReservationIdFromPayload({ data: details });
|
|
184
189
|
} catch (e) {
|
|
185
|
-
return
|
|
190
|
+
return null;
|
|
186
191
|
}
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (!data) return null;
|
|
193
|
-
// Common locations based on HelloAsso docs and community reports:
|
|
194
|
-
// - Order event often contains checkoutIntentId at root of data
|
|
195
|
-
// - Return/back redirects contain checkoutIntentId in query params (not here)
|
|
196
|
-
const candidates = [
|
|
197
|
-
data.checkoutIntentId,
|
|
198
|
-
data.checkoutIntent && (data.checkoutIntent.id || data.checkoutIntent.checkoutIntentId),
|
|
199
|
-
data.order && (data.order.checkoutIntentId || (data.order.checkoutIntent && data.order.checkoutIntent.id)),
|
|
200
|
-
].filter(Boolean);
|
|
201
|
-
if (candidates.length) return String(candidates[0]);
|
|
202
|
-
} catch (e) {}
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Webhook handler
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
205
197
|
|
|
206
|
-
/**
|
|
207
|
-
* Hardened HelloAsso webhook handler.
|
|
208
|
-
* - Requires x-ha-signature (HMAC SHA-256) verification.
|
|
209
|
-
* - Only accepts eventType=Payment with state=Confirmed.
|
|
210
|
-
* - Provides basic replay protection using the payment id.
|
|
211
|
-
*
|
|
212
|
-
* This handler is "safe" by default: if we cannot verify the signature,
|
|
213
|
-
* we refuse the request.
|
|
214
|
-
*/
|
|
215
198
|
async function handler(req, res, next) {
|
|
216
199
|
try {
|
|
217
|
-
if (req.method === 'GET') {
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
if (req.method !== 'POST') {
|
|
221
|
-
return res.status(405).json({ ok: false, error: 'method-not-allowed' });
|
|
222
|
-
}
|
|
200
|
+
if (req.method === 'GET') return res.json({ ok: true });
|
|
201
|
+
if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'method-not-allowed' });
|
|
223
202
|
|
|
224
203
|
const settings = await meta.settings.get(SETTINGS_KEY);
|
|
225
204
|
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
const allowedIps =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
})();
|
|
238
|
-
|
|
239
|
-
if (allowedIps.length && clientIp && !allowedIps.includes(clientIp)) {
|
|
240
|
-
// eslint-disable-next-line no-console
|
|
241
|
-
console.warn('[calendar-onekite] HelloAsso webhook blocked by IP allowlist', { ip: clientIp, allowed: allowedIps });
|
|
242
|
-
return res.status(403).json({ ok: false, error: 'ip-not-allowed' });
|
|
205
|
+
// Optional IP allowlist (configure helloassoWebhookAllowedIps in ACP).
|
|
206
|
+
// If empty, all IPs are accepted.
|
|
207
|
+
const rawIps = String((settings && settings.helloassoWebhookAllowedIps) || '').trim();
|
|
208
|
+
const allowedIps = rawIps ? rawIps.split(/[\s,;]+/g).map((s) => s.trim()).filter(Boolean) : [];
|
|
209
|
+
if (allowedIps.length) {
|
|
210
|
+
const clientIp = getClientIp(req);
|
|
211
|
+
if (!allowedIps.includes(clientIp)) {
|
|
212
|
+
// eslint-disable-next-line no-console
|
|
213
|
+
console.warn('[calendar-onekite] HelloAsso webhook blocked by IP allowlist', { ip: clientIp, allowed: allowedIps });
|
|
214
|
+
return res.status(403).json({ ok: false, error: 'ip-not-allowed' });
|
|
215
|
+
}
|
|
243
216
|
}
|
|
244
217
|
|
|
245
|
-
// At this point, the payload is trusted.
|
|
246
218
|
const payload = req.body;
|
|
247
219
|
|
|
248
220
|
if (!isConfirmedPayment(payload)) {
|
|
249
|
-
// Acknowledge but ignore other event types/states.
|
|
250
221
|
return res.json({ ok: true, ignored: true });
|
|
251
222
|
}
|
|
252
223
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
224
|
+
const eventType = String((payload && payload.eventType) || '').toLowerCase();
|
|
225
|
+
const rid = getReservationIdFromPayload(payload);
|
|
226
|
+
const checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
|
|
227
|
+
|
|
228
|
+
// Incomplete Payment events (no metadata, no checkoutIntentId) cannot be reconciled.
|
|
229
|
+
if (eventType === 'payment' && !rid && !checkoutIntentId) {
|
|
230
|
+
// eslint-disable-next-line no-console
|
|
231
|
+
console.warn('[calendar-onekite] HelloAsso webhook: Payment event has no metadata.reservationId and no checkoutIntentId', {
|
|
232
|
+
paymentId: payload && payload.data && payload.data.id,
|
|
233
|
+
dataKeys: payload && payload.data ? Object.keys(payload.data) : [],
|
|
234
|
+
});
|
|
259
235
|
return res.json({ ok: true, ignored: true, incompletePayment: true });
|
|
260
236
|
}
|
|
261
237
|
|
|
262
238
|
const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId) : null;
|
|
263
|
-
// If we can't identify the payment, acknowledge and ignore (prevents accidental crashes).
|
|
264
239
|
if (!paymentId) {
|
|
240
|
+
// eslint-disable-next-line no-console
|
|
241
|
+
console.warn('[calendar-onekite] HelloAsso webhook: missing payment id', { eventType, dataKeys: payload && payload.data ? Object.keys(payload.data) : [] });
|
|
265
242
|
return res.json({ ok: true, ignored: true, missingPaymentId: true });
|
|
266
243
|
}
|
|
244
|
+
|
|
267
245
|
if (await alreadyProcessed(paymentId)) {
|
|
268
246
|
return res.json({ ok: true, duplicate: true });
|
|
269
247
|
}
|
|
270
248
|
|
|
271
|
-
|
|
249
|
+
// Resolve the reservation ID: try metadata first, then checkoutIntentId mapping, then API call.
|
|
272
250
|
let resolvedRid = rid;
|
|
273
|
-
const checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
|
|
274
251
|
if (!resolvedRid && checkoutIntentId) {
|
|
275
|
-
|
|
276
|
-
resolvedRid = await tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId);
|
|
252
|
+
resolvedRid = await tryRecoverRidFromCheckoutIntent(settings, checkoutIntentId);
|
|
277
253
|
}
|
|
278
|
-
if (!resolvedRid
|
|
279
|
-
|
|
280
|
-
resolvedRid = await tryRecoverReservationIdFromPayment(settings, paymentId);
|
|
254
|
+
if (!resolvedRid) {
|
|
255
|
+
resolvedRid = await tryRecoverRidFromPayment(settings, paymentId);
|
|
281
256
|
}
|
|
282
257
|
if (!resolvedRid) {
|
|
283
258
|
// eslint-disable-next-line no-console
|
|
284
|
-
console.warn('[calendar-onekite] HelloAsso webhook
|
|
285
|
-
// Do
|
|
259
|
+
console.warn('[calendar-onekite] HelloAsso webhook: could not resolve reservationId — payment NOT recorded', { eventType, paymentId, checkoutIntentId });
|
|
260
|
+
// Do not mark as processed: a manual replay may be possible after fixing config.
|
|
286
261
|
return res.json({ ok: true, processed: false, missingReservationId: true });
|
|
287
262
|
}
|
|
288
263
|
|
|
289
264
|
const r = await dbLayer.getReservation(resolvedRid);
|
|
290
265
|
if (!r) {
|
|
266
|
+
// eslint-disable-next-line no-console
|
|
267
|
+
console.warn('[calendar-onekite] HelloAsso webhook: reservation not found (expired/deleted?)', { rid: resolvedRid, paymentId });
|
|
291
268
|
await markProcessed(paymentId);
|
|
292
269
|
return res.json({ ok: true, processed: true, reservationNotFound: true });
|
|
293
270
|
}
|
|
294
271
|
|
|
295
|
-
//
|
|
272
|
+
// eslint-disable-next-line no-console
|
|
273
|
+
console.info('[calendar-onekite] HelloAsso webhook: marking reservation paid', { rid: resolvedRid, prevStatus: r.status, paymentId });
|
|
274
|
+
|
|
296
275
|
r.status = 'paid';
|
|
297
276
|
r.paidAt = Date.now();
|
|
298
|
-
r.paymentId =
|
|
277
|
+
r.paymentId = String(paymentId);
|
|
299
278
|
if (payload.data && payload.data.paymentReceiptUrl) {
|
|
300
279
|
r.paymentReceiptUrl = String(payload.data.paymentReceiptUrl);
|
|
301
280
|
}
|
|
@@ -310,13 +289,11 @@ async function handler(req, res, next) {
|
|
|
310
289
|
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
311
290
|
startDate: r.startDate || '',
|
|
312
291
|
endDate: r.endDate || '',
|
|
313
|
-
paymentId: r.paymentId
|
|
292
|
+
paymentId: r.paymentId,
|
|
314
293
|
});
|
|
315
294
|
|
|
316
|
-
// Real-time notify: refresh calendars for all viewers
|
|
317
295
|
realtime.emitCalendarUpdated({ kind: 'reservation', action: 'paid', rid: String(r.rid), status: r.status });
|
|
318
296
|
|
|
319
|
-
// Notify requester
|
|
320
297
|
const requesterUid = parseInt(r.uid, 10);
|
|
321
298
|
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
322
299
|
if (requesterUid) {
|
|
@@ -340,7 +317,6 @@ async function handler(req, res, next) {
|
|
|
340
317
|
});
|
|
341
318
|
}
|
|
342
319
|
|
|
343
|
-
// Discord webhook (optional)
|
|
344
320
|
try {
|
|
345
321
|
await discord.notifyPaymentReceived(settings, {
|
|
346
322
|
rid: r.rid,
|
|
@@ -361,6 +337,4 @@ async function handler(req, res, next) {
|
|
|
361
337
|
}
|
|
362
338
|
}
|
|
363
339
|
|
|
364
|
-
module.exports = {
|
|
365
|
-
handler,
|
|
366
|
-
};
|
|
340
|
+
module.exports = { handler };
|
package/library.js
CHANGED
|
@@ -65,6 +65,7 @@ Plugin.init = async function (params) {
|
|
|
65
65
|
router.get('/api/v3/plugins/calendar-onekite/events', ...publicExpose, api.getEvents);
|
|
66
66
|
router.get('/api/v3/plugins/calendar-onekite/items', ...publicExpose, api.getItems);
|
|
67
67
|
router.get('/api/v3/plugins/calendar-onekite/capabilities', ...publicExpose, api.getCapabilities);
|
|
68
|
+
router.get('/api/v3/plugins/calendar-onekite/users/search', ...publicExpose, api.searchUsers);
|
|
68
69
|
|
|
69
70
|
// Maintenance / audit (restricted to validator groups)
|
|
70
71
|
router.get('/api/v3/plugins/calendar-onekite/maintenance', ...publicExpose, api.getMaintenance);
|
|
@@ -109,6 +110,7 @@ Plugin.init = async function (params) {
|
|
|
109
110
|
router.get(`${adminBase}/pending`, ...adminMws, admin.listPending);
|
|
110
111
|
router.put(`${adminBase}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
|
|
111
112
|
router.put(`${adminBase}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
|
|
113
|
+
router.put(`${adminBase}/reservations/:rid/mark-paid`, ...adminMws, admin.markReservationPaid);
|
|
112
114
|
|
|
113
115
|
router.post(`${adminBase}/purge`, ...adminMws, admin.purgeByYear);
|
|
114
116
|
router.get(`${adminBase}/debug`, ...adminMws, admin.debugHelloAsso);
|
package/package.json
CHANGED
package/public/admin.js
CHANGED
|
@@ -573,6 +573,13 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
573
573
|
}
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
async function markPaid(rid, payload) {
|
|
577
|
+
return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/mark-paid`, {
|
|
578
|
+
method: 'PUT',
|
|
579
|
+
body: JSON.stringify(payload || {}),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
576
583
|
async function debugHelloAsso() {
|
|
577
584
|
try {
|
|
578
585
|
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
|
|
@@ -1214,6 +1221,34 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
1214
1221
|
});
|
|
1215
1222
|
}
|
|
1216
1223
|
|
|
1224
|
+
// Manual mark-as-paid (recovery when HelloAsso webhook failed)
|
|
1225
|
+
const markPaidBtn = document.getElementById('onekite-mark-paid-btn');
|
|
1226
|
+
if (markPaidBtn) {
|
|
1227
|
+
markPaidBtn.addEventListener('click', async () => {
|
|
1228
|
+
const ridInput = document.getElementById('onekite-mark-paid-rid');
|
|
1229
|
+
const rid = (ridInput ? ridInput.value : '').trim();
|
|
1230
|
+
if (!rid) {
|
|
1231
|
+
showAlert('error', 'ID de réservation requis.');
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
bootbox.confirm(
|
|
1235
|
+
`Marquer la réservation <strong>${escapeHtml(rid)}</strong> comme payée manuellement ?<br><small class="text-muted">Un email de confirmation sera envoyé à l'adhérent.</small>`,
|
|
1236
|
+
async (ok) => {
|
|
1237
|
+
if (!ok) return;
|
|
1238
|
+
try {
|
|
1239
|
+
await withLock(`mark-paid:${rid}`, [markPaidBtn], async () => {
|
|
1240
|
+
await markPaid(rid, { note: 'Paiement validé manuellement depuis ACP' });
|
|
1241
|
+
showAlert('success', `Réservation ${rid} marquée comme payée.`);
|
|
1242
|
+
if (ridInput) ridInput.value = '';
|
|
1243
|
+
});
|
|
1244
|
+
} catch (e) {
|
|
1245
|
+
showAlert('error', `Impossible de marquer comme payée : ${e && e.message ? e.message : e}`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
);
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1217
1252
|
// Accounting (paid reservations)
|
|
1218
1253
|
const accFrom = document.getElementById('onekite-acc-from');
|
|
1219
1254
|
const accTo = document.getElementById('onekite-acc-to');
|
package/public/client.js
CHANGED
|
@@ -821,6 +821,107 @@ async function searchAddresses(query, limit) {
|
|
|
821
821
|
}).filter(h => h && h.displayName);
|
|
822
822
|
}
|
|
823
823
|
|
|
824
|
+
async function searchUsers(q, limit) {
|
|
825
|
+
try {
|
|
826
|
+
const qs = new URLSearchParams({ q: String(q || ''), limit: String(limit || 8) });
|
|
827
|
+
const arr = await fetchJson(`/api/v3/plugins/calendar-onekite/users/search?${qs}`);
|
|
828
|
+
if (!Array.isArray(arr)) return [];
|
|
829
|
+
return arr;
|
|
830
|
+
} catch (e) {
|
|
831
|
+
return [];
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function attachUserAutocomplete(inputEl) {
|
|
836
|
+
if (!inputEl) return;
|
|
837
|
+
if (inputEl.getAttribute('data-onekite-user-ac') === '1') return;
|
|
838
|
+
inputEl.setAttribute('data-onekite-user-ac', '1');
|
|
839
|
+
|
|
840
|
+
const host = inputEl.parentNode || document.body;
|
|
841
|
+
try {
|
|
842
|
+
const cs = window.getComputedStyle(host);
|
|
843
|
+
if (!cs || cs.position === 'static') host.style.position = 'relative';
|
|
844
|
+
} catch (e) {
|
|
845
|
+
host.style.position = 'relative';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const menu = document.createElement('div');
|
|
849
|
+
menu.className = 'onekite-autocomplete-menu';
|
|
850
|
+
menu.style.position = 'absolute';
|
|
851
|
+
menu.style.left = '0';
|
|
852
|
+
menu.style.right = '0';
|
|
853
|
+
menu.style.top = '100%';
|
|
854
|
+
menu.style.zIndex = '2000';
|
|
855
|
+
menu.style.background = '#fff';
|
|
856
|
+
menu.style.border = '1px solid rgba(0,0,0,.15)';
|
|
857
|
+
menu.style.borderTop = '0';
|
|
858
|
+
menu.style.maxHeight = '220px';
|
|
859
|
+
menu.style.overflowY = 'auto';
|
|
860
|
+
menu.style.display = 'none';
|
|
861
|
+
menu.style.borderRadius = '0 0 .375rem .375rem';
|
|
862
|
+
host.appendChild(menu);
|
|
863
|
+
|
|
864
|
+
let timer = null;
|
|
865
|
+
let busy = false;
|
|
866
|
+
|
|
867
|
+
function hide() { menu.style.display = 'none'; menu.innerHTML = ''; }
|
|
868
|
+
|
|
869
|
+
function show(hits) {
|
|
870
|
+
if (!hits || !hits.length) { hide(); return; }
|
|
871
|
+
menu.innerHTML = '';
|
|
872
|
+
hits.forEach((h) => {
|
|
873
|
+
const btn = document.createElement('button');
|
|
874
|
+
btn.type = 'button';
|
|
875
|
+
btn.className = 'onekite-autocomplete-item';
|
|
876
|
+
btn.textContent = h.username;
|
|
877
|
+
btn.style.display = 'block';
|
|
878
|
+
btn.style.width = '100%';
|
|
879
|
+
btn.style.textAlign = 'left';
|
|
880
|
+
btn.style.padding = '.35rem .5rem';
|
|
881
|
+
btn.style.border = '0';
|
|
882
|
+
btn.style.background = 'transparent';
|
|
883
|
+
btn.style.cursor = 'pointer';
|
|
884
|
+
btn.addEventListener('click', () => { inputEl.value = h.username; hide(); });
|
|
885
|
+
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.05)'; });
|
|
886
|
+
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
|
|
887
|
+
menu.appendChild(btn);
|
|
888
|
+
});
|
|
889
|
+
menu.style.display = 'block';
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function run(q) {
|
|
893
|
+
if (busy) return;
|
|
894
|
+
busy = true;
|
|
895
|
+
try {
|
|
896
|
+
const hits = await searchUsers(q, 8);
|
|
897
|
+
if (String(inputEl.value || '').trim() !== q) return;
|
|
898
|
+
show(hits);
|
|
899
|
+
} catch (e) {
|
|
900
|
+
hide();
|
|
901
|
+
} finally {
|
|
902
|
+
busy = false;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
inputEl.addEventListener('input', () => {
|
|
907
|
+
const q = String(inputEl.value || '').trim();
|
|
908
|
+
if (timer) clearTimeout(timer);
|
|
909
|
+
if (q.length < 2) { hide(); return; }
|
|
910
|
+
timer = setTimeout(() => run(q), 250);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
inputEl.addEventListener('focus', () => {
|
|
914
|
+
const q = String(inputEl.value || '').trim();
|
|
915
|
+
if (q.length >= 2) { if (timer) clearTimeout(timer); timer = setTimeout(() => run(q), 150); }
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
document.addEventListener('click', (e) => {
|
|
919
|
+
try { if (!host.contains(e.target)) hide(); } catch (err) {}
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
inputEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
|
|
923
|
+
}
|
|
924
|
+
|
|
824
925
|
function attachAddressAutocomplete(inputEl, onPick) {
|
|
825
926
|
if (!inputEl) return;
|
|
826
927
|
// Avoid double attach
|
|
@@ -1022,7 +1123,8 @@ function toDatetimeLocalValue(date) {
|
|
|
1022
1123
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1023
1124
|
}
|
|
1024
1125
|
|
|
1025
|
-
async function openReservationDialog(selectionInfo, items) {
|
|
1126
|
+
async function openReservationDialog(selectionInfo, items, opts) {
|
|
1127
|
+
const isValidatorMode = !!(opts && opts.isValidator);
|
|
1026
1128
|
const start = selectionInfo.start;
|
|
1027
1129
|
let end = selectionInfo.end;
|
|
1028
1130
|
|
|
@@ -1122,9 +1224,17 @@ function toDatetimeLocalValue(date) {
|
|
|
1122
1224
|
|
|
1123
1225
|
const endDisplay = endInclusiveForDisplay(start, end);
|
|
1124
1226
|
|
|
1227
|
+
const forUserHtml = isValidatorMode ? `
|
|
1228
|
+
<div class="mb-2 mt-1">
|
|
1229
|
+
<label style="font-size:13px; font-weight:600; margin-bottom:3px; display:block;">Pour (pseudo, vide = moi-même)</label>
|
|
1230
|
+
<input type="text" class="form-control form-control-sm" id="onekite-target-username" placeholder="Pseudo de l'adhérent" autocomplete="off">
|
|
1231
|
+
</div>
|
|
1232
|
+
` : '';
|
|
1233
|
+
|
|
1125
1234
|
const messageHtml = `
|
|
1126
1235
|
<div class="mb-2" id="onekite-period"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(endDisplay)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span></div>
|
|
1127
1236
|
${shortcutsHtml}
|
|
1237
|
+
${forUserHtml}
|
|
1128
1238
|
<div class="mb-2"><strong>Matériel</strong></div>
|
|
1129
1239
|
<div id="onekite-items" class="mb-2" style="max-height: 320px; overflow: auto; border: 1px solid var(--bs-border-color, #ddd); border-radius: 6px; padding: 6px;">
|
|
1130
1240
|
${rows}
|
|
@@ -1169,7 +1279,8 @@ function toDatetimeLocalValue(date) {
|
|
|
1169
1279
|
const total = (sum / 100) * days;
|
|
1170
1280
|
// Return the effective end date (exclusive) because duration shortcuts can
|
|
1171
1281
|
// change the range without updating the original FullCalendar selection.
|
|
1172
|
-
|
|
1282
|
+
const targetUsername = isValidatorMode ? ((document.getElementById('onekite-target-username') || {}).value || '').trim() : '';
|
|
1283
|
+
resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end), targetUsername });
|
|
1173
1284
|
},
|
|
1174
1285
|
},
|
|
1175
1286
|
},
|
|
@@ -1177,6 +1288,10 @@ function toDatetimeLocalValue(date) {
|
|
|
1177
1288
|
|
|
1178
1289
|
// live total update
|
|
1179
1290
|
setTimeout(() => {
|
|
1291
|
+
if (isValidatorMode) {
|
|
1292
|
+
attachUserAutocomplete(document.getElementById('onekite-target-username'));
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1180
1295
|
const totalEl = document.getElementById('onekite-total');
|
|
1181
1296
|
const periodEl = document.getElementById('onekite-period');
|
|
1182
1297
|
const daysEl = document.getElementById('onekite-days');
|
|
@@ -1290,6 +1405,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1290
1405
|
const canDeleteSpecial = !!caps.canDeleteSpecial;
|
|
1291
1406
|
const canCreateOuting = !!caps.canCreateOuting;
|
|
1292
1407
|
const canCreateReservation = !!caps.canCreateReservation;
|
|
1408
|
+
const isValidator = !!caps.isValidator;
|
|
1293
1409
|
const specialEventCategoryCid = parseInt(caps.specialEventCategoryCid, 10) || 0;
|
|
1294
1410
|
|
|
1295
1411
|
// Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
|
|
@@ -1380,17 +1496,19 @@ function toDatetimeLocalValue(date) {
|
|
|
1380
1496
|
}
|
|
1381
1497
|
} catch (e) {}
|
|
1382
1498
|
|
|
1383
|
-
// Business rule: nothing can be created in the past.
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1499
|
+
// Business rule: nothing can be created in the past (validators are exempt for regularization).
|
|
1500
|
+
if (!isValidator) {
|
|
1501
|
+
try {
|
|
1502
|
+
const startDateCheck = toLocalYmd(info.start);
|
|
1503
|
+
const todayCheck = toLocalYmd(new Date());
|
|
1504
|
+
if (startDateCheck < todayCheck) {
|
|
1505
|
+
lastDateRuleToastAt = Date.now();
|
|
1506
|
+
showAlert('error', "Impossible de créer pour une date passée.");
|
|
1507
|
+
try { calendar.unselect(); } catch (e) {}
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
} catch (e) {}
|
|
1511
|
+
}
|
|
1394
1512
|
|
|
1395
1513
|
function handleCreateError(e) {
|
|
1396
1514
|
const code = String((e && (e.status || e.message)) || '');
|
|
@@ -1437,22 +1555,24 @@ function toDatetimeLocalValue(date) {
|
|
|
1437
1555
|
showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
|
|
1438
1556
|
return;
|
|
1439
1557
|
}
|
|
1440
|
-
const chosen = await openReservationDialog(sel, items);
|
|
1558
|
+
const chosen = await openReservationDialog(sel, items, { isValidator });
|
|
1441
1559
|
if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
|
|
1442
1560
|
const startDate = toLocalYmd(sel.start);
|
|
1443
1561
|
const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
|
|
1444
|
-
const
|
|
1562
|
+
const reqPayload = {
|
|
1445
1563
|
title: (chosen && chosen.title) ? String(chosen.title) : '',
|
|
1446
1564
|
start: startDate,
|
|
1447
1565
|
end: endDate,
|
|
1448
1566
|
itemIds: chosen.itemIds,
|
|
1449
1567
|
itemNames: chosen.itemNames,
|
|
1450
1568
|
total: chosen.total,
|
|
1451
|
-
}
|
|
1569
|
+
};
|
|
1570
|
+
if (chosen.targetUsername) reqPayload.targetUsername = chosen.targetUsername;
|
|
1571
|
+
const resp = await requestReservation(reqPayload);
|
|
1452
1572
|
if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
|
|
1453
|
-
showAlert('success', 'Réservation confirmée.');
|
|
1573
|
+
showAlert('success', chosen.targetUsername ? `Réservation confirmée pour ${chosen.targetUsername}.` : 'Réservation confirmée.');
|
|
1454
1574
|
} else {
|
|
1455
|
-
showAlert('success', 'Demande envoyée (en attente de validation).');
|
|
1575
|
+
showAlert('success', chosen.targetUsername ? `Demande créée pour ${chosen.targetUsername}.` : 'Demande envoyée (en attente de validation).');
|
|
1456
1576
|
}
|
|
1457
1577
|
invalidateEventsCache();
|
|
1458
1578
|
scheduleRefetch(calendar);
|
|
@@ -1526,22 +1646,24 @@ function toDatetimeLocalValue(date) {
|
|
|
1526
1646
|
showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
|
|
1527
1647
|
return;
|
|
1528
1648
|
}
|
|
1529
|
-
const chosen = await openReservationDialog(sel, items);
|
|
1649
|
+
const chosen = await openReservationDialog(sel, items, { isValidator });
|
|
1530
1650
|
if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
|
|
1531
1651
|
const startDate = toLocalYmd(sel.start);
|
|
1532
1652
|
const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
|
|
1533
|
-
const
|
|
1653
|
+
const reqPayload = {
|
|
1534
1654
|
title: (chosen && chosen.title) ? String(chosen.title) : '',
|
|
1535
1655
|
start: startDate,
|
|
1536
1656
|
end: endDate,
|
|
1537
1657
|
itemIds: chosen.itemIds,
|
|
1538
1658
|
itemNames: chosen.itemNames,
|
|
1539
1659
|
total: chosen.total,
|
|
1540
|
-
}
|
|
1660
|
+
};
|
|
1661
|
+
if (chosen.targetUsername) reqPayload.targetUsername = chosen.targetUsername;
|
|
1662
|
+
const resp = await requestReservation(reqPayload);
|
|
1541
1663
|
if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
|
|
1542
|
-
showAlert('success', 'Réservation confirmée.');
|
|
1664
|
+
showAlert('success', chosen.targetUsername ? `Réservation confirmée pour ${chosen.targetUsername}.` : 'Réservation confirmée.');
|
|
1543
1665
|
} else {
|
|
1544
|
-
showAlert('success', 'Demande envoyée (en attente de validation).');
|
|
1666
|
+
showAlert('success', chosen.targetUsername ? `Demande créée pour ${chosen.targetUsername}.` : 'Demande envoyée (en attente de validation).');
|
|
1545
1667
|
}
|
|
1546
1668
|
invalidateEventsCache();
|
|
1547
1669
|
scheduleRefetch(calendar);
|
|
@@ -1614,8 +1736,9 @@ function toDatetimeLocalValue(date) {
|
|
|
1614
1736
|
longPressDelay: 300,
|
|
1615
1737
|
selectLongPressDelay: 300,
|
|
1616
1738
|
dayCellDidMount: function (arg) {
|
|
1617
|
-
// Visually disable past days for reservation creation rules
|
|
1618
|
-
//
|
|
1739
|
+
// Visually disable past days for reservation creation rules.
|
|
1740
|
+
// Validators are exempt (they can create past-dated reservations for regularization).
|
|
1741
|
+
if (isValidator) return;
|
|
1619
1742
|
try {
|
|
1620
1743
|
const cellDate = arg && arg.date ? new Date(arg.date) : null;
|
|
1621
1744
|
if (!cellDate) return;
|
|
@@ -130,8 +130,8 @@
|
|
|
130
130
|
|
|
131
131
|
<div class="mb-3">
|
|
132
132
|
<label class="form-label">IPs autorisées pour le webhook HelloAsso (csv)</label>
|
|
133
|
-
<input class="form-control" name="helloassoWebhookAllowedIps" placeholder="
|
|
134
|
-
<div class="form-text">
|
|
133
|
+
<input class="form-control" name="helloassoWebhookAllowedIps" placeholder="ex: 1.2.3.4, 5.6.7.8">
|
|
134
|
+
<div class="form-text">IPs autorisées à appeler le webhook (séparées par virgule). <strong>Si vide : toutes les IPs sont acceptées</strong> (recommandé — HelloAsso change ses IPs sans préavis). Ne remplis ce champ que si tu connais les IPs exactes de ton serveur HelloAsso.</div>
|
|
135
135
|
</div>
|
|
136
136
|
|
|
137
137
|
<div class="mb-3">
|
|
@@ -227,6 +227,17 @@
|
|
|
227
227
|
<p class="text-muted">Teste la récupération du token et la liste du matériel (catalogue).</p>
|
|
228
228
|
<button type="button" class="btn btn-secondary me-2" id="onekite-debug-run">Tester le chargement du matériel</button>
|
|
229
229
|
<pre id="onekite-debug-output" class="mt-3 p-3 border rounded onekite-debug-output" style="max-height: 360px; overflow: auto;"></pre>
|
|
230
|
+
|
|
231
|
+
<hr class="my-4" />
|
|
232
|
+
<h5>Récupération paiement manuelle</h5>
|
|
233
|
+
<p class="text-muted" style="max-width: 700px;">
|
|
234
|
+
Si un adhérent a payé via HelloAsso mais que le webhook n'a pas été reçu (IP bloquée, timeout, etc.), entre l'ID de réservation pour la marquer manuellement comme payée. Un email de confirmation est envoyé à l'adhérent.
|
|
235
|
+
</p>
|
|
236
|
+
<div class="d-flex gap-2 align-items-center">
|
|
237
|
+
<input type="text" class="form-control" id="onekite-mark-paid-rid" placeholder="ID de réservation (ex: 1714234567890)" style="max-width: 400px;">
|
|
238
|
+
<button type="button" class="btn btn-warning" id="onekite-mark-paid-btn">Marquer comme payée</button>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="form-text mt-2">L'ID de réservation est visible dans les logs NodeBB ou dans l'URL d'approbation.</div>
|
|
230
241
|
</div>
|
|
231
242
|
|
|
232
243
|
<div class="tab-pane fade" id="onekite-tab-accounting" role="tabpanel">
|