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 +53 -86
- package/lib/db.js +38 -0
- package/lib/scheduler.js +16 -2
- package/package.json +1 -1
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
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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