nodebb-plugin-onekite-calendar 2.0.11 → 2.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/admin.js +21 -9
  3. package/lib/api.js +235 -4
  4. package/lib/db.js +114 -0
  5. package/lib/helloassoWebhook.js +28 -0
  6. package/library.js +7 -0
  7. package/package.json +1 -1
  8. package/pkg/package/CHANGELOG.md +106 -0
  9. package/pkg/package/lib/admin.js +554 -0
  10. package/pkg/package/lib/api.js +1458 -0
  11. package/pkg/package/lib/controllers.js +11 -0
  12. package/pkg/package/lib/db.js +224 -0
  13. package/pkg/package/lib/discord.js +190 -0
  14. package/pkg/package/lib/helloasso.js +352 -0
  15. package/pkg/package/lib/helloassoWebhook.js +389 -0
  16. package/pkg/package/lib/scheduler.js +201 -0
  17. package/pkg/package/lib/widgets.js +460 -0
  18. package/pkg/package/library.js +164 -0
  19. package/pkg/package/package.json +14 -0
  20. package/pkg/package/plugin.json +43 -0
  21. package/pkg/package/public/admin.js +1477 -0
  22. package/pkg/package/public/client.js +2228 -0
  23. package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
  24. package/pkg/package/templates/calendar-onekite.tpl +51 -0
  25. package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
  26. package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
  27. package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
  28. package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
  29. package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
  30. package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
  31. package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
  32. package/plugin.json +1 -1
  33. package/public/admin.js +205 -4
  34. package/public/client.js +238 -7
  35. package/templates/admin/plugins/calendar-onekite.tpl +74 -0
@@ -0,0 +1,352 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ function requestJson(method, url, headers = {}, bodyObj = null) {
6
+ return new Promise((resolve) => {
7
+ const u = new URL(url);
8
+ const body = bodyObj ? JSON.stringify(bodyObj) : null;
9
+
10
+ const req = https.request(
11
+ {
12
+ method,
13
+ hostname: u.hostname,
14
+ port: u.port || 443,
15
+ path: u.pathname + u.search,
16
+ headers: {
17
+ Accept: 'application/json',
18
+ 'Content-Type': 'application/json',
19
+ ...(body ? { 'Content-Length': Buffer.byteLength(body) } : {}),
20
+ ...headers,
21
+ },
22
+ },
23
+ (resp) => {
24
+ let data = '';
25
+ resp.setEncoding('utf8');
26
+ resp.on('data', (chunk) => { data += chunk; });
27
+ resp.on('end', () => {
28
+ const status = resp.statusCode || 0;
29
+
30
+ // Try JSON first, but never throw from here (avoid crashing NodeBB).
31
+ try {
32
+ const json = data ? JSON.parse(data) : null;
33
+ return resolve({ status, json });
34
+ } catch (e) {
35
+ // Non-JSON response (HTML/proxy error/etc.)
36
+ const snippet = String(data || '').slice(0, 500);
37
+ return resolve({ status, json: null, raw: snippet });
38
+ }
39
+ });
40
+ }
41
+ );
42
+
43
+ req.on('error', (err) => {
44
+ resolve({ status: 0, json: null, error: err && err.message ? err.message : String(err) });
45
+ });
46
+ if (body) req.write(body);
47
+ req.end();
48
+ });
49
+ }
50
+
51
+ function baseUrl(env) {
52
+ return env === 'sandbox'
53
+ ? 'https://api.helloasso-sandbox.com'
54
+ : 'https://api.helloasso.com';
55
+ }
56
+
57
+ // In-memory access token cache (per NodeBB process)
58
+ let _tokenCache = { token: null, expiresAt: 0 };
59
+ let _tokenInFlight = null;
60
+
61
+ function _sleep(ms) {
62
+ return new Promise((resolve) => setTimeout(resolve, ms));
63
+ }
64
+
65
+ function _tokenLooksValid() {
66
+ // Refresh 60s before expiration to avoid edge cases
67
+ return !!(_tokenCache.token && Date.now() < (_tokenCache.expiresAt - 60_000));
68
+ }
69
+
70
+ function _isRateLimited(status, parsedJson, bodySnippet) {
71
+ if (status === 429) return true;
72
+ const snip = String(bodySnippet || '').toLowerCase();
73
+ if (snip.includes('1015')) return true; // Cloudflare rate limited
74
+ if (parsedJson && typeof parsedJson === 'object') {
75
+ const j = JSON.stringify(parsedJson).toLowerCase();
76
+ if (j.includes('1015')) return true;
77
+ if (j.includes('rate') && j.includes('limit')) return true;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ function _retryAfterMs(headers) {
83
+ if (!headers) return 0;
84
+ const ra = headers['retry-after'] || headers['Retry-After'];
85
+ if (!ra) return 0;
86
+ const n = parseInt(Array.isArray(ra) ? ra[0] : String(ra), 10);
87
+ if (!Number.isFinite(n) || n <= 0) return 0;
88
+ return Math.min(120_000, n * 1000);
89
+ }
90
+
91
+ function _requestAccessTokenRaw({ env, clientId, clientSecret }) {
92
+ const url = `${baseUrl(env)}/oauth2/token`;
93
+ const body = new URLSearchParams({
94
+ grant_type: 'client_credentials',
95
+ client_id: clientId,
96
+ client_secret: clientSecret,
97
+ }).toString();
98
+
99
+ return new Promise((resolve) => {
100
+ try {
101
+ const u = new URL(url);
102
+ const req = https.request(
103
+ {
104
+ method: 'POST',
105
+ hostname: u.hostname,
106
+ port: u.port || 443,
107
+ path: u.pathname + u.search,
108
+ headers: {
109
+ 'Accept': 'application/json',
110
+ 'Content-Type': 'application/x-www-form-urlencoded',
111
+ 'Content-Length': Buffer.byteLength(body),
112
+ },
113
+ },
114
+ (res) => {
115
+ let data = '';
116
+ res.setEncoding('utf8');
117
+ res.on('data', (chunk) => (data += chunk));
118
+ res.on('end', () => {
119
+ const status = res.statusCode || 0;
120
+ const headers = res.headers || {};
121
+ const snippet = String(data || '').slice(0, 1000);
122
+ resolve({ status, headers, bodyText: data || '', snippet });
123
+ });
124
+ }
125
+ );
126
+ req.on('error', (err) => {
127
+ resolve({ status: 0, headers: {}, bodyText: '', snippet: err && err.message ? String(err.message) : String(err) });
128
+ });
129
+ req.write(body);
130
+ req.end();
131
+ } catch (e) {
132
+ resolve({ status: 0, headers: {}, bodyText: '', snippet: e && e.message ? String(e.message) : String(e) });
133
+ }
134
+ });
135
+ }
136
+
137
+ async function _fetchAccessTokenWithRetry(params) {
138
+ // 3 attempts max with exponential backoff on rate-limit/network
139
+ for (let attempt = 0; attempt < 3; attempt++) {
140
+ const { status, headers, bodyText, snippet } = await _requestAccessTokenRaw(params);
141
+
142
+ // 2xx: parse and return token (+ cache expiry)
143
+ if (status >= 200 && status < 300) {
144
+ try {
145
+ const json = JSON.parse(bodyText || '{}');
146
+ const token = json && json.access_token ? String(json.access_token) : null;
147
+ const expiresIn = parseInt(json && json.expires_in ? String(json.expires_in) : '3600', 10) || 3600;
148
+ if (token) {
149
+ const expiresAt = Date.now() + Math.max(60, expiresIn) * 1000;
150
+ return { token, expiresAt };
151
+ }
152
+ return { token: null, expiresAt: 0 };
153
+ } catch (e) {
154
+ return { token: null, expiresAt: 0 };
155
+ }
156
+ }
157
+
158
+ // Non-2xx: decide whether to retry
159
+ let parsed = null;
160
+ try { parsed = bodyText ? JSON.parse(bodyText) : null; } catch (e) { /* ignore */ }
161
+
162
+ const rateLimited = _isRateLimited(status, parsed, snippet);
163
+ const networkish = status === 0;
164
+
165
+ if ((rateLimited || networkish) && attempt < 2) {
166
+ const base = 1500 * (2 ** attempt);
167
+ const ra = _retryAfterMs(headers);
168
+ const jitter = Math.floor(Math.random() * 250);
169
+ const waitMs = Math.min(60_000, Math.max(base, ra) + jitter);
170
+ await _sleep(waitMs);
171
+ continue;
172
+ }
173
+
174
+ return { token: null, expiresAt: 0 };
175
+ }
176
+
177
+ return { token: null, expiresAt: 0 };
178
+ }
179
+
180
+ async function getAccessToken({ env, clientId, clientSecret }) {
181
+ if (!clientId || !clientSecret) return null;
182
+
183
+ if (_tokenLooksValid()) return _tokenCache.token;
184
+
185
+ // De-duplicate concurrent token requests (prevents bursts -> 429/1015)
186
+ if (_tokenInFlight) return _tokenInFlight;
187
+
188
+ _tokenInFlight = (async () => {
189
+ const { token, expiresAt } = await _fetchAccessTokenWithRetry({ env, clientId, clientSecret });
190
+ if (token) {
191
+ _tokenCache = { token, expiresAt };
192
+ }
193
+ return token || null;
194
+ })();
195
+
196
+ try {
197
+ return await _tokenInFlight;
198
+ } finally {
199
+ _tokenInFlight = null;
200
+ }
201
+ }
202
+
203
+ async function listItems({ env, token, organizationSlug, formType, formSlug }) {
204
+ if (!token || !organizationSlug || !formType || !formSlug) return [];
205
+ // This endpoint returns *sold items* (i.e., items present in orders). If your shop has
206
+ // no sales yet, it will legitimately return an empty list.
207
+ const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/items?pageIndex=1&pageSize=200`;
208
+ const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
209
+ if (status >= 200 && status < 300 && json) {
210
+ return json.data || json.items || [];
211
+ }
212
+ return [];
213
+ }
214
+
215
+ async function getFormPublic({ env, token, organizationSlug, formType, formSlug }) {
216
+ if (!token || !organizationSlug || !formType || !formSlug) return null;
217
+ // Public form details contains extraOptions/customFields and (for Shop) usually the catalog structure.
218
+ const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/public`;
219
+ const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
220
+ if (status >= 200 && status < 300) {
221
+ return json || null;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function extractCatalogItems(publicFormJson) {
227
+ if (!publicFormJson || typeof publicFormJson !== 'object') return [];
228
+
229
+ // Try a few common shapes used in HelloAsso "public" form responses.
230
+ const candidates = [];
231
+ const pushArr = (arr) => {
232
+ if (Array.isArray(arr)) candidates.push(...arr);
233
+ };
234
+
235
+ pushArr(publicFormJson.items);
236
+ pushArr(publicFormJson.tiers);
237
+ pushArr(publicFormJson.products);
238
+ pushArr(publicFormJson.data);
239
+ if (publicFormJson.form) {
240
+ pushArr(publicFormJson.form.items);
241
+ pushArr(publicFormJson.form.tiers);
242
+ pushArr(publicFormJson.form.products);
243
+ }
244
+
245
+ // Some responses nest in "campaign" or "publicForm".
246
+ if (publicFormJson.publicForm) {
247
+ pushArr(publicFormJson.publicForm.items);
248
+ pushArr(publicFormJson.publicForm.tiers);
249
+ pushArr(publicFormJson.publicForm.products);
250
+ }
251
+ if (publicFormJson.campaign) {
252
+ pushArr(publicFormJson.campaign.items);
253
+ pushArr(publicFormJson.campaign.tiers);
254
+ pushArr(publicFormJson.campaign.products);
255
+ }
256
+
257
+ // Normalize to { id, name, price }
258
+ return candidates
259
+ .map((it) => {
260
+ if (!it || typeof it !== 'object') return null;
261
+ const id = it.id ?? it.itemId ?? it.tierId;
262
+ const name = it.name ?? it.label ?? it.title;
263
+ const price =
264
+ (it.amount && (it.amount.total ?? it.amount.value)) ??
265
+ it.price ??
266
+ it.unitPrice ??
267
+ it.totalAmount ??
268
+ it.initialAmount;
269
+ if (!id || !name) return null;
270
+ return { id, name, price: typeof price === 'number' ? price : 0, raw: it };
271
+ })
272
+ .filter(Boolean);
273
+ }
274
+
275
+ async function listCatalogItems({ env, token, organizationSlug, formType, formSlug }) {
276
+ const publicForm = await getFormPublic({ env, token, organizationSlug, formType, formSlug });
277
+ const extracted = extractCatalogItems(publicForm);
278
+ return {
279
+ publicForm,
280
+ items: extracted.map(({ id, name, price, raw }) => ({ id, name, price, raw })),
281
+ };
282
+ }
283
+
284
+ async function createCheckoutIntent({ env, token, organizationSlug, formType, formSlug, totalAmount, payerEmail, callbackUrl, webhookUrl, itemName, containsDonation, metadata }) {
285
+ if (!token || !organizationSlug) return null;
286
+ if (!callbackUrl || !/^https?:\/\//i.test(String(callbackUrl))) {
287
+ console.warn('[calendar-onekite] HelloAsso invalid return/back/error URL', { callbackUrl });
288
+ return null;
289
+ }
290
+ if (webhookUrl && !/^https?:\/\//i.test(String(webhookUrl))) {
291
+ console.warn('[calendar-onekite] HelloAsso invalid webhook URL', { webhookUrl });
292
+ }
293
+ // Checkout intents are created at organization level.
294
+ const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/checkout-intents`;
295
+ const payload = {
296
+ totalAmount: totalAmount,
297
+ initialAmount: totalAmount,
298
+ itemName: itemName || 'Réservation matériel',
299
+ containsDonation: (typeof containsDonation === 'boolean' ? containsDonation : false),
300
+ payer: payerEmail ? { email: payerEmail } : undefined,
301
+ metadata: metadata || undefined,
302
+ backUrl: callbackUrl || '',
303
+ errorUrl: callbackUrl || '',
304
+ returnUrl: callbackUrl || '',
305
+ notificationUrl: webhookUrl || callbackUrl || '',
306
+ };
307
+ const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
308
+ if (status >= 200 && status < 300 && json) {
309
+ return { paymentUrl: (json.redirectUrl || json.checkoutUrl || json.url || null), checkoutIntentId: (json.id || json.checkoutIntentId || null), raw: json };
310
+ }
311
+ // Log the error payload to help diagnose configuration issues (slug, env, urls, amount, etc.)
312
+ try {
313
+ // eslint-disable-next-line no-console
314
+ console.warn('[calendar-onekite] HelloAsso checkout-intent failed', { status, json });
315
+ } catch (e) { /* ignore */ }
316
+ return null;
317
+ }
318
+
319
+ // Fetch detailed payment information (used to recover metadata when webhook payload is incomplete)
320
+ // HelloAsso exposes GET /payments/{paymentId} in its v5 API.
321
+ async function getPaymentDetails({ env, token, paymentId }) {
322
+ if (!token || !paymentId) return null;
323
+ const url = `${baseUrl(env)}/v5/payments/${encodeURIComponent(String(paymentId))}`;
324
+ const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
325
+ if (status >= 200 && status < 300) {
326
+ return json || null;
327
+ }
328
+ return null;
329
+ }
330
+
331
+
332
+
333
+ async function getCheckoutIntentDetails({ env, token, organizationSlug, checkoutIntentId }) {
334
+ if (!token || !organizationSlug || !checkoutIntentId) return null;
335
+ const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(String(organizationSlug))}/checkout-intents/${encodeURIComponent(String(checkoutIntentId))}`;
336
+ const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
337
+ if (status >= 200 && status < 300) {
338
+ return json || null;
339
+ }
340
+ return null;
341
+ }
342
+
343
+ module.exports = {
344
+ getAccessToken,
345
+ listItems,
346
+ getFormPublic,
347
+ extractCatalogItems,
348
+ listCatalogItems,
349
+ createCheckoutIntent,
350
+ getPaymentDetails,
351
+ getCheckoutIntentDetails,
352
+ };