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 +101 -110
- package/lib/store.js +209 -0
- package/library.js +372 -88
- package/package.json +3 -5
- package/plugin.json +8 -8
- package/public/admin.js +101 -153
- package/public/client.js +161 -214
- package/templates/admin/plugins/calendar-onekite.tpl +113 -72
- package/templates/calendar-onekite.tpl +18 -13
- package/templates/emails/calendar-onekite-approved.tpl +1 -5
- package/templates/emails/calendar-onekite-pending.tpl +1 -4
- package/templates/emails/calendar-onekite-refused.tpl +1 -3
- package/README.md +0 -38
- package/lib/controllers.js +0 -11
- package/lib/scheduler.js +0 -50
- package/lib/settings.js +0 -36
- package/public/calendar-onekite.scss +0 -8
- package/templates/calendar-onekite/calendar.tpl +0 -29
- package/templates/emails/calendar-onekite_approved.tpl +0 -10
- package/templates/emails/calendar-onekite_pending.tpl +0 -10
- package/templates/emails/calendar-onekite_refused.tpl +0 -7
package/lib/helloasso.js
CHANGED
|
@@ -3,123 +3,114 @@
|
|
|
3
3
|
const https = require('https');
|
|
4
4
|
const querystring = require('querystring');
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
if (body) req.write(body);
|
|
29
|
-
req.end();
|
|
30
|
-
});
|
|
31
|
-
}
|
|
41
|
+
}
|
|
32
42
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
async getToken() {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
if (this.tokenCache && now < this.tokenExp - 30*1000) return this.tokenCache;
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
51
|
+
const body = querystring.stringify({
|
|
52
|
+
grant_type: 'client_credentials',
|
|
53
|
+
client_id: clientId,
|
|
54
|
+
client_secret: clientSecret,
|
|
55
|
+
});
|
|
61
56
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
};
|