nodebb-plugin-onekite-calendar 2.0.11 → 2.0.13

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/admin.js +21 -9
  3. package/lib/api.js +235 -4
  4. package/lib/db.js +114 -0
  5. package/lib/helloassoWebhook.js +28 -0
  6. package/library.js +7 -0
  7. package/package.json +1 -1
  8. package/pkg/package/CHANGELOG.md +106 -0
  9. package/pkg/package/lib/admin.js +554 -0
  10. package/pkg/package/lib/api.js +1458 -0
  11. package/pkg/package/lib/controllers.js +11 -0
  12. package/pkg/package/lib/db.js +224 -0
  13. package/pkg/package/lib/discord.js +190 -0
  14. package/pkg/package/lib/helloasso.js +352 -0
  15. package/pkg/package/lib/helloassoWebhook.js +389 -0
  16. package/pkg/package/lib/scheduler.js +201 -0
  17. package/pkg/package/lib/widgets.js +460 -0
  18. package/pkg/package/library.js +164 -0
  19. package/pkg/package/package.json +14 -0
  20. package/pkg/package/plugin.json +43 -0
  21. package/pkg/package/public/admin.js +1477 -0
  22. package/pkg/package/public/client.js +2228 -0
  23. package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
  24. package/pkg/package/templates/calendar-onekite.tpl +51 -0
  25. package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
  26. package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
  27. package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
  28. package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
  29. package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
  30. package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
  31. package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
  32. package/plugin.json +1 -1
  33. package/public/admin.js +205 -4
  34. package/public/client.js +238 -7
  35. package/templates/admin/plugins/calendar-onekite.tpl +74 -0
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ const controllers = {};
4
+
5
+ controllers.renderCalendar = async function (req, res) {
6
+ res.render('calendar-onekite', {
7
+ title: 'Calendar',
8
+ });
9
+ };
10
+
11
+ module.exports = controllers;
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ const db = require.main.require('./src/database');
4
+
5
+ const KEY_ZSET = 'calendar-onekite:reservations';
6
+ const KEY_OBJ = (rid) => `calendar-onekite:reservation:${rid}`;
7
+ const KEY_CHECKOUT_INTENT_TO_RID = 'calendar-onekite:helloasso:checkoutIntentToRid';
8
+
9
+ // Special events (non-reservation events shown in a different colour)
10
+ const KEY_SPECIAL_ZSET = 'calendar-onekite:special';
11
+ const KEY_SPECIAL_OBJ = (eid) => `calendar-onekite:special:${eid}`;
12
+
13
+ // Maintenance (simple ON/OFF per item, no dates)
14
+ const KEY_MAINTENANCE_ZSET = 'calendar-onekite:maintenance:itemIds';
15
+
16
+ // Audit log (partitioned by year)
17
+ const KEY_AUDIT_ZSET = (year) => `calendar-onekite:audit:${year}`;
18
+ const KEY_AUDIT_OBJ = (id) => `calendar-onekite:audit:entry:${id}`;
19
+
20
+ // Helpers
21
+ function reservationKey(rid) {
22
+ return KEY_OBJ(rid);
23
+ }
24
+
25
+ function specialKey(eid) {
26
+ return KEY_SPECIAL_OBJ(eid);
27
+ }
28
+
29
+ async function getReservation(rid) {
30
+ return await db.getObject(KEY_OBJ(rid));
31
+ }
32
+
33
+ /**
34
+ * Batch fetch reservations in one DB roundtrip.
35
+ * Returns an array aligned with rids (missing objects => null).
36
+ */
37
+ async function getReservations(rids) {
38
+ const ids = Array.isArray(rids) ? rids.filter(Boolean) : [];
39
+ if (!ids.length) return [];
40
+ const keys = ids.map(reservationKey);
41
+ const rows = await db.getObjects(keys);
42
+ // Ensure rid is present even if older objects were missing it.
43
+ return (rows || []).map((row, idx) => {
44
+ if (!row) return null;
45
+ if (!row.rid) row.rid = String(ids[idx]);
46
+ return row;
47
+ });
48
+ }
49
+
50
+ async function saveReservation(resv) {
51
+ await db.setObject(KEY_OBJ(resv.rid), resv);
52
+ // score = start timestamp
53
+ await db.sortedSetAdd(KEY_ZSET, resv.start, resv.rid);
54
+ // Optional mapping to help reconcile HelloAsso webhooks that don't include metadata
55
+ if (resv.checkoutIntentId) {
56
+ try {
57
+ await db.setObjectField(KEY_CHECKOUT_INTENT_TO_RID, String(resv.checkoutIntentId), String(resv.rid));
58
+ } catch (e) {
59
+ // ignore
60
+ }
61
+ }
62
+ }
63
+
64
+ async function removeReservation(rid) {
65
+ await db.sortedSetRemove(KEY_ZSET, rid);
66
+ await db.delete(KEY_OBJ(rid));
67
+ }
68
+
69
+ async function listReservationIdsByStartRange(startTs, endTs, limit = 1000) {
70
+ // NodeBB db method name is getSortedSetRangeByScore(set, start, stop, min, max)
71
+ // (start/stop are index offsets, min/max are score range)
72
+ const start = 0;
73
+ const stop = Math.max(0, (parseInt(limit, 10) || 1000) - 1);
74
+ return await db.getSortedSetRangeByScore(KEY_ZSET, start, stop, startTs, endTs);
75
+ }
76
+
77
+ async function listAllReservationIds(limit = 5000) {
78
+ return await db.getSortedSetRange(KEY_ZSET, 0, limit - 1);
79
+ }
80
+
81
+ // --------------------
82
+ // Maintenance
83
+ // --------------------
84
+
85
+ async function listMaintenanceItemIds(limit = 10000) {
86
+ return await db.getSortedSetRange(KEY_MAINTENANCE_ZSET, 0, Math.max(0, limit - 1));
87
+ }
88
+
89
+ async function isItemInMaintenance(itemId) {
90
+ if (!itemId) return false;
91
+ try {
92
+ // NodeBB DB returns 1/0 for isSortedSetMember
93
+ if (typeof db.isSortedSetMember === 'function') {
94
+ return !!(await db.isSortedSetMember(KEY_MAINTENANCE_ZSET, String(itemId)));
95
+ }
96
+ } catch (e) {}
97
+ const ids = await listMaintenanceItemIds(20000);
98
+ return Array.isArray(ids) ? ids.map(String).includes(String(itemId)) : false;
99
+ }
100
+
101
+ async function setItemMaintenance(itemId, enabled) {
102
+ const id = String(itemId || '').trim();
103
+ if (!id) return;
104
+ if (enabled) {
105
+ await db.sortedSetAdd(KEY_MAINTENANCE_ZSET, 0, id);
106
+ } else {
107
+ await db.sortedSetRemove(KEY_MAINTENANCE_ZSET, id);
108
+ }
109
+ }
110
+
111
+ async function setAllMaintenance(enabled, itemIds) {
112
+ // Clear set first (fast)
113
+ await db.delete(KEY_MAINTENANCE_ZSET);
114
+ if (!enabled) {
115
+ return { count: 0 };
116
+ }
117
+ const ids = Array.isArray(itemIds) ? itemIds.map(String).filter(Boolean) : [];
118
+ // Add back all ids
119
+ for (const id of ids) {
120
+ await db.sortedSetAdd(KEY_MAINTENANCE_ZSET, 0, id);
121
+ }
122
+ return { count: ids.length };
123
+ }
124
+
125
+ // --------------------
126
+ // Audit
127
+ // --------------------
128
+
129
+ async function addAuditEntry(entry) {
130
+ const e = Object.assign({}, entry || {});
131
+ const ts = Number(e.ts) || Date.now();
132
+ const year = Number(e.year) || new Date(ts).getFullYear();
133
+ e.ts = ts;
134
+ e.year = year;
135
+ const id = e.id ? String(e.id) : `${ts}-${Math.random().toString(16).slice(2)}`;
136
+ e.id = id;
137
+ await db.setObject(KEY_AUDIT_OBJ(id), e);
138
+ await db.sortedSetAdd(KEY_AUDIT_ZSET(year), ts, id);
139
+ return e;
140
+ }
141
+
142
+ async function listAuditEntryIdsByYear(year, start = 0, stop = 200) {
143
+ const y = Number(year) || new Date().getFullYear();
144
+ return await db.getSortedSetRevRange(KEY_AUDIT_ZSET(y), start, stop);
145
+ }
146
+
147
+ async function getAuditEntriesByYear(year, limit = 200) {
148
+ const ids = await listAuditEntryIdsByYear(year, 0, Math.max(0, (Number(limit) || 200) - 1));
149
+ if (!ids || !ids.length) return [];
150
+ const keys = ids.map((id) => KEY_AUDIT_OBJ(id));
151
+ const rows = await db.getObjects(keys);
152
+ // Align with ids order
153
+ return (rows || []).map((row, idx) => row ? Object.assign({ id: String(ids[idx]) }, row) : null).filter(Boolean);
154
+ }
155
+
156
+ async function purgeAuditYear(year) {
157
+ const y = Number(year);
158
+ if (!y) return { ok: false, removed: 0 };
159
+ const ids = await db.getSortedSetRange(KEY_AUDIT_ZSET(y), 0, -1);
160
+ const keys = (ids || []).map((id) => KEY_AUDIT_OBJ(id));
161
+ if (keys.length) {
162
+ // Batch delete objects when possible
163
+ if (typeof db.deleteAll === 'function') {
164
+ await db.deleteAll(keys);
165
+ } else {
166
+ for (const k of keys) {
167
+ // eslint-disable-next-line no-await-in-loop
168
+ await db.delete(k);
169
+ }
170
+ }
171
+ }
172
+ await db.delete(KEY_AUDIT_ZSET(y));
173
+ return { ok: true, removed: (ids || []).length };
174
+ }
175
+
176
+ module.exports = {
177
+ KEY_ZSET,
178
+ KEY_SPECIAL_ZSET,
179
+ KEY_CHECKOUT_INTENT_TO_RID,
180
+ KEY_MAINTENANCE_ZSET,
181
+ getReservation,
182
+ getReservations,
183
+ saveReservation,
184
+ removeReservation,
185
+ // Special events
186
+ getSpecialEvent: async (eid) => await db.getObject(KEY_SPECIAL_OBJ(eid)),
187
+ getSpecialEvents: async (eids) => {
188
+ const ids = Array.isArray(eids) ? eids.filter(Boolean) : [];
189
+ if (!ids.length) return [];
190
+ const keys = ids.map(specialKey);
191
+ const rows = await db.getObjects(keys);
192
+ return (rows || []).map((row, idx) => {
193
+ if (!row) return null;
194
+ if (!row.eid) row.eid = String(ids[idx]);
195
+ return row;
196
+ });
197
+ },
198
+ saveSpecialEvent: async (ev) => {
199
+ await db.setObject(KEY_SPECIAL_OBJ(ev.eid), ev);
200
+ await db.sortedSetAdd(KEY_SPECIAL_ZSET, ev.start, ev.eid);
201
+ },
202
+ removeSpecialEvent: async (eid) => {
203
+ await db.sortedSetRemove(KEY_SPECIAL_ZSET, eid);
204
+ await db.delete(KEY_SPECIAL_OBJ(eid));
205
+ },
206
+ listSpecialIdsByStartRange: async (startTs, endTs, limit = 2000) => {
207
+ const start = 0;
208
+ const stop = Math.max(0, (parseInt(limit, 10) || 2000) - 1);
209
+ return await db.getSortedSetRangeByScore(KEY_SPECIAL_ZSET, start, stop, startTs, endTs);
210
+ },
211
+ listReservationIdsByStartRange,
212
+ listAllReservationIds,
213
+
214
+ // Maintenance
215
+ listMaintenanceItemIds,
216
+ isItemInMaintenance,
217
+ setItemMaintenance,
218
+ setAllMaintenance,
219
+
220
+ // Audit
221
+ addAuditEntry,
222
+ getAuditEntriesByYear,
223
+ purgeAuditYear,
224
+ };
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const { URL } = require('url');
5
+
6
+ function isEnabled(v, defaultValue) {
7
+ if (v === undefined || v === null || v === '') return defaultValue !== false;
8
+ const s = String(v).trim().toLowerCase();
9
+ if (['1', 'true', 'yes', 'on'].includes(s)) return true;
10
+ if (['0', 'false', 'no', 'off'].includes(s)) return false;
11
+ return defaultValue !== false;
12
+ }
13
+
14
+ function formatFRShort(ts) {
15
+ const d = new Date(ts);
16
+ const dd = String(d.getDate()).padStart(2, '0');
17
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
18
+ const yy = String(d.getFullYear()).slice(-2);
19
+ return `${dd}/${mm}/${yy}`;
20
+ }
21
+
22
+ function postWebhook(webhookUrl, payload) {
23
+ return new Promise((resolve, reject) => {
24
+ try {
25
+ const u = new URL(String(webhookUrl));
26
+ if (u.protocol !== 'https:') {
27
+ return reject(new Error('discord-webhook-must-be-https'));
28
+ }
29
+
30
+ const body = Buffer.from(JSON.stringify(payload || {}), 'utf8');
31
+ const req = https.request({
32
+ method: 'POST',
33
+ hostname: u.hostname,
34
+ port: u.port || 443,
35
+ path: u.pathname + (u.search || ''),
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ 'Content-Length': body.length,
39
+ 'User-Agent': 'nodebb-plugin-calendar-onekite',
40
+ },
41
+ }, (res) => {
42
+ const ok = res.statusCode && res.statusCode >= 200 && res.statusCode < 300;
43
+ const chunks = [];
44
+ res.on('data', (c) => chunks.push(c));
45
+ res.on('end', () => {
46
+ if (ok) return resolve(true);
47
+ const msg = Buffer.concat(chunks).toString('utf8');
48
+ return reject(new Error(`discord-webhook-http-${res.statusCode}: ${msg}`));
49
+ });
50
+ });
51
+ req.on('error', reject);
52
+ req.write(body);
53
+ req.end();
54
+ } catch (e) {
55
+ reject(e);
56
+ }
57
+ });
58
+ }
59
+
60
+ function buildReservationMessage(kind, reservation) {
61
+ const calUrl = 'https://www.onekite.com/calendar';
62
+ const username = reservation && reservation.username ? String(reservation.username) : '';
63
+ const items = (reservation && Array.isArray(reservation.itemNames) && reservation.itemNames.length)
64
+ ? reservation.itemNames.map(String)
65
+ : (reservation && Array.isArray(reservation.itemIds) ? reservation.itemIds.map(String) : []);
66
+ const start = reservation && reservation.start ? Number(reservation.start) : NaN;
67
+ const end = reservation && reservation.end ? Number(reservation.end) : NaN;
68
+
69
+ const title = kind === 'paid'
70
+ ? `**[Paiement reçu](${calUrl})** — ${username}`
71
+ : `**[Demande réservation](${calUrl})** — ${username}`;
72
+
73
+ const lines = [
74
+ title,
75
+ '',
76
+ kind === 'paid' ? '**Matériel :**' : '**Matériel demandé :**',
77
+ ];
78
+ for (const it of items) {
79
+ lines.push(`- ${it}`);
80
+ }
81
+ if (Number.isFinite(start) && Number.isFinite(end)) {
82
+ lines.push('');
83
+ lines.push(`Du ${formatFRShort(start)} au ${formatFRShort(end)}`);
84
+ }
85
+ return lines.join('\n');
86
+ }
87
+
88
+ function buildWebhookPayload(kind, reservation) {
89
+ // Discord "regroupe" visuellement les messages consécutifs d'un même auteur.
90
+ // En utilisant un username différent par action, on obtient un message bien distinct.
91
+ const webhookUsername = kind === 'paid'
92
+ ? 'Onekite • Paiement'
93
+ : (kind === 'cancelled' ? 'Onekite • Annulation' : 'Onekite • Réservation');
94
+
95
+ const calUrl = 'https://www.onekite.com/calendar';
96
+ const username = reservation && reservation.username ? String(reservation.username) : '';
97
+ const items = (reservation && Array.isArray(reservation.itemNames) && reservation.itemNames.length)
98
+ ? reservation.itemNames.map(String)
99
+ : (reservation && Array.isArray(reservation.itemIds) ? reservation.itemIds.map(String) : []);
100
+ const start = reservation && reservation.start ? Number(reservation.start) : NaN;
101
+ const end = reservation && reservation.end ? Number(reservation.end) : NaN;
102
+
103
+ const title = kind === 'paid'
104
+ ? '💳 Paiement reçu'
105
+ : (kind === 'cancelled' ? '❌ Réservation annulée' : '⏳ Demande de réservation');
106
+
107
+ const fields = [];
108
+ if (username) {
109
+ fields.push({ name: 'Membre', value: username, inline: true });
110
+ }
111
+ if (items.length) {
112
+ const label = kind === 'request' ? 'Matériel demandé' : 'Matériel';
113
+ fields.push({ name: label, value: items.map((it) => `• ${it}`).join('\n'), inline: false });
114
+ }
115
+ if (Number.isFinite(start) && Number.isFinite(end)) {
116
+ fields.push({ name: 'Période', value: `Du ${formatFRShort(start)} au ${formatFRShort(end)}`, inline: false });
117
+ }
118
+
119
+ if (kind === 'cancelled') {
120
+ const cancelledAt = reservation && reservation.cancelledAt ? Number(reservation.cancelledAt) : NaN;
121
+ if (Number.isFinite(cancelledAt)) {
122
+ fields.push({ name: 'Annulée le', value: formatFRShort(cancelledAt), inline: true });
123
+ }
124
+ }
125
+
126
+ return {
127
+ username: webhookUsername,
128
+ // On laisse "content" vide pour privilégier l'embed (plus lisible sur Discord)
129
+ content: '',
130
+ embeds: [
131
+ {
132
+ title,
133
+ url: calUrl,
134
+ description: kind === 'paid'
135
+ ? 'Un paiement a été reçu pour une réservation.'
136
+ : (kind === 'cancelled'
137
+ ? 'Une réservation a été annulée.'
138
+ : 'Une nouvelle demande de réservation a été créée.'),
139
+ fields,
140
+ footer: { text: 'Onekite • Calendrier' },
141
+ timestamp: new Date().toISOString(),
142
+ },
143
+ ],
144
+ };
145
+ }
146
+
147
+ async function notifyReservationRequested(settings, reservation) {
148
+ const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
149
+ if (!url) return;
150
+ if (!isEnabled(settings.discordNotifyOnRequest, true)) return;
151
+
152
+ try {
153
+ await postWebhook(url, buildWebhookPayload('request', reservation));
154
+ } catch (e) {
155
+ // eslint-disable-next-line no-console
156
+ console.warn('[calendar-onekite] Discord webhook failed (request)', e && e.message ? e.message : String(e));
157
+ }
158
+ }
159
+
160
+ async function notifyPaymentReceived(settings, reservation) {
161
+ const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
162
+ if (!url) return;
163
+ if (!isEnabled(settings.discordNotifyOnPaid, true)) return;
164
+
165
+ try {
166
+ await postWebhook(url, buildWebhookPayload('paid', reservation));
167
+ } catch (e) {
168
+ // eslint-disable-next-line no-console
169
+ console.warn('[calendar-onekite] Discord webhook failed (paid)', e && e.message ? e.message : String(e));
170
+ }
171
+ }
172
+
173
+ async function notifyReservationCancelled(settings, reservation) {
174
+ const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
175
+ if (!url) return;
176
+ if (!isEnabled(settings.discordNotifyOnCancelled, true)) return;
177
+
178
+ try {
179
+ await postWebhook(url, buildWebhookPayload('cancelled', reservation));
180
+ } catch (e) {
181
+ // eslint-disable-next-line no-console
182
+ console.warn('[calendar-onekite] Discord webhook failed (cancelled)', e && e.message ? e.message : String(e));
183
+ }
184
+ }
185
+
186
+ module.exports = {
187
+ notifyReservationRequested,
188
+ notifyPaymentReceived,
189
+ notifyReservationCancelled,
190
+ };