nodebb-plugin-calendar-onekite 11.1.25 → 11.1.27

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/helloasso.js CHANGED
@@ -3,123 +3,114 @@
3
3
  const https = require('https');
4
4
  const querystring = require('querystring');
5
5
 
6
- function requestJson(method, host, path, headers, body) {
7
- return new Promise((resolve, reject) => {
8
- const opts = { method, host, path, headers: headers || {} };
9
- const req = https.request(opts, (res) => {
10
- let data = '';
11
- res.on('data', (d) => { data += d; });
12
- res.on('end', () => {
13
- const ct = res.headers['content-type'] || '';
14
- let parsed = null;
15
- try { parsed = data ? JSON.parse(data) : null; } catch (e) { /* ignore */ }
16
- if (res.statusCode >= 200 && res.statusCode < 300) {
17
- resolve({ statusCode: res.statusCode, body: parsed, raw: data, headers: res.headers });
18
- } else {
19
- const err = new Error('HelloAsso request failed');
20
- err.statusCode = res.statusCode;
21
- err.body = parsed;
22
- err.raw = data;
23
- resolve({ statusCode: res.statusCode, body: parsed, raw: data, headers: res.headers, error: err });
24
- }
6
+ class HelloAsso {
7
+ constructor(settings) {
8
+ this.settings = settings || {};
9
+ this.tokenCache = null;
10
+ this.tokenExp = 0;
11
+ }
12
+
13
+ get host() {
14
+ return this.settings.helloassoEnv === 'production' ? 'api.helloasso.com' : 'api.helloasso-sandbox.com';
15
+ }
16
+
17
+ _request(method, path, { headers = {}, body = null } = {}) {
18
+ return new Promise((resolve, reject) => {
19
+ const opts = {
20
+ method,
21
+ hostname: this.host,
22
+ path,
23
+ headers,
24
+ };
25
+ const req = https.request(opts, (res) => {
26
+ let data = '';
27
+ res.on('data', (c) => { data += c; });
28
+ res.on('end', () => {
29
+ const code = res.statusCode || 0;
30
+ if (code >= 200 && code < 300) {
31
+ if (!data) return resolve({});
32
+ try { return resolve(JSON.parse(data)); } catch (e) { return resolve({ raw: data }); }
33
+ }
34
+ return reject(Object.assign(new Error('helloasso_http_' + code), { statusCode: code, body: data }));
35
+ });
25
36
  });
37
+ req.on('error', reject);
38
+ if (body) req.write(body);
39
+ req.end();
26
40
  });
27
- req.on('error', reject);
28
- if (body) req.write(body);
29
- req.end();
30
- });
31
- }
41
+ }
32
42
 
33
- function apiHost(env) {
34
- return env === 'sandbox' ? 'api.helloasso-sandbox.com' : 'api.helloasso.com';
35
- }
43
+ async getToken() {
44
+ const now = Date.now();
45
+ if (this.tokenCache && now < this.tokenExp - 30*1000) return this.tokenCache;
36
46
 
37
- async function getToken(settings) {
38
- const host = apiHost(settings.helloassoEnv);
39
- const body = querystring.stringify({
40
- grant_type: 'client_credentials',
41
- client_id: settings.helloassoClientId,
42
- client_secret: settings.helloassoClientSecret,
43
- });
44
- const headers = {
45
- 'Content-Type': 'application/x-www-form-urlencoded',
46
- 'Content-Length': Buffer.byteLength(body),
47
- };
48
- const r = await requestJson('POST', host, '/oauth2/token', headers, body);
49
- if (r.error || !r.body || !r.body.access_token) {
50
- return { ok: false, error: r.body || r.raw || 'token-error' };
51
- }
52
- return { ok: true, token: r.body.access_token, expires_in: r.body.expires_in };
53
- }
47
+ const clientId = this.settings.helloassoClientId;
48
+ const clientSecret = this.settings.helloassoClientSecret;
49
+ if (!clientId || !clientSecret) throw new Error('missing-credentials');
54
50
 
55
- async function getPublicForm(settings, token) {
56
- const host = apiHost(settings.helloassoEnv);
57
- const path = `/v5/organizations/${encodeURIComponent(settings.helloassoOrganizationSlug)}/forms/${encodeURIComponent(settings.helloassoFormType)}/${encodeURIComponent(settings.helloassoFormSlug)}/public`;
58
- const headers = { Authorization: `Bearer ${token}` };
59
- return await requestJson('GET', host, path, headers);
60
- }
51
+ const body = querystring.stringify({
52
+ grant_type: 'client_credentials',
53
+ client_id: clientId,
54
+ client_secret: clientSecret,
55
+ });
61
56
 
62
- function extractCatalogItems(publicFormBody) {
63
- const out = [];
64
- if (!publicFormBody) return out;
65
-
66
- // Try common shapes:
67
- // 1) publicFormBody.items
68
- // 2) publicFormBody.form && form.items
69
- // 3) tiers/products-like lists
70
- const candidates = [];
71
- if (Array.isArray(publicFormBody.items)) candidates.push(...publicFormBody.items);
72
- if (publicFormBody.form && Array.isArray(publicFormBody.form.items)) candidates.push(...publicFormBody.form.items);
73
- if (Array.isArray(publicFormBody.products)) candidates.push(...publicFormBody.products);
74
- if (Array.isArray(publicFormBody.tiers)) candidates.push(...publicFormBody.tiers);
75
-
76
- for (const it of candidates) {
77
- const id = it.id || it.itemId || it.publicId || it.tierId;
78
- const name = it.name || it.label || it.title;
79
- let priceCents = null;
80
-
81
- // HelloAsso often uses "price" as cents, or "amount" fields.
82
- if (typeof it.price === 'number') priceCents = it.price;
83
- if (priceCents == null && typeof it.amount === 'number') priceCents = it.amount;
84
- if (priceCents == null && it.unitPrice && typeof it.unitPrice === 'number') priceCents = it.unitPrice;
85
- if (priceCents == null && it.pricing && typeof it.pricing.price === 'number') priceCents = it.pricing.price;
86
-
87
- if (!id || !name) continue;
88
- out.push({ id: String(id), name: String(name), priceCents: Number(priceCents || 0) });
57
+ const resp = await this._request('POST', '/oauth2/token', {
58
+ headers: {
59
+ 'Content-Type': 'application/x-www-form-urlencoded',
60
+ 'Content-Length': Buffer.byteLength(body),
61
+ },
62
+ body,
63
+ });
64
+
65
+ this.tokenCache = resp.access_token;
66
+ this.tokenExp = now + (resp.expires_in ? resp.expires_in*1000 : 3600*1000);
67
+ return this.tokenCache;
89
68
  }
90
- // de-dup by id
91
- const seen = new Set();
92
- return out.filter(x => (seen.has(x.id) ? false : (seen.add(x.id), true)));
93
- }
94
69
 
95
- async function createCheckoutIntent(settings, token, reservation, payer) {
96
- const host = apiHost(settings.helloassoEnv);
97
-
98
- // Minimal checkout-intent for shop: amount + payer + metadata
99
- const path = `/v5/organizations/${encodeURIComponent(settings.helloassoOrganizationSlug)}/checkout-intents`;
100
- const payload = {
101
- totalAmount: reservation.totalCents,
102
- initialAmount: reservation.totalCents,
103
- returnUrl: reservation.returnUrl || '',
104
- errorUrl: reservation.errorUrl || '',
105
- metadata: {
106
- source: 'nodebb-plugin-calendar-onekite',
107
- rid: String(reservation.rid),
108
- },
109
- payer: payer || {},
110
- };
111
- const body = JSON.stringify(payload);
112
- const headers = {
113
- Authorization: `Bearer ${token}`,
114
- 'Content-Type': 'application/json',
115
- 'Content-Length': Buffer.byteLength(body),
116
- };
117
- return await requestJson('POST', host, path, headers, body);
70
+ async getCatalog() {
71
+ const org = this.settings.helloassoOrganizationSlug;
72
+ const formType = this.settings.helloassoFormType || 'shop';
73
+ const formSlug = this.settings.helloassoFormSlug;
74
+ if (!org || !formSlug) return { items: [] };
75
+
76
+ const token = await this.getToken();
77
+ const path = `/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/public`;
78
+ const resp = await this._request('GET', path, {
79
+ headers: { Authorization: `Bearer ${token}` },
80
+ });
81
+
82
+ // Try to extract items from known shapes
83
+ const items = [];
84
+ const pushItem = (id, name, priceCents) => {
85
+ if (!id || !name) return;
86
+ items.push({ id: String(id), name: String(name), priceCents: parseInt(priceCents || 0, 10) || 0 });
87
+ };
88
+
89
+ // Common shapes: resp.items / resp.publicForm?.items / resp.tiers / resp.products
90
+ const candidates = []
91
+ .concat(resp.items || [])
92
+ .concat((resp.publicForm && resp.publicForm.items) || [])
93
+ .concat(resp.tiers || [])
94
+ .concat(resp.products || [])
95
+ .concat((resp.shop && resp.shop.products) || []);
96
+
97
+ for (const it of candidates) {
98
+ const id = it.id || it.itemId || it.productId || it.tierId;
99
+ const name = it.name || it.label || it.title;
100
+ const price = it.price || it.amount || it.unitPrice || it.minimumAmount || (it.prices && it.prices[0] && it.prices[0].price);
101
+ pushItem(id, name, price);
102
+ }
103
+
104
+ // Deduplicate by id
105
+ const seen = new Set();
106
+ const uniq = [];
107
+ for (const it of items) {
108
+ if (seen.has(it.id)) continue;
109
+ seen.add(it.id);
110
+ uniq.push(it);
111
+ }
112
+ return { items: uniq };
113
+ }
118
114
  }
119
115
 
120
- module.exports = {
121
- getToken,
122
- getPublicForm,
123
- extractCatalogItems,
124
- createCheckoutIntent,
125
- };
116
+ module.exports = HelloAsso;
package/lib/store.js ADDED
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ const db = require.main.require('./src/database');
4
+
5
+ const KEY_NEXT = 'onekite:cal:nextRid';
6
+ const KEY_HASH = (rid) => `onekite:cal:r:${rid}`;
7
+ const KEY_Z_START = 'onekite:cal:z:start';
8
+ const KEY_Z_STATUS = (status) => `onekite:cal:z:status:${status}`;
9
+ const KEY_Z_EXPIRES = 'onekite:cal:z:expires';
10
+
11
+ function toDayString(ms) {
12
+ return new Date(ms).toISOString().slice(0, 10);
13
+ }
14
+
15
+ async function nextRid() {
16
+ const v = await db.incrObjectField('global', KEY_NEXT);
17
+ return String(v);
18
+ }
19
+
20
+ async function setReservation(rid, data) {
21
+ await db.setObject(KEY_HASH(rid), data);
22
+ }
23
+
24
+ async function getReservation(rid) {
25
+ const obj = await db.getObject(KEY_HASH(rid));
26
+ if (!obj || !obj.rid) return null;
27
+ // parse
28
+ obj.startMs = parseInt(obj.startMs, 10);
29
+ obj.endMs = parseInt(obj.endMs, 10);
30
+ obj.uid = parseInt(obj.uid, 10);
31
+ obj.expiresAt = parseInt(obj.expiresAt || '0', 10);
32
+ obj.days = parseInt(obj.days || '0', 10);
33
+ obj.totalCents = parseInt(obj.totalCents || '0', 10);
34
+ try { obj.itemIds = JSON.parse(obj.itemIds || '[]'); } catch (e) { obj.itemIds = []; }
35
+ return obj;
36
+ }
37
+
38
+ async function indexReservation(rid, r) {
39
+ await db.sortedSetAdd(KEY_Z_START, r.startMs, rid);
40
+ await db.sortedSetAdd(KEY_Z_STATUS(r.status), r.startMs, rid);
41
+ if (r.expiresAt && r.expiresAt > 0) {
42
+ await db.sortedSetAdd(KEY_Z_EXPIRES, r.expiresAt, rid);
43
+ }
44
+ }
45
+
46
+ async function deindexStatus(rid, oldStatus) {
47
+ await db.sortedSetRemove(KEY_Z_STATUS(oldStatus), rid);
48
+ }
49
+
50
+ async function updateStatusIndex(rid, oldStatus, newStatus, startMs) {
51
+ if (oldStatus) await deindexStatus(rid, oldStatus);
52
+ await db.sortedSetAdd(KEY_Z_STATUS(newStatus), startMs, rid);
53
+ }
54
+
55
+ async function removeExpireIndex(rid) {
56
+ await db.sortedSetRemove(KEY_Z_EXPIRES, rid);
57
+ }
58
+
59
+ async function createReservation(input) {
60
+ const rid = await nextRid();
61
+ const r = {
62
+ rid,
63
+ uid: input.uid,
64
+ startMs: input.startMs,
65
+ endMs: input.endMs,
66
+ status: input.status,
67
+ expiresAt: input.expiresAt || 0,
68
+ days: input.days || 0,
69
+ totalCents: input.totalCents || 0,
70
+ itemIds: JSON.stringify(input.itemIds || []),
71
+ };
72
+ await setReservation(rid, r);
73
+ await indexReservation(rid, r);
74
+ return rid;
75
+ }
76
+
77
+ async function updateReservation(rid, patch) {
78
+ const existing = await getReservation(rid);
79
+ if (!existing) return null;
80
+ const updated = Object.assign({}, existing, patch);
81
+ // indexes
82
+ if (patch.status && patch.status !== existing.status) {
83
+ await updateStatusIndex(rid, existing.status, patch.status, existing.startMs);
84
+ }
85
+ if (typeof patch.expiresAt !== 'undefined') {
86
+ await removeExpireIndex(rid);
87
+ if (patch.expiresAt && patch.expiresAt > 0) {
88
+ await db.sortedSetAdd(KEY_Z_EXPIRES, patch.expiresAt, rid);
89
+ }
90
+ }
91
+ // persist
92
+ const obj = Object.assign({}, updated, {
93
+ itemIds: JSON.stringify(updated.itemIds || existing.itemIds || []),
94
+ });
95
+ await setReservation(rid, obj);
96
+ return updated;
97
+ }
98
+
99
+ async function listByStatus(status, start, stop) {
100
+ const ids = await db.getSortedSetRange(KEY_Z_STATUS(status), start, stop);
101
+ const out = [];
102
+ for (const rid of ids) {
103
+ // eslint-disable-next-line no-await-in-loop
104
+ const r = await getReservation(rid);
105
+ if (r) out.push(r);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function overlaps(aStart, aEnd, bStart, bEnd) {
111
+ return aStart < bEnd && bStart < aEnd;
112
+ }
113
+
114
+ async function checkConflicts(itemIds, startMs, endMs, blockingStatuses) {
115
+ // candidates: reservations with start <= endMs
116
+ const ids = await db.getSortedSetRangeByScore(KEY_Z_START, 0, 2000, 0, endMs);
117
+ const conflicts = [];
118
+ for (const rid of ids) {
119
+ // eslint-disable-next-line no-await-in-loop
120
+ const r = await getReservation(rid);
121
+ if (!r) continue;
122
+ if (!blockingStatuses.has(r.status)) continue;
123
+ if (!overlaps(startMs, endMs, r.startMs, r.endMs)) continue;
124
+ const set = new Set(r.itemIds || []);
125
+ const hit = itemIds.filter(id => set.has(String(id)));
126
+ if (hit.length) conflicts.push({ rid, status: r.status, itemIds: hit, start: toDayString(r.startMs), end: toDayString(r.endMs) });
127
+ }
128
+ return conflicts;
129
+ }
130
+
131
+ async function getEventsInRange(startMs, endMs) {
132
+ const ids = await db.getSortedSetRangeByScore(KEY_Z_START, 0, 2000, startMs - 365*24*3600*1000, endMs);
133
+ const events = [];
134
+ for (const rid of ids) {
135
+ // eslint-disable-next-line no-await-in-loop
136
+ const r = await getReservation(rid);
137
+ if (!r) continue;
138
+ if (!overlaps(startMs, endMs, r.startMs, r.endMs)) continue;
139
+ const icon = r.status === 'pending' ? '⏳' : (r.status === 'refused' ? '⛔' : (r.status === 'awaiting_payment' ? '💳' : '✅'));
140
+ const title = `${icon} #${r.rid}`;
141
+ events.push({
142
+ id: r.rid,
143
+ title,
144
+ start: toDayString(r.startMs),
145
+ end: toDayString(r.endMs),
146
+ allDay: true,
147
+ extendedProps: {
148
+ status: r.status,
149
+ itemIds: r.itemIds || [],
150
+ uid: r.uid,
151
+ days: r.days,
152
+ totalCents: r.totalCents,
153
+ },
154
+ });
155
+ }
156
+ return events;
157
+ }
158
+
159
+ async function sweepExpired() {
160
+ const now = Date.now();
161
+ const ids = await db.getSortedSetRangeByScore(KEY_Z_EXPIRES, 0, 500, 0, now);
162
+ for (const rid of ids) {
163
+ // eslint-disable-next-line no-await-in-loop
164
+ const r = await getReservation(rid);
165
+ if (!r) {
166
+ // eslint-disable-next-line no-await-in-loop
167
+ await removeExpireIndex(rid);
168
+ continue;
169
+ }
170
+ if (r.status === 'pending' && r.expiresAt && r.expiresAt <= now) {
171
+ // eslint-disable-next-line no-await-in-loop
172
+ await updateReservation(rid, { status: 'expired', expiresAt: 0 });
173
+ } else {
174
+ // eslint-disable-next-line no-await-in-loop
175
+ await removeExpireIndex(rid);
176
+ }
177
+ }
178
+ }
179
+
180
+ async function purgeRange(startMs, endMs) {
181
+ const ids = await db.getSortedSetRangeByScore(KEY_Z_START, 0, 100000, startMs, endMs);
182
+ let count = 0;
183
+ for (const rid of ids) {
184
+ // eslint-disable-next-line no-await-in-loop
185
+ const r = await getReservation(rid);
186
+ if (!r) continue;
187
+ // eslint-disable-next-line no-await-in-loop
188
+ await db.delete(KEY_HASH(rid));
189
+ // eslint-disable-next-line no-await-in-loop
190
+ await db.sortedSetRemove(KEY_Z_START, rid);
191
+ // eslint-disable-next-line no-await-in-loop
192
+ await db.sortedSetRemove(KEY_Z_STATUS(r.status), rid);
193
+ // eslint-disable-next-line no-await-in-loop
194
+ await db.sortedSetRemove(KEY_Z_EXPIRES, rid);
195
+ count++;
196
+ }
197
+ return count;
198
+ }
199
+
200
+ module.exports = {
201
+ createReservation,
202
+ getReservation,
203
+ updateReservation,
204
+ listByStatus,
205
+ getEventsInRange,
206
+ sweepExpired,
207
+ purgeRange,
208
+ checkConflicts,
209
+ };