nodebb-plugin-calendar-onekite 11.1.31 → 11.1.32
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 +236 -63
- package/lib/api.js +136 -125
- package/lib/controllers.js +11 -0
- package/lib/db.js +22 -36
- package/lib/helloasso.js +177 -77
- package/lib/scheduler.js +50 -0
- package/library.js +69 -67
- package/package.json +5 -4
- package/plugin.json +12 -16
- package/public/admin.js +199 -99
- package/public/client.js +132 -186
- package/templates/admin/plugins/calendar-onekite.tpl +77 -74
- package/templates/calendar-onekite.tpl +15 -5
- package/templates/emails/calendar-onekite_approved.tpl +10 -0
- package/templates/emails/calendar-onekite_pending.tpl +10 -0
- package/templates/emails/calendar-onekite_refused.tpl +7 -0
package/lib/admin.js
CHANGED
|
@@ -1,76 +1,249 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const meta = require.main.require('./src/meta');
|
|
4
|
-
const
|
|
5
|
-
const
|
|
4
|
+
const user = require.main.require('./src/user');
|
|
5
|
+
const emailer = require.main.require('./src/emailer');
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const dbLayer = require('./db');
|
|
8
|
+
const helloasso = require('./helloasso');
|
|
9
|
+
|
|
10
|
+
const ADMIN_PRIV = 'admin:settings';
|
|
11
|
+
|
|
12
|
+
const admin = {};
|
|
13
|
+
|
|
14
|
+
admin.renderAdmin = async function (req, res) {
|
|
15
|
+
res.render('admin/plugins/calendar-onekite', {
|
|
16
|
+
title: 'Calendar OneKite',
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
admin.getSettings = async function (req, res) {
|
|
8
21
|
const settings = await meta.settings.get('calendar-onekite');
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
res.json(settings || {});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
admin.saveSettings = async function (req, res) {
|
|
26
|
+
await meta.settings.set('calendar-onekite', req.body || {});
|
|
27
|
+
res.json({ ok: true });
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
admin.listPending = async function (req, res) {
|
|
31
|
+
const ids = await dbLayer.listAllReservationIds(5000);
|
|
32
|
+
const pending = [];
|
|
33
|
+
for (const rid of ids) {
|
|
34
|
+
const r = await dbLayer.getReservation(rid);
|
|
35
|
+
if (r && r.status === 'pending') {
|
|
36
|
+
pending.push(r);
|
|
37
|
+
}
|
|
21
38
|
}
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
|
|
40
|
+
res.json(pending);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
admin.approveReservation = async function (req, res) {
|
|
44
|
+
const rid = req.params.rid;
|
|
45
|
+
const r = await dbLayer.getReservation(rid);
|
|
46
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
47
|
+
|
|
48
|
+
r.status = 'approved';
|
|
49
|
+
|
|
50
|
+
// Create HelloAsso payment link if configured
|
|
51
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
52
|
+
const env = settings.helloassoEnv || 'prod';
|
|
53
|
+
const token = await helloasso.getAccessToken({
|
|
54
|
+
env,
|
|
55
|
+
clientId: settings.helloassoClientId,
|
|
56
|
+
clientSecret: settings.helloassoClientSecret,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let paymentUrl = null;
|
|
60
|
+
if (token) {
|
|
61
|
+
const requester = await user.getUserData(r.uid);
|
|
62
|
+
// Determine amount from HelloAsso items (pricing comes from HelloAsso)
|
|
63
|
+
let totalAmount = 0;
|
|
64
|
+
try {
|
|
65
|
+
const items = await helloasso.listItems({
|
|
66
|
+
env,
|
|
67
|
+
token,
|
|
68
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
69
|
+
formType: settings.helloassoFormType,
|
|
70
|
+
formSlug: settings.helloassoFormSlug,
|
|
71
|
+
});
|
|
72
|
+
const normalized = (items || []).map((it) => ({
|
|
73
|
+
id: String(it.id || it.itemId || it.reference || it.name),
|
|
74
|
+
price: it.price || it.amount || it.unitPrice || 0,
|
|
75
|
+
})).filter(it => it.id);
|
|
76
|
+
const match = normalized.find(it => it.id === String(r.itemId));
|
|
77
|
+
totalAmount = match ? parseInt(match.price, 10) || 0 : 0;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
totalAmount = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!totalAmount) {
|
|
83
|
+
return res.status(400).json({ error: 'item-price-not-found' });
|
|
84
|
+
}
|
|
85
|
+
paymentUrl = await helloasso.createCheckoutIntent({
|
|
86
|
+
env,
|
|
87
|
+
token,
|
|
88
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
89
|
+
formType: settings.helloassoFormType,
|
|
90
|
+
formSlug: settings.helloassoFormSlug,
|
|
91
|
+
totalAmount,
|
|
92
|
+
payerEmail: requester && requester.email,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (paymentUrl) {
|
|
97
|
+
r.paymentUrl = paymentUrl;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await dbLayer.saveReservation(r);
|
|
101
|
+
|
|
102
|
+
// Email requester
|
|
103
|
+
try {
|
|
104
|
+
const requester = await user.getUserData(r.uid);
|
|
105
|
+
if (requester && requester.email) {
|
|
106
|
+
await emailer.send('calendar-onekite_approved', requester.email, {
|
|
107
|
+
username: requester.username,
|
|
108
|
+
itemName: r.itemName,
|
|
109
|
+
start: new Date(parseInt(r.start, 10)).toISOString(),
|
|
110
|
+
end: new Date(parseInt(r.end, 10)).toISOString(),
|
|
111
|
+
paymentUrl: paymentUrl || '',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {}
|
|
115
|
+
|
|
116
|
+
res.json({ ok: true, paymentUrl: paymentUrl || null });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
admin.refuseReservation = async function (req, res) {
|
|
120
|
+
const rid = req.params.rid;
|
|
121
|
+
const r = await dbLayer.getReservation(rid);
|
|
122
|
+
if (!r) return res.status(404).json({ error: 'not-found' });
|
|
123
|
+
|
|
124
|
+
r.status = 'refused';
|
|
125
|
+
await dbLayer.saveReservation(r);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const requester = await user.getUserData(r.uid);
|
|
129
|
+
if (requester && requester.email) {
|
|
130
|
+
await emailer.send('calendar-onekite_refused', requester.email, {
|
|
131
|
+
username: requester.username,
|
|
132
|
+
itemName: r.itemName,
|
|
133
|
+
start: new Date(parseInt(r.start, 10)).toISOString(),
|
|
134
|
+
end: new Date(parseInt(r.end, 10)).toISOString(),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {}
|
|
138
|
+
|
|
24
139
|
res.json({ ok: true });
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const endTs = Date.UTC(y+1,0,1,0,0,0);
|
|
42
|
-
|
|
43
|
-
const ids = await dbi.listReservationIdsByStartRange(startTs, endTs, 100000);
|
|
44
|
-
for (const id of ids) {
|
|
45
|
-
await dbi.deleteReservation(id);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
admin.purgeByYear = async function (req, res) {
|
|
143
|
+
const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
|
|
144
|
+
if (!/^\d{4}$/.test(year)) {
|
|
145
|
+
return res.status(400).json({ error: 'invalid-year' });
|
|
146
|
+
}
|
|
147
|
+
const y = parseInt(year, 10);
|
|
148
|
+
const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
|
|
149
|
+
const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
|
|
150
|
+
|
|
151
|
+
const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
|
|
152
|
+
let count = 0;
|
|
153
|
+
for (const rid of ids) {
|
|
154
|
+
await dbLayer.removeReservation(rid);
|
|
155
|
+
count++;
|
|
46
156
|
}
|
|
47
|
-
res.json({ ok: true,
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
157
|
+
res.json({ ok: true, removed: count });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Debug endpoint to validate HelloAsso connectivity and item loading
|
|
161
|
+
admin.debugHelloAsso = async function (req, res) {
|
|
162
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
163
|
+
const env = (settings && settings.helloassoEnv) || 'prod';
|
|
164
|
+
|
|
165
|
+
// Never expose secrets in debug output
|
|
166
|
+
const safeSettings = {
|
|
167
|
+
helloassoEnv: env,
|
|
168
|
+
helloassoClientId: settings && settings.helloassoClientId ? String(settings.helloassoClientId) : '',
|
|
169
|
+
helloassoClientSecret: settings && settings.helloassoClientSecret ? '***' : '',
|
|
170
|
+
helloassoOrganizationSlug: settings && settings.helloassoOrganizationSlug ? String(settings.helloassoOrganizationSlug) : '',
|
|
171
|
+
helloassoFormType: settings && settings.helloassoFormType ? String(settings.helloassoFormType) : '',
|
|
172
|
+
helloassoFormSlug: settings && settings.helloassoFormSlug ? String(settings.helloassoFormSlug) : '',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const out = {
|
|
176
|
+
ok: true,
|
|
177
|
+
settings: safeSettings,
|
|
178
|
+
token: { ok: false },
|
|
179
|
+
// Catalog = what you actually want for a shop (available products/material)
|
|
180
|
+
catalog: { ok: false, count: 0, sample: [], keys: [] },
|
|
181
|
+
// Sold items = items present in orders (can be 0 if no sales yet)
|
|
182
|
+
soldItems: { ok: false, count: 0, sample: [] },
|
|
183
|
+
};
|
|
184
|
+
|
|
55
185
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
186
|
+
const token = await helloasso.getAccessToken({
|
|
187
|
+
env,
|
|
188
|
+
clientId: settings.helloassoClientId,
|
|
189
|
+
clientSecret: settings.helloassoClientSecret,
|
|
190
|
+
});
|
|
191
|
+
if (!token) {
|
|
192
|
+
out.token = { ok: false, error: 'token-null' };
|
|
193
|
+
return res.json(out);
|
|
194
|
+
}
|
|
58
195
|
out.token = { ok: true };
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
196
|
+
|
|
197
|
+
// Catalog items (via /public)
|
|
198
|
+
try {
|
|
199
|
+
const { publicForm, items } = await helloasso.listCatalogItems({
|
|
200
|
+
env,
|
|
201
|
+
token,
|
|
202
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
203
|
+
formType: settings.helloassoFormType,
|
|
204
|
+
formSlug: settings.helloassoFormSlug,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const arr = Array.isArray(items) ? items : [];
|
|
208
|
+
out.catalog.ok = true;
|
|
209
|
+
out.catalog.count = arr.length;
|
|
210
|
+
out.catalog.keys = publicForm && typeof publicForm === 'object' ? Object.keys(publicForm) : [];
|
|
211
|
+
out.catalog.sample = arr.slice(0, 10).map((it) => ({
|
|
212
|
+
id: it.id,
|
|
213
|
+
name: it.name,
|
|
214
|
+
price: it.price ?? null,
|
|
215
|
+
}));
|
|
216
|
+
} catch (e) {
|
|
217
|
+
out.catalog = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [], keys: [] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Sold items
|
|
221
|
+
try {
|
|
222
|
+
const items = await helloasso.listItems({
|
|
223
|
+
env,
|
|
224
|
+
token,
|
|
225
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
226
|
+
formType: settings.helloassoFormType,
|
|
227
|
+
formSlug: settings.helloassoFormSlug,
|
|
228
|
+
});
|
|
229
|
+
const arr = Array.isArray(items) ? items : [];
|
|
230
|
+
out.soldItems.ok = true;
|
|
231
|
+
out.soldItems.count = arr.length;
|
|
232
|
+
out.soldItems.sample = arr.slice(0, 10).map((it) => ({
|
|
233
|
+
id: it.id || it.itemId || it.reference || it.name,
|
|
234
|
+
name: it.name || it.label || it.itemName,
|
|
235
|
+
price: it.price || it.amount || it.unitPrice || null,
|
|
236
|
+
}));
|
|
237
|
+
} catch (e) {
|
|
238
|
+
out.soldItems = { ok: false, error: String(e && e.message ? e.message : e), count: 0, sample: [] };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return res.json(out);
|
|
63
242
|
} catch (e) {
|
|
64
|
-
out.
|
|
65
|
-
out.
|
|
243
|
+
out.ok = false;
|
|
244
|
+
out.token = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
245
|
+
return res.json(out);
|
|
66
246
|
}
|
|
67
|
-
res.json(out);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
module.exports = {
|
|
71
|
-
getSettings,
|
|
72
|
-
saveSettings,
|
|
73
|
-
getPending,
|
|
74
|
-
purgeByYear,
|
|
75
|
-
debugHelloAsso,
|
|
76
247
|
};
|
|
248
|
+
|
|
249
|
+
module.exports = admin;
|
package/lib/api.js
CHANGED
|
@@ -1,152 +1,163 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
3
5
|
const meta = require.main.require('./src/meta');
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return
|
|
6
|
+
const user = require.main.require('./src/user');
|
|
7
|
+
const groups = require.main.require('./src/groups');
|
|
8
|
+
|
|
9
|
+
const dbLayer = require('./db');
|
|
10
|
+
const helloasso = require('./helloasso');
|
|
11
|
+
|
|
12
|
+
function toTs(v) {
|
|
13
|
+
if (!v) return NaN;
|
|
14
|
+
const d = new Date(v);
|
|
15
|
+
return d.getTime();
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
function
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
async function canRequest(uid, settings) {
|
|
19
|
+
const allowed = (settings.allowedGroups || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
20
|
+
if (!allowed.length) return true; // if empty, allow all logged in users
|
|
21
|
+
for (const g of allowed) {
|
|
22
|
+
const isMember = await groups.isMember(uid, g);
|
|
23
|
+
if (isMember) return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
return
|
|
28
|
+
function eventFor(resv) {
|
|
29
|
+
const status = resv.status;
|
|
30
|
+
const icons = { pending: '⏳', approved: '✅', refused: '⛔' };
|
|
31
|
+
const startIsoDate = new Date(parseInt(resv.start, 10)).toISOString().slice(0, 10);
|
|
32
|
+
const endIsoDate = new Date(parseInt(resv.end, 10)).toISOString().slice(0, 10);
|
|
33
|
+
return {
|
|
34
|
+
id: resv.rid,
|
|
35
|
+
title: `${icons[status] || ''} ${resv.itemName || resv.itemId}`.trim(),
|
|
36
|
+
// Day-based calendar: no hours
|
|
37
|
+
allDay: true,
|
|
38
|
+
start: startIsoDate,
|
|
39
|
+
end: endIsoDate,
|
|
40
|
+
extendedProps: {
|
|
41
|
+
status,
|
|
42
|
+
uid: resv.uid,
|
|
43
|
+
itemId: resv.itemId,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
|
|
48
|
+
const api = {};
|
|
31
49
|
|
|
32
|
-
async function
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
}
|
|
50
|
+
api.getEvents = async function (req, res) {
|
|
51
|
+
const startTs = toTs(req.query.start) || 0;
|
|
52
|
+
const endTs = toTs(req.query.end) || (Date.now() + 365 * 24 * 3600 * 1000);
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
catalogCache = { at: now, items: [], raw: null, ok: true, err: null };
|
|
45
|
-
return [];
|
|
54
|
+
const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 2000);
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const rid of ids) {
|
|
57
|
+
const r = await dbLayer.getReservation(rid);
|
|
58
|
+
if (!r) continue;
|
|
59
|
+
if (r.status !== 'pending' && r.status !== 'approved') continue;
|
|
60
|
+
out.push(eventFor(r));
|
|
46
61
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const items = hello.extractItemsFromPublic(pub);
|
|
50
|
-
catalogCache = { at: now, items, raw: pub, ok: true, err: null };
|
|
51
|
-
return items;
|
|
52
|
-
} catch (err) {
|
|
53
|
-
catalogCache = { at: now, items: [], raw: null, ok: false, err: { message: err.message, statusCode: err.statusCode, body: err.body } };
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
56
|
-
}
|
|
62
|
+
res.json(out);
|
|
63
|
+
};
|
|
57
64
|
|
|
58
|
-
async function
|
|
59
|
-
const
|
|
60
|
-
res.json({ ok: true, count: items.length, items });
|
|
61
|
-
}
|
|
65
|
+
api.getItems = async function (req, res) {
|
|
66
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const ids = await dbi.listReservationIdsByStartRange(startTs, endTs, 5000);
|
|
70
|
-
const rows = await dbi.getReservations(ids);
|
|
71
|
-
|
|
72
|
-
const events = rows.map(r => {
|
|
73
|
-
const itemNames = (r.items || []).map(it => it.name).join(', ');
|
|
74
|
-
const title = itemNames || 'Réservation';
|
|
75
|
-
const status = r.status || 'pending';
|
|
76
|
-
const icon = status === 'approved' ? '✅' : status === 'refused' ? '⛔' : '⏳';
|
|
77
|
-
return {
|
|
78
|
-
id: r.id,
|
|
79
|
-
title: `${icon} ${title}`,
|
|
80
|
-
start: toYMD(r.startTs),
|
|
81
|
-
end: toYMD(r.endTs), // end exclusive works for allDay
|
|
82
|
-
allDay: true,
|
|
83
|
-
extendedProps: {
|
|
84
|
-
status,
|
|
85
|
-
requesterUid: r.uid,
|
|
86
|
-
items: r.items || [],
|
|
87
|
-
totalCents: r.totalCents || 0,
|
|
88
|
-
days: r.days || 1,
|
|
89
|
-
}
|
|
90
|
-
};
|
|
68
|
+
const env = settings.helloassoEnv || 'prod';
|
|
69
|
+
const token = await helloasso.getAccessToken({
|
|
70
|
+
env,
|
|
71
|
+
clientId: settings.helloassoClientId,
|
|
72
|
+
clientSecret: settings.helloassoClientSecret,
|
|
91
73
|
});
|
|
92
74
|
|
|
93
|
-
|
|
94
|
-
|
|
75
|
+
if (!token) {
|
|
76
|
+
return res.json([]);
|
|
77
|
+
}
|
|
95
78
|
|
|
96
|
-
|
|
79
|
+
// Important: the /items endpoint on HelloAsso lists *sold items*.
|
|
80
|
+
// For a shop catalog, use the /public form endpoint and extract the catalog.
|
|
81
|
+
const { items: catalog } = await helloasso.listCatalogItems({
|
|
82
|
+
env,
|
|
83
|
+
token,
|
|
84
|
+
organizationSlug: settings.helloassoOrganizationSlug,
|
|
85
|
+
formType: settings.helloassoFormType,
|
|
86
|
+
formSlug: settings.helloassoFormSlug,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const normalized = (catalog || []).map((it) => ({
|
|
90
|
+
id: it.id,
|
|
91
|
+
name: it.name,
|
|
92
|
+
price: typeof it.price === 'number' ? it.price : 0,
|
|
93
|
+
})).filter(it => it.id && it.name);
|
|
94
|
+
|
|
95
|
+
res.json(normalized);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
api.createReservation = async function (req, res) {
|
|
97
99
|
const uid = req.uid;
|
|
98
|
-
if (!uid) return res.status(
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
let sumPerDay = 0;
|
|
112
|
-
for (const id of itemIds) {
|
|
113
|
-
const it = catalog.find(x => x.id === id);
|
|
114
|
-
if (it) {
|
|
115
|
-
chosen.push(it);
|
|
116
|
-
sumPerDay += Number(it.priceCents || 0);
|
|
117
|
-
} else {
|
|
118
|
-
// keep unknown item with 0 price to avoid hard fail
|
|
119
|
-
chosen.push({ id, name: `Item ${id}`, priceCents: 0 });
|
|
120
|
-
}
|
|
100
|
+
if (!uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
101
|
+
|
|
102
|
+
const settings = await meta.settings.get('calendar-onekite');
|
|
103
|
+
const ok = await canRequest(uid, settings);
|
|
104
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
105
|
+
|
|
106
|
+
const start = parseInt(toTs(req.body.start), 10);
|
|
107
|
+
const end = parseInt(toTs(req.body.end), 10);
|
|
108
|
+
const itemId = (req.body.itemId || '').toString();
|
|
109
|
+
const itemName = (req.body.itemName || '').toString();
|
|
110
|
+
|
|
111
|
+
if (!start || !end || !itemId) {
|
|
112
|
+
return res.status(400).json({ error: 'missing-fields' });
|
|
121
113
|
}
|
|
122
|
-
const totalCents = sumPerDay * days;
|
|
123
114
|
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const endTs = new Date(endYMD + 'T00:00:00Z').getTime();
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const rid = crypto.randomUUID();
|
|
127
117
|
|
|
128
118
|
const resv = {
|
|
129
|
-
|
|
130
|
-
uid
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
items: chosen,
|
|
136
|
-
days,
|
|
137
|
-
totalCents,
|
|
119
|
+
rid,
|
|
120
|
+
uid,
|
|
121
|
+
itemId,
|
|
122
|
+
itemName: itemName || itemId,
|
|
123
|
+
start,
|
|
124
|
+
end,
|
|
138
125
|
status: 'pending',
|
|
139
|
-
createdAt:
|
|
126
|
+
createdAt: now,
|
|
140
127
|
};
|
|
141
|
-
await dbi.saveReservation(resv);
|
|
142
128
|
|
|
143
|
-
|
|
144
|
-
|
|
129
|
+
// Save
|
|
130
|
+
await dbLayer.saveReservation(resv);
|
|
145
131
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
132
|
+
// Notify groups by email
|
|
133
|
+
try {
|
|
134
|
+
const notifyGroups = (settings.notifyGroups || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
135
|
+
if (notifyGroups.length) {
|
|
136
|
+
const emailer = require.main.require('./src/emailer');
|
|
137
|
+
const u = await user.getUserData(uid);
|
|
138
|
+
for (const g of notifyGroups) {
|
|
139
|
+
const members = await groups.getMembers(g, 0, -1);
|
|
140
|
+
for (const m of members) {
|
|
141
|
+
const memberUid = typeof m === 'object' && m ? (m.uid || m.userId) : m;
|
|
142
|
+
const md = await user.getUserData(memberUid);
|
|
143
|
+
if (md && md.email) {
|
|
144
|
+
await emailer.send('calendar-onekite_pending', md.email, {
|
|
145
|
+
username: md.username,
|
|
146
|
+
requester: u.username,
|
|
147
|
+
itemName: resv.itemName,
|
|
148
|
+
start: new Date(start).toISOString(),
|
|
149
|
+
end: new Date(end).toISOString(),
|
|
150
|
+
rid,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// ignore email errors
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
res.json({ ok: true, rid });
|
|
152
161
|
};
|
|
162
|
+
|
|
163
|
+
module.exports = api;
|