nodebb-plugin-calendar-onekite 11.1.26 → 11.1.28
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/admin.js +53 -125
- package/lib/api.js +42 -305
- package/lib/db.js +102 -58
- package/lib/helloasso.js +61 -91
- package/lib/store.js +209 -0
- package/library.js +68 -85
- package/package.json +2 -4
- package/plugin.json +4 -12
- package/public/admin.js +67 -170
- package/public/client.js +154 -132
- package/templates/admin/plugins/calendar-onekite.tpl +80 -75
- package/templates/calendar-onekite.tpl +7 -3
- 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/lib/admin.js
CHANGED
|
@@ -1,159 +1,87 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const meta = require.main.require('./src/meta');
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
const
|
|
4
|
+
const User = require.main.require('./src/user');
|
|
5
|
+
|
|
6
|
+
const HelloAsso = require('./helloasso');
|
|
7
|
+
const Store = require('./db');
|
|
7
8
|
|
|
8
9
|
const PLUGIN_ID = 'calendar-onekite';
|
|
9
10
|
|
|
10
11
|
function normalizeCsv(v) {
|
|
11
|
-
return String(v || '')
|
|
12
|
-
.split(',')
|
|
13
|
-
.map(s => s.trim())
|
|
14
|
-
.filter(Boolean)
|
|
15
|
-
.join(',');
|
|
12
|
+
return String(v || '').split(',').map(s => s.trim()).filter(Boolean).join(',');
|
|
16
13
|
}
|
|
17
14
|
|
|
18
|
-
async
|
|
15
|
+
exports.getSettings = async (req, res) => {
|
|
19
16
|
const settings = await meta.settings.get(PLUGIN_ID) || {};
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
// never expose secret
|
|
18
|
+
const safe = { ...settings };
|
|
19
|
+
if (safe.helloassoClientSecret) safe.helloassoClientSecret = '***';
|
|
20
|
+
res.json({ ok: true, settings: safe });
|
|
21
|
+
};
|
|
23
22
|
|
|
24
|
-
async
|
|
23
|
+
exports.putSettings = async (req, res) => {
|
|
25
24
|
const body = req.body || {};
|
|
26
|
-
const
|
|
25
|
+
const existing = await meta.settings.get(PLUGIN_ID) || {};
|
|
26
|
+
|
|
27
27
|
const toSave = {
|
|
28
|
-
helloassoEnv: body.helloassoEnv ||
|
|
29
|
-
helloassoClientId: body.helloassoClientId
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
holdMinutes: parseInt(body.holdMinutes, 10) || 5,
|
|
28
|
+
helloassoEnv: body.helloassoEnv || existing.helloassoEnv || 'sandbox',
|
|
29
|
+
helloassoClientId: body.helloassoClientId ?? existing.helloassoClientId ?? '',
|
|
30
|
+
// allow setting secret only if provided and not "***"
|
|
31
|
+
helloassoClientSecret: (body.helloassoClientSecret && body.helloassoClientSecret !== '***') ? body.helloassoClientSecret : (existing.helloassoClientSecret || ''),
|
|
32
|
+
helloassoOrganizationSlug: body.helloassoOrganizationSlug ?? existing.helloassoOrganizationSlug ?? '',
|
|
33
|
+
helloassoFormType: body.helloassoFormType || existing.helloassoFormType || 'shop',
|
|
34
|
+
helloassoFormSlug: body.helloassoFormSlug ?? existing.helloassoFormSlug ?? '',
|
|
35
|
+
|
|
36
|
+
creatorGroups: normalizeCsv(body.creatorGroups ?? existing.creatorGroups),
|
|
37
|
+
validatorGroups: normalizeCsv(body.validatorGroups ?? existing.validatorGroups),
|
|
38
|
+
notifyGroups: normalizeCsv(body.notifyGroups ?? existing.notifyGroups),
|
|
39
|
+
|
|
40
|
+
holdMinutes: parseInt(body.holdMinutes, 10) || parseInt(existing.holdMinutes, 10) || 5,
|
|
42
41
|
};
|
|
43
42
|
|
|
44
43
|
await meta.settings.set(PLUGIN_ID, toSave);
|
|
45
44
|
res.json({ ok: true });
|
|
46
|
-
}
|
|
45
|
+
};
|
|
47
46
|
|
|
48
|
-
async
|
|
49
|
-
await
|
|
50
|
-
const now = Date.now();
|
|
51
|
-
const ids = await store.listIdsByStartMax(now + 365 * 24 * 3600 * 1000, 5000);
|
|
52
|
-
const pending = [];
|
|
53
|
-
for (const rid of ids) {
|
|
54
|
-
// eslint-disable-next-line no-await-in-loop
|
|
55
|
-
const r = await store.getReservation(rid);
|
|
56
|
-
if (r && String(r.status) === 'pending') pending.push(r);
|
|
57
|
-
}
|
|
58
|
-
pending.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
|
|
47
|
+
exports.getPending = async (req, res) => {
|
|
48
|
+
const pending = await Store.listPending();
|
|
59
49
|
res.json({ ok: true, pending });
|
|
60
|
-
}
|
|
50
|
+
};
|
|
61
51
|
|
|
62
|
-
async
|
|
63
|
-
const year = String((req.body
|
|
64
|
-
if (!/^\d{4}$/.test(year))
|
|
65
|
-
|
|
66
|
-
const y = Number(year);
|
|
67
|
-
const start = Date.UTC(y, 0, 1, 0, 0, 0, 0);
|
|
68
|
-
const end = Date.UTC(y + 1, 0, 1, 0, 0, 0, 0);
|
|
69
|
-
|
|
70
|
-
const ids = await store.listIdsByStartMax(end, 100000);
|
|
71
|
-
let purged = 0;
|
|
72
|
-
for (const rid of ids) {
|
|
73
|
-
// eslint-disable-next-line no-await-in-loop
|
|
74
|
-
const r = await store.getReservation(rid);
|
|
75
|
-
if (!r || !r.startTs) continue;
|
|
76
|
-
const s = Number(r.startTs);
|
|
77
|
-
if (s >= start && s < end) {
|
|
78
|
-
// eslint-disable-next-line no-await-in-loop
|
|
79
|
-
await store.deleteReservation(rid);
|
|
80
|
-
purged++;
|
|
81
|
-
}
|
|
52
|
+
exports.purge = async (req, res) => {
|
|
53
|
+
const year = String((req.body || {}).year || '').trim();
|
|
54
|
+
if (!/^\d{4}$/.test(year)) {
|
|
55
|
+
return res.status(400).json({ ok: false, error: 'invalid-year' });
|
|
82
56
|
}
|
|
83
|
-
|
|
84
|
-
}
|
|
57
|
+
const removed = await Store.purgeByYear(parseInt(year, 10));
|
|
58
|
+
res.json({ ok: true, removed });
|
|
59
|
+
};
|
|
85
60
|
|
|
86
|
-
async
|
|
61
|
+
exports.debugHelloAsso = async (req, res) => {
|
|
87
62
|
const settings = await meta.settings.get(PLUGIN_ID) || {};
|
|
88
63
|
const safe = { ...settings };
|
|
89
64
|
if (safe.helloassoClientSecret) safe.helloassoClientSecret = '***';
|
|
90
65
|
|
|
91
|
-
const out = {
|
|
92
|
-
ok: true,
|
|
93
|
-
settings: safe,
|
|
94
|
-
token: { ok: false },
|
|
95
|
-
catalog: { ok: false, count: 0, sample: [] },
|
|
96
|
-
soldItems: { ok: false, count: 0, sample: [] },
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
if (!settings.helloassoClientId || !settings.helloassoClientSecret) {
|
|
100
|
-
return res.json(out);
|
|
101
|
-
}
|
|
66
|
+
const out = { ok: true, settings: safe, token: { ok: false }, catalog: { ok: false, count: 0, sample: [] } };
|
|
102
67
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const pub = await helloasso.getPublicForm(settings, tok.token);
|
|
111
|
-
if (!pub.error) {
|
|
112
|
-
const items = helloasso.extractCatalogItems(pub.body);
|
|
113
|
-
out.catalog = { ok: true, count: items.length, sample: items.slice(0, 10) };
|
|
114
|
-
} else {
|
|
115
|
-
out.catalog = { ok: false, error: pub.body || pub.raw };
|
|
68
|
+
try {
|
|
69
|
+
const token = await HelloAsso.getToken(settings);
|
|
70
|
+
out.token.ok = !!token;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
out.token.ok = false;
|
|
73
|
+
out.token.error = e.message;
|
|
116
74
|
}
|
|
117
75
|
|
|
118
|
-
// optional "items" endpoint (may be empty without sales)
|
|
119
76
|
try {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const req2 = https.request({ method: 'GET', host, path, headers: { Authorization: `Bearer ${tok.token}` } }, (resp) => {
|
|
125
|
-
let data = '';
|
|
126
|
-
resp.on('data', (d) => { data += d; });
|
|
127
|
-
resp.on('end', () => {
|
|
128
|
-
let parsed = null;
|
|
129
|
-
try { parsed = data ? JSON.parse(data) : null; } catch (e) { /* ignore */ }
|
|
130
|
-
resolve({ statusCode: resp.statusCode, body: parsed });
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
req2.on('error', () => resolve({ statusCode: 0, body: null }));
|
|
134
|
-
req2.end();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
if (r.statusCode >= 200 && r.statusCode < 300 && r.body) {
|
|
138
|
-
const arr = Array.isArray(r.body.data) ? r.body.data : (Array.isArray(r.body) ? r.body : []);
|
|
139
|
-
const sample = arr.slice(0, 10).map(x => ({
|
|
140
|
-
id: String(x.id || x.itemId || ''),
|
|
141
|
-
name: x.name || x.label || '',
|
|
142
|
-
priceCents: x.price || x.amount || 0,
|
|
143
|
-
}));
|
|
144
|
-
out.soldItems = { ok: true, count: arr.length, sample };
|
|
145
|
-
}
|
|
77
|
+
const catalog = await HelloAsso.getCatalog(settings);
|
|
78
|
+
out.catalog.ok = true;
|
|
79
|
+
out.catalog.count = catalog.length;
|
|
80
|
+
out.catalog.sample = catalog.slice(0, 10);
|
|
146
81
|
} catch (e) {
|
|
147
|
-
|
|
82
|
+
out.catalog.ok = false;
|
|
83
|
+
out.catalog.error = e.message;
|
|
148
84
|
}
|
|
149
85
|
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
module.exports = {
|
|
154
|
-
getSettings,
|
|
155
|
-
saveSettings,
|
|
156
|
-
listPending,
|
|
157
|
-
purgeByYear,
|
|
158
|
-
debugHelloAsso,
|
|
86
|
+
res.json(out);
|
|
159
87
|
};
|
package/lib/api.js
CHANGED
|
@@ -1,323 +1,60 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const meta = require.main.require('./src/meta');
|
|
4
|
-
const
|
|
5
|
-
const { isInAnyGroup } = require('./middlewares');
|
|
6
|
-
const store = require('./db');
|
|
7
|
-
const helloasso = require('./helloasso');
|
|
8
|
-
const mailer = require('./mailer');
|
|
4
|
+
const groups = require.main.require('./src/groups');
|
|
9
5
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
let sweeperStarted = false;
|
|
13
|
-
let cachedCatalog = { ts: 0, items: [] };
|
|
14
|
-
|
|
15
|
-
function parseIntSafe(v, def) {
|
|
16
|
-
const n = parseInt(v, 10);
|
|
17
|
-
return Number.isFinite(n) ? n : def;
|
|
18
|
-
}
|
|
6
|
+
const HelloAsso = require('./helloasso');
|
|
7
|
+
const Store = require('./db');
|
|
19
8
|
|
|
20
|
-
|
|
21
|
-
const d = new Date(Number(ts));
|
|
22
|
-
const y = d.getUTCFullYear();
|
|
23
|
-
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
24
|
-
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
25
|
-
return `${y}-${m}-${day}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function overlap(aStart, aEnd, bStart, bEnd) {
|
|
29
|
-
return aStart < bEnd && bStart < aEnd;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function loadSettings() {
|
|
33
|
-
return await meta.settings.get(PLUGIN_ID) || {};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function refreshCatalog(settings) {
|
|
37
|
-
const now = Date.now();
|
|
38
|
-
if (cachedCatalog.items.length && (now - cachedCatalog.ts) < 5 * 60 * 1000) {
|
|
39
|
-
return cachedCatalog.items;
|
|
40
|
-
}
|
|
41
|
-
if (!settings.helloassoClientId || !settings.helloassoClientSecret || !settings.helloassoOrganizationSlug || !settings.helloassoFormSlug) {
|
|
42
|
-
cachedCatalog = { ts: now, items: [] };
|
|
43
|
-
return [];
|
|
44
|
-
}
|
|
45
|
-
const tok = await helloasso.getToken(settings);
|
|
46
|
-
if (!tok.ok) {
|
|
47
|
-
cachedCatalog = { ts: now, items: [] };
|
|
48
|
-
return [];
|
|
49
|
-
}
|
|
50
|
-
const pub = await helloasso.getPublicForm(settings, tok.token);
|
|
51
|
-
if (pub.error) {
|
|
52
|
-
cachedCatalog = { ts: now, items: [] };
|
|
53
|
-
return [];
|
|
54
|
-
}
|
|
55
|
-
const items = helloasso.extractCatalogItems(pub.body);
|
|
56
|
-
cachedCatalog = { ts: now, items };
|
|
57
|
-
return items;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function sweepExpired() {
|
|
61
|
-
const settings = await loadSettings();
|
|
62
|
-
const now = Date.now();
|
|
63
|
-
const ids = await store.listExpiredIds(now, 5000);
|
|
64
|
-
for (const ridStr of ids) {
|
|
65
|
-
// eslint-disable-next-line no-await-in-loop
|
|
66
|
-
const resv = await store.getReservation(ridStr);
|
|
67
|
-
if (!resv || !resv.status) {
|
|
68
|
-
// eslint-disable-next-line no-await-in-loop
|
|
69
|
-
await store.removeFromAllIndexes(ridStr);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const status = String(resv.status);
|
|
73
|
-
if (['pending', 'awaiting_payment'].includes(status)) {
|
|
74
|
-
resv.status = 'expired';
|
|
75
|
-
// eslint-disable-next-line no-await-in-loop
|
|
76
|
-
await store.setReservation(ridStr, resv);
|
|
77
|
-
// stop blocking
|
|
78
|
-
// eslint-disable-next-line no-await-in-loop
|
|
79
|
-
await store.removeFromAllIndexes(ridStr);
|
|
80
|
-
} else {
|
|
81
|
-
// eslint-disable-next-line no-await-in-loop
|
|
82
|
-
await store.removeFromAllIndexes(ridStr);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function startSweeper() {
|
|
88
|
-
if (sweeperStarted) return;
|
|
89
|
-
sweeperStarted = true;
|
|
90
|
-
setInterval(() => {
|
|
91
|
-
sweepExpired().catch(() => {});
|
|
92
|
-
}, 60 * 1000).unref();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function getEvents(req, res) {
|
|
96
|
-
try {
|
|
97
|
-
await sweepExpired();
|
|
98
|
-
const start = parseIntSafe(req.query.startTs, 0);
|
|
99
|
-
const end = parseIntSafe(req.query.endTs, Date.now() + 365 * 24 * 3600 * 1000);
|
|
100
|
-
|
|
101
|
-
const ids = await store.listIdsByStartMax(end, 5000);
|
|
102
|
-
const events = [];
|
|
103
|
-
for (const rid of ids) {
|
|
104
|
-
// eslint-disable-next-line no-await-in-loop
|
|
105
|
-
const r = await store.getReservation(rid);
|
|
106
|
-
if (!r || !r.startTs || !r.endTs) continue;
|
|
107
|
-
if (!overlap(Number(r.startTs), Number(r.endTs), start, end)) continue;
|
|
108
|
-
|
|
109
|
-
const status = String(r.status || 'pending');
|
|
110
|
-
const title = (Array.isArray(r.items) ? r.items.map(it => it.name).join(', ') : (r.itemName || 'Réservation'));
|
|
111
|
-
events.push({
|
|
112
|
-
id: String(r.rid),
|
|
113
|
-
title,
|
|
114
|
-
start: toYMD(r.startTs),
|
|
115
|
-
end: toYMD(r.endTs), // end is exclusive, ok for allDay
|
|
116
|
-
allDay: true,
|
|
117
|
-
extendedProps: {
|
|
118
|
-
status,
|
|
119
|
-
items: r.items || [],
|
|
120
|
-
requestedByUid: r.uid,
|
|
121
|
-
totalCents: r.totalCents || 0,
|
|
122
|
-
days: r.days || 0,
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
res.json({ ok: true, events });
|
|
128
|
-
} catch (e) {
|
|
129
|
-
res.status(500).json({ ok: false, error: 'events-error' });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
9
|
+
const PLUGIN_ID = 'calendar-onekite';
|
|
132
10
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const settings = await loadSettings();
|
|
136
|
-
const items = await refreshCatalog(settings);
|
|
137
|
-
res.json({ ok: true, items });
|
|
138
|
-
} catch (e) {
|
|
139
|
-
res.status(500).json({ ok: false, error: 'items-error' });
|
|
140
|
-
}
|
|
11
|
+
function csvToSet(v) {
|
|
12
|
+
return new Set(String(v || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
141
13
|
}
|
|
142
14
|
|
|
143
|
-
async function
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (creatorGroups) {
|
|
152
|
-
const ok = await isInAnyGroup(uid, creatorGroups);
|
|
153
|
-
if (!ok) return res.status(403).json({ ok: false, error: 'not-allowed-to-create' });
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const body = req.body || {};
|
|
157
|
-
const startTs = parseIntSafe(body.startTs, 0);
|
|
158
|
-
const endTs = parseIntSafe(body.endTs, 0);
|
|
159
|
-
const itemIds = Array.isArray(body.itemIds) ? body.itemIds.map(String).filter(Boolean) : [];
|
|
160
|
-
if (!startTs || !endTs || endTs <= startTs) return res.status(400).json({ ok: false, error: 'invalid-dates' });
|
|
161
|
-
if (!itemIds.length) return res.status(400).json({ ok: false, error: 'no-items' });
|
|
162
|
-
|
|
163
|
-
const dayMs = 24 * 3600 * 1000;
|
|
164
|
-
const days = Math.max(1, Math.round((endTs - startTs) / dayMs));
|
|
165
|
-
const catalog = await refreshCatalog(settings);
|
|
166
|
-
if (!catalog.length) return res.status(400).json({ ok: false, error: 'no-catalog' });
|
|
167
|
-
|
|
168
|
-
const selected = catalog.filter(it => itemIds.includes(String(it.id)));
|
|
169
|
-
if (!selected.length) return res.status(400).json({ ok: false, error: 'items-not-found' });
|
|
170
|
-
|
|
171
|
-
// Conflict check: fetch all reservations starting before endTs and check overlap + item intersection
|
|
172
|
-
const ids = await store.listIdsByStartMax(endTs, 5000);
|
|
173
|
-
const blockingStatuses = new Set(['pending', 'awaiting_payment', 'approved', 'paid']);
|
|
174
|
-
const conflicts = [];
|
|
175
|
-
for (const rid of ids) {
|
|
176
|
-
// eslint-disable-next-line no-await-in-loop
|
|
177
|
-
const r = await store.getReservation(rid);
|
|
178
|
-
if (!r || !r.startTs || !r.endTs) continue;
|
|
179
|
-
if (!blockingStatuses.has(String(r.status))) continue;
|
|
180
|
-
if (!overlap(Number(r.startTs), Number(r.endTs), startTs, endTs)) continue;
|
|
181
|
-
const rItemIds = (Array.isArray(r.items) ? r.items.map(x => String(x.id)) : (r.itemId ? [String(r.itemId)] : []));
|
|
182
|
-
const overlapItems = rItemIds.filter(id => itemIds.includes(id));
|
|
183
|
-
if (overlapItems.length) {
|
|
184
|
-
conflicts.push({ rid: r.rid, itemIds: overlapItems, startTs: r.startTs, endTs: r.endTs, status: r.status });
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (conflicts.length) {
|
|
188
|
-
return res.status(409).json({ ok: false, error: 'conflict', conflicts });
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const totalPerDay = selected.reduce((s, it) => s + Number(it.priceCents || 0), 0);
|
|
192
|
-
const totalCents = totalPerDay * days;
|
|
193
|
-
|
|
194
|
-
const holdMinutes = parseIntSafe(settings.holdMinutes, 5);
|
|
195
|
-
const expiresAt = Date.now() + (holdMinutes * 60 * 1000);
|
|
196
|
-
|
|
197
|
-
const resv = {
|
|
198
|
-
uid,
|
|
199
|
-
startTs,
|
|
200
|
-
endTs,
|
|
201
|
-
days,
|
|
202
|
-
items: selected,
|
|
203
|
-
totalCents,
|
|
204
|
-
status: 'pending',
|
|
205
|
-
createdAt: Date.now(),
|
|
206
|
-
expiresAt,
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
const rid = await store.createReservation(resv);
|
|
210
|
-
|
|
211
|
-
// Notify validators/admins group(s) by email
|
|
212
|
-
await mailer.notifyGroups('calendar-onekite-pending', settings.notifyGroups || '', {
|
|
213
|
-
rid,
|
|
214
|
-
start: toYMD(startTs),
|
|
215
|
-
end: toYMD(endTs),
|
|
216
|
-
items: selected.map(i => i.name).join(', '),
|
|
217
|
-
total: (totalCents / 100).toFixed(2),
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
res.json({ ok: true, rid });
|
|
221
|
-
} catch (e) {
|
|
222
|
-
res.status(500).json({ ok: false, error: 'create-error' });
|
|
15
|
+
async function isMemberOfAny(uid, csv) {
|
|
16
|
+
const set = csvToSet(csv);
|
|
17
|
+
if (!uid || set.size === 0) return true; // if empty => allow
|
|
18
|
+
for (const g of set) {
|
|
19
|
+
try {
|
|
20
|
+
const ok = await groups.isMember(uid, g);
|
|
21
|
+
if (ok) return true;
|
|
22
|
+
} catch {}
|
|
223
23
|
}
|
|
24
|
+
return false;
|
|
224
25
|
}
|
|
225
26
|
|
|
226
|
-
async
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (!resv || String(resv.status) !== 'pending') return res.status(404).json({ ok: false, error: 'not-found' });
|
|
233
|
-
|
|
234
|
-
// Create payment link via HelloAsso
|
|
235
|
-
let paymentUrl = '';
|
|
236
|
-
const catalog = await refreshCatalog(settings);
|
|
237
|
-
// ensure items have latest price
|
|
238
|
-
const map = new Map(catalog.map(i => [String(i.id), i]));
|
|
239
|
-
const selected = (resv.items || []).map(it => map.get(String(it.id)) || it);
|
|
240
|
-
const totalPerDay = selected.reduce((s, it) => s + Number(it.priceCents || 0), 0);
|
|
241
|
-
const totalCents = totalPerDay * Number(resv.days || 1);
|
|
242
|
-
resv.items = selected;
|
|
243
|
-
resv.totalCents = totalCents;
|
|
244
|
-
|
|
245
|
-
if (settings.helloassoClientId && settings.helloassoClientSecret) {
|
|
246
|
-
const tok = await helloasso.getToken(settings);
|
|
247
|
-
if (tok.ok) {
|
|
248
|
-
const payer = {};
|
|
249
|
-
if (User && typeof User.getUserField === 'function') {
|
|
250
|
-
try {
|
|
251
|
-
const email = await User.getUserField(resv.uid, 'email');
|
|
252
|
-
if (email) payer.email = email;
|
|
253
|
-
} catch (e) { /* ignore */ }
|
|
254
|
-
}
|
|
255
|
-
const checkout = await helloasso.createCheckoutIntent(settings, tok.token, resv, payer);
|
|
256
|
-
if (!checkout.error && checkout.body) {
|
|
257
|
-
paymentUrl = checkout.body.redirectUrl || checkout.body.checkoutUrl || '';
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
resv.status = paymentUrl ? 'awaiting_payment' : 'approved';
|
|
263
|
-
resv.paymentUrl = paymentUrl;
|
|
264
|
-
resv.approvedAt = Date.now();
|
|
265
|
-
resv.expiresAt = 0;
|
|
266
|
-
await store.setReservation(rid, resv);
|
|
267
|
-
await store.clearExpireIndex(rid);
|
|
268
|
-
await store.ensureStartIndex(rid, resv.startTs);
|
|
269
|
-
|
|
270
|
-
// Email requester
|
|
271
|
-
await mailer.notifyUser('calendar-onekite-approved', Number(resv.uid), {
|
|
272
|
-
rid,
|
|
273
|
-
start: toYMD(resv.startTs),
|
|
274
|
-
end: toYMD(resv.endTs),
|
|
275
|
-
items: (resv.items || []).map(i => i.name).join(', '),
|
|
276
|
-
total: (resv.totalCents / 100).toFixed(2),
|
|
277
|
-
paymentUrl: paymentUrl || '',
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
res.json({ ok: true, paymentUrl });
|
|
281
|
-
} catch (e) {
|
|
282
|
-
res.status(500).json({ ok: false, error: 'approve-error' });
|
|
283
|
-
}
|
|
284
|
-
}
|
|
27
|
+
exports.getEvents = async (req, res) => {
|
|
28
|
+
const start = req.query.start;
|
|
29
|
+
const end = req.query.end;
|
|
30
|
+
const events = await Store.listEvents(start, end);
|
|
31
|
+
res.json(events);
|
|
32
|
+
};
|
|
285
33
|
|
|
286
|
-
async
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (!resv || String(resv.status) !== 'pending') return res.status(404).json({ ok: false, error: 'not-found' });
|
|
34
|
+
exports.getCatalog = async (req, res) => {
|
|
35
|
+
const settings = await meta.settings.get(PLUGIN_ID) || {};
|
|
36
|
+
const catalog = await HelloAsso.getCatalog(settings);
|
|
37
|
+
res.json({ ok: true, catalog });
|
|
38
|
+
};
|
|
292
39
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
await store.setReservation(rid, resv);
|
|
297
|
-
await store.removeFromAllIndexes(rid);
|
|
40
|
+
exports.createReservation = async (req, res) => {
|
|
41
|
+
const uid = parseInt(req.uid, 10) || 0;
|
|
42
|
+
if (!uid) return res.status(401).json({ ok: false, error: 'not-logged-in' });
|
|
298
43
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
end: toYMD(resv.endTs),
|
|
303
|
-
items: (resv.items || []).map(i => i.name).join(', '),
|
|
304
|
-
});
|
|
44
|
+
const settings = await meta.settings.get(PLUGIN_ID) || {};
|
|
45
|
+
const canCreate = await isMemberOfAny(uid, settings.creatorGroups);
|
|
46
|
+
if (!canCreate) return res.status(403).json({ ok: false, error: 'not-allowed' });
|
|
305
47
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
48
|
+
const body = req.body || {};
|
|
49
|
+
const start = String(body.start || '').slice(0, 10);
|
|
50
|
+
const end = String(body.end || '').slice(0, 10);
|
|
51
|
+
const itemIds = Array.isArray(body.itemIds) ? body.itemIds.map(String) : [];
|
|
52
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(start) || !/^\d{4}-\d{2}-\d{2}$/.test(end) || itemIds.length === 0) {
|
|
53
|
+
return res.status(400).json({ ok: false, error: 'invalid-payload' });
|
|
309
54
|
}
|
|
310
|
-
}
|
|
311
55
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
createReservation,
|
|
317
|
-
approveReservation,
|
|
318
|
-
refuseReservation,
|
|
319
|
-
// exported for admin uses
|
|
320
|
-
sweepExpired,
|
|
321
|
-
refreshCatalog,
|
|
322
|
-
toYMD,
|
|
56
|
+
// Save as pending with expiration
|
|
57
|
+
const holdMinutes = parseInt(settings.holdMinutes, 10) || 5;
|
|
58
|
+
const reservation = await Store.createPending(uid, start, end, itemIds, holdMinutes);
|
|
59
|
+
res.json({ ok: true, reservation });
|
|
323
60
|
};
|