-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathserver.js
More file actions
2169 lines (1893 loc) · 98.8 KB
/
server.js
File metadata and controls
2169 lines (1893 loc) · 98.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const crypto = require('crypto');
const http = require('node:http');
const { EmbedBuilder } = require('discord.js');
const db = require('./lib/db');
const meetingsDb = require('./lib/meetingsDb');
const meetingsHelper = require('./lib/meetingsHelper');
const pushNotifier = require('./lib/pushNotifier');
const { getEventsChannel } = require('./lib/calcomWebhook');
const config = require('./config');
const logger = require('./lib/logger');
const PORT = parseInt(process.env.WEBHOOK_PORT || '3100', 10);
const CALCOM_SECRET = process.env.CALCOM_WEBHOOK_SECRET;
const CLIENT_ID = process.env.DISCORD_CLIENT_ID;
const CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI || `http://localhost:${PORT}/auth/callback`;
// Session store: session_id -> user details
const sessions = new Map();
// Periodic cleanup of expired sessions from Map to prevent memory leaks
setInterval(async () => {
try {
const expiredIds = await db.all(
`SELECT id FROM web_sessions WHERE expires_at < ?`, [Date.now()]
);
for (const { id } of expiredIds) {
sessions.delete(id);
}
} catch (err) {
console.error('[SESSIONS_CLEANUP] Failed to cleanup expired sessions:', err.message);
}
}, 60 * 60 * 1000); // hourly
// Simple in-memory rate limiter helper
const rateLimit = (options) => {
const hits = new Map();
const windowMs = options.windowMs || 60 * 1000;
const max = options.max || 100;
const message = options.message || 'Too many requests, please try again later.';
setInterval(() => {
const now = Date.now();
for (const [ip, timestamps] of hits) {
const active = timestamps.filter(t => now - t < windowMs);
if (active.length === 0) {
hits.delete(ip);
} else {
hits.set(ip, active);
}
}
}, windowMs);
return (req, res, next) => {
const ip = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const now = Date.now();
const timestamps = hits.get(ip) || [];
const activeTimestamps = timestamps.filter(t => now - t < windowMs);
if (activeTimestamps.length >= max) {
return res.status(429).json({ error: message });
}
activeTimestamps.push(now);
hits.set(ip, activeTimestamps);
next();
};
};
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, message: 'Too many authentication attempts. Please try again later.' });
const bookLimiter = rateLimit({ windowMs: 1 * 60 * 1000, max: 5, message: 'Too many booking attempts. Please try again later.' });
const instantLimiter = rateLimit({ windowMs: 1 * 60 * 1000, max: 5, message: 'Too many meeting requests. Please try again later.' });
// Active cities cache
let activeCitiesCache = null;
let activeCitiesCacheTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async function getActiveCities() {
const now = Date.now();
if (activeCitiesCache && (now - activeCitiesCacheTimestamp < CACHE_TTL)) {
return activeCitiesCache;
}
try {
const notion = require('./lib/notion');
const forks = await notion.getForks().catch(() => []);
activeCitiesCache = forks
.filter(f => f.properties?.Status?.select?.name === 'Active')
.map(f => notion.getCityName(f))
.filter(c => c && c !== 'UNKNOWN');
activeCitiesCacheTimestamp = now;
return activeCitiesCache;
} catch (err) {
console.error('[CACHE] Failed to refresh active cities:', err.message);
return activeCitiesCache || [];
}
}
// Timezone offset helper (DST aware)
function getTimezoneOffsetString(timeZone, date = new Date()) {
try {
const str = date.toLocaleString('en-US', { timeZone, timeZoneName: 'longOffset' });
// Match GMT+H:MM or GMT-H:MM or GMT+HH:MM or GMT-HH:MM
const match = str.match(/GMT([+-])(\d+):(\d+)/);
if (match) {
const sign = match[1];
const hours = match[2].padStart(2, '0');
const minutes = match[3].padStart(2, '0');
return `${sign}${hours}:${minutes}`;
}
if (str.includes('GMT') && !str.match(/GMT[+-]/)) {
return '+00:00';
}
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.warn(`[TIMEZONE] Failed to calculate offset for ${timeZone}:`, e.message);
}
}
return '+05:30'; // Default fallback
}
function startWebServer(client) {
const app = express();
// Trust reverse proxy (Nginx) to correctly determine HTTPS
app.set('trust proxy', 1);
// Middleware
app.use(cookieParser());
// Custom body parser to handle raw body for Cal.com signature verification and JSON elsewhere
app.use((req, res, next) => {
if (req.url === '/webhooks/calcom') {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
req.rawBody = Buffer.concat(chunks).toString('utf8');
next();
});
} else {
express.json({ limit: '100kb' })(req, res, next);
}
});
app.use(express.static(path.join(__dirname, 'public')));
app.get('/favicon.ico', (req, res) => {
res.redirect('/favicon.svg');
});
// Auth verification helper middleware
async function checkAuth(req, res, next) {
const sessionId = req.cookies.session_id;
if (sessionId) {
if (sessions.has(sessionId)) {
req.user = sessions.get(sessionId);
return next();
}
try {
const session = await db.get(`SELECT * FROM web_sessions WHERE id = ? AND expires_at > ?`, [sessionId, Date.now()]);
if (session) {
const userDetails = {
id: session.user_id,
username: session.username,
email: session.email
};
sessions.set(sessionId, userDetails);
req.user = userDetails;
return next();
}
} catch (err) {
console.error('[AUTH_ERROR] Session check failed:', err);
}
}
res.status(401).json({ error: 'Unauthorized' });
}
// Auth verification helper middleware for pages (redirects instead of returning JSON)
async function checkPageAuth(req, res, next) {
const sessionId = req.cookies.session_id;
if (sessionId) {
if (sessions.has(sessionId)) {
req.user = sessions.get(sessionId);
return next();
}
try {
const session = await db.get(`SELECT * FROM web_sessions WHERE id = ? AND expires_at > ?`, [sessionId, Date.now()]);
if (session) {
const userDetails = {
id: session.user_id,
username: session.username,
email: session.email
};
sessions.set(sessionId, userDetails);
req.user = userDetails;
return next();
}
} catch (err) {
console.error('[PAGE_AUTH_ERROR] Session check failed:', err);
}
}
res.redirect('/');
}
// ============================================
// DISCORD OAUTH2 ROUTES
// ============================================
app.get('/login', (req, res) => {
if (!CLIENT_ID) {
return res.send('OAuth Error: DISCORD_CLIENT_ID is not configured in .env');
}
const protocol = req.secure || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
const host = req.get('host');
let redirectUri = process.env.REDIRECT_URI;
if (!redirectUri || !redirectUri.includes(host)) {
redirectUri = `${protocol}://${host}/auth/callback`;
}
// Support returning to a specific page after auth (e.g. booking page)
if (req.query.redirect) {
res.cookie('auth_return_to', req.query.redirect, {
maxAge: 10 * 60 * 1000, // 10 minutes
httpOnly: true,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
});
}
// Generate state token for CSRF protection
const state = crypto.randomBytes(16).toString('hex');
res.cookie('oauth_state', state, {
maxAge: 10 * 60 * 1000, // 10 minutes
httpOnly: true,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
});
const discordAuthUrl = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify+email+guilds.join&state=${state}`;
res.redirect(discordAuthUrl);
});
app.get('/auth/callback', authLimiter, async (req, res) => {
const { code, state } = req.query;
const cookieState = req.cookies.oauth_state;
if (!state || !cookieState || state !== cookieState) {
return res.status(400).send('OAuth Error: State parameter validation failed.');
}
res.clearCookie('oauth_state');
if (!code) {
return res.status(400).send('OAuth Error: Missing authorization code.');
}
try {
const protocol = req.secure || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
const host = req.get('host');
let redirectUri = process.env.REDIRECT_URI;
if (!redirectUri || !redirectUri.includes(host)) {
redirectUri = `${protocol}://${host}/auth/callback`;
}
// Exchange code for token
const tokenResponse = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
}),
});
if (!tokenResponse.ok) {
const errText = await tokenResponse.text();
throw new Error(`Token exchange failed: ${errText}`);
}
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
// Fetch user profile
const userResponse = await fetch('https://discord.com/api/users/@me', {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!userResponse.ok) {
throw new Error('Failed to fetch user info from Discord');
}
const userData = await userResponse.json();
// Check/Add user to target guild and verify contributor status
let hasContributorRole = false;
let cityRoleName = null;
let cityRoleId = null;
try {
const targetGuildId = process.env.GUILD_ID || '1480617556292272260';
const targetGuild = client.guilds.cache.get(targetGuildId) || await client.guilds.fetch(targetGuildId).catch(() => null);
if (!targetGuild) {
console.error(`[AUTH_ERROR] Target guild ${targetGuildId} could not be resolved.`);
return res.status(500).send('Authentication failed: Target Discord server could not be resolved.');
}
// Try to fetch member; if they aren't on the server, add them automatically!
let targetMember = await targetGuild.members.fetch(userData.id).catch(() => null);
if (!targetMember) {
console.log(`[AUTH] Adding guest user ${userData.username} (${userData.id}) to guild...`);
await targetGuild.members.add(userData.id, {
accessToken: accessToken
}).catch(joinErr => {
console.error('[AUTH_JOIN_ERROR] Failed to add user to guild:', joinErr.message);
});
// Fetch again to see if they were successfully added
targetMember = await targetGuild.members.fetch(userData.id).catch(() => null);
}
if (targetMember) {
hasContributorRole = targetMember.roles.cache.some(r =>
r.name.toLowerCase() === 'contributor' ||
r.id === '1506019068132462804'
);
// Resolve Discord city role for contributor
try {
const activeCities = await getActiveCities();
const foundCityRole = targetMember.roles.cache.find(r => {
const rName = r.name.toLowerCase();
return activeCities.some(city => rName === `contributor-${city.toLowerCase()}`);
});
if (foundCityRole) {
cityRoleName = foundCityRole.name.replace(/^contributor-/i, '').trim();
const matchedCity = activeCities.find(c => c.toLowerCase() === cityRoleName.toLowerCase());
if (matchedCity) {
cityRoleName = matchedCity;
}
cityRoleId = foundCityRole.id;
}
} catch (roleErr) {
console.warn('[AUTH_CALLBACK] Failed to resolve member city role:', roleErr.message);
}
}
} catch (authRestrictErr) {
console.error('[AUTH_RESTRICT_ERROR] Target guild resolution failed:', authRestrictErr);
}
// Create session
const sessionId = `session_${crypto.randomBytes(16).toString('hex')}`;
const userDetails = {
id: userData.id,
username: userData.username,
email: userData.email || null,
avatar: userData.avatar || null
};
sessions.set(sessionId, userDetails);
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000; // 30 days
await db.run(
`INSERT INTO web_sessions (id, user_id, username, email, avatar, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[sessionId, userData.id, userData.username, userData.email || null, userData.avatar || null, expiresAt]
).catch(err => {
console.error('[AUTH_CALLBACK] Failed to save session to DB:', err.message);
});
// Sync email to meeting_email_preferences table automatically from OAuth profile
if (userData.email) {
await meetingsDb.setUserEmail(userData.id, userData.email).catch(err => {
console.error('[AUTH_CALLBACK] Failed to save email preference:', err.message);
});
}
// Write record or update username in user_availability table ONLY if they are a contributor (host)
if (hasContributorRole) {
let defaultTitle = userData.global_name || userData.username;
const existingUser = await db.get(`SELECT 1 FROM user_availability WHERE discord_id = ?`, [userData.id]);
if (!existingUser) {
await db.run(
`INSERT INTO user_availability (discord_id, username, email, timezone, weekly_hours, booking_link, title, description, associated_role_id, avatar)
VALUES (?, ?, ?, 'Asia/Kolkata', '{"monday":[{"start":"09:00","end":"17:00"}],"tuesday":[{"start":"09:00","end":"17:00"}],"wednesday":[{"start":"09:00","end":"17:00"}],"thursday":[{"start":"09:00","end":"17:00"}],"friday":[{"start":"09:00","end":"17:00"}],"saturday":[],"sunday":[]}', ?, ?, '', ?, ?)`,
[userData.id, userData.username, userData.email || null, `link_${userData.username.toLowerCase().substring(0, 10)}`, defaultTitle, cityRoleId || null, userData.avatar || null]
);
} else {
// Update email and associated_role_id if it changed
await db.run(
`UPDATE user_availability
SET email = ?, associated_role_id = ?, avatar = ?
WHERE discord_id = ?`,
[userData.email || null, cityRoleId || null, userData.avatar || null, userData.id]
);
}
}
// If user has an email, find meetings where they were invited as an external guest
if (userData.email) {
try {
const userEmail = userData.email.trim().toLowerCase();
const escapedEmail = userEmail.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
const pendingMeetings = await db.all(
`SELECT id, temp_channel_id, external_emails FROM meetings
WHERE status != 'cancelled'
AND external_emails LIKE '%' || ? || '%' ESCAPE '\\'`,
[escapedEmail]
);
for (const meet of pendingMeetings) {
let parsedEmails = [];
try {
parsedEmails = JSON.parse(meet.external_emails || '[]');
} catch (e) {
parsedEmails = [];
}
if (parsedEmails.some(e => e.toLowerCase() === userEmail)) {
// 1. Add as a user attendee
await meetingsDb.addAttendee(meet.id, 'user', userData.id).catch(() => {});
// 2. Remove email from external_emails JSON array
const newExternal = parsedEmails.filter(e => e.toLowerCase() !== userEmail);
await db.run(
`UPDATE meetings SET external_emails = ? WHERE id = ?`,
[JSON.stringify(newExternal), meet.id]
).catch(() => {});
// 3. Grant permission to see/connect to the VC channel if provisioned
if (meet.temp_channel_id) {
try {
const targetGuildId = process.env.GUILD_ID || '1480617556292272260';
const targetGuild = client.guilds.cache.get(targetGuildId) || await client.guilds.fetch(targetGuildId).catch(() => null);
if (targetGuild) {
const vcChannel = targetGuild.channels.cache.get(meet.temp_channel_id);
if (vcChannel) {
await vcChannel.permissionOverwrites.edit(userData.id, {
ViewChannel: true,
Connect: true,
Speak: true
}, { reason: 'External guest authenticated with Discord' }).catch(() => {});
}
}
} catch (permErr) {
console.warn('[AUTH_CALLBACK] Failed to update VC permissions for guest:', permErr.message);
}
}
}
}
} catch (pendingErr) {
console.error('[AUTH_CALLBACK] Failed to resolve pending guest meetings:', pendingErr.message);
}
}
// Retrieve return destination
const rawReturnTo = req.cookies.auth_return_to || '/dashboard';
res.clearCookie('auth_return_to');
const returnTo = (rawReturnTo.startsWith('/') && !rawReturnTo.startsWith('//') && !rawReturnTo.startsWith('/\\')) ? rawReturnTo : '/dashboard';
// Set cookie
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});
// Redirect appropriately
if (!hasContributorRole && returnTo === '/dashboard') {
res.redirect('/');
} else {
res.redirect(returnTo);
}
} catch (error) {
console.error('[AUTH_ERROR]', error);
res.status(500).send(`Authentication failed: ${error.message}`);
}
});
app.get('/logout', async (req, res) => {
const sessionId = req.cookies.session_id;
if (sessionId) {
sessions.delete(sessionId);
await db.run('DELETE FROM web_sessions WHERE id = ?', [sessionId]).catch(() => {});
}
res.clearCookie('session_id', { path: '/' });
res.redirect('/');
});
app.get('/dashboard', checkPageAuth, async (req, res) => {
const userId = req.user.id;
// Verify user holds contributor role (by checking user_availability presence)
const isHost = await db.get(`SELECT 1 FROM user_availability WHERE discord_id = ?`, [userId]);
if (isHost) {
return res.sendFile(path.join(__dirname, 'public/dashboard.html'));
} else {
return res.status(403).send('Access Denied: You must be a member of the Bits&Bytes Discord server and hold the "contributor" role to view the dashboard.');
}
});
// ============================================
// API ENDPOINTS
// ============================================
async function resolveMemberRoleAndCity(discordId, associatedRoleId) {
let role = 'contributor';
let cityName = null;
const targetGuildId = process.env.GUILD_ID || '1480617556292272260';
const guild = client.guilds.cache.get(targetGuildId) || await client.guilds.fetch(targetGuildId).catch(() => null);
if (guild) {
const member = await guild.members.fetch(discordId).catch(() => null);
if (member) {
if (member.roles.cache.has('1506019032015310949')) {
role = 'exec_leader';
} else if (member.roles.cache.has('1506323726223016149')) {
role = 'dep_lead';
} else if (member.roles.cache.has(process.env.FORK_LEAD_ROLE_ID || '1490410901147488286')) {
role = 'fork_lead';
}
let roleIdToCheck = associatedRoleId;
if (!roleIdToCheck) {
const activeCities = await getActiveCities();
const foundCityRole = member.roles.cache.find(r => {
const rName = r.name.toLowerCase();
return activeCities.some(city => rName === `contributor-${city.toLowerCase()}`);
});
if (foundCityRole) {
roleIdToCheck = foundCityRole.id;
}
}
if (roleIdToCheck) {
const roleObj = guild.roles.cache.get(roleIdToCheck);
if (roleObj) {
cityName = roleObj.name.replace(/^contributor-/i, '').trim();
const activeCities = await getActiveCities();
const matchedCity = activeCities.find(c => c.toLowerCase() === cityName.toLowerCase());
if (matchedCity) {
cityName = matchedCity;
}
}
}
}
}
return { role, cityName };
}
app.get('/api/user/me', checkAuth, async (req, res) => {
try {
let user = await db.get(`SELECT * FROM user_availability WHERE discord_id = ?`, [req.user.id]);
let isContributor = false;
// Check contributor role
const guildId = process.env.GUILD_ID;
const guild = guildId ? client.guilds.cache.get(guildId) : client.guilds.cache.first();
if (guild) {
const member = await guild.members.fetch(req.user.id).catch(() => null);
isContributor = member ? member.roles.cache.some(r =>
r.name.toLowerCase() === 'contributor' ||
r.id === '1506019068132462804'
) : false;
}
const { role, cityName } = await resolveMemberRoleAndCity(req.user.id, user ? user.associated_role_id : null);
if (user) {
if (user.associated_role_id && guild) {
const rObj = guild.roles.cache.get(user.associated_role_id);
if (rObj) {
user.associated_role_name = rObj.name;
}
}
user.isContributor = isContributor;
user.role = role;
user.cityName = cityName;
} else {
user = {
discord_id: req.user.id,
username: req.user.username,
email: req.user.email,
avatar: req.user.avatar || null,
isGuest: true,
isContributor,
role,
cityName
};
}
res.json(user);
} catch (err) {
console.error('[API_USER_ME_ERROR]', err);
res.status(500).json({ error: 'Failed to retrieve profile' });
}
});
// Update availability config
app.post('/api/user/availability', checkAuth, async (req, res) => {
const { title, booking_link, description, timezone, weekly_hours, calcom_event_type_id } = req.body;
if (!title || !booking_link) {
return res.status(400).json({ error: 'Title and Booking Handle are required' });
}
// Validate booking link format
if (!/^[a-zA-Z0-9-_]+$/.test(booking_link)) {
return res.status(400).json({ error: 'Invalid booking handle characters' });
}
// Validate timezone format
if (timezone) {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
} catch (e) {
return res.status(400).json({ error: 'Invalid timezone name' });
}
}
try {
// Check if weekly_hours is empty (no active day)
let parsedHours = {};
try {
parsedHours = JSON.parse(weekly_hours || '{}');
} catch (e) {
parsedHours = {};
}
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
let hasHours = false;
days.forEach(day => {
if (parsedHours[day] && parsedHours[day].length > 0) {
hasHours = true;
}
});
let finalWeeklyHours = weekly_hours;
if (!hasHours) {
const defaultSchedule = {
monday: [{ start: '09:00', end: '17:00' }],
tuesday: [{ start: '09:00', end: '17:00' }],
wednesday: [{ start: '09:00', end: '17:00' }],
thursday: [{ start: '09:00', end: '17:00' }],
friday: [{ start: '09:00', end: '17:00' }],
saturday: [],
sunday: []
};
finalWeeklyHours = JSON.stringify(defaultSchedule);
}
// Check for uniqueness of link
const otherUser = await db.get(
`SELECT discord_id FROM user_availability WHERE booking_link = ? AND discord_id != ?`,
[booking_link, req.user.id]
);
if (otherUser) {
return res.status(400).json({ error: 'Booking handle is already taken by another member' });
}
await db.run(
`UPDATE user_availability
SET title = ?, booking_link = ?, description = ?, timezone = ?, weekly_hours = ?, calcom_event_type_id = ?
WHERE discord_id = ?`,
[title, booking_link, description, timezone || 'Asia/Kolkata', finalWeeklyHours, calcom_event_type_id || null, req.user.id]
);
// Sync email address to email preferences table too
if (req.user.email) {
await meetingsDb.setUserEmail(req.user.id, req.user.email);
}
res.json({ success: true });
} catch (err) {
console.error('[API_UPDATE_ERROR]', err);
res.status(500).json({ error: 'Database update failed' });
}
});
// Fetch available event types from Cal.com
app.get('/api/calcom/event-types', checkAuth, async (req, res) => {
try {
const calcom = require('./lib/calcom');
const eventTypes = await calcom.getEventTypes();
res.json(eventTypes);
} catch (err) {
console.error('[CALCOM_API_ERROR]', err);
res.status(500).json({ error: 'Failed to retrieve event types' });
}
});
app.get('/api/users', async (req, res) => {
try {
const users = await db.all(`SELECT discord_id, username, title, booking_link, description, timezone, weekly_hours, calcom_event_type_id, associated_role_id, avatar FROM user_availability WHERE booking_link IS NOT NULL`);
const resolvedUsers = await Promise.all(users.map(async (u) => {
const { role, cityName } = await resolveMemberRoleAndCity(u.discord_id, u.associated_role_id);
return {
...u,
role,
cityName
};
}));
res.json(resolvedUsers);
} catch (err) {
console.error('[API_USERS_ERROR]', err);
res.status(500).json({ error: 'Database query failed' });
}
});
// Returns the list of active fork city slugs for the scope selector UI
app.get('/api/forks', async (req, res) => {
try {
const notion = require('./lib/notion');
const forks = await notion.getForks();
const activeForks = forks
.filter(f => f.properties?.Status?.select?.name === 'Active')
.map(f => {
const city = notion.getCityName(f);
return city && city !== 'UNKNOWN' ? city.toLowerCase().replace(/\s+/g, '-') : null;
})
.filter(Boolean)
.sort();
res.json(activeForks);
} catch (err) {
console.error('[API_FORKS_ERROR]', err);
res.status(500).json({ error: 'Failed to fetch forks' });
}
});
// Returns status of the multi-bot listener pool (total configured, busy, available)
app.get('/api/listeners/status', (req, res) => {
try {
const listenerManager = require('./lib/listenerManager');
res.json(listenerManager.getListenerStatus());
} catch (err) {
console.error('[API_LISTENERS_ERROR]', err);
res.status(500).json({ error: 'Failed to fetch listener status' });
}
});
// Helper to pick the right Cal.com event type ID based on meeting duration
function getCalcomEventTypeId(duration) {
const d = parseInt(duration, 10);
if (d <= 15) return process.env.CALCOM_EVENT_TYPE_15 || null;
if (d <= 30) return process.env.CALCOM_EVENT_TYPE_30 || null;
return process.env.CALCOM_EVENT_TYPE_45 || null;
}
// Helper to calculate free slots in UTC for a single host
// NOTE: Always uses local DB — never Cal.com slots API.
// Reason: We share one central Google Calendar across all members/forks.
// Using Cal.com slots would mark a time as "busy" org-wide the moment
// anyone books it, preventing two different people from having parallel
// meetings at the same time. Per-person local DB avoids this.
async function getHostFreeSlotsUTC(host, dateStr, duration, primaryTimeZone) {
const checkDate = new Date(`${dateStr}T12:00:00`);
const offset = getTimezoneOffsetString(primaryTimeZone, checkDate);
const localStartISO = `${dateStr}T00:00:00${offset}`;
const localEndISO = `${dateStr}T23:59:59${offset}`;
const startUTC = new Date(localStartISO).toISOString();
const endUTC = new Date(localEndISO).toISOString();
// Local DB calculation
const hostOffset = getTimezoneOffsetString(host.timezone, checkDate);
const weeklyHours = JSON.parse(host.weekly_hours || '{}');
// We get the meetings for this host
const meetings = await db.all(`
SELECT m.scheduled_time, m.end_time
FROM meetings m
LEFT JOIN meeting_attendees ma ON m.id = ma.meeting_id
WHERE (m.creator_id = ? OR ma.discord_id = ?)
AND m.status != 'cancelled'
`, [host.discord_id, host.discord_id]);
const utcSlots = [];
const primaryDateObj = new Date(localStartISO);
const checkDates = [
new Date(primaryDateObj.getTime() - 24 * 60 * 60 * 1000), // yesterday
primaryDateObj, // today
new Date(primaryDateObj.getTime() + 24 * 60 * 60 * 1000) // tomorrow
];
for (const dObj of checkDates) {
const dStr = dObj.toISOString().split('T')[0];
const dayOfWeekName = dObj.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase();
const dailySlots = weeklyHours[dayOfWeekName] || [];
for (const range of dailySlots) {
const [startH, startM] = range.start.split(':').map(Number);
const [endH, endM] = range.end.split(':').map(Number);
let currentMin = startH * 60 + startM;
const endMin = endH * 60 + endM;
while (currentMin + duration <= endMin) {
const h = String(Math.floor(currentMin / 60)).padStart(2, '0');
const m = String(currentMin % 60).padStart(2, '0');
const timeStr = `${h}:${m}`;
// Calculate slot start time in host's timezone
const slotStartISO = `${dStr}T${timeStr}:00${hostOffset}`;
const slotStartMs = Date.parse(slotStartISO);
const slotEndMs = slotStartMs + duration * 60 * 1000;
// Check if this slot falls within our primary host's date range (startUTC to endUTC)
if (slotStartMs >= Date.parse(startUTC) && slotStartMs <= Date.parse(endUTC) && slotStartMs > Date.now()) {
// Check overlap
const overlaps = meetings.some(m => {
const mStart = Number(m.scheduled_time);
const mEnd = m.end_time ? Number(m.end_time) : (mStart + 30 * 60 * 1000);
return (slotStartMs < mEnd && slotEndMs > mStart);
});
if (!overlaps) {
utcSlots.push(new Date(slotStartMs).toISOString());
}
}
currentMin += 15; // 15-minute increments for start times
}
}
}
return utcSlots;
}
// Calculate free slots for a host
app.get('/api/availability/:bookingLink', async (req, res) => {
const { bookingLink } = req.params;
const { date } = req.query; // format YYYY-MM-DD
const duration = parseInt(req.query.duration || 30, 10);
const additionalHosts = req.query.additional ? req.query.additional.split(',') : [];
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ error: 'Valid date parameter YYYY-MM-DD required' });
}
// Validate calendar date correctness
const checkDate = new Date(`${date}T00:00:00+05:30`);
if (isNaN(checkDate.getTime())) {
return res.status(400).json({ error: 'Invalid calendar date' });
}
try {
const primaryHost = await db.get(`SELECT * FROM user_availability WHERE booking_link = ?`, [bookingLink]);
if (!primaryHost) {
return res.status(404).json({ error: 'Primary host not found' });
}
// Fetch primary host slots
let commonSlots = await getHostFreeSlotsUTC(primaryHost, date, duration, primaryHost.timezone);
// Fetch and intersect additional host slots
for (const handle of additionalHosts) {
if (!handle.trim()) continue;
const addHost = await db.get(`SELECT * FROM user_availability WHERE booking_link = ?`, [handle.trim()]);
if (addHost) {
const hostSlots = await getHostFreeSlotsUTC(addHost, date, duration, primaryHost.timezone);
// Intersect
commonSlots = commonSlots.filter(slot => hostSlots.includes(slot));
}
}
// Convert common UTC slots back to primary host's timezone time strings (HH:MM)
const resultSlots = [];
for (const utcTime of commonSlots) {
const dateObj = new Date(utcTime);
const localTimeStr = dateObj.toLocaleTimeString('en-US', {
timeZone: primaryHost.timezone,
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const parts = localTimeStr.split(':');
if (parts.length >= 2) {
const formatted = `${parts[0].padStart(2, '0')}:${parts[1].padStart(2, '0')}`;
if (!resultSlots.includes(formatted)) {
resultSlots.push(formatted);
}
}
}
resultSlots.sort();
res.json(resultSlots);
} catch (err) {
console.error('[AVAILABILITY_API_ERROR]', err);
res.status(500).json({ error: 'Failed to calculate slots' });
}
});
// ============================================
// MEETING PAGE ROUTES
// ============================================
// Serve meeting page HTML
app.get('/m/:meetCode', async (req, res) => {
const { meetCode } = req.params;
if (!/^[a-z]{3}-[a-z]{4}-[a-z]{3}$/.test(meetCode)) {
return res.status(404).sendFile(path.join(__dirname, 'public/index.html'));
}
const meeting = await meetingsDb.getMeetingByCode(meetCode).catch(() => null);
if (!meeting) {
return res.status(404).sendFile(path.join(__dirname, 'public/meet.html'));
}
res.sendFile(path.join(__dirname, 'public/meet.html'));
});
// API: Get meeting details by code
app.get('/api/meeting/:meetCode', async (req, res) => {
try {
const { meetCode } = req.params;
const meeting = await meetingsDb.getMeetingByCode(meetCode);
if (!meeting) {
return res.status(404).json({ error: 'Meeting not found.' });
}
// Resolve attendee details (username, avatar, display name)
const guild = client.guilds.cache.first();
const attendeesResolved = [];
for (const att of (meeting.attendees || [])) {
try {
if (att.type === 'user') {
const member = guild ? await guild.members.fetch(att.discordId).catch(() => null) : null;
const userAvail = await db.get('SELECT title, avatar FROM user_availability WHERE discord_id = ?', [att.discordId]);
attendeesResolved.push({
discordId: att.discordId,
type: att.type,
username: member ? member.user.username : 'Unknown',
displayName: userAvail?.title || (member ? member.displayName : 'Unknown'),
avatar: userAvail?.avatar || (member ? member.user.avatar : null)
});
} else if (att.type === 'role') {
attendeesResolved.push({
discordId: att.discordId,
type: 'role',
username: 'Role Invite',
displayName: guild ? (guild.roles.cache.get(att.discordId)?.name || 'Role') : 'Role',
avatar: null
});
}
} catch (e) {
attendeesResolved.push({ discordId: att.discordId, type: att.type, username: 'Unknown', displayName: 'Unknown', avatar: null });
}
}
// Get reschedule history and count
const rescheduleHistory = await meetingsDb.getRescheduleHistory(meeting.id);
const rescheduleCount = await meetingsDb.getRescheduleCount(meeting.id);
// Resolve rescheduled_by names in history
for (const entry of rescheduleHistory) {
try {
const member = guild ? await guild.members.fetch(entry.rescheduled_by).catch(() => null) : null;
entry.rescheduled_by_name = member ? member.displayName : entry.rescheduled_by;
} catch (e) {
entry.rescheduled_by_name = entry.rescheduled_by;
}
}
res.json({
...meeting,
attendeesResolved,
rescheduleHistory,
rescheduleCount,
guild_id: guild ? guild.id : null
});
} catch (err) {
console.error('[API_MEETING_ERROR]', err);
res.status(500).json({ error: 'Failed to fetch meeting details.' });
}
});
// API: Reschedule a meeting
app.post('/api/meeting/:meetCode/reschedule', checkAuth, async (req, res) => {
try {
const { meetCode } = req.params;
const { date, time, reason } = req.body;
if (!date || !time || !reason) {
return res.status(400).json({ error: 'Date, time, and reason are required.' });
}
const meeting = await meetingsDb.getMeetingByCode(meetCode);
if (!meeting) {
return res.status(404).json({ error: 'Meeting not found.' });
}
// Permission check: creator or booker