nodebb-plugin-calendar-onekite 12.0.17 → 12.0.18

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
@@ -12,11 +12,22 @@ const logger = require.main.require('./src/logger');
12
12
 
13
13
  const dbLayer = require('./db');
14
14
 
15
+ // Fast membership check without N calls to groups.isMember.
16
+ // NodeBB's groups.getUserGroups([uid]) returns an array (per uid) of group objects.
17
+ // We compare against both group slugs and names to be tolerant with older settings.
15
18
  async function userInAnyGroup(uid, allowed) {
16
- if (!uid || !allowed || !allowed.length) return false;
19
+ if (!uid || !Array.isArray(allowed) || !allowed.length) return false;
17
20
  const ug = await groups.getUserGroups([uid]);
18
- const slugs = ug[0] || [];
19
- return allowed.some(g => slugs.includes(g));
21
+ const list = (ug && ug[0]) ? ug[0] : [];
22
+ const seen = new Set();
23
+ for (const g of list) {
24
+ if (!g) continue;
25
+ if (g.slug) seen.add(String(g.slug));
26
+ if (g.name) seen.add(String(g.name));
27
+ if (g.groupName) seen.add(String(g.groupName));
28
+ if (g.displayName) seen.add(String(g.displayName));
29
+ }
30
+ return allowed.some(v => seen.has(String(v)));
20
31
  }
21
32
 
22
33
 
@@ -35,17 +46,7 @@ function normalizeAllowedGroups(raw) {
35
46
  return s.split(',').map(v => String(v).trim().replace(/^"+|"+$/g, '')).filter(Boolean);
36
47
  }
37
48
 
38
- async function isMemberOfAnyGroup(uid, allowed) {
39
- if (!uid || !allowed || !allowed.length) return false;
40
- for (const g of allowed) {
41
- try {
42
- if (await groups.isMember(uid, g)) return true;
43
- } catch (e) {
44
- // ignore
45
- }
46
- }
47
- return false;
48
- }
49
+ // NOTE: Avoid per-group async checks (groups.isMember) when possible.
49
50
 
50
51
 
51
52
  const helloasso = require('./helloasso');
@@ -160,53 +161,8 @@ function autoCreatorGroupForYear(year) {
160
161
  }
161
162
 
162
163
 
163
- async function resolveGroupName(maybeSlugOrName) {
164
- const g = String(maybeSlugOrName || '').trim();
165
- if (!g) return '';
166
- try {
167
- // NodeBB stores a mapping from slug -> groupName in groupslug:groupname
168
- // groupName is the internal identifier used by groups.isMember()
169
- const mapped = await db.getObjectField('groupslug:groupname', g);
170
- if (mapped) return String(mapped);
171
- } catch (e) {}
172
- return g;
173
- }
174
-
175
- async function resolveAllowedGroups(list) {
176
- const out = [];
177
- for (const raw of (list || [])) {
178
- const gn = await resolveGroupName(raw);
179
- if (gn) out.push(gn);
180
- }
181
- // de-duplicate, preserve order
182
- return [...new Set(out)];
183
- }
184
-
185
-
186
- async function expandGroupCandidates(rawList) {
187
- const out = [];
188
- for (const raw of (rawList || [])) {
189
- const g = String(raw || '').trim();
190
- if (!g) continue;
191
- out.push(g);
192
- const mapped = await resolveGroupName(g);
193
- if (mapped && mapped !== g) out.push(mapped);
194
- }
195
- return [...new Set(out)];
196
- }
197
-
198
- async function checkAnyMembership(uid, rawList, fnLabel) {
199
- const candidates = await expandGroupCandidates(rawList);
200
- for (const g of candidates) {
201
- try {
202
- const ok = await groups.isMember(uid, g);
203
- if (ok) return { ok: true, matched: g, candidates };
204
- } catch (e) {
205
- logger.warn('[calendar-onekite] auth check error', { uid, group: g, fn: fnLabel, err: String(e && e.message || e) });
206
- }
207
- }
208
- return { ok: false, matched: '', candidates };
209
- }
164
+ // (removed) group slug/name resolving helpers: we now use userInAnyGroup() which
165
+ // matches both slugs and names and avoids extra DB lookups.
210
166
 
211
167
 
212
168
  function autoFormSlugForYear(year) {
@@ -278,8 +234,7 @@ async function canDeleteSpecial(uid, settings) {
278
234
  try {
279
235
  const isAdmin = await groups.isMember(uid, 'administrators');
280
236
  if (isAdmin) return true;} catch (e) {}
281
- const rawAllowed = (settings.specialDeleterGroups || settings.specialCreatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
282
- const allowed = await resolveAllowedGroups(rawAllowed);
237
+ const allowed = normalizeAllowedGroups(settings.specialDeleterGroups || settings.specialCreatorGroups || '');
283
238
  if (!allowed.length) return false;
284
239
  if (await userInAnyGroup(uid, allowed)) return true;
285
240
 
@@ -378,8 +333,9 @@ api.getEvents = async function (req, res) {
378
333
  const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
379
334
  const ids = await dbLayer.listReservationIdsByStartRange(wideStart, endTs, 5000);
380
335
  const out = [];
381
- for (const rid of ids) {
382
- const r = await dbLayer.getReservation(rid);
336
+ // Batch fetch = major perf win when there are many reservations.
337
+ const reservations = await dbLayer.getReservations(ids);
338
+ for (const r of (reservations || [])) {
383
339
  if (!r) continue;
384
340
  // Purged from calendar view (kept for accounting)
385
341
  if (r.calendarPurgedAt) continue;
@@ -425,8 +381,8 @@ api.getEvents = async function (req, res) {
425
381
  // Special events
426
382
  try {
427
383
  const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
428
- for (const eid of specialIds) {
429
- const sev = await dbLayer.getSpecialEvent(eid);
384
+ const specials = await dbLayer.getSpecialEvents(specialIds);
385
+ for (const sev of (specials || [])) {
430
386
  if (!sev) continue;
431
387
  const sStart = parseInt(sev.start, 10);
432
388
  const sEnd = parseInt(sev.end, 10);
@@ -677,8 +633,8 @@ api.createReservation = async function (req, res) {
677
633
  const wideStart2 = Math.max(0, start - 366 * 24 * 3600 * 1000);
678
634
  const candidateIds = await dbLayer.listReservationIdsByStartRange(wideStart2, end, 5000);
679
635
  const conflicts = [];
680
- for (const cid of candidateIds) {
681
- const existing = await dbLayer.getReservation(cid);
636
+ const existingRows = await dbLayer.getReservations(candidateIds);
637
+ for (const existing of (existingRows || [])) {
682
638
  if (!existing || !blocking.has(existing.status)) continue;
683
639
  const exStart = parseInt(existing.start, 10);
684
640
  const exEnd = parseInt(existing.end, 10);
@@ -729,24 +685,35 @@ api.createReservation = async function (req, res) {
729
685
  const itemsLabel = (resv.itemNames || []).join(', ');
730
686
  for (const g of notifyGroups) {
731
687
  const members = await groups.getMembers(g, 0, -1);
732
- for (const memberUid of (members || [])) {
733
- const md = await user.getUserFields(memberUid, ['username', 'email']);
688
+ const uids = Array.isArray(members) ? members : [];
689
+
690
+ // Batch fetch user email/username when supported by this NodeBB version.
691
+ let usersData = [];
692
+ try {
693
+ if (typeof user.getUsersFields === 'function') {
694
+ usersData = await user.getUsersFields(uids, ['username', 'email']);
695
+ } else {
696
+ usersData = await Promise.all(uids.map(async (memberUid) => {
697
+ try { return await user.getUserFields(memberUid, ['username', 'email']); }
698
+ catch (e) { return null; }
699
+ }));
700
+ }
701
+ } catch (e) {
702
+ usersData = [];
703
+ }
704
+
705
+ for (const md of (usersData || [])) {
734
706
  if (md && md.email) {
735
- await sendEmail(
736
- 'calendar-onekite_pending',
737
- md.email,
738
- 'Location matériel - Demande de réservation',
739
- {
740
- username: md.username,
741
- requester: requester.username,
742
- itemName: itemsLabel,
743
- itemNames: resv.itemNames || [],
744
- dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
745
- start: formatFR(start),
746
- end: formatFR(end),
747
- total: resv.total || 0,
748
- }
749
- );
707
+ await sendEmail('calendar-onekite_pending', md.email, 'Location matériel - Demande de réservation', {
708
+ username: md.username,
709
+ requester: requester.username,
710
+ itemName: itemsLabel,
711
+ itemNames: resv.itemNames || [],
712
+ dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
713
+ start: formatFR(start),
714
+ end: formatFR(end),
715
+ total: resv.total || 0,
716
+ });
750
717
  }
751
718
  }
752
719
  }
package/lib/db.js CHANGED
@@ -10,10 +10,36 @@ const KEY_CHECKOUT_INTENT_TO_RID = 'calendar-onekite:helloasso:checkoutIntentToR
10
10
  const KEY_SPECIAL_ZSET = 'calendar-onekite:special';
11
11
  const KEY_SPECIAL_OBJ = (eid) => `calendar-onekite:special:${eid}`;
12
12
 
13
+ // Helpers
14
+ function reservationKey(rid) {
15
+ return KEY_OBJ(rid);
16
+ }
17
+
18
+ function specialKey(eid) {
19
+ return KEY_SPECIAL_OBJ(eid);
20
+ }
21
+
13
22
  async function getReservation(rid) {
14
23
  return await db.getObject(KEY_OBJ(rid));
15
24
  }
16
25
 
26
+ /**
27
+ * Batch fetch reservations in one DB roundtrip.
28
+ * Returns an array aligned with rids (missing objects => null).
29
+ */
30
+ async function getReservations(rids) {
31
+ const ids = Array.isArray(rids) ? rids.filter(Boolean) : [];
32
+ if (!ids.length) return [];
33
+ const keys = ids.map(reservationKey);
34
+ const rows = await db.getObjects(keys);
35
+ // Ensure rid is present even if older objects were missing it.
36
+ return (rows || []).map((row, idx) => {
37
+ if (!row) return null;
38
+ if (!row.rid) row.rid = String(ids[idx]);
39
+ return row;
40
+ });
41
+ }
42
+
17
43
  async function saveReservation(resv) {
18
44
  await db.setObject(KEY_OBJ(resv.rid), resv);
19
45
  // score = start timestamp
@@ -50,10 +76,22 @@ module.exports = {
50
76
  KEY_SPECIAL_ZSET,
51
77
  KEY_CHECKOUT_INTENT_TO_RID,
52
78
  getReservation,
79
+ getReservations,
53
80
  saveReservation,
54
81
  removeReservation,
55
82
  // Special events
56
83
  getSpecialEvent: async (eid) => await db.getObject(KEY_SPECIAL_OBJ(eid)),
84
+ getSpecialEvents: async (eids) => {
85
+ const ids = Array.isArray(eids) ? eids.filter(Boolean) : [];
86
+ if (!ids.length) return [];
87
+ const keys = ids.map(specialKey);
88
+ const rows = await db.getObjects(keys);
89
+ return (rows || []).map((row, idx) => {
90
+ if (!row) return null;
91
+ if (!row.eid) row.eid = String(ids[idx]);
92
+ return row;
93
+ });
94
+ },
57
95
  saveSpecialEvent: async (ev) => {
58
96
  await db.setObject(KEY_SPECIAL_OBJ(ev.eid), ev);
59
97
  await db.sortedSetAdd(KEY_SPECIAL_ZSET, ev.start, ev.eid);
package/lib/scheduler.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const meta = require.main.require('./src/meta');
4
+ const db = require.main.require('./src/database');
4
5
  const dbLayer = require('./db');
5
6
 
6
7
  let timer = null;
@@ -109,7 +110,16 @@ async function processAwaitingPayment() {
109
110
  const expireAt = approvedAt + 2 * holdMins * 60 * 1000;
110
111
 
111
112
  if (!r.reminderSent && now >= reminderAt && now < expireAt) {
112
- // Send reminder once
113
+ // Send reminder once (guarded across clustered NodeBB processes)
114
+ const reminderKey = 'calendar-onekite:email:reminderSent';
115
+ const first = await db.setAdd(reminderKey, rid);
116
+ if (!first) {
117
+ // another process already sent it
118
+ r.reminderSent = true;
119
+ r.reminderAt = now;
120
+ await dbLayer.saveReservation(r);
121
+ continue;
122
+ }
113
123
  const u = await user.getUserFields(r.uid, ['username', 'email']);
114
124
  if (u && u.email) {
115
125
  await sendEmail('calendar-onekite_reminder', u.email, 'Location matériel - Rappel', {
@@ -130,8 +140,12 @@ async function processAwaitingPayment() {
130
140
 
131
141
  if (now >= expireAt) {
132
142
  // Expire: remove reservation so it disappears from calendar and frees items
143
+ // Guard email send across clustered NodeBB processes
144
+ const expiredKey = 'calendar-onekite:email:expiredSent';
145
+ const firstExpired = await db.setAdd(expiredKey, rid);
146
+ const shouldEmail = !!firstExpired;
133
147
  const u = await user.getUserFields(r.uid, ['username', 'email']);
134
- if (u && u.email) {
148
+ if (shouldEmail && u && u.email) {
135
149
  await sendEmail('calendar-onekite_expired', u.email, 'Location matériel - Rappel', {
136
150
  username: u.username,
137
151
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "12.0.17",
3
+ "version": "12.0.18",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",