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
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 24 additions & 6 deletions pages/api/student_email_join.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,7 +22,8 @@
email: session.user.email
},
select: {
id: true
id: true,
fccProperUserId: true
}
});
// Grab class info here
Expand All @@ -35,9 +37,9 @@
});
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

Check notice on line 42 in pages/api/student_email_join.js

View check run for this annotation

codefactor.io / CodeFactor

pages/api/student_email_join.js#L42

Unresolved 'todo' comment. (eslint/no-warning-comments)
// are placed inside of the teacher array rather than as a regular student
else if (userInfo.role === 'NONE') {
// This runs only when a new user attempts to join a classroom.
Expand All @@ -50,7 +52,23 @@
}
});
}
// 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]
Expand Down
22 changes: 20 additions & 2 deletions pages/dashboard/[id].js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: {
Expand Down
22 changes: 20 additions & 2 deletions pages/dashboard/v2/[id].js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 =
Expand Down
19 changes: 18 additions & 1 deletion pages/dashboard/v2/details/[id]/[studentEmail].js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand Down
95 changes: 95 additions & 0 deletions util/fcc-api.js
Original file line number Diff line number Diff line change
@@ -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.<string, Array<{id: string, completedDate: number}>>}>}
*/
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 };
}
44 changes: 40 additions & 4 deletions util/student/fetchStudentData.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,45 @@
import { fetchUserData } from '../fcc-api';
import { resolveAllStudentsToDashboardFormat } from '../challengeMapUtils';

/**
* Fetches student data from the mock data URL
* @returns {Promise<Array>} 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<Array<{email: string, certifications: Array}>>}
* 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>} 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);
Expand Down
Loading