nodebb-plugin-calendar-onekite 11.1.0 → 11.1.1

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 CHANGED
@@ -1,42 +1,38 @@
1
1
  # nodebb-plugin-calendar-onekite
2
2
 
3
- Plugin NodeBB (testé avec NodeBB v4.x) qui ajoute un calendrier de réservation de matériel basé sur FullCalendar.
4
-
5
- ## Fonctionnalités
6
-
7
- - Page publique: `/calendar`
8
- - Création de demandes de réservation (clic ou sélection de plage)
9
- - Blocage temporaire des demandes en attente (expiration automatique)
10
- - Validation/refus via l'ACP
11
- - Synchronisation des items (matériel + prix) depuis HelloAsso
12
- - Création de checkout-intents HelloAsso lors de la validation
13
- - Purge du calendrier par année
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.
14
4
 
15
5
  ## Installation
16
6
 
17
- Dans le dossier NodeBB:
7
+ Dans le dossier NodeBB :
18
8
 
19
9
  ```bash
20
10
  npm install /path/to/nodebb-plugin-calendar-onekite
11
+ # ou si vous avez un zip, dézippez dans node_modules puis :
21
12
  ./nodebb build
22
13
  ./nodebb restart
23
14
  ```
24
15
 
25
- Activez le plugin dans l'ACP.
16
+ Activez ensuite le plugin dans lACP.
26
17
 
27
- ## Configuration (ACP)
18
+ ## Page
28
19
 
29
- ACP Plugins Calendar (OneKite)
20
+ - URL : `/calendar` (ex: https://www.onekite.com/calendar)
30
21
 
31
- - Groupes autorisés à demander une réservation (CSV)
32
- - Groupes notifiés par email (CSV)
33
- - Durée de blocage en attente (minutes)
34
- - Paramètres HelloAsso (sandbox/prod, clientId/secret, organizationSlug, formType, formSlug, returnURL, ...)
22
+ ## Paramètres ACP
35
23
 
36
- ## Webhook HelloAsso
24
+ ACP Plugins → Calendar OneKite
37
25
 
38
- Endpoint: `/api/calendar-onekite/helloasso/webhook`
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)
39
34
 
40
- > Le plugin fournit un endpoint minimal pour marquer une réservation comme payée à partir d'un `checkoutIntentId`.
41
- > Adaptez-le selon votre stratégie (notification HelloAsso / polling / etc.).
35
+ ## Notes
42
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 ADDED
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
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
+
10
+ async function renderAdmin(req, res) {
11
+ res.render('admin/plugins/calendar-onekite', {
12
+ title: 'Calendar OneKite',
13
+ });
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' });
55
+
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()),
73
+ });
74
+
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',
82
+ });
83
+ } catch (e) { /* ignore */ }
84
+
85
+ res.json({ ok: true, reservation: updated });
86
+ }
87
+
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
+ });
102
+
103
+ 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,
130
+ };
package/lib/api.js ADDED
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ 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
+ const helloasso = require('./helloasso');
9
+
10
+ function parseGroups(str) {
11
+ return String(str || '')
12
+ .split(',')
13
+ .map(s => s.trim())
14
+ .filter(Boolean);
15
+ }
16
+
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
57
+ }
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);
88
+ }
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
+ }
98
+
99
+ async function createReservation(req, res) {
100
+ const uid = req.uid;
101
+ const s = await settings.get();
102
+ const allowedGroups = parseGroups(s.allowedGroups);
103
+ const notifyGroups = parseGroups(s.notifyGroups);
104
+
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
+ }
109
+
110
+ const body = req.body || {};
111
+ const start = Date.parse(body.start);
112
+ const end = Date.parse(body.end);
113
+
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,
128
+ start,
129
+ end,
130
+ status: 'pending',
131
+ expiresAt,
132
+ });
133
+
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
+ });
142
+
143
+ res.json({ reservation });
144
+ }
145
+
146
+ module.exports = {
147
+ getEvents,
148
+ getItems,
149
+ createReservation,
150
+ // exported for admin module reuse
151
+ parseGroups,
152
+ isUserInAnyGroup,
153
+ sendEmailToGroups,
154
+ };