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.
- package/CHANGELOG.md +29 -0
- package/lib/admin.js +21 -9
- package/lib/api.js +235 -4
- package/lib/db.js +114 -0
- package/lib/helloassoWebhook.js +28 -0
- package/library.js +7 -0
- package/package.json +1 -1
- package/pkg/package/CHANGELOG.md +106 -0
- package/pkg/package/lib/admin.js +554 -0
- package/pkg/package/lib/api.js +1458 -0
- package/pkg/package/lib/controllers.js +11 -0
- package/pkg/package/lib/db.js +224 -0
- package/pkg/package/lib/discord.js +190 -0
- package/pkg/package/lib/helloasso.js +352 -0
- package/pkg/package/lib/helloassoWebhook.js +389 -0
- package/pkg/package/lib/scheduler.js +201 -0
- package/pkg/package/lib/widgets.js +460 -0
- package/pkg/package/library.js +164 -0
- package/pkg/package/package.json +14 -0
- package/pkg/package/plugin.json +43 -0
- package/pkg/package/public/admin.js +1477 -0
- package/pkg/package/public/client.js +2228 -0
- package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
- package/pkg/package/templates/calendar-onekite.tpl +51 -0
- package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
- package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
- package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
- package/plugin.json +1 -1
- package/public/admin.js +205 -4
- package/public/client.js +238 -7
- package/templates/admin/plugins/calendar-onekite.tpl +74 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Changelog – calendar-onekite
|
|
2
|
+
|
|
3
|
+
## 1.3.6
|
|
4
|
+
- ACP Comptabilisation : ajout d’un tableau séparé « Détails des sorties gratuites » (avec le nom du matériel).
|
|
5
|
+
- Mobile FAB : la modale utilise maintenant des champs date avec calendrier (type="date") et empêche toute réservation pour le jour même ou dans le passé.
|
|
6
|
+
|
|
7
|
+
## 1.3.5
|
|
8
|
+
- ACP : ajout d’un paramètre « Location longue durée (jours) pour validateurs ». Si une réservation faite par un validateur dépasse ce nombre de jours, elle redevient payante et suit le workflow normal (demande → validation → paiement HelloAsso). Mets 0 pour conserver le comportement « toujours gratuit ».
|
|
9
|
+
|
|
10
|
+
## 1.3.4
|
|
11
|
+
- Réservations gratuites (validateurs) : ne sont plus comptées comme chiffre d’affaires dans la comptabilisation. Elles apparaissent désormais sur une ligne séparée « Sorties gratuites » (et sont marquées (gratuit) dans le détail).
|
|
12
|
+
|
|
13
|
+
## 1.3.3
|
|
14
|
+
- Comptabilité : les réservations « auto-checkées » (réservations faites par un validateur) recalculent désormais le total côté serveur (catalogue HelloAsso × nb jours calendaires) afin d’être comptabilisées correctement.
|
|
15
|
+
|
|
16
|
+
## 1.3.2
|
|
17
|
+
- Les membres validateurs (ceux qui peuvent valider/supprimer une demande) voient leurs propres réservations passer directement en statut payé/checked (pas de workflow paiement).
|
|
18
|
+
- UI : message de succès adapté (réservation confirmée).
|
|
19
|
+
|
|
20
|
+
## 1.3.1
|
|
21
|
+
- ACP : ajout de 2 actions rapides dans l’onglet Maintenance : « Tout mettre en maintenance » et « Tout enlever de maintenance » (avec audit).
|
|
22
|
+
|
|
23
|
+
## 1.3.0
|
|
24
|
+
- Maintenance (sans dates) : blocage manuel ON/OFF par matériel (ACP + API). Les matériels en maintenance sont grisés et affichés comme : 🔧 Nom (en maintenance).
|
|
25
|
+
- Audit : journal des actions (demande, validation, refus, annulation, maintenance) avec consultation et purge par année (ACP + API).
|
|
26
|
+
|
|
27
|
+
## 1.2.18
|
|
28
|
+
- API events + anti double booking : les tests de chevauchement utilisent désormais en priorité startDate/endDate (YYYY-MM-DD) quand disponibles (logique calendaire pure, endDate exclusive). Cela supprime définitivement les faux chevauchements liés aux timestamps/fuseaux/DST, notamment sur mobile et « Durée rapide ».
|
|
29
|
+
|
|
30
|
+
## 1.2.17
|
|
31
|
+
- Modale réservation : la requête de disponibilité initiale utilise aussi des dates calendaires (YYYY-MM-DD) au lieu de startStr/endStr/toISOString(), ce qui corrige le grisé erroné (mobile + durée rapide).
|
|
32
|
+
|
|
33
|
+
## 1.2.16
|
|
34
|
+
- Modale réservation (Durée rapide) : correction du grisé erroné des matériels réservés la veille. Les requêtes de disponibilité utilisent désormais des dates calendaires (YYYY-MM-DD) au lieu de toISOString() (UTC), pour éviter tout faux chevauchement lié au fuseau/DST.
|
|
35
|
+
|
|
36
|
+
## 1.2.15
|
|
37
|
+
- Réservations (Durée rapide) : génération des évènements all-day basée sur startDate/endDate (YYYY-MM-DD) quand disponibles, pour éviter tout décalage lié à toISOString() (UTC) et empêcher un grisé « non disponible » le jour suivant.
|
|
38
|
+
|
|
39
|
+
## 1.2.14
|
|
40
|
+
- Réservations : correction d’un faux chevauchement (problème 1h) quand FullCalendar envoie des bornes all-day à minuit UTC (Z / +00:00) — le lendemain n’est plus marqué « non disponible ».
|
|
41
|
+
|
|
42
|
+
## 1.2.13
|
|
43
|
+
- ACP (mode sombre) : correction de la visibilité de la liste d’autocomplete d’adresse (couleurs via variables Bootstrap)
|
|
44
|
+
|
|
45
|
+
## 1.2.12
|
|
46
|
+
- Modales (calendrier + ACP) : autocomplete adresse rendu identique et compatible Bootstrap input-group (plus de wrapper qui casse l’affichage)
|
|
47
|
+
|
|
48
|
+
## 1.2.11
|
|
49
|
+
- ACP : ajout de la recherche automatique d’adresse (autocomplete Nominatim) dans la modale de validation (unitaire + batch), comme sur le calendrier
|
|
50
|
+
|
|
51
|
+
## 1.2.10
|
|
52
|
+
- HelloAsso : calcul des jours 100% fiable (différence en jours calendaires Y/M/J, sans dépendance aux heures/au fuseau/DST)
|
|
53
|
+
- FullCalendar : endDate traitée comme exclusive partout (UI + checkout)
|
|
54
|
+
- HelloAsso : montant du checkout recalculé côté serveur à partir du catalogue (prix/jour × nbJours)
|
|
55
|
+
|
|
56
|
+
## 1.2.9
|
|
57
|
+
- Modale réservation : retour du grisé des matériels indisponibles (API events expose à nouveau itemIds)
|
|
58
|
+
|
|
59
|
+
## 1.2.8
|
|
60
|
+
- Popup réservation : correction « Durée rapide » (la période envoyée correspond bien à la durée sélectionnée)
|
|
61
|
+
|
|
62
|
+
## 1.2.7
|
|
63
|
+
- UI : suppression du bouton flottant mobile « + Réserver »
|
|
64
|
+
- Client : nettoyage du code associé (suppression du bloc FAB)
|
|
65
|
+
|
|
66
|
+
## 1.2.6
|
|
67
|
+
- ACP : anti double action (verrou UI + boutons désactivés/spinner) sur valider/refuser (unitaire + batch)
|
|
68
|
+
|
|
69
|
+
## 1.2.5
|
|
70
|
+
- Mobile : bouton flottant « + Réserver »
|
|
71
|
+
- Création : anti double-tap/click (verrou actions) + invalidation cache events + refetch léger
|
|
72
|
+
- Calendrier : jours passés / aujourd’hui affichés comme non-sélectionnables (règle visuelle)
|
|
73
|
+
- Popup réservation : raccourcis de durée (1j/2j/3j/7j) + recalcul total + mise à jour des matériels bloqués
|
|
74
|
+
- ACP : actions en batch (valider/refuser une sélection) + compteur de sélection
|
|
75
|
+
- API : idempotence sur valider/refuser + audit enrichi (refusedBy, cancelledByUsername)
|
|
76
|
+
|
|
77
|
+
## 1.0.3
|
|
78
|
+
- Suppression du texte d’archivage dans le toast de purge (plus de « 0 archivés »)
|
|
79
|
+
- Renommage du plugin : nodebb-plugin-onekite-calendar
|
|
80
|
+
- Discord : notification « ❌ Réservation annulée » (annulation manuelle + annulation automatique) + option ACP
|
|
81
|
+
|
|
82
|
+
## 1.0.2
|
|
83
|
+
- Purge calendrier : suppression réelle des réservations (aucune logique d’archivage)
|
|
84
|
+
- Compta conservée séparément (la purge n’y touche jamais)
|
|
85
|
+
|
|
86
|
+
## 1.0.1
|
|
87
|
+
- ACP : modales Valider / Refuser OK (plus de retour au premier onglet)
|
|
88
|
+
- Scheduler : optimisation (batch DB, scans réduits)
|
|
89
|
+
- API events : pagination interne + ETag optimisé
|
|
90
|
+
- HelloAsso : token mis en cache + protection rate-limit (429 / Cloudflare 1015), sans logs supplémentaires
|
|
91
|
+
- Discord : notifications en embeds + icônes ⏳ (demande) et 💳 (paiement)
|
|
92
|
+
- Widget : suppression de “2 semaines” dans le titre affiché
|
|
93
|
+
- Correctifs divers de stabilité/performance (dont crash au démarrage)
|
|
94
|
+
|
|
95
|
+
## 1.0.0
|
|
96
|
+
- Première version stable du plugin calendar-onekite
|
|
97
|
+
- Gestion des réservations de matériel avec contrôle de disponibilité
|
|
98
|
+
- Calendrier FullCalendar via CDN
|
|
99
|
+
- Validation / refus des demandes depuis l’ACP
|
|
100
|
+
- Notifications Discord
|
|
101
|
+
- Intégration paiements HelloAsso
|
|
102
|
+
|
|
103
|
+
## 1.2.19
|
|
104
|
+
- Mobile: ajout d’un bouton flottant (FAB) sur la page calendrier uniquement.
|
|
105
|
+
- Le FAB ouvre une mini-modale de sélection de dates (dd/mm/yyyy) puis ouvre la modale standard de réservation.
|
|
106
|
+
- Le FAB est automatiquement retiré quand on navigue hors de la page calendrier.
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const meta = require.main.require('./src/meta');
|
|
4
|
+
const user = require.main.require('./src/user');
|
|
5
|
+
const emailer = require.main.require('./src/emailer');
|
|
6
|
+
const nconf = require.main.require('nconf');
|
|
7
|
+
|
|
8
|
+
function forumBaseUrl() {
|
|
9
|
+
const base = String(nconf.get('url') || '').trim().replace(/\/$/, '');
|
|
10
|
+
return base;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatFR(tsOrIso) {
|
|
14
|
+
const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
|
|
15
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
16
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
17
|
+
const yyyy = d.getFullYear();
|
|
18
|
+
return `${dd}/${mm}/${yyyy}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
|
|
22
|
+
const base = String(baseLabel || 'Réservation matériel Onekite').trim();
|
|
23
|
+
const items = Array.isArray(itemNames) ? itemNames.map((s) => String(s || '').trim()).filter(Boolean) : [];
|
|
24
|
+
const range = (start && end) ? `Du ${formatFR(start)} au ${formatFR(end)}` : '';
|
|
25
|
+
const lines = [base];
|
|
26
|
+
items.forEach((it) => lines.push(`• ${it}`));
|
|
27
|
+
if (range) lines.push(range);
|
|
28
|
+
let out = lines.join('\n').trim();
|
|
29
|
+
if (out.length > 250) {
|
|
30
|
+
out = out.slice(0, 249).trimEnd() + '…';
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function sendEmail(template, uid, subject, data) {
|
|
36
|
+
const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
|
|
37
|
+
if (!Number.isInteger(toUid) || toUid <= 0) return;
|
|
38
|
+
|
|
39
|
+
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
if (typeof emailer.send !== 'function') return;
|
|
43
|
+
// NodeBB 4.x: send(template, uid, params)
|
|
44
|
+
// Do NOT branch on function.length (unreliable once wrapped/bound).
|
|
45
|
+
await emailer.send(template, toUid, params);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.warn('[calendar-onekite] Failed to send email', {
|
|
48
|
+
template,
|
|
49
|
+
uid: toUid,
|
|
50
|
+
err: err && err.message ? err.message : String(err),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeCallbackUrl(configured, meta) {
|
|
56
|
+
const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
57
|
+
let url = (configured || '').trim();
|
|
58
|
+
if (!url) {
|
|
59
|
+
url = base ? `${base}/helloasso` : '';
|
|
60
|
+
}
|
|
61
|
+
if (url && url.startsWith('/') && base) {
|
|
62
|
+
url = `${base}${url}`;
|
|
63
|
+
}
|
|
64
|
+
return url;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeReturnUrl(meta) {
|
|
68
|
+
const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
69
|
+
const b = String(base || '').trim().replace(/\/$/, '');
|
|
70
|
+
if (!b) return '';
|
|
71
|
+
return `${b}/calendar`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
const dbLayer = require('./db');
|
|
76
|
+
const helloasso = require('./helloasso');
|
|
77
|
+
|
|
78
|
+
const ADMIN_PRIV = 'admin:settings';
|
|
79
|
+
|
|
80
|
+
const admin = {};
|
|
81
|
+
|
|
82
|
+
admin.renderAdmin = async function (req, res) {
|
|
83
|
+
res.render('admin/plugins/calendar-onekite', {
|
|
84
|
+
title: 'Calendar Onekite',
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
admin.getSettings = async function (req, res) {
|
|
89
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
90
|
+
res.json(settings || {});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
admin.saveSettings = async function (req, res) {
|
|
94
|
+
await meta.settings.set('calendar-onekite', req.body || {});
|
|
95
|
+
res.json({ ok: true });
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
admin.listPending = async function (req, res) {
|
|
99
|
+
const ids = await dbLayer.listAllReservationIds(5000);
|
|
100
|
+
// Batch fetch to avoid N DB round-trips.
|
|
101
|
+
const rows = await dbLayer.getReservations(ids);
|
|
102
|
+
const pending = (rows || []).filter(r => r && r.status === 'pending');
|
|
103
|
+
pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
104
|
+
res.json(pending);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
admin.approveReservation = async function (req, res) {
|
|
108
|
+
const rid = req.params.rid;
|
|
109
|
+
const r = await dbLayer.getReservation(rid);
|
|
110
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
111
|
+
|
|
112
|
+
r.status = 'awaiting_payment';
|
|
113
|
+
r.pickupAddress = String((req.body && (req.body.pickupAddress || req.body.adminNote || req.body.note)) || '').trim();
|
|
114
|
+
r.notes = String((req.body && req.body.notes) || '').trim();
|
|
115
|
+
r.pickupTime = String((req.body && (req.body.pickupTime || req.body.pickup)) || '').trim();
|
|
116
|
+
r.pickupLat = String((req.body && req.body.pickupLat) || '').trim();
|
|
117
|
+
r.pickupLon = String((req.body && req.body.pickupLon) || '').trim();
|
|
118
|
+
r.approvedAt = Date.now();
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const approver = await user.getUserFields(req.uid, ['username']);
|
|
122
|
+
r.approvedBy = req.uid;
|
|
123
|
+
r.approvedByUsername = approver && approver.username ? approver.username : '';
|
|
124
|
+
} catch (e) {
|
|
125
|
+
r.approvedBy = req.uid;
|
|
126
|
+
r.approvedByUsername = '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Create HelloAsso payment link if configured
|
|
130
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
131
|
+
const env = settings.helloassoEnv || 'prod';
|
|
132
|
+
const token = await helloasso.getAccessToken({
|
|
133
|
+
env,
|
|
134
|
+
clientId: settings.helloassoClientId,
|
|
135
|
+
clientSecret: settings.helloassoClientSecret,
|
|
136
|
+
});
|
|
137
|
+
if (!token) {
|
|
138
|
+
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let paymentUrl = null;
|
|
142
|
+
if (token) {
|
|
143
|
+
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
144
|
+
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
145
|
+
const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
146
|
+
const base = forumBaseUrl();
|
|
147
|
+
const returnUrl = base ? `${base}/calendar` : '';
|
|
148
|
+
const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
|
|
149
|
+
const year = new Date(Number(r.start)).getFullYear();
|
|
150
|
+
const intent = await helloasso.createCheckoutIntent({
|
|
151
|
+
env,
|
|
152
|
+
token,
|
|
153
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
154
|
+
formType: settings.helloassoFormType,
|
|
155
|
+
// Form slug is derived from the year
|
|
156
|
+
formSlug: `locations-materiel-${year}`,
|
|
157
|
+
totalAmount,
|
|
158
|
+
payerEmail: requester && requester.email,
|
|
159
|
+
// User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
|
|
160
|
+
callbackUrl: returnUrl,
|
|
161
|
+
webhookUrl: webhookUrl,
|
|
162
|
+
itemName: buildHelloAssoItemName('Réservation matériel Onekite', r.itemNames || (r.itemName ? [r.itemName] : []), r.start, r.end),
|
|
163
|
+
containsDonation: false,
|
|
164
|
+
metadata: {
|
|
165
|
+
reservationId: String(rid),
|
|
166
|
+
items: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
|
|
167
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
|
|
171
|
+
? (intent.paymentUrl || intent.redirectUrl)
|
|
172
|
+
: (typeof intent === 'string' ? intent : null);
|
|
173
|
+
if (intent && intent.checkoutIntentId) {
|
|
174
|
+
r.checkoutIntentId = intent.checkoutIntentId;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (paymentUrl) {
|
|
179
|
+
r.paymentUrl = paymentUrl;
|
|
180
|
+
} else {
|
|
181
|
+
console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await dbLayer.saveReservation(r);
|
|
185
|
+
|
|
186
|
+
// Email requester
|
|
187
|
+
try {
|
|
188
|
+
const requesterUid = parseInt(r.uid, 10);
|
|
189
|
+
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
190
|
+
if (requesterUid) {
|
|
191
|
+
const latNum = Number(r.pickupLat);
|
|
192
|
+
const lonNum = Number(r.pickupLon);
|
|
193
|
+
const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
|
|
194
|
+
? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
|
|
195
|
+
: '';
|
|
196
|
+
await sendEmail('calendar-onekite_approved', requesterUid, 'Location matériel - Réservation validée', {
|
|
197
|
+
uid: requesterUid,
|
|
198
|
+
username: requester && requester.username ? requester.username : '',
|
|
199
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
200
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
201
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
202
|
+
paymentUrl: paymentUrl || '',
|
|
203
|
+
pickupAddress: r.pickupAddress || '',
|
|
204
|
+
notes: r.notes || '',
|
|
205
|
+
pickupTime: r.pickupTime || '',
|
|
206
|
+
pickupLat: r.pickupLat || '',
|
|
207
|
+
pickupLon: r.pickupLon || '',
|
|
208
|
+
mapUrl,
|
|
209
|
+
validatedBy: r.approvedByUsername || '',
|
|
210
|
+
validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {}
|
|
214
|
+
|
|
215
|
+
res.json({ ok: true, paymentUrl: paymentUrl || null });
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
admin.refuseReservation = async function (req, res) {
|
|
219
|
+
const rid = req.params.rid;
|
|
220
|
+
const r = await dbLayer.getReservation(rid);
|
|
221
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
222
|
+
|
|
223
|
+
r.status = 'refused';
|
|
224
|
+
r.refusedAt = Date.now();
|
|
225
|
+
r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
|
|
226
|
+
await dbLayer.saveReservation(r);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const requesterUid = parseInt(r.uid, 10);
|
|
230
|
+
const requester = await user.getUserFields(requesterUid, ['username']);
|
|
231
|
+
if (requesterUid) {
|
|
232
|
+
await sendEmail('calendar-onekite_refused', requesterUid, 'Location matériel - Réservation refusée', {
|
|
233
|
+
uid: requesterUid,
|
|
234
|
+
username: requester && requester.username ? requester.username : '',
|
|
235
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
236
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
237
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
238
|
+
start: formatFR(r.start),
|
|
239
|
+
end: formatFR(r.end),
|
|
240
|
+
refusedReason: r.refusedReason || '',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
} catch (e) {}
|
|
244
|
+
|
|
245
|
+
res.json({ ok: true });
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
admin.purgeByYear = async function (req, res) {
|
|
249
|
+
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
250
|
+
if (!/^\d{4}$/.test(year)) {
|
|
251
|
+
return res.status(400).json({ error: 'invalid-year' });
|
|
252
|
+
}
|
|
253
|
+
const y = parseInt(year, 10);
|
|
254
|
+
const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
|
|
255
|
+
const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
|
|
256
|
+
|
|
257
|
+
const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
|
|
258
|
+
let removed = 0;
|
|
259
|
+
for (const rid of ids) {
|
|
260
|
+
const r = await dbLayer.getReservation(rid);
|
|
261
|
+
if (!r) continue;
|
|
262
|
+
|
|
263
|
+
await dbLayer.removeReservation(rid);
|
|
264
|
+
removed++;
|
|
265
|
+
}
|
|
266
|
+
res.json({ ok: true, removed });
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
admin.purgeSpecialEventsByYear = async function (req, res) {
|
|
270
|
+
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
271
|
+
if (!/^\d{4}$/.test(year)) {
|
|
272
|
+
return res.status(400).json({ error: 'invalid-year' });
|
|
273
|
+
}
|
|
274
|
+
const y = parseInt(year, 10);
|
|
275
|
+
const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
|
|
276
|
+
const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
|
|
277
|
+
|
|
278
|
+
const ids = await dbLayer.listSpecialIdsByStartRange(startTs, endTs, 100000);
|
|
279
|
+
let count = 0;
|
|
280
|
+
for (const eid of ids) {
|
|
281
|
+
await dbLayer.removeSpecialEvent(eid);
|
|
282
|
+
count++;
|
|
283
|
+
}
|
|
284
|
+
return res.json({ ok: true, removed: count });
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Debug endpoint to validate HelloAsso connectivity and item loading
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
admin.debugHelloAsso = async function (req, res) {
|
|
291
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
292
|
+
const env = (settings && settings.helloassoEnv) || 'prod';
|
|
293
|
+
|
|
294
|
+
// Never expose secrets in debug output
|
|
295
|
+
const safeSettings = {
|
|
296
|
+
helloassoEnv: env,
|
|
297
|
+
helloassoClientId: settings && settings.helloassoClientId ? String(settings.helloassoClientId) : '',
|
|
298
|
+
helloassoClientSecret: settings && settings.helloassoClientSecret ? '***' : '',
|
|
299
|
+
helloassoOrganizationSlug: settings && settings.helloassoOrganizationSlug ? String(settings.helloassoOrganizationSlug) : '',
|
|
300
|
+
helloassoFormType: settings && settings.helloassoFormType ? String(settings.helloassoFormType) : '',
|
|
301
|
+
helloassoFormSlug: settings && settings.helloassoFormSlug ? String(settings.helloassoFormSlug) : '',
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const out = {
|
|
305
|
+
ok: true,
|
|
306
|
+
settings: safeSettings,
|
|
307
|
+
token: { ok: false },
|
|
308
|
+
// Catalog = what you actually want for a shop (available products/material)
|
|
309
|
+
catalog: { ok: false, count: 0, sample: [], keys: [] },
|
|
310
|
+
// Sold items = items present in orders (can be 0 if no sales yet)
|
|
311
|
+
soldItems: { ok: false, count: 0, sample: [] },
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const token = await helloasso.getAccessToken({
|
|
316
|
+
env,
|
|
317
|
+
clientId: settings.helloassoClientId,
|
|
318
|
+
clientSecret: settings.helloassoClientSecret,
|
|
319
|
+
});
|
|
320
|
+
if (!token) {
|
|
321
|
+
out.token = { ok: false, error: 'token-null' };
|
|
322
|
+
return res.json(out);
|
|
323
|
+
}
|
|
324
|
+
out.token = { ok: true };
|
|
325
|
+
|
|
326
|
+
// Catalog items (via /public)
|
|
327
|
+
try {
|
|
328
|
+
const y = new Date().getFullYear();
|
|
329
|
+
const { publicForm, items } = await helloasso.listCatalogItems({
|
|
330
|
+
env,
|
|
331
|
+
token,
|
|
332
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
333
|
+
formType: settings.helloassoFormType,
|
|
334
|
+
formSlug: `locations-materiel-${y}`,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const arr = Array.isArray(items) ? items : [];
|
|
338
|
+
out.catalog.ok = true;
|
|
339
|
+
out.catalog.count = arr.length;
|
|
340
|
+
out.catalog.keys = publicForm && typeof publicForm === 'object' ? Object.keys(publicForm) : [];
|
|
341
|
+
out.catalog.sample = arr.slice(0, 10).map((it) => ({
|
|
342
|
+
id: it.id,
|
|
343
|
+
name: it.name,
|
|
344
|
+
price: it.price ?? null,
|
|
345
|
+
}));
|
|
346
|
+
} catch (e) {
|
|
347
|
+
out.catalog = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [], keys: [] };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Sold items
|
|
351
|
+
try {
|
|
352
|
+
const y2 = new Date().getFullYear();
|
|
353
|
+
const items = await helloasso.listItems({
|
|
354
|
+
env,
|
|
355
|
+
token,
|
|
356
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
357
|
+
formType: settings.helloassoFormType,
|
|
358
|
+
formSlug: `locations-materiel-${y2}`,
|
|
359
|
+
});
|
|
360
|
+
const arr = Array.isArray(items) ? items : [];
|
|
361
|
+
out.soldItems.ok = true;
|
|
362
|
+
out.soldItems.count = arr.length;
|
|
363
|
+
out.soldItems.sample = arr.slice(0, 10).map((it) => ({
|
|
364
|
+
id: it.id || it.itemId || it.reference || it.name,
|
|
365
|
+
name: it.name || it.label || it.itemName,
|
|
366
|
+
price: it.price || it.amount || it.unitPrice || null,
|
|
367
|
+
}));
|
|
368
|
+
} catch (e) {
|
|
369
|
+
out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return res.json(out);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
out.ok = false;
|
|
375
|
+
out.token = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
376
|
+
return res.json(out);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Accounting endpoint: aggregates paid reservations so you can contabilize what was rented.
|
|
381
|
+
// Query params:
|
|
382
|
+
// from=YYYY-MM-DD (inclusive, based on reservation.start)
|
|
383
|
+
// to=YYYY-MM-DD (exclusive, based on reservation.start)
|
|
384
|
+
admin.getAccounting = async function (req, res) {
|
|
385
|
+
const qFrom = String((req.query && req.query.from) || '').trim();
|
|
386
|
+
const qTo = String((req.query && req.query.to) || '').trim();
|
|
387
|
+
const parseDay = (s) => {
|
|
388
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
389
|
+
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
|
|
390
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
391
|
+
const ts = dt.getTime();
|
|
392
|
+
return Number.isFinite(ts) ? ts : null;
|
|
393
|
+
};
|
|
394
|
+
const fromTs = parseDay(qFrom);
|
|
395
|
+
const toTs = parseDay(qTo);
|
|
396
|
+
|
|
397
|
+
// Default: last 12 months (UTC)
|
|
398
|
+
const now = new Date();
|
|
399
|
+
const defaultTo = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
400
|
+
const defaultFrom = Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1);
|
|
401
|
+
const minTs = fromTs ?? defaultFrom;
|
|
402
|
+
const maxTs = toTs ?? defaultTo;
|
|
403
|
+
|
|
404
|
+
const ids = await dbLayer.listAllReservationIds(100000);
|
|
405
|
+
const rows = [];
|
|
406
|
+
const byItem = new Map();
|
|
407
|
+
let freeCount = 0;
|
|
408
|
+
|
|
409
|
+
for (const rid of ids) {
|
|
410
|
+
const r = await dbLayer.getReservation(rid);
|
|
411
|
+
if (!r) continue;
|
|
412
|
+
if (String(r.status) !== 'paid') continue;
|
|
413
|
+
if (r.accPurgedAt) continue;
|
|
414
|
+
const start = parseInt(r.start, 10);
|
|
415
|
+
if (!Number.isFinite(start)) continue;
|
|
416
|
+
if (start < minTs || start >= maxTs) continue;
|
|
417
|
+
|
|
418
|
+
const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
|
|
419
|
+
? r.itemNames
|
|
420
|
+
: (r.itemName ? [r.itemName] : []);
|
|
421
|
+
|
|
422
|
+
const isFree = !!r.isFree;
|
|
423
|
+
const total = isFree ? 0 : (Number(r.total) || 0);
|
|
424
|
+
const startDate = formatFR(r.start);
|
|
425
|
+
const endDate = formatFR(r.end);
|
|
426
|
+
|
|
427
|
+
if (isFree) freeCount += 1;
|
|
428
|
+
|
|
429
|
+
rows.push({
|
|
430
|
+
rid: r.rid,
|
|
431
|
+
uid: r.uid,
|
|
432
|
+
username: r.username || '',
|
|
433
|
+
start: r.start,
|
|
434
|
+
end: r.end,
|
|
435
|
+
startDate,
|
|
436
|
+
endDate,
|
|
437
|
+
items: itemNames,
|
|
438
|
+
total,
|
|
439
|
+
paidAt: r.paidAt || '',
|
|
440
|
+
isFree,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Only count paid (non-free) reservations into revenue by item.
|
|
444
|
+
if (!isFree) {
|
|
445
|
+
for (const name of itemNames) {
|
|
446
|
+
const key = String(name || '').trim();
|
|
447
|
+
if (!key) continue;
|
|
448
|
+
const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
|
|
449
|
+
cur.count += 1;
|
|
450
|
+
cur.total += total;
|
|
451
|
+
byItem.set(key, cur);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const summary = Array.from(byItem.values()).sort((a, b) => b.count - a.count);
|
|
457
|
+
if (freeCount > 0) {
|
|
458
|
+
summary.push({ item: 'Sorties gratuites', count: freeCount, total: 0, isFree: true });
|
|
459
|
+
}
|
|
460
|
+
rows.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
461
|
+
|
|
462
|
+
return res.json({
|
|
463
|
+
ok: true,
|
|
464
|
+
from: new Date(minTs).toISOString().slice(0, 10),
|
|
465
|
+
to: new Date(maxTs).toISOString().slice(0, 10),
|
|
466
|
+
summary,
|
|
467
|
+
rows,
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
admin.exportAccountingCsv = async function (req, res) {
|
|
472
|
+
// Reuse the same logic and emit a CSV.
|
|
473
|
+
const fakeRes = { json: (x) => x };
|
|
474
|
+
const data = await admin.getAccounting(req, fakeRes);
|
|
475
|
+
// If getAccounting returned via res.json, data is undefined; rebuild by calling logic directly.
|
|
476
|
+
// Easiest: call getAccounting's internals by fetching the endpoint logic via HTTP is not possible here.
|
|
477
|
+
// So we re-run getAccounting but capture output by monkeypatching.
|
|
478
|
+
let payload;
|
|
479
|
+
await admin.getAccounting(req, { json: (x) => { payload = x; return x; } });
|
|
480
|
+
if (!payload || !payload.ok) {
|
|
481
|
+
return res.status(500).send('error');
|
|
482
|
+
}
|
|
483
|
+
const escape = (v) => {
|
|
484
|
+
const s = String(v ?? '');
|
|
485
|
+
if (/[\n\r,\"]/g.test(s)) {
|
|
486
|
+
return '"' + s.replace(/"/g, '""') + '"';
|
|
487
|
+
}
|
|
488
|
+
return s;
|
|
489
|
+
};
|
|
490
|
+
const lines = [];
|
|
491
|
+
lines.push(['rid', 'username', 'uid', 'start', 'end', 'items', 'total', 'paidAt', 'isFree'].map(escape).join(','));
|
|
492
|
+
for (const r of payload.rows || []) {
|
|
493
|
+
lines.push([
|
|
494
|
+
r.rid,
|
|
495
|
+
r.username,
|
|
496
|
+
r.uid,
|
|
497
|
+
r.startDate,
|
|
498
|
+
r.endDate,
|
|
499
|
+
(Array.isArray(r.items) ? r.items.join(' | ') : ''),
|
|
500
|
+
(Number(r.total) || 0).toFixed(2),
|
|
501
|
+
r.paidAt ? new Date(parseInt(r.paidAt, 10)).toISOString() : '',
|
|
502
|
+
r.isFree ? '1' : '0',
|
|
503
|
+
].map(escape).join(','));
|
|
504
|
+
}
|
|
505
|
+
const csv = lines.join('\n');
|
|
506
|
+
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
507
|
+
res.setHeader('Content-Disposition', 'attachment; filename="calendar-onekite-accounting.csv"');
|
|
508
|
+
return res.send(csv);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
admin.purgeAccounting = async function (req, res) {
|
|
513
|
+
const qFrom = String((req.query && req.query.from) || '').trim();
|
|
514
|
+
const qTo = String((req.query && req.query.to) || '').trim();
|
|
515
|
+
const parseDay = (s) => {
|
|
516
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
517
|
+
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
|
|
518
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
519
|
+
const ts = dt.getTime();
|
|
520
|
+
return Number.isFinite(ts) ? ts : null;
|
|
521
|
+
};
|
|
522
|
+
const fromTs = parseDay(qFrom);
|
|
523
|
+
const toTs = parseDay(qTo);
|
|
524
|
+
|
|
525
|
+
const now = new Date();
|
|
526
|
+
const defaultTo = Date.UTC(now.getUTCFullYear() + 100, 0, 1); // far future
|
|
527
|
+
const defaultFrom = Date.UTC(1970, 0, 1);
|
|
528
|
+
const minTs = fromTs ?? defaultFrom;
|
|
529
|
+
const maxTs = toTs ?? defaultTo;
|
|
530
|
+
|
|
531
|
+
const ids = await dbLayer.listAllReservationIds(200000);
|
|
532
|
+
let purged = 0;
|
|
533
|
+
const ts = Date.now();
|
|
534
|
+
|
|
535
|
+
for (const rid of ids) {
|
|
536
|
+
const r = await dbLayer.getReservation(rid);
|
|
537
|
+
if (!r) continue;
|
|
538
|
+
if (String(r.status) !== 'paid') continue;
|
|
539
|
+
// Already purged from accounting
|
|
540
|
+
if (r.accPurgedAt) continue;
|
|
541
|
+
const start = parseInt(r.start, 10);
|
|
542
|
+
if (!Number.isFinite(start)) continue;
|
|
543
|
+
if (start < minTs || start >= maxTs) continue;
|
|
544
|
+
|
|
545
|
+
r.accPurgedAt = ts;
|
|
546
|
+
await dbLayer.saveReservation(r);
|
|
547
|
+
purged++;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return res.json({ ok: true, purged });
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
module.exports = admin;
|