From 98d7abd599ee7b63db6f9433bf0094b03c13b541 Mon Sep 17 00:00:00 2001 From: Newton Ly Chung Date: Wed, 25 Feb 2026 09:22:36 -0800 Subject: [PATCH] feat(api): Endpoints for classroom - exchanging user data (#62063) Co-authored-by: Newton Ly Chung Co-authored-by: Carly Thomas Co-authored-by: Oliver Eyton-Williams --- api/src/app.ts | 1 + api/src/routes/protected/classroom.test.ts | 217 +++++++++++++++++++++ api/src/routes/protected/classroom.ts | 111 +++++++++++ api/src/routes/protected/index.ts | 1 + api/src/schemas/classroom/classroom.ts | 30 +++ 5 files changed, 360 insertions(+) create mode 100644 api/src/routes/protected/classroom.test.ts create mode 100644 api/src/routes/protected/classroom.ts create mode 100644 api/src/schemas/classroom/classroom.ts diff --git a/api/src/app.ts b/api/src/app.ts index 737c80587cf..635534da867 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -196,6 +196,7 @@ export const build = async ( fastify.addHook('onRequest', fastify.send401IfNoUser); await fastify.register(protectedRoutes.userGetRoutes); + await fastify.register(protectedRoutes.classroomRoutes); }); // Routes that redirect if access is denied: diff --git a/api/src/routes/protected/classroom.test.ts b/api/src/routes/protected/classroom.test.ts new file mode 100644 index 00000000000..f21614c4e16 --- /dev/null +++ b/api/src/routes/protected/classroom.test.ts @@ -0,0 +1,217 @@ +import { describe, test, expect, beforeAll, afterEach, vi } from 'vitest'; + +import { createUserInput } from '../../utils/create-user.js'; +import { + createSuperRequest, + defaultUserEmail, + defaultUserId, + devLogin, + resetDefaultUser, + setupServer, + superRequest +} from '../../../vitest.utils.js'; + +describe('classroom routes', () => { + setupServer(); + + describe('Authenticated user', () => { + let setCookies: string[]; + let superPost: ReturnType; + + const classroomUserEmail = 'student1@example.com'; + const nonClassroomUserEmail = 'student2@example.com'; + const classroomUserId = '000000000000000000000001'; + const nonClassroomUserId = '000000000000000000000002'; + + beforeAll(async () => { + setCookies = await devLogin(); + superPost = createSuperRequest({ method: 'POST', setCookies }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + + // Cleanup users created by these tests + await fastifyTestInstance.prisma.user.deleteMany({ + where: { email: { in: [classroomUserEmail, nonClassroomUserEmail] } } + }); + + // Reset default user to a clean state + await resetDefaultUser(); + }); + + describe('POST /api/protected/classroom/get-user-id', () => { + test('returns 400 for missing email', async () => { + const missingRes = await superPost( + '/api/protected/classroom/get-user-id' + ).send({}); + + expect(missingRes.status).toBe(400); + }); + + test('returns 200 with empty userId for invalid email format', async () => { + const invalidRes = await superPost( + '/api/protected/classroom/get-user-id' + ).send({ email: 'not-an-email' }); + + expect(invalidRes.status).toBe(200); + expect(invalidRes.body).toStrictEqual({ userId: '' }); + }); + + test('returns 200 with empty userId when no classroom account matches email', async () => { + // Default user is not a classroom account by default + const res = await superPost( + '/api/protected/classroom/get-user-id' + ).send({ email: defaultUserEmail }); + + expect(res.status).toBe(200); + expect(res.body).toStrictEqual({ userId: '' }); + }); + + test('returns 200 with userId for a classroom account', async () => { + // Make the default user a classroom account + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { isClassroomAccount: true } + }); + + const res = await superPost( + '/api/protected/classroom/get-user-id' + ).send({ email: defaultUserEmail }); + + expect(res.status).toBe(200); + expect(res.body).toStrictEqual({ userId: defaultUserId }); + }); + + test('returns 500 when the database query fails', async () => { + vi.spyOn( + fastifyTestInstance.prisma.user, + 'findFirst' + ).mockRejectedValue(new Error('test')); + + const res = await superPost( + '/api/protected/classroom/get-user-id' + ).send({ email: defaultUserEmail }); + + expect(res.status).toBe(500); + expect(res.body).toStrictEqual({ error: 'Failed to retrieve user id' }); + }); + }); + + describe('POST /api/protected/classroom/get-user-data', () => { + test('returns 400 when more than 50 userIds are provided', async () => { + const tooMany = Array.from({ length: 51 }, (_, i) => `id-${i}`); + + const res = await superPost( + '/api/protected/classroom/get-user-data' + ).send({ userIds: tooMany }); + + expect(res.status).toBe(400); + expect(res.body).toStrictEqual({ + error: 'Too many users requested. Maximum 50 allowed.' + }); + }); + + test('returns data only for classroom accounts', async () => { + const now = Date.now(); + + // Make default user a classroom account with one completed challenge + await fastifyTestInstance.prisma.user.update({ + where: { id: defaultUserId }, + data: { + isClassroomAccount: true, + completedChallenges: [ + { + id: 'challenge-default', + completedDate: now, + files: [] + } + ] + } + }); + + // Create an additional classroom user + await fastifyTestInstance.prisma.user.create({ + data: { + ...createUserInput(classroomUserEmail), + id: classroomUserId, + isClassroomAccount: true, + completedChallenges: [ + { + id: 'challenge-student', + completedDate: now + 1, + files: [] + } + ] + } + }); + + // Create a non-classroom user that should be filtered out + await fastifyTestInstance.prisma.user.create({ + data: { + ...createUserInput(nonClassroomUserEmail), + id: nonClassroomUserId, + isClassroomAccount: false, + completedChallenges: [] + } + }); + + const res = await superPost( + '/api/protected/classroom/get-user-data' + ).send({ + userIds: [defaultUserId, classroomUserId, nonClassroomUserId] + }); + + expect(res.status).toBe(200); + const responseBody = res.body as { + data: Record< + string, + Array<{ id: string; completedDate: number }> | undefined + >; + }; + expect(Object.keys(responseBody.data)).toEqual( + expect.arrayContaining([defaultUserId, classroomUserId]) + ); + expect(responseBody.data).not.toHaveProperty(nonClassroomUserId); + + expect(responseBody.data[defaultUserId]?.[0]).toMatchObject({ + id: 'challenge-default', + completedDate: now + }); + expect(responseBody.data[classroomUserId]?.[0]).toMatchObject({ + id: 'challenge-student', + completedDate: now + 1 + }); + }); + + test('returns 500 when the database query fails', async () => { + vi.spyOn(fastifyTestInstance.prisma.user, 'findMany').mockRejectedValue( + new Error('test') + ); + + const res = await superPost( + '/api/protected/classroom/get-user-data' + ).send({ userIds: [defaultUserId] }); + + expect(res.status).toBe(500); + expect(res.body).toStrictEqual({ + error: 'Failed to retrieve user data' + }); + }); + }); + }); + + describe('Unauthenticated user', () => { + test('POST requests are rejected with 401', async () => { + const res = await superRequest( + '/api/protected/classroom/get-user-id', + { + method: 'POST' + }, + { sendCSRFToken: false } + ).send({ email: 'someone@example.com' }); + + expect(res.status).toBe(401); + }); + }); +}); diff --git a/api/src/routes/protected/classroom.ts b/api/src/routes/protected/classroom.ts new file mode 100644 index 00000000000..921933c7bf0 --- /dev/null +++ b/api/src/routes/protected/classroom.ts @@ -0,0 +1,111 @@ +import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; +import { + normalizeChallenges, + NormalizedChallenge +} from '../../utils/normalize.js'; +import * as schemas from '../../schemas/classroom/classroom.js'; + +/** + * Fastify plugin for classroom-related protected routes. + * Provides endpoint for retrieving user data for classrooms. + * @param fastify - The Fastify instance. + * @param _options - Plugin options (unused). + * @param done - Callback to signal plugin registration is complete. + */ +export const classroomRoutes: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + // Endpoint to retrieve a user's ID from a user's email. + // If we send a 404 error here, it will stop the entire classroom process from working. + // Instead, we indicate that the user was not found through a null response and continue. + fastify.post( + '/api/protected/classroom/get-user-id', + { + schema: schemas.classroomGetUserIdSchema + }, + async (request, reply) => { + const { email } = request.body; + + // Basic email validation - return empty userId for invalid emails + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return reply.send({ userId: '' }); + } + + try { + // Find the user by email + const user = await fastify.prisma.user.findFirst({ + where: { email, isClassroomAccount: true }, + select: { id: true } + }); + + if (!user) { + return reply.send({ userId: '' }); + } + + return reply.send({ + userId: user.id + }); + } catch (error) { + fastify.log.error(error); + return reply.code(500).send({ error: 'Failed to retrieve user id' }); + } + } + ); + + // Endpoint to retrieve user(s) data from a list of user ids + fastify.post( + '/api/protected/classroom/get-user-data', + { + schema: schemas.classroomGetUserDataSchema + }, + async (request, reply) => { + const { userIds = [] } = request.body; + + // Limit number of users per request for performance + // Send custom error message if this is exceeded + if (userIds.length > 50) { + return reply.code(400).send({ + error: 'Too many users requested. Maximum 50 allowed.' + }); + } + + try { + // Find all the requested users by user id + const users = await fastify.prisma.user.findMany({ + where: { + id: { in: userIds }, + isClassroomAccount: true + }, + select: { + id: true, + completedChallenges: true + } + }); + + // Map to transform user data into the required format + const userData: Record = {}; + + users.forEach(user => { + // Normalize challenges + const normalizedChallenges = normalizeChallenges( + user.completedChallenges + ); + + userData[user.id] = normalizedChallenges; + }); + + return reply.send({ + data: userData + }); + } catch (error) { + fastify.log.error(error); + return reply.code(500).send({ error: 'Failed to retrieve user data' }); + } + } + ); + + done(); +}; diff --git a/api/src/routes/protected/index.ts b/api/src/routes/protected/index.ts index 01c1fb9208e..87e738f8035 100644 --- a/api/src/routes/protected/index.ts +++ b/api/src/routes/protected/index.ts @@ -1,5 +1,6 @@ export * from './certificate.js'; export * from './challenge.js'; +export * from './classroom.js'; export * from './donate.js'; export * from './settings.js'; export * from './user.js'; diff --git a/api/src/schemas/classroom/classroom.ts b/api/src/schemas/classroom/classroom.ts new file mode 100644 index 00000000000..bd56308c45e --- /dev/null +++ b/api/src/schemas/classroom/classroom.ts @@ -0,0 +1,30 @@ +import { Type } from '@fastify/type-provider-typebox'; +export const classroomGetUserIdSchema = { + body: Type.Object({ + email: Type.String({ maxLength: 1024 }) + }), + response: { + 200: Type.Object({ userId: Type.String() }), + 500: Type.Object({ error: Type.String() }) + } +}; +export const classroomGetUserDataSchema = { + body: Type.Object({ + userIds: Type.Array(Type.String()) + }), + response: { + 200: Type.Object({ + data: Type.Record( + Type.String(), + Type.Array( + Type.Object({ + id: Type.String(), + completedDate: Type.Number() + }) + ) + ) + }), + 400: Type.Object({ error: Type.String() }), + 500: Type.Object({ error: Type.String() }) + } +};