From 3c1a35694e9e4dad21b5999f2e2036f9a8401d22 Mon Sep 17 00:00:00 2001 From: Mrugesh Mohapatra Date: Wed, 25 Feb 2026 23:18:39 +0530 Subject: [PATCH] feat: integrate fCC API for student completion data Connect the classroom app to freeCodeCamp's service-to-service API endpoints for fetching real student completion data instead of mock json-server data. - Add util/fcc-api.js (Bearer token auth, auto-batching at 50 IDs) - Add fetchClassroomStudentData() to fetchStudentData.js - Resolve fccProperUserId on student join (non-blocking) - Dashboard pages conditionally use fCC API when FCC_API_URL is set - Add FCC_API_URL and TPA_API_BEARER_TOKEN to .env.sample --- .env.sample | 5 + pages/api/student_email_join.js | 30 ++++-- pages/dashboard/[id].js | 22 ++++- pages/dashboard/v2/[id].js | 22 ++++- .../v2/details/[id]/[studentEmail].js | 19 +++- util/fcc-api.js | 95 +++++++++++++++++++ util/student/fetchStudentData.js | 44 ++++++++- 7 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 util/fcc-api.js diff --git a/.env.sample b/.env.sample index 63e0d5ab0..59a22134b 100644 --- a/.env.sample +++ b/.env.sample @@ -2,6 +2,11 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/classroom" MOCK_USER_DATA_URL = "http://localhost:3001/data" NEXTAUTH_URL="http://localhost:3000" +# freeCodeCamp API (for fetching student completion data) +# When set, dashboard pages use the fCC API instead of mock data. +FCC_API_URL=http://localhost:3000 +TPA_API_BEARER_TOKEN=tpa_api_bearer_token_from_dashboard + #set NEXTAUTH_SECRET with random string (can create via commandline `openssl rand -base64 32`) #https://next-auth.js.org/configuration/options#secret NEXTAUTH_SECRET=abcdefghijlmnopqrstuvwxyz diff --git a/pages/api/student_email_join.js b/pages/api/student_email_join.js index 738b4fe20..785e791e5 100644 --- a/pages/api/student_email_join.js +++ b/pages/api/student_email_join.js @@ -1,17 +1,18 @@ import prisma from '../../prisma/prisma'; import { unstable_getServerSession } from 'next-auth'; import { authOptions } from './auth/[...nextauth]'; +import { fetchUserIdByEmail } from '../../util/fcc-api'; export default async function handle(req, res) { // unstable_getServerSession is recommended here: https://next-auth.js.org/configuration/nextjs const session = await unstable_getServerSession(req, res, authOptions); - if (!req.method == 'PUT') { - res.status(405).end(); + if (req.method !== 'PUT') { + return res.status(405).end(); } if (!session) { - res.status(403).end(); + return res.status(403).end(); } const body = req.body; @@ -21,7 +22,8 @@ export default async function handle(req, res) { email: session.user.email }, select: { - id: true + id: true, + fccProperUserId: true } }); // Grab class info here @@ -35,7 +37,7 @@ export default async function handle(req, res) { }); const existsInClassroom = checkClass.fccUserIds.includes(userInfo.id); if (existsInClassroom) { - res.status(409).end(); + return res.status(409).end(); } // TODO: Once we allow multiple teachers inside of a classroom, make sure that the teachers // are placed inside of the teacher array rather than as a regular student @@ -50,7 +52,23 @@ export default async function handle(req, res) { } }); } - // Update calssroom with user id + // Resolve fCC Proper user ID if not already linked + if (!userInfo.fccProperUserId) { + try { + const { userId } = await fetchUserIdByEmail(session.user.email); + if (userId) { + await prisma.user.update({ + where: { email: session.user.email }, + data: { fccProperUserId: userId } + }); + } + } catch (err) { + // Don't block the join — fCC ID can be resolved later + console.error('Failed to resolve fCC user ID:', err); + } + } + + // Update classroom with user id await prisma.classroom.update({ where: { classroomId: body.join[0] diff --git a/pages/dashboard/[id].js b/pages/dashboard/[id].js index 95e003192..5fce90b88 100644 --- a/pages/dashboard/[id].js +++ b/pages/dashboard/[id].js @@ -5,7 +5,10 @@ import Navbar from '../../components/navbar'; import DashTabs from '../../components/dashtabs'; import { getSession } from 'next-auth/react'; import { createSuperblockDashboardObject } from '../../util/dashboard/createSuperblockDashboardObject'; -import { fetchStudentData } from '../../util/student/fetchStudentData'; +import { + fetchClassroomStudentData, + fetchStudentData +} from '../../util/student/fetchStudentData'; import redirectUser from '../../util/redirectUser.js'; // NOTE: These functions are deprecated for v9 curriculum (no individual REST API JSON files) @@ -59,7 +62,22 @@ export async function getServerSideProps(context) { let superBlockJsons = await getSuperBlockJsons(superblockURLS); let dashboardObjs = await createSuperblockDashboardObject(superBlockJsons); - let currStudentData = await fetchStudentData(); + // Fetch student completion data from fCC API (falls back to mock data + // if FCC_API_URL is not configured, for local development). + let currStudentData; + if (process.env.FCC_API_URL) { + const classroom = await prisma.classroom.findUnique({ + where: { classroomId: context.params.id }, + select: { fccUserIds: true } + }); + const students = await prisma.user.findMany({ + where: { id: { in: classroom.fccUserIds } }, + select: { id: true, email: true, fccProperUserId: true } + }); + currStudentData = await fetchClassroomStudentData(students); + } else { + currStudentData = await fetchStudentData(); + } return { props: { diff --git a/pages/dashboard/v2/[id].js b/pages/dashboard/v2/[id].js index 0de21d81b..3ba2c1733 100644 --- a/pages/dashboard/v2/[id].js +++ b/pages/dashboard/v2/[id].js @@ -7,7 +7,10 @@ import GlobalDashboardTable from '../../../components/dashtable_v2'; import React from 'react'; import { createSuperblockDashboardObject } from '../../../util/dashboard/createSuperblockDashboardObject'; import { getTotalChallengesForSuperblocks } from '../../../util/student/calculateProgress'; -import { fetchStudentData } from '../../../util/student/fetchStudentData'; +import { + fetchClassroomStudentData, + fetchStudentData +} from '../../../util/student/fetchStudentData'; import { checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher } from '../../../util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher'; import redirectUser from '../../../util/redirectUser.js'; @@ -66,7 +69,22 @@ export async function getServerSideProps(context) { let totalChallenges = getTotalChallengesForSuperblocks(dashboardObjs); - let studentData = await fetchStudentData(); + // Fetch student completion data from fCC API (falls back to mock data + // if FCC_API_URL is not configured, for local development). + let studentData; + if (process.env.FCC_API_URL) { + const classroom = await prisma.classroom.findUnique({ + where: { classroomId: context.params.id }, + select: { fccUserIds: true } + }); + const students = await prisma.user.findMany({ + where: { id: { in: classroom.fccUserIds } }, + select: { id: true, email: true, fccProperUserId: true } + }); + studentData = await fetchClassroomStudentData(students); + } else { + studentData = await fetchStudentData(); + } // 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 61f9ef0cb..f653cf9cf 100644 --- a/pages/dashboard/v2/details/[id]/[studentEmail].js +++ b/pages/dashboard/v2/details/[id]/[studentEmail].js @@ -6,6 +6,7 @@ import { getSession } from 'next-auth/react'; import { createSuperblockDashboardObject } from '../../../../../util/dashboard/createSuperblockDashboardObject'; import { getSuperblockTitlesInClassroomByIndex } from '../../../../../util/curriculum/getSuperblockTitlesInClassroomByIndex'; import { getIndividualStudentData } from '../../../../../util/student/getIndividualStudentData'; +import { fetchClassroomStudentData } from '../../../../../util/student/fetchStudentData'; import React from 'react'; import redirectUser from '../../../../../util/redirectUser.js'; import styles from '../../../../../components/DetailsCSS.module.css'; @@ -81,7 +82,23 @@ export async function getServerSideProps(context) { superBlockJsons ); - let studentData = await getIndividualStudentData(studentEmail); + // Fetch individual student data from fCC API (falls back to mock data + // if FCC_API_URL is not configured, for local development). + let studentData; + if (process.env.FCC_API_URL) { + const student = await prisma.user.findFirst({ + where: { email: studentEmail }, + select: { id: true, email: true, fccProperUserId: true } + }); + if (student?.fccProperUserId) { + const results = await fetchClassroomStudentData([student]); + studentData = results[0] || { email: studentEmail, certifications: [] }; + } else { + studentData = { email: studentEmail, certifications: [] }; + } + } else { + studentData = await getIndividualStudentData(studentEmail); + } return { props: { diff --git a/util/fcc-api.js b/util/fcc-api.js new file mode 100644 index 000000000..d38860948 --- /dev/null +++ b/util/fcc-api.js @@ -0,0 +1,95 @@ +const MAX_USER_IDS_PER_REQUEST = 50; + +function getFccApiUrl() { + return process.env.FCC_API_URL; +} + +function getHeaders() { + const token = process.env.TPA_API_BEARER_TOKEN; + if (!token) { + throw new Error('TPA_API_BEARER_TOKEN is not set'); + } + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }; +} + +/** + * Look up a freeCodeCamp user ID by email. + * Only returns a userId for users who have opted into classroom mode. + * + * @param {string} email + * @returns {Promise<{userId: string}>} userId is empty string if not found + */ +export async function fetchUserIdByEmail(email) { + const res = await fetch(`${getFccApiUrl()}/apps/classroom/get-user-id`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify({ email }) + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error( + 'get-user-id failed', + res.status, + body.error || res.statusText + ); + throw new Error('Failed to look up fCC user ID'); + } + + return res.json(); +} + +/** + * Fetch challenge completion data for an array of fCC user IDs. + * Automatically batches requests when there are more than 50 IDs. + * + * @param {string[]} userIds - Array of fCC user ObjectIDs + * @returns {Promise<{data: Object.>}>} + */ +export async function fetchUserData(userIds) { + if (!userIds || userIds.length === 0) { + return { data: {} }; + } + + // Batch into chunks of 50 + const chunks = []; + for (let i = 0; i < userIds.length; i += MAX_USER_IDS_PER_REQUEST) { + chunks.push(userIds.slice(i, i + MAX_USER_IDS_PER_REQUEST)); + } + + const results = await Promise.all( + chunks.map(async chunk => { + const res = await fetch( + `${getFccApiUrl()}/apps/classroom/get-user-data`, + { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify({ userIds: chunk }) + } + ); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error( + 'get-user-data failed', + res.status, + body.error || res.statusText + ); + throw new Error('Failed to fetch student data from fCC API'); + } + + return res.json(); + }) + ); + + // Merge all batch results into a single data object + const merged = {}; + for (const result of results) { + Object.assign(merged, result.data); + } + + return { data: merged }; +} diff --git a/util/student/fetchStudentData.js b/util/student/fetchStudentData.js index 9eaee14e7..0db0cbe84 100644 --- a/util/student/fetchStudentData.js +++ b/util/student/fetchStudentData.js @@ -1,9 +1,45 @@ +import { fetchUserData } from '../fcc-api'; +import { resolveAllStudentsToDashboardFormat } from '../challengeMapUtils'; + /** - * Fetches student data from the mock data URL - * @returns {Promise} Array of student objects + * Fetches student completion data from the fCC API and transforms it into the + * nested dashboard format expected by the classroom UI components. * - * NOTE: This is a mock data function used for testing. - * In production, use FCC Proper API with fccProperUserIds. + * @param {Array<{id: string, email: string, fccProperUserId: string|null}>} students + * Classroom User records with at least id, email, and fccProperUserId. + * @returns {Promise>} + * Dashboard-ready student data, one entry per student that has a linked fCC account. + */ +export async function fetchClassroomStudentData(students) { + const studentsWithFccId = students.filter(s => s.fccProperUserId); + + if (studentsWithFccId.length === 0) return []; + + const fccUserIds = studentsWithFccId.map(s => s.fccProperUserId); + const { data } = await fetchUserData(fccUserIds); + + // Map fccProperUserId → email so resolveAllStudentsToDashboardFormat + // can key the output by email (which the dashboard components expect). + const idToEmail = {}; + for (const student of studentsWithFccId) { + idToEmail[student.fccProperUserId] = student.email; + } + + const emailKeyedData = {}; + for (const [fccId, challenges] of Object.entries(data)) { + const email = idToEmail[fccId]; + if (email) { + emailKeyedData[email] = challenges; + } + } + + return resolveAllStudentsToDashboardFormat(emailKeyedData); +} + +/** + * Fetches student data from the mock data URL (development only). + * @returns {Promise} Array of student objects + * @deprecated Use fetchClassroomStudentData with fCC API in production. */ export async function fetchStudentData() { let data = await fetch(process.env.MOCK_USER_DATA_URL);