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 +86 -47
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/templates/admin/plugins/equipment-calendar.tpl +3 -44
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", "
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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].
|
|
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,
|
|
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
|
-
|
|
215
|
+
|
|
216
|
+
// Cache 10 minutes
|
|
217
|
+
const cached = await db.getObject(cacheKey);
|
|
218
218
|
const now = Date.now();
|
|
219
|
-
if (
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
303
|
+
const price = typeof price === 'number' ? price : 0;
|
|
274
304
|
|
|
275
305
|
return {
|
|
276
306
|
id: id || name,
|
|
277
307
|
name,
|
|
278
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
package/plugin.json
CHANGED
|
@@ -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", "
|
|
32
|
-
{ "id": "light1", "name": "Projecteur", "
|
|
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>
|
|
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">
|