nodebb-plugin-calendar-onekite 12.0.17 → 12.0.19

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/api.js CHANGED
@@ -12,11 +12,22 @@ const logger = require.main.require('./src/logger');
12
12
 
13
13
  const dbLayer = require('./db');
14
14
 
15
+ // Fast membership check without N calls to groups.isMember.
16
+ // NodeBB's groups.getUserGroups([uid]) returns an array (per uid) of group objects.
17
+ // We compare against both group slugs and names to be tolerant with older settings.
15
18
  async function userInAnyGroup(uid, allowed) {
16
- if (!uid || !allowed || !allowed.length) return false;
19
+ if (!uid || !Array.isArray(allowed) || !allowed.length) return false;
17
20
  const ug = await groups.getUserGroups([uid]);
18
- const slugs = ug[0] || [];
19
- return allowed.some(g => slugs.includes(g));
21
+ const list = (ug && ug[0]) ? ug[0] : [];
22
+ const seen = new Set();
23
+ for (const g of list) {
24
+ if (!g) continue;
25
+ if (g.slug) seen.add(String(g.slug));
26
+ if (g.name) seen.add(String(g.name));
27
+ if (g.groupName) seen.add(String(g.groupName));
28
+ if (g.displayName) seen.add(String(g.displayName));
29
+ }
30
+ return allowed.some(v => seen.has(String(v)));
20
31
  }
21
32
 
22
33
 
@@ -35,20 +46,11 @@ function normalizeAllowedGroups(raw) {
35
46
  return s.split(',').map(v => String(v).trim().replace(/^"+|"+$/g, '')).filter(Boolean);
36
47
  }
37
48
 
38
- async function isMemberOfAnyGroup(uid, allowed) {
39
- if (!uid || !allowed || !allowed.length) return false;
40
- for (const g of allowed) {
41
- try {
42
- if (await groups.isMember(uid, g)) return true;
43
- } catch (e) {
44
- // ignore
45
- }
46
- }
47
- return false;
48
- }
49
+ // NOTE: Avoid per-group async checks (groups.isMember) when possible.
49
50
 
50
51
 
51
52
  const helloasso = require('./helloasso');
53
+ const discord = require('./discord');
52
54
 
53
55
  // Email helper: NodeBB's Emailer signature differs across versions.
54
56
  // We try the common forms. Any failure is logged for debugging.
@@ -160,53 +162,8 @@ function autoCreatorGroupForYear(year) {
160
162
  }
161
163
 
162
164
 
163
- async function resolveGroupName(maybeSlugOrName) {
164
- const g = String(maybeSlugOrName || '').trim();
165
- if (!g) return '';
166
- try {
167
- // NodeBB stores a mapping from slug -> groupName in groupslug:groupname
168
- // groupName is the internal identifier used by groups.isMember()
169
- const mapped = await db.getObjectField('groupslug:groupname', g);
170
- if (mapped) return String(mapped);
171
- } catch (e) {}
172
- return g;
173
- }
174
-
175
- async function resolveAllowedGroups(list) {
176
- const out = [];
177
- for (const raw of (list || [])) {
178
- const gn = await resolveGroupName(raw);
179
- if (gn) out.push(gn);
180
- }
181
- // de-duplicate, preserve order
182
- return [...new Set(out)];
183
- }
184
-
185
-
186
- async function expandGroupCandidates(rawList) {
187
- const out = [];
188
- for (const raw of (rawList || [])) {
189
- const g = String(raw || '').trim();
190
- if (!g) continue;
191
- out.push(g);
192
- const mapped = await resolveGroupName(g);
193
- if (mapped && mapped !== g) out.push(mapped);
194
- }
195
- return [...new Set(out)];
196
- }
197
-
198
- async function checkAnyMembership(uid, rawList, fnLabel) {
199
- const candidates = await expandGroupCandidates(rawList);
200
- for (const g of candidates) {
201
- try {
202
- const ok = await groups.isMember(uid, g);
203
- if (ok) return { ok: true, matched: g, candidates };
204
- } catch (e) {
205
- logger.warn('[calendar-onekite] auth check error', { uid, group: g, fn: fnLabel, err: String(e && e.message || e) });
206
- }
207
- }
208
- return { ok: false, matched: '', candidates };
209
- }
165
+ // (removed) group slug/name resolving helpers: we now use userInAnyGroup() which
166
+ // matches both slugs and names and avoids extra DB lookups.
210
167
 
211
168
 
212
169
  function autoFormSlugForYear(year) {
@@ -278,8 +235,7 @@ async function canDeleteSpecial(uid, settings) {
278
235
  try {
279
236
  const isAdmin = await groups.isMember(uid, 'administrators');
280
237
  if (isAdmin) return true;} catch (e) {}
281
- const rawAllowed = (settings.specialDeleterGroups || settings.specialCreatorGroups || '').split(',').map(s => s.trim()).filter(Boolean);
282
- const allowed = await resolveAllowedGroups(rawAllowed);
238
+ const allowed = normalizeAllowedGroups(settings.specialDeleterGroups || settings.specialCreatorGroups || '');
283
239
  if (!allowed.length) return false;
284
240
  if (await userInAnyGroup(uid, allowed)) return true;
285
241
 
@@ -378,8 +334,9 @@ api.getEvents = async function (req, res) {
378
334
  const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
379
335
  const ids = await dbLayer.listReservationIdsByStartRange(wideStart, endTs, 5000);
380
336
  const out = [];
381
- for (const rid of ids) {
382
- const r = await dbLayer.getReservation(rid);
337
+ // Batch fetch = major perf win when there are many reservations.
338
+ const reservations = await dbLayer.getReservations(ids);
339
+ for (const r of (reservations || [])) {
383
340
  if (!r) continue;
384
341
  // Purged from calendar view (kept for accounting)
385
342
  if (r.calendarPurgedAt) continue;
@@ -425,8 +382,8 @@ api.getEvents = async function (req, res) {
425
382
  // Special events
426
383
  try {
427
384
  const specialIds = await dbLayer.listSpecialIdsByStartRange(wideStart, endTs, 5000);
428
- for (const eid of specialIds) {
429
- const sev = await dbLayer.getSpecialEvent(eid);
385
+ const specials = await dbLayer.getSpecialEvents(specialIds);
386
+ for (const sev of (specials || [])) {
430
387
  if (!sev) continue;
431
388
  const sStart = parseInt(sev.start, 10);
432
389
  const sEnd = parseInt(sev.end, 10);
@@ -677,8 +634,8 @@ api.createReservation = async function (req, res) {
677
634
  const wideStart2 = Math.max(0, start - 366 * 24 * 3600 * 1000);
678
635
  const candidateIds = await dbLayer.listReservationIdsByStartRange(wideStart2, end, 5000);
679
636
  const conflicts = [];
680
- for (const cid of candidateIds) {
681
- const existing = await dbLayer.getReservation(cid);
637
+ const existingRows = await dbLayer.getReservations(candidateIds);
638
+ for (const existing of (existingRows || [])) {
682
639
  if (!existing || !blocking.has(existing.status)) continue;
683
640
  const exStart = parseInt(existing.start, 10);
684
641
  const exEnd = parseInt(existing.end, 10);
@@ -729,24 +686,35 @@ api.createReservation = async function (req, res) {
729
686
  const itemsLabel = (resv.itemNames || []).join(', ');
730
687
  for (const g of notifyGroups) {
731
688
  const members = await groups.getMembers(g, 0, -1);
732
- for (const memberUid of (members || [])) {
733
- const md = await user.getUserFields(memberUid, ['username', 'email']);
689
+ const uids = Array.isArray(members) ? members : [];
690
+
691
+ // Batch fetch user email/username when supported by this NodeBB version.
692
+ let usersData = [];
693
+ try {
694
+ if (typeof user.getUsersFields === 'function') {
695
+ usersData = await user.getUsersFields(uids, ['username', 'email']);
696
+ } else {
697
+ usersData = await Promise.all(uids.map(async (memberUid) => {
698
+ try { return await user.getUserFields(memberUid, ['username', 'email']); }
699
+ catch (e) { return null; }
700
+ }));
701
+ }
702
+ } catch (e) {
703
+ usersData = [];
704
+ }
705
+
706
+ for (const md of (usersData || [])) {
734
707
  if (md && md.email) {
735
- await sendEmail(
736
- 'calendar-onekite_pending',
737
- md.email,
738
- 'Location matériel - Demande de réservation',
739
- {
740
- username: md.username,
741
- requester: requester.username,
742
- itemName: itemsLabel,
743
- itemNames: resv.itemNames || [],
744
- dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
745
- start: formatFR(start),
746
- end: formatFR(end),
747
- total: resv.total || 0,
748
- }
749
- );
708
+ await sendEmail('calendar-onekite_pending', md.email, 'Location matériel - Demande de réservation', {
709
+ username: md.username,
710
+ requester: requester.username,
711
+ itemName: itemsLabel,
712
+ itemNames: resv.itemNames || [],
713
+ dateRange: `Du ${formatFR(start)} au ${formatFR(end)}`,
714
+ start: formatFR(start),
715
+ end: formatFR(end),
716
+ total: resv.total || 0,
717
+ });
750
718
  }
751
719
  }
752
720
  }
@@ -755,6 +723,20 @@ api.createReservation = async function (req, res) {
755
723
  console.warn('[calendar-onekite] Failed to send pending email', e && e.message ? e.message : e);
756
724
  }
757
725
 
726
+ // Discord webhook (optional)
727
+ try {
728
+ await discord.notifyReservationRequested(settings, {
729
+ rid: resv.rid,
730
+ uid: resv.uid,
731
+ username: resv.username || '',
732
+ itemIds: resv.itemIds || [],
733
+ itemNames: resv.itemNames || [],
734
+ start: resv.start,
735
+ end: resv.end,
736
+ status: resv.status,
737
+ });
738
+ } catch (e) {}
739
+
758
740
  res.json({ ok: true, rid });
759
741
  };
760
742
 
package/lib/db.js CHANGED
@@ -10,10 +10,36 @@ const KEY_CHECKOUT_INTENT_TO_RID = 'calendar-onekite:helloasso:checkoutIntentToR
10
10
  const KEY_SPECIAL_ZSET = 'calendar-onekite:special';
11
11
  const KEY_SPECIAL_OBJ = (eid) => `calendar-onekite:special:${eid}`;
12
12
 
13
+ // Helpers
14
+ function reservationKey(rid) {
15
+ return KEY_OBJ(rid);
16
+ }
17
+
18
+ function specialKey(eid) {
19
+ return KEY_SPECIAL_OBJ(eid);
20
+ }
21
+
13
22
  async function getReservation(rid) {
14
23
  return await db.getObject(KEY_OBJ(rid));
15
24
  }
16
25
 
26
+ /**
27
+ * Batch fetch reservations in one DB roundtrip.
28
+ * Returns an array aligned with rids (missing objects => null).
29
+ */
30
+ async function getReservations(rids) {
31
+ const ids = Array.isArray(rids) ? rids.filter(Boolean) : [];
32
+ if (!ids.length) return [];
33
+ const keys = ids.map(reservationKey);
34
+ const rows = await db.getObjects(keys);
35
+ // Ensure rid is present even if older objects were missing it.
36
+ return (rows || []).map((row, idx) => {
37
+ if (!row) return null;
38
+ if (!row.rid) row.rid = String(ids[idx]);
39
+ return row;
40
+ });
41
+ }
42
+
17
43
  async function saveReservation(resv) {
18
44
  await db.setObject(KEY_OBJ(resv.rid), resv);
19
45
  // score = start timestamp
@@ -50,10 +76,22 @@ module.exports = {
50
76
  KEY_SPECIAL_ZSET,
51
77
  KEY_CHECKOUT_INTENT_TO_RID,
52
78
  getReservation,
79
+ getReservations,
53
80
  saveReservation,
54
81
  removeReservation,
55
82
  // Special events
56
83
  getSpecialEvent: async (eid) => await db.getObject(KEY_SPECIAL_OBJ(eid)),
84
+ getSpecialEvents: async (eids) => {
85
+ const ids = Array.isArray(eids) ? eids.filter(Boolean) : [];
86
+ if (!ids.length) return [];
87
+ const keys = ids.map(specialKey);
88
+ const rows = await db.getObjects(keys);
89
+ return (rows || []).map((row, idx) => {
90
+ if (!row) return null;
91
+ if (!row.eid) row.eid = String(ids[idx]);
92
+ return row;
93
+ });
94
+ },
57
95
  saveSpecialEvent: async (ev) => {
58
96
  await db.setObject(KEY_SPECIAL_OBJ(ev.eid), ev);
59
97
  await db.sortedSetAdd(KEY_SPECIAL_ZSET, ev.start, ev.eid);
package/lib/discord.js ADDED
@@ -0,0 +1,119 @@
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
+ 'Contenu du message',
77
+ '',
78
+ kind === 'paid' ? '**Matériel :**' : '**Matériel demandé :**',
79
+ ];
80
+ for (const it of items) {
81
+ lines.push(`- ${it}`);
82
+ }
83
+ if (Number.isFinite(start) && Number.isFinite(end)) {
84
+ lines.push('');
85
+ lines.push(`Du ${formatFRShort(start)} au ${formatFRShort(end)}`);
86
+ }
87
+ return lines.join('\n');
88
+ }
89
+
90
+ async function notifyReservationRequested(settings, reservation) {
91
+ const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
92
+ if (!url) return;
93
+ if (!isEnabled(settings.discordNotifyOnRequest, true)) return;
94
+
95
+ try {
96
+ await postWebhook(url, { content: buildReservationMessage('request', reservation) });
97
+ } catch (e) {
98
+ // eslint-disable-next-line no-console
99
+ console.warn('[calendar-onekite] Discord webhook failed (request)', e && e.message ? e.message : String(e));
100
+ }
101
+ }
102
+
103
+ async function notifyPaymentReceived(settings, reservation) {
104
+ const url = settings && settings.discordWebhookUrl ? String(settings.discordWebhookUrl).trim() : '';
105
+ if (!url) return;
106
+ if (!isEnabled(settings.discordNotifyOnPaid, true)) return;
107
+
108
+ try {
109
+ await postWebhook(url, { content: buildReservationMessage('paid', reservation) });
110
+ } catch (e) {
111
+ // eslint-disable-next-line no-console
112
+ console.warn('[calendar-onekite] Discord webhook failed (paid)', e && e.message ? e.message : String(e));
113
+ }
114
+ }
115
+
116
+ module.exports = {
117
+ notifyReservationRequested,
118
+ notifyPaymentReceived,
119
+ };
@@ -17,6 +17,7 @@ try {
17
17
 
18
18
  const dbLayer = require('./db');
19
19
  const helloasso = require('./helloasso');
20
+ const discord = require('./discord');
20
21
 
21
22
  const SETTINGS_KEY = 'calendar-onekite';
22
23
 
@@ -363,6 +364,20 @@ async function handler(req, res, next) {
363
364
  });
364
365
  }
365
366
 
367
+ // Discord webhook (optional)
368
+ try {
369
+ await discord.notifyPaymentReceived(settings, {
370
+ rid: r.rid,
371
+ uid: r.uid,
372
+ username: (requester && requester.username) ? requester.username : (r.username || ''),
373
+ itemIds: r.itemIds || (r.itemId ? [r.itemId] : []),
374
+ itemNames: r.itemNames || (r.itemName ? [r.itemName] : []),
375
+ start: r.start,
376
+ end: r.end,
377
+ status: r.status,
378
+ });
379
+ } catch (e) {}
380
+
366
381
  await markProcessed(paymentId);
367
382
  return res.json({ ok: true, processed: true });
368
383
  } catch (err) {
package/lib/scheduler.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const meta = require.main.require('./src/meta');
4
+ const db = require.main.require('./src/database');
4
5
  const dbLayer = require('./db');
5
6
 
6
7
  let timer = null;
@@ -109,7 +110,16 @@ async function processAwaitingPayment() {
109
110
  const expireAt = approvedAt + 2 * holdMins * 60 * 1000;
110
111
 
111
112
  if (!r.reminderSent && now >= reminderAt && now < expireAt) {
112
- // Send reminder once
113
+ // Send reminder once (guarded across clustered NodeBB processes)
114
+ const reminderKey = 'calendar-onekite:email:reminderSent';
115
+ const first = await db.setAdd(reminderKey, rid);
116
+ if (!first) {
117
+ // another process already sent it
118
+ r.reminderSent = true;
119
+ r.reminderAt = now;
120
+ await dbLayer.saveReservation(r);
121
+ continue;
122
+ }
113
123
  const u = await user.getUserFields(r.uid, ['username', 'email']);
114
124
  if (u && u.email) {
115
125
  await sendEmail('calendar-onekite_reminder', u.email, 'Location matériel - Rappel', {
@@ -130,8 +140,12 @@ async function processAwaitingPayment() {
130
140
 
131
141
  if (now >= expireAt) {
132
142
  // Expire: remove reservation so it disappears from calendar and frees items
143
+ // Guard email send across clustered NodeBB processes
144
+ const expiredKey = 'calendar-onekite:email:expiredSent';
145
+ const firstExpired = await db.setAdd(expiredKey, rid);
146
+ const shouldEmail = !!firstExpired;
133
147
  const u = await user.getUserFields(r.uid, ['username', 'email']);
134
- if (u && u.email) {
148
+ if (shouldEmail && u && u.email) {
135
149
  await sendEmail('calendar-onekite_expired', u.email, 'Location matériel - Rappel', {
136
150
  username: u.username,
137
151
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
package/lib/widgets.js ADDED
@@ -0,0 +1,171 @@
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, '&amp;')
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 passes an object with .widgets array.
32
+ if (!widgetData || !Array.isArray(widgetData.widgets)) {
33
+ return widgetData;
34
+ }
35
+
36
+ widgetData.widgets.push({
37
+ widget: 'calendar-onekite-twoweeks',
38
+ name: 'Calendrier OneKite (2 semaines)',
39
+ description: 'Affiche la semaine courante + la semaine suivante (FullCalendar via CDN).',
40
+ content: '',
41
+ });
42
+
43
+ return widgetData;
44
+ };
45
+
46
+ widgets.renderTwoWeeksWidget = async function (data) {
47
+ // data: { widget, ... }
48
+ const id = makeDomId();
49
+ const calUrl = widgetCalendarUrl();
50
+ const apiBase = forumBaseUrl();
51
+ const eventsEndpoint = `${apiBase}/api/v3/plugins/calendar-onekite/events`;
52
+
53
+ const idJson = JSON.stringify(id);
54
+ const calUrlJson = JSON.stringify(calUrl);
55
+ const eventsEndpointJson = JSON.stringify(eventsEndpoint);
56
+
57
+ const html = `
58
+ <div class="onekite-twoweeks">
59
+ <div class="d-flex justify-content-between align-items-center mb-1">
60
+ <div style="font-weight: 600;">Calendrier (2 semaines)</div>
61
+ <a href="${escapeHtml(calUrl)}" class="btn btn-sm btn-outline-secondary" style="line-height: 1.1;">Ouvrir</a>
62
+ </div>
63
+ <div id="${escapeHtml(id)}"></div>
64
+ </div>
65
+
66
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@latest/main.min.css" />
67
+ <script>
68
+ (function(){
69
+ const containerId = ${idJson};
70
+ const calUrl = ${calUrlJson};
71
+ const eventsEndpoint = ${eventsEndpointJson};
72
+
73
+ function loadOnce(tag, attrs) {
74
+ return new Promise((resolve, reject) => {
75
+ try {
76
+ const key = attrs && (attrs.id || attrs.href || attrs.src);
77
+ if (key && document.querySelector((attrs.id ? ('#' + attrs.id) : (attrs.href ? ('link[href="' + attrs.href + '"]') : ('script[src="' + attrs.src + '"]'))))) {
78
+ resolve();
79
+ return;
80
+ }
81
+ const el = document.createElement(tag);
82
+ Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
83
+ el.onload = () => resolve();
84
+ el.onerror = () => reject(new Error('load-failed'));
85
+ document.head.appendChild(el);
86
+ } catch (e) {
87
+ reject(e);
88
+ }
89
+ });
90
+ }
91
+
92
+ async function ensureFullCalendar() {
93
+ if (window.FullCalendar && window.FullCalendar.Calendar) {
94
+ return;
95
+ }
96
+ await loadOnce('script', {
97
+ id: 'onekite-fullcalendar-global',
98
+ src: 'https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.js',
99
+ async: 'true'
100
+ });
101
+ await loadOnce('script', {
102
+ id: 'onekite-fullcalendar-locales',
103
+ src: 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@latest/locales-all.global.min.js',
104
+ async: 'true'
105
+ });
106
+ }
107
+
108
+ async function init() {
109
+ const el = document.getElementById(containerId);
110
+ if (!el) return;
111
+
112
+ await ensureFullCalendar();
113
+
114
+ // Define a 2-week dayGrid view
115
+ const calendar = new window.FullCalendar.Calendar(el, {
116
+ initialView: 'dayGridTwoWeek',
117
+ views: {
118
+ dayGridTwoWeek: {
119
+ type: 'dayGrid',
120
+ duration: { weeks: 2 },
121
+ buttonText: '2 semaines',
122
+ },
123
+ },
124
+ locale: 'fr',
125
+ firstDay: 1,
126
+ height: 'auto',
127
+ headerToolbar: {
128
+ left: 'prev,next',
129
+ center: 'title',
130
+ right: '',
131
+ },
132
+ navLinks: false,
133
+ eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
134
+ events: function(info, successCallback, failureCallback) {
135
+ const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
136
+ fetch(eventsEndpoint + '?' + qs.toString(), { credentials: 'same-origin' })
137
+ .then((r) => r.json())
138
+ .then((json) => successCallback(json || []))
139
+ .catch((e) => failureCallback(e));
140
+ },
141
+ dateClick: function() {
142
+ window.location.href = calUrl;
143
+ },
144
+ eventClick: function() {
145
+ window.location.href = calUrl;
146
+ },
147
+ });
148
+
149
+ calendar.render();
150
+ }
151
+
152
+ // Widgets can be rendered after ajaxify; delay a tick.
153
+ setTimeout(() => init().catch(() => {}), 0);
154
+ })();
155
+ </script>
156
+
157
+ <style>
158
+ .onekite-twoweeks .fc .fc-toolbar-title { font-size: 1rem; }
159
+ .onekite-twoweeks .fc .fc-button { padding: .2rem .35rem; font-size: .75rem; }
160
+ .onekite-twoweeks .fc .fc-daygrid-day-number { font-size: .75rem; padding: 2px; }
161
+ .onekite-twoweeks .fc .fc-col-header-cell-cushion { font-size: .75rem; }
162
+ .onekite-twoweeks .fc .fc-event-title { font-size: .72rem; }
163
+ </style>
164
+ `;
165
+
166
+ data = data || {};
167
+ data.html = html;
168
+ return data;
169
+ };
170
+
171
+ module.exports = widgets;
package/library.js CHANGED
@@ -12,6 +12,7 @@ const api = require('./lib/api');
12
12
  const admin = require('./lib/admin');
13
13
  const scheduler = require('./lib/scheduler');
14
14
  const helloassoWebhook = require('./lib/helloassoWebhook');
15
+ const widgets = require('./lib/widgets');
15
16
  const bodyParser = require('body-parser');
16
17
 
17
18
  const Plugin = {};
@@ -149,4 +150,8 @@ Plugin.emailModify = async function (data) {
149
150
  return data;
150
151
  };
151
152
 
153
+ // Widgets
154
+ Plugin.defineWidgets = widgets.defineWidgets;
155
+ Plugin.renderTwoWeeksWidget = widgets.renderTwoWeeksWidget;
156
+
152
157
  module.exports = Plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "12.0.17",
3
+ "version": "12.0.19",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -15,6 +15,14 @@
15
15
  {
16
16
  "hook": "filter:email.modify",
17
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"
18
26
  }
19
27
  ],
20
28
  "staticDirs": {
@@ -31,5 +39,5 @@
31
39
  "acpScripts": [
32
40
  "public/admin.js"
33
41
  ],
34
- "version": "1.0.1.1"
42
+ "version": "1.1.1"
35
43
  }
@@ -53,6 +53,31 @@
53
53
  <div class="form-text">Après validation (statut <code>paiement en attente</code>), un rappel est envoyé après ce délai. La réservation est ensuite expirée après <strong>2×</strong> ce délai.</div>
54
54
  </div>
55
55
 
56
+ <hr class="my-4" />
57
+ <h4>Discord</h4>
58
+
59
+ <div class="mb-3">
60
+ <label class="form-label">Webhook URL</label>
61
+ <input class="form-control" name="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
62
+ <div class="form-text">Si vide, aucune notification Discord n'est envoyée.</div>
63
+ </div>
64
+
65
+ <div class="mb-3">
66
+ <label class="form-label">Envoyer une notification à la demande</label>
67
+ <select class="form-select" name="discordNotifyOnRequest">
68
+ <option value="1">Oui</option>
69
+ <option value="0">Non</option>
70
+ </select>
71
+ </div>
72
+
73
+ <div class="mb-3">
74
+ <label class="form-label">Envoyer une notification au paiement reçu</label>
75
+ <select class="form-select" name="discordNotifyOnPaid">
76
+ <option value="1">Oui</option>
77
+ <option value="0">Non</option>
78
+ </select>
79
+ </div>
80
+
56
81
  <h4 class="mt-4">HelloAsso</h4>
57
82
 
58
83
  <div class="mb-3">