nodebb-plugin-equipment-calendar 0.8.3 → 0.9.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/library.js CHANGED
@@ -27,12 +27,12 @@ const DEFAULT_SETTINGS = {
27
27
  creatorGroups: 'registered-users',
28
28
  approverGroup: 'administrators',
29
29
  notifyGroup: 'administrators',
30
- // JSON array of items: [{ "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true }]
30
+ // JSON array of items: [{ "id": "cam1", "name": "Caméra A", "price": 5000, "location": "Stock A", "active": true }]
31
31
  itemsJson: '[]',
32
32
  itemsSource: 'manual',
33
33
  ha_itemsFormType: '',
34
34
  ha_itemsFormSlug: '',
35
- ha_locationMapJson: '{}',
35
+
36
36
  ha_calendarItemNamePrefix: 'Location matériel',
37
37
  paymentTimeoutMinutes: 10,
38
38
  // HelloAsso
@@ -49,18 +49,11 @@ const DEFAULT_SETTINGS = {
49
49
  };
50
50
 
51
51
 
52
- function parseLocationMap(locationMapJson) {
53
- try {
54
- const obj = JSON.parse(locationMapJson || '{}');
55
- return (obj && typeof obj === 'object') ? obj : {};
56
- } catch (e) {
57
- return {};
58
- }
59
- }
60
52
 
61
53
  let haTokenCache = null; // { accessToken, refreshToken, expMs }
62
54
 
63
- async function getHelloAssoAccessToken(settings, opts = {}) {
55
+ async function getHelloAssoAccessToken(settings,
56
+ saved, opts = {}) {
64
57
  const now = Date.now();
65
58
  if (!opts.force && haTokenCache && haTokenCache.accessToken && haTokenCache.expMs && now < haTokenCache.expMs - 30_000) {
66
59
  return haTokenCache.accessToken;
@@ -121,7 +114,8 @@ async function getHelloAssoAccessToken(settings, opts = {}) {
121
114
  }
122
115
 
123
116
 
124
- async function createHelloAssoCheckoutIntent(settings, bookingId, reservations) {
117
+ async function createHelloAssoCheckoutIntent(settings,
118
+ saved, bookingId, reservations) {
125
119
  const org = String(settings.ha_organizationSlug || '').trim();
126
120
  if (!org) throw new Error('HelloAsso organization slug missing');
127
121
 
@@ -136,7 +130,7 @@ async function createHelloAssoCheckoutIntent(settings, bookingId, reservations)
136
130
 
137
131
  const names = reservations.map(r => (byId[r.itemId] && byId[r.itemId].name) || r.itemId);
138
132
  const totalAmount = reservations.reduce((sum, r) => {
139
- const price = (byId[r.itemId] && parseInt(byId[r.itemId].priceCents, 10)) || 0;
133
+ const price = (byId[r.itemId] && parseInt(byId[r.itemId].price, 10)) || 0;
140
134
  return sum + (Number.isFinite(price) ? price : 0);
141
135
  }, 0);
142
136
 
@@ -180,7 +174,8 @@ async function createHelloAssoCheckoutIntent(settings, bookingId, reservations)
180
174
  return await resp.json();
181
175
  }
182
176
 
183
- async function fetchHelloAssoCheckoutIntent(settings, checkoutIntentId) {
177
+ async function fetchHelloAssoCheckoutIntent(settings,
178
+ saved, checkoutIntentId) {
184
179
  const org = String(settings.ha_organizationSlug || '').trim();
185
180
  const token = await getHelloAssoAccessToken(settings);
186
181
  const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents/${encodeURIComponent(checkoutIntentId)}`;
@@ -207,38 +202,75 @@ function isCheckoutPaid(checkout) {
207
202
  return false;
208
203
  }
209
204
 
205
+
210
206
  async function fetchHelloAssoItems(settings) {
211
207
  const org = String(settings.ha_organizationSlug || '').trim();
212
208
  const formType = String(settings.ha_itemsFormType || '').trim();
213
209
  const formSlug = String(settings.ha_itemsFormSlug || '').trim();
214
210
  if (!org || !formType || !formSlug) return [];
215
211
 
212
+ const token = await getHelloAssoAccessToken(settings);
213
+ const base = (String(settings.ha_apiBaseUrl || '').trim() || 'https://api.helloasso.com').replace(/\/$/, '');
216
214
  const cacheKey = `equipmentCalendar:ha:items:${org}:${formType}:${formSlug}`;
217
- const cache = await db.getObject(cacheKey);
215
+
216
+ // Cache 10 minutes
217
+ const cached = await db.getObject(cacheKey);
218
218
  const now = Date.now();
219
- if (cache && cache.payload && cache.expiresAt && now < parseInt(cache.expiresAt, 10)) {
220
- try {
221
- return JSON.parse(cache.payload);
222
- } catch (e) {}
219
+ if (cached && cached.itemsJson && cached.expMs && now < parseInt(cached.expMs, 10)) {
220
+ try { return JSON.parse(cached.itemsJson); } catch (e) {}
223
221
  }
224
222
 
225
- const token = await getHelloAssoAccessToken(settings);
226
- const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/items`;
227
- const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}` } });
223
+ // IMPORTANT:
224
+ // - /forms/.../items = "articles vendus" (liés aux commandes) => peut renvoyer 0 si aucune commande
225
+ // - /forms/.../public = données publiques détaillées => contient la structure (tiers / products) du formulaire
226
+ const publicUrl = `${base}/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/public`;
227
+ const resp = await fetchFn(publicUrl, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
228
228
  if (!resp.ok) {
229
229
  const t = await resp.text();
230
- throw new Error(`HelloAsso items error: ${resp.status} ${t}`);
230
+ throw new Error(`HelloAsso public form error: ${resp.status} ${t}`);
231
+ }
232
+ const data = await resp.json();
233
+
234
+ // Extract catalog items:
235
+ // structure differs by formType; common pattern: data.tiers[] or data.tiers[].products[] / items[]
236
+ const out = [];
237
+ const tiers = (data && (data.tiers || data.tiersList || data.prices || data.priceCategories)) || [];
238
+ const tierArr = Array.isArray(tiers) ? tiers : [];
239
+
240
+ function pushItem(it, tierName) {
241
+ if (!it) return;
242
+ const id = String(it.id || it.itemId || it.reference || it.slug || it.code || it.name || '').trim();
243
+ const name = String(it.name || it.label || it.title || '').trim() || (tierName ? String(tierName) : id);
244
+ if (!id || !name) return;
245
+ const amount = it.amount || it.price || it.unitPrice || it.totalAmount || it.initialAmount;
246
+ const price = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
247
+ out.push({ id, name, price: String(price), location: '' });
231
248
  }
232
- const json = await resp.json();
233
- // API responses are usually { data: [...] } but keep it flexible
234
- const list = Array.isArray(json) ? json : (Array.isArray(json.data) ? json.data : []);
235
249
 
236
- // cache 15 minutes
237
- try {
238
- await db.setObject(cacheKey, { payload: JSON.stringify(list), expiresAt: String(now + 15 * 60 * 1000) });
239
- } catch (e) {}
250
+ // Try a few known layouts
251
+ for (const t of tierArr) {
252
+ const tierName = t && (t.name || t.label || t.title);
253
+ const products = (t && (t.items || t.products || t.prices || t.options)) || [];
254
+ const arr = Array.isArray(products) ? products : [];
255
+ if (arr.length) {
256
+ arr.forEach(p => pushItem(p, tierName));
257
+ } else {
258
+ // sometimes tier itself is the product
259
+ pushItem(t, tierName);
260
+ }
261
+ }
262
+
263
+ // Fallback: some forms expose items directly
264
+ if (!out.length && data && Array.isArray(data.items)) {
265
+ data.items.forEach(p => pushItem(p, ''));
266
+ }
267
+
268
+ await db.setObject(cacheKey, {
269
+ itemsJson: JSON.stringify(out),
270
+ expMs: String(Date.now() + 10 * 60 * 1000),
271
+ });
240
272
 
241
- return list;
273
+ return out;
242
274
  }
243
275
 
244
276
  function parseItems(itemsJson) {
@@ -248,7 +280,7 @@ function parseItems(itemsJson) {
248
280
  return arr.map(it => ({
249
281
  id: String(it.id || '').trim(),
250
282
  name: String(it.name || '').trim(),
251
- priceCents: Number(it.priceCents || 0),
283
+ price: Number(it.price || 0),
252
284
  location: String(it.location || '').trim(),
253
285
  active: it.active !== false,
254
286
  })).filter(it => it.id && it.name);
@@ -260,9 +292,7 @@ function parseItems(itemsJson) {
260
292
  async function getActiveItems(settings) {
261
293
  const source = String(settings.itemsSource || 'manual');
262
294
  if (source === 'helloasso') {
263
- const rawItems = await fetchHelloAssoItems(settings);
264
- const locMap = parseLocationMap(settings.ha_locationMapJson);
265
- return (rawItems || []).map((it) => {
295
+ const rawItems = await fetchHelloAssoItems(settings); return (rawItems || []).map((it) => {
266
296
  const id = String(it.id || it.itemId || it.reference || it.slug || it.name || '').trim();
267
297
  const name = String(it.name || it.label || it.title || id).trim();
268
298
  // Price handling is not displayed in the public calendar, keep a field if you want later
@@ -270,13 +300,12 @@ async function getActiveItems(settings) {
270
300
  (it.price && (it.price.value || it.price.amount)) ||
271
301
  (it.amount && (it.amount.value || it.amount)) ||
272
302
  it.price;
273
- const priceCents = typeof price === 'number' ? price : 0;
303
+ const price = typeof price === 'number' ? price : 0;
274
304
 
275
305
  return {
276
306
  id: id || name,
277
307
  name,
278
- location: String(locMap[id] || ''),
279
- priceCents,
308
+ price,
280
309
  active: true,
281
310
  source: 'helloasso',
282
311
  };
@@ -476,14 +505,15 @@ async function helloAssoGetAccessToken(settings) {
476
505
  return resp.data.access_token;
477
506
  }
478
507
 
479
- async function helloAssoCreateCheckout(settings, token, reservation, item) {
508
+ async function helloAssoCreateCheckout(settings,
509
+ saved, token, reservation, item) {
480
510
  // Minimal: create a checkout intent and return redirectUrl
481
511
  // This endpoint/payload may need adaptation depending on your HelloAsso setup.
482
512
  const org = settings.ha_organizationSlug;
483
513
  if (!org) throw new Error('HelloAsso organizationSlug missing');
484
514
 
485
515
  const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
486
- const amountCents = Math.max(0, Number(item.priceCents || 0));
516
+ const amountCents = Math.max(0, Number(item.price || 0));
487
517
 
488
518
  const payload = {
489
519
  totalAmount: amountCents,
@@ -734,6 +764,7 @@ async function renderAdminReservationsPage(req, res) {
734
764
  res.render('admin/plugins/equipment-calendar-reservations', {
735
765
  title: 'Equipment Calendar - Réservations',
736
766
  settings,
767
+ saved,
737
768
  rows: pageRows,
738
769
  hasRows: pageRows.length > 0,
739
770
  itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
@@ -805,7 +836,8 @@ async function handleHelloAssoTest(req, res) {
805
836
  const clear = String(req.query.clear || '') === '1';
806
837
  // force=1 skips in-memory cache and refresh_token; clear=1 wipes stored refresh token
807
838
  haTokenCache = null;
808
- await getHelloAssoAccessToken(settings, { force, clearStored: clear });
839
+ await getHelloAssoAccessToken(settings,
840
+ saved, { force, clearStored: clear });
809
841
  const items = await fetchHelloAssoItems(settings);
810
842
  const list = Array.isArray(items) ? items : (Array.isArray(items.data) ? items.data : []);
811
843
  count = list.length;
@@ -816,7 +848,7 @@ async function handleHelloAssoTest(req, res) {
816
848
  }));
817
849
  hasSampleItems = sampleItems && sampleItems.length > 0;
818
850
  ok = true;
819
- message = `OK: token valide. Items récupérés: ${count}.`;
851
+ message = `OK: token valide. Catalogue récupéré via /public : ${count} item(s).`;
820
852
  } catch (e) {
821
853
  ok = false;
822
854
  message = (e && e.message) ? e.message : String(e);
@@ -828,6 +860,7 @@ async function handleHelloAssoTest(req, res) {
828
860
  message,
829
861
  count,
830
862
  settings,
863
+ saved,
831
864
  sampleItems,
832
865
  hasSampleItems,
833
866
  hasSampleItems: sampleItems && sampleItems.length > 0,
@@ -836,9 +869,11 @@ async function handleHelloAssoTest(req, res) {
836
869
 
837
870
  async function renderAdminPage(req, res) {
838
871
  const settings = await getSettings();
872
+ const saved = String(req.query.saved || '') === '1';
839
873
  res.render('admin/plugins/equipment-calendar', {
840
874
  title: 'Equipment Calendar',
841
875
  settings,
876
+ saved,
842
877
  saved: req.query && String(req.query.saved || '') === '1',
843
878
  purged: req.query && parseInt(req.query.purged, 10) || 0,
844
879
  view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
@@ -863,7 +898,8 @@ async function handleHelloAssoCallback(req, res) {
863
898
 
864
899
  try {
865
900
  if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
866
- const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
901
+ const checkout = await fetchHelloAssoCheckoutIntent(settings,
902
+ saved, checkoutIntentId);
867
903
 
868
904
  const paid = isCheckoutPaid(checkout);
869
905
  if (paid) {
@@ -1113,7 +1149,8 @@ async function renderApprovalsPage(req, res) {
1113
1149
 
1114
1150
  // --- Actions ---
1115
1151
 
1116
- async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
1152
+ async function createReservationForItem(req, res, settings,
1153
+ saved, itemId, startMs, endMs, notesUser, bookingId) {
1117
1154
  const items = await getActiveItems(settings);
1118
1155
  const item = items.find(i => i.id === itemId);
1119
1156
  if (!item) {
@@ -1157,7 +1194,8 @@ async function handleCreateReservation(req, res) {
1157
1194
  const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
1158
1195
  const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
1159
1196
  if (!itemIds.length) {
1160
- return res.status(400).send('itemId required');
1197
+ req.flash('error', 'Veuillez sélectionner au moins un matériel.');
1198
+ return res.redirect('/equipment/calendar');
1161
1199
  }
1162
1200
  const item = items.find(i => i.id === itemId);
1163
1201
  if (!item) return res.status(400).send('Invalid item');
@@ -1237,7 +1275,8 @@ async function handleApproveReservation(req, res) {
1237
1275
  return res.status(400).send('HelloAsso not configured');
1238
1276
  }
1239
1277
  const token = await helloAssoGetAccessToken(settings);
1240
- const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
1278
+ const checkout = await helloAssoCreateCheckout(settings,
1279
+ saved, token, reservation, item);
1241
1280
  reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
1242
1281
  reservation.ha_paymentUrl = checkout.paymentUrl;
1243
1282
  }
@@ -1389,7 +1428,7 @@ async function handleAdminSave(req, res) {
1389
1428
  itemsSource: String(req.body.itemsSource || DEFAULT_SETTINGS.itemsSource),
1390
1429
  ha_itemsFormType: String(req.body.ha_itemsFormType || DEFAULT_SETTINGS.ha_itemsFormType),
1391
1430
  ha_itemsFormSlug: String(req.body.ha_itemsFormSlug || DEFAULT_SETTINGS.ha_itemsFormSlug),
1392
- ha_locationMapJson: String(req.body.ha_locationMapJson || DEFAULT_SETTINGS.ha_locationMapJson),
1431
+
1393
1432
  ha_clientId: String(req.body.ha_clientId || ''),
1394
1433
  ha_clientSecret: String(req.body.ha_clientSecret || ''),
1395
1434
  ha_organizationSlug: String(req.body.ha_organizationSlug || ''),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
5
  "main": "library.js",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -25,6 +25,6 @@
25
25
  "scripts": [
26
26
  "public/js/client.js"
27
27
  ],
28
- "version": "0.4.9",
28
+ "version": "0.5.2",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -28,55 +28,14 @@
28
28
  <div class="alert alert-warning">
29
29
  Le champ "Matériel" doit être un JSON valide (array). Exemple :
30
30
  <pre class="mb-0">[
31
- { "id": "cam1", "name": "Caméra A", "priceCents": 50, "location": "Stock A", "active": true },
32
- { "id": "light1", "name": "Projecteur", "priceCents": 20, "location": "Stock B", "active": true }
31
+ { "id": "cam1", "name": "Caméra A", "price": 50, "": "Stock A", "active": true },
32
+ { "id": "light1", "name": "Projecteur", "price": 20, "": "Stock B", "active": true }
33
33
  ]</pre>
34
- <div class="mt-2">Note : <strong>priceCents est en euros</strong> dans ce plugin (ex: 50 = 50€).</div>
34
+ <div class="mt-2">Note : <strong>price est en euros</strong> dans ce plugin (ex: 50 = 50€).</div>
35
35
  </div>
36
36
 
37
37
  <div class="card card-body mb-3">
38
38
  <h5>Permissions</h5>
39
- <div class="mb-3">
40
- <label class="form-label">Groupes autorisés à créer (CSV)</label>
41
- <input name="creatorGroups" class="form-control" value="{settings.creatorGroups}">
42
- </div>
43
- <div class="mb-3">
44
- <label class="form-label">Groupe validateur</label>
45
- <input name="approverGroup" class="form-control" value="{settings.approverGroup}">
46
- </div>
47
- <div class="mb-3">
48
- <label class="form-label">Groupe notifié (emails/notifs)</label>
49
- <input name="notifyGroup" class="form-control" value="{settings.notifyGroup}">
50
- </div>
51
- <div class="form-check">
52
- <input class="form-check-input" type="checkbox" name="showRequesterToAll" id="showRequesterToAll" {{{ if view_showRequesterToAll }}}checked{{{ end }}}>
53
- <label class="form-check-label" for="showRequesterToAll">Afficher le demandeur à tout le monde</label>
54
- </div>
55
- </div>
56
-
57
- <div class="card card-body mb-3">
58
- <h5>Matériel</h5>
59
- <div class="mb-3">
60
- <label class="form-label">Source</label>
61
- <select class="form-select" name="itemsSource">
62
- <option value="manual">Manuel (JSON)</option>
63
- <option value="helloasso">HelloAsso (articles d’un formulaire)</option>
64
- </select>
65
- <div class="form-text">Si HelloAsso est choisi, la liste du matériel est récupérée via l’API HelloAsso (items d’un formulaire).</div>
66
- </div>
67
-
68
- <div class="mb-3">
69
- <label class="form-label">HelloAsso formType (ex: shop, event, membership, donation)</label>
70
- <input class="form-control" name="ha_itemsFormType" value="{settings.ha_itemsFormType}">
71
- </div>
72
- <div class="mb-3">
73
- <label class="form-label">HelloAsso formSlug</label>
74
- <input class="form-control" name="ha_itemsFormSlug" value="{settings.ha_itemsFormSlug}">
75
- </div>
76
- <div class="mb-3">
77
- <label class="form-label">Mapping lieux (JSON) par id d’article HelloAsso</label>
78
- <textarea class="form-control" rows="4" name="ha_locationMapJson">{settings.ha_locationMapJson}</textarea>
79
- <div class="form-text">Ex: { "12345": "Local A", "67890": "Local B" }</div>
80
39
  </div>
81
40
 
82
41
  <div class="mb-3">