nodebb-plugin-onekite-calendar 1.0.21 → 1.0.23

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/CHANGELOG.md CHANGED
@@ -4,8 +4,6 @@
4
4
  - Suppression du texte d’archivage dans le toast de purge (plus de « 0 archivés »)
5
5
  - Renommage du plugin : nodebb-plugin-onekite-calendar
6
6
  - Discord : notification « ❌ Réservation annulée » (annulation manuelle + annulation automatique) + option ACP
7
- - Groupes ACP : les champs CSV acceptent aussi ; et retours à la ligne
8
- - Notifications email (groupes) : résolution robuste nom/slug et normalisation des membres (uids)
9
7
 
10
8
  ## 1.0.2
11
9
  - Purge calendrier : suppression réelle des réservations (aucune logique d’archivage)
package/lib/admin.js CHANGED
@@ -32,49 +32,24 @@ function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
32
32
  return out;
33
33
  }
34
34
 
35
- async function sendEmail(template, toEmail, subject, data) {
36
- // Prefer sending by uid (NodeBB core expects uid in various places)
37
- const uid = data && Number.isInteger(data.uid) ? data.uid : null;
38
- if (!toEmail && !uid) return;
35
+ async function sendEmail(template, uid, subject, data) {
36
+ const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
37
+ if (!Number.isInteger(toUid) || toUid <= 0) return;
39
38
 
40
- const settings = await meta.settings.get('calendar-onekite').catch(() => ({}));
41
- const lang = (settings && settings.defaultLang) || (meta && meta.config && meta.config.defaultLang) || 'fr';
42
39
  const params = Object.assign({}, data || {}, subject ? { subject } : {});
43
40
 
44
- // If we have a uid, use the native uid-based sender first.
45
41
  try {
46
- if (uid && typeof emailer.send === 'function') {
47
- // NodeBB: send(template, uid, params)
48
- if (emailer.send.length >= 3) {
49
- await emailer.send(template, uid, params);
50
- } else {
51
- await emailer.send(template, uid, params);
52
- }
53
- return;
42
+ if (typeof emailer.send !== 'function') return;
43
+ // NodeBB 4.x: send(template, uid, params)
44
+ if (emailer.send.length >= 3) {
45
+ await emailer.send(template, toUid, params);
46
+ } else {
47
+ await emailer.send(template, toUid);
54
48
  }
55
49
  } catch (err) {
56
50
  console.warn('[calendar-onekite] Failed to send email', {
57
51
  template,
58
- toEmail,
59
- err: err && err.message ? err.message : String(err),
60
- });
61
- }
62
-
63
- try {
64
- if (typeof emailer.sendToEmail === 'function') {
65
- // NodeBB: sendToEmail(template, email, language, params)
66
- if (emailer.sendToEmail.length >= 4) {
67
- await emailer.sendToEmail(template, toEmail, lang, params);
68
- } else {
69
- // Older signature: sendToEmail(template, email, params)
70
- await emailer.sendToEmail(template, toEmail, params);
71
- }
72
- return;
73
- }
74
- } catch (err) {
75
- console.warn('[calendar-onekite] Failed to send email', {
76
- template,
77
- toEmail,
52
+ uid: toUid,
78
53
  err: err && err.message ? err.message : String(err),
79
54
  });
80
55
  }
@@ -187,11 +162,11 @@ admin.approveReservation = async function (req, res) {
187
162
  // User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
188
163
  callbackUrl: returnUrl,
189
164
  webhookUrl: webhookUrl,
190
- itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
165
+ itemName: buildHelloAssoItemName('Réservation matériel Onekite', Array.isArray(r.itemNames) ? r.itemNames : [], r.start, r.end),
191
166
  containsDonation: false,
192
167
  metadata: {
193
168
  reservationId: String(rid),
194
- items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
169
+ items: (Array.isArray(r.itemNames) ? r.itemNames : []).filter(Boolean),
195
170
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
196
171
  },
197
172
  });
@@ -213,18 +188,19 @@ admin.approveReservation = async function (req, res) {
213
188
 
214
189
  // Email requester
215
190
  try {
216
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
217
- if (requester && requester.email) {
191
+ const requesterUid = parseInt(r.uid, 10);
192
+ const requester = await user.getUserFields(requesterUid, ['username']);
193
+ if (requesterUid) {
218
194
  const latNum = Number(r.pickupLat);
219
195
  const lonNum = Number(r.pickupLon);
220
196
  const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
221
197
  ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
222
198
  : '';
223
- await sendEmail('calendar-onekite_approved', requester.email, 'Location matériel - Réservation validée', {
224
- uid: parseInt(r.uid, 10),
225
- username: requester.username,
226
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
227
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
199
+ await sendEmail('calendar-onekite_approved', requesterUid, 'Location matériel - Réservation validée', {
200
+ uid: requesterUid,
201
+ username: requester && requester.username ? requester.username : '',
202
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : ''),
203
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : []),
228
204
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
229
205
  paymentUrl: paymentUrl || '',
230
206
  pickupAddress: r.pickupAddress || '',
@@ -253,13 +229,14 @@ admin.refuseReservation = async function (req, res) {
253
229
  await dbLayer.saveReservation(r);
254
230
 
255
231
  try {
256
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
257
- if (requester && requester.email) {
258
- await sendEmail('calendar-onekite_refused', requester.email, 'Location matériel - Réservation refusée', {
259
- uid: parseInt(r.uid, 10),
260
- username: requester.username,
261
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
262
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
232
+ const requesterUid = parseInt(r.uid, 10);
233
+ const requester = await user.getUserFields(requesterUid, ['username']);
234
+ if (requesterUid) {
235
+ await sendEmail('calendar-onekite_refused', requesterUid, 'Location matériel - Réservation refusée', {
236
+ uid: requesterUid,
237
+ username: requester && requester.username ? requester.username : '',
238
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : ''),
239
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : []),
263
240
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
264
241
  start: formatFR(r.start),
265
242
  end: formatFR(r.end),
@@ -442,7 +419,7 @@ admin.getAccounting = async function (req, res) {
442
419
 
443
420
  const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
444
421
  ? r.itemNames
445
- : (r.itemName ? [r.itemName] : []);
422
+ : [];
446
423
 
447
424
  const total = Number(r.total) || 0;
448
425
  const startDate = formatFR(r.start);
package/lib/api.js CHANGED
@@ -20,27 +20,6 @@ async function getMembersByGroupIdentifier(groupIdentifier) {
20
20
  const id = String(groupIdentifier || '').trim();
21
21
  if (!id) return [];
22
22
 
23
- const toUids = (arr) => {
24
- if (!Array.isArray(arr)) return [];
25
- // NodeBB may return an array of uids (numbers/strings), or an array of user objects (with uid).
26
- const uids = arr.map((v) => {
27
- if (v == null) return null;
28
- if (typeof v === 'number') return v;
29
- if (typeof v === 'string') {
30
- const n = parseInt(v, 10);
31
- return Number.isFinite(n) ? n : null;
32
- }
33
- if (typeof v === 'object') {
34
- const maybe = v.uid ?? v.userId ?? v.id;
35
- const n = parseInt(String(maybe), 10);
36
- return Number.isFinite(n) ? n : null;
37
- }
38
- return null;
39
- }).filter((n) => Number.isFinite(n));
40
- // de-dupe
41
- return [...new Set(uids)];
42
- };
43
-
44
23
  // First try direct.
45
24
  let members = [];
46
25
  try {
@@ -48,17 +27,7 @@ async function getMembersByGroupIdentifier(groupIdentifier) {
48
27
  } catch (e) {
49
28
  members = [];
50
29
  }
51
- const directUids = toUids(members);
52
- if (directUids.length) return directUids;
53
-
54
- // Some NodeBB builds expose getMembersByGroupSlug (slug -> uids)
55
- if (typeof groups.getMembersByGroupSlug === 'function') {
56
- try {
57
- const bySlug = await groups.getMembersByGroupSlug(id, 0, -1);
58
- const bySlugUids = toUids(bySlug);
59
- if (bySlugUids.length) return bySlugUids;
60
- } catch (e) {}
61
- }
30
+ if (Array.isArray(members) && members.length) return members;
62
31
 
63
32
  // Then try slug -> groupName mapping when available.
64
33
  if (typeof groups.getGroupNameByGroupSlug === 'function') {
@@ -81,12 +50,27 @@ async function getMembersByGroupIdentifier(groupIdentifier) {
81
50
  } catch (e) {
82
51
  members = [];
83
52
  }
84
- const byNameUids = toUids(members);
85
- if (byNameUids.length) return byNameUids;
53
+ if (Array.isArray(members) && members.length) return members;
86
54
  }
87
55
  }
88
56
 
89
- return [];
57
+ return Array.isArray(members) ? members : [];
58
+ }
59
+
60
+
61
+ function normalizeUids(members) {
62
+ if (!Array.isArray(members)) return [];
63
+ const out = [];
64
+ for (const m of members) {
65
+ if (Number.isInteger(m)) { out.push(m); continue; }
66
+ if (typeof m === 'string' && m.trim() && !Number.isNaN(parseInt(m, 10))) { out.push(parseInt(m, 10)); continue; }
67
+ if (m && typeof m === 'object' && (Number.isInteger(m.uid) || (typeof m.uid === 'string' && m.uid.trim()))) {
68
+ const u = Number.isInteger(m.uid) ? m.uid : parseInt(m.uid, 10);
69
+ if (!Number.isNaN(u)) out.push(u);
70
+ }
71
+ }
72
+ // de-dupe
73
+ return Array.from(new Set(out));
90
74
  }
91
75
 
92
76
  // Fast membership check without N calls to groups.isMember.
@@ -120,8 +104,7 @@ function normalizeAllowedGroups(raw) {
120
104
  if (Array.isArray(parsed)) return parsed.map(v => String(v).trim()).filter(Boolean);
121
105
  } catch (e) {}
122
106
  }
123
- // Accept commas, semicolons, or new lines as separators
124
- return s.split(/[,;\n]+/).map(v => String(v).trim().replace(/^"+|"+$/g, '')).filter(Boolean);
107
+ return s.split(',').map(v => String(v).trim().replace(/^"+|"+$/g, '')).filter(Boolean);
125
108
  }
126
109
 
127
110
  // NOTE: Avoid per-group async checks (groups.isMember) when possible.
@@ -130,54 +113,25 @@ function normalizeAllowedGroups(raw) {
130
113
  const helloasso = require('./helloasso');
131
114
  const discord = require('./discord');
132
115
 
133
- // Email helper: NodeBB's Emailer signature differs across versions.
134
- // We try the common forms. Any failure is logged for debugging.
135
- async function sendEmail(template, toEmail, subject, data) {
136
- if (!toEmail) return;
137
-
138
- // Language can come from plugin settings or NodeBB config depending on version.
139
- let lang = 'fr';
140
- try {
141
- const s = await meta.settings.get('calendar-onekite').catch(() => ({}));
142
- lang = (s && s.defaultLang) || (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
143
- } catch (e) {
144
- lang = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
145
- }
116
+ // Email helper (NodeBB 4.x): always send by uid.
117
+ // Subject must be provided inside params.subject.
118
+ async function sendEmail(template, uid, subject, data) {
119
+ const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
120
+ if (!Number.isInteger(toUid) || toUid <= 0) return;
146
121
 
147
- // Subject is not a positional arg; it must be injected via params.subject.
148
122
  const params = Object.assign({}, data || {}, subject ? { subject } : {});
149
123
 
150
- // Prefer sendToEmail when available (most consistent across versions).
151
124
  try {
152
- if (typeof emailer.sendToEmail === 'function') {
153
- // NodeBB: sendToEmail(template, email, language, params)
154
- if (emailer.sendToEmail.length >= 4) {
155
- await emailer.sendToEmail(template, toEmail, lang, params);
156
- } else {
157
- // Older signature: sendToEmail(template, email, params)
158
- await emailer.sendToEmail(template, toEmail, params);
159
- }
160
- return;
161
- }
162
- } catch (err) {
163
- // eslint-disable-next-line no-console
164
- console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String((err && err.message) || err) });
165
- }
166
-
167
- // Very old/unusual builds: try send() but only if it clearly accepts an email.
168
- try {
169
- if (typeof emailer.send === 'function') {
170
- // Some builds accept (template, email, language, params)
171
- if (emailer.send.length >= 4) {
172
- await emailer.send(template, toEmail, lang, params);
173
- } else {
174
- // Some builds accept (template, email, params)
175
- await emailer.send(template, toEmail, params);
176
- }
125
+ if (typeof emailer.send !== 'function') return;
126
+ // NodeBB: send(template, uid, params)
127
+ if (emailer.send.length >= 3) {
128
+ await emailer.send(template, toUid, params);
129
+ } else {
130
+ await emailer.send(template, toUid);
177
131
  }
178
132
  } catch (err) {
179
133
  // eslint-disable-next-line no-console
180
- console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String((err && err.message) || err) });
134
+ console.warn('[calendar-onekite] Failed to send email', { template, uid: toUid, err: String((err && err.message) || err) });
181
135
  }
182
136
  }
183
137
 
@@ -374,15 +328,18 @@ function eventsFor(resv) {
374
328
  const startIsoDate = new Date(parseInt(resv.start, 10)).toISOString().slice(0, 10);
375
329
  const endIsoDate = new Date(parseInt(resv.end, 10)).toISOString().slice(0, 10);
376
330
 
377
- const itemIds = Array.isArray(resv.itemIds) ? resv.itemIds : (resv.itemId ? [resv.itemId] : []);
378
- const itemNames = Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : []);
331
+ const itemIds = Array.isArray(resv.itemIds) ? resv.itemIds.filter(Boolean) : [];
332
+ const itemNames = Array.isArray(resv.itemNames) ? resv.itemNames.filter(Boolean) : [];
379
333
 
380
334
  // One line = one material: return one calendar event per item
335
+ if (!itemIds.length && !itemNames.length) {
336
+ return [];
337
+ }
381
338
  const out = [];
382
- const count = Math.max(itemIds.length, itemNames.length, 1);
339
+ const count = Math.max(itemIds.length, itemNames.length);
383
340
  for (let i = 0; i < count; i++) {
384
- const itemId = String(itemIds[i] || itemIds[0] || resv.itemId || '');
385
- const itemName = String(itemNames[i] || itemNames[0] || resv.itemName || itemId);
341
+ const itemId = String(itemIds[i] || itemIds[0] || i);
342
+ const itemName = String(itemNames[i] || itemNames[0] || itemId);
386
343
  out.push({
387
344
  // keep id unique per item for FullCalendar, but keep the real rid in extendedProps.rid
388
345
  id: `${resv.rid}:${itemId || i}`,
@@ -399,15 +356,14 @@ function eventsFor(resv) {
399
356
  uid: resv.uid,
400
357
  approvedBy: resv.approvedBy || 0,
401
358
  approvedByUsername: resv.approvedByUsername || '',
402
- itemIds: itemIds.filter(Boolean),
403
- itemNames: itemNames.filter(Boolean),
359
+ itemIds: itemIds,
360
+ itemNames: itemNames,
404
361
  itemIdLine: itemId,
405
362
  itemNameLine: itemName,
406
363
  },
407
364
  });
408
365
  }
409
366
  return out;
410
- }
411
367
 
412
368
  function eventsForSpecial(ev) {
413
369
  const start = new Date(parseInt(ev.start, 10));
@@ -577,8 +533,8 @@ api.getReservationDetails = async function (req, res) {
577
533
  status: r.status,
578
534
  uid: r.uid,
579
535
  username: r.username || '',
580
- itemNames: Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []),
581
- itemIds: Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : []),
536
+ itemNames: Array.isArray(r.itemNames) ? r.itemNames : [],
537
+ itemIds: Array.isArray(r.itemIds) ? r.itemIds : [],
582
538
  start: r.start,
583
539
  end: r.end,
584
540
  approvedByUsername: r.approvedByUsername || '',
@@ -742,9 +698,9 @@ api.createReservation = async function (req, res) {
742
698
  return res.status(400).json({ error: "bad-dates" });
743
699
  }
744
700
 
745
- // Support both legacy single itemId and new itemIds[] payload
746
- const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds.map(String) : ((req.body.itemId ? [String(req.body.itemId)] : []));
747
- const itemNames = Array.isArray(req.body.itemNames) ? req.body.itemNames.map(String) : (req.body.itemName ? [String(req.body.itemName)] : []);
701
+ // NodeBB 4.x only: require modern payload
702
+ const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds.map(String) : [];
703
+ const itemNames = Array.isArray(req.body.itemNames) ? req.body.itemNames.map(String) : [];
748
704
 
749
705
  const total = typeof req.body.total === 'number' ? req.body.total : parseFloat(String(req.body.total || '0'));
750
706
 
@@ -763,7 +719,7 @@ api.createReservation = async function (req, res) {
763
719
  const exStart = parseInt(existing.start, 10);
764
720
  const exEnd = parseInt(existing.end, 10);
765
721
  if (!(exStart < end && start < exEnd)) continue;
766
- const exItemIds = Array.isArray(existing.itemIds) ? existing.itemIds : (existing.itemId ? [existing.itemId] : []);
722
+ const exItemIds = Array.isArray(existing.itemIds) ? existing.itemIds : [];
767
723
  const shared = exItemIds.filter(x => itemIds.includes(String(x)));
768
724
  if (shared.length) {
769
725
  conflicts.push({ rid: existing.rid, itemIds: shared, status: existing.status });
@@ -788,9 +744,6 @@ api.createReservation = async function (req, res) {
788
744
  username: username || null,
789
745
  itemIds,
790
746
  itemNames: itemNames.length ? itemNames : itemIds,
791
- // keep legacy fields for backward compatibility
792
- itemId: itemIds[0],
793
- itemName: (itemNames[0] || itemIds[0]),
794
747
  start,
795
748
  end,
796
749
  status: 'pending',
@@ -801,44 +754,35 @@ api.createReservation = async function (req, res) {
801
754
  // Save
802
755
  await dbLayer.saveReservation(resv);
803
756
 
804
- // Notify groups by email (NodeBB emailer config)
757
+ // Notify groups by email (NodeBB 4.x, UID-only)
805
758
  try {
806
- const notifyGroups = (settings.notifyGroups || '').split(',').map(s => s.trim()).filter(Boolean);
759
+ const notifyGroups = String(settings.notifyGroups || '')
760
+ .split(/[\n,;]+/)
761
+ .map(s => s.trim())
762
+ .filter(Boolean);
763
+
807
764
  if (notifyGroups.length) {
808
- const requester = await user.getUserFields(uid, ['username', 'email']);
809
- const itemsLabel = (resv.itemNames || []).join(', ');
765
+ const requester = await user.getUserFields(uid, ['username']);
766
+ const itemsLabel = (itemNames && itemNames.length ? itemNames : itemIds).join(', ');
767
+
810
768
  for (const g of notifyGroups) {
811
769
  const members = await getMembersByGroupIdentifier(g);
812
- const uids = Array.isArray(members) ? members : [];
813
-
814
- // Batch fetch user email/username when supported by this NodeBB version.
815
- let usersData = [];
816
- try {
817
- if (typeof user.getUsersFields === 'function') {
818
- usersData = await user.getUsersFields(uids, ['username', 'email']);
819
- } else {
820
- usersData = await Promise.all(uids.map(async (memberUid) => {
821
- try { return await user.getUserFields(memberUid, ['username', 'email']); }
822
- catch (e) { return null; }
823
- }));
824
- }
825
- } catch (e) {
826
- usersData = [];
827
- }
828
-
829
- for (const md of (usersData || [])) {
830
- if (md && md.email) {
831
- await sendEmail('calendar-onekite_pending', md.email, 'Location matériel - Demande de réservation', {
832
- username: md.username,
833
- requester: requester.username,
834
- itemName: itemsLabel,
835
- itemNames: resv.itemNames || [],
836
- dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
837
- start: formatFR(start),
838
- end: formatFR(end),
839
- total: resv.total || 0,
840
- });
841
- }
770
+ const uids = normalizeUids(members);
771
+
772
+ for (const memberUid of uids) {
773
+ const u = parseInt(memberUid, 10);
774
+ if (!Number.isInteger(u) || u <= 0) continue;
775
+
776
+ await sendEmail('calendar-onekite_pending', u, 'Location matériel - Demande de réservation', {
777
+ uid: u,
778
+ requester: requester && requester.username ? requester.username : '',
779
+ itemName: itemsLabel,
780
+ itemNames: (itemNames && itemNames.length ? itemNames : itemIds),
781
+ dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
782
+ start: formatFR(start),
783
+ end: formatFR(end),
784
+ total: resv.total || 0,
785
+ });
842
786
  }
843
787
  }
844
788
  }
@@ -852,8 +796,8 @@ api.createReservation = async function (req, res) {
852
796
  rid: resv.rid,
853
797
  uid: resv.uid,
854
798
  username: resv.username || '',
855
- itemIds: resv.itemIds || [],
856
- itemNames: resv.itemNames || [],
799
+ itemIds: itemIds || [],
800
+ itemNames: itemNames || [],
857
801
  start: resv.start,
858
802
  end: resv.end,
859
803
  status: resv.status,
@@ -877,8 +821,7 @@ api.approveReservation = async function (req, res) {
877
821
  if (r.status !== 'pending') return res.status(400).json({ error: 'bad-status' });
878
822
 
879
823
  r.status = 'awaiting_payment';
880
- // Backwards compatible: old clients sent `adminNote` to describe the pickup place.
881
- r.pickupAddress = String((req.body && (req.body.pickupAddress || req.body.adminNote)) || '').trim();
824
+ r.pickupAddress = String((req.body && req.body.pickupAddress) || '').trim();
882
825
  r.notes = String((req.body && req.body.notes) || '').trim();
883
826
  r.pickupTime = String((req.body && req.body.pickupTime) || '').trim();
884
827
  r.pickupLat = String((req.body && req.body.pickupLat) || '').trim();
@@ -918,11 +861,11 @@ api.approveReservation = async function (req, res) {
918
861
  // Can be overridden via ACP setting `helloassoCallbackUrl`.
919
862
  callbackUrl: normalizeReturnUrl(meta),
920
863
  webhookUrl: normalizeCallbackUrl(settings2.helloassoCallbackUrl, meta),
921
- itemName: buildHelloAssoItemName('', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
864
+ itemName: buildHelloAssoItemName('', Array.isArray(r.itemNames) ? r.itemNames : [], r.start, r.end),
922
865
  containsDonation: false,
923
866
  metadata: {
924
867
  reservationId: String(rid),
925
- items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
868
+ items: (Array.isArray(r.itemNames) ? r.itemNames : []).filter(Boolean),
926
869
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
927
870
  },
928
871
  });
@@ -943,17 +886,19 @@ api.approveReservation = async function (req, res) {
943
886
  await dbLayer.saveReservation(r);
944
887
 
945
888
  // Email requester
946
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
947
- if (requester && requester.email) {
889
+ const requesterUid = parseInt(r.uid, 10);
890
+ const requester = await user.getUserFields(requesterUid, ['username']);
891
+ if (requesterUid) {
948
892
  const latNum = Number(r.pickupLat);
949
893
  const lonNum = Number(r.pickupLon);
950
894
  const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
951
895
  ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
952
896
  : '';
953
- await sendEmail('calendar-onekite_approved', requester.email, 'Location matériel - Réservation validée', {
954
- username: requester.username,
955
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
956
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
897
+ await sendEmail('calendar-onekite_approved', requesterUid, 'Location matériel - Réservation validée', {
898
+ uid: requesterUid,
899
+ username: requester && requester.username ? requester.username : '',
900
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : ''),
901
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : []),
957
902
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
958
903
  start: formatFR(r.start),
959
904
  end: formatFR(r.end),
@@ -987,12 +932,14 @@ api.refuseReservation = async function (req, res) {
987
932
  r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
988
933
  await dbLayer.saveReservation(r);
989
934
 
990
- const requester = await user.getUserFields(r.uid, ['username', 'email']);
991
- if (requester && requester.email) {
992
- await sendEmail('calendar-onekite_refused', requester.email, 'Location matériel - Demande de réservation', {
993
- username: requester.username,
994
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
995
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
935
+ const requesterUid2 = parseInt(r.uid, 10);
936
+ const requester = await user.getUserFields(requesterUid2, ['username']);
937
+ if (requesterUid2) {
938
+ await sendEmail('calendar-onekite_refused', requesterUid2, 'Location matériel - Demande de réservation', {
939
+ uid: requesterUid2,
940
+ username: requester && requester.username ? requester.username : '',
941
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : ''),
942
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : []),
996
943
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
997
944
  start: formatFR(r.start),
998
945
  end: formatFR(r.end),
@@ -24,50 +24,22 @@ const SETTINGS_KEY = 'calendar-onekite';
24
24
  // Replay protection: store processed payment ids.
25
25
  const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
26
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;
27
+ async function sendEmail(template, uid, subject, data) {
28
+ const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
29
+ if (!Number.isInteger(toUid) || toUid <= 0) return;
30
30
  try {
31
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';
32
+ if (typeof emailer.send !== 'function') return;
36
33
  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;
34
+ // NodeBB 4.x: send(template, uid, params)
35
+ if (emailer.send.length >= 3) {
36
+ await emailer.send(template, toUid, params);
37
+ } else {
38
+ await emailer.send(template, toUid);
53
39
  }
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
40
  } catch (err) {
69
41
  // eslint-disable-next-line no-console
70
- console.warn('[calendar-onekite] Failed to send email (webhook)', { template, toEmail, err: String(err && err.message || err) });
42
+ console.warn('[calendar-onekite] Failed to send email (webhook)', { template, uid: toUid, err: String((err && err.message) || err) });
71
43
  }
72
44
  }
73
45
 
@@ -352,13 +324,14 @@ async function handler(req, res, next) {
352
324
  } catch (e) {}
353
325
 
354
326
  // 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] : [])),
327
+ const requesterUid = parseInt(r.uid, 10);
328
+ const requester = await user.getUserFields(requesterUid, ['username']);
329
+ if (requesterUid) {
330
+ await sendEmail('calendar-onekite_paid', requesterUid, 'Location matériel - Paiement reçu', {
331
+ uid: requesterUid,
332
+ username: requester && requester.username ? requester.username : '',
333
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : ''),
334
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : []),
362
335
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
363
336
  paymentReceiptUrl: r.paymentReceiptUrl || '',
364
337
  });
@@ -370,8 +343,8 @@ async function handler(req, res, next) {
370
343
  rid: r.rid,
371
344
  uid: r.uid,
372
345
  username: (requester && requester.username) ? requester.username : (r.username || ''),
373
- itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
374
- itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
346
+ itemIds: Array.isArray(r.itemIds) ? r.itemIds : [],
347
+ itemNames: Array.isArray(r.itemNames) ? r.itemNames : [],
375
348
  start: r.start,
376
349
  end: r.end,
377
350
  status: r.status,
package/lib/scheduler.js CHANGED
@@ -57,55 +57,26 @@ async function processAwaitingPayment() {
57
57
  const emailer = require.main.require('./src/emailer');
58
58
  const user = require.main.require('./src/user');
59
59
 
60
- async function sendEmail(template, toEmail, subject, data) {
61
- if (!toEmail) return;
60
+ // NodeBB 4.x: always send by uid.
61
+ async function sendEmail(template, uid, subject, data) {
62
+ const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
63
+ if (!Number.isInteger(toUid) || toUid <= 0) return;
62
64
 
63
- // Language can come from plugin settings or NodeBB config depending on version.
64
- let lang = 'fr';
65
- try {
66
- const s = await meta.settings.get('calendar-onekite').catch(() => ({}));
67
- lang = (s && s.defaultLang) || (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
68
- } catch (e) {
69
- lang = (meta && meta.config && (meta.config.defaultLang || meta.config.defaultLanguage)) || 'fr';
70
- }
71
-
72
- // Subject is NOT a positional argument; it must be provided in params.subject.
73
65
  const params = Object.assign({}, data || {}, subject ? { subject } : {});
74
66
 
75
67
  try {
76
- if (typeof emailer.sendToEmail === 'function') {
77
- // NodeBB: sendToEmail(template, email, language, params)
78
- if (emailer.sendToEmail.length >= 4) {
79
- await emailer.sendToEmail(template, toEmail, lang, params);
80
- } else {
81
- // Older signature: sendToEmail(template, email, params)
82
- await emailer.sendToEmail(template, toEmail, params);
83
- }
84
- return;
68
+ if (typeof emailer.send !== 'function') return;
69
+ // NodeBB: send(template, uid, params)
70
+ if (emailer.send.length >= 3) {
71
+ await emailer.send(template, toUid, params);
72
+ } else {
73
+ await emailer.send(template, toUid);
85
74
  }
86
75
  } catch (err) {
87
76
  // eslint-disable-next-line no-console
88
77
  console.warn('[calendar-onekite] Failed to send email (scheduler)', {
89
78
  template,
90
- toEmail,
91
- err: String((err && err.message) || err),
92
- });
93
- }
94
-
95
- // Very old/unusual builds: try send() but only if it clearly accepts an email.
96
- try {
97
- if (typeof emailer.send === 'function') {
98
- if (emailer.send.length >= 4) {
99
- await emailer.send(template, toEmail, lang, params);
100
- } else {
101
- await emailer.send(template, toEmail, params);
102
- }
103
- }
104
- } catch (err) {
105
- // eslint-disable-next-line no-console
106
- console.warn('[calendar-onekite] Failed to send email (scheduler)', {
107
- template,
108
- toEmail,
79
+ uid: toUid,
109
80
  err: String((err && err.message) || err),
110
81
  });
111
82
  }
@@ -140,18 +111,22 @@ async function processAwaitingPayment() {
140
111
  await dbLayer.saveReservation(r);
141
112
  continue;
142
113
  }
143
- const u = await user.getUserFields(r.uid, ['username', 'email']);
144
- if (u && u.email) {
145
- await sendEmail('calendar-onekite_reminder', u.email, 'Location matériel - Rappel', {
146
- username: u.username,
147
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
148
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
114
+
115
+ const toUid = parseInt(r.uid, 10);
116
+ const u = await user.getUserFields(toUid, ['username']);
117
+ if (toUid) {
118
+ await sendEmail('calendar-onekite_reminder', toUid, 'Location matériel - Rappel', {
119
+ uid: toUid,
120
+ username: (u && u.username) ? u.username : '',
121
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : ''),
122
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : []),
149
123
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
150
124
  paymentUrl: r.paymentUrl || '',
151
125
  delayMinutes: holdMins,
152
126
  pickupLine: r.pickupTime ? (r.adminNote ? `${r.pickupTime} à ${r.adminNote}` : r.pickupTime) : '',
153
127
  });
154
128
  }
129
+
155
130
  r.reminderSent = true;
156
131
  r.reminderAt = now;
157
132
  await dbLayer.saveReservation(r);
@@ -170,12 +145,15 @@ async function processAwaitingPayment() {
170
145
  const firstDiscord = await db.setAdd(discordKey, rid);
171
146
  const shouldDiscord = !!firstDiscord;
172
147
 
173
- const u = await user.getUserFields(r.uid, ['username', 'email']);
174
- if (shouldEmail && u && u.email) {
175
- await sendEmail('calendar-onekite_expired', u.email, 'Location matériel - Rappel', {
176
- username: u.username,
177
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
178
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
148
+ const toUid = parseInt(r.uid, 10);
149
+ const u = await user.getUserFields(toUid, ['username']);
150
+
151
+ if (shouldEmail && toUid) {
152
+ await sendEmail('calendar-onekite_expired', toUid, 'Location matériel - Rappel', {
153
+ uid: toUid,
154
+ username: (u && u.username) ? u.username : '',
155
+ itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : ''),
156
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : []),
179
157
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
180
158
  delayMinutes: holdMins,
181
159
  });
@@ -197,6 +175,7 @@ async function processAwaitingPayment() {
197
175
  });
198
176
  } catch (e) {}
199
177
  }
178
+
200
179
  await dbLayer.removeReservation(rid);
201
180
  }
202
181
  }
package/library.js CHANGED
@@ -109,13 +109,8 @@ Plugin.init = async function (params) {
109
109
  },
110
110
  type: ['application/json', 'application/*+json'],
111
111
  });
112
- // Accept webhook on both legacy root path and namespaced plugin path.
113
- // Some reverse proxies block unknown root paths, so /plugins/... is recommended.
114
- router.post('/helloasso', helloassoJson, helloassoWebhook.handler);
115
112
  router.post('/plugins/calendar-onekite/helloasso', helloassoJson, helloassoWebhook.handler);
116
113
 
117
- // Optional: health checks
118
- router.get('/helloasso', (req, res) => res.json({ ok: true }));
119
114
  router.get('/plugins/calendar-onekite/helloasso', (req, res) => res.json({ ok: true }));
120
115
 
121
116
  scheduler.start();
@@ -133,8 +128,7 @@ Plugin.addAdminNavigation = async function (header) {
133
128
 
134
129
 
135
130
  // Ensure our transactional emails always get a subject.
136
- // NodeBB's Emailer.sendToEmail signature expects (template, email, language, params),
137
- // so plugins typically inject/modify the subject via this hook.
131
+ // Ensure our transactional emails always get a subject (NodeBB email hook).
138
132
  Plugin.emailModify = async function (data) {
139
133
  try {
140
134
  if (!data || !data.template) return data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
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/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "1.0.21"
42
+ "version": "1.0.23"
43
43
  }
package/public/admin.js CHANGED
@@ -221,14 +221,14 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
221
221
  for (const r of list) {
222
222
  if (r && r.rid) pendingCache.set(String(r.rid), r);
223
223
  const created = r.createdAt ? fmtFR(r.createdAt) : '';
224
- const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
224
+ const itemNames = Array.isArray(r.itemNames) ? r.itemNames : [];
225
225
  const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
226
226
  const div = document.createElement('div');
227
227
  div.className = 'list-group-item onekite-pending-row';
228
228
  div.innerHTML = `
229
229
  <div class="d-flex justify-content-between align-items-start gap-2">
230
230
  <div style="min-width: 0;">
231
- <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
231
+ <div><strong>${itemsHtml}</strong></div>
232
232
  <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
233
233
  <div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
234
234
  </div>
@@ -492,7 +492,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
492
492
  ? r.itemNames
493
493
  : (typeof r.itemNames === 'string' && r.itemNames.trim()
494
494
  ? r.itemNames.split(',').map(s => s.trim()).filter(Boolean)
495
- : ([r.itemName || r.itemId].filter(Boolean)));
495
+ : ([]));
496
496
  const itemsListHtml = itemNames.length
497
497
  ? `<div class="mb-2"><strong>Matériel</strong><ul style="margin:0.25rem 0 0 1.1rem; padding:0;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul></div>`
498
498
  : '';
package/public/client.js CHANGED
@@ -787,7 +787,7 @@ function toDatetimeLocalValue(date) {
787
787
  (evs || []).forEach((ev) => {
788
788
  const st = (ev.extendedProps && ev.extendedProps.status) || '';
789
789
  if (!['pending', 'awaiting_payment', 'approved', 'paid'].includes(st)) return;
790
- const ids = (ev.extendedProps && ev.extendedProps.itemIds) || (ev.extendedProps && ev.extendedProps.itemId ? [ev.extendedProps.itemId] : []);
790
+ const ids = (ev.extendedProps && ev.extendedProps.itemIds) || [];
791
791
  ids.forEach((id) => blocked.add(String(id)));
792
792
  });
793
793
  } catch (e) {}
@@ -1300,12 +1300,7 @@ function toDatetimeLocalValue(date) {
1300
1300
  p = Object.assign({}, p0, details);
1301
1301
  } else if (p0.type === 'special' && p0.eid) {
1302
1302
  const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
1303
- p = Object.assign({}, p0, details, {
1304
- // keep backward compat with older field names used by templates below
1305
- pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1306
- pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1307
- pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1308
- });
1303
+ p = Object.assign({}, p0, details);
1309
1304
  }
1310
1305
  } catch (e) {
1311
1306
  // ignore detail fetch errors; fall back to minimal props
@@ -1370,7 +1365,7 @@ function toDatetimeLocalValue(date) {
1370
1365
  ? `<div class="mb-2"><strong>Réservée par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
1371
1366
  : '';
1372
1367
  const itemsHtml = (() => {
1373
- const names = Array.isArray(p.itemNames) ? p.itemNames : (typeof p.itemNames === 'string' && p.itemNames.trim() ? p.itemNames.split(',').map(s=>s.trim()).filter(Boolean) : (p.itemName ? [p.itemName] : []));
1368
+ const names = Array.isArray(p.itemNames) ? p.itemNames : (typeof p.itemNames === 'string' && p.itemNames.trim() ? p.itemNames.split(',').map(s=>s.trim()).filter(Boolean) : []);
1374
1369
  if (names.length) {
1375
1370
  return `<ul style="margin:0 0 0 1.1rem; padding:0;">${names.map(n => `<li>${String(n).replace(/</g,'&lt;').replace(/>/g,'&gt;')}</li>`).join('')}</ul>`;
1376
1371
  }
@@ -1505,7 +1500,7 @@ function toDatetimeLocalValue(date) {
1505
1500
  callback: async () => {
1506
1501
  const itemNames = Array.isArray(p.itemNames) && p.itemNames.length
1507
1502
  ? p.itemNames
1508
- : (typeof p.itemNames === 'string' && p.itemNames.trim() ? p.itemNames.split(',').map(s=>s.trim()).filter(Boolean) : (p.itemName ? [p.itemName] : []));
1503
+ : (typeof p.itemNames === 'string' && p.itemNames.trim() ? p.itemNames.split(',').map(s=>s.trim()).filter(Boolean) : []);
1509
1504
  const itemsListHtml = itemNames.length
1510
1505
  ? `<div class="mb-2"><strong>Matériel</strong><ul style="margin:0.25rem 0 0 1.1rem; padding:0;">${itemNames.map(n => `<li>${String(n).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</li>`).join('')}</ul></div>`
1511
1506
  : '';