nodebb-plugin-calendar-onekite 11.1.6 → 11.1.7
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/README.md +36 -1
- package/lib/admin.js +89 -56
- package/lib/api.js +132 -64
- package/lib/controllers.js +7 -5
- package/lib/db.js +99 -38
- package/lib/helloasso.js +86 -133
- package/lib/scheduler.js +23 -15
- package/lib/settings.js +23 -21
- package/library.js +26 -40
- package/package.json +12 -5
- package/plugin.json +14 -7
- package/public/admin.js +181 -78
- package/public/calendar-onekite.scss +8 -2
- package/public/client.js +162 -59
- package/templates/admin/plugins/calendar-onekite.tpl +100 -71
- package/templates/calendar-onekite/calendar.tpl +23 -10
- package/templates/emails/calendar-onekite-approved.tpl +8 -7
- package/templates/emails/calendar-onekite-pending.tpl +9 -7
- package/templates/emails/calendar-onekite-refused.tpl +9 -6
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
1
|
# nodebb-plugin-calendar-onekite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Plugin NodeBB (v4.7.x) : calendrier de réservation de matériel basé sur FullCalendar, workflow de validation via ACP, et génération de lien de paiement HelloAsso.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Dans le dossier NodeBB :
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install /path/to/nodebb-plugin-calendar-onekite
|
|
11
|
+
# ou si vous avez un zip, dézippez dans node_modules puis :
|
|
12
|
+
./nodebb build
|
|
13
|
+
./nodebb restart
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Activez ensuite le plugin dans l’ACP.
|
|
17
|
+
|
|
18
|
+
## Page
|
|
19
|
+
|
|
20
|
+
- URL : `/calendar` (ex: https://www.onekite.com/calendar)
|
|
21
|
+
|
|
22
|
+
## Paramètres ACP
|
|
23
|
+
|
|
24
|
+
ACP → Plugins → Calendar OneKite
|
|
25
|
+
|
|
26
|
+
- `allowedGroups` : groupes autorisés à créer une demande (séparés par virgules)
|
|
27
|
+
- `notifyGroups` : groupes notifiés par email
|
|
28
|
+
- `pendingHoldMinutes` : durée de blocage d’une demande en attente
|
|
29
|
+
- HelloAsso :
|
|
30
|
+
- `helloassoEnv` : sandbox/prod
|
|
31
|
+
- `helloassoClientId` / `helloassoClientSecret`
|
|
32
|
+
- `helloassoOrganizationSlug` / `helloassoFormType` / `helloassoFormSlug`
|
|
33
|
+
- `helloassoReturnUrl` (optionnel)
|
|
34
|
+
|
|
35
|
+
## Notes
|
|
36
|
+
|
|
37
|
+
- Les demandes sont créées en statut `pending` puis expirent automatiquement après `pendingHoldMinutes`.
|
|
38
|
+
- La validation admin crée un checkout-intent HelloAsso et envoie le lien de paiement au demandeur.
|
package/lib/admin.js
CHANGED
|
@@ -1,97 +1,130 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const Settings = require('./settings');
|
|
4
|
-
const DB = require('./db');
|
|
5
|
-
const HelloAsso = require('./helloasso');
|
|
6
|
-
|
|
7
|
-
const user = require.main.require('./src/user');
|
|
8
3
|
const emailer = require.main.require('./src/emailer');
|
|
4
|
+
const users = require.main.require('./src/user');
|
|
5
|
+
const { settings, defaults } = require('./settings');
|
|
6
|
+
const db = require('./db');
|
|
7
|
+
const helloasso = require('./helloasso');
|
|
8
|
+
const { parseGroups, sendEmailToGroups } = require('./api');
|
|
9
9
|
|
|
10
10
|
async function renderAdmin(req, res) {
|
|
11
|
-
const settings = await Settings.get();
|
|
12
11
|
res.render('admin/plugins/calendar-onekite', {
|
|
13
12
|
title: 'Calendar OneKite',
|
|
14
|
-
settings,
|
|
15
13
|
});
|
|
16
14
|
}
|
|
17
15
|
|
|
18
16
|
async function getSettings(req, res) {
|
|
19
|
-
const s = await
|
|
20
|
-
res.json({ settings: s });
|
|
17
|
+
const s = await settings.get();
|
|
18
|
+
res.json({ settings: { ...defaults, ...s } });
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
async function saveSettings(req, res) {
|
|
24
|
-
const
|
|
25
|
-
|
|
22
|
+
const body = req.body || {};
|
|
23
|
+
// Basic sanitization
|
|
24
|
+
const clean = {
|
|
25
|
+
allowedGroups: String(body.allowedGroups || '').trim(),
|
|
26
|
+
notifyGroups: String(body.notifyGroups || '').trim(),
|
|
27
|
+
pendingHoldMinutes: Math.max(1, parseInt(body.pendingHoldMinutes, 10) || 5),
|
|
28
|
+
cleanupIntervalSeconds: Math.max(10, parseInt(body.cleanupIntervalSeconds, 10) || 60),
|
|
29
|
+
|
|
30
|
+
helloassoEnv: (body.helloassoEnv === 'prod') ? 'prod' : 'sandbox',
|
|
31
|
+
helloassoClientId: String(body.helloassoClientId || '').trim(),
|
|
32
|
+
helloassoClientSecret: String(body.helloassoClientSecret || '').trim(),
|
|
33
|
+
helloassoOrganizationSlug: String(body.helloassoOrganizationSlug || '').trim(),
|
|
34
|
+
helloassoFormType: String(body.helloassoFormType || 'event').trim(),
|
|
35
|
+
helloassoFormSlug: String(body.helloassoFormSlug || '').trim(),
|
|
36
|
+
helloassoReturnUrl: String(body.helloassoReturnUrl || '').trim(),
|
|
37
|
+
|
|
38
|
+
showUsernamesOnCalendar: !!body.showUsernamesOnCalendar,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
await settings.set(clean);
|
|
42
|
+
res.json({ ok: true, settings: clean });
|
|
26
43
|
}
|
|
27
44
|
|
|
28
|
-
async function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
res.json({ requests: pending });
|
|
45
|
+
async function listPending(req, res) {
|
|
46
|
+
const pending = await db.listPending(200);
|
|
47
|
+
res.json({ pending });
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
async function
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
38
|
-
|
|
39
|
-
ev.status = 'approved';
|
|
50
|
+
async function approveReservation(req, res) {
|
|
51
|
+
const rid = req.params.rid;
|
|
52
|
+
const reservation = await db.getReservation(rid);
|
|
53
|
+
if (!reservation) return res.status(404).json({ error: 'Réservation introuvable' });
|
|
54
|
+
if (reservation.status !== 'pending') return res.status(400).json({ error: 'Statut incompatible' });
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
const buyer = await users.getUserFields(reservation.uid, ['username', 'email', 'fullname']);
|
|
57
|
+
let paymentUrl = '';
|
|
58
|
+
try {
|
|
59
|
+
const checkout = await helloasso.createCheckoutIntent(reservation, {
|
|
60
|
+
username: buyer.username,
|
|
61
|
+
email: buyer.email,
|
|
62
|
+
});
|
|
63
|
+
paymentUrl = checkout.paymentUrl;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return res.status(500).json({ error: `HelloAsso: ${e.message}` });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const updated = await db.updateReservation(rid, {
|
|
69
|
+
status: 'approved',
|
|
70
|
+
paymentUrl,
|
|
71
|
+
adminUid: String(req.uid),
|
|
72
|
+
adminActionAt: String(Date.now()),
|
|
44
73
|
});
|
|
45
|
-
ev.paymentUrl = payUrl || '';
|
|
46
74
|
|
|
47
|
-
await DB.saveEvent(ev);
|
|
48
|
-
|
|
49
|
-
// email requester
|
|
50
75
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
paymentUrl
|
|
55
|
-
|
|
56
|
-
|
|
76
|
+
await emailer.send('calendar-onekite-approved', reservation.uid, {
|
|
77
|
+
subject: `[NodeBB] Réservation validée — paiement`,
|
|
78
|
+
reservation: updated,
|
|
79
|
+
paymentUrl,
|
|
80
|
+
url: paymentUrl,
|
|
81
|
+
site_title: (req.app && req.app.locals && req.app.locals.config && req.app.locals.config.title) || 'NodeBB',
|
|
57
82
|
});
|
|
58
|
-
} catch (e) {}
|
|
83
|
+
} catch (e) { /* ignore */ }
|
|
59
84
|
|
|
60
|
-
res.json({ ok: true,
|
|
85
|
+
res.json({ ok: true, reservation: updated });
|
|
61
86
|
}
|
|
62
87
|
|
|
63
|
-
async function
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
88
|
+
async function refuseReservation(req, res) {
|
|
89
|
+
const rid = req.params.rid;
|
|
90
|
+
const note = String((req.body && req.body.note) || '').trim();
|
|
91
|
+
|
|
92
|
+
const reservation = await db.getReservation(rid);
|
|
93
|
+
if (!reservation) return res.status(404).json({ error: 'Réservation introuvable' });
|
|
94
|
+
if (reservation.status !== 'pending') return res.status(400).json({ error: 'Statut incompatible' });
|
|
95
|
+
|
|
96
|
+
const updated = await db.updateReservation(rid, {
|
|
97
|
+
status: 'refused',
|
|
98
|
+
adminUid: String(req.uid),
|
|
99
|
+
adminActionAt: String(Date.now()),
|
|
100
|
+
adminNote: note,
|
|
101
|
+
});
|
|
69
102
|
|
|
70
103
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
await emailer.send('calendar-onekite-refused', reservation.uid, {
|
|
105
|
+
subject: `[NodeBB] Réservation refusée`,
|
|
106
|
+
reservation: updated,
|
|
107
|
+
note,
|
|
108
|
+
site_title: (req.app && req.app.locals && req.app.locals.config && req.app.locals.config.title) || 'NodeBB',
|
|
76
109
|
});
|
|
77
|
-
} catch (e) {}
|
|
110
|
+
} catch (e) { /* ignore */ }
|
|
78
111
|
|
|
79
|
-
res.json({ ok: true });
|
|
112
|
+
res.json({ ok: true, reservation: updated });
|
|
80
113
|
}
|
|
81
114
|
|
|
82
115
|
async function purgeByYear(req, res) {
|
|
83
|
-
const year = req.body && req.body.year;
|
|
84
|
-
if (
|
|
85
|
-
const
|
|
86
|
-
res.json({ ok: true,
|
|
116
|
+
const year = String((req.body && req.body.year) || '').trim();
|
|
117
|
+
if (!/^\d{4}$/.test(year)) return res.status(400).json({ error: 'Année invalide (YYYY)' });
|
|
118
|
+
const result = await db.purgeYear(year);
|
|
119
|
+
res.json({ ok: true, result });
|
|
87
120
|
}
|
|
88
121
|
|
|
89
122
|
module.exports = {
|
|
90
123
|
renderAdmin,
|
|
91
124
|
getSettings,
|
|
92
125
|
saveSettings,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
126
|
+
listPending,
|
|
127
|
+
approveReservation,
|
|
128
|
+
refuseReservation,
|
|
96
129
|
purgeByYear,
|
|
97
130
|
};
|
package/lib/api.js
CHANGED
|
@@ -1,86 +1,154 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const DB = require('./db');
|
|
4
|
-
const Settings = require('./settings');
|
|
5
|
-
const Scheduler = require('./scheduler');
|
|
6
|
-
|
|
7
3
|
const groups = require.main.require('./src/groups');
|
|
8
|
-
const
|
|
4
|
+
const users = require.main.require('./src/user');
|
|
9
5
|
const emailer = require.main.require('./src/emailer');
|
|
6
|
+
const { settings } = require('./settings');
|
|
7
|
+
const db = require('./db');
|
|
8
|
+
const helloasso = require('./helloasso');
|
|
10
9
|
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
function parseGroups(str) {
|
|
11
|
+
return String(str || '')
|
|
12
|
+
.split(',')
|
|
13
|
+
.map(s => s.trim())
|
|
14
|
+
.filter(Boolean);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
async function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
17
|
+
async function isUserInAnyGroup(uid, groupNames) {
|
|
18
|
+
if (!uid || !groupNames.length) return false;
|
|
19
|
+
// groups.isMember exists in core; fall back to getUserGroups if needed
|
|
20
|
+
for (const g of groupNames) {
|
|
21
|
+
try {
|
|
22
|
+
// eslint-disable-next-line no-await-in-loop
|
|
23
|
+
const ok = await groups.isMember(uid, g);
|
|
24
|
+
if (ok) return true;
|
|
25
|
+
} catch (e) { /* ignore */ }
|
|
26
|
+
}
|
|
27
|
+
// fallback
|
|
28
|
+
try {
|
|
29
|
+
const ug = await groups.getUserGroups([uid]);
|
|
30
|
+
const names = (ug && ug[uid] || []).map(x => x && (x.name || x.displayName)).filter(Boolean);
|
|
31
|
+
return names.some(n => groupNames.includes(n));
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function sendEmailToGroups(groupNames, template, data) {
|
|
38
|
+
const memberUids = new Set();
|
|
39
|
+
for (const g of groupNames) {
|
|
40
|
+
try {
|
|
41
|
+
// eslint-disable-next-line no-await-in-loop
|
|
42
|
+
const uids = await groups.getMembers(g, 0, -1);
|
|
43
|
+
(uids || []).forEach(uid => memberUids.add(uid));
|
|
44
|
+
} catch (e) { /* ignore */ }
|
|
45
|
+
}
|
|
46
|
+
const uids = Array.from(memberUids);
|
|
47
|
+
if (!uids.length) return;
|
|
48
|
+
|
|
49
|
+
// Send one email per user (NodeBB emailer is per-uid)
|
|
50
|
+
// If emailer signature changes, this is the only place to adjust.
|
|
51
|
+
for (const uid of uids) {
|
|
52
|
+
try {
|
|
53
|
+
// eslint-disable-next-line no-await-in-loop
|
|
54
|
+
await emailer.send(template, uid, data);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// ignore individual failures
|
|
28
57
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function getEvents(req, res) {
|
|
62
|
+
const start = Date.parse(req.query.start);
|
|
63
|
+
const end = Date.parse(req.query.end);
|
|
64
|
+
const startMs = Number.isFinite(start) ? start : (Date.now() - 30 * 86400 * 1000);
|
|
65
|
+
const endMs = Number.isFinite(end) ? end : (Date.now() + 365 * 86400 * 1000);
|
|
66
|
+
|
|
67
|
+
const list = await db.getReservationsBetween(startMs, endMs);
|
|
68
|
+
|
|
69
|
+
const events = list.map(r => {
|
|
70
|
+
const status = r.status || 'pending';
|
|
71
|
+
const icon = status === 'approved' ? '✅' : status === 'refused' ? '⛔' : status === 'expired' ? '⌛' : '⏳';
|
|
72
|
+
const title = `${icon} ${r.itemName || 'Matériel'}${r.username ? ` — ${r.username}` : ''}`;
|
|
73
|
+
return {
|
|
74
|
+
id: r.rid,
|
|
75
|
+
title,
|
|
76
|
+
start: new Date(Number(r.start)).toISOString(),
|
|
77
|
+
end: new Date(Number(r.end)).toISOString(),
|
|
78
|
+
extendedProps: {
|
|
79
|
+
status,
|
|
80
|
+
itemId: r.itemId,
|
|
81
|
+
itemName: r.itemName,
|
|
82
|
+
paymentUrl: r.paymentUrl || '',
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
res.json(events);
|
|
40
88
|
}
|
|
41
89
|
|
|
42
|
-
async function
|
|
90
|
+
async function getItems(req, res) {
|
|
91
|
+
try {
|
|
92
|
+
const items = await helloasso.listFormItems();
|
|
93
|
+
res.json({ items });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
res.status(500).json({ error: e.message });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function createReservation(req, res) {
|
|
43
100
|
const uid = req.uid;
|
|
44
|
-
|
|
101
|
+
const s = await settings.get();
|
|
102
|
+
const allowedGroups = parseGroups(s.allowedGroups);
|
|
103
|
+
const notifyGroups = parseGroups(s.notifyGroups);
|
|
45
104
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
105
|
+
const can = await isUserInAnyGroup(uid, allowedGroups);
|
|
106
|
+
if (!can) {
|
|
107
|
+
return res.status(403).json({ error: 'Vous n’êtes pas autorisé à réserver du matériel.' });
|
|
108
|
+
}
|
|
49
109
|
|
|
50
110
|
const body = req.body || {};
|
|
51
|
-
const start =
|
|
52
|
-
const end =
|
|
53
|
-
if (!start || !end || end <= start) return res.status(400).json({ error: 'bad-range' });
|
|
111
|
+
const start = Date.parse(body.start);
|
|
112
|
+
const end = Date.parse(body.end);
|
|
54
113
|
|
|
55
|
-
|
|
56
|
-
status: '
|
|
114
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
|
115
|
+
return res.status(400).json({ error: 'Plage de dates invalide.' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const holdMin = Math.max(1, parseInt(s.pendingHoldMinutes, 10) || 5);
|
|
119
|
+
const expiresAt = Date.now() + holdMin * 60 * 1000;
|
|
120
|
+
|
|
121
|
+
const user = await users.getUserFields(uid, ['username', 'email', 'fullname']);
|
|
122
|
+
const reservation = await db.createReservation({
|
|
123
|
+
uid,
|
|
124
|
+
username: user.username,
|
|
125
|
+
itemId: body.itemId,
|
|
126
|
+
itemName: body.itemName,
|
|
127
|
+
itemPrice: body.itemPrice,
|
|
57
128
|
start,
|
|
58
129
|
end,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
title: body.title || 'Réservation',
|
|
62
|
-
itemId: body.itemId || '',
|
|
63
|
-
itemName: body.itemName || '',
|
|
64
|
-
amountCents: Number(body.amountCents || 0),
|
|
130
|
+
status: 'pending',
|
|
131
|
+
expiresAt,
|
|
65
132
|
});
|
|
66
133
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
username: fromUser && fromUser.username,
|
|
76
|
-
start: new Date(start).toISOString(),
|
|
77
|
-
end: new Date(end).toISOString(),
|
|
78
|
-
adminUrl: (process.env.NODEBB_URL || '') + '/admin/plugins/calendar-onekite',
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
} catch (e) {}
|
|
134
|
+
// Notify admin group by email (pending)
|
|
135
|
+
await sendEmailToGroups(notifyGroups, 'calendar-onekite-pending', {
|
|
136
|
+
subject: `[NodeBB] Nouvelle demande de réservation`,
|
|
137
|
+
reservation,
|
|
138
|
+
user,
|
|
139
|
+
url: `${req.protocol}://${req.get('host')}/admin/plugins/calendar-onekite`,
|
|
140
|
+
site_title: (req.app && req.app.locals && req.app.locals.config && req.app.locals.config.title) || 'NodeBB',
|
|
141
|
+
});
|
|
82
142
|
|
|
83
|
-
res.json({
|
|
143
|
+
res.json({ reservation });
|
|
84
144
|
}
|
|
85
145
|
|
|
86
|
-
module.exports = {
|
|
146
|
+
module.exports = {
|
|
147
|
+
getEvents,
|
|
148
|
+
getItems,
|
|
149
|
+
createReservation,
|
|
150
|
+
// exported for admin module reuse
|
|
151
|
+
parseGroups,
|
|
152
|
+
isUserInAnyGroup,
|
|
153
|
+
sendEmailToGroups,
|
|
154
|
+
};
|
package/lib/controllers.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const { settings } = require('./settings');
|
|
4
4
|
|
|
5
5
|
async function renderCalendar(req, res) {
|
|
6
|
-
const
|
|
6
|
+
const s = await settings.get();
|
|
7
7
|
res.render('calendar-onekite/calendar', {
|
|
8
8
|
title: 'Calendrier',
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
calendarOnekite: {
|
|
10
|
+
showUsernamesOnCalendar: !!s.showUsernamesOnCalendar,
|
|
11
11
|
},
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
module.exports = {
|
|
15
|
+
module.exports = {
|
|
16
|
+
renderCalendar,
|
|
17
|
+
};
|
package/lib/db.js
CHANGED
|
@@ -1,59 +1,120 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const db = require.main.require('./src/database');
|
|
4
|
+
const { v4: uuidv4 } = require('uuid');
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
6
|
+
const KEY_PREFIX = 'calendar-onekite';
|
|
7
|
+
const RES_KEY = rid => `${KEY_PREFIX}:reservation:${rid}`;
|
|
8
|
+
const ZSET_BY_START = `${KEY_PREFIX}:reservations:byStart`;
|
|
9
|
+
const ZSET_PENDING_BY_EXPIRES = `${KEY_PREFIX}:reservations:pendingByExpires`;
|
|
10
|
+
const YEAR_SET = y => `${KEY_PREFIX}:reservations:year:${y}`;
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
return String(
|
|
12
|
+
function yearFromMs(ms) {
|
|
13
|
+
const d = new Date(Number(ms));
|
|
14
|
+
return String(d.getUTCFullYear());
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
async function
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
async function createReservation(data) {
|
|
18
|
+
const rid = uuidv4();
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
|
|
21
|
+
const reservation = {
|
|
22
|
+
rid,
|
|
23
|
+
uid: String(data.uid),
|
|
24
|
+
username: data.username || '',
|
|
25
|
+
itemId: String(data.itemId || ''),
|
|
26
|
+
itemName: String(data.itemName || ''),
|
|
27
|
+
itemPrice: String(data.itemPrice || ''),
|
|
28
|
+
start: String(data.start),
|
|
29
|
+
end: String(data.end),
|
|
30
|
+
status: data.status || 'pending', // pending | approved | refused | expired
|
|
31
|
+
createdAt: String(now),
|
|
32
|
+
expiresAt: String(data.expiresAt || (now + 5 * 60 * 1000)),
|
|
33
|
+
paymentUrl: data.paymentUrl || '',
|
|
34
|
+
adminUid: '',
|
|
35
|
+
adminActionAt: '',
|
|
36
|
+
adminNote: '',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const y = yearFromMs(reservation.start);
|
|
40
|
+
await db.setObject(RES_KEY(rid), reservation);
|
|
41
|
+
await db.sortedSetAdd(ZSET_BY_START, Number(reservation.start), rid);
|
|
42
|
+
await db.setAdd(YEAR_SET(y), rid);
|
|
43
|
+
|
|
44
|
+
if (reservation.status === 'pending') {
|
|
45
|
+
await db.sortedSetAdd(ZSET_PENDING_BY_EXPIRES, Number(reservation.expiresAt), rid);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return reservation;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getReservation(rid) {
|
|
52
|
+
const obj = await db.getObject(RES_KEY(rid));
|
|
53
|
+
return obj && obj.rid ? obj : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function updateReservation(rid, patch) {
|
|
57
|
+
await db.setObject(RES_KEY(rid), patch);
|
|
58
|
+
const res = await getReservation(rid);
|
|
59
|
+
|
|
60
|
+
// Maintain pending index
|
|
61
|
+
if (patch.status || patch.expiresAt) {
|
|
62
|
+
const score = Number(res.expiresAt || 0);
|
|
63
|
+
if (res.status === 'pending') {
|
|
64
|
+
await db.sortedSetAdd(ZSET_PENDING_BY_EXPIRES, score, rid);
|
|
65
|
+
} else {
|
|
66
|
+
await db.sortedSetRemove(ZSET_PENDING_BY_EXPIRES, rid);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return res;
|
|
21
70
|
}
|
|
22
71
|
|
|
23
|
-
async function
|
|
24
|
-
|
|
72
|
+
async function getReservationsBetween(startMs, endMs) {
|
|
73
|
+
const rids = await db.getSortedSetRangeByScore(ZSET_BY_START, 0, -1, startMs, endMs);
|
|
74
|
+
if (!rids || !rids.length) return [];
|
|
75
|
+
const keys = rids.map(RES_KEY);
|
|
76
|
+
const objs = await db.getObjects(keys);
|
|
77
|
+
return (objs || []).filter(Boolean).filter(o => o && o.rid);
|
|
25
78
|
}
|
|
26
79
|
|
|
27
|
-
async function
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const keys = ids.map(id => HASH_PREFIX + id);
|
|
31
|
-
const events = await db.getObjects(keys);
|
|
32
|
-
return (events || []).filter(Boolean);
|
|
80
|
+
async function getPendingExpired(nowMs, limit = 100) {
|
|
81
|
+
const rids = await db.getSortedSetRangeByScore(ZSET_PENDING_BY_EXPIRES, 0, limit - 1, 0, nowMs);
|
|
82
|
+
return rids || [];
|
|
33
83
|
}
|
|
34
84
|
|
|
35
|
-
async function
|
|
36
|
-
|
|
37
|
-
|
|
85
|
+
async function listPending(limit = 200) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
// pendingByExpires is sorted by expiresAt; use that as list, but include not yet expired
|
|
88
|
+
const rids = await db.getSortedSetRange(ZSET_PENDING_BY_EXPIRES, 0, limit - 1);
|
|
89
|
+
const keys = (rids || []).map(RES_KEY);
|
|
90
|
+
const objs = await db.getObjects(keys);
|
|
91
|
+
return (objs || []).filter(Boolean).filter(o => o.status === 'pending' && Number(o.expiresAt) > now);
|
|
38
92
|
}
|
|
39
93
|
|
|
40
94
|
async function purgeYear(year) {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
95
|
+
const ykey = YEAR_SET(year);
|
|
96
|
+
const rids = await db.getSetMembers(ykey);
|
|
97
|
+
if (!rids || !rids.length) return { year, purged: 0 };
|
|
98
|
+
|
|
99
|
+
// remove from indexes
|
|
100
|
+
await db.sortedSetRemove(ZSET_BY_START, rids);
|
|
101
|
+
await db.sortedSetRemove(ZSET_PENDING_BY_EXPIRES, rids);
|
|
102
|
+
|
|
103
|
+
// delete objects
|
|
104
|
+
await db.deleteAll(rids.map(RES_KEY));
|
|
105
|
+
|
|
106
|
+
// delete year set
|
|
107
|
+
await db.delete(ykey);
|
|
108
|
+
|
|
109
|
+
return { year, purged: rids.length };
|
|
51
110
|
}
|
|
52
111
|
|
|
53
112
|
module.exports = {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
113
|
+
createReservation,
|
|
114
|
+
getReservation,
|
|
115
|
+
updateReservation,
|
|
116
|
+
getReservationsBetween,
|
|
117
|
+
getPendingExpired,
|
|
118
|
+
listPending,
|
|
58
119
|
purgeYear,
|
|
59
120
|
};
|