nodebb-plugin-calendar-onekite 11.1.30 → 11.1.31
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 +2 -78
- package/lib/db.js +0 -21
- package/library.js +0 -10
- package/package.json +1 -1
- package/lib/email.js +0 -13
- package/lib/logic.js +0 -120
- package/lib/mailer.js +0 -78
- package/lib/middlewares.js +0 -69
- package/lib/perms.js +0 -44
- package/lib/settings.js +0 -18
- package/lib/store.js +0 -209
- package/lib/sweeper.js +0 -16
- package/templates/emails/calendar-onekite-approved.tpl +0 -1
- package/templates/emails/calendar-onekite-pending.tpl +0 -1
- package/templates/emails/calendar-onekite-refused.tpl +0 -1
package/lib/api.js
CHANGED
|
@@ -4,12 +4,6 @@ const meta = require.main.require('./src/meta');
|
|
|
4
4
|
const dbi = require('./db');
|
|
5
5
|
const hello = require('./helloasso');
|
|
6
6
|
|
|
7
|
-
const BLOCKING_STATUSES = new Set(['pending', 'awaiting_payment', 'approved', 'paid']);
|
|
8
|
-
|
|
9
|
-
function overlaps(aStart, aEnd, bStart, bEnd) {
|
|
10
|
-
return Number(aStart) < Number(bEnd) && Number(bStart) < Number(aEnd);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
7
|
function parseDateParam(s) {
|
|
14
8
|
// FullCalendar sends ISO date/time. Accept 'YYYY-MM-DD' too.
|
|
15
9
|
const d = new Date(s);
|
|
@@ -35,35 +29,6 @@ function daysBetweenInclusive(startYMD, endYMDExclusive) {
|
|
|
35
29
|
|
|
36
30
|
let catalogCache = { at: 0, items: [], raw: null, ok: false, err: null };
|
|
37
31
|
|
|
38
|
-
async function sweepExpiredPending() {
|
|
39
|
-
const now = Date.now();
|
|
40
|
-
const ids = await dbi.listExpiredIds(now, 5000);
|
|
41
|
-
if (!ids.length) return 0;
|
|
42
|
-
const rows = await dbi.getReservations(ids);
|
|
43
|
-
let expiredCount = 0;
|
|
44
|
-
for (const r of rows) {
|
|
45
|
-
if (!r) continue;
|
|
46
|
-
const status = r.status || 'pending';
|
|
47
|
-
const expiresAt = Number(r.expiresAt || 0);
|
|
48
|
-
if (status === 'pending' && expiresAt && expiresAt <= now) {
|
|
49
|
-
r.status = 'expired';
|
|
50
|
-
r.expiredAt = now;
|
|
51
|
-
r.expiresAt = '';
|
|
52
|
-
await dbi.saveReservation(r);
|
|
53
|
-
await dbi.clearExpiryIndex(r.id);
|
|
54
|
-
expiredCount++;
|
|
55
|
-
} else {
|
|
56
|
-
// Not actually expired, remove from expiry index to avoid repeated scans
|
|
57
|
-
if (expiresAt && expiresAt > now) {
|
|
58
|
-
// keep
|
|
59
|
-
} else {
|
|
60
|
-
await dbi.clearExpiryIndex(r.id);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return expiredCount;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
32
|
async function getSettings() {
|
|
68
33
|
const settings = await meta.settings.get('calendar-onekite');
|
|
69
34
|
return settings || {};
|
|
@@ -96,7 +61,6 @@ async function getCatalog(req, res) {
|
|
|
96
61
|
}
|
|
97
62
|
|
|
98
63
|
async function getEvents(req, res) {
|
|
99
|
-
await sweepExpiredPending();
|
|
100
64
|
const start = parseDateParam(req.query.start);
|
|
101
65
|
const end = parseDateParam(req.query.end);
|
|
102
66
|
const startTs = start ? start.getTime() : Date.now() - 365*86400000;
|
|
@@ -105,17 +69,11 @@ async function getEvents(req, res) {
|
|
|
105
69
|
const ids = await dbi.listReservationIdsByStartRange(startTs, endTs, 5000);
|
|
106
70
|
const rows = await dbi.getReservations(ids);
|
|
107
71
|
|
|
108
|
-
const events = rows
|
|
109
|
-
.filter(r => {
|
|
110
|
-
const status = r.status || 'pending';
|
|
111
|
-
// Hide expired requests from calendar
|
|
112
|
-
return status !== 'expired';
|
|
113
|
-
})
|
|
114
|
-
.map(r => {
|
|
72
|
+
const events = rows.map(r => {
|
|
115
73
|
const itemNames = (r.items || []).map(it => it.name).join(', ');
|
|
116
74
|
const title = itemNames || 'Réservation';
|
|
117
75
|
const status = r.status || 'pending';
|
|
118
|
-
const icon = status === 'approved' ? '✅' : status === '
|
|
76
|
+
const icon = status === 'approved' ? '✅' : status === 'refused' ? '⛔' : '⏳';
|
|
119
77
|
return {
|
|
120
78
|
id: r.id,
|
|
121
79
|
title: `${icon} ${title}`,
|
|
@@ -139,9 +97,6 @@ async function createReservation(req, res) {
|
|
|
139
97
|
const uid = req.uid;
|
|
140
98
|
if (!uid) return res.status(403).json({ status: { code: 'forbidden', message: 'Not logged in' } });
|
|
141
99
|
|
|
142
|
-
// Expire old pending holds before checking availability
|
|
143
|
-
await sweepExpiredPending();
|
|
144
|
-
|
|
145
100
|
const body = req.body || {};
|
|
146
101
|
const startYMD = body.start;
|
|
147
102
|
const endYMD = body.end;
|
|
@@ -170,35 +125,6 @@ async function createReservation(req, res) {
|
|
|
170
125
|
const startTs = new Date(startYMD + 'T00:00:00Z').getTime();
|
|
171
126
|
const endTs = new Date(endYMD + 'T00:00:00Z').getTime();
|
|
172
127
|
|
|
173
|
-
// Prevent double-booking (reserved OR awaiting payment OR approved) for any selected item
|
|
174
|
-
// Query candidates by start range; then filter by overlap and item intersection
|
|
175
|
-
const candidateIds = await dbi.listReservationIdsByStartRange(startTs - 86400000, endTs + 86400000, 10000);
|
|
176
|
-
const candidates = await dbi.getReservations(candidateIds);
|
|
177
|
-
const wanted = new Set(itemIds);
|
|
178
|
-
const conflicts = [];
|
|
179
|
-
for (const r of candidates) {
|
|
180
|
-
if (!r) continue;
|
|
181
|
-
const status = r.status || 'pending';
|
|
182
|
-
if (!BLOCKING_STATUSES.has(status)) continue;
|
|
183
|
-
if (status === 'pending' && r.expiresAt && Number(r.expiresAt) <= Date.now()) continue;
|
|
184
|
-
if (!overlaps(startTs, endTs, Number(r.startTs), Number(r.endTs))) continue;
|
|
185
|
-
const rItemIds = (r.items || []).map(it => String(it.id));
|
|
186
|
-
const hit = rItemIds.filter(x => wanted.has(x));
|
|
187
|
-
if (hit.length) {
|
|
188
|
-
conflicts.push({ id: r.id, status, itemIds: hit, startYMD: r.startYMD, endYMD: r.endYMD });
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
if (conflicts.length) {
|
|
192
|
-
return res.status(409).json({
|
|
193
|
-
status: { code: 'conflict', message: 'Matériel déjà réservé ou en attente de paiement.' },
|
|
194
|
-
conflicts,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const settings = await getSettings();
|
|
199
|
-
const holdMinutes = Math.max(1, parseInt(settings.holdMinutes, 10) || 5);
|
|
200
|
-
const expiresAt = Date.now() + holdMinutes * 60 * 1000;
|
|
201
|
-
|
|
202
128
|
const resv = {
|
|
203
129
|
id,
|
|
204
130
|
uid: String(uid),
|
|
@@ -211,7 +137,6 @@ async function createReservation(req, res) {
|
|
|
211
137
|
totalCents,
|
|
212
138
|
status: 'pending',
|
|
213
139
|
createdAt: Date.now(),
|
|
214
|
-
expiresAt,
|
|
215
140
|
};
|
|
216
141
|
await dbi.saveReservation(resv);
|
|
217
142
|
|
|
@@ -222,7 +147,6 @@ module.exports = {
|
|
|
222
147
|
getEvents,
|
|
223
148
|
getCatalog,
|
|
224
149
|
createReservation,
|
|
225
|
-
_sweepExpiredPending: sweepExpiredPending,
|
|
226
150
|
_getCatalogItems: getCatalogItems,
|
|
227
151
|
_catalogCache: () => catalogCache,
|
|
228
152
|
};
|
package/lib/db.js
CHANGED
|
@@ -4,7 +4,6 @@ const db = require.main.require('./src/database');
|
|
|
4
4
|
|
|
5
5
|
const KEYS = {
|
|
6
6
|
Z_BY_START: 'calendar-onekite:reservations:byStart',
|
|
7
|
-
Z_BY_EXPIRES: 'calendar-onekite:reservations:byExpires',
|
|
8
7
|
HASH: (id) => `calendar-onekite:reservation:${id}`,
|
|
9
8
|
NEXT_ID: 'calendar-onekite:reservation:nextId',
|
|
10
9
|
};
|
|
@@ -17,13 +16,6 @@ async function nextId() {
|
|
|
17
16
|
async function saveReservation(resv) {
|
|
18
17
|
await db.setObject(KEYS.HASH(resv.id), resv);
|
|
19
18
|
await db.sortedSetAdd(KEYS.Z_BY_START, resv.startTs, resv.id);
|
|
20
|
-
|
|
21
|
-
// Track expiration for pending reservations
|
|
22
|
-
if (resv.expiresAt) {
|
|
23
|
-
await db.sortedSetAdd(KEYS.Z_BY_EXPIRES, Number(resv.expiresAt), resv.id);
|
|
24
|
-
} else {
|
|
25
|
-
await db.sortedSetRemove(KEYS.Z_BY_EXPIRES, resv.id);
|
|
26
|
-
}
|
|
27
19
|
}
|
|
28
20
|
|
|
29
21
|
async function getReservation(id) {
|
|
@@ -46,17 +38,6 @@ async function listReservationIdsByStartRange(startTs, endTs, limit = 2000) {
|
|
|
46
38
|
async function deleteReservation(id) {
|
|
47
39
|
await db.delete(KEYS.HASH(id));
|
|
48
40
|
await db.sortedSetRemove(KEYS.Z_BY_START, id);
|
|
49
|
-
await db.sortedSetRemove(KEYS.Z_BY_EXPIRES, id);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function listExpiredIds(nowTs, limit = 5000) {
|
|
53
|
-
// NodeBB db API: getSortedSetRangeByScore(set, start, stop, min, max)
|
|
54
|
-
const ids = await db.getSortedSetRangeByScore(KEYS.Z_BY_EXPIRES, 0, limit - 1, 0, nowTs);
|
|
55
|
-
return ids || [];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function clearExpiryIndex(id) {
|
|
59
|
-
await db.sortedSetRemove(KEYS.Z_BY_EXPIRES, id);
|
|
60
41
|
}
|
|
61
42
|
|
|
62
43
|
async function listAllIds(limit=100000) {
|
|
@@ -71,7 +52,5 @@ module.exports = {
|
|
|
71
52
|
getReservations,
|
|
72
53
|
listReservationIdsByStartRange,
|
|
73
54
|
deleteReservation,
|
|
74
|
-
listExpiredIds,
|
|
75
|
-
clearExpiryIndex,
|
|
76
55
|
listAllIds,
|
|
77
56
|
};
|
package/library.js
CHANGED
|
@@ -7,7 +7,6 @@ const api = require('./lib/api');
|
|
|
7
7
|
const admin = require('./lib/admin');
|
|
8
8
|
|
|
9
9
|
const Plugin = {};
|
|
10
|
-
let sweeper = null;
|
|
11
10
|
|
|
12
11
|
function isFn(fn) { return typeof fn === 'function'; }
|
|
13
12
|
function safeMws(arr) { return (Array.isArray(arr) ? arr : []).filter(isFn); }
|
|
@@ -76,15 +75,6 @@ Plugin.init = async function (params) {
|
|
|
76
75
|
router.get('/api/admin/plugins/calendar-onekite/pending', ...adminMws, admin.getPending);
|
|
77
76
|
router.post('/api/admin/plugins/calendar-onekite/purge', ...adminMws, admin.purgeByYear);
|
|
78
77
|
router.get('/api/admin/plugins/calendar-onekite/debug', ...adminMws, admin.debugHelloAsso);
|
|
79
|
-
|
|
80
|
-
// Background expiry sweeper: ensures pending holds are released even if nobody loads the calendar
|
|
81
|
-
if (!sweeper) {
|
|
82
|
-
sweeper = setInterval(() => {
|
|
83
|
-
api._sweepExpiredPending().catch(() => {});
|
|
84
|
-
}, 60 * 1000);
|
|
85
|
-
// do not keep the process alive just for the timer
|
|
86
|
-
if (typeof sweeper.unref === 'function') sweeper.unref();
|
|
87
|
-
}
|
|
88
78
|
};
|
|
89
79
|
|
|
90
80
|
Plugin.addAdminNavigation = async function (header) {
|
package/package.json
CHANGED
package/lib/email.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const emailer = require.main.require('./src/emailer');
|
|
4
|
-
|
|
5
|
-
async function sendToUid(template, uid, data) {
|
|
6
|
-
try {
|
|
7
|
-
await emailer.send(template, uid, data);
|
|
8
|
-
} catch (e) {
|
|
9
|
-
// don't crash server
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
module.exports = { sendToUid };
|
package/lib/logic.js
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const db = require('./db');
|
|
4
|
-
const { getCatalogItems } = require('./helloasso');
|
|
5
|
-
const settings = require('./settings');
|
|
6
|
-
|
|
7
|
-
const BLOCKING_STATUSES = new Set(['pending', 'awaiting_payment', 'approved', 'paid']);
|
|
8
|
-
|
|
9
|
-
function dateToTs(dateStr) {
|
|
10
|
-
// interpret as UTC midnight
|
|
11
|
-
return Date.parse(dateStr + 'T00:00:00.000Z');
|
|
12
|
-
}
|
|
13
|
-
function addDays(dateStr, days) {
|
|
14
|
-
const ts = dateToTs(dateStr) + days * 86400000;
|
|
15
|
-
const d = new Date(ts);
|
|
16
|
-
return d.toISOString().slice(0,10);
|
|
17
|
-
}
|
|
18
|
-
function overlap(aStart, aEnd, bStart, bEnd) {
|
|
19
|
-
return aStart < bEnd && bStart < aEnd;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function sweepExpired(nowTs = Date.now()) {
|
|
23
|
-
const expiredIds = await db.listExpiredIds(nowTs);
|
|
24
|
-
for (const rid of expiredIds) {
|
|
25
|
-
const r = await db.getReservation(rid);
|
|
26
|
-
if (!r) continue;
|
|
27
|
-
if (r.status === 'pending' && r.expiresAt && r.expiresAt <= nowTs) {
|
|
28
|
-
r.status = 'expired';
|
|
29
|
-
r.expiresAt = 0;
|
|
30
|
-
await db.saveReservation(r);
|
|
31
|
-
} else {
|
|
32
|
-
// remove from expires index
|
|
33
|
-
r.expiresAt = 0;
|
|
34
|
-
await db.saveReservation(r);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function listEvents(startDate, endDate) {
|
|
40
|
-
await sweepExpired();
|
|
41
|
-
const startTs = dateToTs(startDate);
|
|
42
|
-
const endTs = dateToTs(endDate);
|
|
43
|
-
const ids = await db.listReservationIdsByStartRange(startTs - 86400000*400, endTs); // wide window
|
|
44
|
-
const events = [];
|
|
45
|
-
for (const id of ids) {
|
|
46
|
-
const r = await db.getReservation(id);
|
|
47
|
-
if (!r) continue;
|
|
48
|
-
if (r.status === 'expired') continue;
|
|
49
|
-
if (overlap(dateToTs(r.startDate), dateToTs(r.endDate), startTs, endTs)) {
|
|
50
|
-
events.push(r);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return events;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function computeTotal(itemIds, days) {
|
|
57
|
-
const { items } = await getCatalogItems();
|
|
58
|
-
const map = new Map(items.map(i => [i.id, i]));
|
|
59
|
-
let sum = 0;
|
|
60
|
-
for (const id of itemIds) {
|
|
61
|
-
const it = map.get(String(id));
|
|
62
|
-
if (it) sum += it.priceCents;
|
|
63
|
-
}
|
|
64
|
-
return sum * days;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function checkConflicts(itemIds, startDate, endDate) {
|
|
68
|
-
const events = await listEvents(startDate, endDate);
|
|
69
|
-
const conflicts = [];
|
|
70
|
-
const wantSet = new Set(itemIds.map(String));
|
|
71
|
-
for (const r of events) {
|
|
72
|
-
if (!BLOCKING_STATUSES.has(r.status)) continue;
|
|
73
|
-
const has = (r.itemIds || []).some(x => wantSet.has(String(x)));
|
|
74
|
-
if (has && overlap(dateToTs(r.startDate), dateToTs(r.endDate), dateToTs(startDate), dateToTs(endDate))) {
|
|
75
|
-
conflicts.push({ rid: r.rid, status: r.status, startDate: r.startDate, endDate: r.endDate, itemIds: r.itemIds });
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return conflicts;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function createReservation(uid, itemIds, startDate, endDate, note='') {
|
|
82
|
-
await sweepExpired();
|
|
83
|
-
const s = await settings.get();
|
|
84
|
-
const holdMinutes = parseInt(s.holdMinutes, 10) || 5;
|
|
85
|
-
|
|
86
|
-
// normalize dates and days (end exclusive)
|
|
87
|
-
const startTs = dateToTs(startDate);
|
|
88
|
-
const endTs = dateToTs(endDate);
|
|
89
|
-
const days = Math.max(1, Math.round((endTs - startTs) / 86400000));
|
|
90
|
-
const conflicts = await checkConflicts(itemIds, startDate, endDate);
|
|
91
|
-
if (conflicts.length) {
|
|
92
|
-
const err = new Error('conflict');
|
|
93
|
-
err.code = 'conflict';
|
|
94
|
-
err.conflicts = conflicts;
|
|
95
|
-
throw err;
|
|
96
|
-
}
|
|
97
|
-
const totalCents = await computeTotal(itemIds, days);
|
|
98
|
-
|
|
99
|
-
const rid = await db.nextId();
|
|
100
|
-
const now = Date.now();
|
|
101
|
-
const expiresAt = now + holdMinutes * 60 * 1000;
|
|
102
|
-
|
|
103
|
-
const title = `${itemIds.length} matériel(s)`;
|
|
104
|
-
const resv = { rid, uid, itemIds: itemIds.map(String), startDate, endDate, startTs, endTs, status: 'pending', createdAt: now, expiresAt, days, totalCents, title, note };
|
|
105
|
-
await db.saveReservation(resv);
|
|
106
|
-
return resv;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function setStatus(rid, status) {
|
|
110
|
-
const r = await db.getReservation(rid);
|
|
111
|
-
if (!r) return null;
|
|
112
|
-
r.status = status;
|
|
113
|
-
if (status !== 'pending') {
|
|
114
|
-
r.expiresAt = 0;
|
|
115
|
-
}
|
|
116
|
-
await db.saveReservation(r);
|
|
117
|
-
return r;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
module.exports = { listEvents, createReservation, checkConflicts, sweepExpired, setStatus, dateToTs };
|
package/lib/mailer.js
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const Emailer = (() => {
|
|
4
|
-
try { return require.main.require('./src/emailer'); } catch (e) { return null; }
|
|
5
|
-
})();
|
|
6
|
-
const User = (() => {
|
|
7
|
-
try { return require.main.require('./src/user'); } catch (e) { return null; }
|
|
8
|
-
})();
|
|
9
|
-
const Groups = (() => {
|
|
10
|
-
try { return require.main.require('./src/groups'); } catch (e) { return null; }
|
|
11
|
-
})();
|
|
12
|
-
const meta = (() => {
|
|
13
|
-
try { return require.main.require('./src/meta'); } catch (e) { return null; }
|
|
14
|
-
})();
|
|
15
|
-
|
|
16
|
-
const PLUGIN_ID = 'calendar-onekite';
|
|
17
|
-
|
|
18
|
-
async function getGroupMemberUids(groupName) {
|
|
19
|
-
if (!Groups) return [];
|
|
20
|
-
if (typeof Groups.getMembers === 'function') {
|
|
21
|
-
return await Groups.getMembers(groupName, 0, -1);
|
|
22
|
-
}
|
|
23
|
-
if (typeof Groups.getMemberUsers === 'function') {
|
|
24
|
-
const users = await Groups.getMemberUsers(groupName, 0, 1000);
|
|
25
|
-
return users.map(u => u && u.uid).filter(Boolean);
|
|
26
|
-
}
|
|
27
|
-
return [];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function getEmailsFromUids(uids) {
|
|
31
|
-
if (!User) return [];
|
|
32
|
-
const data = await User.getUsersFields(uids, ['email']);
|
|
33
|
-
return (data || []).map(u => u && u.email).filter(Boolean);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function sendEmailToEmails(template, emails, data) {
|
|
37
|
-
if (!Emailer || !emails.length) return { ok: false, skipped: true };
|
|
38
|
-
// Try different APIs depending on NodeBB version
|
|
39
|
-
try {
|
|
40
|
-
if (typeof Emailer.sendToEmail === 'function') {
|
|
41
|
-
for (const email of emails) {
|
|
42
|
-
// eslint-disable-next-line no-await-in-loop
|
|
43
|
-
await Emailer.sendToEmail(template, email, data);
|
|
44
|
-
}
|
|
45
|
-
return { ok: true };
|
|
46
|
-
}
|
|
47
|
-
if (typeof Emailer.send === 'function') {
|
|
48
|
-
for (const email of emails) {
|
|
49
|
-
// eslint-disable-next-line no-await-in-loop
|
|
50
|
-
await Emailer.send(template, email, data);
|
|
51
|
-
}
|
|
52
|
-
return { ok: true };
|
|
53
|
-
}
|
|
54
|
-
} catch (e) {
|
|
55
|
-
return { ok: false, error: e.message };
|
|
56
|
-
}
|
|
57
|
-
return { ok: false, error: 'Emailer API not found' };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function notifyGroups(template, groupsCsv, data) {
|
|
61
|
-
const groups = String(groupsCsv || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
62
|
-
const allUids = [];
|
|
63
|
-
for (const g of groups) {
|
|
64
|
-
// eslint-disable-next-line no-await-in-loop
|
|
65
|
-
const uids = await getGroupMemberUids(g);
|
|
66
|
-
allUids.push(...uids);
|
|
67
|
-
}
|
|
68
|
-
const uniq = Array.from(new Set(allUids.map(String))).map(Number).filter(n => n > 0);
|
|
69
|
-
const emails = await getEmailsFromUids(uniq);
|
|
70
|
-
return await sendEmailToEmails(template, emails, data);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function notifyUser(template, uid, data) {
|
|
74
|
-
const emails = await getEmailsFromUids([uid]);
|
|
75
|
-
return await sendEmailToEmails(template, emails, data);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
module.exports = { notifyGroups, notifyUser };
|
package/lib/middlewares.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const Groups = (() => {
|
|
4
|
-
try { return require.main.require('./src/groups'); } catch (e) { return null; }
|
|
5
|
-
})();
|
|
6
|
-
const meta = (() => {
|
|
7
|
-
try { return require.main.require('./src/meta'); } catch (e) { return null; }
|
|
8
|
-
})();
|
|
9
|
-
|
|
10
|
-
const PLUGIN_ID = 'calendar-onekite';
|
|
11
|
-
|
|
12
|
-
function safeMiddlewares(mws) {
|
|
13
|
-
return (Array.isArray(mws) ? mws : []).filter(fn => typeof fn === 'function');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function ensureLoggedIn(req, res, next) {
|
|
17
|
-
if (req && req.uid && Number(req.uid) > 0) return next();
|
|
18
|
-
res.status(401).json({ ok: false, error: 'not-logged-in' });
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function isInAnyGroup(uid, groupsCsv) {
|
|
22
|
-
if (!Groups || !uid || uid <= 0) return false;
|
|
23
|
-
const groups = String(groupsCsv || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
24
|
-
if (!groups.length) return false;
|
|
25
|
-
for (const g of groups) {
|
|
26
|
-
// Groups.isMember(uid, groupName)
|
|
27
|
-
if (typeof Groups.isMember === 'function') {
|
|
28
|
-
// eslint-disable-next-line no-await-in-loop
|
|
29
|
-
const ok = await Groups.isMember(uid, g);
|
|
30
|
-
if (ok) return true;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function ensureAdminGroup(req, res, next) {
|
|
37
|
-
try {
|
|
38
|
-
const uid = Number(req.uid) || 0;
|
|
39
|
-
if (uid <= 0) return res.status(401).json({ ok: false, error: 'not-logged-in' });
|
|
40
|
-
// Default: administrators group
|
|
41
|
-
const ok = await isInAnyGroup(uid, 'administrators');
|
|
42
|
-
if (ok) return next();
|
|
43
|
-
return res.status(403).json({ ok: false, error: 'not-authorized' });
|
|
44
|
-
} catch (e) {
|
|
45
|
-
return res.status(500).json({ ok: false, error: 'internal-error' });
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function ensureValidatorGroup(req, res, next) {
|
|
50
|
-
try {
|
|
51
|
-
const uid = Number(req.uid) || 0;
|
|
52
|
-
if (uid <= 0) return res.status(401).json({ ok: false, error: 'not-logged-in' });
|
|
53
|
-
const settings = meta ? await meta.settings.get(PLUGIN_ID) : {};
|
|
54
|
-
const groupsCsv = settings && settings.validatorGroups ? settings.validatorGroups : 'administrators';
|
|
55
|
-
const ok = await isInAnyGroup(uid, groupsCsv) || await isInAnyGroup(uid, 'administrators');
|
|
56
|
-
if (ok) return next();
|
|
57
|
-
return res.status(403).json({ ok: false, error: 'not-validator' });
|
|
58
|
-
} catch (e) {
|
|
59
|
-
return res.status(500).json({ ok: false, error: 'internal-error' });
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
module.exports = {
|
|
64
|
-
safeMiddlewares,
|
|
65
|
-
ensureLoggedIn,
|
|
66
|
-
ensureAdminGroup,
|
|
67
|
-
ensureValidatorGroup,
|
|
68
|
-
isInAnyGroup,
|
|
69
|
-
};
|
package/lib/perms.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const Groups = require.main.require('./src/groups');
|
|
4
|
-
const settings = require('./settings');
|
|
5
|
-
|
|
6
|
-
async function isInAnyGroup(uid, groups) {
|
|
7
|
-
if (!uid || uid <= 0) return false;
|
|
8
|
-
for (const g of groups) {
|
|
9
|
-
try {
|
|
10
|
-
const ok = await Groups.isMember(uid, g);
|
|
11
|
-
if (ok) return true;
|
|
12
|
-
} catch {}
|
|
13
|
-
}
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async function canCreate(uid) {
|
|
18
|
-
const s = await settings.get();
|
|
19
|
-
const groups = settings.splitCsv(s.creatorGroups);
|
|
20
|
-
if (!groups.length) return !!uid;
|
|
21
|
-
return await isInAnyGroup(uid, groups);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function canValidate(uid) {
|
|
25
|
-
const s = await settings.get();
|
|
26
|
-
const groups = settings.splitCsv(s.validatorGroups);
|
|
27
|
-
if (!groups.length) return false;
|
|
28
|
-
return await isInAnyGroup(uid, groups);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function listNotifyUids() {
|
|
32
|
-
const s = await settings.get();
|
|
33
|
-
const groups = settings.splitCsv(s.notifyGroups);
|
|
34
|
-
const uids = new Set();
|
|
35
|
-
for (const g of groups) {
|
|
36
|
-
try {
|
|
37
|
-
const members = await Groups.getMembers(g, 0, -1);
|
|
38
|
-
(members || []).forEach(uid => uids.add(parseInt(uid, 10)));
|
|
39
|
-
} catch {}
|
|
40
|
-
}
|
|
41
|
-
return [...uids].filter(n => Number.isFinite(n) && n > 0);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
module.exports = { canCreate, canValidate, listNotifyUids };
|
package/lib/settings.js
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
const meta = require.main.require('./src/meta');
|
|
3
|
-
const PLUGIN_ID = 'calendar-onekite';
|
|
4
|
-
|
|
5
|
-
async function get() {
|
|
6
|
-
const s = await meta.settings.get(PLUGIN_ID);
|
|
7
|
-
return s || {};
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function normalizeCsv(v) {
|
|
11
|
-
return String(v || '').split(',').map(s => s.trim()).filter(Boolean).join(',');
|
|
12
|
-
}
|
|
13
|
-
function splitCsv(v) {
|
|
14
|
-
const n = normalizeCsv(v);
|
|
15
|
-
return n ? n.split(',') : [];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
module.exports = { PLUGIN_ID, get, normalizeCsv, splitCsv };
|
package/lib/store.js
DELETED
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const db = require.main.require('./src/database');
|
|
4
|
-
|
|
5
|
-
const KEY_NEXT = 'onekite:cal:nextRid';
|
|
6
|
-
const KEY_HASH = (rid) => `onekite:cal:r:${rid}`;
|
|
7
|
-
const KEY_Z_START = 'onekite:cal:z:start';
|
|
8
|
-
const KEY_Z_STATUS = (status) => `onekite:cal:z:status:${status}`;
|
|
9
|
-
const KEY_Z_EXPIRES = 'onekite:cal:z:expires';
|
|
10
|
-
|
|
11
|
-
function toDayString(ms) {
|
|
12
|
-
return new Date(ms).toISOString().slice(0, 10);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async function nextRid() {
|
|
16
|
-
const v = await db.incrObjectField('global', KEY_NEXT);
|
|
17
|
-
return String(v);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async function setReservation(rid, data) {
|
|
21
|
-
await db.setObject(KEY_HASH(rid), data);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function getReservation(rid) {
|
|
25
|
-
const obj = await db.getObject(KEY_HASH(rid));
|
|
26
|
-
if (!obj || !obj.rid) return null;
|
|
27
|
-
// parse
|
|
28
|
-
obj.startMs = parseInt(obj.startMs, 10);
|
|
29
|
-
obj.endMs = parseInt(obj.endMs, 10);
|
|
30
|
-
obj.uid = parseInt(obj.uid, 10);
|
|
31
|
-
obj.expiresAt = parseInt(obj.expiresAt || '0', 10);
|
|
32
|
-
obj.days = parseInt(obj.days || '0', 10);
|
|
33
|
-
obj.totalCents = parseInt(obj.totalCents || '0', 10);
|
|
34
|
-
try { obj.itemIds = JSON.parse(obj.itemIds || '[]'); } catch (e) { obj.itemIds = []; }
|
|
35
|
-
return obj;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function indexReservation(rid, r) {
|
|
39
|
-
await db.sortedSetAdd(KEY_Z_START, r.startMs, rid);
|
|
40
|
-
await db.sortedSetAdd(KEY_Z_STATUS(r.status), r.startMs, rid);
|
|
41
|
-
if (r.expiresAt && r.expiresAt > 0) {
|
|
42
|
-
await db.sortedSetAdd(KEY_Z_EXPIRES, r.expiresAt, rid);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async function deindexStatus(rid, oldStatus) {
|
|
47
|
-
await db.sortedSetRemove(KEY_Z_STATUS(oldStatus), rid);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function updateStatusIndex(rid, oldStatus, newStatus, startMs) {
|
|
51
|
-
if (oldStatus) await deindexStatus(rid, oldStatus);
|
|
52
|
-
await db.sortedSetAdd(KEY_Z_STATUS(newStatus), startMs, rid);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function removeExpireIndex(rid) {
|
|
56
|
-
await db.sortedSetRemove(KEY_Z_EXPIRES, rid);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function createReservation(input) {
|
|
60
|
-
const rid = await nextRid();
|
|
61
|
-
const r = {
|
|
62
|
-
rid,
|
|
63
|
-
uid: input.uid,
|
|
64
|
-
startMs: input.startMs,
|
|
65
|
-
endMs: input.endMs,
|
|
66
|
-
status: input.status,
|
|
67
|
-
expiresAt: input.expiresAt || 0,
|
|
68
|
-
days: input.days || 0,
|
|
69
|
-
totalCents: input.totalCents || 0,
|
|
70
|
-
itemIds: JSON.stringify(input.itemIds || []),
|
|
71
|
-
};
|
|
72
|
-
await setReservation(rid, r);
|
|
73
|
-
await indexReservation(rid, r);
|
|
74
|
-
return rid;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function updateReservation(rid, patch) {
|
|
78
|
-
const existing = await getReservation(rid);
|
|
79
|
-
if (!existing) return null;
|
|
80
|
-
const updated = Object.assign({}, existing, patch);
|
|
81
|
-
// indexes
|
|
82
|
-
if (patch.status && patch.status !== existing.status) {
|
|
83
|
-
await updateStatusIndex(rid, existing.status, patch.status, existing.startMs);
|
|
84
|
-
}
|
|
85
|
-
if (typeof patch.expiresAt !== 'undefined') {
|
|
86
|
-
await removeExpireIndex(rid);
|
|
87
|
-
if (patch.expiresAt && patch.expiresAt > 0) {
|
|
88
|
-
await db.sortedSetAdd(KEY_Z_EXPIRES, patch.expiresAt, rid);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
// persist
|
|
92
|
-
const obj = Object.assign({}, updated, {
|
|
93
|
-
itemIds: JSON.stringify(updated.itemIds || existing.itemIds || []),
|
|
94
|
-
});
|
|
95
|
-
await setReservation(rid, obj);
|
|
96
|
-
return updated;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function listByStatus(status, start, stop) {
|
|
100
|
-
const ids = await db.getSortedSetRange(KEY_Z_STATUS(status), start, stop);
|
|
101
|
-
const out = [];
|
|
102
|
-
for (const rid of ids) {
|
|
103
|
-
// eslint-disable-next-line no-await-in-loop
|
|
104
|
-
const r = await getReservation(rid);
|
|
105
|
-
if (r) out.push(r);
|
|
106
|
-
}
|
|
107
|
-
return out;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function overlaps(aStart, aEnd, bStart, bEnd) {
|
|
111
|
-
return aStart < bEnd && bStart < aEnd;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function checkConflicts(itemIds, startMs, endMs, blockingStatuses) {
|
|
115
|
-
// candidates: reservations with start <= endMs
|
|
116
|
-
const ids = await db.getSortedSetRangeByScore(KEY_Z_START, 0, 2000, 0, endMs);
|
|
117
|
-
const conflicts = [];
|
|
118
|
-
for (const rid of ids) {
|
|
119
|
-
// eslint-disable-next-line no-await-in-loop
|
|
120
|
-
const r = await getReservation(rid);
|
|
121
|
-
if (!r) continue;
|
|
122
|
-
if (!blockingStatuses.has(r.status)) continue;
|
|
123
|
-
if (!overlaps(startMs, endMs, r.startMs, r.endMs)) continue;
|
|
124
|
-
const set = new Set(r.itemIds || []);
|
|
125
|
-
const hit = itemIds.filter(id => set.has(String(id)));
|
|
126
|
-
if (hit.length) conflicts.push({ rid, status: r.status, itemIds: hit, start: toDayString(r.startMs), end: toDayString(r.endMs) });
|
|
127
|
-
}
|
|
128
|
-
return conflicts;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function getEventsInRange(startMs, endMs) {
|
|
132
|
-
const ids = await db.getSortedSetRangeByScore(KEY_Z_START, 0, 2000, startMs - 365*24*3600*1000, endMs);
|
|
133
|
-
const events = [];
|
|
134
|
-
for (const rid of ids) {
|
|
135
|
-
// eslint-disable-next-line no-await-in-loop
|
|
136
|
-
const r = await getReservation(rid);
|
|
137
|
-
if (!r) continue;
|
|
138
|
-
if (!overlaps(startMs, endMs, r.startMs, r.endMs)) continue;
|
|
139
|
-
const icon = r.status === 'pending' ? '⏳' : (r.status === 'refused' ? '⛔' : (r.status === 'awaiting_payment' ? '💳' : '✅'));
|
|
140
|
-
const title = `${icon} #${r.rid}`;
|
|
141
|
-
events.push({
|
|
142
|
-
id: r.rid,
|
|
143
|
-
title,
|
|
144
|
-
start: toDayString(r.startMs),
|
|
145
|
-
end: toDayString(r.endMs),
|
|
146
|
-
allDay: true,
|
|
147
|
-
extendedProps: {
|
|
148
|
-
status: r.status,
|
|
149
|
-
itemIds: r.itemIds || [],
|
|
150
|
-
uid: r.uid,
|
|
151
|
-
days: r.days,
|
|
152
|
-
totalCents: r.totalCents,
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
return events;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async function sweepExpired() {
|
|
160
|
-
const now = Date.now();
|
|
161
|
-
const ids = await db.getSortedSetRangeByScore(KEY_Z_EXPIRES, 0, 500, 0, now);
|
|
162
|
-
for (const rid of ids) {
|
|
163
|
-
// eslint-disable-next-line no-await-in-loop
|
|
164
|
-
const r = await getReservation(rid);
|
|
165
|
-
if (!r) {
|
|
166
|
-
// eslint-disable-next-line no-await-in-loop
|
|
167
|
-
await removeExpireIndex(rid);
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
if (r.status === 'pending' && r.expiresAt && r.expiresAt <= now) {
|
|
171
|
-
// eslint-disable-next-line no-await-in-loop
|
|
172
|
-
await updateReservation(rid, { status: 'expired', expiresAt: 0 });
|
|
173
|
-
} else {
|
|
174
|
-
// eslint-disable-next-line no-await-in-loop
|
|
175
|
-
await removeExpireIndex(rid);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async function purgeRange(startMs, endMs) {
|
|
181
|
-
const ids = await db.getSortedSetRangeByScore(KEY_Z_START, 0, 100000, startMs, endMs);
|
|
182
|
-
let count = 0;
|
|
183
|
-
for (const rid of ids) {
|
|
184
|
-
// eslint-disable-next-line no-await-in-loop
|
|
185
|
-
const r = await getReservation(rid);
|
|
186
|
-
if (!r) continue;
|
|
187
|
-
// eslint-disable-next-line no-await-in-loop
|
|
188
|
-
await db.delete(KEY_HASH(rid));
|
|
189
|
-
// eslint-disable-next-line no-await-in-loop
|
|
190
|
-
await db.sortedSetRemove(KEY_Z_START, rid);
|
|
191
|
-
// eslint-disable-next-line no-await-in-loop
|
|
192
|
-
await db.sortedSetRemove(KEY_Z_STATUS(r.status), rid);
|
|
193
|
-
// eslint-disable-next-line no-await-in-loop
|
|
194
|
-
await db.sortedSetRemove(KEY_Z_EXPIRES, rid);
|
|
195
|
-
count++;
|
|
196
|
-
}
|
|
197
|
-
return count;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
module.exports = {
|
|
201
|
-
createReservation,
|
|
202
|
-
getReservation,
|
|
203
|
-
updateReservation,
|
|
204
|
-
listByStatus,
|
|
205
|
-
getEventsInRange,
|
|
206
|
-
sweepExpired,
|
|
207
|
-
purgeRange,
|
|
208
|
-
checkConflicts,
|
|
209
|
-
};
|
package/lib/sweeper.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
const logic = require('./logic');
|
|
3
|
-
|
|
4
|
-
let started = false;
|
|
5
|
-
let timer = null;
|
|
6
|
-
|
|
7
|
-
function start() {
|
|
8
|
-
if (started) return;
|
|
9
|
-
started = true;
|
|
10
|
-
timer = setInterval(() => {
|
|
11
|
-
logic.sweepExpired().catch(() => {});
|
|
12
|
-
}, 60 * 1000);
|
|
13
|
-
if (timer.unref) timer.unref();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
module.exports = { start };
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Votre réservation #{rid} du {startDate} au {endDate} a été validée.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Nouvelle demande de réservation #{rid} du {startDate} au {endDate} par {username}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
Votre réservation #{rid} du {startDate} au {endDate} a été refusée.
|