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 CHANGED
@@ -1,76 +1,249 @@
1
1
  'use strict';
2
2
 
3
3
  const meta = require.main.require('./src/meta');
4
- const dbi = require('./db');
5
- const api = require('./api');
4
+ const user = require.main.require('./src/user');
5
+ const emailer = require.main.require('./src/emailer');
6
6
 
7
- async function getSettings(req, res) {
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
- // mask secret
10
- if (settings && settings.helloassoClientSecret) settings.helloassoClientSecret = '***';
11
- res.json({ ok: true, settings: settings || {} });
12
- }
13
-
14
- async function saveSettings(req, res) {
15
- const body = req.body || {};
16
- // If secret is '***', do not overwrite.
17
- const current = await meta.settings.get('calendar-onekite') || {};
18
- const next = { ...current, ...body };
19
- if (body.helloassoClientSecret === '***') {
20
- next.helloassoClientSecret = current.helloassoClientSecret;
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
- await meta.settings.set('calendar-onekite', next);
23
- // invalidate catalog cache
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 getPending(req, res) {
28
- const ids = await dbi.listAllIds(5000);
29
- const rows = await dbi.getReservations(ids);
30
- const pending = rows.filter(r => (r.status || 'pending') === 'pending')
31
- .sort((a,b)=> (b.createdAt||0)-(a.createdAt||0))
32
- .slice(0, 200);
33
- res.json({ ok: true, pending });
34
- }
35
-
36
- async function purgeByYear(req, res) {
37
- const year = String((req.body||{}).year || '').trim();
38
- if (!/^\d{4}$/.test(year)) return res.status(400).json({ status: { code: 'bad-request', message: 'Année invalide (YYYY)' } });
39
- const y = Number(year);
40
- const startTs = Date.UTC(y,0,1,0,0,0);
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, deleted: ids.length });
48
- }
49
-
50
- async function debugHelloAsso(req, res) {
51
- const settings = await meta.settings.get('calendar-onekite') || {};
52
- const masked = { ...settings };
53
- if (masked.helloassoClientSecret) masked.helloassoClientSecret = '***';
54
- const out = { ok: true, settings: masked };
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
- // token + catalog
57
- const items = await api._getCatalogItems();
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
- out.catalog = { ok: true, count: items.length, sample: items.slice(0, 10) };
60
- // also expose cache error if any
61
- const cache = api._catalogCache();
62
- if (cache && cache.err) out.catalog.err = cache.err;
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.token = { ok: false, message: e.message };
65
- out.catalog = { ok: false, count: 0, sample: [], message: e.message };
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 dbi = require('./db');
5
- const hello = require('./helloasso');
6
-
7
- function parseDateParam(s) {
8
- // FullCalendar sends ISO date/time. Accept 'YYYY-MM-DD' too.
9
- const d = new Date(s);
10
- if (!s || Number.isNaN(d.getTime())) return null;
11
- return d;
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 toYMD(ts) {
15
- const d = new Date(Number(ts));
16
- const y = d.getUTCFullYear();
17
- const m = String(d.getUTCMonth()+1).padStart(2,'0');
18
- const da = String(d.getUTCDate()).padStart(2,'0');
19
- return `${y}-${m}-${da}`;
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 daysBetweenInclusive(startYMD, endYMDExclusive) {
23
- // FullCalendar selection end is exclusive. We compute number of days selected.
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 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
- let catalogCache = { at: 0, items: [], raw: null, ok: false, err: null };
48
+ const api = {};
31
49
 
32
- async function getSettings() {
33
- const settings = await meta.settings.get('calendar-onekite');
34
- return settings || {};
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
- async function getCatalogItems() {
38
- const settings = await getSettings();
39
- const now = Date.now();
40
- if (catalogCache.ok && (now - catalogCache.at) < 5*60*1000) {
41
- return catalogCache.items;
42
- }
43
- if (!settings.helloassoClientId || !settings.helloassoClientSecret || !settings.helloassoOrganizationSlug || !settings.helloassoFormSlug || !settings.helloassoFormType) {
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
- try {
48
- const pub = await hello.getShopCatalog(settings);
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 getCatalog(req, res) {
59
- const items = await getCatalogItems();
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
- async function getEvents(req, res) {
64
- const start = parseDateParam(req.query.start);
65
- const end = parseDateParam(req.query.end);
66
- const startTs = start ? start.getTime() : Date.now() - 365*86400000;
67
- const endTs = end ? end.getTime() : Date.now() + 365*86400000;
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
- res.json(events);
94
- }
75
+ if (!token) {
76
+ return res.json([]);
77
+ }
95
78
 
96
- async function createReservation(req, res) {
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(403).json({ status: { code: 'forbidden', message: 'Not logged in' } });
99
-
100
- const body = req.body || {};
101
- const startYMD = body.start;
102
- const endYMD = body.end;
103
- const itemIds = Array.isArray(body.itemIds) ? body.itemIds.map(String) : [];
104
- if (!startYMD || !endYMD) return res.status(400).json({ status: { code: 'bad-request', message: 'Missing dates' } });
105
- if (!itemIds.length) return res.status(400).json({ status: { code: 'bad-request', message: 'Missing itemIds' } });
106
-
107
- const days = daysBetweenInclusive(startYMD, endYMD);
108
-
109
- const catalog = await getCatalogItems();
110
- const chosen = [];
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 id = await dbi.nextId();
125
- const startTs = new Date(startYMD + 'T00:00:00Z').getTime();
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
- id,
130
- uid: String(uid),
131
- startTs,
132
- endTs,
133
- startYMD,
134
- endYMD,
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: Date.now(),
126
+ createdAt: now,
140
127
  };
141
- await dbi.saveReservation(resv);
142
128
 
143
- res.json({ ok: true, reservation: resv });
144
- }
129
+ // Save
130
+ await dbLayer.saveReservation(resv);
145
131
 
146
- module.exports = {
147
- getEvents,
148
- getCatalog,
149
- createReservation,
150
- _getCatalogItems: getCatalogItems,
151
- _catalogCache: () => catalogCache,
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;
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ const controllers = {};
4
+
5
+ controllers.renderCalendar = async function (req, res) {
6
+ res.render('calendar-onekite', {
7
+ title: 'Calendar',
8
+ });
9
+ };
10
+
11
+ module.exports = controllers;