nodebb-plugin-onekite-calendar 2.0.98 → 2.1.0

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/api.js CHANGED
@@ -1502,7 +1502,6 @@ api.createReservation = async function (req, res) {
1502
1502
  start: formatFR(start),
1503
1503
  end: formatFR(end),
1504
1504
  total: resv.total || 0,
1505
- adminUrl: `${forumBaseUrl()}/admin/plugins/calendar-onekite`,
1506
1505
  });
1507
1506
  }
1508
1507
  }
package/lib/helloasso.js CHANGED
@@ -26,15 +26,16 @@ function requestJson(method, url, headers = {}, bodyObj = null) {
26
26
  resp.on('data', (chunk) => { data += chunk; });
27
27
  resp.on('end', () => {
28
28
  const status = resp.statusCode || 0;
29
+ const headers = resp.headers || {};
29
30
 
30
31
  // Try JSON first, but never throw from here (avoid crashing NodeBB).
31
32
  try {
32
33
  const json = data ? JSON.parse(data) : null;
33
- return resolve({ status, json });
34
+ return resolve({ status, json, headers });
34
35
  } catch (e) {
35
36
  // Non-JSON response (HTML/proxy error/etc.)
36
37
  const snippet = String(data || '').slice(0, 500);
37
- return resolve({ status, json: null, raw: snippet });
38
+ return resolve({ status, json: null, raw: snippet, headers });
38
39
  }
39
40
  });
40
41
  }
@@ -177,6 +178,14 @@ async function _fetchAccessTokenWithRetry(params) {
177
178
  return { token: null, expiresAt: 0 };
178
179
  }
179
180
 
181
+ // Force the next getAccessToken() call to fetch a fresh token instead of reusing
182
+ // the in-memory cache. Callers should invoke this after a downstream API call
183
+ // comes back 401/403 with a cached token, since a stale/rotated-secret token
184
+ // would otherwise keep failing silently for up to ~1h (the normal cache TTL).
185
+ function invalidateTokenCache() {
186
+ _tokenCache = { token: null, expiresAt: 0 };
187
+ }
188
+
180
189
  async function getAccessToken({ env, clientId, clientSecret }) {
181
190
  if (!clientId || !clientSecret) return null;
182
191
 
@@ -331,18 +340,60 @@ async function getPaymentDetails({ env, token, paymentId }) {
331
340
 
332
341
 
333
342
 
343
+ // Returns { ok, status, reason, json }. On success, ok=true and json is the checkout
344
+ // intent payload. On failure, ok=false and `reason` distinguishes a genuine 404 from
345
+ // transient errors (rate-limit, auth, network, 5xx) so callers don't misreport
346
+ // "does not exist" for issues that are actually retryable.
334
347
  async function getCheckoutIntentDetails({ env, token, organizationSlug, checkoutIntentId }) {
335
- if (!token || !organizationSlug || !checkoutIntentId) return null;
348
+ if (!token || !organizationSlug || !checkoutIntentId) {
349
+ return { ok: false, status: 0, reason: 'missing-params', json: null };
350
+ }
336
351
  const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(String(organizationSlug))}/checkout-intents/${encodeURIComponent(String(checkoutIntentId))}`;
337
- const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
338
- if (status >= 200 && status < 300) {
339
- return json || null;
352
+
353
+ let last = { status: 0, json: null };
354
+ for (let attempt = 0; attempt < 3; attempt++) {
355
+ // eslint-disable-next-line no-await-in-loop
356
+ const result = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
357
+ last = result;
358
+ const { status, json, raw, headers } = result;
359
+
360
+ if (status >= 200 && status < 300) {
361
+ return { ok: true, status, reason: null, json: json || null };
362
+ }
363
+
364
+ const rateLimited = _isRateLimited(status, json, raw);
365
+ const networkish = status === 0;
366
+
367
+ if ((rateLimited || networkish) && attempt < 2) {
368
+ const base = 1000 * (2 ** attempt);
369
+ const ra = _retryAfterMs(headers);
370
+ const jitter = Math.floor(Math.random() * 200);
371
+ // eslint-disable-next-line no-await-in-loop
372
+ await _sleep(Math.min(15_000, Math.max(base, ra) + jitter));
373
+ continue;
374
+ }
375
+ break;
340
376
  }
341
- return null;
377
+
378
+ const { status, json, raw, error } = last;
379
+ let reason = 'http-error';
380
+ if (status === 0) reason = 'network-error';
381
+ else if (status === 401 || status === 403) reason = 'unauthorized';
382
+ else if (status === 404) reason = 'not-found';
383
+ else if (status === 429) reason = 'rate-limited';
384
+ else if (status >= 500) reason = 'server-error';
385
+
386
+ // eslint-disable-next-line no-console
387
+ console.warn('[calendar-onekite] HelloAsso getCheckoutIntentDetails failed', {
388
+ checkoutIntentId, status, reason, detail: raw || error || (json ? JSON.stringify(json).slice(0, 300) : ''),
389
+ });
390
+
391
+ return { ok: false, status, reason, json: json || null };
342
392
  }
343
393
 
344
394
  module.exports = {
345
395
  getAccessToken,
396
+ invalidateTokenCache,
346
397
  listItems,
347
398
  getFormPublic,
348
399
  extractCatalogItems,
@@ -218,13 +218,14 @@ async function tryRecoverRidFromCheckoutIntent(settings, checkoutIntentId) {
218
218
  clientSecret: settings.helloassoClientSecret,
219
219
  });
220
220
  if (!token) return null;
221
- const details = await helloasso.getCheckoutIntentDetails({
221
+ const detailsResult = await helloasso.getCheckoutIntentDetails({
222
222
  env: settings.helloassoEnv || 'prod',
223
223
  token,
224
224
  organizationSlug: settings.helloassoOrganizationSlug,
225
225
  checkoutIntentId,
226
226
  });
227
- if (!details) return null;
227
+ if (!detailsResult.ok || !detailsResult.json) return null;
228
+ const details = detailsResult.json;
228
229
  const rid = getReservationIdFromPayload({ data: details });
229
230
  if (rid) {
230
231
  try { await db.setObjectField(dbLayer.KEY_CHECKOUT_INTENT_TO_RID, String(checkoutIntentId), String(rid)); } catch (e) {}
@@ -42,13 +42,52 @@ async function syncPayment({ r, settings, actorUid }) {
42
42
  });
43
43
  if (!token) return { ok: false, reason: 'helloasso-auth-failed' };
44
44
 
45
- const details = await helloasso.getCheckoutIntentDetails({
45
+ let detailsResult = await helloasso.getCheckoutIntentDetails({
46
46
  env: settings.helloassoEnv || 'prod',
47
47
  token,
48
48
  organizationSlug: settings.helloassoOrganizationSlug,
49
49
  checkoutIntentId,
50
50
  });
51
- if (!details) return { ok: false, reason: 'checkout-intent-not-found' };
51
+
52
+ // A cached token can go stale (e.g. HelloAsso secret rotated in ACP) and would
53
+ // otherwise keep failing every retry for up to ~1h. Force one fresh-token retry.
54
+ if (!detailsResult.ok && detailsResult.reason === 'unauthorized') {
55
+ helloasso.invalidateTokenCache();
56
+ const freshToken = await helloasso.getAccessToken({
57
+ env: settings.helloassoEnv || 'prod',
58
+ clientId: settings.helloassoClientId,
59
+ clientSecret: settings.helloassoClientSecret,
60
+ });
61
+ if (freshToken) {
62
+ detailsResult = await helloasso.getCheckoutIntentDetails({
63
+ env: settings.helloassoEnv || 'prod',
64
+ token: freshToken,
65
+ organizationSlug: settings.helloassoOrganizationSlug,
66
+ checkoutIntentId,
67
+ });
68
+ }
69
+ }
70
+
71
+ if (!detailsResult.ok) {
72
+ // Only a genuine HTTP 404 means "does not exist on HelloAsso". Everything else
73
+ // (rate-limit, auth, network, 5xx) is transient — surface it distinctly so the
74
+ // admin knows to retry rather than assuming the payment is missing.
75
+ const reasonMap = {
76
+ 'not-found': 'checkout-intent-not-found',
77
+ 'rate-limited': 'helloasso-rate-limited-try-again',
78
+ 'unauthorized': 'helloasso-auth-failed',
79
+ 'network-error': 'helloasso-network-error-try-again',
80
+ 'server-error': 'helloasso-server-error-try-again',
81
+ 'missing-params': 'no-checkout-intent-id',
82
+ };
83
+ return {
84
+ ok: false,
85
+ reason: reasonMap[detailsResult.reason] || `helloasso-error-${detailsResult.status || 'unknown'}`,
86
+ httpStatus: detailsResult.status,
87
+ };
88
+ }
89
+ const details = detailsResult.json;
90
+ if (!details) return { ok: false, reason: 'checkout-intent-empty-response' };
52
91
 
53
92
  const order = details.order ? details.order : details;
54
93
  const orderState = String((order.state || order.status) || '').toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.98",
3
+ "version": "2.1.0",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -2433,7 +2433,22 @@ function toDatetimeLocalValue(date) {
2433
2433
  try { bootbox.hideAll(); } catch (e) {}
2434
2434
  } else {
2435
2435
  const reason = (resp && resp.reason) || 'inconnu';
2436
- showAlert('warning', `Paiement non confirmé chez HelloAsso (${reason}).`);
2436
+ const retryReasons = [
2437
+ 'helloasso-rate-limited-try-again',
2438
+ 'helloasso-network-error-try-again',
2439
+ 'helloasso-server-error-try-again',
2440
+ ];
2441
+ if (retryReasons.includes(reason)) {
2442
+ showAlert('warning', 'HelloAsso est momentanément indisponible (limite de requêtes ou erreur réseau) — réessayez dans quelques secondes.');
2443
+ } else if (reason === 'helloasso-auth-failed') {
2444
+ showAlert('warning', 'Connexion à HelloAsso impossible (identifiants API) — vérifiez la configuration dans les réglages du plugin.');
2445
+ } else if (reason === 'checkout-intent-not-found') {
2446
+ showAlert('warning', 'HelloAsso ne trouve plus ce paiement (checkout intent introuvable) — vérifiez manuellement dans le back-office HelloAsso.');
2447
+ } else if (reason === 'not-paid-on-helloasso') {
2448
+ showAlert('warning', 'Paiement pas encore confirmé chez HelloAsso.');
2449
+ } else {
2450
+ showAlert('warning', `Paiement non confirmé chez HelloAsso (${reason}).`);
2451
+ }
2437
2452
  }
2438
2453
  } catch (e) {
2439
2454
  showAlert('error', 'Erreur lors de la vérification du paiement.');
@@ -18,4 +18,4 @@
18
18
 
19
19
  <p><strong>Total estimé :</strong> {total} €</p>
20
20
 
21
- <p><a href="{adminUrl}">Ouvrir l'ACP</a></p>
21
+ <p><a href="https://www.onekite.com/calendar">Voir le calendrier</a></p>
@@ -13,4 +13,4 @@
13
13
 
14
14
  <p>{dateRange}</p>
15
15
 
16
- <p><a href="{adminUrl}">Ouvrir l'ACP (Demandes en attente)</a></p>
16
+ <p><a href="https://www.onekite.com/calendar">Voir le calendrier</a></p>