nodebb-plugin-calendar-onekite 11.1.23 → 11.1.25
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 +125 -42
- package/lib/api.js +285 -114
- package/lib/db.js +44 -30
- package/lib/helloasso.js +84 -62
- package/lib/mailer.js +78 -0
- package/lib/middlewares.js +69 -0
- package/library.js +93 -71
- package/package.json +4 -3
- package/plugin.json +12 -5
- package/public/admin.js +155 -84
- package/public/client.js +233 -172
- package/templates/admin/plugins/calendar-onekite.tpl +75 -65
- package/templates/calendar-onekite.tpl +14 -5
- package/templates/emails/calendar-onekite-approved.tpl +5 -8
- package/templates/emails/calendar-onekite-pending.tpl +4 -9
- package/templates/emails/calendar-onekite-refused.tpl +3 -9
package/lib/admin.js
CHANGED
|
@@ -1,76 +1,159 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const meta = require.main.require('./src/meta');
|
|
4
|
-
const dbi = require('./db');
|
|
5
4
|
const api = require('./api');
|
|
5
|
+
const store = require('./db');
|
|
6
|
+
const helloasso = require('./helloasso');
|
|
7
|
+
|
|
8
|
+
const PLUGIN_ID = 'calendar-onekite';
|
|
9
|
+
|
|
10
|
+
function normalizeCsv(v) {
|
|
11
|
+
return String(v || '')
|
|
12
|
+
.split(',')
|
|
13
|
+
.map(s => s.trim())
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join(',');
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
async function getSettings(req, res) {
|
|
8
|
-
const settings = await meta.settings.get(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
res.json({ ok: true, settings: settings || {} });
|
|
19
|
+
const settings = await meta.settings.get(PLUGIN_ID) || {};
|
|
20
|
+
if (settings.helloassoClientSecret) settings.helloassoClientSecret = '***';
|
|
21
|
+
res.json({ ok: true, settings });
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
async function saveSettings(req, res) {
|
|
15
25
|
const body = req.body || {};
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
const current = await meta.settings.get(PLUGIN_ID) || {};
|
|
27
|
+
const toSave = {
|
|
28
|
+
helloassoEnv: body.helloassoEnv || current.helloassoEnv || 'sandbox',
|
|
29
|
+
helloassoClientId: body.helloassoClientId || '',
|
|
30
|
+
helloassoClientSecret: body.helloassoClientSecret && body.helloassoClientSecret !== '***'
|
|
31
|
+
? body.helloassoClientSecret
|
|
32
|
+
: (current.helloassoClientSecret || ''),
|
|
33
|
+
helloassoOrganizationSlug: body.helloassoOrganizationSlug || '',
|
|
34
|
+
helloassoFormType: body.helloassoFormType || 'shop',
|
|
35
|
+
helloassoFormSlug: body.helloassoFormSlug || '',
|
|
36
|
+
|
|
37
|
+
creatorGroups: normalizeCsv(body.creatorGroups),
|
|
38
|
+
validatorGroups: normalizeCsv(body.validatorGroups),
|
|
39
|
+
notifyGroups: normalizeCsv(body.notifyGroups),
|
|
40
|
+
|
|
41
|
+
holdMinutes: parseInt(body.holdMinutes, 10) || 5,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
await meta.settings.set(PLUGIN_ID, toSave);
|
|
24
45
|
res.json({ ok: true });
|
|
25
46
|
}
|
|
26
47
|
|
|
27
|
-
async function
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
async function listPending(req, res) {
|
|
49
|
+
await api.sweepExpired();
|
|
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));
|
|
33
59
|
res.json({ ok: true, pending });
|
|
34
60
|
}
|
|
35
61
|
|
|
36
62
|
async function purgeByYear(req, res) {
|
|
37
|
-
const year = String((req.body
|
|
38
|
-
if (!/^\d{4}$/.test(year)) return res.status(400).json({
|
|
63
|
+
const year = String((req.body && req.body.year) || '').trim();
|
|
64
|
+
if (!/^\d{4}$/.test(year)) return res.status(400).json({ ok: false, error: 'invalid-year' });
|
|
65
|
+
|
|
39
66
|
const y = Number(year);
|
|
40
|
-
const
|
|
41
|
-
const
|
|
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);
|
|
42
69
|
|
|
43
|
-
const ids = await
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
|
46
82
|
}
|
|
47
|
-
res.json({ ok: true,
|
|
83
|
+
res.json({ ok: true, purged });
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
async function debugHelloAsso(req, res) {
|
|
51
|
-
const settings = await meta.settings.get(
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
87
|
+
const settings = await meta.settings.get(PLUGIN_ID) || {};
|
|
88
|
+
const safe = { ...settings };
|
|
89
|
+
if (safe.helloassoClientSecret) safe.helloassoClientSecret = '***';
|
|
90
|
+
|
|
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
|
+
}
|
|
102
|
+
|
|
103
|
+
const tok = await helloasso.getToken(settings);
|
|
104
|
+
out.token = { ok: tok.ok };
|
|
105
|
+
if (!tok.ok) {
|
|
106
|
+
out.token.error = tok.error;
|
|
107
|
+
return res.json(out);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pub = await helloasso.getPublicForm(settings, tok.token);
|
|
111
|
+
if (!pub.error) {
|
|
112
|
+
const items = helloasso.extractCatalogItems(pub.body);
|
|
59
113
|
out.catalog = { ok: true, count: items.length, sample: items.slice(0, 10) };
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
114
|
+
} else {
|
|
115
|
+
out.catalog = { ok: false, error: pub.body || pub.raw };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// optional "items" endpoint (may be empty without sales)
|
|
119
|
+
try {
|
|
120
|
+
const https = require('https');
|
|
121
|
+
const host = (settings.helloassoEnv === 'sandbox') ? 'api.helloasso-sandbox.com' : 'api.helloasso.com';
|
|
122
|
+
const path = `/v5/organizations/${encodeURIComponent(settings.helloassoOrganizationSlug)}/forms/${encodeURIComponent(settings.helloassoFormType)}/${encodeURIComponent(settings.helloassoFormSlug)}/items?pageIndex=1&pageSize=50`;
|
|
123
|
+
const r = await new Promise((resolve) => {
|
|
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
|
+
}
|
|
63
146
|
} catch (e) {
|
|
64
|
-
|
|
65
|
-
out.catalog = { ok: false, count: 0, sample: [], message: e.message };
|
|
147
|
+
// ignore
|
|
66
148
|
}
|
|
67
|
-
|
|
149
|
+
|
|
150
|
+
return res.json(out);
|
|
68
151
|
}
|
|
69
152
|
|
|
70
153
|
module.exports = {
|
|
71
154
|
getSettings,
|
|
72
155
|
saveSettings,
|
|
73
|
-
|
|
156
|
+
listPending,
|
|
74
157
|
purgeByYear,
|
|
75
158
|
debugHelloAsso,
|
|
76
159
|
};
|
package/lib/api.js
CHANGED
|
@@ -1,152 +1,323 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const meta = require.main.require('./src/meta');
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
const User = (() => { try { return require.main.require('./src/user'); } catch (e) { return null; }})();
|
|
5
|
+
const { isInAnyGroup } = require('./middlewares');
|
|
6
|
+
const store = require('./db');
|
|
7
|
+
const helloasso = require('./helloasso');
|
|
8
|
+
const mailer = require('./mailer');
|
|
9
|
+
|
|
10
|
+
const PLUGIN_ID = 'calendar-onekite';
|
|
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;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
function toYMD(ts) {
|
|
15
21
|
const d = new Date(Number(ts));
|
|
16
22
|
const y = d.getUTCFullYear();
|
|
17
|
-
const m = String(d.getUTCMonth()+1).padStart(2,'0');
|
|
18
|
-
const
|
|
19
|
-
return `${y}-${m}-${
|
|
23
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
24
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
25
|
+
return `${y}-${m}-${day}`;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
const s = new Date(startYMD + 'T00:00:00Z');
|
|
25
|
-
const e = new Date(endYMDExclusive + 'T00:00:00Z');
|
|
26
|
-
const diff = Math.max(0, Math.round((e - s) / 86400000));
|
|
27
|
-
return diff || 1;
|
|
28
|
+
function overlap(aStart, aEnd, bStart, bEnd) {
|
|
29
|
+
return aStart < bEnd && bStart < aEnd;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
async function getSettings() {
|
|
33
|
-
const settings = await meta.settings.get('calendar-onekite');
|
|
34
|
-
return settings || {};
|
|
32
|
+
async function loadSettings() {
|
|
33
|
+
return await meta.settings.get(PLUGIN_ID) || {};
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
async function
|
|
38
|
-
const settings = await getSettings();
|
|
36
|
+
async function refreshCatalog(settings) {
|
|
39
37
|
const now = Date.now();
|
|
40
|
-
if (
|
|
41
|
-
return
|
|
38
|
+
if (cachedCatalog.items.length && (now - cachedCatalog.ts) < 5 * 60 * 1000) {
|
|
39
|
+
return cachedCatalog.items;
|
|
42
40
|
}
|
|
43
|
-
if (!settings.helloassoClientId || !settings.helloassoClientSecret || !settings.helloassoOrganizationSlug || !settings.helloassoFormSlug
|
|
44
|
-
|
|
41
|
+
if (!settings.helloassoClientId || !settings.helloassoClientSecret || !settings.helloassoOrganizationSlug || !settings.helloassoFormSlug) {
|
|
42
|
+
cachedCatalog = { ts: now, items: [] };
|
|
45
43
|
return [];
|
|
46
44
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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: [] };
|
|
54
53
|
return [];
|
|
55
54
|
}
|
|
55
|
+
const items = helloasso.extractCatalogItems(pub.body);
|
|
56
|
+
cachedCatalog = { ts: now, items };
|
|
57
|
+
return items;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
async function
|
|
59
|
-
const
|
|
60
|
-
|
|
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();
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
async function getEvents(req, res) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
}
|
|
132
|
+
|
|
133
|
+
async function getCatalogItems(req, res) {
|
|
134
|
+
try {
|
|
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
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function createReservation(req, res) {
|
|
144
|
+
try {
|
|
145
|
+
await sweepExpired();
|
|
146
|
+
const settings = await loadSettings();
|
|
147
|
+
const uid = Number(req.uid) || 0;
|
|
148
|
+
|
|
149
|
+
// Check creator group
|
|
150
|
+
const creatorGroups = settings.creatorGroups || '';
|
|
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 });
|
|
89
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,
|
|
90
207
|
};
|
|
91
|
-
});
|
|
92
208
|
|
|
93
|
-
|
|
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' });
|
|
223
|
+
}
|
|
94
224
|
}
|
|
95
225
|
|
|
96
|
-
async function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
226
|
+
async function approveReservation(req, res) {
|
|
227
|
+
try {
|
|
228
|
+
await sweepExpired();
|
|
229
|
+
const settings = await loadSettings();
|
|
230
|
+
const rid = String(req.params.rid);
|
|
231
|
+
const resv = await store.getReservation(rid);
|
|
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
|
+
}
|
|
120
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
|
+
}
|
|
285
|
+
|
|
286
|
+
async function refuseReservation(req, res) {
|
|
287
|
+
try {
|
|
288
|
+
await sweepExpired();
|
|
289
|
+
const rid = String(req.params.rid);
|
|
290
|
+
const resv = await store.getReservation(rid);
|
|
291
|
+
if (!resv || String(resv.status) !== 'pending') return res.status(404).json({ ok: false, error: 'not-found' });
|
|
292
|
+
|
|
293
|
+
resv.status = 'refused';
|
|
294
|
+
resv.refusedAt = Date.now();
|
|
295
|
+
resv.expiresAt = 0;
|
|
296
|
+
await store.setReservation(rid, resv);
|
|
297
|
+
await store.removeFromAllIndexes(rid);
|
|
298
|
+
|
|
299
|
+
await mailer.notifyUser('calendar-onekite-refused', Number(resv.uid), {
|
|
300
|
+
rid,
|
|
301
|
+
start: toYMD(resv.startTs),
|
|
302
|
+
end: toYMD(resv.endTs),
|
|
303
|
+
items: (resv.items || []).map(i => i.name).join(', '),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
res.json({ ok: true });
|
|
307
|
+
} catch (e) {
|
|
308
|
+
res.status(500).json({ ok: false, error: 'refuse-error' });
|
|
121
309
|
}
|
|
122
|
-
const totalCents = sumPerDay * days;
|
|
123
|
-
|
|
124
|
-
const id = await dbi.nextId();
|
|
125
|
-
const startTs = new Date(startYMD + 'T00:00:00Z').getTime();
|
|
126
|
-
const endTs = new Date(endYMD + 'T00:00:00Z').getTime();
|
|
127
|
-
|
|
128
|
-
const resv = {
|
|
129
|
-
id,
|
|
130
|
-
uid: String(uid),
|
|
131
|
-
startTs,
|
|
132
|
-
endTs,
|
|
133
|
-
startYMD,
|
|
134
|
-
endYMD,
|
|
135
|
-
items: chosen,
|
|
136
|
-
days,
|
|
137
|
-
totalCents,
|
|
138
|
-
status: 'pending',
|
|
139
|
-
createdAt: Date.now(),
|
|
140
|
-
};
|
|
141
|
-
await dbi.saveReservation(resv);
|
|
142
|
-
|
|
143
|
-
res.json({ ok: true, reservation: resv });
|
|
144
310
|
}
|
|
145
311
|
|
|
146
312
|
module.exports = {
|
|
313
|
+
startSweeper,
|
|
147
314
|
getEvents,
|
|
148
|
-
|
|
315
|
+
getCatalogItems,
|
|
149
316
|
createReservation,
|
|
150
|
-
|
|
151
|
-
|
|
317
|
+
approveReservation,
|
|
318
|
+
refuseReservation,
|
|
319
|
+
// exported for admin uses
|
|
320
|
+
sweepExpired,
|
|
321
|
+
refreshCatalog,
|
|
322
|
+
toYMD,
|
|
152
323
|
};
|