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 */}