nodebb-plugin-calendar-onekite 11.1.11 → 11.1.12

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 CHANGED
@@ -1,130 +1,137 @@
1
1
  'use strict';
2
2
 
3
+ const meta = require.main.require('./src/meta');
4
+ const user = require.main.require('./src/user');
3
5
  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');
6
+
7
+ const dbLayer = require('./db');
7
8
  const helloasso = require('./helloasso');
8
- const { parseGroups, sendEmailToGroups } = require('./api');
9
9
 
10
- async function renderAdmin(req, res) {
10
+ const ADMIN_PRIV = 'admin:settings';
11
+
12
+ const admin = {};
13
+
14
+ admin.renderAdmin = async function (req, res) {
11
15
  res.render('admin/plugins/calendar-onekite', {
12
16
  title: 'Calendar OneKite',
13
17
  });
14
- }
15
-
16
- async function getSettings(req, res) {
17
- const s = await settings.get();
18
- res.json({ settings: { ...defaults, ...s } });
19
- }
20
-
21
- async function saveSettings(req, res) {
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 });
43
- }
44
-
45
- async function listPending(req, res) {
46
- const pending = await db.listPending(200);
47
- res.json({ pending });
48
- }
49
-
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' });
18
+ };
55
19
 
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}` });
20
+ admin.getSettings = async function (req, res) {
21
+ const settings = await meta.settings.get('calendar-onekite');
22
+ res.json(settings || {});
23
+ };
24
+
25
+ admin.saveSettings = async function (req, res) {
26
+ await meta.settings.set('calendar-onekite', req.body || {});
27
+ res.json({ ok: true });
28
+ };
29
+
30
+ admin.listPending = async function (req, res) {
31
+ const ids = await dbLayer.listAllReservationIds(5000);
32
+ const pending = [];
33
+ for (const rid of ids) {
34
+ const r = await dbLayer.getReservation(rid);
35
+ if (r && r.status === 'pending') {
36
+ pending.push(r);
37
+ }
66
38
  }
39
+ pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
40
+ res.json(pending);
41
+ };
67
42
 
68
- const updated = await db.updateReservation(rid, {
69
- status: 'approved',
70
- paymentUrl,
71
- adminUid: String(req.uid),
72
- adminActionAt: String(Date.now()),
43
+ admin.approveReservation = async function (req, res) {
44
+ const rid = req.params.rid;
45
+ const r = await dbLayer.getReservation(rid);
46
+ if (!r) return res.status(404).json({ error: 'not-found' });
47
+
48
+ r.status = 'approved';
49
+
50
+ // Create HelloAsso payment link if configured
51
+ const settings = await meta.settings.get('calendar-onekite');
52
+ const env = settings.helloassoEnv || 'prod';
53
+ const token = await helloasso.getAccessToken({
54
+ env,
55
+ clientId: settings.helloassoClientId,
56
+ clientSecret: settings.helloassoClientSecret,
73
57
  });
74
58
 
75
- try {
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',
59
+ let paymentUrl = null;
60
+ if (token) {
61
+ const requester = await user.getUserData(r.uid);
62
+ paymentUrl = await helloasso.createCheckoutIntent({
63
+ env,
64
+ token,
65
+ organizationSlug: settings.helloassoOrganizationSlug,
66
+ formType: settings.helloassoFormType,
67
+ formSlug: settings.helloassoFormSlug,
68
+ totalAmount: parseInt(settings.defaultAmount || '0', 10) || 0,
69
+ payerEmail: requester && requester.email,
82
70
  });
83
- } catch (e) { /* ignore */ }
71
+ }
84
72
 
85
- res.json({ ok: true, reservation: updated });
86
- }
73
+ if (paymentUrl) {
74
+ r.paymentUrl = paymentUrl;
75
+ }
87
76
 
88
- async function refuseReservation(req, res) {
89
- const rid = req.params.rid;
90
- const note = String((req.body && req.body.note) || '').trim();
77
+ await dbLayer.saveReservation(r);
91
78
 
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' });
79
+ // Email requester
80
+ try {
81
+ const requester = await user.getUserData(r.uid);
82
+ if (requester && requester.email) {
83
+ await emailer.send('calendar-onekite_approved', requester.email, {
84
+ username: requester.username,
85
+ itemName: r.itemName,
86
+ start: new Date(parseInt(r.start, 10)).toISOString(),
87
+ end: new Date(parseInt(r.end, 10)).toISOString(),
88
+ paymentUrl: paymentUrl || '',
89
+ });
90
+ }
91
+ } catch (e) {}
92
+
93
+ res.json({ ok: true, paymentUrl: paymentUrl || null });
94
+ };
95
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
- });
96
+ admin.refuseReservation = async function (req, res) {
97
+ const rid = req.params.rid;
98
+ const r = await dbLayer.getReservation(rid);
99
+ if (!r) return res.status(404).json({ error: 'not-found' });
100
+
101
+ r.status = 'refused';
102
+ await dbLayer.saveReservation(r);
102
103
 
103
104
  try {
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',
109
- });
110
- } catch (e) { /* ignore */ }
111
-
112
- res.json({ ok: true, reservation: updated });
113
- }
114
-
115
- async function purgeByYear(req, res) {
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 });
120
- }
121
-
122
- module.exports = {
123
- renderAdmin,
124
- getSettings,
125
- saveSettings,
126
- listPending,
127
- approveReservation,
128
- refuseReservation,
129
- purgeByYear,
105
+ const requester = await user.getUserData(r.uid);
106
+ if (requester && requester.email) {
107
+ await emailer.send('calendar-onekite_refused', requester.email, {
108
+ username: requester.username,
109
+ itemName: r.itemName,
110
+ start: new Date(parseInt(r.start, 10)).toISOString(),
111
+ end: new Date(parseInt(r.end, 10)).toISOString(),
112
+ });
113
+ }
114
+ } catch (e) {}
115
+
116
+ res.json({ ok: true });
130
117
  };
118
+
119
+ admin.purgeByYear = async function (req, res) {
120
+ const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
121
+ if (!/^\d{4}$/.test(year)) {
122
+ return res.status(400).json({ error: 'invalid-year' });
123
+ }
124
+ const y = parseInt(year, 10);
125
+ const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
126
+ const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
127
+
128
+ const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
129
+ let count = 0;
130
+ for (const rid of ids) {
131
+ await dbLayer.removeReservation(rid);
132
+ count++;
133
+ }
134
+ res.json({ ok: true, removed: count });
135
+ };
136
+
137
+ module.exports = admin;
package/lib/api.js CHANGED
@@ -1,154 +1,158 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
4
+
5
+ const meta = require.main.require('./src/meta');
6
+ const user = require.main.require('./src/user');
3
7
  const groups = require.main.require('./src/groups');
4
- const users = require.main.require('./src/user');
5
- const emailer = require.main.require('./src/emailer');
6
- const { settings } = require('./settings');
7
- const db = require('./db');
8
+
9
+ const dbLayer = require('./db');
8
10
  const helloasso = require('./helloasso');
9
11
 
10
- function parseGroups(str) {
11
- return String(str || '')
12
- .split(',')
13
- .map(s => s.trim())
14
- .filter(Boolean);
12
+ function toTs(v) {
13
+ if (!v) return NaN;
14
+ const d = new Date(v);
15
+ return d.getTime();
15
16
  }
16
17
 
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;
18
+ async function canRequest(uid, settings) {
19
+ const allowed = (settings.allowedGroups || '').split(',').map(s => s.trim()).filter(Boolean);
20
+ if (!allowed.length) return true; // if empty, allow all logged in users
21
+ for (const g of allowed) {
22
+ const isMember = await groups.isMember(uid, g);
23
+ if (isMember) return true;
34
24
  }
25
+ return false;
26
+ }
27
+
28
+ function eventFor(resv) {
29
+ const status = resv.status;
30
+ const icons = { pending: '⏳', approved: '✅', refused: '⛔' };
31
+ return {
32
+ id: resv.rid,
33
+ title: `${icons[status] || ''} ${resv.itemName || resv.itemId}`.trim(),
34
+ start: new Date(parseInt(resv.start, 10)).toISOString(),
35
+ end: new Date(parseInt(resv.end, 10)).toISOString(),
36
+ extendedProps: {
37
+ status,
38
+ uid: resv.uid,
39
+ itemId: resv.itemId,
40
+ },
41
+ };
35
42
  }
36
43
 
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 */ }
44
+ const api = {};
45
+
46
+ api.getEvents = async function (req, res) {
47
+ const startTs = toTs(req.query.start) || 0;
48
+ const endTs = toTs(req.query.end) || (Date.now() + 365 * 24 * 3600 * 1000);
49
+
50
+ const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 2000);
51
+ const out = [];
52
+ for (const rid of ids) {
53
+ const r = await dbLayer.getReservation(rid);
54
+ if (!r) continue;
55
+ if (r.status !== 'pending' && r.status !== 'approved') continue;
56
+ out.push(eventFor(r));
45
57
  }
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
57
- }
58
+ res.json(out);
59
+ };
60
+
61
+ api.getItems = async function (req, res) {
62
+ const settings = await meta.settings.get('calendar-onekite');
63
+
64
+ const env = settings.helloassoEnv || 'prod';
65
+ const token = await helloasso.getAccessToken({
66
+ env,
67
+ clientId: settings.helloassoClientId,
68
+ clientSecret: settings.helloassoClientSecret,
69
+ });
70
+
71
+ if (!token) {
72
+ return res.json([]);
58
73
  }
59
- }
60
74
 
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
- };
75
+ const items = await helloasso.listItems({
76
+ env,
77
+ token,
78
+ organizationSlug: settings.helloassoOrganizationSlug,
79
+ formType: settings.helloassoFormType,
80
+ formSlug: settings.helloassoFormSlug,
85
81
  });
86
82
 
87
- res.json(events);
88
- }
83
+ // Normalize minimal fields for client
84
+ const normalized = (items || []).map((it) => ({
85
+ id: it.id || it.itemId || it.reference || it.name,
86
+ name: it.name || it.label || `Item ${it.id || ''}`,
87
+ price: it.price || it.amount || it.unitPrice || 0,
88
+ })).filter(it => it.id && it.name);
89
89
 
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
- }
90
+ res.json(normalized);
91
+ };
98
92
 
99
- async function createReservation(req, res) {
93
+ api.createReservation = async function (req, res) {
100
94
  const uid = req.uid;
101
- const s = await settings.get();
102
- const allowedGroups = parseGroups(s.allowedGroups);
103
- const notifyGroups = parseGroups(s.notifyGroups);
95
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
104
96
 
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
- }
97
+ const settings = await meta.settings.get('calendar-onekite');
98
+ const ok = await canRequest(uid, settings);
99
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
109
100
 
110
- const body = req.body || {};
111
- const start = Date.parse(body.start);
112
- const end = Date.parse(body.end);
101
+ const start = parseInt(toTs(req.body.start), 10);
102
+ const end = parseInt(toTs(req.body.end), 10);
103
+ const itemId = (req.body.itemId || '').toString();
104
+ const itemName = (req.body.itemName || '').toString();
113
105
 
114
- if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
115
- return res.status(400).json({ error: 'Plage de dates invalide.' });
106
+ if (!start || !end || !itemId) {
107
+ return res.status(400).json({ error: 'missing-fields' });
116
108
  }
117
109
 
118
- const holdMin = Math.max(1, parseInt(s.pendingHoldMinutes, 10) || 5);
119
- const expiresAt = Date.now() + holdMin * 60 * 1000;
110
+ const now = Date.now();
111
+ const rid = crypto.randomUUID();
120
112
 
121
- const user = await users.getUserFields(uid, ['username', 'email', 'fullname']);
122
- const reservation = await db.createReservation({
113
+ const resv = {
114
+ rid,
123
115
  uid,
124
- username: user.username,
125
- itemId: body.itemId,
126
- itemName: body.itemName,
127
- itemPrice: body.itemPrice,
116
+ itemId,
117
+ itemName: itemName || itemId,
128
118
  start,
129
119
  end,
130
120
  status: 'pending',
131
- expiresAt,
132
- });
121
+ createdAt: now,
122
+ };
133
123
 
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
- });
124
+ // Save
125
+ await dbLayer.saveReservation(resv);
142
126
 
143
- res.json({ reservation });
144
- }
127
+ // Notify groups by email
128
+ try {
129
+ const notifyGroups = (settings.notifyGroups || '').split(',').map(s => s.trim()).filter(Boolean);
130
+ if (notifyGroups.length) {
131
+ const emailer = require.main.require('./src/emailer');
132
+ const u = await user.getUserData(uid);
133
+ for (const g of notifyGroups) {
134
+ const members = await groups.getMembers(g, 0, -1);
135
+ for (const m of members) {
136
+ const memberUid = typeof m === 'object' && m ? (m.uid || m.userId) : m;
137
+ const md = await user.getUserData(memberUid);
138
+ if (md && md.email) {
139
+ await emailer.send('calendar-onekite_pending', md.email, {
140
+ username: md.username,
141
+ requester: u.username,
142
+ itemName: resv.itemName,
143
+ start: new Date(start).toISOString(),
144
+ end: new Date(end).toISOString(),
145
+ rid,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ }
151
+ } catch (e) {
152
+ // ignore email errors
153
+ }
145
154
 
146
- module.exports = {
147
- getEvents,
148
- getItems,
149
- createReservation,
150
- // exported for admin module reuse
151
- parseGroups,
152
- isUserInAnyGroup,
153
- sendEmailToGroups,
155
+ res.json({ ok: true, rid });
154
156
  };
157
+
158
+ module.exports = api;
@@ -1,17 +1,11 @@
1
1
  'use strict';
2
2
 
3
- const { settings } = require('./settings');
3
+ const controllers = {};
4
4
 
5
- async function renderCalendar(req, res) {
6
- const s = await settings.get();
7
- res.render('calendar-onekite/calendar', {
8
- title: 'Calendrier',
9
- calendarOnekite: {
10
- showUsernamesOnCalendar: !!s.showUsernamesOnCalendar,
11
- },
5
+ controllers.renderCalendar = async function (req, res) {
6
+ res.render('calendar-onekite', {
7
+ title: 'Calendar',
12
8
  });
13
- }
14
-
15
- module.exports = {
16
- renderCalendar,
17
9
  };
10
+
11
+ module.exports = controllers;