nodebb-plugin-onekite-calendar 1.0.12 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin.js +568 -0
- package/lib/api.js +941 -0
- package/lib/controllers.js +11 -0
- package/lib/db.js +110 -0
- package/lib/discord.js +163 -0
- package/lib/helloasso.js +352 -0
- package/lib/helloassoWebhook.js +390 -0
- package/lib/scheduler.js +182 -0
- package/lib/widgets.js +425 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const db = require.main.require('./src/database');
|
|
6
|
+
const meta = require.main.require('./src/meta');
|
|
7
|
+
const user = require.main.require('./src/user');
|
|
8
|
+
// Real-time updates
|
|
9
|
+
let io;
|
|
10
|
+
try {
|
|
11
|
+
const socketIO = require.main.require('./src/socket.io');
|
|
12
|
+
io = socketIO.server || socketIO;
|
|
13
|
+
} catch (e) {
|
|
14
|
+
io = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
const dbLayer = require('./db');
|
|
19
|
+
const helloasso = require('./helloasso');
|
|
20
|
+
const discord = require('./discord');
|
|
21
|
+
|
|
22
|
+
const SETTINGS_KEY = 'calendar-onekite';
|
|
23
|
+
|
|
24
|
+
// Replay protection: store processed payment ids.
|
|
25
|
+
const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
|
|
26
|
+
|
|
27
|
+
async function sendEmail(template, toEmail, subject, data) {
|
|
28
|
+
const uidFromData = data && Number.isInteger(data.uid) ? data.uid : null;
|
|
29
|
+
if (!toEmail && !uidFromData) return;
|
|
30
|
+
try {
|
|
31
|
+
const emailer = require.main.require('./src/emailer');
|
|
32
|
+
// NodeBB core signature is typically:
|
|
33
|
+
// sendToEmail(template, email, language, params)
|
|
34
|
+
// Subject must be provided inside params.subject (and can be finalized via filter:email.modify).
|
|
35
|
+
const language = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
|
|
36
|
+
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
37
|
+
|
|
38
|
+
// In NodeBB 4.x, various email flows expect a uid to be present.
|
|
39
|
+
// If we have it, always prefer the uid-based sender.
|
|
40
|
+
let uid = uidFromData;
|
|
41
|
+
if (!uid && toEmail && typeof user.getUidByEmail === 'function') {
|
|
42
|
+
try {
|
|
43
|
+
uid = await user.getUidByEmail(toEmail);
|
|
44
|
+
if (uid && typeof uid === 'string') uid = parseInt(uid, 10);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
uid = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (uid && typeof emailer.send === 'function') {
|
|
50
|
+
// NodeBB: send(template, uid, params)
|
|
51
|
+
await emailer.send(template, uid, params);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof emailer.sendToEmail === 'function') {
|
|
56
|
+
// Prefer the canonical signature.
|
|
57
|
+
// If a fork uses a shorter signature, we'll still try passing params as 3rd arg.
|
|
58
|
+
if (emailer.sendToEmail.length >= 4) {
|
|
59
|
+
await emailer.sendToEmail(template, toEmail, language, params);
|
|
60
|
+
} else {
|
|
61
|
+
await emailer.sendToEmail(template, toEmail, params);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Do not call emailer.send with an email address (it expects uid). If we reach here,
|
|
67
|
+
// fall back to sendToEmail only.
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// eslint-disable-next-line no-console
|
|
70
|
+
console.warn('[calendar-onekite] Failed to send email (webhook)', { template, toEmail, err: String(err && err.message || err) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatFR(tsOrIso) {
|
|
75
|
+
const d = new Date(tsOrIso);
|
|
76
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
77
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
78
|
+
const yyyy = d.getFullYear();
|
|
79
|
+
return `${dd}/${mm}/${yyyy}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getReservationIdFromPayload(payload) {
|
|
83
|
+
try {
|
|
84
|
+
const data = payload && payload.data ? payload.data : null;
|
|
85
|
+
if (!data) return null;
|
|
86
|
+
|
|
87
|
+
// HelloAsso commonly uses "metadata" for checkout-intents/payments.
|
|
88
|
+
const metaCandidates = [
|
|
89
|
+
data.meta,
|
|
90
|
+
data.metadata,
|
|
91
|
+
data.checkoutIntent && (data.checkoutIntent.meta || data.checkoutIntent.metadata),
|
|
92
|
+
data.order && (data.order.meta || data.order.metadata),
|
|
93
|
+
].filter(Boolean);
|
|
94
|
+
|
|
95
|
+
for (const metaObj of metaCandidates) {
|
|
96
|
+
if (typeof metaObj === 'object' && !Array.isArray(metaObj) && metaObj.reservationId) {
|
|
97
|
+
return String(metaObj.reservationId);
|
|
98
|
+
}
|
|
99
|
+
// Some systems send metadata as array of key/value pairs
|
|
100
|
+
if (Array.isArray(metaObj)) {
|
|
101
|
+
const found = metaObj.find((x) => x && (x.key === 'reservationId' || x.name === 'reservationId'));
|
|
102
|
+
if (found && (found.value || found.val)) return String(found.value || found.val);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function tryRecoverReservationIdFromPayment(settings, paymentId) {
|
|
112
|
+
if (!paymentId) return null;
|
|
113
|
+
try {
|
|
114
|
+
const token = await helloasso.getAccessToken({
|
|
115
|
+
env: settings.helloassoEnv || 'live',
|
|
116
|
+
clientId: settings.helloassoClientId,
|
|
117
|
+
clientSecret: settings.helloassoClientSecret,
|
|
118
|
+
});
|
|
119
|
+
if (!token) return null;
|
|
120
|
+
const details = await helloasso.getPaymentDetails({
|
|
121
|
+
env: settings.helloassoEnv || 'live',
|
|
122
|
+
token,
|
|
123
|
+
paymentId,
|
|
124
|
+
});
|
|
125
|
+
if (!details) return null;
|
|
126
|
+
// Reuse the same extraction logic on the payment details object.
|
|
127
|
+
return getReservationIdFromPayload({ data: details });
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId) {
|
|
134
|
+
if (!checkoutIntentId) return null;
|
|
135
|
+
try {
|
|
136
|
+
// First, try a direct mapping stored when the checkout intent was created.
|
|
137
|
+
const mapped = await db.getObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId));
|
|
138
|
+
if (mapped) return String(mapped);
|
|
139
|
+
|
|
140
|
+
// If no mapping exists (older reservations), query HelloAsso for intent details to read metadata.
|
|
141
|
+
const token = await helloasso.getAccessToken({
|
|
142
|
+
env: settings.helloassoEnv || 'live',
|
|
143
|
+
clientId: settings.helloassoClientId,
|
|
144
|
+
clientSecret: settings.helloassoClientSecret,
|
|
145
|
+
});
|
|
146
|
+
if (!token) return null;
|
|
147
|
+
const details = await helloasso.getCheckoutIntentDetails({
|
|
148
|
+
env: settings.helloassoEnv || 'live',
|
|
149
|
+
token,
|
|
150
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
151
|
+
checkoutIntentId,
|
|
152
|
+
});
|
|
153
|
+
if (!details) return null;
|
|
154
|
+
const rid = getReservationIdFromPayload({ data: details });
|
|
155
|
+
if (rid) {
|
|
156
|
+
// persist mapping for next webhooks
|
|
157
|
+
try {
|
|
158
|
+
await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid));
|
|
159
|
+
} catch (e) {}
|
|
160
|
+
return String(rid);
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function alreadyProcessed(paymentId) {
|
|
169
|
+
if (!paymentId) return false;
|
|
170
|
+
try {
|
|
171
|
+
return await db.isSetMember(PROCESSED_KEY, String(paymentId));
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function markProcessed(paymentId) {
|
|
178
|
+
if (!paymentId) return;
|
|
179
|
+
try {
|
|
180
|
+
await db.setAdd(PROCESSED_KEY, String(paymentId));
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// ignore
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isConfirmedPayment(payload) {
|
|
187
|
+
try {
|
|
188
|
+
if (!payload || !payload.data) return false;
|
|
189
|
+
const eventType = String(payload.eventType || '').toLowerCase();
|
|
190
|
+
const stateRaw = payload.data.state || payload.data.status || payload.data.paymentState || '';
|
|
191
|
+
const state = String(stateRaw).toLowerCase();
|
|
192
|
+
|
|
193
|
+
// We accept the most common "paid" states seen in HelloAsso webhooks.
|
|
194
|
+
const okState = ['confirmed', 'authorized', 'paid', 'processed', 'succeeded', 'success'].includes(state);
|
|
195
|
+
|
|
196
|
+
// HelloAsso may send eventType "Payment" and/or "Order".
|
|
197
|
+
if (eventType === 'payment') {
|
|
198
|
+
return okState;
|
|
199
|
+
}
|
|
200
|
+
if (eventType === 'order') {
|
|
201
|
+
// Order payloads may not carry a payment-like "state" field; accept if missing,
|
|
202
|
+
// but still require a recognizable "paid" state when provided.
|
|
203
|
+
return !state || okState;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function formatFR(tsOrIso) {
|
|
212
|
+
const d = new Date(tsOrIso);
|
|
213
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
214
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
215
|
+
const yyyy = d.getFullYear();
|
|
216
|
+
return `${dd}/${mm}/${yyyy}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function getReservationIdFromPayload(payload) {
|
|
220
|
+
try {
|
|
221
|
+
const m = payload && payload.data ? payload.data.meta : null;
|
|
222
|
+
if (!m) return null;
|
|
223
|
+
if (typeof m === 'object' && m.reservationId) return String(m.reservationId);
|
|
224
|
+
if (typeof m === 'object' && m.reservationID) return String(m.reservationID);
|
|
225
|
+
return null;
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
function getCheckoutIntentIdFromPayload(payload) {
|
|
233
|
+
try {
|
|
234
|
+
const data = payload && payload.data ? payload.data : null;
|
|
235
|
+
if (!data) return null;
|
|
236
|
+
// Common locations based on HelloAsso docs and community reports:
|
|
237
|
+
// - Order event often contains checkoutIntentId at root of data
|
|
238
|
+
// - Return/back redirects contain checkoutIntentId in query params (not here)
|
|
239
|
+
const candidates = [
|
|
240
|
+
data.checkoutIntentId,
|
|
241
|
+
data.checkoutIntent && (data.checkoutIntent.id || data.checkoutIntent.checkoutIntentId),
|
|
242
|
+
data.order && (data.order.checkoutIntentId || (data.order.checkoutIntent && data.order.checkoutIntent.id)),
|
|
243
|
+
].filter(Boolean);
|
|
244
|
+
if (candidates.length) return String(candidates[0]);
|
|
245
|
+
} catch (e) {}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Hardened HelloAsso webhook handler.
|
|
251
|
+
* - Requires x-ha-signature (HMAC SHA-256) verification.
|
|
252
|
+
* - Only accepts eventType=Payment with state=Confirmed.
|
|
253
|
+
* - Provides basic replay protection using the payment id.
|
|
254
|
+
*
|
|
255
|
+
* This handler is "safe" by default: if we cannot verify the signature,
|
|
256
|
+
* we refuse the request.
|
|
257
|
+
*/
|
|
258
|
+
async function handler(req, res, next) {
|
|
259
|
+
try {
|
|
260
|
+
if (req.method === 'GET') {
|
|
261
|
+
return res.json({ ok: true });
|
|
262
|
+
}
|
|
263
|
+
if (req.method !== 'POST') {
|
|
264
|
+
return res.status(405).json({ ok: false, error: 'method-not-allowed' });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const settings = await meta.settings.get(SETTINGS_KEY);
|
|
268
|
+
|
|
269
|
+
// Restrict webhook calls by IP (HelloAsso partner mode signature is not used).
|
|
270
|
+
const defaultAllowed = ['51.138.206.200', '4.233.135.234'];
|
|
271
|
+
const raw = String((settings && settings.helloassoWebhookAllowedIps) || '').trim();
|
|
272
|
+
const allowedIps = (raw ? raw.split(/[\s,;]+/g) : defaultAllowed).map(s => String(s || '').trim()).filter(Boolean);
|
|
273
|
+
|
|
274
|
+
const clientIp = (() => {
|
|
275
|
+
const cf = req.headers['cf-connecting-ip'];
|
|
276
|
+
if (cf) return String(cf).trim();
|
|
277
|
+
const xff = req.headers['x-forwarded-for'];
|
|
278
|
+
if (xff) return String(xff).split(',')[0].trim();
|
|
279
|
+
return String(req.ip || '').trim();
|
|
280
|
+
})();
|
|
281
|
+
|
|
282
|
+
if (allowedIps.length && clientIp && !allowedIps.includes(clientIp)) {
|
|
283
|
+
// eslint-disable-next-line no-console
|
|
284
|
+
console.warn('[calendar-onekite] HelloAsso webhook blocked by IP allowlist', { ip: clientIp, allowed: allowedIps });
|
|
285
|
+
return res.status(403).json({ ok: false, error: 'ip-not-allowed' });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// At this point, the payload is trusted.
|
|
289
|
+
const payload = req.body;
|
|
290
|
+
|
|
291
|
+
if (!isConfirmedPayment(payload)) {
|
|
292
|
+
// Acknowledge but ignore other event types/states.
|
|
293
|
+
return res.json({ ok: true, ignored: true });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Ignore incomplete Payment events (HelloAsso sometimes omits metadata and checkoutIntentId on Payment webhooks).
|
|
297
|
+
// We rely on Order events (or checkoutIntent mappings) for reliable reconciliation.
|
|
298
|
+
const _eventType = String((payload && payload.eventType) || '').toLowerCase();
|
|
299
|
+
const _rid = getReservationIdFromPayload(payload);
|
|
300
|
+
const _checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
|
|
301
|
+
if (_eventType === 'payment' && !_rid && !_checkoutIntentId) {
|
|
302
|
+
return res.json({ ok: true, ignored: true, incompletePayment: true });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const paymentId = payload && payload.data ? (payload.data.id || payload.data.paymentId) : null;
|
|
306
|
+
// If we can't identify the payment, acknowledge and ignore (prevents accidental crashes).
|
|
307
|
+
if (!paymentId) {
|
|
308
|
+
return res.json({ ok: true, ignored: true, missingPaymentId: true });
|
|
309
|
+
}
|
|
310
|
+
if (await alreadyProcessed(paymentId)) {
|
|
311
|
+
return res.json({ ok: true, duplicate: true });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const rid = getReservationIdFromPayload(payload);
|
|
315
|
+
let resolvedRid = rid;
|
|
316
|
+
const checkoutIntentId = getCheckoutIntentIdFromPayload(payload);
|
|
317
|
+
if (!resolvedRid && checkoutIntentId) {
|
|
318
|
+
// Some webhook payloads omit metadata but provide checkoutIntentId (common on Order events).
|
|
319
|
+
resolvedRid = await tryRecoverReservationIdFromCheckoutIntent(settings, checkoutIntentId);
|
|
320
|
+
}
|
|
321
|
+
if (!resolvedRid && paymentId) {
|
|
322
|
+
// Some webhook payloads omit metadata; try to fetch the payment details.
|
|
323
|
+
resolvedRid = await tryRecoverReservationIdFromPayment(settings, paymentId);
|
|
324
|
+
}
|
|
325
|
+
if (!resolvedRid) {
|
|
326
|
+
// eslint-disable-next-line no-console
|
|
327
|
+
console.warn('[calendar-onekite] HelloAsso webhook missing reservationId in metadata', { eventType: payload && payload.eventType, paymentId, checkoutIntentId });
|
|
328
|
+
// Do NOT mark as processed: if metadata/config is fixed later, a manual replay may be possible.
|
|
329
|
+
return res.json({ ok: true, processed: false, missingReservationId: true });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const r = await dbLayer.getReservation(resolvedRid);
|
|
333
|
+
if (!r) {
|
|
334
|
+
await markProcessed(paymentId);
|
|
335
|
+
return res.json({ ok: true, processed: true, reservationNotFound: true });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Mark as paid and persist payment metadata.
|
|
339
|
+
r.status = 'paid';
|
|
340
|
+
r.paidAt = Date.now();
|
|
341
|
+
r.paymentId = paymentId ? String(paymentId) : '';
|
|
342
|
+
if (payload.data && payload.data.paymentReceiptUrl) {
|
|
343
|
+
r.paymentReceiptUrl = String(payload.data.paymentReceiptUrl);
|
|
344
|
+
}
|
|
345
|
+
await dbLayer.saveReservation(r);
|
|
346
|
+
|
|
347
|
+
// Real-time notify: refresh calendars for all viewers (owner + validators/admins)
|
|
348
|
+
try {
|
|
349
|
+
if (io && io.sockets && typeof io.sockets.emit === 'function') {
|
|
350
|
+
io.sockets.emit('event:calendar-onekite.reservationUpdated', { rid: r.rid, status: r.status });
|
|
351
|
+
}
|
|
352
|
+
} catch (e) {}
|
|
353
|
+
|
|
354
|
+
// Notify requester
|
|
355
|
+
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
356
|
+
if (requester && requester.email) {
|
|
357
|
+
await sendEmail('calendar-onekite_paid', requester.email, 'Location matériel - Paiement reçu', {
|
|
358
|
+
uid: parseInt(r.uid, 10),
|
|
359
|
+
username: requester.username,
|
|
360
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
361
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
362
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
363
|
+
paymentReceiptUrl: r.paymentReceiptUrl || '',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Discord webhook (optional)
|
|
368
|
+
try {
|
|
369
|
+
await discord.notifyPaymentReceived(settings, {
|
|
370
|
+
rid: r.rid,
|
|
371
|
+
uid: r.uid,
|
|
372
|
+
username: (requester && requester.username) ? requester.username : (r.username || ''),
|
|
373
|
+
itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
|
|
374
|
+
itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
|
|
375
|
+
start: r.start,
|
|
376
|
+
end: r.end,
|
|
377
|
+
status: r.status,
|
|
378
|
+
});
|
|
379
|
+
} catch (e) {}
|
|
380
|
+
|
|
381
|
+
await markProcessed(paymentId);
|
|
382
|
+
return res.json({ ok: true, processed: true });
|
|
383
|
+
} catch (err) {
|
|
384
|
+
return next(err);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = {
|
|
389
|
+
handler,
|
|
390
|
+
};
|
package/lib/scheduler.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const meta = require.main.require('./src/meta');
|
|
4
|
+
const db = require.main.require('./src/database');
|
|
5
|
+
const dbLayer = require('./db');
|
|
6
|
+
|
|
7
|
+
let timer = null;
|
|
8
|
+
|
|
9
|
+
function getSetting(settings, key, fallback) {
|
|
10
|
+
const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
|
|
11
|
+
if (v == null || v === '') return fallback;
|
|
12
|
+
if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
|
|
13
|
+
return v;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Pending holds: short lock after a user creates a request (defaults to 5 minutes)
|
|
17
|
+
async function expirePending() {
|
|
18
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
19
|
+
const holdMins = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
|
|
22
|
+
const ids = await dbLayer.listAllReservationIds(5000);
|
|
23
|
+
if (!ids || !ids.length) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const rid of ids) {
|
|
28
|
+
const resv = await dbLayer.getReservation(rid);
|
|
29
|
+
if (!resv || resv.status !== 'pending') {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const createdAt = parseInt(resv.createdAt, 10) || 0;
|
|
33
|
+
const expiresAt = createdAt + holdMins * 60 * 1000;
|
|
34
|
+
if (now > expiresAt) {
|
|
35
|
+
// Expire (remove from calendar)
|
|
36
|
+
await dbLayer.removeReservation(rid);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Payment window logic:
|
|
42
|
+
// - When a reservation is validated it becomes awaiting_payment
|
|
43
|
+
// - We send a reminder after `paymentHoldMinutes` (default 60)
|
|
44
|
+
// - We expire (and remove) after `2 * paymentHoldMinutes`
|
|
45
|
+
async function processAwaitingPayment() {
|
|
46
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
47
|
+
const holdMins = parseInt(
|
|
48
|
+
getSetting(settings, 'paymentHoldMinutes', getSetting(settings, 'holdMinutes', '60')),
|
|
49
|
+
10
|
|
50
|
+
) || 60;
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
|
|
53
|
+
const ids = await dbLayer.listAllReservationIds(5000);
|
|
54
|
+
if (!ids || !ids.length) return;
|
|
55
|
+
|
|
56
|
+
const emailer = require.main.require('./src/emailer');
|
|
57
|
+
const user = require.main.require('./src/user');
|
|
58
|
+
|
|
59
|
+
async function sendEmail(template, toEmail, subject, data) {
|
|
60
|
+
if (!toEmail) return;
|
|
61
|
+
try {
|
|
62
|
+
// NodeBB core signature:
|
|
63
|
+
// Emailer.sendToEmail(template, email, language, params[, callback])
|
|
64
|
+
// Subject is NOT a positional argument; it must be provided in params.subject
|
|
65
|
+
// and optionally copied into the final email by filter:email.modify.
|
|
66
|
+
const language = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
|
|
67
|
+
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
68
|
+
|
|
69
|
+
if (typeof emailer.sendToEmail === 'function') {
|
|
70
|
+
await emailer.sendToEmail(template, toEmail, language, params);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fallbacks for older/unusual builds
|
|
75
|
+
if (typeof emailer.send === 'function') {
|
|
76
|
+
// Some builds accept (template, email, language, params)
|
|
77
|
+
if (emailer.send.length >= 4) {
|
|
78
|
+
await emailer.send(template, toEmail, language, params);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Some builds accept (template, email, params)
|
|
82
|
+
await emailer.send(template, toEmail, params);
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
console.warn('[calendar-onekite] Failed to send email (scheduler)', {
|
|
87
|
+
template,
|
|
88
|
+
toEmail,
|
|
89
|
+
err: String((err && err.message) || err),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatFR(ts) {
|
|
95
|
+
const d = new Date(ts);
|
|
96
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
97
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
98
|
+
const yyyy = d.getFullYear();
|
|
99
|
+
return `${dd}/${mm}/${yyyy}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const rid of ids) {
|
|
103
|
+
const r = await dbLayer.getReservation(rid);
|
|
104
|
+
if (!r || r.status !== 'awaiting_payment') continue;
|
|
105
|
+
|
|
106
|
+
const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
|
|
107
|
+
if (!approvedAt) continue;
|
|
108
|
+
|
|
109
|
+
const reminderAt = approvedAt + holdMins * 60 * 1000;
|
|
110
|
+
const expireAt = approvedAt + 2 * holdMins * 60 * 1000;
|
|
111
|
+
|
|
112
|
+
if (!r.reminderSent && now >= reminderAt && now < expireAt) {
|
|
113
|
+
// Send reminder once (guarded across clustered NodeBB processes)
|
|
114
|
+
const reminderKey = 'calendar-onekite:email:reminderSent';
|
|
115
|
+
const first = await db.setAdd(reminderKey, rid);
|
|
116
|
+
if (!first) {
|
|
117
|
+
// another process already sent it
|
|
118
|
+
r.reminderSent = true;
|
|
119
|
+
r.reminderAt = now;
|
|
120
|
+
await dbLayer.saveReservation(r);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const u = await user.getUserFields(r.uid, ['username', 'email']);
|
|
124
|
+
if (u && u.email) {
|
|
125
|
+
await sendEmail('calendar-onekite_reminder', u.email, 'Location matériel - Rappel', {
|
|
126
|
+
username: u.username,
|
|
127
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
128
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
129
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
130
|
+
paymentUrl: r.paymentUrl || '',
|
|
131
|
+
delayMinutes: holdMins,
|
|
132
|
+
pickupLine: r.pickupTime ? (r.adminNote ? `${r.pickupTime} à ${r.adminNote}` : r.pickupTime) : '',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
r.reminderSent = true;
|
|
136
|
+
r.reminderAt = now;
|
|
137
|
+
await dbLayer.saveReservation(r);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (now >= expireAt) {
|
|
142
|
+
// Expire: remove reservation so it disappears from calendar and frees items
|
|
143
|
+
// Guard email send across clustered NodeBB processes
|
|
144
|
+
const expiredKey = 'calendar-onekite:email:expiredSent';
|
|
145
|
+
const firstExpired = await db.setAdd(expiredKey, rid);
|
|
146
|
+
const shouldEmail = !!firstExpired;
|
|
147
|
+
const u = await user.getUserFields(r.uid, ['username', 'email']);
|
|
148
|
+
if (shouldEmail && u && u.email) {
|
|
149
|
+
await sendEmail('calendar-onekite_expired', u.email, 'Location matériel - Rappel', {
|
|
150
|
+
username: u.username,
|
|
151
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
152
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
153
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
154
|
+
delayMinutes: holdMins,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
await dbLayer.removeReservation(rid);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function start() {
|
|
163
|
+
if (timer) return;
|
|
164
|
+
timer = setInterval(() => {
|
|
165
|
+
expirePending().catch(() => {});
|
|
166
|
+
processAwaitingPayment().catch(() => {});
|
|
167
|
+
}, 60 * 1000);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function stop() {
|
|
171
|
+
if (timer) {
|
|
172
|
+
clearInterval(timer);
|
|
173
|
+
timer = null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
start,
|
|
179
|
+
stop,
|
|
180
|
+
expirePending,
|
|
181
|
+
processAwaitingPayment,
|
|
182
|
+
};
|