From 1952a412edcd04b332655199737c340a4389d174 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 22 Dec 2025 13:31:04 -0600 Subject: [PATCH] feat(classroom): implement enrollment system (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add EnrollChildFlow component for parents to enroll children - Add enrollment hooks (useEnrolledStudents, useCreateEnrollmentRequest, etc.) - Update StudentManagerTab with enrolled students and pending requests - Add "Enroll in another classroom" option for teachers who are parents - Fix critical auth bug: convert guestId to user.id in all classroom API routes The API routes were incorrectly using viewerId (guestId from cookie) when they should have been using users.id. This caused 403 errors for all classroom operations. Fixed by adding getOrCreateUser() to convert guestId to proper user.id before calling business logic functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 8 +- .../[requestId]/approve/route.ts | 23 +- .../[requestId]/deny/route.ts | 23 +- .../enrollment-requests/route.ts | 28 +- .../enrollments/[playerId]/route.ts | 23 +- .../[classroomId]/enrollments/route.ts | 21 +- .../presence/[playerId]/route.ts | 23 +- .../[classroomId]/presence/route.ts | 28 +- .../app/api/classrooms/[classroomId]/route.ts | 26 +- apps/web/src/app/practice/PracticeClient.tsx | 113 +++- .../classroom/ClassroomDashboard.tsx | 71 ++- .../components/classroom/EnrollChildFlow.tsx | 441 ++++++++++++++ .../classroom/StudentManagerTab.tsx | 552 +++++++++++++++--- apps/web/src/components/classroom/index.ts | 1 + apps/web/src/hooks/useClassroom.ts | 207 ++++++- 15 files changed, 1465 insertions(+), 123 deletions(-) create mode 100644 apps/web/src/components/classroom/EnrollChildFlow.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c4005ce8..4a8b1b0c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -254,7 +254,13 @@ "Bash(mcp__sqlite__describe_table:*)", "Bash(git diff:*)", "Bash(git show:*)", - "Bash(npx tsx:*)" + "Bash(npx tsx:*)", + "Bash(xargs ls:*)", + "Bash(mcp__sqlite__list_tables)", + "WebFetch(domain:developer.chrome.com)", + "Bash(claude mcp add:*)", + "Bash(claude mcp:*)", + "Bash(git rev-parse:*)" ], "deny": [], "ask": [] diff --git a/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/approve/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/approve/route.ts index 32f4dc11..6e6ed946 100644 --- a/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/approve/route.ts +++ b/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/approve/route.ts @@ -1,7 +1,25 @@ +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' import { approveEnrollmentRequest, getTeacherClassroom } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + interface RouteParams { params: Promise<{ classroomId: string; requestId: string }> } @@ -16,14 +34,15 @@ export async function POST(req: NextRequest, { params }: RouteParams) { try { const { classroomId, requestId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Verify user is the teacher of this classroom - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) if (!classroom || classroom.id !== classroomId) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } - const result = await approveEnrollmentRequest(requestId, viewerId, 'teacher') + const result = await approveEnrollmentRequest(requestId, user.id, 'teacher') return NextResponse.json({ request: result.request, diff --git a/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/deny/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/deny/route.ts index 7d1b1d88..cd7c5ab5 100644 --- a/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/deny/route.ts +++ b/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/[requestId]/deny/route.ts @@ -1,7 +1,25 @@ +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' import { denyEnrollmentRequest, getTeacherClassroom } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + interface RouteParams { params: Promise<{ classroomId: string; requestId: string }> } @@ -16,14 +34,15 @@ export async function POST(req: NextRequest, { params }: RouteParams) { try { const { classroomId, requestId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Verify user is the teacher of this classroom - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) if (!classroom || classroom.id !== classroomId) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } - const request = await denyEnrollmentRequest(requestId, viewerId, 'teacher') + const request = await denyEnrollmentRequest(requestId, user.id, 'teacher') return NextResponse.json({ request }) } catch (error) { diff --git a/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/route.ts index 89bdd139..c2a15bfa 100644 --- a/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/route.ts +++ b/apps/web/src/app/api/classrooms/[classroomId]/enrollment-requests/route.ts @@ -1,4 +1,6 @@ +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' import { createEnrollmentRequest, getPendingRequestsForClassroom, @@ -7,6 +9,22 @@ import { } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + interface RouteParams { params: Promise<{ classroomId: string }> } @@ -21,9 +39,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) { try { const { classroomId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Verify user is the teacher of this classroom - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) if (!classroom || classroom.id !== classroomId) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } @@ -48,6 +67,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) { try { const { classroomId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) const body = await req.json() if (!body.playerId) { @@ -55,9 +75,9 @@ export async function POST(req: NextRequest, { params }: RouteParams) { } // Determine role: is user the teacher or a parent? - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) const isTeacher = classroom?.id === classroomId - const parentCheck = await isParent(viewerId, body.playerId) + const parentCheck = await isParent(user.id, body.playerId) if (!isTeacher && !parentCheck) { return NextResponse.json( @@ -71,7 +91,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) { const request = await createEnrollmentRequest({ classroomId, playerId: body.playerId, - requestedBy: viewerId, + requestedBy: user.id, requestedByRole, }) diff --git a/apps/web/src/app/api/classrooms/[classroomId]/enrollments/[playerId]/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/enrollments/[playerId]/route.ts index fd77774e..c34bf220 100644 --- a/apps/web/src/app/api/classrooms/[classroomId]/enrollments/[playerId]/route.ts +++ b/apps/web/src/app/api/classrooms/[classroomId]/enrollments/[playerId]/route.ts @@ -1,7 +1,25 @@ +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' import { unenrollStudent, getTeacherClassroom, isParent } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + interface RouteParams { params: Promise<{ classroomId: string; playerId: string }> } @@ -16,11 +34,12 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) { try { const { classroomId, playerId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Check authorization: must be teacher of classroom OR parent of student - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) const isTeacher = classroom?.id === classroomId - const parentCheck = await isParent(viewerId, playerId) + const parentCheck = await isParent(user.id, playerId) if (!isTeacher && !parentCheck) { return NextResponse.json( diff --git a/apps/web/src/app/api/classrooms/[classroomId]/enrollments/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/enrollments/route.ts index ec5dcc7e..c532cb76 100644 --- a/apps/web/src/app/api/classrooms/[classroomId]/enrollments/route.ts +++ b/apps/web/src/app/api/classrooms/[classroomId]/enrollments/route.ts @@ -1,7 +1,25 @@ +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' import { getEnrolledStudents, getTeacherClassroom } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + interface RouteParams { params: Promise<{ classroomId: string }> } @@ -16,9 +34,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) { try { const { classroomId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Verify user is the teacher of this classroom - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) if (!classroom || classroom.id !== classroomId) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } diff --git a/apps/web/src/app/api/classrooms/[classroomId]/presence/[playerId]/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/presence/[playerId]/route.ts index 41a53f5f..62eed5e4 100644 --- a/apps/web/src/app/api/classrooms/[classroomId]/presence/[playerId]/route.ts +++ b/apps/web/src/app/api/classrooms/[classroomId]/presence/[playerId]/route.ts @@ -1,7 +1,25 @@ +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' import { leaveSpecificClassroom, getTeacherClassroom, isParent } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + interface RouteParams { params: Promise<{ classroomId: string; playerId: string }> } @@ -16,11 +34,12 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) { try { const { classroomId, playerId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Check authorization: must be teacher of classroom OR parent of student - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) const isTeacher = classroom?.id === classroomId - const parentCheck = await isParent(viewerId, playerId) + const parentCheck = await isParent(user.id, playerId) if (!isTeacher && !parentCheck) { return NextResponse.json( diff --git a/apps/web/src/app/api/classrooms/[classroomId]/presence/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/presence/route.ts index e56bc141..8d28cf59 100644 --- a/apps/web/src/app/api/classrooms/[classroomId]/presence/route.ts +++ b/apps/web/src/app/api/classrooms/[classroomId]/presence/route.ts @@ -1,4 +1,6 @@ +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' import { enterClassroom, getClassroomPresence, @@ -7,6 +9,22 @@ import { } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + interface RouteParams { params: Promise<{ classroomId: string }> } @@ -21,9 +39,10 @@ export async function GET(req: NextRequest, { params }: RouteParams) { try { const { classroomId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Verify user is the teacher of this classroom - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) if (!classroom || classroom.id !== classroomId) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } @@ -55,6 +74,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) { try { const { classroomId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) const body = await req.json() if (!body.playerId) { @@ -62,9 +82,9 @@ export async function POST(req: NextRequest, { params }: RouteParams) { } // Check authorization: must be teacher of classroom OR parent of student - const classroom = await getTeacherClassroom(viewerId) + const classroom = await getTeacherClassroom(user.id) const isTeacher = classroom?.id === classroomId - const parentCheck = await isParent(viewerId, body.playerId) + const parentCheck = await isParent(user.id, body.playerId) if (!isTeacher && !parentCheck) { return NextResponse.json( @@ -76,7 +96,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) { const result = await enterClassroom({ playerId: body.playerId, classroomId, - enteredBy: viewerId, + enteredBy: user.id, }) if (!result.success) { diff --git a/apps/web/src/app/api/classrooms/[classroomId]/route.ts b/apps/web/src/app/api/classrooms/[classroomId]/route.ts index ebe277ec..48b28647 100644 --- a/apps/web/src/app/api/classrooms/[classroomId]/route.ts +++ b/apps/web/src/app/api/classrooms/[classroomId]/route.ts @@ -1,4 +1,6 @@ +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' import { deleteClassroom, getClassroom, @@ -7,6 +9,22 @@ import { } from '@/lib/classroom' import { getViewerId } from '@/lib/viewer' +/** + * Get or create user record for a viewerId (guestId) + */ +async function getOrCreateUser(viewerId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, viewerId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId: viewerId }).returning() + user = newUser + } + + return user +} + interface RouteParams { params: Promise<{ classroomId: string }> } @@ -45,11 +63,12 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) { try { const { classroomId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) const body = await req.json() // Handle code regeneration separately if (body.regenerateCode) { - const newCode = await regenerateClassroomCode(classroomId, viewerId) + const newCode = await regenerateClassroomCode(classroomId, user.id) if (!newCode) { return NextResponse.json( { error: 'Not authorized or classroom not found' }, @@ -69,7 +88,7 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 }) } - const classroom = await updateClassroom(classroomId, viewerId, updates) + const classroom = await updateClassroom(classroomId, user.id, updates) if (!classroom) { return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 }) @@ -92,8 +111,9 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) { try { const { classroomId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) - const success = await deleteClassroom(classroomId, viewerId) + const success = await deleteClassroom(classroomId, user.id) if (!success) { return NextResponse.json({ error: 'Not authorized or classroom not found' }, { status: 403 }) diff --git a/apps/web/src/app/practice/PracticeClient.tsx b/apps/web/src/app/practice/PracticeClient.tsx index 1a9e7c5e..65151efb 100644 --- a/apps/web/src/app/practice/PracticeClient.tsx +++ b/apps/web/src/app/practice/PracticeClient.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation' import { useCallback, useMemo, useState } from 'react' import { Z_INDEX } from '@/constants/zIndex' -import { ClassroomDashboard, CreateClassroomForm } from '@/components/classroom' +import { ClassroomDashboard, CreateClassroomForm, EnrollChildFlow } from '@/components/classroom' import { PageWithNav } from '@/components/PageWithNav' import { StudentFilterBar } from '@/components/practice/StudentFilterBar' import { StudentSelector, type StudentWithProgress } from '@/components/practice' @@ -34,6 +34,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) { // Classroom state - check if user is a teacher const { data: classroom, isLoading: isLoadingClassroom } = useMyClassroom() const [showCreateClassroom, setShowCreateClassroom] = useState(false) + const [showEnrollChild, setShowEnrollChild] = useState(false) // Filter state const [searchQuery, setSearchQuery] = useState('') @@ -190,6 +191,15 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) { setShowCreateClassroom(false) }, []) + // Handle enrollment flow + const handleEnrollChild = useCallback(() => { + setShowEnrollChild(true) + }, []) + + const handleCloseEnrollChild = useCallback(() => { + setShowEnrollChild(false) + }, []) + // If user is a teacher, show the classroom dashboard if (classroom) { return ( @@ -235,6 +245,31 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) { ) } + // Show enroll child flow if requested + if (showEnrollChild) { + return ( + +
+ +
+
+ ) + } + // Parent view - show student list with filter bar return ( @@ -295,31 +330,67 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) { Build your soroban skills one step at a time

- {/* Become a Teacher option */} + {/* Parent/Teacher options */} {!isLoadingClassroom && !classroom && ( - + {/* Enroll in Classroom option - for parents with a teacher */} + {players.length > 0 && ( + + )} + + {/* Become a Teacher option */} + + )} diff --git a/apps/web/src/components/classroom/ClassroomDashboard.tsx b/apps/web/src/components/classroom/ClassroomDashboard.tsx index 4ceb6b83..6238c565 100644 --- a/apps/web/src/components/classroom/ClassroomDashboard.tsx +++ b/apps/web/src/components/classroom/ClassroomDashboard.tsx @@ -6,6 +6,7 @@ import { useTheme } from '@/contexts/ThemeContext' import { css } from '../../../styled-system/css' import { ClassroomCodeShare } from './ClassroomCodeShare' import { ClassroomTab } from './ClassroomTab' +import { EnrollChildFlow } from './EnrollChildFlow' import { StudentManagerTab } from './StudentManagerTab' type TabId = 'classroom' | 'students' @@ -29,6 +30,7 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' const [activeTab, setActiveTab] = useState('classroom') + const [showEnrollChild, setShowEnrollChild] = useState(false) const tabs: { id: TabId; label: string; icon: string }[] = [ { id: 'classroom', label: 'Classroom', icon: '🏫' }, @@ -95,16 +97,45 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas borderColor: isDark ? 'green.800' : 'green.200', })} > -

- Your Children -

+

+ Your Children +

+ +
)} + {/* Enroll child modal */} + {showEnrollChild && ( +
{ + if (e.target === e.currentTarget) { + setShowEnrollChild(false) + } + }} + > + setShowEnrollChild(false)} + onCancel={() => setShowEnrollChild(false)} + /> +
+ )} + {/* Tab navigation */}