diff --git a/data/challengeMap.json b/data/challengeMap.json new file mode 100644 index 000000000..077404aaa --- /dev/null +++ b/data/challengeMap.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index 4895ac83c..330488562 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -14,6 +14,12 @@ export const authOptions = { clientId: process.env.AUTH0_CLIENT_ID, clientSecret: process.env.AUTH0_CLIENT_SECRET, issuer: process.env.AUTH0_ISSUER, + authorization: { + params: { + audience: 'http://localhost:3000/api/hello', // 👈 this is key + scope: 'openid profile email' // Add custom scopes if needed + } + }, // Enable dangerous account linking in dev environment ...(process.env.DANGEROUS_ACCOUNT_LINKING_ENABLED == 'true' ? { allowDangerousEmailAccountLinking: true } @@ -21,7 +27,37 @@ export const authOptions = { }) // ...add more providers here ], + session: { + strategy: 'jwt' + }, callbacks: { + async jwt({ token, account }) { + let ttl = 0; + let created = 0; + + // Calculate TTL (Time-To-Live) in milliseconds + if (token.exp && token.iat) { + created = token.iat; + ttl = (token.exp - Math.floor(Date.now() / 1000)) * 1000; + token.ttl = ttl; + token.created = new Date(created * 1000).toISOString(); // Convert Unix timestamp to ISO date string; + } + + // Persist the OAuth access_token to the token right after signin + if (account) { + token.accessToken = account.access_token; + } + + return token; + }, + async session({ session, token }) { + // Send properties to the client, like an access_token from a provider + session.accessToken = token.accessToken; + session.ttl = token.ttl; + session.created = token.created; + + return session; + }, async redirect({ url, baseUrl }) { // Allows relative callback URLs if (url.startsWith('/')) return `${baseUrl}${url}`; @@ -48,7 +84,7 @@ if (process.env.GITHUB_OAUTH_PROVIDER_ENABLED == 'true') { export default NextAuth(authOptions); /* Test Cases - Auth0 Google/GitHub -> GitHub - GitHub -> Auth0 Google/GitHub + Auth0 Google/GitHub -> GitHub + GitHub -> Auth0 Google/GitHub - Tested on Incognito tab of Microsoft Edge, Brave, Safari, Chrome, FireFox*/ + Tested on Incognito tab of Microsoft Edge, Brave, Safari, Chrome, FireFox*/ diff --git a/pages/api/fcc-proxy.js b/pages/api/fcc-proxy.js new file mode 100644 index 000000000..d6f3f888f --- /dev/null +++ b/pages/api/fcc-proxy.js @@ -0,0 +1,60 @@ +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + // Parse cookies from request header + const cookies = {}; + if (req.headers.cookie) { + req.headers.cookie.split(';').forEach(cookie => { + const [name, value] = cookie.trim().split('='); + cookies[name] = decodeURIComponent(value); + }); + } + + // Get token from cookie if it exists + const cookieToken = cookies.jwt_access_token; + const { targetUrl, ...bodyData } = req.body; + + if (!cookieToken) { + console.log('Unauthorized!'); + return res.status(401).json({ error: 'Unauthorized' }); + } + + if (!targetUrl) { + console.log('Missing targetUrl'); + return res.status(400).json({ error: 'Missing targetUrl' }); + } + + console.log('proxy hit', { + targetUrl, + bodyDataKeys: Object.keys(bodyData) + }); + + // Build the full FCC URL + const fccUrl = `http://localhost:3000${targetUrl}`; + + const headers = { + 'Content-Type': 'application/json', + Cookie: `jwt_access_token=${cookieToken}` + }; + + // Make POST request with body data + const fccResponse = await fetch(fccUrl, { + method: 'POST', + headers, + body: JSON.stringify(bodyData), + credentials: 'include' + }); + + // Get the response data + const data = await fccResponse.json(); + + // Return the data to the client + return res.status(fccResponse.status).json(data); + } catch (error) { + console.error('Error proxying request to FCC:', error); + return res.status(500).json({ error: 'Failed to fetch from FCC' }); + } +} diff --git a/pages/dashboard/v2/[id].js b/pages/dashboard/v2/[id].js index 706492318..1b5a49ba1 100644 --- a/pages/dashboard/v2/[id].js +++ b/pages/dashboard/v2/[id].js @@ -64,7 +64,7 @@ export async function getServerSideProps(context) { let totalChallenges = getTotalChallengesForSuperblocks(dashboardObjs); - let studentData = await fetchStudentData(); + let studentData = await fetchStudentData(context.params.id, context); // Temporary check to map/accomodate hard-coded mock student data progress in unselected superblocks by teacher let studentsAreEnrolledInSuperblocks = diff --git a/pages/dashboard/v2/details/[id]/[studentEmail].js b/pages/dashboard/v2/details/[id]/[studentEmail].js index 79f5c00b3..d6271c037 100644 --- a/pages/dashboard/v2/details/[id]/[studentEmail].js +++ b/pages/dashboard/v2/details/[id]/[studentEmail].js @@ -78,8 +78,11 @@ export async function getServerSideProps(context) { let superblocksDetailsJSONArray = await createSuperblockDashboardObject( superBlockJsons ); - - let studentData = await getIndividualStudentData(studentEmail); + let studentData = await getIndividualStudentData( + studentEmail, + context.params.id, + context + ); return { props: { diff --git a/util/api_proccesor.js b/util/api_proccesor.js index ed9a1c538..4d0d4405f 100644 --- a/util/api_proccesor.js +++ b/util/api_proccesor.js @@ -283,7 +283,7 @@ If you are having issues with the selector, you should probably check there. order: currBlock[certificationName]['blocks'][course]['challenges'][ 'order' - ] + ] ?? null }; return currCourseBlock; }); @@ -296,16 +296,98 @@ If you are having issues with the selector, you should probably check there. return sortedBlocks.flat(1); } +import { getStudentDataByUserIds } from './fcc_proper'; +import { resolveAllStudentsToDashboardFormat } from './challengeMapUtils'; +// TODO: Comment out the import prisma line. +// This will cause the frontend to break because we can't import it in this file. +// I haven't commented it out here due to ESLint rules stating that it must be defined. +import prisma from '../prisma/prisma'; + /** ============ fetchStudentData() ============ */ -export async function fetchStudentData() { - let data = await fetch(process.env.MOCK_USER_DATA_URL); - return data.json(); +/** + * [Parameters] Looks for students in a classroom, and checks for their fccProperUserIds. + * + * [Returns] a 2d array of objects, where the array length is 1, and array[0] is length N, where array[0][N] are objects + * with block (not superblock) data. + */ +export async function fetchStudentData(classroomId, context) { + try { + // First, get the classroom data including the fccUserIds + const classroomData = await prisma.classroom.findUnique({ + where: { + classroomId: classroomId + }, + select: { + fccUserIds: true + } + }); + if (!classroomData) { + console.error('No classroom found with ID:', classroomId); + return []; + } + + // Now get the users with those IDs + const students = await prisma.user.findMany({ + where: { + id: { + in: classroomData.fccUserIds + } + }, + select: { + email: true, + fccProperUserId: true + } + }); + + // If no students, return empty array + if (students.length === 0) { + return []; + } + + // id -> email lookup + const idToEmail = new Map(students.map(s => [s.fccProperUserId, s.email])); + + const userIds = Array.from(idToEmail.keys()); + + // Call: Get student data in batches (max 50 per request) + const batchSize = 50; + const allStudentDataByEmail = {}; + + for (let i = 0; i < userIds.length; i += batchSize) { + const batchIds = userIds.slice(i, i + batchSize); + + const batchDataById = await getStudentDataByUserIds(batchIds, context); + + // Remap keys from userId -> email + Object.entries(batchDataById).forEach(([userId, value]) => { + const email = idToEmail.get(userId); + if (email) { + allStudentDataByEmail[email] = value; + } + }); + } + + // Resolve to dashboard format + if (Object.keys(allStudentDataByEmail).length > 0) { + return resolveAllStudentsToDashboardFormat(allStudentDataByEmail); + } + + // Otherwise, return as-is (for legacy/mock data) + return []; + } catch (error) { + console.error('Error in fetchStudentData:', error); + return []; + } } /** ============ getIndividualStudentData(studentEmail) ============ */ // Uses for the details page -export async function getIndividualStudentData(studentEmail) { - let studentData = await fetchStudentData(); +export async function getIndividualStudentData( + studentEmail, + classroomId, + context +) { + let studentData = await fetchStudentData(classroomId, context); let individualStudentObj = {}; studentData.forEach(individualStudentDetailsObj => { if (individualStudentDetailsObj.email === studentEmail) { diff --git a/util/challengeMapUtils.js b/util/challengeMapUtils.js new file mode 100644 index 000000000..3c1e59e1b --- /dev/null +++ b/util/challengeMapUtils.js @@ -0,0 +1,61 @@ +import challengeMap from '../data/challengeMap.json'; + +/** + * Resolves a full FCC Proper student data object (from the proxy) to the dashboard format. + * @param {Object} studentDataFromFCC - { email1: [completedChallenges], email2: [completedChallenges], ... } + * @returns {Array} - Array of student objects: { email, certifications: [...] } + */ +export function resolveAllStudentsToDashboardFormat(studentDataFromFCC) { + if (!studentDataFromFCC || typeof studentDataFromFCC !== 'object') return []; + return Object.entries(studentDataFromFCC).map( + ([email, completedChallenges]) => ({ + email, + ...buildStudentDashboardData(completedChallenges, challengeMap) + }) + ); +} +/** + * Transforms a student's flat completed challenge array into the nested dashboard format. + * @param {Array} completedChallenges - Array of completed challenge objects (with id, completedDate, etc.) + * @param {Object} challengeMap - The challenge map object from /api/build-challenge-map + * @returns {Object} - Nested structure: { certifications: [ { [certName]: { blocks: [ { [blockName]: { completedChallenges: [...] } } ] } } ] } + */ +export function buildStudentDashboardData(completedChallenges, challengeMap) { + const result = { certifications: [] }; + const certMap = {}; + + completedChallenges.forEach(challenge => { + const mapEntry = challengeMap[challenge.id]; + if (!mapEntry) { + // DEBUG: Print missing challenge IDs, confirm with curriculum team if these challenge IDs are no longer valid. + // console.warn('Challenge ID not found in challengeMap:', challenge.id); + return; // skip unknown ids + } + const { certification, block, name } = mapEntry; + if (!certMap[certification]) { + certMap[certification] = { blocks: {} }; + } + if (!certMap[certification].blocks[block]) { + certMap[certification].blocks[block] = { completedChallenges: [] }; + } + certMap[certification].blocks[block].completedChallenges.push({ + ...challenge, + challengeName: name + }); + }); + + // Convert to the expected nested array format + for (const cert in certMap) { + const certObj = {}; + certObj[cert] = { + blocks: Object.entries(certMap[cert].blocks).map( + ([blockName, blockObj]) => ({ + [blockName]: blockObj + }) + ) + }; + result.certifications.push(certObj); + } + + return result; +} diff --git a/util/fcc_proper.js b/util/fcc_proper.js new file mode 100644 index 000000000..317a64f08 --- /dev/null +++ b/util/fcc_proper.js @@ -0,0 +1,104 @@ +const { getSession } = require('next-auth/react'); + +async function fetchFromFCC(options = {}, context = null) { + // Get session, with context if provided (for server-side calls) + const session = context ? await getSession(context) : await getSession(); + + if (!session) { + throw new Error('User not authenticated'); + } + + // Determine if we're running on the server + const isServer = typeof window === 'undefined'; + + // Use absolute URL when on server, relative URL when on client + const baseUrl = isServer + ? process.env.NEXTAUTH_URL || 'http://localhost:3001' + : ''; + const url = `${baseUrl}/api/fcc-proxy`; + + // Get the auth cookie if we're server-side and have context + let headers = { + 'Content-Type': 'application/json' + }; + + // If we're in a server context, forward the cookie header + if (isServer && context && context.req && context.req.headers.cookie) { + headers['Cookie'] = context.req.headers.cookie; + } + + // Send the request to our server-side API route + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + ...options, + targetUrl: options.targetUrl + }), + credentials: 'include' // Important for cookies + }); + + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}`); + } + + return response.json(); +} + +/** + * Get FCC Proper User ID for a single email + * @param {string} email - Student email + * @param {Object} context - Next.js context (for server-side auth) + * @returns {Promise} - FCC Proper user ID or null if not found + */ +async function getFccProperUserIdByEmail(email, context = null) { + const response = await fetchFromFCC( + { + email: email, + inClassroom: true, // Special flag for classroom app access + targetUrl: '/api/protected/classroom/get-user-id' + }, + context + ); + + console.log('getFccProperUserIdByEmail response:', response); + console.log( + 'getFccProperUserIdByEmail response.data?.userId:', + response?.userId + ); + + return response?.userId || null; +} + +/** + * Get student progress data for multiple FCC Proper User IDs + * @param {string[]} userIds - Array of FCC Proper user IDs (max 50) + * @param {Object} context - Next.js context (for server-side auth) + * @returns {Promise} - { userId: [completedChallenges], ... } + */ +async function getStudentDataByUserIds(userIds, context = null) { + if (!Array.isArray(userIds) || userIds.length === 0) { + throw new Error('userIds must be a non-empty array'); + } + + if (userIds.length > 50) { + throw new Error('Maximum 50 user IDs allowed per request'); + } + + const response = await fetchFromFCC( + { + userIds: userIds, + inClassroom: true, + targetUrl: '/api/protected/classroom/get-user-data' + }, + context + ); + + return response.data || {}; +} + +module.exports = { + fetchFromFCC, + getFccProperUserIdByEmail, + getStudentDataByUserIds +};