nodebb-plugin-onekite-calendar 1.0.0

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/widgets.js ADDED
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ const nconf = require.main.require('nconf');
4
+
5
+ function forumBaseUrl() {
6
+ return String(nconf.get('url') || '').trim().replace(/\/$/, '');
7
+ }
8
+
9
+ function escapeHtml(s) {
10
+ return String(s || '')
11
+ .replace(/&/g, '&')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+
18
+ function makeDomId() {
19
+ const r = Math.floor(Math.random() * 1e9);
20
+ return `onekite-twoweeks-${Date.now()}-${r}`;
21
+ }
22
+
23
+ function widgetCalendarUrl() {
24
+ // Per request, keep the public URL fixed (even if forum base differs)
25
+ return 'https://www.onekite.com/calendar';
26
+ }
27
+
28
+ const widgets = {};
29
+
30
+ widgets.defineWidgets = async function (widgetData) {
31
+ // NodeBB versions differ:
32
+ // - Some pass an object: { widgets: [...] }
33
+ // - Others pass the array directly
34
+ const list = Array.isArray(widgetData)
35
+ ? widgetData
36
+ : (widgetData && Array.isArray(widgetData.widgets) ? widgetData.widgets : null);
37
+
38
+ if (!list) {
39
+ return widgetData;
40
+ }
41
+
42
+ list.push({
43
+ widget: 'calendar-onekite-twoweeks',
44
+ name: 'Calendrier OneKite (2 semaines)',
45
+ description: 'Affiche la semaine courante + la semaine suivante (FullCalendar via CDN).',
46
+ content: '',
47
+ });
48
+
49
+ return widgetData;
50
+ };
51
+
52
+ widgets.renderTwoWeeksWidget = async function (data) {
53
+ // data: { widget, ... }
54
+ const id = makeDomId();
55
+ const calUrl = widgetCalendarUrl();
56
+ const apiBase = forumBaseUrl();
57
+ const eventsEndpoint = `${apiBase}/api/v3/plugins/calendar-onekite/events`;
58
+
59
+ const idJson = JSON.stringify(id);
60
+ const calUrlJson = JSON.stringify(calUrl);
61
+ const eventsEndpointJson = JSON.stringify(eventsEndpoint);
62
+
63
+ const html = `
64
+ <div class="onekite-twoweeks">
65
+ <div class="d-flex justify-content-between align-items-center mb-1">
66
+ <div style="font-weight: 600;">Calendrier</div>
67
+ <a href="${escapeHtml(calUrl)}" class="btn btn-sm btn-outline-secondary" style="line-height: 1.1;">Ouvrir</a>
68
+ </div>
69
+ <div id="${escapeHtml(id)}"></div>
70
+ </div>
71
+
72
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@latest/main.min.css" />
73
+ <script>
74
+ (function(){
75
+ const containerId = ${idJson};
76
+ const calUrl = ${calUrlJson};
77
+ const eventsEndpoint = ${eventsEndpointJson};
78
+
79
+ function loadOnce(tag, attrs) {
80
+ return new Promise((resolve, reject) => {
81
+ try {
82
+ const key = attrs && (attrs.id || attrs.href || attrs.src);
83
+ if (key && document.querySelector((attrs.id ? ('#' + attrs.id) : (attrs.href ? ('link[href="' + attrs.href + '"]') : ('script[src="' + attrs.src + '"]'))))) {
84
+ resolve();
85
+ return;
86
+ }
87
+ const el = document.createElement(tag);
88
+ Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
89
+ el.onload = () => resolve();
90
+ el.onerror = () => reject(new Error('load-failed'));
91
+ document.head.appendChild(el);
92
+ } catch (e) {
93
+ reject(e);
94
+ }
95
+ });
96
+ }
97
+
98
+ async function ensureFullCalendar() {
99
+ if (window.FullCalendar && window.FullCalendar.Calendar) {
100
+ return;
101
+ }
102
+ await loadOnce('script', {
103
+ id: 'onekite-fullcalendar-global',
104
+ src: 'https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.js',
105
+ async: 'true'
106
+ });
107
+ await loadOnce('script', {
108
+ id: 'onekite-fullcalendar-locales',
109
+ src: 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@latest/locales-all.global.min.js',
110
+ async: 'true'
111
+ });
112
+ }
113
+
114
+ async function init() {
115
+ const el = document.getElementById(containerId);
116
+ if (!el) return;
117
+
118
+ await ensureFullCalendar();
119
+
120
+ // Define a 2-week dayGrid view
121
+ const calendar = new window.FullCalendar.Calendar(el, {
122
+ initialView: 'dayGridTwoWeek',
123
+ views: {
124
+ dayGridTwoWeek: {
125
+ type: 'dayGrid',
126
+ duration: { weeks: 2 },
127
+ buttonText: '2 semaines',
128
+ },
129
+ },
130
+ locale: 'fr',
131
+ firstDay: 1,
132
+ height: 'auto',
133
+ headerToolbar: {
134
+ left: 'prev,next',
135
+ center: 'title',
136
+ right: '',
137
+ },
138
+ navLinks: false,
139
+ eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
140
+ events: function(info, successCallback, failureCallback) {
141
+ const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
142
+ fetch(eventsEndpoint + '?' + qs.toString(), { credentials: 'same-origin' })
143
+ .then((r) => r.json())
144
+ .then((json) => successCallback(json || []))
145
+ .catch((e) => failureCallback(e));
146
+ },
147
+ dateClick: function() {
148
+ window.location.href = calUrl;
149
+ },
150
+ eventClick: function() {
151
+ window.location.href = calUrl;
152
+ },
153
+ });
154
+
155
+ calendar.render();
156
+ }
157
+
158
+ // Widgets can be rendered after ajaxify; delay a tick.
159
+ setTimeout(() => init().catch(() => {}), 0);
160
+ })();
161
+ </script>
162
+
163
+ <style>
164
+ .onekite-twoweeks .fc .fc-toolbar-title { font-size: 1rem; }
165
+ .onekite-twoweeks .fc .fc-button { padding: .2rem .35rem; font-size: .75rem; }
166
+ .onekite-twoweeks .fc .fc-daygrid-day-number { font-size: .75rem; padding: 2px; }
167
+ .onekite-twoweeks .fc .fc-col-header-cell-cushion { font-size: .75rem; }
168
+ .onekite-twoweeks .fc .fc-event-title { font-size: .72rem; }
169
+ </style>
170
+ `;
171
+
172
+ data = data || {};
173
+ data.html = html;
174
+ return data;
175
+ };
176
+
177
+ module.exports = widgets;
package/library.js ADDED
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ // We use NodeBB's route helpers for page routes so the rendered page includes
4
+ // the normal client bundle (ajaxify/requirejs). The helper signatures differ
5
+ // across NodeBB versions, but for NodeBB v4.x the order is:
6
+ // setupPageRoute(router, path, middlewaresArray, handler)
7
+ // setupAdminPageRoute(router, path, middlewaresArray, handler)
8
+ const routeHelpers = require.main.require('./src/routes/helpers');
9
+
10
+ const controllers = require('./lib/controllers.js');
11
+ const api = require('./lib/api');
12
+ const admin = require('./lib/admin');
13
+ const scheduler = require('./lib/scheduler');
14
+ const helloassoWebhook = require('./lib/helloassoWebhook');
15
+ const widgets = require('./lib/widgets');
16
+ const bodyParser = require('body-parser');
17
+
18
+ const Plugin = {};
19
+
20
+ const isFn = (fn) => typeof fn === 'function';
21
+ const mw = (...fns) => fns.filter(isFn);
22
+
23
+ Plugin.init = async function (params) {
24
+ const { router, middleware } = params;
25
+
26
+ // Build middleware arrays safely and always spread them into Express route methods.
27
+ // Express will throw if any callback is undefined, so we filter strictly.
28
+ // Auth middlewares differ slightly depending on NodeBB configuration.
29
+ // In v4, some installs rely on middleware.authenticate rather than exposeUid.
30
+ const baseExpose = mw(middleware && (middleware.authenticate || middleware.exposeUid));
31
+ const publicExpose = baseExpose;
32
+ const publicAuth = mw(middleware && (middleware.authenticate || middleware.exposeUid), middleware && middleware.ensureLoggedIn);
33
+
34
+ // Robust admin guard: avoid middleware.admin.checkPrivileges() signature differences
35
+ // across NodeBB versions. We treat membership in the 'administrators' group as admin.
36
+ const Groups = require.main.require('./src/groups');
37
+ async function adminOnly(req, res, next) {
38
+ try {
39
+ const uid = req.uid || (req.user && req.user.uid) || (req.session && req.session.uid);
40
+ if (!uid) {
41
+ return res.status(401).json({ status: { code: 'not-authorized', message: 'Not logged in' } });
42
+ }
43
+ const isAdmin = await Groups.isMember(uid, 'administrators');
44
+ if (!isAdmin) {
45
+ return res.status(403).json({ status: { code: 'not-authorized', message: 'Not allowed' } });
46
+ }
47
+ return next();
48
+ } catch (err) {
49
+ return next(err);
50
+ }
51
+ }
52
+ const adminMws = mw(
53
+ middleware && (middleware.authenticate || middleware.exposeUid),
54
+ middleware && middleware.ensureLoggedIn,
55
+ adminOnly
56
+ );
57
+
58
+ // Page routes (HTML)
59
+ // IMPORTANT: pass an ARRAY for middlewares (even if empty), otherwise
60
+ // setupPageRoute will throw "middlewares is not iterable".
61
+ routeHelpers.setupPageRoute(router, '/calendar', mw(), controllers.renderCalendar);
62
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/calendar-onekite', mw(), admin.renderAdmin);
63
+
64
+ // Public API (JSON) — NodeBB 4.x only (v3 API)
65
+ router.get('/api/v3/plugins/calendar-onekite/events', ...publicExpose, api.getEvents);
66
+ router.get('/api/v3/plugins/calendar-onekite/items', ...publicExpose, api.getItems);
67
+ router.get('/api/v3/plugins/calendar-onekite/capabilities', ...publicExpose, api.getCapabilities);
68
+
69
+ router.post('/api/v3/plugins/calendar-onekite/reservations', ...publicExpose, api.createReservation);
70
+ router.get('/api/v3/plugins/calendar-onekite/reservations/:rid', ...publicExpose, api.getReservationDetails);
71
+ router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/approve', ...publicExpose, api.approveReservation);
72
+ router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/refuse', ...publicExpose, api.refuseReservation);
73
+ router.put('/api/v3/plugins/calendar-onekite/reservations/:rid/cancel', ...publicExpose, api.cancelReservation);
74
+
75
+ router.post('/api/v3/plugins/calendar-onekite/special-events', ...publicExpose, api.createSpecialEvent);
76
+ router.get('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.getSpecialEventDetails);
77
+ router.delete('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.deleteSpecialEvent);
78
+
79
+ // Admin API (JSON)
80
+ const adminBases = ['/api/v3/admin/plugins/calendar-onekite'];
81
+
82
+ adminBases.forEach((base) => {
83
+ router.get(`${base}/settings`, ...adminMws, admin.getSettings);
84
+ router.put(`${base}/settings`, ...adminMws, admin.saveSettings);
85
+
86
+ router.get(`${base}/pending`, ...adminMws, admin.listPending);
87
+ router.put(`${base}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
88
+ router.put(`${base}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
89
+
90
+ router.post(`${base}/purge`, ...adminMws, admin.purgeByYear);
91
+ router.get(`${base}/debug`, ...adminMws, admin.debugHelloAsso);
92
+ // Accounting / exports
93
+ router.get(`${base}/accounting`, ...adminMws, admin.getAccounting);
94
+ router.get(`${base}/accounting.csv`, ...adminMws, admin.exportAccountingCsv);
95
+ router.post(`${base}/accounting/purge`, ...adminMws, admin.purgeAccounting);
96
+
97
+ // Purge special events by year
98
+ router.post(`${base}/special-events/purge`, ...adminMws, admin.purgeSpecialEventsByYear);
99
+ });
100
+
101
+ // HelloAsso callback endpoint (hardened)
102
+ // - Only accepts POST
103
+ // - Verifies x-ha-signature (HMAC SHA-256) using the configured client secret
104
+ // - Basic replay protection
105
+ // NOTE: we capture the raw body for signature verification.
106
+ const helloassoJson = bodyParser.json({
107
+ verify: (req, _res, buf) => {
108
+ req.rawBody = buf;
109
+ },
110
+ type: ['application/json', 'application/*+json'],
111
+ });
112
+ // Accept webhook on both legacy root path and namespaced plugin path.
113
+ // Some reverse proxies block unknown root paths, so /plugins/... is recommended.
114
+ router.post('/helloasso', helloassoJson, helloassoWebhook.handler);
115
+ router.post('/plugins/calendar-onekite/helloasso', helloassoJson, helloassoWebhook.handler);
116
+
117
+ // Optional: health checks
118
+ router.get('/helloasso', (req, res) => res.json({ ok: true }));
119
+ router.get('/plugins/calendar-onekite/helloasso', (req, res) => res.json({ ok: true }));
120
+
121
+ scheduler.start();
122
+ };
123
+
124
+ Plugin.addAdminNavigation = async function (header) {
125
+ header.plugins = header.plugins || [];
126
+ header.plugins.push({
127
+ route: '/plugins/calendar-onekite',
128
+ icon: 'fa-calendar',
129
+ name: 'Calendar OneKite',
130
+ });
131
+ return header;
132
+ };
133
+
134
+
135
+ // Ensure our transactional emails always get a subject.
136
+ // NodeBB's Emailer.sendToEmail signature expects (template, email, language, params),
137
+ // so plugins typically inject/modify the subject via this hook.
138
+ Plugin.emailModify = async function (data) {
139
+ try {
140
+ if (!data || !data.template) return data;
141
+ const tpl = String(data.template);
142
+ if (!tpl.startsWith('calendar-onekite_')) return data;
143
+
144
+ // If the caller provided a subject (we pass it in params.subject), copy it to data.subject.
145
+ const provided = data.params && data.params.subject ? String(data.params.subject) : '';
146
+ if (provided && (!data.subject || !String(data.subject).trim())) {
147
+ data.subject = provided;
148
+ }
149
+ } catch (e) {}
150
+ return data;
151
+ };
152
+
153
+ // Widgets
154
+ Plugin.defineWidgets = widgets.defineWidgets;
155
+ Plugin.renderTwoWeeksWidget = widgets.renderTwoWeeksWidget;
156
+
157
+ module.exports = Plugin;
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "nodebb-plugin-onekite-calendar",
3
+ "version": "1.0.0",
4
+ "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
+ "main": "library.js",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=18"
9
+ },
10
+ "dependencies": {},
11
+ "nbbpm": {
12
+ "compatibility": "^4.0.0"
13
+ }
14
+ }
package/plugin.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "id": "nodebb-plugin-calendar-onekite",
3
+ "name": "Calendar OneKite",
4
+ "description": "Equipment reservation calendar (FullCalendar) with admin approval & HelloAsso checkout",
5
+ "url": "https://www.onekite.com/calendar",
6
+ "hooks": [
7
+ {
8
+ "hook": "static:app.load",
9
+ "method": "init"
10
+ },
11
+ {
12
+ "hook": "filter:admin.header.build",
13
+ "method": "addAdminNavigation"
14
+ },
15
+ {
16
+ "hook": "filter:email.modify",
17
+ "method": "emailModify"
18
+ },
19
+ {
20
+ "hook": "filter:widgets.getWidgets",
21
+ "method": "defineWidgets"
22
+ },
23
+ {
24
+ "hook": "filter:widget.render:calendar-onekite-twoweeks",
25
+ "method": "renderTwoWeeksWidget"
26
+ }
27
+ ],
28
+ "staticDirs": {
29
+ "public": "./public"
30
+ },
31
+ "templates": "./templates",
32
+ "modules": {
33
+ "../admin/plugins/calendar-onekite.js": "./public/admin.js",
34
+ "admin/plugins/calendar-onekite": "./public/admin.js"
35
+ },
36
+ "scripts": [
37
+ "public/client.js"
38
+ ],
39
+ "acpScripts": [
40
+ "public/admin.js"
41
+ ],
42
+ "version": "1.3.5"
43
+ }