nodebb-plugin-calendar-onekite 11.1.29 → 11.1.30
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/admin.js +64 -97
- package/lib/api.js +213 -109
- package/lib/db.js +53 -64
- package/lib/helloasso.js +69 -70
- package/library.js +82 -32
- package/package.json +6 -3
- package/plugin.json +8 -7
- package/public/admin.js +109 -73
- package/public/client.js +186 -158
- package/templates/admin/plugins/calendar-onekite.tpl +62 -64
- package/templates/calendar-onekite.tpl +5 -10
package/lib/admin.js
CHANGED
|
@@ -1,109 +1,76 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const Groups = require.main.require('./src/groups');
|
|
4
3
|
const meta = require.main.require('./src/meta');
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const settings = require('./settings');
|
|
8
|
-
const db = require('./db');
|
|
4
|
+
const dbi = require('./db');
|
|
5
|
+
const api = require('./api');
|
|
9
6
|
|
|
10
|
-
async function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
next();
|
|
16
|
-
} catch (e) {
|
|
17
|
-
res.status(500).json({ ok:false, error:'server' });
|
|
18
|
-
}
|
|
7
|
+
async function getSettings(req, res) {
|
|
8
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
9
|
+
// mask secret
|
|
10
|
+
if (settings && settings.helloassoClientSecret) settings.helloassoClientSecret = '***';
|
|
11
|
+
res.json({ ok: true, settings: settings || {} });
|
|
19
12
|
}
|
|
20
13
|
|
|
21
|
-
function
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
router.put(`${prefix}/admin/plugins/calendar-onekite/settings`, ...mws, ensureAdmin, async (req, res) => {
|
|
34
|
-
const p = req.body || {};
|
|
35
|
-
const toSave = {
|
|
36
|
-
helloassoEnv: p.helloassoEnv || 'sandbox',
|
|
37
|
-
helloassoClientId: p.helloassoClientId || '',
|
|
38
|
-
helloassoClientSecret: p.helloassoClientSecret && p.helloassoClientSecret !== '***' ? p.helloassoClientSecret : (await meta.settings.getOne(settings.PLUGIN_ID, 'helloassoClientSecret')) || '',
|
|
39
|
-
helloassoOrganizationSlug: p.helloassoOrganizationSlug || '',
|
|
40
|
-
helloassoFormType: p.helloassoFormType || 'shop',
|
|
41
|
-
helloassoFormSlug: p.helloassoFormSlug || '',
|
|
42
|
-
creatorGroups: settings.normalizeCsv(p.creatorGroups),
|
|
43
|
-
validatorGroups: settings.normalizeCsv(p.validatorGroups),
|
|
44
|
-
notifyGroups: settings.normalizeCsv(p.notifyGroups),
|
|
45
|
-
holdMinutes: parseInt(p.holdMinutes, 10) || 5,
|
|
46
|
-
};
|
|
47
|
-
await meta.settings.set(settings.PLUGIN_ID, toSave);
|
|
48
|
-
res.json({ ok:true });
|
|
49
|
-
});
|
|
14
|
+
async function saveSettings(req, res) {
|
|
15
|
+
const body = req.body || {};
|
|
16
|
+
// If secret is '***', do not overwrite.
|
|
17
|
+
const current = await meta.settings.get('calendar-onekite') || {};
|
|
18
|
+
const next = { ...current, ...body };
|
|
19
|
+
if (body.helloassoClientSecret === '***') {
|
|
20
|
+
next.helloassoClientSecret = current.helloassoClientSecret;
|
|
21
|
+
}
|
|
22
|
+
await meta.settings.set('calendar-onekite', next);
|
|
23
|
+
// invalidate catalog cache
|
|
24
|
+
res.json({ ok: true });
|
|
25
|
+
}
|
|
50
26
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
27
|
+
async function getPending(req, res) {
|
|
28
|
+
const ids = await dbi.listAllIds(5000);
|
|
29
|
+
const rows = await dbi.getReservations(ids);
|
|
30
|
+
const pending = rows.filter(r => (r.status || 'pending') === 'pending')
|
|
31
|
+
.sort((a,b)=> (b.createdAt||0)-(a.createdAt||0))
|
|
32
|
+
.slice(0, 200);
|
|
33
|
+
res.json({ ok: true, pending });
|
|
34
|
+
}
|
|
59
35
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
67
|
-
router.post(`${prefix}/admin/plugins/calendar-onekite/reservations/:rid/refuse`, ...mws, ensureAdmin, async (req, res) => {
|
|
68
|
-
const rid = parseInt(req.params.rid, 10);
|
|
69
|
-
const r = await logic.setStatus(rid, 'refused');
|
|
70
|
-
if (!r) return res.status(404).json({ ok:false, error:'not-found' });
|
|
71
|
-
res.json({ ok:true });
|
|
72
|
-
});
|
|
36
|
+
async function purgeByYear(req, res) {
|
|
37
|
+
const year = String((req.body||{}).year || '').trim();
|
|
38
|
+
if (!/^\d{4}$/.test(year)) return res.status(400).json({ status: { code: 'bad-request', message: 'Année invalide (YYYY)' } });
|
|
39
|
+
const y = Number(year);
|
|
40
|
+
const startTs = Date.UTC(y,0,1,0,0,0);
|
|
41
|
+
const endTs = Date.UTC(y+1,0,1,0,0,0);
|
|
73
42
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const endDate = `${year}-12-31`;
|
|
81
|
-
const startTs = Date.parse(`${startDate}T00:00:00.000Z`);
|
|
82
|
-
const endTs = Date.parse(`${endDate}T23:59:59.999Z`);
|
|
83
|
-
const ids = await db.listReservationIdsByStartRange(startTs, endTs, 5000);
|
|
84
|
-
for (const id of ids) {
|
|
85
|
-
await db.removeReservation(id);
|
|
86
|
-
}
|
|
87
|
-
res.json({ ok:true, removed: ids.length });
|
|
88
|
-
});
|
|
43
|
+
const ids = await dbi.listReservationIdsByStartRange(startTs, endTs, 100000);
|
|
44
|
+
for (const id of ids) {
|
|
45
|
+
await dbi.deleteReservation(id);
|
|
46
|
+
}
|
|
47
|
+
res.json({ ok: true, deleted: ids.length });
|
|
48
|
+
}
|
|
89
49
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
50
|
+
async function debugHelloAsso(req, res) {
|
|
51
|
+
const settings = await meta.settings.get('calendar-onekite') || {};
|
|
52
|
+
const masked = { ...settings };
|
|
53
|
+
if (masked.helloassoClientSecret) masked.helloassoClientSecret = '***';
|
|
54
|
+
const out = { ok: true, settings: masked };
|
|
55
|
+
try {
|
|
56
|
+
// token + catalog
|
|
57
|
+
const items = await api._getCatalogItems();
|
|
58
|
+
out.token = { ok: true };
|
|
59
|
+
out.catalog = { ok: true, count: items.length, sample: items.slice(0, 10) };
|
|
60
|
+
// also expose cache error if any
|
|
61
|
+
const cache = api._catalogCache();
|
|
62
|
+
if (cache && cache.err) out.catalog.err = cache.err;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
out.token = { ok: false, message: e.message };
|
|
65
|
+
out.catalog = { ok: false, count: 0, sample: [], message: e.message };
|
|
66
|
+
}
|
|
67
|
+
res.json(out);
|
|
107
68
|
}
|
|
108
69
|
|
|
109
|
-
module.exports = {
|
|
70
|
+
module.exports = {
|
|
71
|
+
getSettings,
|
|
72
|
+
saveSettings,
|
|
73
|
+
getPending,
|
|
74
|
+
purgeByYear,
|
|
75
|
+
debugHelloAsso,
|
|
76
|
+
};
|
package/lib/api.js
CHANGED
|
@@ -1,124 +1,228 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
3
|
+
const meta = require.main.require('./src/meta');
|
|
4
|
+
const dbi = require('./db');
|
|
5
|
+
const hello = require('./helloasso');
|
|
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
|
+
function parseDateParam(s) {
|
|
14
|
+
// FullCalendar sends ISO date/time. Accept 'YYYY-MM-DD' too.
|
|
15
|
+
const d = new Date(s);
|
|
16
|
+
if (!s || Number.isNaN(d.getTime())) return null;
|
|
17
|
+
return d;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toYMD(ts) {
|
|
21
|
+
const d = new Date(Number(ts));
|
|
22
|
+
const y = d.getUTCFullYear();
|
|
23
|
+
const m = String(d.getUTCMonth()+1).padStart(2,'0');
|
|
24
|
+
const da = String(d.getUTCDate()).padStart(2,'0');
|
|
25
|
+
return `${y}-${m}-${da}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function daysBetweenInclusive(startYMD, endYMDExclusive) {
|
|
29
|
+
// FullCalendar selection end is exclusive. We compute number of days selected.
|
|
30
|
+
const s = new Date(startYMD + 'T00:00:00Z');
|
|
31
|
+
const e = new Date(endYMDExclusive + 'T00:00:00Z');
|
|
32
|
+
const diff = Math.max(0, Math.round((e - s) / 86400000));
|
|
33
|
+
return diff || 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let catalogCache = { at: 0, items: [], raw: null, ok: false, err: null };
|
|
37
|
+
|
|
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
|
+
async function getSettings() {
|
|
68
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
69
|
+
return settings || {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function getCatalogItems() {
|
|
73
|
+
const settings = await getSettings();
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
if (catalogCache.ok && (now - catalogCache.at) < 5*60*1000) {
|
|
76
|
+
return catalogCache.items;
|
|
77
|
+
}
|
|
78
|
+
if (!settings.helloassoClientId || !settings.helloassoClientSecret || !settings.helloassoOrganizationSlug || !settings.helloassoFormSlug || !settings.helloassoFormType) {
|
|
79
|
+
catalogCache = { at: now, items: [], raw: null, ok: true, err: null };
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const pub = await hello.getShopCatalog(settings);
|
|
84
|
+
const items = hello.extractItemsFromPublic(pub);
|
|
85
|
+
catalogCache = { at: now, items, raw: pub, ok: true, err: null };
|
|
86
|
+
return items;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
catalogCache = { at: now, items: [], raw: null, ok: false, err: { message: err.message, statusCode: err.statusCode, body: err.body } };
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function getCatalog(req, res) {
|
|
94
|
+
const items = await getCatalogItems();
|
|
95
|
+
res.json({ ok: true, count: items.length, items });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function getEvents(req, res) {
|
|
99
|
+
await sweepExpiredPending();
|
|
100
|
+
const start = parseDateParam(req.query.start);
|
|
101
|
+
const end = parseDateParam(req.query.end);
|
|
102
|
+
const startTs = start ? start.getTime() : Date.now() - 365*86400000;
|
|
103
|
+
const endTs = end ? end.getTime() : Date.now() + 365*86400000;
|
|
104
|
+
|
|
105
|
+
const ids = await dbi.listReservationIdsByStartRange(startTs, endTs, 5000);
|
|
106
|
+
const rows = await dbi.getReservations(ids);
|
|
107
|
+
|
|
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 => {
|
|
115
|
+
const itemNames = (r.items || []).map(it => it.name).join(', ');
|
|
116
|
+
const title = itemNames || 'Réservation';
|
|
117
|
+
const status = r.status || 'pending';
|
|
118
|
+
const icon = status === 'approved' ? '✅' : status === 'awaiting_payment' ? '💳' : status === 'refused' ? '⛔' : '⏳';
|
|
119
|
+
return {
|
|
120
|
+
id: r.id,
|
|
121
|
+
title: `${icon} ${title}`,
|
|
122
|
+
start: toYMD(r.startTs),
|
|
123
|
+
end: toYMD(r.endTs), // end exclusive works for allDay
|
|
27
124
|
allDay: true,
|
|
28
125
|
extendedProps: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
canValidate: canVal,
|
|
126
|
+
status,
|
|
127
|
+
requesterUid: r.uid,
|
|
128
|
+
items: r.items || [],
|
|
129
|
+
totalCents: r.totalCents || 0,
|
|
130
|
+
days: r.days || 1,
|
|
35
131
|
}
|
|
36
|
-
}
|
|
37
|
-
json(res, { ok:true, events: fc });
|
|
132
|
+
};
|
|
38
133
|
});
|
|
39
134
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
const { items } = await helloasso.getCatalogItems();
|
|
44
|
-
json(res, { ok:true, items });
|
|
45
|
-
} catch (e) {
|
|
46
|
-
json(res, { ok:false, items: [], error:'helloasso' });
|
|
47
|
-
}
|
|
48
|
-
});
|
|
135
|
+
res.json(events);
|
|
136
|
+
}
|
|
49
137
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!uid) return res.status(401).json({ ok:false, error:'not-logged-in' });
|
|
54
|
-
if (!(await perms.canCreate(uid))) return res.status(403).json({ ok:false, error:'not-allowed' });
|
|
55
|
-
|
|
56
|
-
const itemIds = Array.isArray(req.body.itemIds) ? req.body.itemIds : [];
|
|
57
|
-
const startDate = String(req.body.startDate || '').slice(0,10);
|
|
58
|
-
const endDate = String(req.body.endDate || '').slice(0,10);
|
|
59
|
-
const note = String(req.body.note || '').slice(0,500);
|
|
60
|
-
|
|
61
|
-
if (!itemIds.length) return res.status(400).json({ ok:false, error:'no-items' });
|
|
62
|
-
if (!startDate || !endDate) return res.status(400).json({ ok:false, error:'missing-dates' });
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const r = await logic.createReservation(uid, itemIds, startDate, endDate, note);
|
|
66
|
-
|
|
67
|
-
// email notify groups
|
|
68
|
-
const notifyUids = await perms.listNotifyUids();
|
|
69
|
-
const user = await User.getUserFields(uid, ['username']);
|
|
70
|
-
for (const nu of notifyUids) {
|
|
71
|
-
await email.sendToUid('calendar-onekite-pending', nu, {
|
|
72
|
-
subject: 'Nouvelle demande de réservation',
|
|
73
|
-
username: user.username,
|
|
74
|
-
startDate: r.startDate,
|
|
75
|
-
endDate: r.endDate,
|
|
76
|
-
rid: r.rid,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
138
|
+
async function createReservation(req, res) {
|
|
139
|
+
const uid = req.uid;
|
|
140
|
+
if (!uid) return res.status(403).json({ status: { code: 'forbidden', message: 'Not logged in' } });
|
|
79
141
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
142
|
+
// Expire old pending holds before checking availability
|
|
143
|
+
await sweepExpiredPending();
|
|
144
|
+
|
|
145
|
+
const body = req.body || {};
|
|
146
|
+
const startYMD = body.start;
|
|
147
|
+
const endYMD = body.end;
|
|
148
|
+
const itemIds = Array.isArray(body.itemIds) ? body.itemIds.map(String) : [];
|
|
149
|
+
if (!startYMD || !endYMD) return res.status(400).json({ status: { code: 'bad-request', message: 'Missing dates' } });
|
|
150
|
+
if (!itemIds.length) return res.status(400).json({ status: { code: 'bad-request', message: 'Missing itemIds' } });
|
|
151
|
+
|
|
152
|
+
const days = daysBetweenInclusive(startYMD, endYMD);
|
|
153
|
+
|
|
154
|
+
const catalog = await getCatalogItems();
|
|
155
|
+
const chosen = [];
|
|
156
|
+
let sumPerDay = 0;
|
|
157
|
+
for (const id of itemIds) {
|
|
158
|
+
const it = catalog.find(x => x.id === id);
|
|
159
|
+
if (it) {
|
|
160
|
+
chosen.push(it);
|
|
161
|
+
sumPerDay += Number(it.priceCents || 0);
|
|
162
|
+
} else {
|
|
163
|
+
// keep unknown item with 0 price to avoid hard fail
|
|
164
|
+
chosen.push({ id, name: `Item ${id}`, priceCents: 0 });
|
|
86
165
|
}
|
|
87
|
-
}
|
|
166
|
+
}
|
|
167
|
+
const totalCents = sumPerDay * days;
|
|
88
168
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!uid) return res.status(401).json({ ok:false, error:'not-logged-in' });
|
|
93
|
-
if (!(await perms.canValidate(uid))) return res.status(403).json({ ok:false, error:'not-validator' });
|
|
94
|
-
const rid = parseInt(req.params.rid, 10);
|
|
95
|
-
const r = await logic.setStatus(rid, 'awaiting_payment');
|
|
96
|
-
if (!r) return res.status(404).json({ ok:false, error:'not-found' });
|
|
97
|
-
// notify requester
|
|
98
|
-
await email.sendToUid('calendar-onekite-approved', r.uid, {
|
|
99
|
-
subject: 'Réservation validée',
|
|
100
|
-
startDate: r.startDate,
|
|
101
|
-
endDate: r.endDate,
|
|
102
|
-
rid: r.rid,
|
|
103
|
-
});
|
|
104
|
-
json(res, { ok:true });
|
|
105
|
-
});
|
|
169
|
+
const id = await dbi.nextId();
|
|
170
|
+
const startTs = new Date(startYMD + 'T00:00:00Z').getTime();
|
|
171
|
+
const endTs = new Date(endYMD + 'T00:00:00Z').getTime();
|
|
106
172
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
119
195
|
});
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
const resv = {
|
|
203
|
+
id,
|
|
204
|
+
uid: String(uid),
|
|
205
|
+
startTs,
|
|
206
|
+
endTs,
|
|
207
|
+
startYMD,
|
|
208
|
+
endYMD,
|
|
209
|
+
items: chosen,
|
|
210
|
+
days,
|
|
211
|
+
totalCents,
|
|
212
|
+
status: 'pending',
|
|
213
|
+
createdAt: Date.now(),
|
|
214
|
+
expiresAt,
|
|
215
|
+
};
|
|
216
|
+
await dbi.saveReservation(resv);
|
|
217
|
+
|
|
218
|
+
res.json({ ok: true, reservation: resv });
|
|
122
219
|
}
|
|
123
220
|
|
|
124
|
-
module.exports = {
|
|
221
|
+
module.exports = {
|
|
222
|
+
getEvents,
|
|
223
|
+
getCatalog,
|
|
224
|
+
createReservation,
|
|
225
|
+
_sweepExpiredPending: sweepExpiredPending,
|
|
226
|
+
_getCatalogItems: getCatalogItems,
|
|
227
|
+
_catalogCache: () => catalogCache,
|
|
228
|
+
};
|
package/lib/db.js
CHANGED
|
@@ -3,86 +3,75 @@
|
|
|
3
3
|
const db = require.main.require('./src/database');
|
|
4
4
|
|
|
5
5
|
const KEYS = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
Z_BY_START: 'calendar-onekite:reservations:byStart',
|
|
7
|
+
Z_BY_EXPIRES: 'calendar-onekite:reservations:byExpires',
|
|
8
|
+
HASH: (id) => `calendar-onekite:reservation:${id}`,
|
|
9
|
+
NEXT_ID: 'calendar-onekite:reservation:nextId',
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
async function nextId() {
|
|
13
|
-
const
|
|
14
|
-
return
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function safeJson(v, fallback) {
|
|
18
|
-
try { return JSON.parse(v); } catch { return fallback; }
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function hydrate(obj) {
|
|
22
|
-
return {
|
|
23
|
-
rid: parseInt(obj.rid, 10),
|
|
24
|
-
uid: parseInt(obj.uid, 10),
|
|
25
|
-
startDate: obj.startDate,
|
|
26
|
-
endDate: obj.endDate,
|
|
27
|
-
startTs: parseInt(obj.startTs, 10),
|
|
28
|
-
endTs: parseInt(obj.endTs, 10),
|
|
29
|
-
status: obj.status,
|
|
30
|
-
createdAt: parseInt(obj.createdAt, 10),
|
|
31
|
-
expiresAt: parseInt(obj.expiresAt || '0', 10),
|
|
32
|
-
itemIds: safeJson(obj.itemIds, []),
|
|
33
|
-
days: parseInt(obj.days || '0', 10),
|
|
34
|
-
totalCents: parseInt(obj.totalCents || '0', 10),
|
|
35
|
-
title: obj.title || '',
|
|
36
|
-
note: obj.note || '',
|
|
37
|
-
};
|
|
13
|
+
const id = await db.incrObjectField('calendar-onekite:meta', 'nextId');
|
|
14
|
+
return String(id);
|
|
38
15
|
}
|
|
39
16
|
|
|
40
17
|
async function saveReservation(resv) {
|
|
41
|
-
|
|
42
|
-
await db.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
startDate: String(resv.startDate),
|
|
46
|
-
endDate: String(resv.endDate),
|
|
47
|
-
startTs: String(resv.startTs),
|
|
48
|
-
endTs: String(resv.endTs),
|
|
49
|
-
status: String(resv.status),
|
|
50
|
-
createdAt: String(resv.createdAt),
|
|
51
|
-
expiresAt: String(resv.expiresAt || 0),
|
|
52
|
-
itemIds: JSON.stringify(resv.itemIds || []),
|
|
53
|
-
days: String(resv.days || 0),
|
|
54
|
-
totalCents: String(resv.totalCents || 0),
|
|
55
|
-
title: String(resv.title || ''),
|
|
56
|
-
note: String(resv.note || ''),
|
|
57
|
-
});
|
|
58
|
-
await db.sortedSetAdd(KEYS.zsetStart, resv.startTs, String(resv.rid));
|
|
18
|
+
await db.setObject(KEYS.HASH(resv.id), resv);
|
|
19
|
+
await db.sortedSetAdd(KEYS.Z_BY_START, resv.startTs, resv.id);
|
|
20
|
+
|
|
21
|
+
// Track expiration for pending reservations
|
|
59
22
|
if (resv.expiresAt) {
|
|
60
|
-
await db.sortedSetAdd(KEYS.
|
|
23
|
+
await db.sortedSetAdd(KEYS.Z_BY_EXPIRES, Number(resv.expiresAt), resv.id);
|
|
61
24
|
} else {
|
|
62
|
-
await db.sortedSetRemove(KEYS.
|
|
25
|
+
await db.sortedSetRemove(KEYS.Z_BY_EXPIRES, resv.id);
|
|
63
26
|
}
|
|
64
27
|
}
|
|
65
28
|
|
|
66
|
-
async function getReservation(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
29
|
+
async function getReservation(id) {
|
|
30
|
+
return await db.getObject(KEYS.HASH(id));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getReservations(ids) {
|
|
34
|
+
if (!ids.length) return [];
|
|
35
|
+
const keys = ids.map(KEYS.HASH);
|
|
36
|
+
const objects = await db.getObjects(keys);
|
|
37
|
+
return objects.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function listReservationIdsByStartRange(startTs, endTs, limit = 2000) {
|
|
41
|
+
// NodeBB db API: getSortedSetRangeByScore(set, start, stop, min, max)
|
|
42
|
+
const ids = await db.getSortedSetRangeByScore(KEYS.Z_BY_START, 0, limit - 1, startTs, endTs);
|
|
43
|
+
return ids || [];
|
|
70
44
|
}
|
|
71
45
|
|
|
72
|
-
async function
|
|
73
|
-
|
|
74
|
-
|
|
46
|
+
async function deleteReservation(id) {
|
|
47
|
+
await db.delete(KEYS.HASH(id));
|
|
48
|
+
await db.sortedSetRemove(KEYS.Z_BY_START, id);
|
|
49
|
+
await db.sortedSetRemove(KEYS.Z_BY_EXPIRES, id);
|
|
75
50
|
}
|
|
76
51
|
|
|
77
|
-
async function listExpiredIds(nowTs, limit =
|
|
78
|
-
|
|
79
|
-
|
|
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 || [];
|
|
80
56
|
}
|
|
81
57
|
|
|
82
|
-
async function
|
|
83
|
-
await db.
|
|
84
|
-
await db.sortedSetRemove(KEYS.zsetStart, String(rid));
|
|
85
|
-
await db.sortedSetRemove(KEYS.zsetExpires, String(rid));
|
|
58
|
+
async function clearExpiryIndex(id) {
|
|
59
|
+
await db.sortedSetRemove(KEYS.Z_BY_EXPIRES, id);
|
|
86
60
|
}
|
|
87
61
|
|
|
88
|
-
|
|
62
|
+
async function listAllIds(limit=100000) {
|
|
63
|
+
return await db.getSortedSetRange(KEYS.Z_BY_START, 0, limit-1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
KEYS,
|
|
68
|
+
nextId,
|
|
69
|
+
saveReservation,
|
|
70
|
+
getReservation,
|
|
71
|
+
getReservations,
|
|
72
|
+
listReservationIdsByStartRange,
|
|
73
|
+
deleteReservation,
|
|
74
|
+
listExpiredIds,
|
|
75
|
+
clearExpiryIndex,
|
|
76
|
+
listAllIds,
|
|
77
|
+
};
|