Skip to content
Open
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
3 changes: 3 additions & 0 deletions data/challengeMap.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{

}
42 changes: 39 additions & 3 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 All @@ -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*/
60 changes: 60 additions & 0 deletions pages/api/fcc-proxy.js
Original file line number Diff line number Diff line change
@@ -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' });
}
}
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
94 changes: 88 additions & 6 deletions util/api_proccesor.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@
order:
currBlock[certificationName]['blocks'][course]['challenges'][
'order'
]
] ?? null
};
return currCourseBlock;
});
Expand All @@ -296,16 +296,98 @@
return sortedBlocks.flat(1);
}

import { getStudentDataByUserIds } from './fcc_proper';
import { resolveAllStudentsToDashboardFormat } from './challengeMapUtils';
// TODO: Comment out the import prisma line.

Check notice on line 301 in util/api_proccesor.js

View check run for this annotation

codefactor.io / CodeFactor

util/api_proccesor.js#L301

Unresolved 'todo' comment. (eslint/no-warning-comments)
// 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) {
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They have confirmed that we are to ignore missing challenge map IDs as they are in reference to old steps that are no longer included or tracked (therefore legacy) and we do not list legacy data. The only reason for keeping the notation is in case they might wanted the debug option here. Otherwise this notation can be removed.

// 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;
}
Loading
Loading