From 3d7b9140dd7fa14d4b06a87559f186c886ee327d Mon Sep 17 00:00:00 2001 From: Maria Guerra Date: Thu, 27 Nov 2025 17:05:49 -0400 Subject: [PATCH 1/7] Added slack functions (deployed on firebase functions) --- mentor-match-app/functions/index.js | 6 + mentor-match-app/functions/package-lock.json | 10 +- mentor-match-app/functions/package.json | 2 +- mentor-match-app/functions/slack.js | 244 +++++++ mentor-match-app/package-lock.json | 669 ++++++++++++++++-- mentor-match-app/package.json | 5 + mentor-match-app/src/api/slackBridge.js | 31 + mentor-match-app/src/components/chat/Chat.jsx | 76 +- 8 files changed, 968 insertions(+), 75 deletions(-) create mode 100644 mentor-match-app/functions/slack.js create mode 100644 mentor-match-app/src/api/slackBridge.js diff --git a/mentor-match-app/functions/index.js b/mentor-match-app/functions/index.js index 628d495..b766181 100644 --- a/mentor-match-app/functions/index.js +++ b/mentor-match-app/functions/index.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ const { onRequest } = require('firebase-functions/v2/https') const { setGlobalOptions } = require('firebase-functions/v2') const express = require('express') @@ -71,3 +72,8 @@ app.use((req, res) => { }) exports.api = onRequest({ timeoutSeconds: 60, memory: '256MiB' }, (req, res) => app(req, res)) + +// Export Slack functions (implemented in functions/slack.js) +const { slackEvents, slackBridgeMessages } = require('./slack') +exports.slackEvents = slackEvents +exports.slackBridgeMessages = slackBridgeMessages 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..f1aa4ff --- /dev/null +++ b/mentor-match-app/functions/slack.js @@ -0,0 +1,244 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +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(); + +// 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; +} + +// 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' }); + } + + // 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); + } + + 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'); + } + + // 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 appConversationId via Firestore inverse lookup + const appConversationId = await findAppConversationIdBySlackChannel(channelId); + if (!appConversationId) { + console.warn('[slack] No mapping found for channel', channelId); + return; + } + + // Persist Slack message to Firestore (optional) + try { + const messagesCol = db.collection('chat-rooms').doc(appConversationId).collection('messages'); + await messagesCol.add({ + senderId: `slack:${event.user}`, + text, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + read: false, + source: 'slack' + }); + 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); + } + + console.log('[slack] inbound message forwarded', { appConversationId, channelId, user: event.user, ts }); + } catch (e) { + console.warn('[slack] Event processing error', e.message || e); + } +}); + +// 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 { + // Try to find existing mapping document first + let slackChannelId = null; + try { + const doc = await db.collection('chat-rooms').doc(appConversationId).get(); + if (doc.exists) slackChannelId = doc.data().slackChannelId || null; + } catch (e) { + console.warn('[slack] Firestore read failed', e.message); + } + + // Create a private channel if none found + if (!slackChannelId) { + // Create channel via Slack API + // Note: to create a private channel you need the proper scopes (conversations.create or groups:write depending) + const name = `conv-${appConversationId}`.slice(0, 60); + // Use Slack API to create a private channel (conversations.create with is_private: true) + const token = process.env.SLACK_BOT_TOKEN; + if (!token) return res.status(500).json({ error: 'Missing SLACK_BOT_TOKEN' }); + const createRes = await fetch('https://slack.com/api/conversations.create', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name, is_private: true }) + }); + const createData = await createRes.json(); + if (!createData.ok) { + const details = createData; + console.error('[slack] create channel failed', details); + if (details.error === 'missing_scope') { + return res.status(403).json({ error: 'missing_scope', details }); + } + return res.status(500).json({ error: 'create_failed', details }); + } + slackChannelId = createData.channel.id; + // Persist mapping in Firestore + await db.collection('chat-rooms').doc(appConversationId).set({ + slackChannelId, + slackLinkedAt: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }); + // Also keep a lightweight mapping doc + await db.collection('slack-conversations').doc(appConversationId).set({ + slackChannelId, + createdAt: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }); + } + + // Post message + await postMessageToSlack(slackChannelId, text); + + // Persist message + try { + const messagesCol = db.collection('chat-rooms').doc(appConversationId).collection('messages'); + await messagesCol.add({ + senderId: `app:${userId || 'unknown'}`, + text, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + read: false, + source: 'app' + }); + await db.collection('chat-rooms').doc(appConversationId).set({ + lastMessage: text, + lastMessageTime: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }); + } catch (e) { + console.warn('[slack] persist outgoing message failed', e.message); + } + + return res.json({ ok: true, slackChannelId }); + } 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..99532fc 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" }, @@ -5476,6 +5480,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 +6007,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 +7509,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 +7869,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 +7937,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 +9864,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 +11047,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 +11097,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 +13218,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 +17842,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 +17878,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 +17907,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 +19687,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 +19794,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 +21188,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", @@ -22716,30 +23292,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 +23398,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 +23437,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 +24147,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..3712b64 100644 --- a/mentor-match-app/package.json +++ b/mentor-match-app/package.json @@ -22,6 +22,10 @@ "express": "^5.1.0", "firebase": "^10.13.1", "firebase-admin": "^13.5.0", + "@slack/web-api": "^7.5.0", + "socket.io": "^4.7.5", + "socket.io-client": "^4.7.5", + "body-parser": "^1.20.2", "formik": "^2.4.6", "lottie-react": "^2.4.1", "node-fetch": "^2.6.7", @@ -43,6 +47,7 @@ "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/api/slackBridge.js b/mentor-match-app/src/api/slackBridge.js new file mode 100644 index 0000000..5cba24b --- /dev/null +++ b/mentor-match-app/src/api/slackBridge.js @@ -0,0 +1,31 @@ +const baseURL = process.env.REACT_APP_SLACK_BRIDGE_URL || '' + +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 { ok: true } + } catch (e) { + return { ok: false, error: e.message } + } +} + +export async function createSlackChannel(name, purpose = '') { + if (!baseURL) return { ok: false, error: 'Slack bridge URL not configured' } + try { + const resp = await fetch(`${baseURL}/api/admin/channels`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, purpose }) + }) + 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/components/chat/Chat.jsx b/mentor-match-app/src/components/chat/Chat.jsx index 5aba8a5..cb1f884 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,54 @@ const Chat = ({ } }, [selectedChatRoomId, user?.uid]) + // Derive slackEnabled from selected chat room's slackChannelId field + useEffect(() => { + if (!selectedChatRoom) { + setSlackEnabled(false) + return + } + setSlackEnabled(Boolean(selectedChatRoom.slackChannelId)) + }, [selectedChatRoom]) + + // 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) + } + }, [slackEnabled, selectedChatRoomId]) + // Actively viewing a chat: mark new incoming messages as read useEffect(() => { if (!drawerOpen || !selectedChatRoomId || !user?.uid) return @@ -382,6 +439,23 @@ 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.message) + } + } + }, + [handleSendMessage, slackEnabled, slackForwardAll, selectedChatRoom?.id, user?.uid] + ) + const handleTyping = useCallback(() => { if (!selectedChatRoom?.id || !user?.uid) return setTypingStatus(selectedChatRoom.id, user.uid, true) @@ -639,7 +713,7 @@ const Chat = ({ Date: Wed, 17 Dec 2025 16:34:44 -0400 Subject: [PATCH 2/7] Added Slack OAuth pages --- mentor-match-app/functions/index.js | 123 +++++++++++++++- mentor-match-app/src/App.jsx | 5 + mentor-match-app/src/assets/slackIcon.svg | 1 + .../src/components/SlackOAuthPage.jsx | 107 ++++++++++++++ .../src/components/dashboard/SideMenu.jsx | 67 ++++++++- .../src/components/dashboard/SlackPage.jsx | 137 ++++++++++++++++++ mentor-match-app/src/hooks/useUser.jsx | 20 +++ mentor-match-app/src/pages/Dashboard.jsx | 6 +- 8 files changed, 454 insertions(+), 12 deletions(-) create mode 100644 mentor-match-app/src/assets/slackIcon.svg create mode 100644 mentor-match-app/src/components/SlackOAuthPage.jsx create mode 100644 mentor-match-app/src/components/dashboard/SlackPage.jsx diff --git a/mentor-match-app/functions/index.js b/mentor-match-app/functions/index.js index b766181..c06d5df 100644 --- a/mentor-match-app/functions/index.js +++ b/mentor-match-app/functions/index.js @@ -66,6 +66,102 @@ app.get('/api/orcid/record/:orcidId', async (req, res) => { } }) +// Add Slack OAuth start + callback on the Express app +const admin = require('firebase-admin'); + +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 user_scope = 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=${user_scope}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`; + return res.redirect(url); +}); + +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, + bot: tokenData.bot, + installedAt: admin.firestore.FieldValue.serverTimestamp(), + raw: tokenData + }, { 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 userDocRef = db.collection('users').doc(state); + await userDocRef.set({ + slack: { + userId: authedUser.id, + accessToken: authedUser.access_token, + scope: authedUser.scope || null, + teamId: team.id || null, + obtainedAt: admin.firestore.FieldValue.serverTimestamp(), + raw: tokenData + } + }, { 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' }); + } +}) + // 404 fallback app.use((req, res) => { res.status(404).json({ error: 'Not Found', path: req.path }) @@ -73,7 +169,26 @@ app.use((req, res) => { exports.api = onRequest({ timeoutSeconds: 60, memory: '256MiB' }, (req, res) => app(req, res)) -// Export Slack functions (implemented in functions/slack.js) -const { slackEvents, slackBridgeMessages } = require('./slack') -exports.slackEvents = slackEvents -exports.slackBridgeMessages = slackBridgeMessages +// Remove eager require of functions/slack which can delay initialization. +// Instead expose lightweight onRequest wrappers that lazy-require the real handlers. + +let _slackModule = null; +function ensureSlackModule() { + if (!_slackModule) { + // require on first invocation to avoid heavy init during deployment + _slackModule = require('./slack'); + } +} + +// 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/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/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..fe39e84 --- /dev/null +++ b/mentor-match-app/src/components/SlackOAuthPage.jsx @@ -0,0 +1,107 @@ +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(() => { + 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) { + window.opener.postMessage( + { type: 'SLACK_OAUTH_SUCCESS', state }, + '*' + ) + 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' }) + } + } + + 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/dashboard/SideMenu.jsx b/mentor-match-app/src/components/dashboard/SideMenu.jsx index 963a1cd..48d57c6 100644 --- a/mentor-match-app/src/components/dashboard/SideMenu.jsx +++ b/mentor-match-app/src/components/dashboard/SideMenu.jsx @@ -3,7 +3,8 @@ import { MenuList, MenuItem, ListItemIcon, - ListItemText + ListItemText, + Typography } from '@mui/material' import { Group as GroupIcon, @@ -14,10 +15,12 @@ import { } from '@mui/icons-material' import { useUser } from '../../hooks/useUser' import { useNavigate } from 'react-router-dom' +import { useThemeContext } from '../../hooks/useTheme' -const SideMenu = ({ setView }) => { +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' @@ -32,7 +35,11 @@ const SideMenu = ({ setView }) => { - Admin Dashboard + + Admin Dashboard + )} @@ -43,7 +50,11 @@ const SideMenu = ({ setView }) => { - Dashboard + + Dashboard + @@ -54,7 +65,13 @@ const SideMenu = ({ setView }) => { - Mentorship Application + + Mentorship Application + @@ -66,7 +83,11 @@ const SideMenu = ({ setView }) => { - Current Mentees + + Current Mentees + )} @@ -79,10 +100,42 @@ const SideMenu = ({ setView }) => { - Current Mentor + + Current Mentor + )} + setView('slack')}> + + + + + + + + + + + + Slack Channel + + + ) 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..383893d --- /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 Slack channels directly within + the app. + + + {checking ? ( + + ) : linked ? ( + + + + Connected: {slackInfo?.userId} + + + + ) : ( + + )} + + + + + + ) +} + +export default SlackPage diff --git a/mentor-match-app/src/hooks/useUser.jsx b/mentor-match-app/src/hooks/useUser.jsx index eb39ee3..97f3058 100644 --- a/mentor-match-app/src/hooks/useUser.jsx +++ b/mentor-match-app/src/hooks/useUser.jsx @@ -67,6 +67,26 @@ const UserProvider = ({ children }) => { return unsubscribe }, []) + // 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) + }, [user]) + useEffect(() => { const fetchUsers = async () => { const usersList = await getUsers({ includePrivate: !!user?.isAdmin }) 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' && } From 2f459ed88be6d8158e15c255107313d2b6f50653 Mon Sep 17 00:00:00 2001 From: Maria Guerra Date: Mon, 22 Dec 2025 19:11:31 -0400 Subject: [PATCH 3/7] Implementation of private slack channels for mentorship pairs --- mentor-match-app/.gitignore | 2 + mentor-match-app/functions/index.js | 115 ++++++++- mentor-match-app/functions/slack.js | 231 ++++++++++++++---- mentor-match-app/package-lock.json | 18 +- mentor-match-app/package.json | 11 +- mentor-match-app/src/api/firebaseConfig.js | 45 +++- mentor-match-app/src/api/slackBridge.js | 20 +- mentor-match-app/src/components/chat/Chat.jsx | 18 +- 8 files changed, 386 insertions(+), 74 deletions(-) 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 c06d5df..649d317 100644 --- a/mentor-match-app/functions/index.js +++ b/mentor-match-app/functions/index.js @@ -69,6 +69,14 @@ 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'); @@ -162,6 +170,105 @@ app.get('/api/auth/slack/callback', async (req, res) => { } }) +// 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' }); + + // 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); + + 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 }) @@ -172,13 +279,7 @@ exports.api = onRequest({ timeoutSeconds: 60, memory: '256MiB' }, (req, res) => // Remove eager require of functions/slack which can delay initialization. // Instead expose lightweight onRequest wrappers that lazy-require the real handlers. -let _slackModule = null; -function ensureSlackModule() { - if (!_slackModule) { - // require on first invocation to avoid heavy init during deployment - _slackModule = require('./slack'); - } -} +// 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. diff --git a/mentor-match-app/functions/slack.js b/mentor-match-app/functions/slack.js index f1aa4ff..a267b0c 100644 --- a/mentor-match-app/functions/slack.js +++ b/mentor-match-app/functions/slack.js @@ -13,6 +13,68 @@ try { 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 openImChannelWithSlackUser(slackUserId) { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) throw new Error('Missing SLACK_BOT_TOKEN'); + if (!slackUserId) throw new Error('Missing slackUserId'); + + const res = await fetch('https://slack.com/api/conversations.open', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ users: slackUserId, return_im: true }) + }); + const data = await res.json(); + if (!data.ok) { + const err = new Error('Slack API error'); + err.data = data; + throw err; + } + return data.channel?.id || 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']; @@ -124,8 +186,9 @@ const slackEvents = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, async (r const text = event.text || ''; const ts = event.ts; - // Resolve appConversationId via Firestore inverse lookup - const appConversationId = await findAppConversationIdBySlackChannel(channelId); + // 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; @@ -149,12 +212,66 @@ const slackEvents = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, async (r console.warn('[slack] persist message failed', e.message); } - console.log('[slack] inbound message forwarded', { appConversationId, channelId, user: event.user, ts }); + 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. + + let 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'); @@ -162,55 +279,79 @@ const slackBridgeMessages = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, if (!appConversationId || !text) return res.status(400).json({ error: 'Missing appConversationId or text' }); try { - // Try to find existing mapping document first - let slackChannelId = null; - try { - const doc = await db.collection('chat-rooms').doc(appConversationId).get(); - if (doc.exists) slackChannelId = doc.data().slackChannelId || null; - } catch (e) { - console.warn('[slack] Firestore read failed', e.message); - } + // 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() || {}) : {}; - // Create a private channel if none found - if (!slackChannelId) { - // Create channel via Slack API - // Note: to create a private channel you need the proper scopes (conversations.create or groups:write depending) - const name = `conv-${appConversationId}`.slice(0, 60); - // Use Slack API to create a private channel (conversations.create with is_private: true) - const token = process.env.SLACK_BOT_TOKEN; - if (!token) return res.status(500).json({ error: 'Missing SLACK_BOT_TOKEN' }); - const createRes = await fetch('https://slack.com/api/conversations.create', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ name, is_private: true }) - }); - const createData = await createRes.json(); - if (!createData.ok) { - const details = createData; - console.error('[slack] create channel failed', details); - if (details.error === 'missing_scope') { - return res.status(403).json({ error: 'missing_scope', details }); - } - return res.status(500).json({ error: 'create_failed', details }); + // 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' }); } - slackChannelId = createData.channel.id; - // Persist mapping in Firestore + + linkType = 'channel'; // It is a channel now, just private + + // Persist the link await db.collection('chat-rooms').doc(appConversationId).set({ - slackChannelId, + slackChannelId: targetSlackChannelId, + slackLinkType: 'private_group', slackLinkedAt: admin.firestore.FieldValue.serverTimestamp() }, { merge: true }); - // Also keep a lightweight mapping doc - await db.collection('slack-conversations').doc(appConversationId).set({ - slackChannelId, - createdAt: 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(slackChannelId, text); + await postMessageToSlack(targetSlackChannelId, text); // Persist message try { @@ -230,7 +371,7 @@ const slackBridgeMessages = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, console.warn('[slack] persist outgoing message failed', e.message); } - return res.json({ ok: true, slackChannelId }); + return res.json({ ok: true, slackChannelId: targetSlackChannelId, type: linkType }); } catch (err) { const details = err?.data || err; console.error('[slackBridge] Error posting message', details); diff --git a/mentor-match-app/package-lock.json b/mentor-match-app/package-lock.json index 99532fc..482678c 100644 --- a/mentor-match-app/package-lock.json +++ b/mentor-match-app/package-lock.json @@ -46,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": { @@ -23147,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", diff --git a/mentor-match-app/package.json b/mentor-match-app/package.json index 3712b64..901ed02 100644 --- a/mentor-match-app/package.json +++ b/mentor-match-app/package.json @@ -12,20 +12,18 @@ "@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", "firebase": "^10.13.1", "firebase-admin": "^13.5.0", - "@slack/web-api": "^7.5.0", - "socket.io": "^4.7.5", - "socket.io-client": "^4.7.5", - "body-parser": "^1.20.2", "formik": "^2.4.6", "lottie-react": "^2.4.1", "node-fetch": "^2.6.7", @@ -34,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" }, @@ -41,7 +41,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" }, "scripts": { "start": "react-scripts start", 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 index 5cba24b..716eb41 100644 --- a/mentor-match-app/src/api/slackBridge.js +++ b/mentor-match-app/src/api/slackBridge.js @@ -1,4 +1,6 @@ -const baseURL = process.env.REACT_APP_SLACK_BRIDGE_URL || '' +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' } @@ -9,19 +11,27 @@ export async function postSlackMessage(appConversationId, text, userId) { body: JSON.stringify({ appConversationId, text, userId }) }) if (!resp.ok) throw new Error(`HTTP ${resp.status}`) - return { ok: true } + return await resp.json() } catch (e) { return { ok: false, error: e.message } } } -export async function createSlackChannel(name, purpose = '') { +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?.() + const resp = await fetch(`${baseURL}/api/admin/channels`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, purpose }) + 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() diff --git a/mentor-match-app/src/components/chat/Chat.jsx b/mentor-match-app/src/components/chat/Chat.jsx index cb1f884..214a9ab 100644 --- a/mentor-match-app/src/components/chat/Chat.jsx +++ b/mentor-match-app/src/components/chat/Chat.jsx @@ -223,8 +223,14 @@ const Chat = ({ setSlackEnabled(false) return } - setSlackEnabled(Boolean(selectedChatRoom.slackChannelId)) - }, [selectedChatRoom]) + 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(() => { @@ -453,7 +459,13 @@ const Chat = ({ } } }, - [handleSendMessage, slackEnabled, slackForwardAll, selectedChatRoom?.id, user?.uid] + [ + handleSendMessage, + slackEnabled, + slackForwardAll, + selectedChatRoom?.id, + user?.uid + ] ) const handleTyping = useCallback(() => { From 12216507d570360526e01cf34666734b482f0a9b Mon Sep 17 00:00:00 2001 From: Maria Guerra Date: Mon, 22 Dec 2025 19:30:19 -0400 Subject: [PATCH 4/7] Fix linter errors --- mentor-match-app/functions/index.js | 153 +++++----- mentor-match-app/functions/slack.js | 376 +++++++++++------------- mentor-match-app/src/api/slackBridge.js | 4 +- 3 files changed, 250 insertions(+), 283 deletions(-) diff --git a/mentor-match-app/functions/index.js b/mentor-match-app/functions/index.js index 649d317..6cf15cf 100644 --- a/mentor-match-app/functions/index.js +++ b/mentor-match-app/functions/index.js @@ -67,39 +67,39 @@ app.get('/api/orcid/record/:orcidId', async (req, res) => { }) // Add Slack OAuth start + callback on the Express app -const admin = require('firebase-admin'); +const admin = require('firebase-admin') // Lazily load slack function module for lightweight cold starts -let _slackModule = null; -function ensureSlackModule() { +let _slackModule = null +function ensureSlackModule () { if (!_slackModule) { - _slackModule = require('./slack'); + _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 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 user_scope = encodeURIComponent('users:read'); - const redirectUri = "https://mentor.accel.ai/api/auth/slack/callback"; - const state = encodeURIComponent(req.query.state || ''); + 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=${user_scope}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`; - return res.redirect(url); -}); + 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) +}) 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 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'); + 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({ @@ -107,26 +107,26 @@ app.get('/api/auth/slack/callback', async (req, res) => { 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(); + }) + 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 }); + 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(); + if (!admin.apps.length) admin.initializeApp() + const db = admin.firestore() // Save team/install info - const team = tokenData.team || {}; - const authedUser = tokenData.authed_user || null; + const team = tokenData.team || {} + const authedUser = tokenData.authed_user || null if (team && team.id) { try { await db.collection('slack-installations').doc(team.id).set({ @@ -134,16 +134,16 @@ app.get('/api/auth/slack/callback', async (req, res) => { bot: tokenData.bot, installedAt: admin.firestore.FieldValue.serverTimestamp(), raw: tokenData - }, { merge: true }); + }, { merge: true }) } catch (e) { - console.warn('[slack oauth] persist installation failed', e.message); + 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 userDocRef = db.collection('users').doc(state); + const userDocRef = db.collection('users').doc(state) await userDocRef.set({ slack: { userId: authedUser.id, @@ -153,52 +153,52 @@ app.get('/api/auth/slack/callback', async (req, res) => { obtainedAt: admin.firestore.FieldValue.serverTimestamp(), raw: tokenData } - }, { merge: true }); + }, { merge: true }) } catch (e) { - console.warn('[slack oauth] persist user token failed', e.message); + 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'); + 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); + 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' }); + 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); + 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' }); + 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' }); + 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 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 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' }); + const { name, purpose = '', appConversationId = null } = req.body || {} + if (!name) return res.status(400).json({ error: 'missing_name' }) // Slack channel names: lowercase, no spaces, <= 80 chars. const normalized = String(name) @@ -207,7 +207,7 @@ app.post('/api/admin/channels', async (req, res) => { .replace(/[^a-z0-9-_]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') - .slice(0, 80); + .slice(0, 80) const createRes = await fetch('https://slack.com/api/conversations.create', { method: 'POST', @@ -216,16 +216,16 @@ app.post('/api/admin/channels', async (req, res) => { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: normalized, is_private: true }) - }); - const createData = await createRes.json(); + }) + 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(403).json({ error: 'missing_scope', details: createData }) } - return res.status(500).json({ error: 'create_failed', details: createData }); + return res.status(500).json({ error: 'create_failed', details: createData }) } - const slackChannelId = createData.channel?.id; + const slackChannelId = createData.channel?.id // Optional: map created channel to a conversation if (appConversationId && slackChannelId) { @@ -233,17 +233,17 @@ app.post('/api/admin/channels', async (req, res) => { slackChannelId, slackLinkedAt: admin.firestore.FieldValue.serverTimestamp(), slackLinkType: 'channel' - }, { merge: true }); + }, { merge: true }) await db.collection('slack-conversations').doc(appConversationId).set({ slackChannelId, createdAt: admin.firestore.FieldValue.serverTimestamp() - }, { merge: true }); + }, { merge: true }) await db.collection('slack-channel-map').doc(slackChannelId).set({ appConversationId, type: 'channel', slackChannelId, updatedAt: admin.firestore.FieldValue.serverTimestamp() - }, { merge: true }); + }, { merge: true }) } // Set purpose if provided (best-effort) @@ -256,16 +256,16 @@ app.post('/api/admin/channels', async (req, res) => { '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 }); + 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' }); + console.error('[admin/channels] error', e) + return res.status(500).json({ error: e.message || 'internal' }) } }) @@ -284,12 +284,11 @@ exports.api = onRequest({ timeoutSeconds: 60, memory: '256MiB' }, (req, res) => // 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); -}); + ensureSlackModule() + return _slackModule.slackEvents(req, res) +}) exports.slackBridgeMessages = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, async (req, res) => { - ensureSlackModule(); - return _slackModule.slackBridgeMessages(req, res); -}); - + ensureSlackModule() + return _slackModule.slackBridgeMessages(req, res) +}) diff --git a/mentor-match-app/functions/slack.js b/mentor-match-app/functions/slack.js index a267b0c..dee78bf 100644 --- a/mentor-match-app/functions/slack.js +++ b/mentor-match-app/functions/slack.js @@ -1,114 +1,92 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -const { onRequest } = require('firebase-functions/v2/https'); -const crypto = require('crypto'); -const admin = require('firebase-admin'); +const { onRequest } = require('firebase-functions/v2/https') +const crypto = require('crypto') +const admin = require('firebase-admin') try { if (!admin.apps.length) { - admin.initializeApp(); + admin.initializeApp() } } catch (e) { // already initialized or running in environment with default credentials } -const db = admin.firestore(); +const db = admin.firestore() -async function getSlackUserIdForAppUser(appUserId) { - if (!appUserId) return null; +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; + 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; + console.warn('[slack] getSlackUserIdForAppUser failed', e.message) + return null } } -async function openImChannelWithSlackUser(slackUserId) { - const token = process.env.SLACK_BOT_TOKEN; - if (!token) throw new Error('Missing SLACK_BOT_TOKEN'); - if (!slackUserId) throw new Error('Missing slackUserId'); - - const res = await fetch('https://slack.com/api/conversations.open', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ users: slackUserId, return_im: true }) - }); - const data = await res.json(); - if (!data.ok) { - const err = new Error('Slack API error'); - err.data = data; - throw err; - } - return data.channel?.id || null; -} - -async function putReverseChannelMapping(slackChannelId, mapping) { - if (!slackChannelId) return; +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 }); + }, { merge: true }) } catch (e) { - console.warn('[slack] putReverseChannelMapping failed', e.message); + console.warn('[slack] putReverseChannelMapping failed', e.message) } } -async function findMappingBySlackChannelId(channelId) { - if (!channelId) return null; +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(); + 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); + 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 }; + 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}`; +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)); + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)) } catch { - return false; + return false } } // Find appConversationId by slackChannelId in Firestore (assumes slackChannelId stored on chat-rooms docs) -async function findAppConversationIdBySlackChannel(channelId) { +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; + 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); + console.warn('[slack] inverse lookup failed', e.message) } - return null; + 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'); +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: { @@ -116,119 +94,124 @@ async function postMessageToSlack(channel, text) { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel, text }) - }); - const data = await res.json(); + }) + const data = await res.json() if (!data.ok) { - const err = new Error('Slack API error'); - err.data = data; - throw err; + 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) } - return data; } // 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); + const rawBody = req.rawBody ? req.rawBody.toString('utf8') : (Buffer.isBuffer(req.body) ? req.body.toString('utf8') : null) - let payload = null; + let payload = null if (rawBody) { try { - payload = JSON.parse(rawBody); + payload = JSON.parse(rawBody) } catch (err) { - console.warn('[slack] rawBody JSON parse failed, will try req.body', err.message); + 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; + payload = req.body } if (!payload) { - console.warn('[slack] No payload found (rawBody and req.body empty)'); - return res.status(400).json({ error: 'challenge_failed' }); + console.warn('[slack] No payload found (rawBody and req.body empty)') + return res.status(400).json({ error: 'challenge_failed' }) } // 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; + 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.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); + console.log('[slack] url_verification success, returning challenge') + return res.status(200).type('text/plain').send(challenge) } if (!process.env.SLACK_SIGNING_SECRET) { - console.error('[slack] SLACK_SIGNING_SECRET not configured'); - return res.status(500).send('Signing secret not configured'); + console.error('[slack] SLACK_SIGNING_SECRET not configured') + return res.status(500).send('Signing secret not configured') } - const rawForSig = rawBody || JSON.stringify(payload); + const rawForSig = rawBody || JSON.stringify(payload) if (!verifySlackSignature(req, rawForSig)) { - console.warn('[slack] Signature verification failed'); - return res.status(401).send('Invalid signature'); + console.warn('[slack] Signature verification failed') + return res.status(401).send('Invalid signature') } // Ack immediately - res.sendStatus(200); + 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 { 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; + 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; + const mapping = await findMappingBySlackChannelId(channelId) + const appConversationId = mapping?.appConversationId || null if (!appConversationId) { - console.warn('[slack] No mapping found for channel', channelId); - return; + console.warn('[slack] No mapping found for channel', channelId) + return } - // Persist Slack message to Firestore (optional) - try { - const messagesCol = db.collection('chat-rooms').doc(appConversationId).collection('messages'); - await messagesCol.add({ - senderId: `slack:${event.user}`, - text, - timestamp: admin.firestore.FieldValue.serverTimestamp(), - read: false, - source: 'slack' - }); - 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); - } + // 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 }); + 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); + 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'); +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 normalizedName = roomName.toLowerCase().replace(/[^a-z0-9-_]+/g, '-').slice(0, 80) + const createRes = await fetch('https://slack.com/api/conversations.create', { method: 'POST', headers: { @@ -236,22 +219,22 @@ async function createPrivateChannelWithUsers(roomName, slackUserIds) { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: normalizedName, is_private: true }) - }); - const createData = await createRes.json(); - + }) + 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; + 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? + // 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. - - let channelId = createData.channel?.id; - + + const channelId = createData.channel?.id + // 2. Invite users if (channelId && slackUserIds && slackUserIds.length > 0) { const inviteRes = await fetch('https://slack.com/api/conversations.invite', { @@ -261,125 +244,110 @@ async function createPrivateChannelWithUsers(roomName, slackUserIds) { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: channelId, users: slackUserIds.join(',') }) - }); - const inviteData = await inviteRes.json(); + }) + 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 + console.warn('[slack] warning: failed to invite users', inviteData) + // We continue anyway, as the channel was created } } - - return channelId; + + 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' }); + 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() || {}) : {}; + 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; + 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 : []; + const participants = Array.isArray(room.participants) ? room.participants : [] // We want to invite ALL participants who have Slack linked - const slackUserIds = []; - const missingSlackUsers = []; + const slackUserIds = [] + const missingSlackUsers = [] for (const uid of participants) { - const sid = await getSlackUserIdForAppUser(uid); - if (sid) slackUserIds.push(sid); - else missingSlackUsers.push(uid); + 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' }); + // 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. + 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}`; + const roomName = `mentor-${validRoomId}` try { - targetSlackChannelId = await createPrivateChannelWithUsers(roomName, slackUserIds); + 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 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' }); + return res.status(500).json({ error: 'channel_creation_returned_no_id' }) } - - linkType = 'channel'; // It is a channel now, just private + + 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 }); + }, { 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 - }); + // Ensure reverse mapping exists (idempotent-ish) + await putReverseChannelMapping(targetSlackChannelId, { + appConversationId, + type: 'channel', + slackChannelId: targetSlackChannelId + }) } // Post message - await postMessageToSlack(targetSlackChannelId, text); + await postMessageToSlack(targetSlackChannelId, text) - // Persist message - try { - const messagesCol = db.collection('chat-rooms').doc(appConversationId).collection('messages'); - await messagesCol.add({ - senderId: `app:${userId || 'unknown'}`, - text, - timestamp: admin.firestore.FieldValue.serverTimestamp(), - read: false, - source: 'app' - }); - await db.collection('chat-rooms').doc(appConversationId).set({ - lastMessage: text, - lastMessageTime: admin.firestore.FieldValue.serverTimestamp() - }, { merge: true }); - } catch (e) { - console.warn('[slack] persist outgoing message failed', e.message); - } + // Persist message (using helper) + await persistMessageToRoom(appConversationId, text, `app:${userId || 'unknown'}`, 'app') - return res.json({ ok: true, slackChannelId: targetSlackChannelId, type: linkType }); + return res.json({ ok: true, slackChannelId: targetSlackChannelId, type: linkType }) } catch (err) { - const details = err?.data || err; - console.error('[slackBridge] Error posting message', details); + 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(403).json({ error: 'missing_scope', details }) } - return res.status(500).json({ error: err.message || 'internal', slackError: details || null }); + return res.status(500).json({ error: err.message || 'internal', slackError: details || null }) } -}); +}) -module.exports = { slackEvents, slackBridgeMessages }; +module.exports = { slackEvents, slackBridgeMessages } diff --git a/mentor-match-app/src/api/slackBridge.js b/mentor-match-app/src/api/slackBridge.js index 716eb41..5b1da58 100644 --- a/mentor-match-app/src/api/slackBridge.js +++ b/mentor-match-app/src/api/slackBridge.js @@ -2,7 +2,7 @@ const baseURL = process.env.REACT_APP_SLACK_BRIDGE_URL || (typeof window !== 'undefined' ? window.location.origin : '') -export async function postSlackMessage(appConversationId, text, userId) { +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`, { @@ -17,7 +17,7 @@ export async function postSlackMessage(appConversationId, text, userId) { } } -export async function createSlackChannel(name, purpose = '', appConversationId = null) { +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 From f309a9a59702265ad6bf7a8dcc83c11ad3087806 Mon Sep 17 00:00:00 2001 From: Maria Guerra Date: Mon, 22 Dec 2025 19:47:01 -0400 Subject: [PATCH 5/7] Fix linter errors --- mentor-match-app/functions/index.js | 2 +- mentor-match-app/functions/slack.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mentor-match-app/functions/index.js b/mentor-match-app/functions/index.js index 6cf15cf..28fe01d 100644 --- a/mentor-match-app/functions/index.js +++ b/mentor-match-app/functions/index.js @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ + const { onRequest } = require('firebase-functions/v2/https') const { setGlobalOptions } = require('firebase-functions/v2') const express = require('express') diff --git a/mentor-match-app/functions/slack.js b/mentor-match-app/functions/slack.js index dee78bf..857cfe4 100644 --- a/mentor-match-app/functions/slack.js +++ b/mentor-match-app/functions/slack.js @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ + const { onRequest } = require('firebase-functions/v2/https') const crypto = require('crypto') const admin = require('firebase-admin') From 6462137b1afb5f3b0ab74a25c20681da84f53804 Mon Sep 17 00:00:00 2001 From: Maria Guerra Date: Tue, 23 Dec 2025 16:07:43 -0400 Subject: [PATCH 6/7] Implemented Copilot suggestions --- mentor-match-app/functions/index.js | 42 ++++++++++++--- mentor-match-app/functions/slack.js | 22 ++++---- mentor-match-app/src/App.test.js | 14 ++--- mentor-match-app/src/api/slackBridge.js | 2 + .../src/components/SlackOAuthPage.jsx | 7 ++- mentor-match-app/src/components/chat/Chat.jsx | 12 ++++- .../src/components/dashboard/SideMenu.jsx | 45 +++++++++------- .../src/components/dashboard/SlackPage.jsx | 6 +-- mentor-match-app/src/hooks/useUser.jsx | 14 ++--- mentor-match-app/src/setupTests.js | 52 +++++++++++++++++++ 10 files changed, 159 insertions(+), 57 deletions(-) diff --git a/mentor-match-app/functions/index.js b/mentor-match-app/functions/index.js index 28fe01d..13d3af8 100644 --- a/mentor-match-app/functions/index.js +++ b/mentor-match-app/functions/index.js @@ -90,6 +90,25 @@ app.get('/api/auth/slack/start', (req, res) => { 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) @@ -131,9 +150,8 @@ app.get('/api/auth/slack/callback', async (req, res) => { try { await db.collection('slack-installations').doc(team.id).set({ team, - bot: tokenData.bot, - installedAt: admin.firestore.FieldValue.serverTimestamp(), - raw: tokenData + botUserId: tokenData.bot?.bot_user_id, + installedAt: admin.firestore.FieldValue.serverTimestamp() }, { merge: true }) } catch (e) { console.warn('[slack oauth] persist installation failed', e.message) @@ -143,15 +161,15 @@ app.get('/api/auth/slack/callback', async (req, res) => { // 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: authedUser.access_token, + accessToken: encryptedToken, // Encrypted scope: authedUser.scope || null, teamId: team.id || null, - obtainedAt: admin.firestore.FieldValue.serverTimestamp(), - raw: tokenData + obtainedAt: admin.firestore.FieldValue.serverTimestamp() } }, { merge: true }) } catch (e) { @@ -200,6 +218,14 @@ app.post('/api/admin/channels', async (req, res) => { 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() @@ -209,6 +235,10 @@ app.post('/api/admin/channels', async (req, res) => { .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: { diff --git a/mentor-match-app/functions/slack.js b/mentor-match-app/functions/slack.js index 857cfe4..82f1024 100644 --- a/mentor-match-app/functions/slack.js +++ b/mentor-match-app/functions/slack.js @@ -148,17 +148,6 @@ const slackEvents = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, async (r return res.status(400).json({ error: 'challenge_failed' }) } - // 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) - } - if (!process.env.SLACK_SIGNING_SECRET) { console.error('[slack] SLACK_SIGNING_SECRET not configured') return res.status(500).send('Signing secret not configured') @@ -170,6 +159,17 @@ const slackEvents = onRequest({ timeoutSeconds: 30, memory: '256MiB' }, async (r 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) 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/slackBridge.js b/mentor-match-app/src/api/slackBridge.js index 5b1da58..edddda9 100644 --- a/mentor-match-app/src/api/slackBridge.js +++ b/mentor-match-app/src/api/slackBridge.js @@ -25,6 +25,8 @@ export async function createSlackChannel (name, purpose = '', appConversationId 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: { diff --git a/mentor-match-app/src/components/SlackOAuthPage.jsx b/mentor-match-app/src/components/SlackOAuthPage.jsx index fe39e84..2373c98 100644 --- a/mentor-match-app/src/components/SlackOAuthPage.jsx +++ b/mentor-match-app/src/components/SlackOAuthPage.jsx @@ -13,6 +13,7 @@ const SlackOAuthSuccess = () => { 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) @@ -21,7 +22,7 @@ const SlackOAuthSuccess = () => { const fetchStatus = async () => { try { - const userId = user.uid + 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) @@ -38,9 +39,10 @@ const SlackOAuthSuccess = () => { // 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 @@ -60,6 +62,7 @@ const SlackOAuthSuccess = () => { } } catch (e) { setInfo({ message: 'Failed to fetch user info' }) + setLoading(false) } } diff --git a/mentor-match-app/src/components/chat/Chat.jsx b/mentor-match-app/src/components/chat/Chat.jsx index 214a9ab..ad85d91 100644 --- a/mentor-match-app/src/components/chat/Chat.jsx +++ b/mentor-match-app/src/components/chat/Chat.jsx @@ -269,6 +269,13 @@ const Chat = ({ 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 @@ -455,7 +462,10 @@ const Chat = ({ try { await postSlackMessage(selectedChatRoom.id, message, user?.uid) } catch (e) { - console.warn('Slack forward failed', e.message) + 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.' + ) } } }, diff --git a/mentor-match-app/src/components/dashboard/SideMenu.jsx b/mentor-match-app/src/components/dashboard/SideMenu.jsx index 48d57c6..2b56786 100644 --- a/mentor-match-app/src/components/dashboard/SideMenu.jsx +++ b/mentor-match-app/src/components/dashboard/SideMenu.jsx @@ -11,7 +11,8 @@ import { Dashboard as DashboardIcon, AccountBox as MentorIcon, Assignment as ApplicationIcon, - ManageAccounts as AdminIcon + ManageAccounts as AdminIcon, + OpenInNew as OpenInNewIcon } from '@mui/icons-material' import { useUser } from '../../hooks/useUser' import { useNavigate } from 'react-router-dom' @@ -27,22 +28,7 @@ const SideMenu = ({ setView, currentView }) => { return ( - {isAdmin && ( - navigate('/admin')}> - - - - - - Admin Dashboard - - - - )} + setView('dashboard')}> @@ -117,9 +103,9 @@ const SideMenu = ({ setView, currentView }) => { viewBox="0 0 24 24" fill="none" stroke={theme.palette.primary.main} - stroke-width="1.5" - stroke-linecap="round" - stroke-linejoin="round" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" > @@ -136,6 +122,25 @@ const SideMenu = ({ setView, currentView }) => { + {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 index 383893d..351174a 100644 --- a/mentor-match-app/src/components/dashboard/SlackPage.jsx +++ b/mentor-match-app/src/components/dashboard/SlackPage.jsx @@ -85,8 +85,8 @@ const SlackPage = () => { Have a Slack account? Connect it to Mentor Match to receive - notifications and messages from our Slack channels directly within - the app. + notifications and messages from our channels and sync your chats + from the app to Slack. {checking ? ( @@ -99,7 +99,7 @@ const SlackPage = () => { sx={{ height: '20px', width: '20px' }} /> - Connected: {slackInfo?.userId} + Account connected successfully