diff --git a/mentor-match-app/.gitignore b/mentor-match-app/.gitignore index 65d5881..4efbded 100644 --- a/mentor-match-app/.gitignore +++ b/mentor-match-app/.gitignore @@ -23,6 +23,8 @@ .env.development .env.production server/.env +server/slack +**/.env.local npm-debug.log* yarn-debug.log* diff --git a/mentor-match-app/functions/index.js b/mentor-match-app/functions/index.js index 628d495..13d3af8 100644 --- a/mentor-match-app/functions/index.js +++ b/mentor-match-app/functions/index.js @@ -1,3 +1,4 @@ + const { onRequest } = require('firebase-functions/v2/https') const { setGlobalOptions } = require('firebase-functions/v2') const express = require('express') @@ -65,9 +66,259 @@ app.get('/api/orcid/record/:orcidId', async (req, res) => { } }) +// Add Slack OAuth start + callback on the Express app +const admin = require('firebase-admin') + +// Lazily load slack function module for lightweight cold starts +let _slackModule = null +function ensureSlackModule () { + if (!_slackModule) { + _slackModule = require('./slack') + } +} + +app.get('/api/auth/slack/start', (req, res) => { + const clientId = process.env.SLACK_CLIENT_ID + if (!clientId) return res.status(500).send('SLACK_CLIENT_ID not configured') + + const scope = encodeURIComponent('chat:write,channels:read,groups:write,im:write') + const userScope = encodeURIComponent('users:read') + const redirectUri = 'https://mentor.accel.ai/api/auth/slack/callback' + const state = encodeURIComponent(req.query.state || '') + + const url = `https://slack.com/oauth/v2/authorize?client_id=${clientId}&scope=${scope}&user_scope=${userScope}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}` + return res.redirect(url) +}) + +const crypto = require('crypto') + +// Helper: Encrypt sensitive token +function encryptToken (text) { + const secret = process.env.SLACK_SIGNING_SECRET + if (!text || !secret) return text + try { + const iv = crypto.randomBytes(16) + const key = crypto.createHash('sha256').update(secret).digest() + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv) + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + return iv.toString('hex') + ':' + encrypted.toString('hex') + } catch (e) { + console.error('Encryption failed', e) + return text // Fallback to plain text if encryption fails (dev mode?) + } +} + +app.get('/api/auth/slack/callback', async (req, res) => { + const code = req.query.code + const state = req.query.state // expected to be your app user id (passed from client) + if (!code) return res.status(400).send('Missing code') + if (!state) return res.status(400).send('Missing state (app user id)') + + const clientId = process.env.SLACK_CLIENT_ID + const clientSecret = process.env.SLACK_CLIENT_SECRET + const redirectUri = 'https://mentor.accel.ai/api/auth/slack/callback' + if (!clientId || !clientSecret) return res.status(500).send('Slack client config missing') + + try { + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri + }) + + const tokenRes = await fetch('https://slack.com/api/oauth.v2.access', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }) + const tokenData = await tokenRes.json() + if (!tokenData.ok) { + console.error('[slack oauth] token exchange failed', tokenData) + return res.status(500).json({ error: 'oauth_failed', details: tokenData }) + } + + // Init Firestore via Admin SDK + if (!admin.apps.length) admin.initializeApp() + const db = admin.firestore() + + // Save team/install info + const team = tokenData.team || {} + const authedUser = tokenData.authed_user || null + if (team && team.id) { + try { + await db.collection('slack-installations').doc(team.id).set({ + team, + botUserId: tokenData.bot?.bot_user_id, + installedAt: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }) + } catch (e) { + console.warn('[slack oauth] persist installation failed', e.message) + } + } + + // Persist Slack token on your app user document identified by state (app user id) + if (authedUser && authedUser.id && authedUser.access_token) { + try { + const encryptedToken = encryptToken(authedUser.access_token) + const userDocRef = db.collection('users').doc(state) + await userDocRef.set({ + slack: { + userId: authedUser.id, + accessToken: encryptedToken, // Encrypted + scope: authedUser.scope || null, + teamId: team.id || null, + obtainedAt: admin.firestore.FieldValue.serverTimestamp() + } + }, { merge: true }) + } catch (e) { + console.warn('[slack oauth] persist user token failed', e.message) + } + } else { + console.warn('[slack oauth] authed_user missing in token response; user token not stored') + } + + const frontend = process.env.FRONTEND_ORIGIN || 'http://localhost:3000' + const redirectBack = `${frontend}/slack/oauth/success?installed=true&state=${encodeURIComponent(state || '')}` + return res.redirect(redirectBack) + } catch (err) { + console.error('[slack oauth] unexpected error', err) + return res.status(500).json({ error: err.message || 'internal' }) + } +}) + +// POST /api/messages (App -> Slack bridge) +// This is routed via Firebase Hosting rewrite (/api/** -> exports.api). +app.post('/api/messages', async (req, res) => { + ensureSlackModule() + return _slackModule.slackBridgeMessages(req, res) +}) + +// POST /api/admin/channels (Admin creates Slack channel) +// Requires Firebase ID token and membership in Firestore `admins/{uid}`. +app.post('/api/admin/channels', async (req, res) => { + try { + const authHeader = req.headers.authorization || '' + const idToken = authHeader.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null + if (!idToken) return res.status(401).json({ error: 'missing_auth' }) + + if (!admin.apps.length) admin.initializeApp() + const decoded = await admin.auth().verifyIdToken(idToken) + const uid = decoded?.uid + if (!uid) return res.status(401).json({ error: 'invalid_auth' }) + + const db = admin.firestore() + const adminSnap = await db.collection('admins').doc(uid).get() + if (!adminSnap.exists) return res.status(403).json({ error: 'not_admin' }) + + const token = process.env.SLACK_BOT_TOKEN + if (!token) return res.status(500).json({ error: 'Missing SLACK_BOT_TOKEN' }) + + const { name, purpose = '', appConversationId = null } = req.body || {} + if (!name) return res.status(400).json({ error: 'missing_name' }) + + // Validate appConversationId if provided + if (appConversationId) { + const roomSnap = await db.collection('chat-rooms').doc(appConversationId).get() + if (!roomSnap.exists) { + return res.status(404).json({ error: 'conversation_not_found', appConversationId }) + } + } + + // Slack channel names: lowercase, no spaces, <= 80 chars. + const normalized = String(name) + .trim() + .toLowerCase() + .replace(/[^a-z0-9-_]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 80) + + if (!normalized) { + return res.status(400).json({ error: 'invalid_name_after_sanitization', original: name }) + } + + const createRes = await fetch('https://slack.com/api/conversations.create', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: normalized, is_private: true }) + }) + const createData = await createRes.json() + if (!createData.ok) { + if (createData.error === 'missing_scope') { + return res.status(403).json({ error: 'missing_scope', details: createData }) + } + return res.status(500).json({ error: 'create_failed', details: createData }) + } + + const slackChannelId = createData.channel?.id + + // Optional: map created channel to a conversation + if (appConversationId && slackChannelId) { + await db.collection('chat-rooms').doc(appConversationId).set({ + slackChannelId, + slackLinkedAt: admin.firestore.FieldValue.serverTimestamp(), + slackLinkType: 'channel' + }, { merge: true }) + await db.collection('slack-conversations').doc(appConversationId).set({ + slackChannelId, + createdAt: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }) + await db.collection('slack-channel-map').doc(slackChannelId).set({ + appConversationId, + type: 'channel', + slackChannelId, + updatedAt: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }) + } + + // Set purpose if provided (best-effort) + if (purpose && slackChannelId) { + try { + await fetch('https://slack.com/api/conversations.setPurpose', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ channel: slackChannelId, purpose: String(purpose).slice(0, 250) }) + }) + } catch (e) { + // ignore + } + } + + return res.json({ ok: true, slackChannelId, name: normalized }) + } catch (e) { + console.error('[admin/channels] error', e) + return res.status(500).json({ error: e.message || 'internal' }) + } +}) + // 404 fallback app.use((req, res) => { res.status(404).json({ error: 'Not Found', path: req.path }) }) exports.api = onRequest({ timeoutSeconds: 60, memory: '256MiB' }, (req, res) => app(req, res)) + +// Remove eager require of functions/slack which can delay initialization. +// Instead expose lightweight onRequest wrappers that lazy-require the real handlers. + +// keep exports.slackEvents + exports.slackBridgeMessages for direct function URLs if needed + +// Export wrapped v2 HTTPS functions that defer loading the implementation until invoked. +// This keeps the backend specification (onRequest) at module-load time but avoids expensive initialization. +exports.slackEvents = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, async (req, res) => { + ensureSlackModule() + return _slackModule.slackEvents(req, res) +}) + +exports.slackBridgeMessages = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, async (req, res) => { + ensureSlackModule() + return _slackModule.slackBridgeMessages(req, res) +}) diff --git a/mentor-match-app/functions/package-lock.json b/mentor-match-app/functions/package-lock.json index 2246b28..1d4e323 100644 --- a/mentor-match-app/functions/package-lock.json +++ b/mentor-match-app/functions/package-lock.json @@ -8,7 +8,7 @@ "dependencies": { "express": "^4.21.2", "firebase-admin": "^13.5.0", - "firebase-functions": "^6.4.0" + "firebase-functions": "^7.0.0" }, "engines": { "node": "20" @@ -1183,9 +1183,9 @@ } }, "node_modules/firebase-functions": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.4.0.tgz", - "integrity": "sha512-Q/LGhJrmJEhT0dbV60J4hCkVSeOM6/r7xJS/ccmkXzTWMjo+UPAYX9zlQmGlEjotstZ0U9GtQSJSgbB2Z+TJDg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.0.tgz", + "integrity": "sha512-IPedw7JJ4Ok7t1Lg75RDCQkLtUhs5V1I/WzZK25iGjK9Ej7U5EWFBchiLX0uhuNlRMKixUFuiDAGj2A5ryg2bw==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.5", @@ -1198,7 +1198,7 @@ "firebase-functions": "lib/bin/firebase-functions.js" }, "engines": { - "node": ">=14.10.0" + "node": ">=18.0.0" }, "peerDependencies": { "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" diff --git a/mentor-match-app/functions/package.json b/mentor-match-app/functions/package.json index 7426280..2846556 100644 --- a/mentor-match-app/functions/package.json +++ b/mentor-match-app/functions/package.json @@ -18,6 +18,6 @@ "dependencies": { "express": "^4.21.2", "firebase-admin": "^13.5.0", - "firebase-functions": "^6.4.0" + "firebase-functions": "^7.0.0" } } diff --git a/mentor-match-app/functions/slack.js b/mentor-match-app/functions/slack.js new file mode 100644 index 0000000..82f1024 --- /dev/null +++ b/mentor-match-app/functions/slack.js @@ -0,0 +1,353 @@ + +const { onRequest } = require('firebase-functions/v2/https') +const crypto = require('crypto') +const admin = require('firebase-admin') + +try { + if (!admin.apps.length) { + admin.initializeApp() + } +} catch (e) { + // already initialized or running in environment with default credentials +} + +const db = admin.firestore() + +async function getSlackUserIdForAppUser (appUserId) { + if (!appUserId) return null + try { + const snap = await db.collection('users').doc(appUserId).get() + if (!snap.exists) return null + const data = snap.data() || {} + return data?.slack?.userId || null + } catch (e) { + console.warn('[slack] getSlackUserIdForAppUser failed', e.message) + return null + } +} + +async function putReverseChannelMapping (slackChannelId, mapping) { + if (!slackChannelId) return + try { + await db.collection('slack-channel-map').doc(slackChannelId).set({ + ...mapping, + updatedAt: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }) + } catch (e) { + console.warn('[slack] putReverseChannelMapping failed', e.message) + } +} + +async function findMappingBySlackChannelId (channelId) { + if (!channelId) return null + // Fast path: reverse-map collection + try { + const snap = await db.collection('slack-channel-map').doc(channelId).get() + if (snap.exists) return snap.data() + } catch (e) { + console.warn('[slack] reverse map lookup failed', e.message) + } + // Backward-compatible fallback: look up chat-room by slackChannelId + const appConversationId = await findAppConversationIdBySlackChannel(channelId) + if (!appConversationId) return null + return { appConversationId, type: 'channel', slackChannelId: channelId } +} + +// Helper: verify Slack signature (req.rawBody required) +function verifySlackSignature (req, rawBody) { + const ts = req.headers['x-slack-request-timestamp'] + const sig = req.headers['x-slack-signature'] + const secret = process.env.SLACK_SIGNING_SECRET + if (!ts || !sig || !secret) return false + const fiveMinutes = 60 * 5 + const now = Math.floor(Date.now() / 1000) + if (Math.abs(now - Number(ts)) > fiveMinutes) return false + const base = `v0:${ts}:${rawBody}` + const hmac = crypto.createHmac('sha256', secret).update(base).digest('hex') + const expected = `v0=${hmac}` + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)) + } catch { + return false + } +} + +// Find appConversationId by slackChannelId in Firestore (assumes slackChannelId stored on chat-rooms docs) +async function findAppConversationIdBySlackChannel (channelId) { + try { + const q = await db.collection('chat-rooms').where('slackChannelId', '==', channelId).limit(1).get() + if (!q.empty) return q.docs[0].id + } catch (e) { + console.warn('[slack] inverse lookup failed', e.message) + } + return null +} + +// Post message to Slack using bot token (uses global fetch available on Node 18+) +async function postMessageToSlack (channel, text) { + const token = process.env.SLACK_BOT_TOKEN + if (!token) throw new Error('Missing SLACK_BOT_TOKEN') + const res = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ channel, text }) + }) + const data = await res.json() + if (!data.ok) { + const err = new Error('Slack API error') + err.data = data + throw err + } + return data +} + +// Helper: persist message to Firestore (avoids code duplication) +async function persistMessageToRoom (appConversationId, text, senderId, source) { + try { + const messagesCol = db.collection('chat-rooms').doc(appConversationId).collection('messages') + await messagesCol.add({ + senderId, + text, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + read: false, + source + }) + await db.collection('chat-rooms').doc(appConversationId).set({ + lastMessage: text, + lastMessageTime: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }) + } catch (e) { + console.warn('[slack] persist message failed', e.message) + } +} + +// Slack Events function (for Event Subscriptions) +const slackEvents = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, async (req, res) => { + // Try rawBody first, fall back to parsed req.body if available + const rawBody = req.rawBody ? req.rawBody.toString('utf8') : (Buffer.isBuffer(req.body) ? req.body.toString('utf8') : null) + + let payload = null + if (rawBody) { + try { + payload = JSON.parse(rawBody) + } catch (err) { + console.warn('[slack] rawBody JSON parse failed, will try req.body', err.message) + } + } + + // fallback to parsed body (some proxies/platforms pre-parse JSON) + if (!payload && req.body && typeof req.body === 'object') { + payload = req.body + } + + if (!payload) { + console.warn('[slack] No payload found (rawBody and req.body empty)') + return res.status(400).json({ error: 'challenge_failed' }) + } + + if (!process.env.SLACK_SIGNING_SECRET) { + console.error('[slack] SLACK_SIGNING_SECRET not configured') + return res.status(500).send('Signing secret not configured') + } + + const rawForSig = rawBody || JSON.stringify(payload) + if (!verifySlackSignature(req, rawForSig)) { + console.warn('[slack] Signature verification failed') + return res.status(401).send('Invalid signature') + } + + // Handle URL verification first: always return the challenge as plain text + if (payload.type === 'url_verification') { + const challenge = payload.challenge || (payload?.body && payload.body.challenge) || null + if (!challenge) { + console.warn('[slack] url_verification payload missing challenge', payload) + return res.status(400).json({ error: 'challenge_failed' }) + } + console.log('[slack] url_verification success, returning challenge') + return res.status(200).type('text/plain').send(challenge) + } + + // Ack immediately + res.sendStatus(200) + + // Process asynchronously + try { + const { event } = payload || {} + if (!event) return + if (event.bot_id || event.subtype) return + if (event.type !== 'message') return + + const channelId = event.channel + const text = event.text || '' + const ts = event.ts + + // Resolve mapping via reverse-map (supports DM + channel) + const mapping = await findMappingBySlackChannelId(channelId) + const appConversationId = mapping?.appConversationId || null + if (!appConversationId) { + console.warn('[slack] No mapping found for channel', channelId) + return + } + + // Persist Slack message to Firestore (using helper) + await persistMessageToRoom(appConversationId, text, `slack:${event.user}`, 'slack') + + console.log('[slack] inbound message forwarded', { appConversationId, channelId, type: mapping?.type || 'unknown', user: event.user, ts }) + } catch (e) { + console.warn('[slack] Event processing error', e.message || e) + } +}) + +// App -> Slack bridge: create/find channel and post message + +// Helper: create a private channel and invite users +async function createPrivateChannelWithUsers (roomName, slackUserIds) { + const token = process.env.SLACK_BOT_TOKEN + if (!token) throw new Error('Missing SLACK_BOT_TOKEN') + + // 1. Create Channel + // Slack channel names must be lowercase, no spaces, max 80 chars + const normalizedName = roomName.toLowerCase().replace(/[^a-z0-9-_]+/g, '-').slice(0, 80) + + const createRes = await fetch('https://slack.com/api/conversations.create', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: normalizedName, is_private: true }) + }) + const createData = await createRes.json() + + if (!createData.ok && createData.error !== 'name_taken') { + const err = new Error('Slack conversations.create failed') + err.data = createData + throw err + } + + // If name taken, we might need to handle it or reuse, but for now let's assume unique IDs in name or error out + // Actually if name_taken, we need to find that channel? + // For mentorships, we should probably append a random ID if we want to be safe, or assume the ID in name is unique. + // If it failed with name_taken, createData.channel might be missing. + + const channelId = createData.channel?.id + + // 2. Invite users + if (channelId && slackUserIds && slackUserIds.length > 0) { + const inviteRes = await fetch('https://slack.com/api/conversations.invite', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ channel: channelId, users: slackUserIds.join(',') }) + }) + const inviteData = await inviteRes.json() + if (!inviteData.ok) { + console.warn('[slack] warning: failed to invite users', inviteData) + // We continue anyway, as the channel was created + } + } + + return channelId +} + +// App -> Slack bridge: create/find channel and post message +const slackBridgeMessages = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, async (req, res) => { + if (req.method !== 'POST') return res.status(405).send('Method Not Allowed') + const { appConversationId, text, userId } = req.body || {} + if (!appConversationId || !text) return res.status(400).json({ error: 'Missing appConversationId or text' }) + + try { + // Load room to decide between Channel-link vs DM-link + const roomSnap = await db.collection('chat-rooms').doc(appConversationId).get() + const room = roomSnap.exists ? (roomSnap.data() || {}) : {} + + // 1) If room has an explicit slackChannelId, treat it as a group/channel integration + let targetSlackChannelId = room.slackChannelId || null + let linkType = targetSlackChannelId ? 'channel' : null + + // 2) If NOT linked, create a PRIVATE CHANNEL for this pair (Mentorship style) + if (!targetSlackChannelId) { + const participants = Array.isArray(room.participants) ? room.participants : [] + // We want to invite ALL participants who have Slack linked + const slackUserIds = [] + const missingSlackUsers = [] + + for (const uid of participants) { + const sid = await getSlackUserIdForAppUser(uid) + if (sid) slackUserIds.push(sid) + else missingSlackUsers.push(uid) + } + + if (slackUserIds.length === 0) { + // No one is on Slack, can't bridge. + return res.json({ ok: true, forwarded: false, reason: 'no_linked_slack_users' }) + } + + // Create a unique name: mentorship- + // Using last 6 chars of appConversationId for brevity? Or full ID? + // Full ID is safer for uniqueness. + const validRoomId = appConversationId.replace(/[^a-z0-9-_]/gi, '').toLowerCase() + // room names max 80 chars. `mentorship-` is 11 chars. + // If validRoomId is long (UUID), it fits. + const roomName = `mentor-${validRoomId}` + + try { + targetSlackChannelId = await createPrivateChannelWithUsers(roomName, slackUserIds) + } catch (err) { + // If name taken, maybe we can try to find it? + // For now, let's just log and fail if we can't create. + console.error('[slackBridge] createPrivateChannel failed', err) + return res.status(500).json({ error: 'create_channel_failed', details: err.data || err.message }) + } + + if (!targetSlackChannelId) { + return res.status(500).json({ error: 'channel_creation_returned_no_id' }) + } + + linkType = 'channel' // It is a channel now, just private + + // Persist the link + await db.collection('chat-rooms').doc(appConversationId).set({ + slackChannelId: targetSlackChannelId, + slackLinkType: 'private_group', + slackLinkedAt: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }) + + // Reverse mapping + await putReverseChannelMapping(targetSlackChannelId, { + appConversationId, + type: 'channel', // treat as channel for inbound events + slackChannelId: targetSlackChannelId + }) + } else { + // Ensure reverse mapping exists (idempotent-ish) + await putReverseChannelMapping(targetSlackChannelId, { + appConversationId, + type: 'channel', + slackChannelId: targetSlackChannelId + }) + } + + // Post message + await postMessageToSlack(targetSlackChannelId, text) + + // Persist message (using helper) + await persistMessageToRoom(appConversationId, text, `app:${userId || 'unknown'}`, 'app') + + return res.json({ ok: true, slackChannelId: targetSlackChannelId, type: linkType }) + } catch (err) { + const details = err?.data || err + console.error('[slackBridge] Error posting message', details) + if (details?.error === 'missing_scope') { + return res.status(403).json({ error: 'missing_scope', details }) + } + return res.status(500).json({ error: err.message || 'internal', slackError: details || null }) + } +}) + +module.exports = { slackEvents, slackBridgeMessages } diff --git a/mentor-match-app/package-lock.json b/mentor-match-app/package-lock.json index 99dc3e8..482678c 100644 --- a/mentor-match-app/package-lock.json +++ b/mentor-match-app/package-lock.json @@ -17,11 +17,13 @@ "@mui/icons-material": "^6.1.0", "@mui/lab": "^6.0.0-beta.9", "@mui/material": "^6.1.0", + "@slack/web-api": "^7.5.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "ajv": "^8.17.1", "ajv-keywords": "^5.1.0", + "body-parser": "^1.20.2", "cors": "^2.8.5", "dotenv": "^17.2.1", "express": "^5.1.0", @@ -35,6 +37,8 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", + "socket.io": "^4.7.5", + "socket.io-client": "^4.7.5", "web-vitals": "^2.1.4", "yup": "^1.4.0" }, @@ -42,7 +46,8 @@ "@babel/plugin-proposal-private-property-in-object": "^7.18.6", "@typescript-eslint/parser": "^5.62.0", "concurrently": "^8.2.2", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "web-streams-polyfill": "^4.2.0" } }, "node_modules/@adobe/css-tools": { @@ -5476,6 +5481,81 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@slack/logger": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.0.tgz", + "integrity": "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18.0.0" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.18.0.tgz", + "integrity": "sha512-ZKrdeoppbM+3l2KKOi4/3oFYKCEwiW3dQfdHZDcecJ9rAmEqWPnARYmac9taZNitb0xnSgu6GOpHgwaKI8se2g==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.12.0.tgz", + "integrity": "sha512-LrDxjYyqjeYYQGVdVZ6EYHunFmzveOr2pFpShr6TzW4KNFpdNNnpKekjtMg0PJlOsMibSySLGQqiBZQDasmRCA==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.0", + "@slack/types": "^2.18.0", + "@types/node": ">=18.0.0", + "@types/retry": "0.12.0", + "axios": "^1.11.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/web-api/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/@slack/web-api/node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -5928,6 +6008,15 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -7421,6 +7510,33 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -7754,6 +7870,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -7813,23 +7938,91 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "license": "MIT", "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" }, "engines": { - "node": ">=18" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/bonjour-service": { @@ -9672,6 +9865,133 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/enhanced-resolve": { "version": "5.18.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", @@ -10728,6 +11048,26 @@ "node": ">= 0.6" } }, + "node_modules/express/node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/express/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -10758,6 +11098,37 @@ "node": ">= 0.6" } }, + "node_modules/express/node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/express/node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -12848,6 +13219,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -17466,6 +17843,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -17493,6 +17879,22 @@ "node": ">=8" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -17506,6 +17908,18 @@ "node": ">=8" } }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -19274,6 +19688,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -19375,20 +19795,32 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.6.3", + "iconv-lite": "0.4.24", "unpipe": "1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -20757,6 +21189,151 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -22571,6 +23148,21 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.2.0.tgz", + "integrity": "sha512-0rYDzGOh9EZpig92umN5g5D/9A1Kff7k0/mzPSSCY8jEQeYkgRMoY7LhbXtUCWzLCMX0TUE9aoHkjFNB7D9pfA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "test/benchmark-test", + "test/rollup-test", + "test/webpack-test" + ], + "engines": { + "node": ">= 8" + } + }, "node_modules/web-vitals": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", @@ -22716,30 +23308,6 @@ } } }, - "node_modules/webpack-dev-server/node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/webpack-dev-server/node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -22846,18 +23414,6 @@ "node": ">= 0.6" } }, - "node_modules/webpack-dev-server/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/webpack-dev-server/node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -22897,21 +23453,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/webpack-dev-server/node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/webpack-dev-server/node_modules/send": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", @@ -23622,6 +24163,14 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/mentor-match-app/package.json b/mentor-match-app/package.json index 140e897..901ed02 100644 --- a/mentor-match-app/package.json +++ b/mentor-match-app/package.json @@ -12,11 +12,13 @@ "@mui/icons-material": "^6.1.0", "@mui/lab": "^6.0.0-beta.9", "@mui/material": "^6.1.0", + "@slack/web-api": "^7.5.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "ajv": "^8.17.1", "ajv-keywords": "^5.1.0", + "body-parser": "^1.20.2", "cors": "^2.8.5", "dotenv": "^17.2.1", "express": "^5.1.0", @@ -30,6 +32,8 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", + "socket.io": "^4.7.5", + "socket.io-client": "^4.7.5", "web-vitals": "^2.1.4", "yup": "^1.4.0" }, @@ -37,12 +41,14 @@ "@babel/plugin-proposal-private-property-in-object": "^7.18.6", "@typescript-eslint/parser": "^5.62.0", "concurrently": "^8.2.2", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "web-streams-polyfill": "^4.2.0" }, "scripts": { "start": "react-scripts start", "start:dev": "react-scripts start", "start:server": "node server/orcidProxy.js", + "start:slack-server": "node server/slack/slackServer.js", "start:prod": "npm run build && NODE_ENV=production node server/orcidProxy.js", "build": "react-scripts build", "test": "react-scripts test", diff --git a/mentor-match-app/src/App.jsx b/mentor-match-app/src/App.jsx index d42eb0a..bf30c73 100644 --- a/mentor-match-app/src/App.jsx +++ b/mentor-match-app/src/App.jsx @@ -17,6 +17,7 @@ import AdminRoute from './components/AdminRoute' import AdminDashboard from './pages/AdminDashboard' import MentorPick from './pages/MentorPick' import RootHandler from './components/RootHandler' +import SlackOAuthSuccess from './components/SlackOAuthPage' function App() { return ( @@ -36,6 +37,10 @@ function App() { } /> } /> + } + /> diff --git a/mentor-match-app/src/App.test.js b/mentor-match-app/src/App.test.js index 1f03afe..5537601 100644 --- a/mentor-match-app/src/App.test.js +++ b/mentor-match-app/src/App.test.js @@ -1,8 +1,8 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; +import { render, screen } from '@testing-library/react' +import App from './App' -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); +test('renders app shell', () => { + render() + // App shows a loading progressbar while user state loads. + expect(screen.getByRole('progressbar')).toBeInTheDocument() +}) diff --git a/mentor-match-app/src/api/firebaseConfig.js b/mentor-match-app/src/api/firebaseConfig.js index a508d48..70a1835 100644 --- a/mentor-match-app/src/api/firebaseConfig.js +++ b/mentor-match-app/src/api/firebaseConfig.js @@ -5,26 +5,55 @@ import { getFirestore } from 'firebase/firestore' import { getMessaging } from 'firebase/messaging' import { getAnalytics } from 'firebase/analytics' +const isTest = process.env.NODE_ENV === 'test' +const isBrowser = typeof window !== 'undefined' + const firebaseConfig = { - apiKey: process.env.REACT_APP_FIREBASE_API_KEY, - authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, - projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID, - storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET, - messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, - appId: process.env.REACT_APP_FIREBASE_APP_ID, + // Provide safe defaults in Jest so importing the app doesn't crash. + // These values are only used in tests; runtime uses REACT_APP_FIREBASE_* env vars. + apiKey: process.env.REACT_APP_FIREBASE_API_KEY || (isTest ? 'test-api-key' : undefined), + authDomain: + process.env.REACT_APP_FIREBASE_AUTH_DOMAIN || (isTest ? 'test.firebaseapp.com' : undefined), + projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID || (isTest ? 'test-project' : undefined), + storageBucket: + process.env.REACT_APP_FIREBASE_STORAGE_BUCKET || (isTest ? 'test.appspot.com' : undefined), + messagingSenderId: + process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID || (isTest ? '000000000000' : undefined), + appId: process.env.REACT_APP_FIREBASE_APP_ID || (isTest ? '1:000000000000:web:test' : undefined), measurementId: process.env.REACT_APP_FIREBASE_MEASUREMENT_ID } // Initialize Firebase const app = initializeApp(firebaseConfig) -const analytics = getAnalytics(app) +// Analytics breaks in Jest/Node (and isn't needed for tests). Only init in real browser builds. +let analytics = null +try { + if ( + !isTest && + isBrowser && + firebaseConfig.measurementId && + firebaseConfig.projectId + ) { + analytics = getAnalytics(app) + } +} catch (e) { + analytics = null +} const auth = getAuth(app) const storage = getStorage(app) const db = getFirestore(app) -const messaging = getMessaging(app) +// Messaging also breaks in Jest/Node and requires browser APIs. +let messaging = null +try { + if (!isTest && isBrowser && firebaseConfig.messagingSenderId) { + messaging = getMessaging(app) + } +} catch (e) { + messaging = null +} const googleProvider = new GoogleAuthProvider() const githubProvider = new GithubAuthProvider() diff --git a/mentor-match-app/src/api/slackBridge.js b/mentor-match-app/src/api/slackBridge.js new file mode 100644 index 0000000..edddda9 --- /dev/null +++ b/mentor-match-app/src/api/slackBridge.js @@ -0,0 +1,43 @@ +const baseURL = + process.env.REACT_APP_SLACK_BRIDGE_URL || + (typeof window !== 'undefined' ? window.location.origin : '') + +export async function postSlackMessage (appConversationId, text, userId) { + if (!baseURL) return { ok: false, error: 'Slack bridge URL not configured' } + try { + const resp = await fetch(`${baseURL}/api/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appConversationId, text, userId }) + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return await resp.json() + } catch (e) { + return { ok: false, error: e.message } + } +} + +export async function createSlackChannel (name, purpose = '', appConversationId = null) { + if (!baseURL) return { ok: false, error: 'Slack bridge URL not configured' } + try { + // Admin-only endpoint: include Firebase ID token + const { getAuth } = await import('firebase/auth') + const auth = getAuth() + const token = await auth.currentUser?.getIdToken?.() + + if (!token) return { ok: false, error: 'not_authenticated' } + + const resp = await fetch(`${baseURL}/api/admin/channels`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}) + }, + body: JSON.stringify({ name, purpose, appConversationId }) + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return await resp.json() + } catch (e) { + return { ok: false, error: e.message } + } +} diff --git a/mentor-match-app/src/assets/slackIcon.svg b/mentor-match-app/src/assets/slackIcon.svg new file mode 100644 index 0000000..69a4eb6 --- /dev/null +++ b/mentor-match-app/src/assets/slackIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mentor-match-app/src/components/SlackOAuthPage.jsx b/mentor-match-app/src/components/SlackOAuthPage.jsx new file mode 100644 index 0000000..2373c98 --- /dev/null +++ b/mentor-match-app/src/components/SlackOAuthPage.jsx @@ -0,0 +1,110 @@ +import React, { useEffect, useState } from 'react' +import { Box, Card, Typography, CircularProgress } from '@mui/material' +import { useLocation, useNavigate } from 'react-router-dom' +import { getUserById } from '../api/users' +import { useUser } from '../hooks/useUser' + +const SlackOAuthSuccess = () => { + const location = useLocation() + const navigate = useNavigate() + const [loading, setLoading] = useState(true) + const [installed, setInstalled] = useState(false) + const [info, setInfo] = useState(null) + const { user, refreshUser } = useUser() + + useEffect(() => { + if (!user) return + const params = new URLSearchParams(location.search) + const installedFlag = params.get('installed') === 'true' + console.log('Slack OAuth installed:', installedFlag) + const state = params.get('state') + setInstalled(installedFlag) + + const fetchStatus = async () => { + try { + const userId = user?.uid + // If we have a state param, only query that user; otherwise query the logged-in user + const lookupId = state || userId + const doc = await getUserById(lookupId) + if (doc && doc.slack) { + // Only non-sensitive info + setInfo({ userSlackId: doc.slack.userId, teamId: doc.slack.teamId }) + // Refresh app user (so UI updates immediately) + try { + await refreshUser(state || undefined) + } catch (e) { + console.warn('Failed to refresh user after OAuth:', e) + } + + // If opened as a popup, notify the opener so it can refresh and close this window + try { + if (window.opener && window.opener !== window) { + const targetOrigin = window.location.origin + window.opener.postMessage( + { type: 'SLACK_OAUTH_SUCCESS', state }, + targetOrigin + ) + setTimeout(() => window.close(), 900) + return + } + } catch (e) { + console.warn('Could not postMessage to opener', e) + } + + // Otherwise, navigate back to the dashboard + setTimeout(() => { + navigate('/dashboard') + }, 900) + setLoading(false) + } else { + setInfo({ message: 'No Slack token saved for this account yet.' }) + setLoading(false) + } + } catch (e) { + setInfo({ message: 'Failed to fetch user info' }) + setLoading(false) + } + } + + fetchStatus() + }, [location.search, user]) + + return ( + + + Slack Authentication + + {loading ? ( + + + + ) : ( + <> + {installed ? ( + + Slack synchronization was successful! Returning to + dashboard... + + ) : ( + + Slack installation did not complete (missing success query). + You can close this tab and try again. + + )} + {info && info.userSlackId && ( + + Linked Slack user ID: {info.userSlackId} (team {info.teamId}) + + )} + {info && info.message && ( + {info.message} + )} + + )} + + + + ) +} + +export default SlackOAuthSuccess diff --git a/mentor-match-app/src/components/chat/Chat.jsx b/mentor-match-app/src/components/chat/Chat.jsx index 5aba8a5..ad85d91 100644 --- a/mentor-match-app/src/components/chat/Chat.jsx +++ b/mentor-match-app/src/components/chat/Chat.jsx @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { useMemo, useCallback, useEffect, useState, useRef } from 'react' +import { io } from 'socket.io-client' +import { postSlackMessage } from '../../api/slackBridge' import { MainContainer, Sidebar, @@ -85,6 +87,13 @@ const Chat = ({ // Track latest message per room to keep sidebar info fresh const [lastMessageByRoom, setLastMessageByRoom] = useState({}) const lastMsgUnsubsRef = useRef({}) + // Slack integration flags + const [slackEnabled, setSlackEnabled] = useState(false) + const slackForwardAll = useMemo( + () => process.env.REACT_APP_ENABLE_SLACK_FORWARD === 'true', + [] + ) + const socketRef = useRef(null) // Audio: context + last played message per room const audioCtxRef = useRef(null) @@ -208,6 +217,67 @@ const Chat = ({ } }, [selectedChatRoomId, user?.uid]) + // Derive slackEnabled from selected chat room's slackChannelId field + useEffect(() => { + if (!selectedChatRoom) { + setSlackEnabled(false) + return + } + const otherUserId = selectedChatRoom?.participants?.find( + (id) => id !== user?.uid + ) + const otherHasSlack = Boolean( + otherUserId && conversationUsers?.[otherUserId]?.slack?.userId + ) + setSlackEnabled(Boolean(selectedChatRoom.slackChannelId || otherHasSlack)) + }, [selectedChatRoom, user?.uid, conversationUsers]) + + // Initialize socket.io for Slack bridge messages + useEffect(() => { + if (!slackEnabled) return + if (!socketRef.current) { + const baseURL = process.env.REACT_APP_SLACK_BRIDGE_URL + if (!baseURL) return + socketRef.current = io(baseURL, { transports: ['websocket'] }) + socketRef.current.on('message', (payload) => { + if ( + payload?.appConversationId === selectedChatRoomIdRef.current && + payload?.source === 'slack' + ) { + // Message will also appear via Firestore listener if persisted; avoid duplicate + // Only inject if not already present (by ts or text match) + setMessages((prev) => { + const exists = prev.some( + (m) => m.text === payload.text && m.source === 'slack' + ) + if (exists) return prev + return [ + ...prev, + { + id: `slack-${payload.ts}`, + text: payload.text, + senderId: `slack:${payload.user}`, + timestamp: new Date(), + read: false, + source: 'slack' + } + ] + }) + } + }) + } + if (socketRef.current && selectedChatRoomId) { + socketRef.current.emit('join', selectedChatRoomId) + } + + return () => { + if (socketRef.current) { + socketRef.current.disconnect() + socketRef.current = null + } + } + }, [slackEnabled, selectedChatRoomId]) + // Actively viewing a chat: mark new incoming messages as read useEffect(() => { if (!drawerOpen || !selectedChatRoomId || !user?.uid) return @@ -382,6 +452,32 @@ const Chat = ({ [selectedChatRoom?.id, user?.uid] ) + const handleDualSend = useCallback( + async (message) => { + await handleSendMessage(message) + const shouldForward = + (slackEnabled || slackForwardAll) && + Boolean(process.env.REACT_APP_SLACK_BRIDGE_URL) + if (shouldForward) { + try { + await postSlackMessage(selectedChatRoom.id, message, user?.uid) + } catch (e) { + console.warn('Slack forward failed', e && e.message ? e.message : e) + alert( + 'Your message was sent in the app, but could not be forwarded to Slack. Please try again later.' + ) + } + } + }, + [ + handleSendMessage, + slackEnabled, + slackForwardAll, + selectedChatRoom?.id, + user?.uid + ] + ) + const handleTyping = useCallback(() => { if (!selectedChatRoom?.id || !user?.uid) return setTypingStatus(selectedChatRoom.id, user.uid, true) @@ -639,7 +735,7 @@ const Chat = ({ { +const SideMenu = ({ setView, currentView }) => { const { user, isAdmin } = useUser() const navigate = useNavigate() + const { theme } = useThemeContext() const isMentor = user?.role === 'Mentor' || user?.role === 'Mentor/Mentee' const isMentee = user?.role === 'Mentee' || user?.role === 'Mentor/Mentee' return ( - {isAdmin && ( - navigate('/admin')}> - - - - - Admin Dashboard - - - )} + setView('dashboard')}> @@ -43,7 +36,11 @@ const SideMenu = ({ setView }) => { - Dashboard + + Dashboard + @@ -54,7 +51,13 @@ const SideMenu = ({ setView }) => { - Mentorship Application + + Mentorship Application + @@ -66,7 +69,11 @@ const SideMenu = ({ setView }) => { - Current Mentees + + Current Mentees + )} @@ -79,10 +86,61 @@ const SideMenu = ({ setView }) => { - Current Mentor + + Current Mentor + )} + setView('slack')}> + + + + + + + + + + + + Slack Channel + + + + {isAdmin && ( + navigate('/admin')}> + + + + + + Admin Dashboard + + + + + + + )} ) diff --git a/mentor-match-app/src/components/dashboard/SlackPage.jsx b/mentor-match-app/src/components/dashboard/SlackPage.jsx new file mode 100644 index 0000000..351174a --- /dev/null +++ b/mentor-match-app/src/components/dashboard/SlackPage.jsx @@ -0,0 +1,137 @@ +import React, { useState, useEffect } from 'react' +import { + Box, + Card, + Stack, + Button, + Typography, + CircularProgress +} from '@mui/material' +import SlackIcon from '../../assets/slackIcon.svg' +import { useUser } from '../../hooks/useUser' + +const SlackPage = () => { + const [loading, setLoading] = useState(false) + const [linked, setLinked] = useState(false) + const [slackInfo, setSlackInfo] = useState(null) + const [checking, setChecking] = useState(true) + + const { user } = useUser() + + const handleLogin = async () => { + try { + setLoading(true) + const state = encodeURIComponent(user?.uid || '') + const bridge = + process.env.REACT_APP_SLACK_BRIDGE_URL || window.location.origin + window.location.href = `${bridge}/api/auth/slack/start?state=${state}` + } catch (e) { + console.error('Slack OAuth start failed', e) + setLoading(false) + } + } + + useEffect(() => { + const checkLinked = async () => { + setChecking(true) + try { + if (!user) { + setLinked(false) + setSlackInfo(null) + setChecking(false) + return + } + if (user.slack && user.slack.userId) { + setLinked(true) + setSlackInfo({ userId: user.slack.userId, teamId: user.slack.teamId }) + } else { + setLinked(false) + setSlackInfo(null) + } + } catch (e) { + console.error('Failed to check slack link:', e) + setLinked(false) + setSlackInfo(null) + } finally { + setChecking(false) + } + } + + checkLinked() + }, [user]) + + return ( + + + + + + Connect to Slack + + + + + Have a Slack account? Connect it to Mentor Match to receive + notifications and messages from our channels and sync your chats + from the app to Slack. + + + {checking ? ( + + ) : linked ? ( + + + + Account connected successfully + + + + ) : ( + + )} + + + + + + ) +} + +export default SlackPage diff --git a/mentor-match-app/src/hooks/useUser.jsx b/mentor-match-app/src/hooks/useUser.jsx index eb39ee3..eb5a867 100644 --- a/mentor-match-app/src/hooks/useUser.jsx +++ b/mentor-match-app/src/hooks/useUser.jsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState } from 'react' +import { createContext, useContext, useEffect, useState, useCallback } from 'react' import { onAuthStateChanged } from 'firebase/auth' import { auth, db } from '../api/firebaseConfig' import { doc, getDoc } from 'firebase/firestore' @@ -15,7 +15,7 @@ const UserProvider = ({ children }) => { const [mentees, setMentees] = useState([]) // Function to fetch the logged-in user's data - const fetchLoggedUser = async (uid) => { + const fetchLoggedUser = useCallback(async (uid) => { setLoading(true) // ensure loading true at start try { console.log(`Fetching user data for uid: ${uid}`) @@ -44,15 +44,15 @@ const UserProvider = ({ children }) => { } finally { setLoading(false) // guarantee completion } - } + }, []) // Function to refresh the logged-in user's data - const refreshUser = async (uidOverride) => { + const refreshUser = useCallback(async (uidOverride) => { const targetUid = uidOverride || user?.uid || auth.currentUser?.uid if (targetUid) { await fetchLoggedUser(targetUid) } - } + }, [fetchLoggedUser, user?.uid]) useEffect(() => { const unsubscribe = onAuthStateChanged(auth, async (authUser) => { @@ -65,7 +65,27 @@ const UserProvider = ({ children }) => { } }) return unsubscribe - }, []) + }, [fetchLoggedUser]) + + // Listen for cross-window messages (e.g., from OAuth popup) to refresh user data + useEffect(() => { + const handler = async (e) => { + try { + if (!e || !e.data) return + if (e.data.type === 'SLACK_OAUTH_SUCCESS') { + console.log( + '[useUser] Received SLACK_OAUTH_SUCCESS message, refreshing user', + e.data.state + ) + await refreshUser(e.data.state) + } + } catch (err) { + console.warn('Error handling window message in useUser:', err) + } + } + window.addEventListener('message', handler) + return () => window.removeEventListener('message', handler) + }, [refreshUser]) useEffect(() => { const fetchUsers = async () => { diff --git a/mentor-match-app/src/pages/Dashboard.jsx b/mentor-match-app/src/pages/Dashboard.jsx index f43babc..2bab56f 100644 --- a/mentor-match-app/src/pages/Dashboard.jsx +++ b/mentor-match-app/src/pages/Dashboard.jsx @@ -13,6 +13,7 @@ import MatchAlert from '../components/dashboard/MatchAlert' import SideMenu from '../components/dashboard/SideMenu' import CurrentMentor from '../components/dashboard/CurrentMentor' import ApplicationStatus from '../components/dashboard/ApplicationStatus' +import SlackPage from '../components/dashboard/SlackPage' // Hooks and services import { getUserById } from '../api/users' @@ -117,7 +118,9 @@ const Dashboard = () => { direction={{ xs: 'row-reverse', lg: 'column' }} > - {user && } + {user && ( + + )} {/* TODO: Show match alert when theres a new mentee match or when mentor match results are ready */} @@ -165,6 +168,7 @@ const Dashboard = () => { {user && viewType === 'applicationStatus' && ( )} + {user && viewType === 'slack' && } diff --git a/mentor-match-app/src/setupTests.js b/mentor-match-app/src/setupTests.js index 8f2609b..c415562 100644 --- a/mentor-match-app/src/setupTests.js +++ b/mentor-match-app/src/setupTests.js @@ -2,4 +2,56 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; +import '@testing-library/jest-dom' + +// Firebase Auth (node build) pulls in undici in some environments, which +// requires TextEncoder/TextDecoder. Jest's environment may not provide them. +// Polyfill them for tests only. +// eslint-disable-next-line no-undef +if (typeof TextEncoder === 'undefined' || typeof TextDecoder === 'undefined') { + // eslint-disable-next-line global-require + const { TextEncoder, TextDecoder } = require('util') + // eslint-disable-next-line no-undef + global.TextEncoder = TextEncoder + // eslint-disable-next-line no-undef + global.TextDecoder = TextDecoder +} + +// Firebase Auth may pull in undici, which expects Web Streams APIs. +// Polyfill ReadableStream/WritableStream/TransformStream for Jest. +// eslint-disable-next-line no-undef +if (typeof ReadableStream === 'undefined') { + // eslint-disable-next-line global-require + const streams = require('web-streams-polyfill') + // eslint-disable-next-line no-undef + global.ReadableStream = streams.ReadableStream + // eslint-disable-next-line no-undef + global.WritableStream = streams.WritableStream + // eslint-disable-next-line no-undef + global.TransformStream = streams.TransformStream +} + +// jsdom does not implement canvas APIs used by lottie-web. Mock lottie-react +// so importing components that use animations doesn't crash tests. +jest.mock('lottie-react', () => { + // eslint-disable-next-line global-require + const React = require('react') + const Mock = (props) => React.createElement('div', { 'data-testid': 'lottie-mock', ...props }) + return { __esModule: true, default: Mock } +}) + +// Defensive: some codebases import lottie-web directly. +jest.mock('lottie-web', () => ({ __esModule: true, default: {} })) + +// Avoid React act() warnings from auth state listeners in tests. +// We don't need real Firebase Auth behavior for component render tests. +jest.mock('firebase/auth', () => { + const actual = jest.requireActual('firebase/auth') + return { + ...actual, + onAuthStateChanged: (_auth, _callback) => { + // Do not invoke callback; keep provider stable in Jest. + return () => {} + } + } +})