nodebb-plugin-onekite-calendar 2.0.71 → 2.0.73
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 -4
- package/lib/api.js +79 -73
- package/lib/db.js +3 -16
- package/lib/helloassoWebhook.js +128 -157
- package/lib/scheduler.js +1 -4
- package/lib/shared.js +2 -2
- package/library.js +21 -22
- package/package.json +1 -1
- package/public/admin.js +35 -0
- package/public/client.js +43 -25
- package/templates/admin/plugins/calendar-onekite.tpl +13 -2
package/lib/admin.js
CHANGED
|
@@ -20,10 +20,6 @@ const {
|
|
|
20
20
|
autoFormSlugForYear,
|
|
21
21
|
} = shared;
|
|
22
22
|
|
|
23
|
-
// Kept for local compatibility in accounting helper
|
|
24
|
-
function baseUrl() { return forumBaseUrl(); }
|
|
25
|
-
|
|
26
|
-
|
|
27
23
|
const dbLayer = require('./db');
|
|
28
24
|
const helloasso = require('./helloasso');
|
|
29
25
|
|
|
@@ -200,6 +196,67 @@ admin.approveReservation = async function (req, res) {
|
|
|
200
196
|
res.json({ ok: true, paymentUrl: paymentUrl || null });
|
|
201
197
|
};
|
|
202
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
|
+
|
|
203
260
|
admin.refuseReservation = async function (req, res) {
|
|
204
261
|
const rid = req.params.rid;
|
|
205
262
|
const r = await dbLayer.getReservation(rid);
|
package/lib/api.js
CHANGED
|
@@ -361,12 +361,6 @@ function eventsForOuting(o) {
|
|
|
361
361
|
|
|
362
362
|
const api = {};
|
|
363
363
|
|
|
364
|
-
// baseUrl, hmacSecret, signCalendarLink, ymdToCompact, dtToGCalUtc,
|
|
365
|
-
// buildCalendarLinks are imported from shared.js
|
|
366
|
-
function baseUrl() {
|
|
367
|
-
return forumBaseUrl();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
364
|
function computeEtag(payload) {
|
|
371
365
|
// Weak ETag is fine here: it is only used to skip identical JSON payloads.
|
|
372
366
|
const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
|
|
@@ -773,19 +767,19 @@ api.getCapabilities = async function (req, res) {
|
|
|
773
767
|
specialEventCategoryCid: 0,
|
|
774
768
|
});
|
|
775
769
|
}
|
|
776
|
-
const [canMod, canSpecialC, canSpecialD,
|
|
770
|
+
const [canMod, canSpecialC, canSpecialD, canReq] = await Promise.all([
|
|
777
771
|
canValidate(uid, settings),
|
|
778
772
|
canCreateSpecial(uid, settings),
|
|
779
773
|
canDeleteSpecial(uid, settings),
|
|
780
774
|
canRequest(uid, settings, Date.now()),
|
|
781
|
-
canRequest(uid, settings, Date.now()),
|
|
782
775
|
]);
|
|
783
776
|
res.json({
|
|
784
777
|
canModerate: canMod,
|
|
778
|
+
isValidator: canMod,
|
|
785
779
|
canCreateSpecial: canSpecialC,
|
|
786
780
|
canDeleteSpecial: canSpecialD,
|
|
787
|
-
canCreateOuting:
|
|
788
|
-
canCreateReservation:
|
|
781
|
+
canCreateOuting: canReq,
|
|
782
|
+
canCreateReservation: canReq,
|
|
789
783
|
specialEventCategoryCid: parseInt(settings && settings.specialEventCategoryId, 10) || 0,
|
|
790
784
|
});
|
|
791
785
|
};
|
|
@@ -1231,37 +1225,20 @@ api.createReservation = async function (req, res) {
|
|
|
1231
1225
|
const startDate = (typeof startRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(startRaw.trim())) ? startRaw.trim() : null;
|
|
1232
1226
|
const endDate = (typeof endRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(endRaw.trim())) ? endRaw.trim() : null;
|
|
1233
1227
|
|
|
1234
|
-
// Validators can create "free" reservations that skip the payment workflow.
|
|
1235
|
-
// However, long rentals should follow the normal paid workflow.
|
|
1236
|
-
// Setting: validatorFreeMaxDays (days, endDate exclusive). If empty/0 => always free.
|
|
1237
|
-
let validatorFreeMaxDays = 0;
|
|
1238
|
-
try {
|
|
1239
|
-
const v = parseInt(String(settings.validatorFreeMaxDays || '').trim(), 10);
|
|
1240
|
-
validatorFreeMaxDays = Number.isFinite(v) ? v : 0;
|
|
1241
|
-
} catch (e) {
|
|
1242
|
-
validatorFreeMaxDays = 0;
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
1228
|
// Reliable calendar-day count (endDate is EXCLUSIVE)
|
|
1246
1229
|
const nbDays = (startDate && endDate) ? (calendarDaysExclusiveYmd(startDate, endDate) || 1) : Math.max(1, Math.round((end - start) / (24 * 60 * 60 * 1000)));
|
|
1247
1230
|
|
|
1248
|
-
//
|
|
1249
|
-
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();
|
|
1250
1235
|
|
|
1251
|
-
//
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
if (start < today0ts) {
|
|
1258
|
-
return res.status(400).json({
|
|
1259
|
-
error: 'date-too-soon',
|
|
1260
|
-
message: "Impossible de réserver pour une date passée.",
|
|
1261
|
-
});
|
|
1262
|
-
}
|
|
1263
|
-
} catch (e) {
|
|
1264
|
-
// 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
|
+
});
|
|
1265
1242
|
}
|
|
1266
1243
|
|
|
1267
1244
|
// Support both legacy single itemId and new itemIds[] payload
|
|
@@ -1318,16 +1295,53 @@ api.createReservation = async function (req, res) {
|
|
|
1318
1295
|
const now = Date.now();
|
|
1319
1296
|
const rid = crypto.randomUUID();
|
|
1320
1297
|
|
|
1321
|
-
// Snapshot username for
|
|
1322
|
-
let
|
|
1298
|
+
// Snapshot creator (validator) username for audit trail
|
|
1299
|
+
let creatorUsername = null;
|
|
1323
1300
|
try {
|
|
1324
|
-
|
|
1301
|
+
creatorUsername = await user.getUserField(uid, 'username');
|
|
1325
1302
|
} catch (e) {}
|
|
1326
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
|
+
|
|
1327
1339
|
const resv = {
|
|
1328
1340
|
rid,
|
|
1329
|
-
uid,
|
|
1330
|
-
username:
|
|
1341
|
+
uid: targetUid,
|
|
1342
|
+
username: targetUsername || null,
|
|
1343
|
+
createdBy: uid,
|
|
1344
|
+
createdByUsername: creatorUsername || null,
|
|
1331
1345
|
itemIds,
|
|
1332
1346
|
itemNames: itemNames.length ? itemNames : itemIds,
|
|
1333
1347
|
// keep legacy fields for backward compatibility
|
|
@@ -1337,19 +1351,17 @@ api.createReservation = async function (req, res) {
|
|
|
1337
1351
|
end,
|
|
1338
1352
|
startDate,
|
|
1339
1353
|
endDate,
|
|
1340
|
-
status: isValidatorFree ? 'paid' : 'pending',
|
|
1354
|
+
status: (isValidatorFree || isRegularization) ? 'paid' : 'pending',
|
|
1341
1355
|
createdAt: now,
|
|
1342
|
-
paidAt: isValidatorFree ? now : 0,
|
|
1343
|
-
approvedBy: isValidatorFree ? uid : 0,
|
|
1344
|
-
|
|
1345
|
-
// Validator self-reservations are FREE (no payment required) and must not be
|
|
1346
|
-
// counted as revenue.
|
|
1356
|
+
paidAt: (isValidatorFree || isRegularization) ? now : 0,
|
|
1357
|
+
approvedBy: (isValidatorFree || isRegularization) ? uid : 0,
|
|
1358
|
+
approvedByUsername: (isValidatorFree || isRegularization) ? (creatorUsername || '') : '',
|
|
1347
1359
|
isFree: !!isValidatorFree,
|
|
1348
1360
|
total: isValidatorFree ? 0 : (isNaN(total) ? 0 : total),
|
|
1361
|
+
manuallyPaid: isRegularization ? true : undefined,
|
|
1349
1362
|
};
|
|
1350
1363
|
|
|
1351
|
-
//
|
|
1352
|
-
// 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.
|
|
1353
1365
|
|
|
1354
1366
|
// Save
|
|
1355
1367
|
await dbLayer.saveReservation(resv);
|
|
@@ -1358,12 +1370,17 @@ api.createReservation = async function (req, res) {
|
|
|
1358
1370
|
realtime.emitCalendarUpdated({ kind: 'reservation', action: 'created', rid: resv.rid, status: resv.status });
|
|
1359
1371
|
|
|
1360
1372
|
// Audit
|
|
1361
|
-
|
|
1373
|
+
const auditAction = isValidatorFree ? 'reservation_self_checked'
|
|
1374
|
+
: isRegularization ? 'reservation_manually_paid'
|
|
1375
|
+
: 'reservation_requested';
|
|
1376
|
+
await auditLog(auditAction, uid, {
|
|
1362
1377
|
targetType: 'reservation',
|
|
1363
1378
|
targetId: String(resv.rid),
|
|
1364
|
-
uid: Number(
|
|
1365
|
-
requesterUid: Number(
|
|
1366
|
-
requesterUsername:
|
|
1379
|
+
uid: Number(targetUid) || 0,
|
|
1380
|
+
requesterUid: Number(targetUid) || 0,
|
|
1381
|
+
requesterUsername: targetUsername || '',
|
|
1382
|
+
createdBy: Number(uid) || 0,
|
|
1383
|
+
createdByUsername: creatorUsername || '',
|
|
1367
1384
|
itemIds: resv.itemIds || [],
|
|
1368
1385
|
itemNames: resv.itemNames || [],
|
|
1369
1386
|
startDate: resv.startDate || '',
|
|
@@ -1371,12 +1388,12 @@ api.createReservation = async function (req, res) {
|
|
|
1371
1388
|
status: resv.status,
|
|
1372
1389
|
});
|
|
1373
1390
|
|
|
1374
|
-
if (!isValidatorFree) {
|
|
1391
|
+
if (!isValidatorFree && !isRegularization) {
|
|
1375
1392
|
|
|
1376
1393
|
|
|
1377
1394
|
// Notify groups by email (NodeBB emailer config)
|
|
1378
1395
|
try {
|
|
1379
|
-
const notifyGroups = (settings.notifyGroups
|
|
1396
|
+
const notifyGroups = normalizeAllowedGroups(settings.notifyGroups);
|
|
1380
1397
|
if (notifyGroups.length) {
|
|
1381
1398
|
const requester = await user.getUserFields(uid, ['username', 'email']);
|
|
1382
1399
|
const itemsLabel = (resv.itemNames || []).join(', ');
|
|
@@ -1445,7 +1462,7 @@ api.createReservation = async function (req, res) {
|
|
|
1445
1462
|
|
|
1446
1463
|
}
|
|
1447
1464
|
|
|
1448
|
-
res.json({ ok: true, rid, status: resv.status, autoPaid: !!isValidatorFree });
|
|
1465
|
+
res.json({ ok: true, rid, status: resv.status, autoPaid: !!(isValidatorFree || isRegularization) });
|
|
1449
1466
|
};
|
|
1450
1467
|
|
|
1451
1468
|
// Validator actions (from calendar popup)
|
|
@@ -1601,18 +1618,7 @@ api.approveReservation = async function (req, res) {
|
|
|
1601
1618
|
pickupLon: r.pickupLon || '',
|
|
1602
1619
|
mapUrl,
|
|
1603
1620
|
paymentUrl: r.paymentUrl || '',
|
|
1604
|
-
|
|
1605
|
-
type: 'reservation',
|
|
1606
|
-
id: String(r.rid),
|
|
1607
|
-
uid: requesterUid,
|
|
1608
|
-
title: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Location - ${r.itemNames.join(', ')}` : 'Location',
|
|
1609
|
-
details: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Matériel: ${r.itemNames.join(', ')}` : (r.itemName ? `Matériel: ${r.itemName}` : ''),
|
|
1610
|
-
location: r.pickupAddress || '',
|
|
1611
|
-
allDay: true,
|
|
1612
|
-
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),
|
|
1613
|
-
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),
|
|
1614
|
-
})).icsUrl,
|
|
1615
|
-
googleCalUrl: (buildCalendarLinks({
|
|
1621
|
+
...buildCalendarLinks({
|
|
1616
1622
|
type: 'reservation',
|
|
1617
1623
|
id: String(r.rid),
|
|
1618
1624
|
uid: requesterUid,
|
|
@@ -1622,7 +1628,7 @@ api.approveReservation = async function (req, res) {
|
|
|
1622
1628
|
allDay: true,
|
|
1623
1629
|
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),
|
|
1624
1630
|
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),
|
|
1625
|
-
})
|
|
1631
|
+
}),
|
|
1626
1632
|
validatedBy: r.approvedByUsername || '',
|
|
1627
1633
|
validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
|
|
1628
1634
|
});
|
|
@@ -1972,7 +1978,7 @@ api.getIcs = async function (req, res) {
|
|
|
1972
1978
|
allDay: true,
|
|
1973
1979
|
startYmd,
|
|
1974
1980
|
endYmd,
|
|
1975
|
-
url: `${
|
|
1981
|
+
url: `${forumBaseUrl()}/calendar`,
|
|
1976
1982
|
});
|
|
1977
1983
|
} else if (type === 'special') {
|
|
1978
1984
|
const ev = await dbLayer.getSpecialEvent(id);
|
|
@@ -1987,7 +1993,7 @@ api.getIcs = async function (req, res) {
|
|
|
1987
1993
|
allDay: false,
|
|
1988
1994
|
start,
|
|
1989
1995
|
end,
|
|
1990
|
-
url: `${
|
|
1996
|
+
url: `${forumBaseUrl()}/calendar`,
|
|
1991
1997
|
});
|
|
1992
1998
|
} else if (type === 'outing') {
|
|
1993
1999
|
const o = await dbLayer.getOuting(id);
|
|
@@ -2002,7 +2008,7 @@ api.getIcs = async function (req, res) {
|
|
|
2002
2008
|
allDay: false,
|
|
2003
2009
|
start,
|
|
2004
2010
|
end,
|
|
2005
|
-
url: `${
|
|
2011
|
+
url: `${forumBaseUrl()}/calendar`,
|
|
2006
2012
|
});
|
|
2007
2013
|
} else {
|
|
2008
2014
|
return res.status(400).send('unknown-type');
|
package/lib/db.js
CHANGED
|
@@ -21,19 +21,6 @@ const KEY_MAINTENANCE_ZSET = 'calendar-onekite:maintenance:itemIds';
|
|
|
21
21
|
const KEY_AUDIT_ZSET = (year) => `calendar-onekite:audit:${year}`;
|
|
22
22
|
const KEY_AUDIT_OBJ = (id) => `calendar-onekite:audit:entry:${id}`;
|
|
23
23
|
|
|
24
|
-
// Helpers
|
|
25
|
-
function reservationKey(rid) {
|
|
26
|
-
return KEY_OBJ(rid);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function specialKey(eid) {
|
|
30
|
-
return KEY_SPECIAL_OBJ(eid);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function outingKey(oid) {
|
|
34
|
-
return KEY_OUTING_OBJ(oid);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
24
|
async function getReservation(rid) {
|
|
38
25
|
return await db.getObject(KEY_OBJ(rid));
|
|
39
26
|
}
|
|
@@ -45,7 +32,7 @@ async function getReservation(rid) {
|
|
|
45
32
|
async function getReservations(rids) {
|
|
46
33
|
const ids = Array.isArray(rids) ? rids.filter(Boolean) : [];
|
|
47
34
|
if (!ids.length) return [];
|
|
48
|
-
const keys = ids.map(
|
|
35
|
+
const keys = ids.map(KEY_OBJ);
|
|
49
36
|
const rows = await db.getObjects(keys);
|
|
50
37
|
// Ensure rid is present even if older objects were missing it.
|
|
51
38
|
return (rows || []).map((row, idx) => {
|
|
@@ -198,7 +185,7 @@ module.exports = {
|
|
|
198
185
|
getSpecialEvents: async (eids) => {
|
|
199
186
|
const ids = Array.isArray(eids) ? eids.filter(Boolean) : [];
|
|
200
187
|
if (!ids.length) return [];
|
|
201
|
-
const keys = ids.map(
|
|
188
|
+
const keys = ids.map(KEY_SPECIAL_OBJ);
|
|
202
189
|
const rows = await db.getObjects(keys);
|
|
203
190
|
return (rows || []).map((row, idx) => {
|
|
204
191
|
if (!row) return null;
|
|
@@ -225,7 +212,7 @@ module.exports = {
|
|
|
225
212
|
getOutings: async (oids) => {
|
|
226
213
|
const ids = Array.isArray(oids) ? oids.filter(Boolean) : [];
|
|
227
214
|
if (!ids.length) return [];
|
|
228
|
-
const keys = ids.map(
|
|
215
|
+
const keys = ids.map(KEY_OUTING_OBJ);
|
|
229
216
|
const rows = await db.getObjects(keys);
|
|
230
217
|
return (rows || []).map((row, idx) => {
|
|
231
218
|
if (!row) return null;
|
package/lib/helloassoWebhook.js
CHANGED
|
@@ -1,21 +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;
|
|
16
13
|
|
|
17
|
-
|
|
14
|
+
const SETTINGS_KEY = 'calendar-onekite';
|
|
15
|
+
const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
|
|
18
16
|
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
19
20
|
|
|
20
21
|
function buildReservationCalendarLinks(r) {
|
|
21
22
|
const startYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate)))
|
|
@@ -53,19 +54,21 @@ async function auditLog(action, actorUid, payload) {
|
|
|
53
54
|
} catch (e) {}
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|
|
62
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.
|
|
63
67
|
function getReservationIdFromPayload(payload) {
|
|
64
68
|
try {
|
|
65
69
|
const data = payload && payload.data ? payload.data : null;
|
|
66
70
|
if (!data) return null;
|
|
67
71
|
|
|
68
|
-
// HelloAsso commonly uses "metadata" for checkout-intents/payments.
|
|
69
72
|
const metaCandidates = [
|
|
70
73
|
data.meta,
|
|
71
74
|
data.metadata,
|
|
@@ -77,56 +80,81 @@ function getReservationIdFromPayload(payload) {
|
|
|
77
80
|
if (typeof metaObj === 'object' && !Array.isArray(metaObj) && metaObj.reservationId) {
|
|
78
81
|
return String(metaObj.reservationId);
|
|
79
82
|
}
|
|
80
|
-
// Some
|
|
83
|
+
// Some implementations send metadata as an array of { key, value } pairs
|
|
81
84
|
if (Array.isArray(metaObj)) {
|
|
82
85
|
const found = metaObj.find((x) => x && (x.key === 'reservationId' || x.name === 'reservationId'));
|
|
83
86
|
if (found && (found.value || found.val)) return String(found.value || found.val);
|
|
84
87
|
}
|
|
85
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;
|
|
86
121
|
} catch (e) {
|
|
87
|
-
|
|
122
|
+
return false;
|
|
88
123
|
}
|
|
89
|
-
return null;
|
|
90
124
|
}
|
|
91
125
|
|
|
92
|
-
async function
|
|
93
|
-
if (!paymentId) return
|
|
126
|
+
async function alreadyProcessed(paymentId) {
|
|
127
|
+
if (!paymentId) return false;
|
|
94
128
|
try {
|
|
95
|
-
|
|
96
|
-
env: settings.helloassoEnv || 'live',
|
|
97
|
-
clientId: settings.helloassoClientId,
|
|
98
|
-
clientSecret: settings.helloassoClientSecret,
|
|
99
|
-
});
|
|
100
|
-
if (!token) return null;
|
|
101
|
-
const details = await helloasso.getPaymentDetails({
|
|
102
|
-
env: settings.helloassoEnv || 'live',
|
|
103
|
-
token,
|
|
104
|
-
paymentId,
|
|
105
|
-
});
|
|
106
|
-
if (!details) return null;
|
|
107
|
-
// Reuse the same extraction logic on the payment details object.
|
|
108
|
-
return getReservationIdFromPayload({ data: details });
|
|
129
|
+
return await db.isSetMember(PROCESSED_KEY, String(paymentId));
|
|
109
130
|
} catch (e) {
|
|
110
|
-
return
|
|
131
|
+
return false;
|
|
111
132
|
}
|
|
112
133
|
}
|
|
113
134
|
|
|
114
|
-
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) {
|
|
115
145
|
if (!checkoutIntentId) return null;
|
|
116
146
|
try {
|
|
117
|
-
// First, try a direct mapping stored when the checkout intent was created.
|
|
118
147
|
const mapped = await db.getObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId));
|
|
119
148
|
if (mapped) return String(mapped);
|
|
120
149
|
|
|
121
|
-
// If no mapping exists (older reservations), query HelloAsso for intent details to read metadata.
|
|
122
150
|
const token = await helloasso.getAccessToken({
|
|
123
|
-
env: settings.helloassoEnv || '
|
|
151
|
+
env: settings.helloassoEnv || 'prod',
|
|
124
152
|
clientId: settings.helloassoClientId,
|
|
125
153
|
clientSecret: settings.helloassoClientSecret,
|
|
126
154
|
});
|
|
127
155
|
if (!token) return null;
|
|
128
156
|
const details = await helloasso.getCheckoutIntentDetails({
|
|
129
|
-
env: settings.helloassoEnv || '
|
|
157
|
+
env: settings.helloassoEnv || 'prod',
|
|
130
158
|
token,
|
|
131
159
|
organizationSlug: settings.helloassoOrganizationSlug,
|
|
132
160
|
checkoutIntentId,
|
|
@@ -134,171 +162,119 @@ async function tryRecoverReservationIdFromCheckoutIntent(settings, checkoutInten
|
|
|
134
162
|
if (!details) return null;
|
|
135
163
|
const rid = getReservationIdFromPayload({ data: details });
|
|
136
164
|
if (rid) {
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid));
|
|
140
|
-
} catch (e) {}
|
|
165
|
+
try { await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid)); } catch (e) {}
|
|
141
166
|
return String(rid);
|
|
142
167
|
}
|
|
143
|
-
} catch (e) {
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
168
|
+
} catch (e) {}
|
|
146
169
|
return null;
|
|
147
170
|
}
|
|
148
171
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return await db.isSetMember(PROCESSED_KEY, String(paymentId));
|
|
153
|
-
} catch (e) {
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async function markProcessed(paymentId) {
|
|
159
|
-
if (!paymentId) return;
|
|
160
|
-
try {
|
|
161
|
-
await db.setAdd(PROCESSED_KEY, String(paymentId));
|
|
162
|
-
} catch (e) {
|
|
163
|
-
// ignore
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
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;
|
|
168
175
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// Order payloads may not carry a payment-like "state" field; accept if missing,
|
|
183
|
-
// but still require a recognizable "paid" state when provided.
|
|
184
|
-
return !state || okState;
|
|
185
|
-
}
|
|
186
|
-
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 });
|
|
187
189
|
} catch (e) {
|
|
188
|
-
return
|
|
190
|
+
return null;
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (!data) return null;
|
|
196
|
-
// Common locations based on HelloAsso docs and community reports:
|
|
197
|
-
// - Order event often contains checkoutIntentId at root of data
|
|
198
|
-
// - Return/back redirects contain checkoutIntentId in query params (not here)
|
|
199
|
-
const candidates = [
|
|
200
|
-
data.checkoutIntentId,
|
|
201
|
-
data.checkoutIntent && (data.checkoutIntent.id || data.checkoutIntent.checkoutIntentId),
|
|
202
|
-
data.order && (data.order.checkoutIntentId || (data.order.checkoutIntent && data.order.checkoutIntent.id)),
|
|
203
|
-
].filter(Boolean);
|
|
204
|
-
if (candidates.length) return String(candidates[0]);
|
|
205
|
-
} catch (e) {}
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Webhook handler
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
208
197
|
|
|
209
|
-
/**
|
|
210
|
-
* Hardened HelloAsso webhook handler.
|
|
211
|
-
* - Requires x-ha-signature (HMAC SHA-256) verification.
|
|
212
|
-
* - Only accepts eventType=Payment with state=Confirmed.
|
|
213
|
-
* - Provides basic replay protection using the payment id.
|
|
214
|
-
*
|
|
215
|
-
* This handler is "safe" by default: if we cannot verify the signature,
|
|
216
|
-
* we refuse the request.
|
|
217
|
-
*/
|
|
218
198
|
async function handler(req, res, next) {
|
|
219
199
|
try {
|
|
220
|
-
if (req.method === 'GET') {
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
if (req.method !== 'POST') {
|
|
224
|
-
return res.status(405).json({ ok: false, error: 'method-not-allowed' });
|
|
225
|
-
}
|
|
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' });
|
|
226
202
|
|
|
227
203
|
const settings = await meta.settings.get(SETTINGS_KEY);
|
|
228
204
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
const allowedIps =
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
})();
|
|
241
|
-
|
|
242
|
-
if (allowedIps.length && clientIp && !allowedIps.includes(clientIp)) {
|
|
243
|
-
// eslint-disable-next-line no-console
|
|
244
|
-
console.warn('[calendar-onekite] HelloAsso webhook blocked by IP allowlist', { ip: clientIp, allowed: allowedIps });
|
|
245
|
-
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
|
+
}
|
|
246
216
|
}
|
|
247
217
|
|
|
248
|
-
// At this point, the payload is trusted.
|
|
249
218
|
const payload = req.body;
|
|
250
219
|
|
|
251
220
|
if (!isConfirmedPayment(payload)) {
|
|
252
|
-
// Acknowledge but ignore other event types/states.
|
|
253
221
|
return res.json({ ok: true, ignored: true });
|
|
254
222
|
}
|
|
255
223
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
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
|
+
});
|
|
262
235
|
return res.json({ ok: true, ignored: true, incompletePayment: true });
|
|
263
236
|
}
|
|
264
237
|
|
|
265
238
|
const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId) : null;
|
|
266
|
-
// If we can't identify the payment, acknowledge and ignore (prevents accidental crashes).
|
|
267
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) : [] });
|
|
268
242
|
return res.json({ ok: true, ignored: true, missingPaymentId: true });
|
|
269
243
|
}
|
|
244
|
+
|
|
270
245
|
if (await alreadyProcessed(paymentId)) {
|
|
271
246
|
return res.json({ ok: true, duplicate: true });
|
|
272
247
|
}
|
|
273
248
|
|
|
274
|
-
|
|
249
|
+
// Resolve the reservation ID: try metadata first, then checkoutIntentId mapping, then API call.
|
|
275
250
|
let resolvedRid = rid;
|
|
276
|
-
const checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
|
|
277
251
|
if (!resolvedRid && checkoutIntentId) {
|
|
278
|
-
|
|
279
|
-
resolvedRid = await tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId);
|
|
252
|
+
resolvedRid = await tryRecoverRidFromCheckoutIntent(settings, checkoutIntentId);
|
|
280
253
|
}
|
|
281
|
-
if (!resolvedRid
|
|
282
|
-
|
|
283
|
-
resolvedRid = await tryRecoverReservationIdFromPayment(settings, paymentId);
|
|
254
|
+
if (!resolvedRid) {
|
|
255
|
+
resolvedRid = await tryRecoverRidFromPayment(settings, paymentId);
|
|
284
256
|
}
|
|
285
257
|
if (!resolvedRid) {
|
|
286
258
|
// eslint-disable-next-line no-console
|
|
287
|
-
console.warn('[calendar-onekite] HelloAsso webhook
|
|
288
|
-
// 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.
|
|
289
261
|
return res.json({ ok: true, processed: false, missingReservationId: true });
|
|
290
262
|
}
|
|
291
263
|
|
|
292
264
|
const r = await dbLayer.getReservation(resolvedRid);
|
|
293
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 });
|
|
294
268
|
await markProcessed(paymentId);
|
|
295
269
|
return res.json({ ok: true, processed: true, reservationNotFound: true });
|
|
296
270
|
}
|
|
297
271
|
|
|
298
|
-
//
|
|
272
|
+
// eslint-disable-next-line no-console
|
|
273
|
+
console.info('[calendar-onekite] HelloAsso webhook: marking reservation paid', { rid: resolvedRid, prevStatus: r.status, paymentId });
|
|
274
|
+
|
|
299
275
|
r.status = 'paid';
|
|
300
276
|
r.paidAt = Date.now();
|
|
301
|
-
r.paymentId =
|
|
277
|
+
r.paymentId = String(paymentId);
|
|
302
278
|
if (payload.data && payload.data.paymentReceiptUrl) {
|
|
303
279
|
r.paymentReceiptUrl = String(payload.data.paymentReceiptUrl);
|
|
304
280
|
}
|
|
@@ -313,13 +289,11 @@ async function handler(req, res, next) {
|
|
|
313
289
|
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
314
290
|
startDate: r.startDate || '',
|
|
315
291
|
endDate: r.endDate || '',
|
|
316
|
-
paymentId: r.paymentId
|
|
292
|
+
paymentId: r.paymentId,
|
|
317
293
|
});
|
|
318
294
|
|
|
319
|
-
// Real-time notify: refresh calendars for all viewers
|
|
320
295
|
realtime.emitCalendarUpdated({ kind: 'reservation', action: 'paid', rid: String(r.rid), status: r.status });
|
|
321
296
|
|
|
322
|
-
// Notify requester
|
|
323
297
|
const requesterUid = parseInt(r.uid, 10);
|
|
324
298
|
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
325
299
|
if (requesterUid) {
|
|
@@ -343,7 +317,6 @@ async function handler(req, res, next) {
|
|
|
343
317
|
});
|
|
344
318
|
}
|
|
345
319
|
|
|
346
|
-
// Discord webhook (optional)
|
|
347
320
|
try {
|
|
348
321
|
await discord.notifyPaymentReceived(settings, {
|
|
349
322
|
rid: r.rid,
|
|
@@ -364,6 +337,4 @@ async function handler(req, res, next) {
|
|
|
364
337
|
}
|
|
365
338
|
}
|
|
366
339
|
|
|
367
|
-
module.exports = {
|
|
368
|
-
handler,
|
|
369
|
-
};
|
|
340
|
+
module.exports = { handler };
|
package/lib/scheduler.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const nconf = require.main.require('nconf');
|
|
4
4
|
const db = require.main.require('./src/database');
|
|
5
|
+
const user = require.main.require('./src/user');
|
|
5
6
|
const dbLayer = require('./db');
|
|
6
7
|
const discord = require('./discord');
|
|
7
8
|
const realtime = require('./realtime');
|
|
@@ -109,8 +110,6 @@ async function expirePending(preIds, preReservations) {
|
|
|
109
110
|
const validatorReminderMins = parseInt(getSetting(settings, 'validatorReminderMinutesPending', '0'), 10) || 0;
|
|
110
111
|
const now = Date.now();
|
|
111
112
|
|
|
112
|
-
const user = require.main.require('./src/user');
|
|
113
|
-
|
|
114
113
|
const adminUrl = (() => {
|
|
115
114
|
const base = forumBaseUrl();
|
|
116
115
|
return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
|
|
@@ -214,8 +213,6 @@ async function processAwaitingPayment(preIds, preReservations) {
|
|
|
214
213
|
const ids = preIds || await dbLayer.listAllReservationIds(5000);
|
|
215
214
|
if (!ids || !ids.length) return;
|
|
216
215
|
|
|
217
|
-
const user = require.main.require('./src/user');
|
|
218
|
-
|
|
219
216
|
const adminUrl = (() => {
|
|
220
217
|
const base = forumBaseUrl();
|
|
221
218
|
return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
|
package/lib/shared.js
CHANGED
|
@@ -23,8 +23,8 @@ function forumBaseUrl() {
|
|
|
23
23
|
// Try meta.config first (runtime-accurate), then nconf fallback.
|
|
24
24
|
let base = '';
|
|
25
25
|
try {
|
|
26
|
-
base = (meta && meta.config &&
|
|
27
|
-
? String(meta.config.url
|
|
26
|
+
base = (meta && meta.config && meta.config.url)
|
|
27
|
+
? String(meta.config.url)
|
|
28
28
|
: '';
|
|
29
29
|
} catch (_) { /* ignore */ }
|
|
30
30
|
if (!base) {
|
package/library.js
CHANGED
|
@@ -101,28 +101,27 @@ Plugin.init = async function (params) {
|
|
|
101
101
|
router.get('/plugins/calendar-onekite/ics/:type/:id', api.getIcs);
|
|
102
102
|
|
|
103
103
|
// Admin API (JSON)
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
});
|
|
104
|
+
const adminBase = '/api/v3/admin/plugins/calendar-onekite';
|
|
105
|
+
|
|
106
|
+
router.get(`${adminBase}/settings`, ...adminMws, admin.getSettings);
|
|
107
|
+
router.put(`${adminBase}/settings`, ...adminMws, admin.saveSettings);
|
|
108
|
+
|
|
109
|
+
router.get(`${adminBase}/pending`, ...adminMws, admin.listPending);
|
|
110
|
+
router.put(`${adminBase}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
|
|
111
|
+
router.put(`${adminBase}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
|
|
112
|
+
router.put(`${adminBase}/reservations/:rid/mark-paid`, ...adminMws, admin.markReservationPaid);
|
|
113
|
+
|
|
114
|
+
router.post(`${adminBase}/purge`, ...adminMws, admin.purgeByYear);
|
|
115
|
+
router.get(`${adminBase}/debug`, ...adminMws, admin.debugHelloAsso);
|
|
116
|
+
// Accounting / exports
|
|
117
|
+
router.get(`${adminBase}/accounting`, ...adminMws, admin.getAccounting);
|
|
118
|
+
router.get(`${adminBase}/accounting.csv`, ...adminMws, admin.exportAccountingCsv);
|
|
119
|
+
router.post(`${adminBase}/accounting/purge`, ...adminMws, admin.purgeAccounting);
|
|
120
|
+
|
|
121
|
+
// Purge special events by year
|
|
122
|
+
router.post(`${adminBase}/special-events/purge`, ...adminMws, admin.purgeSpecialEventsByYear);
|
|
123
|
+
// Purge outings by year
|
|
124
|
+
router.post(`${adminBase}/outings/purge`, ...adminMws, admin.purgeOutingsByYear);
|
|
126
125
|
|
|
127
126
|
// HelloAsso callback endpoint (hardened)
|
|
128
127
|
// - Only accepts POST
|
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
|
@@ -1022,7 +1022,8 @@ function toDatetimeLocalValue(date) {
|
|
|
1022
1022
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1023
1023
|
}
|
|
1024
1024
|
|
|
1025
|
-
async function openReservationDialog(selectionInfo, items) {
|
|
1025
|
+
async function openReservationDialog(selectionInfo, items, opts) {
|
|
1026
|
+
const isValidatorMode = !!(opts && opts.isValidator);
|
|
1026
1027
|
const start = selectionInfo.start;
|
|
1027
1028
|
let end = selectionInfo.end;
|
|
1028
1029
|
|
|
@@ -1122,9 +1123,17 @@ function toDatetimeLocalValue(date) {
|
|
|
1122
1123
|
|
|
1123
1124
|
const endDisplay = endInclusiveForDisplay(start, end);
|
|
1124
1125
|
|
|
1126
|
+
const forUserHtml = isValidatorMode ? `
|
|
1127
|
+
<div class="mb-2 mt-1">
|
|
1128
|
+
<label style="font-size:13px; font-weight:600; margin-bottom:3px; display:block;">Pour (pseudo, vide = moi-même)</label>
|
|
1129
|
+
<input type="text" class="form-control form-control-sm" id="onekite-target-username" placeholder="Pseudo de l'adhérent" autocomplete="off">
|
|
1130
|
+
</div>
|
|
1131
|
+
` : '';
|
|
1132
|
+
|
|
1125
1133
|
const messageHtml = `
|
|
1126
1134
|
<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
1135
|
${shortcutsHtml}
|
|
1136
|
+
${forUserHtml}
|
|
1128
1137
|
<div class="mb-2"><strong>Matériel</strong></div>
|
|
1129
1138
|
<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
1139
|
${rows}
|
|
@@ -1169,7 +1178,8 @@ function toDatetimeLocalValue(date) {
|
|
|
1169
1178
|
const total = (sum / 100) * days;
|
|
1170
1179
|
// Return the effective end date (exclusive) because duration shortcuts can
|
|
1171
1180
|
// change the range without updating the original FullCalendar selection.
|
|
1172
|
-
|
|
1181
|
+
const targetUsername = isValidatorMode ? ((document.getElementById('onekite-target-username') || {}).value || '').trim() : '';
|
|
1182
|
+
resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end), targetUsername });
|
|
1173
1183
|
},
|
|
1174
1184
|
},
|
|
1175
1185
|
},
|
|
@@ -1290,6 +1300,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1290
1300
|
const canDeleteSpecial = !!caps.canDeleteSpecial;
|
|
1291
1301
|
const canCreateOuting = !!caps.canCreateOuting;
|
|
1292
1302
|
const canCreateReservation = !!caps.canCreateReservation;
|
|
1303
|
+
const isValidator = !!caps.isValidator;
|
|
1293
1304
|
const specialEventCategoryCid = parseInt(caps.specialEventCategoryCid, 10) || 0;
|
|
1294
1305
|
|
|
1295
1306
|
// Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
|
|
@@ -1380,17 +1391,19 @@ function toDatetimeLocalValue(date) {
|
|
|
1380
1391
|
}
|
|
1381
1392
|
} catch (e) {}
|
|
1382
1393
|
|
|
1383
|
-
// Business rule: nothing can be created in the past.
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
+
// Business rule: nothing can be created in the past (validators are exempt for regularization).
|
|
1395
|
+
if (!isValidator) {
|
|
1396
|
+
try {
|
|
1397
|
+
const startDateCheck = toLocalYmd(info.start);
|
|
1398
|
+
const todayCheck = toLocalYmd(new Date());
|
|
1399
|
+
if (startDateCheck < todayCheck) {
|
|
1400
|
+
lastDateRuleToastAt = Date.now();
|
|
1401
|
+
showAlert('error', "Impossible de créer pour une date passée.");
|
|
1402
|
+
try { calendar.unselect(); } catch (e) {}
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
} catch (e) {}
|
|
1406
|
+
}
|
|
1394
1407
|
|
|
1395
1408
|
function handleCreateError(e) {
|
|
1396
1409
|
const code = String((e && (e.status || e.message)) || '');
|
|
@@ -1437,22 +1450,24 @@ function toDatetimeLocalValue(date) {
|
|
|
1437
1450
|
showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
|
|
1438
1451
|
return;
|
|
1439
1452
|
}
|
|
1440
|
-
const chosen = await openReservationDialog(sel, items);
|
|
1453
|
+
const chosen = await openReservationDialog(sel, items, { isValidator });
|
|
1441
1454
|
if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
|
|
1442
1455
|
const startDate = toLocalYmd(sel.start);
|
|
1443
1456
|
const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
|
|
1444
|
-
const
|
|
1457
|
+
const reqPayload = {
|
|
1445
1458
|
title: (chosen && chosen.title) ? String(chosen.title) : '',
|
|
1446
1459
|
start: startDate,
|
|
1447
1460
|
end: endDate,
|
|
1448
1461
|
itemIds: chosen.itemIds,
|
|
1449
1462
|
itemNames: chosen.itemNames,
|
|
1450
1463
|
total: chosen.total,
|
|
1451
|
-
}
|
|
1464
|
+
};
|
|
1465
|
+
if (chosen.targetUsername) reqPayload.targetUsername = chosen.targetUsername;
|
|
1466
|
+
const resp = await requestReservation(reqPayload);
|
|
1452
1467
|
if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
|
|
1453
|
-
showAlert('success', 'Réservation confirmée.');
|
|
1468
|
+
showAlert('success', chosen.targetUsername ? `Réservation confirmée pour ${chosen.targetUsername}.` : 'Réservation confirmée.');
|
|
1454
1469
|
} else {
|
|
1455
|
-
showAlert('success', 'Demande envoyée (en attente de validation).');
|
|
1470
|
+
showAlert('success', chosen.targetUsername ? `Demande créée pour ${chosen.targetUsername}.` : 'Demande envoyée (en attente de validation).');
|
|
1456
1471
|
}
|
|
1457
1472
|
invalidateEventsCache();
|
|
1458
1473
|
scheduleRefetch(calendar);
|
|
@@ -1526,22 +1541,24 @@ function toDatetimeLocalValue(date) {
|
|
|
1526
1541
|
showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
|
|
1527
1542
|
return;
|
|
1528
1543
|
}
|
|
1529
|
-
const chosen = await openReservationDialog(sel, items);
|
|
1544
|
+
const chosen = await openReservationDialog(sel, items, { isValidator });
|
|
1530
1545
|
if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
|
|
1531
1546
|
const startDate = toLocalYmd(sel.start);
|
|
1532
1547
|
const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(sel.end);
|
|
1533
|
-
const
|
|
1548
|
+
const reqPayload = {
|
|
1534
1549
|
title: (chosen && chosen.title) ? String(chosen.title) : '',
|
|
1535
1550
|
start: startDate,
|
|
1536
1551
|
end: endDate,
|
|
1537
1552
|
itemIds: chosen.itemIds,
|
|
1538
1553
|
itemNames: chosen.itemNames,
|
|
1539
1554
|
total: chosen.total,
|
|
1540
|
-
}
|
|
1555
|
+
};
|
|
1556
|
+
if (chosen.targetUsername) reqPayload.targetUsername = chosen.targetUsername;
|
|
1557
|
+
const resp = await requestReservation(reqPayload);
|
|
1541
1558
|
if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
|
|
1542
|
-
showAlert('success', 'Réservation confirmée.');
|
|
1559
|
+
showAlert('success', chosen.targetUsername ? `Réservation confirmée pour ${chosen.targetUsername}.` : 'Réservation confirmée.');
|
|
1543
1560
|
} else {
|
|
1544
|
-
showAlert('success', 'Demande envoyée (en attente de validation).');
|
|
1561
|
+
showAlert('success', chosen.targetUsername ? `Demande créée pour ${chosen.targetUsername}.` : 'Demande envoyée (en attente de validation).');
|
|
1545
1562
|
}
|
|
1546
1563
|
invalidateEventsCache();
|
|
1547
1564
|
scheduleRefetch(calendar);
|
|
@@ -1614,8 +1631,9 @@ function toDatetimeLocalValue(date) {
|
|
|
1614
1631
|
longPressDelay: 300,
|
|
1615
1632
|
selectLongPressDelay: 300,
|
|
1616
1633
|
dayCellDidMount: function (arg) {
|
|
1617
|
-
// Visually disable past days for reservation creation rules
|
|
1618
|
-
//
|
|
1634
|
+
// Visually disable past days for reservation creation rules.
|
|
1635
|
+
// Validators are exempt (they can create past-dated reservations for regularization).
|
|
1636
|
+
if (isValidator) return;
|
|
1619
1637
|
try {
|
|
1620
1638
|
const cellDate = arg && arg.date ? new Date(arg.date) : null;
|
|
1621
1639
|
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">
|