nodebb-plugin-calendar-onekite 11.2.13 → 11.2.14
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/package.json +1 -1
- package/pkg/package/lib/admin.js +545 -0
- package/pkg/package/lib/api.js +698 -0
- package/pkg/package/lib/controllers.js +11 -0
- package/pkg/package/lib/db.js +72 -0
- package/pkg/package/lib/helloasso.js +278 -0
- package/pkg/package/lib/helloassoWebhook.js +375 -0
- package/pkg/package/lib/scheduler.js +168 -0
- package/pkg/package/package.json +14 -0
- package/pkg/package/plugin.json +35 -0
- package/pkg/package/public/admin.js +786 -0
- package/pkg/package/public/client.js +1517 -0
- package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +194 -0
- package/pkg/package/templates/calendar-onekite.tpl +46 -0
- package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -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 +15 -0
- package/public/admin.js +156 -185
- package/templates/admin/plugins/calendar-onekite.tpl +8 -8
- /package/{library.js → pkg/package/library.js} +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
async function getReservation(rid) {
|
|
14
|
+
return await db.getObject(KEY_OBJ(rid));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function saveReservation(resv) {
|
|
18
|
+
await db.setObject(KEY_OBJ(resv.rid), resv);
|
|
19
|
+
// score = start timestamp
|
|
20
|
+
await db.sortedSetAdd(KEY_ZSET, resv.start, resv.rid);
|
|
21
|
+
// Optional mapping to help reconcile HelloAsso webhooks that don't include metadata
|
|
22
|
+
if (resv.checkoutIntentId) {
|
|
23
|
+
try {
|
|
24
|
+
await db.setObjectField(KEY_CHECKOUT_INTENT_TO_RID, String(resv.checkoutIntentId), String(resv.rid));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
// ignore
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function removeReservation(rid) {
|
|
32
|
+
await db.sortedSetRemove(KEY_ZSET, rid);
|
|
33
|
+
await db.delete(KEY_OBJ(rid));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function listReservationIdsByStartRange(startTs, endTs, limit = 1000) {
|
|
37
|
+
// NodeBB db method name is getSortedSetRangeByScore(set, start, stop, min, max)
|
|
38
|
+
// (start/stop are index offsets, min/max are score range)
|
|
39
|
+
const start = 0;
|
|
40
|
+
const stop = Math.max(0, (parseInt(limit, 10) || 1000) - 1);
|
|
41
|
+
return await db.getSortedSetRangeByScore(KEY_ZSET, start, stop, startTs, endTs);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function listAllReservationIds(limit = 5000) {
|
|
45
|
+
return await db.getSortedSetRange(KEY_ZSET, 0, limit - 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
KEY_ZSET,
|
|
50
|
+
KEY_SPECIAL_ZSET,
|
|
51
|
+
KEY_CHECKOUT_INTENT_TO_RID,
|
|
52
|
+
getReservation,
|
|
53
|
+
saveReservation,
|
|
54
|
+
removeReservation,
|
|
55
|
+
// Special events
|
|
56
|
+
getSpecialEvent: async (eid) => await db.getObject(KEY_SPECIAL_OBJ(eid)),
|
|
57
|
+
saveSpecialEvent: async (ev) => {
|
|
58
|
+
await db.setObject(KEY_SPECIAL_OBJ(ev.eid), ev);
|
|
59
|
+
await db.sortedSetAdd(KEY_SPECIAL_ZSET, ev.start, ev.eid);
|
|
60
|
+
},
|
|
61
|
+
removeSpecialEvent: async (eid) => {
|
|
62
|
+
await db.sortedSetRemove(KEY_SPECIAL_ZSET, eid);
|
|
63
|
+
await db.delete(KEY_SPECIAL_OBJ(eid));
|
|
64
|
+
},
|
|
65
|
+
listSpecialIdsByStartRange: async (startTs, endTs, limit = 2000) => {
|
|
66
|
+
const start = 0;
|
|
67
|
+
const stop = Math.max(0, (parseInt(limit, 10) || 2000) - 1);
|
|
68
|
+
return await db.getSortedSetRangeByScore(KEY_SPECIAL_ZSET, start, stop, startTs, endTs);
|
|
69
|
+
},
|
|
70
|
+
listReservationIdsByStartRange,
|
|
71
|
+
listAllReservationIds,
|
|
72
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
|
|
5
|
+
function requestJson(method, url, headers = {}, bodyObj = null) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const u = new URL(url);
|
|
8
|
+
const body = bodyObj ? JSON.stringify(bodyObj) : null;
|
|
9
|
+
|
|
10
|
+
const req = https.request(
|
|
11
|
+
{
|
|
12
|
+
method,
|
|
13
|
+
hostname: u.hostname,
|
|
14
|
+
port: u.port || 443,
|
|
15
|
+
path: u.pathname + u.search,
|
|
16
|
+
headers: {
|
|
17
|
+
Accept: 'application/json',
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
...(body ? { 'Content-Length': Buffer.byteLength(body) } : {}),
|
|
20
|
+
...headers,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
(resp) => {
|
|
24
|
+
let data = '';
|
|
25
|
+
resp.setEncoding('utf8');
|
|
26
|
+
resp.on('data', (chunk) => { data += chunk; });
|
|
27
|
+
resp.on('end', () => {
|
|
28
|
+
const status = resp.statusCode || 0;
|
|
29
|
+
|
|
30
|
+
// Try JSON first, but never throw from here (avoid crashing NodeBB).
|
|
31
|
+
try {
|
|
32
|
+
const json = data ? JSON.parse(data) : null;
|
|
33
|
+
return resolve({ status, json });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
// Non-JSON response (HTML/proxy error/etc.)
|
|
36
|
+
const snippet = String(data || '').slice(0, 500);
|
|
37
|
+
return resolve({ status, json: null, raw: snippet });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
req.on('error', (err) => {
|
|
44
|
+
resolve({ status: 0, json: null, error: err && err.message ? err.message : String(err) });
|
|
45
|
+
});
|
|
46
|
+
if (body) req.write(body);
|
|
47
|
+
req.end();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function baseUrl(env) {
|
|
52
|
+
return env === 'sandbox'
|
|
53
|
+
? 'https://api.helloasso-sandbox.com'
|
|
54
|
+
: 'https://api.helloasso.com';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function getAccessToken({ env, clientId, clientSecret }) {
|
|
58
|
+
if (!clientId || !clientSecret) return null;
|
|
59
|
+
const url = `${baseUrl(env)}/oauth2/token`;
|
|
60
|
+
const body = new URLSearchParams({
|
|
61
|
+
grant_type: 'client_credentials',
|
|
62
|
+
client_id: clientId,
|
|
63
|
+
client_secret: clientSecret,
|
|
64
|
+
}).toString();
|
|
65
|
+
|
|
66
|
+
// Be very defensive here: HelloAsso may return HTML (proxy/CDN error pages)
|
|
67
|
+
// or empty bodies on failure. This function must never crash the process.
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const u = new URL(url);
|
|
70
|
+
const req = https.request(
|
|
71
|
+
{
|
|
72
|
+
method: 'POST',
|
|
73
|
+
hostname: u.hostname,
|
|
74
|
+
port: u.port || 443,
|
|
75
|
+
path: u.pathname + u.search,
|
|
76
|
+
headers: {
|
|
77
|
+
'Accept': 'application/json',
|
|
78
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
79
|
+
'Content-Length': Buffer.byteLength(body),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
(res) => {
|
|
83
|
+
let data = '';
|
|
84
|
+
res.setEncoding('utf8');
|
|
85
|
+
res.on('data', (chunk) => (data += chunk));
|
|
86
|
+
res.on('end', () => {
|
|
87
|
+
const status = res.statusCode || 0;
|
|
88
|
+
const snippet = String(data || '').slice(0, 500);
|
|
89
|
+
|
|
90
|
+
// If non-2xx, still try to parse JSON to log a meaningful error.
|
|
91
|
+
if (status < 200 || status >= 300) {
|
|
92
|
+
let parsed = null;
|
|
93
|
+
try {
|
|
94
|
+
parsed = data ? JSON.parse(data) : null;
|
|
95
|
+
} catch (e) {
|
|
96
|
+
// ignore
|
|
97
|
+
}
|
|
98
|
+
console.warn('[calendar-onekite] HelloAsso token request failed', {
|
|
99
|
+
status,
|
|
100
|
+
body: parsed || snippet,
|
|
101
|
+
});
|
|
102
|
+
return resolve(null);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2xx: must be JSON with access_token
|
|
106
|
+
try {
|
|
107
|
+
const json = JSON.parse(data || '{}');
|
|
108
|
+
const token = json && json.access_token ? String(json.access_token) : null;
|
|
109
|
+
if (!token) {
|
|
110
|
+
console.warn('[calendar-onekite] HelloAsso token missing in response', { status, body: json || snippet });
|
|
111
|
+
}
|
|
112
|
+
return resolve(token);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
console.warn('[calendar-onekite] HelloAsso token parse error', { status, body: snippet });
|
|
115
|
+
return resolve(null);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
req.on('error', (err) => {
|
|
121
|
+
console.warn('[calendar-onekite] HelloAsso token network error', { message: err && err.message ? err.message : String(err) });
|
|
122
|
+
resolve(null);
|
|
123
|
+
});
|
|
124
|
+
req.write(body);
|
|
125
|
+
req.end();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function listItems({ env, token, organizationSlug, formType, formSlug }) {
|
|
130
|
+
if (!token || !organizationSlug || !formType || !formSlug) return [];
|
|
131
|
+
// This endpoint returns *sold items* (i.e., items present in orders). If your shop has
|
|
132
|
+
// no sales yet, it will legitimately return an empty list.
|
|
133
|
+
const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/items?pageIndex=1&pageSize=200`;
|
|
134
|
+
const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
|
|
135
|
+
if (status >= 200 && status < 300 && json) {
|
|
136
|
+
return json.data || json.items || [];
|
|
137
|
+
}
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function getFormPublic({ env, token, organizationSlug, formType, formSlug }) {
|
|
142
|
+
if (!token || !organizationSlug || !formType || !formSlug) return null;
|
|
143
|
+
// Public form details contains extraOptions/customFields and (for Shop) usually the catalog structure.
|
|
144
|
+
const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/public`;
|
|
145
|
+
const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
|
|
146
|
+
if (status >= 200 && status < 300) {
|
|
147
|
+
return json || null;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function extractCatalogItems(publicFormJson) {
|
|
153
|
+
if (!publicFormJson || typeof publicFormJson !== 'object') return [];
|
|
154
|
+
|
|
155
|
+
// Try a few common shapes used in HelloAsso "public" form responses.
|
|
156
|
+
const candidates = [];
|
|
157
|
+
const pushArr = (arr) => {
|
|
158
|
+
if (Array.isArray(arr)) candidates.push(...arr);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
pushArr(publicFormJson.items);
|
|
162
|
+
pushArr(publicFormJson.tiers);
|
|
163
|
+
pushArr(publicFormJson.products);
|
|
164
|
+
pushArr(publicFormJson.data);
|
|
165
|
+
if (publicFormJson.form) {
|
|
166
|
+
pushArr(publicFormJson.form.items);
|
|
167
|
+
pushArr(publicFormJson.form.tiers);
|
|
168
|
+
pushArr(publicFormJson.form.products);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Some responses nest in "campaign" or "publicForm".
|
|
172
|
+
if (publicFormJson.publicForm) {
|
|
173
|
+
pushArr(publicFormJson.publicForm.items);
|
|
174
|
+
pushArr(publicFormJson.publicForm.tiers);
|
|
175
|
+
pushArr(publicFormJson.publicForm.products);
|
|
176
|
+
}
|
|
177
|
+
if (publicFormJson.campaign) {
|
|
178
|
+
pushArr(publicFormJson.campaign.items);
|
|
179
|
+
pushArr(publicFormJson.campaign.tiers);
|
|
180
|
+
pushArr(publicFormJson.campaign.products);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Normalize to { id, name, price }
|
|
184
|
+
return candidates
|
|
185
|
+
.map((it) => {
|
|
186
|
+
if (!it || typeof it !== 'object') return null;
|
|
187
|
+
const id = it.id ?? it.itemId ?? it.tierId;
|
|
188
|
+
const name = it.name ?? it.label ?? it.title;
|
|
189
|
+
const price =
|
|
190
|
+
(it.amount && (it.amount.total ?? it.amount.value)) ??
|
|
191
|
+
it.price ??
|
|
192
|
+
it.unitPrice ??
|
|
193
|
+
it.totalAmount ??
|
|
194
|
+
it.initialAmount;
|
|
195
|
+
if (!id || !name) return null;
|
|
196
|
+
return { id, name, price: typeof price === 'number' ? price : 0, raw: it };
|
|
197
|
+
})
|
|
198
|
+
.filter(Boolean);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function listCatalogItems({ env, token, organizationSlug, formType, formSlug }) {
|
|
202
|
+
const publicForm = await getFormPublic({ env, token, organizationSlug, formType, formSlug });
|
|
203
|
+
const extracted = extractCatalogItems(publicForm);
|
|
204
|
+
return {
|
|
205
|
+
publicForm,
|
|
206
|
+
items: extracted.map(({ id, name, price, raw }) => ({ id, name, price, raw })),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function createCheckoutIntent({ env, token, organizationSlug, formType, formSlug, totalAmount, payerEmail, callbackUrl, webhookUrl, itemName, containsDonation, metadata }) {
|
|
211
|
+
if (!token || !organizationSlug) return null;
|
|
212
|
+
if (!callbackUrl || !/^https?:\/\//i.test(String(callbackUrl))) {
|
|
213
|
+
console.warn('[calendar-onekite] HelloAsso invalid return/back/error URL', { callbackUrl });
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (webhookUrl && !/^https?:\/\//i.test(String(webhookUrl))) {
|
|
217
|
+
console.warn('[calendar-onekite] HelloAsso invalid webhook URL', { webhookUrl });
|
|
218
|
+
}
|
|
219
|
+
// Checkout intents are created at organization level.
|
|
220
|
+
const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(organizationSlug)}/checkout-intents`;
|
|
221
|
+
const payload = {
|
|
222
|
+
totalAmount: totalAmount,
|
|
223
|
+
initialAmount: totalAmount,
|
|
224
|
+
itemName: itemName || 'Réservation matériel',
|
|
225
|
+
containsDonation: (typeof containsDonation === 'boolean' ? containsDonation : false),
|
|
226
|
+
payer: payerEmail ? { email: payerEmail } : undefined,
|
|
227
|
+
metadata: metadata || undefined,
|
|
228
|
+
backUrl: callbackUrl || '',
|
|
229
|
+
errorUrl: callbackUrl || '',
|
|
230
|
+
returnUrl: callbackUrl || '',
|
|
231
|
+
notificationUrl: webhookUrl || callbackUrl || '',
|
|
232
|
+
};
|
|
233
|
+
const { status, json } = await requestJson('POST', url, { Authorization: `Bearer ${token}` }, payload);
|
|
234
|
+
if (status >= 200 && status < 300 && json) {
|
|
235
|
+
return { paymentUrl: (json.redirectUrl || json.checkoutUrl || json.url || null), checkoutIntentId: (json.id || json.checkoutIntentId || null), raw: json };
|
|
236
|
+
}
|
|
237
|
+
// Log the error payload to help diagnose configuration issues (slug, env, urls, amount, etc.)
|
|
238
|
+
try {
|
|
239
|
+
// eslint-disable-next-line no-console
|
|
240
|
+
console.warn('[calendar-onekite] HelloAsso checkout-intent failed', { status, json });
|
|
241
|
+
} catch (e) { /* ignore */ }
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Fetch detailed payment information (used to recover metadata when webhook payload is incomplete)
|
|
246
|
+
// HelloAsso exposes GET /payments/{paymentId} in its v5 API.
|
|
247
|
+
async function getPaymentDetails({ env, token, paymentId }) {
|
|
248
|
+
if (!token || !paymentId) return null;
|
|
249
|
+
const url = `${baseUrl(env)}/v5/payments/${encodeURIComponent(String(paymentId))}`;
|
|
250
|
+
const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
|
|
251
|
+
if (status >= 200 && status < 300) {
|
|
252
|
+
return json || null;
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async function getCheckoutIntentDetails({ env, token, organizationSlug, checkoutIntentId }) {
|
|
260
|
+
if (!token || !organizationSlug || !checkoutIntentId) return null;
|
|
261
|
+
const url = `${baseUrl(env)}/v5/organizations/${encodeURIComponent(String(organizationSlug))}/checkout-intents/${encodeURIComponent(String(checkoutIntentId))}`;
|
|
262
|
+
const { status, json } = await requestJson('GET', url, { Authorization: `Bearer ${token}` });
|
|
263
|
+
if (status >= 200 && status < 300) {
|
|
264
|
+
return json || null;
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
getAccessToken,
|
|
271
|
+
listItems,
|
|
272
|
+
getFormPublic,
|
|
273
|
+
extractCatalogItems,
|
|
274
|
+
listCatalogItems,
|
|
275
|
+
createCheckoutIntent,
|
|
276
|
+
getPaymentDetails,
|
|
277
|
+
getCheckoutIntentDetails,
|
|
278
|
+
};
|