Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53,507 changes: 53,507 additions & 0 deletions data/challengeMap.json

Large diffs are not rendered by default.

9 changes: 3 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@
"version": "0.0.0",
"private": true,
"description": "Classroom mode for freeCodeCamp",
"engines": {
"node": "^16",
"npm": "^8"
},
"main": "index.js",
"repository": {
"type": "git",
Expand All @@ -18,7 +14,7 @@
},
"homepage": "https://github.com/freecodecamp/classroom#readme",
"scripts": {
"develop": "next dev",
"develop": "next dev -p 3001",
"build": "next build",
"start": "next start",
"test:watch": "jest --watch",
Expand All @@ -28,7 +24,8 @@
"lint:pretty": "prettier --ignore-path .gitignore --check .",
"format": "prettier --ignore-path .gitignore --write .",
"prepare": "husky install",
"mock-fcc-data": "npx json-server --watch mock-json-server/fccdata.json --port 3001"
"mock-fcc-data": "npx json-server --watch mock-json-server/fccdata.json --port 3002",
"update-challenge-map": "node scripts/update-challenge-map.mjs"
},
"dependencies": {
"@headlessui/react": "1.7.17",
Expand Down
36 changes: 36 additions & 0 deletions pages/api/auth/[...nextauth].js
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,50 @@ 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 }
: {})
})
// ...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}`;
Expand Down
59 changes: 59 additions & 0 deletions pages/api/fcc-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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 { emails } = req.body;

if (!cookieToken) {
console.log('Unauthorized!');
return res.status(401).json({ error: 'Unauthorized' });
}

if (!emails || !Array.isArray(emails)) {
console.log('Missing or invalid emails array');
return res.status(400).json({ error: 'Missing emails array' });
}

// Convert email array to comma-separated string
const emailsString = emails.join(',');

// Build the URL with query parameters
const fccUrl = `http://localhost:3000/api/protected/classroom/get-user-data?emails=${encodeURIComponent(
emailsString
)}`;

const headers = {
'Content-Type': 'application/json',
Cookie: `jwt_access_token=${cookieToken}`
};

// Make the request - change to GET method and remove body
const fccResponse = await fetch(fccUrl, {
method: 'GET',
headers,
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' });
}
}
2 changes: 1 addition & 1 deletion pages/dashboard/v2/[id].js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
7 changes: 5 additions & 2 deletions pages/dashboard/v2/details/[id]/[studentEmail].js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
24 changes: 24 additions & 0 deletions scripts/update-challenge-map.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { writeFile } from 'fs/promises';
// The update-challenge-map.mjs script works only with challenge-map url set in this file,
// due to CORS requirements that classroom be in the same domain as proper.
// Otherwise, none of these requests will go through.
const CHALLENGE_MAP_URL = 'http://localhost:3000/api/build-challenge-map';
const OUTPUT_PATH = new URL('../data/challengeMap.json', import.meta.url);

async function updateChallengeMap() {
try {
console.log('Fetching challenge map from:', CHALLENGE_MAP_URL);
const res = await fetch(CHALLENGE_MAP_URL);
if (!res.ok) {
throw new Error(`Failed to fetch challenge map: ${res.status} ${res.statusText}`);
}
const map = await res.json();
await writeFile(OUTPUT_PATH, JSON.stringify(map, null, 2));
console.log('Challenge map saved to', OUTPUT_PATH.pathname);
} catch (err) {
console.error('Error updating challenge map:', err);
process.exit(1);
}
}

updateChallengeMap();
80 changes: 75 additions & 5 deletions util/api_proccesor.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,16 +296,86 @@ If you are having issues with the selector, you should probably check there.
return sortedBlocks.flat(1);
}

import { fetchFromFCC } from './fcc_proper';
import { resolveAllStudentsToDashboardFormat } from './challengeMapUtils';
import prisma from '../prisma/prisma';

/** ============ fetchStudentData() ============ */
export async function fetchStudentData() {
let data = await fetch(process.env.MOCK_USER_DATA_URL);
return data.json();
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,
fccCertifications: true,
classroomName: 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: {
id: true,
email: true,
name: true
}
});

// If no students, return empty array
if (students.length === 0) {
return [];
}

// Extract just the email addresses for the FCC API call
const studentEmails = students.map(student => student.email);

// Use fetchFromFCC instead of direct fetch
const data = await fetchFromFCC(
{
emails: studentEmails
},
context
);

// If FCC Proper returns { data: { email: [completedChallenges] } }, resolve to dashboard format
if (
data &&
data.data &&
typeof data.data === 'object' &&
!Array.isArray(data.data)
) {
return resolveAllStudentsToDashboardFormat(data.data);
}

// Otherwise, return as-is (for legacy/mock data)
return data.data || [];
} 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) {
Expand Down
61 changes: 61 additions & 0 deletions util/challengeMapUtils.js
Original file line number Diff line number Diff line change
@@ -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;
}
51 changes: 51 additions & 0 deletions util/fcc_proper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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({
emails: options.emails || [],
options: 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();
}

module.exports = {
fetchFromFCC
};
Loading