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/CHANGELOG.md +45 -0
- package/lib/admin.js +562 -0
- package/lib/api.js +916 -0
- package/lib/controllers.js +11 -0
- package/lib/db.js +110 -0
- package/lib/discord.js +163 -0
- package/lib/helloasso.js +352 -0
- package/lib/helloassoWebhook.js +390 -0
- package/lib/scheduler.js +182 -0
- package/lib/widgets.js +177 -0
- package/library.js +157 -0
- package/package.json +14 -0
- package/plugin.json +43 -0
- package/public/admin.js +812 -0
- package/public/client.js +1801 -0
- package/templates/admin/plugins/calendar-onekite.tpl +215 -0
- package/templates/calendar-onekite.tpl +51 -0
- package/templates/emails/calendar-onekite_approved.tpl +40 -0
- package/templates/emails/calendar-onekite_expired.tpl +11 -0
- package/templates/emails/calendar-onekite_paid.tpl +15 -0
- package/templates/emails/calendar-onekite_pending.tpl +15 -0
- package/templates/emails/calendar-onekite_refused.tpl +15 -0
- package/templates/emails/calendar-onekite_reminder.tpl +20 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
## 1.1.0
|
|
2
|
+
|
|
3
|
+
## 1.3.4
|
|
4
|
+
- Fix: HelloAsso OAuth token rate-limit (429/1015) by caching token, de-duplicating requests, and adding backoff retries (no extra logs).
|
|
5
|
+
- Discord: add ⏳/💳 icons in embed titles.
|
|
6
|
+
### Perf / prod (NodeBB v4)
|
|
7
|
+
- FullCalendar : passage au CDN **@latest** et utilisation de `main.min.css` (supprime l’erreur 404 `index.global.min.css`).
|
|
8
|
+
- API `events` : payload allégé (les détails sont chargés à la demande), tri stable.
|
|
9
|
+
- Cache intelligent : support **ETag** côté serveur + requêtes conditionnelles côté client (réduit les transferts lors des refetch).
|
|
10
|
+
- Prefetch : préchargement du mois précédent/suivant (si l’utilisateur navigue, la vue est instantanée).
|
|
11
|
+
- Robustesse : anti double-refetch (debounce sur les updates socket) et annulation des fetch concurrents.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## 1.0.1.1
|
|
15
|
+
- ACP (NodeBB v4) : empêche l’affichage multiple des popups de succès lors de l’enregistrement (déduplication des alerts).
|
|
16
|
+
|
|
17
|
+
## 1.0.1
|
|
18
|
+
- Correctif : après validation d'une réservation depuis l’ACP, le lien HelloAsso (paymentUrl) est maintenant bien enregistré et transmis au calendrier (bouton **Payer maintenant** visible dans la modale).
|
|
19
|
+
|
|
20
|
+
# Changelog
|
|
21
|
+
|
|
22
|
+
## 1.3.2
|
|
23
|
+
|
|
24
|
+
- Discord : notifications envoyées en **embed** (plus lisible) pour les demandes et paiements.
|
|
25
|
+
- Discord : contenu texte désactivé (embed only) pour éviter le bruit.
|
|
26
|
+
|
|
27
|
+
## 1.0.0
|
|
28
|
+
|
|
29
|
+
### ACP
|
|
30
|
+
- Locations en attente : validation/refus avec les **mêmes modales** que côté calendrier (adresse + carte + notes + heure / raison de refus) et **suppression de la ligne** après action.
|
|
31
|
+
- Réglages : correction de l’enregistrement des **groupes** dans l’onglet Évènements (form + payload), et correction du décalage d’affichage des onglets.
|
|
32
|
+
|
|
33
|
+
### Calendrier (front)
|
|
34
|
+
- Évènements : suppression de l’icône « pin-it ».
|
|
35
|
+
- Évènements : alignement de l’horloge identique aux icônes des réservations (rendu sans colonne de temps FullCalendar).
|
|
36
|
+
- Création d’évènement : clic sur un jour = valeur par défaut **07:00 → 07:00** (gestion robuste de l’`end` exclusif FullCalendar).
|
|
37
|
+
- Modales : correction d’un cas où, après annulation (ESC/clic extérieur), les modales ne réapparaissaient plus.
|
|
38
|
+
- **Nouveau** : conservation du dernier mode sélectionné (**Location** / **Évènement**) même après création/annulation/refetch (persisté par utilisateur via `localStorage`).
|
|
39
|
+
|
|
40
|
+
### Emails
|
|
41
|
+
- Template relance : ajout du mĂŞme bloc de paiement que dans pending (bouton + lien de secours).
|
|
42
|
+
|
|
43
|
+
### Maintenance
|
|
44
|
+
- Nettoyage des CSS/handlers devenus inutiles suite aux corrections d’alignement.
|
|
45
|
+
- Harmonisation des versions (`package.json` + `plugin.json`) en `1.0.0`.
|
package/lib/admin.js
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
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
|
+
async function sendEmail(template, toEmail, subject, data) {
|
|
22
|
+
// Prefer sending by uid (NodeBB core expects uid in various places)
|
|
23
|
+
const uid = data && Number.isInteger(data.uid) ? data.uid : null;
|
|
24
|
+
if (!toEmail && !uid) return;
|
|
25
|
+
|
|
26
|
+
const settings = await meta.settings.get('calendar-onekite').catch(() => ({}));
|
|
27
|
+
const lang = (settings && settings.defaultLang) || (meta && meta.config && meta.config.defaultLang) || 'fr';
|
|
28
|
+
const params = Object.assign({}, data || {}, subject ? { subject } : {});
|
|
29
|
+
|
|
30
|
+
// If we have a uid, use the native uid-based sender first.
|
|
31
|
+
try {
|
|
32
|
+
if (uid && typeof emailer.send === 'function') {
|
|
33
|
+
// NodeBB: send(template, uid, params)
|
|
34
|
+
if (emailer.send.length >= 3) {
|
|
35
|
+
await emailer.send(template, uid, params);
|
|
36
|
+
} else {
|
|
37
|
+
await emailer.send(template, uid, params);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.warn('[calendar-onekite] Failed to send email', {
|
|
43
|
+
template,
|
|
44
|
+
toEmail,
|
|
45
|
+
err: err && err.message ? err.message : String(err),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
if (typeof emailer.sendToEmail === 'function') {
|
|
51
|
+
// NodeBB: sendToEmail(template, email, language, params)
|
|
52
|
+
if (emailer.sendToEmail.length >= 4) {
|
|
53
|
+
await emailer.sendToEmail(template, toEmail, lang, params);
|
|
54
|
+
} else {
|
|
55
|
+
// Older signature: sendToEmail(template, email, params)
|
|
56
|
+
await emailer.sendToEmail(template, toEmail, params);
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.warn('[calendar-onekite] Failed to send email', {
|
|
62
|
+
template,
|
|
63
|
+
toEmail,
|
|
64
|
+
err: err && err.message ? err.message : String(err),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeCallbackUrl(configured, meta) {
|
|
70
|
+
const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
71
|
+
let url = (configured || '').trim();
|
|
72
|
+
if (!url) {
|
|
73
|
+
url = base ? `${base}/helloasso` : '';
|
|
74
|
+
}
|
|
75
|
+
if (url && url.startsWith('/') && base) {
|
|
76
|
+
url = `${base}${url}`;
|
|
77
|
+
}
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeReturnUrl(meta) {
|
|
82
|
+
const base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
|
|
83
|
+
const b = String(base || '').trim().replace(/\/$/, '');
|
|
84
|
+
if (!b) return '';
|
|
85
|
+
return `${b}/calendar`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
const dbLayer = require('./db');
|
|
90
|
+
const helloasso = require('./helloasso');
|
|
91
|
+
|
|
92
|
+
const ADMIN_PRIV = 'admin:settings';
|
|
93
|
+
|
|
94
|
+
const admin = {};
|
|
95
|
+
|
|
96
|
+
admin.renderAdmin = async function (req, res) {
|
|
97
|
+
res.render('admin/plugins/calendar-onekite', {
|
|
98
|
+
title: 'Calendar OneKite',
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
admin.getSettings = async function (req, res) {
|
|
103
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
104
|
+
res.json(settings || {});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
admin.saveSettings = async function (req, res) {
|
|
108
|
+
await meta.settings.set('calendar-onekite', req.body || {});
|
|
109
|
+
res.json({ ok: true });
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
admin.listPending = async function (req, res) {
|
|
113
|
+
const ids = await dbLayer.listAllReservationIds(5000);
|
|
114
|
+
// Batch fetch to avoid N DB round-trips.
|
|
115
|
+
const rows = await dbLayer.getReservations(ids);
|
|
116
|
+
const pending = (rows || []).filter(r => r && r.status === 'pending');
|
|
117
|
+
pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
118
|
+
res.json(pending);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
admin.approveReservation = async function (req, res) {
|
|
122
|
+
const rid = req.params.rid;
|
|
123
|
+
const r = await dbLayer.getReservation(rid);
|
|
124
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
125
|
+
|
|
126
|
+
r.status = 'awaiting_payment';
|
|
127
|
+
r.pickupAddress = String((req.body && (req.body.pickupAddress || req.body.adminNote || req.body.note)) || '').trim();
|
|
128
|
+
r.notes = String((req.body && req.body.notes) || '').trim();
|
|
129
|
+
r.pickupTime = String((req.body && (req.body.pickupTime || req.body.pickup)) || '').trim();
|
|
130
|
+
r.pickupLat = String((req.body && req.body.pickupLat) || '').trim();
|
|
131
|
+
r.pickupLon = String((req.body && req.body.pickupLon) || '').trim();
|
|
132
|
+
r.approvedAt = Date.now();
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const approver = await user.getUserFields(req.uid, ['username']);
|
|
136
|
+
r.approvedBy = req.uid;
|
|
137
|
+
r.approvedByUsername = approver && approver.username ? approver.username : '';
|
|
138
|
+
} catch (e) {
|
|
139
|
+
r.approvedBy = req.uid;
|
|
140
|
+
r.approvedByUsername = '';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Create HelloAsso payment link if configured
|
|
144
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
145
|
+
const env = settings.helloassoEnv || 'prod';
|
|
146
|
+
const token = await helloasso.getAccessToken({
|
|
147
|
+
env,
|
|
148
|
+
clientId: settings.helloassoClientId,
|
|
149
|
+
clientSecret: settings.helloassoClientSecret,
|
|
150
|
+
});
|
|
151
|
+
if (!token) {
|
|
152
|
+
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let paymentUrl = null;
|
|
156
|
+
if (token) {
|
|
157
|
+
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
158
|
+
// r.total is stored as an estimated total in euros; HelloAsso expects cents.
|
|
159
|
+
const totalAmount = Math.max(0, Math.round((Number(r.total) || 0) * 100));
|
|
160
|
+
const base = forumBaseUrl();
|
|
161
|
+
const returnUrl = base ? `${base}/calendar` : '';
|
|
162
|
+
const webhookUrl = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
|
|
163
|
+
const year = new Date(Number(r.start)).getFullYear();
|
|
164
|
+
const intent = await helloasso.createCheckoutIntent({
|
|
165
|
+
env,
|
|
166
|
+
token,
|
|
167
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
168
|
+
formType: settings.helloassoFormType,
|
|
169
|
+
// Form slug is derived from the year
|
|
170
|
+
formSlug: `locations-materiel-${year}`,
|
|
171
|
+
totalAmount,
|
|
172
|
+
payerEmail: requester && requester.email,
|
|
173
|
+
// User return/back/error URLs must be real pages; webhook uses the plugin endpoint.
|
|
174
|
+
callbackUrl: returnUrl,
|
|
175
|
+
webhookUrl: webhookUrl,
|
|
176
|
+
itemName: 'Réservation matériel OneKite',
|
|
177
|
+
containsDonation: false,
|
|
178
|
+
metadata: { reservationId: String(rid) },
|
|
179
|
+
});
|
|
180
|
+
paymentUrl = intent && (intent.paymentUrl || intent.redirectUrl)
|
|
181
|
+
? (intent.paymentUrl || intent.redirectUrl)
|
|
182
|
+
: (typeof intent === 'string' ? intent : null);
|
|
183
|
+
if (intent && intent.checkoutIntentId) {
|
|
184
|
+
r.checkoutIntentId = intent.checkoutIntentId;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (paymentUrl) {
|
|
189
|
+
r.paymentUrl = paymentUrl;
|
|
190
|
+
} else {
|
|
191
|
+
console.warn('[calendar-onekite] HelloAsso payment link not created (approve ACP)', { rid });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await dbLayer.saveReservation(r);
|
|
195
|
+
|
|
196
|
+
// Email requester
|
|
197
|
+
try {
|
|
198
|
+
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
199
|
+
if (requester && requester.email) {
|
|
200
|
+
const latNum = Number(r.pickupLat);
|
|
201
|
+
const lonNum = Number(r.pickupLon);
|
|
202
|
+
const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
|
|
203
|
+
? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
|
|
204
|
+
: '';
|
|
205
|
+
await sendEmail('calendar-onekite_approved', requester.email, 'Location matériel - Réservation validée', {
|
|
206
|
+
uid: parseInt(r.uid, 10),
|
|
207
|
+
username: requester.username,
|
|
208
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
209
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
210
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
211
|
+
paymentUrl: paymentUrl || '',
|
|
212
|
+
pickupAddress: r.pickupAddress || '',
|
|
213
|
+
notes: r.notes || '',
|
|
214
|
+
pickupTime: r.pickupTime || '',
|
|
215
|
+
pickupLat: r.pickupLat || '',
|
|
216
|
+
pickupLon: r.pickupLon || '',
|
|
217
|
+
mapUrl,
|
|
218
|
+
validatedBy: r.approvedByUsername || '',
|
|
219
|
+
validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
} catch (e) {}
|
|
223
|
+
|
|
224
|
+
res.json({ ok: true, paymentUrl: paymentUrl || null });
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
admin.refuseReservation = async function (req, res) {
|
|
228
|
+
const rid = req.params.rid;
|
|
229
|
+
const r = await dbLayer.getReservation(rid);
|
|
230
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
231
|
+
|
|
232
|
+
r.status = 'refused';
|
|
233
|
+
r.refusedAt = Date.now();
|
|
234
|
+
r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
|
|
235
|
+
await dbLayer.saveReservation(r);
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const requester = await user.getUserFields(r.uid, ['username', 'email']);
|
|
239
|
+
if (requester && requester.email) {
|
|
240
|
+
await sendEmail('calendar-onekite_refused', requester.email, 'Location matériel - Réservation refusée', {
|
|
241
|
+
uid: parseInt(r.uid, 10),
|
|
242
|
+
username: requester.username,
|
|
243
|
+
itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
|
|
244
|
+
itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
|
|
245
|
+
dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
|
|
246
|
+
start: formatFR(r.start),
|
|
247
|
+
end: formatFR(r.end),
|
|
248
|
+
refusedReason: r.refusedReason || '',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {}
|
|
252
|
+
|
|
253
|
+
res.json({ ok: true });
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
admin.purgeByYear = async function (req, res) {
|
|
257
|
+
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
258
|
+
if (!/^\d{4}$/.test(year)) {
|
|
259
|
+
return res.status(400).json({ error: 'invalid-year' });
|
|
260
|
+
}
|
|
261
|
+
const y = parseInt(year, 10);
|
|
262
|
+
const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
|
|
263
|
+
const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
|
|
264
|
+
|
|
265
|
+
const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
|
|
266
|
+
let removed = 0;
|
|
267
|
+
let archivedForAccounting = 0;
|
|
268
|
+
const ts = Date.now();
|
|
269
|
+
for (const rid of ids) {
|
|
270
|
+
const r = await dbLayer.getReservation(rid);
|
|
271
|
+
if (!r) continue;
|
|
272
|
+
|
|
273
|
+
// Keep paid reservations for accounting; just hide them from the calendar.
|
|
274
|
+
if (String(r.status) === 'paid') {
|
|
275
|
+
if (!r.calendarPurgedAt) {
|
|
276
|
+
r.calendarPurgedAt = ts;
|
|
277
|
+
await dbLayer.saveReservation(r);
|
|
278
|
+
}
|
|
279
|
+
archivedForAccounting++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await dbLayer.removeReservation(rid);
|
|
284
|
+
removed++;
|
|
285
|
+
}
|
|
286
|
+
res.json({ ok: true, removed, archivedForAccounting });
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
admin.purgeSpecialEventsByYear = async function (req, res) {
|
|
290
|
+
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
291
|
+
if (!/^\d{4}$/.test(year)) {
|
|
292
|
+
return res.status(400).json({ error: 'invalid-year' });
|
|
293
|
+
}
|
|
294
|
+
const y = parseInt(year, 10);
|
|
295
|
+
const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
|
|
296
|
+
const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
|
|
297
|
+
|
|
298
|
+
const ids = await dbLayer.listSpecialIdsByStartRange(startTs, endTs, 100000);
|
|
299
|
+
let count = 0;
|
|
300
|
+
for (const eid of ids) {
|
|
301
|
+
await dbLayer.removeSpecialEvent(eid);
|
|
302
|
+
count++;
|
|
303
|
+
}
|
|
304
|
+
return res.json({ ok: true, removed: count });
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Debug endpoint to validate HelloAsso connectivity and item loading
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
admin.debugHelloAsso = async function (req, res) {
|
|
311
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
312
|
+
const env = (settings && settings.helloassoEnv) || 'prod';
|
|
313
|
+
|
|
314
|
+
// Never expose secrets in debug output
|
|
315
|
+
const safeSettings = {
|
|
316
|
+
helloassoEnv: env,
|
|
317
|
+
helloassoClientId: settings && settings.helloassoClientId ? String(settings.helloassoClientId) : '',
|
|
318
|
+
helloassoClientSecret: settings && settings.helloassoClientSecret ? '***' : '',
|
|
319
|
+
helloassoOrganizationSlug: settings && settings.helloassoOrganizationSlug ? String(settings.helloassoOrganizationSlug) : '',
|
|
320
|
+
helloassoFormType: settings && settings.helloassoFormType ? String(settings.helloassoFormType) : '',
|
|
321
|
+
helloassoFormSlug: settings && settings.helloassoFormSlug ? String(settings.helloassoFormSlug) : '',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const out = {
|
|
325
|
+
ok: true,
|
|
326
|
+
settings: safeSettings,
|
|
327
|
+
token: { ok: false },
|
|
328
|
+
// Catalog = what you actually want for a shop (available products/material)
|
|
329
|
+
catalog: { ok: false, count: 0, sample: [], keys: [] },
|
|
330
|
+
// Sold items = items present in orders (can be 0 if no sales yet)
|
|
331
|
+
soldItems: { ok: false, count: 0, sample: [] },
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const token = await helloasso.getAccessToken({
|
|
336
|
+
env,
|
|
337
|
+
clientId: settings.helloassoClientId,
|
|
338
|
+
clientSecret: settings.helloassoClientSecret,
|
|
339
|
+
});
|
|
340
|
+
if (!token) {
|
|
341
|
+
out.token = { ok: false, error: 'token-null' };
|
|
342
|
+
return res.json(out);
|
|
343
|
+
}
|
|
344
|
+
out.token = { ok: true };
|
|
345
|
+
|
|
346
|
+
// Catalog items (via /public)
|
|
347
|
+
try {
|
|
348
|
+
const y = new Date().getFullYear();
|
|
349
|
+
const { publicForm, items } = await helloasso.listCatalogItems({
|
|
350
|
+
env,
|
|
351
|
+
token,
|
|
352
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
353
|
+
formType: settings.helloassoFormType,
|
|
354
|
+
formSlug: `locations-materiel-${y}`,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const arr = Array.isArray(items) ? items : [];
|
|
358
|
+
out.catalog.ok = true;
|
|
359
|
+
out.catalog.count = arr.length;
|
|
360
|
+
out.catalog.keys = publicForm && typeof publicForm === 'object' ? Object.keys(publicForm) : [];
|
|
361
|
+
out.catalog.sample = arr.slice(0, 10).map((it) => ({
|
|
362
|
+
id: it.id,
|
|
363
|
+
name: it.name,
|
|
364
|
+
price: it.price ?? null,
|
|
365
|
+
}));
|
|
366
|
+
} catch (e) {
|
|
367
|
+
out.catalog = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [], keys: [] };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Sold items
|
|
371
|
+
try {
|
|
372
|
+
const y2 = new Date().getFullYear();
|
|
373
|
+
const items = await helloasso.listItems({
|
|
374
|
+
env,
|
|
375
|
+
token,
|
|
376
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
377
|
+
formType: settings.helloassoFormType,
|
|
378
|
+
formSlug: `locations-materiel-${y2}`,
|
|
379
|
+
});
|
|
380
|
+
const arr = Array.isArray(items) ? items : [];
|
|
381
|
+
out.soldItems.ok = true;
|
|
382
|
+
out.soldItems.count = arr.length;
|
|
383
|
+
out.soldItems.sample = arr.slice(0, 10).map((it) => ({
|
|
384
|
+
id: it.id || it.itemId || it.reference || it.name,
|
|
385
|
+
name: it.name || it.label || it.itemName,
|
|
386
|
+
price: it.price || it.amount || it.unitPrice || null,
|
|
387
|
+
}));
|
|
388
|
+
} catch (e) {
|
|
389
|
+
out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return res.json(out);
|
|
393
|
+
} catch (e) {
|
|
394
|
+
out.ok = false;
|
|
395
|
+
out.token = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
396
|
+
return res.json(out);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Accounting endpoint: aggregates paid reservations so you can contabilize what was rented.
|
|
401
|
+
// Query params:
|
|
402
|
+
// from=YYYY-MM-DD (inclusive, based on reservation.start)
|
|
403
|
+
// to=YYYY-MM-DD (exclusive, based on reservation.start)
|
|
404
|
+
admin.getAccounting = async function (req, res) {
|
|
405
|
+
const qFrom = String((req.query && req.query.from) || '').trim();
|
|
406
|
+
const qTo = String((req.query && req.query.to) || '').trim();
|
|
407
|
+
const parseDay = (s) => {
|
|
408
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
409
|
+
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
|
|
410
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
411
|
+
const ts = dt.getTime();
|
|
412
|
+
return Number.isFinite(ts) ? ts : null;
|
|
413
|
+
};
|
|
414
|
+
const fromTs = parseDay(qFrom);
|
|
415
|
+
const toTs = parseDay(qTo);
|
|
416
|
+
|
|
417
|
+
// Default: last 12 months (UTC)
|
|
418
|
+
const now = new Date();
|
|
419
|
+
const defaultTo = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
420
|
+
const defaultFrom = Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1);
|
|
421
|
+
const minTs = fromTs ?? defaultFrom;
|
|
422
|
+
const maxTs = toTs ?? defaultTo;
|
|
423
|
+
|
|
424
|
+
const ids = await dbLayer.listAllReservationIds(100000);
|
|
425
|
+
const rows = [];
|
|
426
|
+
const byItem = new Map();
|
|
427
|
+
|
|
428
|
+
for (const rid of ids) {
|
|
429
|
+
const r = await dbLayer.getReservation(rid);
|
|
430
|
+
if (!r) continue;
|
|
431
|
+
if (String(r.status) !== 'paid') continue;
|
|
432
|
+
if (r.accPurgedAt) continue;
|
|
433
|
+
const start = parseInt(r.start, 10);
|
|
434
|
+
if (!Number.isFinite(start)) continue;
|
|
435
|
+
if (start < minTs || start >= maxTs) continue;
|
|
436
|
+
|
|
437
|
+
const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
|
|
438
|
+
? r.itemNames
|
|
439
|
+
: (r.itemName ? [r.itemName] : []);
|
|
440
|
+
|
|
441
|
+
const total = Number(r.total) || 0;
|
|
442
|
+
const startDate = formatFR(r.start);
|
|
443
|
+
const endDate = formatFR(r.end);
|
|
444
|
+
|
|
445
|
+
rows.push({
|
|
446
|
+
rid: r.rid,
|
|
447
|
+
uid: r.uid,
|
|
448
|
+
username: r.username || '',
|
|
449
|
+
start: r.start,
|
|
450
|
+
end: r.end,
|
|
451
|
+
startDate,
|
|
452
|
+
endDate,
|
|
453
|
+
items: itemNames,
|
|
454
|
+
total,
|
|
455
|
+
paidAt: r.paidAt || '',
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
for (const name of itemNames) {
|
|
459
|
+
const key = String(name || '').trim();
|
|
460
|
+
if (!key) continue;
|
|
461
|
+
const cur = byItem.get(key) || { item: key, count: 0, total: 0 };
|
|
462
|
+
cur.count += 1;
|
|
463
|
+
cur.total += total;
|
|
464
|
+
byItem.set(key, cur);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const summary = Array.from(byItem.values()).sort((a, b) => b.count - a.count);
|
|
469
|
+
rows.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
470
|
+
|
|
471
|
+
return res.json({
|
|
472
|
+
ok: true,
|
|
473
|
+
from: new Date(minTs).toISOString().slice(0, 10),
|
|
474
|
+
to: new Date(maxTs).toISOString().slice(0, 10),
|
|
475
|
+
summary,
|
|
476
|
+
rows,
|
|
477
|
+
});
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
admin.exportAccountingCsv = async function (req, res) {
|
|
481
|
+
// Reuse the same logic and emit a CSV.
|
|
482
|
+
const fakeRes = { json: (x) => x };
|
|
483
|
+
const data = await admin.getAccounting(req, fakeRes);
|
|
484
|
+
// If getAccounting returned via res.json, data is undefined; rebuild by calling logic directly.
|
|
485
|
+
// Easiest: call getAccounting's internals by fetching the endpoint logic via HTTP is not possible here.
|
|
486
|
+
// So we re-run getAccounting but capture output by monkeypatching.
|
|
487
|
+
let payload;
|
|
488
|
+
await admin.getAccounting(req, { json: (x) => { payload = x; return x; } });
|
|
489
|
+
if (!payload || !payload.ok) {
|
|
490
|
+
return res.status(500).send('error');
|
|
491
|
+
}
|
|
492
|
+
const escape = (v) => {
|
|
493
|
+
const s = String(v ?? '');
|
|
494
|
+
if (/[\n\r,\"]/g.test(s)) {
|
|
495
|
+
return '"' + s.replace(/"/g, '""') + '"';
|
|
496
|
+
}
|
|
497
|
+
return s;
|
|
498
|
+
};
|
|
499
|
+
const lines = [];
|
|
500
|
+
lines.push(['rid', 'username', 'uid', 'start', 'end', 'items', 'total', 'paidAt'].map(escape).join(','));
|
|
501
|
+
for (const r of payload.rows || []) {
|
|
502
|
+
lines.push([
|
|
503
|
+
r.rid,
|
|
504
|
+
r.username,
|
|
505
|
+
r.uid,
|
|
506
|
+
r.startDate,
|
|
507
|
+
r.endDate,
|
|
508
|
+
(Array.isArray(r.items) ? r.items.join(' | ') : ''),
|
|
509
|
+
(Number(r.total) || 0).toFixed(2),
|
|
510
|
+
r.paidAt ? new Date(parseInt(r.paidAt, 10)).toISOString() : '',
|
|
511
|
+
].map(escape).join(','));
|
|
512
|
+
}
|
|
513
|
+
const csv = lines.join('\n');
|
|
514
|
+
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
515
|
+
res.setHeader('Content-Disposition', 'attachment; filename="calendar-onekite-accounting.csv"');
|
|
516
|
+
return res.send(csv);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
admin.purgeAccounting = async function (req, res) {
|
|
521
|
+
const qFrom = String((req.query && req.query.from) || '').trim();
|
|
522
|
+
const qTo = String((req.query && req.query.to) || '').trim();
|
|
523
|
+
const parseDay = (s) => {
|
|
524
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|
525
|
+
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
|
|
526
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
527
|
+
const ts = dt.getTime();
|
|
528
|
+
return Number.isFinite(ts) ? ts : null;
|
|
529
|
+
};
|
|
530
|
+
const fromTs = parseDay(qFrom);
|
|
531
|
+
const toTs = parseDay(qTo);
|
|
532
|
+
|
|
533
|
+
const now = new Date();
|
|
534
|
+
const defaultTo = Date.UTC(now.getUTCFullYear() + 100, 0, 1); // far future
|
|
535
|
+
const defaultFrom = Date.UTC(1970, 0, 1);
|
|
536
|
+
const minTs = fromTs ?? defaultFrom;
|
|
537
|
+
const maxTs = toTs ?? defaultTo;
|
|
538
|
+
|
|
539
|
+
const ids = await dbLayer.listAllReservationIds(200000);
|
|
540
|
+
let purged = 0;
|
|
541
|
+
const ts = Date.now();
|
|
542
|
+
|
|
543
|
+
for (const rid of ids) {
|
|
544
|
+
const r = await dbLayer.getReservation(rid);
|
|
545
|
+
if (!r) continue;
|
|
546
|
+
if (String(r.status) !== 'paid') continue;
|
|
547
|
+
// Already purged from accounting
|
|
548
|
+
if (r.accPurgedAt) continue;
|
|
549
|
+
const start = parseInt(r.start, 10);
|
|
550
|
+
if (!Number.isFinite(start)) continue;
|
|
551
|
+
if (start < minTs || start >= maxTs) continue;
|
|
552
|
+
|
|
553
|
+
r.accPurgedAt = ts;
|
|
554
|
+
await dbLayer.saveReservation(r);
|
|
555
|
+
purged++;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return res.json({ ok: true, purged });
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
module.exports = admin;
|