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 62eed5e4..da5468b0 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 @@ -48,7 +48,8 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) { ) } - await leaveSpecificClassroom(playerId, classroomId) + // Pass 'teacher' if removed by teacher, 'self' otherwise (parent removing their child) + await leaveSpecificClassroom(playerId, classroomId, isTeacher ? 'teacher' : 'self') return NextResponse.json({ success: true }) } catch (error) { diff --git a/apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts b/apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts index cd66c0d7..1bb4d472 100644 --- a/apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts +++ b/apps/web/src/app/api/enrollment-requests/[requestId]/approve/route.ts @@ -1,10 +1,26 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { approveEnrollmentRequest, isParent } from '@/lib/classroom' -import { db } from '@/db' -import { enrollmentRequests } from '@/db/schema' import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { enrollmentRequests } from '@/db/schema' +import { approveEnrollmentRequest, 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<{ requestId: string }> } @@ -19,6 +35,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) { try { const { requestId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Get the request to verify parent owns the child const request = await db.query.enrollmentRequests.findFirst({ @@ -30,12 +47,12 @@ export async function POST(req: NextRequest, { params }: RouteParams) { } // Verify user is a parent of the child in the request - const parentCheck = await isParent(viewerId, request.playerId) + const parentCheck = await isParent(user.id, request.playerId) if (!parentCheck) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } - const result = await approveEnrollmentRequest(requestId, viewerId, 'parent') + const result = await approveEnrollmentRequest(requestId, user.id, 'parent') return NextResponse.json({ request: result.request, diff --git a/apps/web/src/app/api/enrollment-requests/[requestId]/deny/route.ts b/apps/web/src/app/api/enrollment-requests/[requestId]/deny/route.ts index b9a6578d..f0fa199c 100644 --- a/apps/web/src/app/api/enrollment-requests/[requestId]/deny/route.ts +++ b/apps/web/src/app/api/enrollment-requests/[requestId]/deny/route.ts @@ -1,10 +1,26 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { denyEnrollmentRequest, isParent } from '@/lib/classroom' -import { db } from '@/db' -import { enrollmentRequests } from '@/db/schema' import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { enrollmentRequests } from '@/db/schema' +import { denyEnrollmentRequest, 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<{ requestId: string }> } @@ -19,6 +35,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) { try { const { requestId } = await params const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Get the request to verify parent owns the child const request = await db.query.enrollmentRequests.findFirst({ @@ -30,12 +47,12 @@ export async function POST(req: NextRequest, { params }: RouteParams) { } // Verify user is a parent of the child in the request - const parentCheck = await isParent(viewerId, request.playerId) + const parentCheck = await isParent(user.id, request.playerId) if (!parentCheck) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } - const updatedRequest = await denyEnrollmentRequest(requestId, viewerId, 'parent') + const updatedRequest = await denyEnrollmentRequest(requestId, user.id, 'parent') return NextResponse.json({ request: updatedRequest }) } catch (error) { diff --git a/apps/web/src/app/api/enrollment-requests/pending/route.ts b/apps/web/src/app/api/enrollment-requests/pending/route.ts index 269b9e79..60d102a0 100644 --- a/apps/web/src/app/api/enrollment-requests/pending/route.ts +++ b/apps/web/src/app/api/enrollment-requests/pending/route.ts @@ -1,18 +1,40 @@ +import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { db, schema } from '@/db' import { getPendingRequestsForParent } 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 +} + /** * GET /api/enrollment-requests/pending * Get enrollment requests pending current user's approval as parent * - * Returns: { requests: EnrollmentRequest[] } + * These are requests initiated by teachers for the user's children, + * where parent approval hasn't been given yet. + * + * Returns: { requests: EnrollmentRequestWithRelations[] } */ export async function GET() { try { const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) - const requests = await getPendingRequestsForParent(viewerId) + const requests = await getPendingRequestsForParent(user.id) return NextResponse.json({ requests }) } catch (error) { diff --git a/apps/web/src/app/api/players/[id]/enrolled-classrooms/route.ts b/apps/web/src/app/api/players/[id]/enrolled-classrooms/route.ts new file mode 100644 index 00000000..959547b8 --- /dev/null +++ b/apps/web/src/app/api/players/[id]/enrolled-classrooms/route.ts @@ -0,0 +1,33 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getEnrolledClassrooms, canPerformAction } from '@/lib/classroom' +import { getDbUserId } from '@/lib/viewer' + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * GET /api/players/[id]/enrolled-classrooms + * Get classrooms that this student is enrolled in + * + * Returns: { classrooms: Classroom[] } + */ +export async function GET(req: NextRequest, { params }: RouteParams) { + try { + const { id: playerId } = await params + const userId = await getDbUserId() + + // Check authorization: must have at least view access + const canView = await canPerformAction(userId, playerId, 'view') + if (!canView) { + return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) + } + + const classrooms = await getEnrolledClassrooms(playerId) + + return NextResponse.json({ classrooms }) + } catch (error) { + console.error('Failed to fetch enrolled classrooms:', error) + return NextResponse.json({ error: 'Failed to fetch enrolled classrooms' }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/players/[id]/presence/route.ts b/apps/web/src/app/api/players/[id]/presence/route.ts index c2f4d3ca..c7e5db8f 100644 --- a/apps/web/src/app/api/players/[id]/presence/route.ts +++ b/apps/web/src/app/api/players/[id]/presence/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { getStudentPresence, canPerformAction } from '@/lib/classroom' -import { getViewerId } from '@/lib/viewer' +import { getDbUserId } from '@/lib/viewer' interface RouteParams { params: Promise<{ id: string }> @@ -15,10 +15,10 @@ interface RouteParams { export async function GET(req: NextRequest, { params }: RouteParams) { try { const { id: playerId } = await params - const viewerId = await getViewerId() + const userId = await getDbUserId() // Check authorization: must have at least view access - const canView = await canPerformAction(viewerId, playerId, 'view') + const canView = await canPerformAction(userId, playerId, 'view') if (!canView) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } diff --git a/apps/web/src/app/practice/PracticeClient.tsx b/apps/web/src/app/practice/PracticeClient.tsx index 65151efb..3a695aff 100644 --- a/apps/web/src/app/practice/PracticeClient.tsx +++ b/apps/web/src/app/practice/PracticeClient.tsx @@ -3,7 +3,12 @@ import { useRouter } from 'next/navigation' import { useCallback, useMemo, useState } from 'react' import { Z_INDEX } from '@/constants/zIndex' -import { ClassroomDashboard, CreateClassroomForm, EnrollChildFlow } from '@/components/classroom' +import { + ClassroomDashboard, + CreateClassroomForm, + EnrollChildFlow, + PendingApprovalsSection, +} from '@/components/classroom' import { PageWithNav } from '@/components/PageWithNav' import { StudentFilterBar } from '@/components/practice/StudentFilterBar' import { StudentSelector, type StudentWithProgress } from '@/components/practice' @@ -133,7 +138,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) { return next }) } else { - router.push(`/practice/${student.id}/resume`, { scroll: false }) + router.push(`/practice/${student.id}/dashboard`, { scroll: false }) } }, [router, editMode] @@ -394,6 +399,9 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) { )} + {/* Pending Enrollment Approvals - for parents to approve teacher-initiated requests */} + + {/* Needs Attention Section - uses same bucket styling as other sections */} {studentsNeedingAttention.length > 0 && (
diff --git a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx b/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx deleted file mode 100644 index dbd8a422..00000000 --- a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx +++ /dev/null @@ -1,1021 +0,0 @@ -'use client' - -import { useQueryClient } from '@tanstack/react-query' -import { useRouter } from 'next/navigation' -import { useCallback, useMemo, useState } from 'react' -import { PageWithNav } from '@/components/PageWithNav' -import { PracticeSubNav } from '@/components/practice' -import { useTheme } from '@/contexts/ThemeContext' -import type { Player } from '@/db/schema/players' -import type { SessionPlan } from '@/db/schema/session-plans' -import { DEFAULT_PLAN_CONFIG } from '@/db/schema/session-plans' -import { - ActiveSessionExistsClientError, - sessionPlanKeys, - useApproveSessionPlan, - useGenerateSessionPlan, - useStartSessionPlan, -} from '@/hooks/useSessionPlan' -import { css } from '../../../../../styled-system/css' - -// Plan configuration constants (from DEFAULT_PLAN_CONFIG) -const PART_TIME_WEIGHTS = DEFAULT_PLAN_CONFIG.partTimeWeights -const PURPOSE_WEIGHTS = { - focus: DEFAULT_PLAN_CONFIG.focusWeight, - reinforce: DEFAULT_PLAN_CONFIG.reinforceWeight, - review: DEFAULT_PLAN_CONFIG.reviewWeight, - // Note: Challenge slots are calculated as remainder after focus/reinforce/review - // and use CHALLENGE_RATIO_BY_PART_TYPE in the actual session planner -} - -interface ConfigureClientProps { - studentId: string - player: Player - /** If there's an existing draft plan, pass it here */ - existingPlan?: SessionPlan | null - /** Current phase focus description from curriculum */ - focusDescription: string - /** Average seconds per problem based on student's history */ - avgSecondsPerProblem: number - /** List of mastered skill IDs that will be used in problem generation */ - masteredSkillIds: string[] -} - -/** - * Session structure part types with their display info - */ -const PART_INFO = [ - { - type: 'abacus' as const, - emoji: '🧮', - label: 'Use Abacus', - description: 'Practice with the physical abacus', - }, - { - type: 'visualization' as const, - emoji: '🧠', - label: 'Visualization', - description: 'Mental math by picturing beads', - }, - { - type: 'linear' as const, - emoji: '💭', - label: 'Mental Math', - description: 'Solve problems in your head', - }, -] - -/** - * Get part type colors (dark mode aware) - */ -function getPartTypeColors( - type: 'abacus' | 'visualization' | 'linear', - isDark: boolean -): { bg: string; border: string; text: string } { - switch (type) { - case 'abacus': - return isDark - ? { bg: 'blue.900', border: 'blue.700', text: 'blue.200' } - : { bg: 'blue.50', border: 'blue.200', text: 'blue.700' } - case 'visualization': - return isDark - ? { bg: 'purple.900', border: 'purple.700', text: 'purple.200' } - : { bg: 'purple.50', border: 'purple.200', text: 'purple.700' } - case 'linear': - return isDark - ? { bg: 'orange.900', border: 'orange.700', text: 'orange.200' } - : { bg: 'orange.50', border: 'orange.200', text: 'orange.700' } - } -} - -/** - * Skill category display names - */ -const SKILL_CATEGORY_NAMES: Record = { - basic: 'Basic', - fiveComplements: '5-Complements', - fiveComplementsSub: '5-Complements (Sub)', - tenComplements: '10-Complements', - tenComplementsSub: '10-Complements (Sub)', - advanced: 'Advanced', -} - -/** - * Group and format skill IDs for display - */ -function groupSkillsByCategory(skillIds: string[]): Map { - const grouped = new Map() - - for (const skillId of skillIds) { - const [category, ...rest] = skillId.split('.') - const skillName = rest.join('.') - - if (!grouped.has(category)) { - grouped.set(category, []) - } - grouped.get(category)!.push(skillName) - } - - return grouped -} - -/** - * Enabled parts configuration - */ -type EnabledParts = { - abacus: boolean - visualization: boolean - linear: boolean -} - -/** - * Calculate estimated session breakdown based on duration and enabled parts - */ -function calculateEstimates( - durationMinutes: number, - avgSecondsPerProblem: number, - enabledParts: EnabledParts -) { - // Filter to only enabled parts and recalculate weights - const enabledPartTypes = (['abacus', 'visualization', 'linear'] as const).filter( - (type) => enabledParts[type] - ) - - // If no parts enabled, return zeros - if (enabledPartTypes.length === 0) { - return { - totalProblems: 0, - parts: [ - { - type: 'abacus' as const, - weight: 0, - minutes: 0, - problems: 0, - enabled: false, - }, - { - type: 'visualization' as const, - weight: 0, - minutes: 0, - problems: 0, - enabled: false, - }, - { - type: 'linear' as const, - weight: 0, - minutes: 0, - problems: 0, - enabled: false, - }, - ], - purposes: { focus: 0, reinforce: 0, review: 0, challenge: 0 }, - } - } - - // Calculate total weight of enabled parts - const totalEnabledWeight = enabledPartTypes.reduce( - (sum, type) => sum + PART_TIME_WEIGHTS[type], - 0 - ) - - const totalProblems = Math.max(3, Math.floor((durationMinutes * 60) / avgSecondsPerProblem)) - - // Calculate problems per part based on normalized weights - const parts = (['abacus', 'visualization', 'linear'] as const).map((type) => { - const enabled = enabledParts[type] - if (!enabled) { - return { type, weight: 0, minutes: 0, problems: 0, enabled: false } - } - const normalizedWeight = PART_TIME_WEIGHTS[type] / totalEnabledWeight - return { - type, - weight: normalizedWeight, - minutes: Math.round(durationMinutes * normalizedWeight), - problems: Math.max(1, Math.round(totalProblems * normalizedWeight)), - enabled: true, - } - }) - - // Recalculate actual total problems from enabled parts - const actualTotalProblems = parts.reduce((sum, p) => sum + p.problems, 0) - - // Calculate purpose breakdown - const focusCount = Math.round(actualTotalProblems * PURPOSE_WEIGHTS.focus) - const reinforceCount = Math.round(actualTotalProblems * PURPOSE_WEIGHTS.reinforce) - const reviewCount = Math.round(actualTotalProblems * PURPOSE_WEIGHTS.review) - const challengeCount = Math.max( - 0, - actualTotalProblems - focusCount - reinforceCount - reviewCount - ) - - return { - totalProblems: actualTotalProblems, - parts, - purposes: { - focus: focusCount, - reinforce: reinforceCount, - review: reviewCount, - challenge: challengeCount, - }, - } -} - -/** - * Unified session configuration and preview component - * - * Features: - * - Duration selector that updates preview in real-time - * - Live preview showing estimated problems, session structure, problem breakdown - * - Single "Let's Go!" button that generates + approves + starts the session - * - Handles existing draft plans gracefully - */ -export function ConfigureClient({ - studentId, - player, - existingPlan, - focusDescription, - avgSecondsPerProblem, - masteredSkillIds, -}: ConfigureClientProps) { - const router = useRouter() - const queryClient = useQueryClient() - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - - // Duration state - use existing plan's duration if available - const [durationMinutes, setDurationMinutes] = useState(existingPlan?.targetDurationMinutes ?? 10) - - // Term count state - max terms per problem for abacus part - const [abacusMaxTerms, setAbacusMaxTerms] = useState(DEFAULT_PLAN_CONFIG.abacusTermCount.max) - - // Enabled parts state - which session parts to include - const [enabledParts, setEnabledParts] = useState({ - abacus: true, - visualization: true, - linear: true, - }) - - // Toggle a part on/off - const togglePart = useCallback((partType: keyof EnabledParts) => { - setEnabledParts((prev) => { - // Don't allow disabling the last enabled part - const enabledCount = Object.values(prev).filter(Boolean).length - if (enabledCount === 1 && prev[partType]) { - return prev - } - return { ...prev, [partType]: !prev[partType] } - }) - }, []) - - // Calculate visualization max terms (75% of abacus) - const visualizationMaxTerms = Math.max(2, Math.round(abacusMaxTerms * 0.75)) - - // Calculate live estimates based on current duration and enabled parts - const estimates = useMemo( - () => calculateEstimates(durationMinutes, avgSecondsPerProblem, enabledParts), - [durationMinutes, avgSecondsPerProblem, enabledParts] - ) - - const generatePlan = useGenerateSessionPlan() - const approvePlan = useApproveSessionPlan() - const startPlan = useStartSessionPlan() - - // Combined loading state - const isStarting = generatePlan.isPending || approvePlan.isPending || startPlan.isPending - - // Error state - const error = - (generatePlan.error && !(generatePlan.error instanceof ActiveSessionExistsClientError)) || - approvePlan.error || - startPlan.error - ? { - message: 'Unable to start session', - suggestion: 'This may be a temporary issue. Try again or refresh the page.', - } - : null - - /** - * Handle "Let's Go!" click - generates, approves, and starts the session in one flow - */ - const handleStart = useCallback(async () => { - generatePlan.reset() - approvePlan.reset() - startPlan.reset() - - try { - let plan: SessionPlan - - // If we have an existing draft plan with the same duration, use it - if (existingPlan && existingPlan.targetDurationMinutes === durationMinutes) { - plan = existingPlan - } else { - // Generate a new plan - try { - plan = await generatePlan.mutateAsync({ - playerId: studentId, - durationMinutes, - abacusTermCount: { min: 3, max: abacusMaxTerms }, - enabledParts, - }) - } catch (err) { - if (err instanceof ActiveSessionExistsClientError) { - // Use the existing plan - plan = err.existingPlan - queryClient.setQueryData(sessionPlanKeys.active(studentId), plan) - } else { - throw err - } - } - } - - // Approve the plan - await approvePlan.mutateAsync({ - playerId: studentId, - planId: plan.id, - }) - - // Start the plan - await startPlan.mutateAsync({ - playerId: studentId, - planId: plan.id, - }) - - // Redirect to practice page (shows first problem) - router.push(`/practice/${studentId}`, { scroll: false }) - } catch { - // Errors will show in the error state - } - }, [ - studentId, - durationMinutes, - abacusMaxTerms, - enabledParts, - existingPlan, - generatePlan, - approvePlan, - startPlan, - queryClient, - router, - ]) - - return ( - - {/* Practice Sub-Navigation */} - - -
-
- {/* Header */} -
-

- Configure Session -

-

- Focus: {focusDescription} -

-
- - {/* Main Card - Duration + Preview */} -
- {/* Duration Selector */} -
- -
- {[5, 10, 15, 20, 30, 45].map((mins) => ( - - ))} -
-
- - {/* Terms per Problem Selector */} -
- -
- {[3, 4, 5, 6, 7, 8].map((terms) => ( - - ))} -
-
- 🧮 Abacus: up to {abacusMaxTerms} numbers  •  🧠 Visualize: up to{' '} - {visualizationMaxTerms} numbers (75%) -
-
- - {/* Live Preview - Summary */} -
- {/* Problem Count - centered prominently */} -
-
- {estimates.totalProblems} - - problems - -
-
- - {/* Three-Part Structure */} -
-

- Session Structure{' '} - - (tap to toggle) - -

-
- {PART_INFO.map((partInfo, index) => { - const partEstimate = estimates.parts[index] - const isEnabled = enabledParts[partInfo.type] - const colors = getPartTypeColors(partInfo.type, isDark) - return ( - - ) - })} -
-
- - {/* Problem Type Breakdown */} -
-

- Problem Mix -

-
- {/* Focus */} -
-
- {estimates.purposes.focus} -
-
- Focus -
-
- - {/* Reinforce */} -
-
- {estimates.purposes.reinforce} -
-
- Reinforce -
-
- - {/* Review */} -
-
- {estimates.purposes.review} -
-
- Review -
-
- - {/* Challenge */} -
-
- {estimates.purposes.challenge} -
-
- Challenge -
-
-
-
- - {/* Mastered Skills Summary (collapsible) */} -
- - Mastered Skills ({masteredSkillIds.length}) - - -
- {masteredSkillIds.length === 0 ? ( -

- No skills marked as mastered yet. Go to Dashboard to set skills. -

- ) : ( -
- {Array.from(groupSkillsByCategory(masteredSkillIds)).map( - ([category, skills]) => ( -
-
- {SKILL_CATEGORY_NAMES[category] || category} -
-
- {skills.map((skill) => ( - - {skill} - - ))} -
-
- ) - )} -
- )} -
-
-
-
- - {/* Error display */} - {error && ( -
-
- ⚠️ -
-
- {error.message} -
-
- {error.suggestion} -
-
-
-
- )} - - {/* Action Buttons */} -
- -
-
-
-
- ) -} diff --git a/apps/web/src/app/practice/[studentId]/configure/page.tsx b/apps/web/src/app/practice/[studentId]/configure/page.tsx deleted file mode 100644 index 3165e346..00000000 --- a/apps/web/src/app/practice/[studentId]/configure/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { redirect } from 'next/navigation' - -// Disable caching - session data should be fresh -export const dynamic = 'force-dynamic' - -interface ConfigurePageProps { - params: Promise<{ studentId: string }> -} - -/** - * Configure Practice Session Page - DEPRECATED - * - * This page now redirects to the dashboard. The session configuration - * modal is accessible from the dashboard via the "Start Practice" button. - * - * URL: /practice/[studentId]/configure → redirects to /practice/[studentId]/dashboard - */ -export default async function ConfigurePage({ params }: ConfigurePageProps) { - const { studentId } = await params - - // Redirect to dashboard - the StartPracticeModal is now accessible from there - redirect(`/practice/${studentId}/dashboard`) -} diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx index d12ddb23..af005709 100644 --- a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx +++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useState } from 'react' +import { EnterClassroomButton } from '@/components/classroom' import { PageWithNav } from '@/components/PageWithNav' import { type ActiveSessionState, @@ -30,6 +31,7 @@ import type { PlayerSkillMastery } from '@/db/schema/player-skill-mastery' import type { Player } from '@/db/schema/players' import type { PracticeSession } from '@/db/schema/practice-sessions' import type { SessionPlan } from '@/db/schema/session-plans' +import { usePlayerPresenceSocket } from '@/hooks/usePlayerPresenceSocket' import { useSessionMode } from '@/hooks/useSessionMode' import type { SessionMode } from '@/lib/curriculum/session-mode' import { useRefreshSkillRecency, useSetMasteredSkills } from '@/hooks/usePlayerCurriculum' @@ -2522,6 +2524,10 @@ export function DashboardClient({ // Session mode - single source of truth for session planning decisions const { data: sessionMode, isLoading: isLoadingSessionMode } = useSessionMode(studentId) + // Subscribe to player presence updates via WebSocket + // This ensures the UI updates when teacher removes student from classroom + usePlayerPresenceSocket(studentId) + // Tab state - sync with URL const [activeTab, setActiveTab] = useState(initialTab) @@ -2687,6 +2693,17 @@ export function DashboardClient({ })} >
+ {/* Classroom presence - allows entering enrolled classrooms for live practice */} +
+ +
+ {/* Session mode banner - renders in-flow, projects to nav on scroll */} { - router.push(`/practice/${studentId}`, { scroll: false }) - }, [studentId, router]) - - // Handle starting fresh - abandon current session and go to configure - const handleStartFresh = useCallback(() => { - abandonSession.mutate( - { playerId: studentId, planId: session.id }, - { - onSuccess: () => { - router.push(`/practice/${studentId}/configure`, { scroll: false }) - }, - } - ) - }, [studentId, session.id, abandonSession, router]) - - return ( - - {/* Practice Sub-Navigation */} - - -
-
- {/* Header */} -
-

- Welcome Back! -

-

- Continue where you left off -

-
- - {/* Continue Session Card */} - -
-
-
- ) -} diff --git a/apps/web/src/app/practice/[studentId]/resume/page.tsx b/apps/web/src/app/practice/[studentId]/resume/page.tsx deleted file mode 100644 index 5e570a0a..00000000 --- a/apps/web/src/app/practice/[studentId]/resume/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { redirect } from 'next/navigation' - -// Disable caching for this page -export const dynamic = 'force-dynamic' - -interface ResumePageProps { - params: Promise<{ studentId: string }> -} - -/** - * Resume Session Page - DEPRECATED - * - * This page now redirects to the main practice page. The "welcome back" - * experience is now handled by the SessionPausedModal which shows automatically - * when returning to an in-progress session. - * - * URL: /practice/[studentId]/resume → redirects to /practice/[studentId] - */ -export default async function ResumePage({ params }: ResumePageProps) { - const { studentId } = await params - - // The main practice page now handles the "welcome back" modal - redirect(`/practice/${studentId}`) -} diff --git a/apps/web/src/components/classroom/ClassroomDashboard.tsx b/apps/web/src/components/classroom/ClassroomDashboard.tsx index 6238c565..90a8befb 100644 --- a/apps/web/src/components/classroom/ClassroomDashboard.tsx +++ b/apps/web/src/components/classroom/ClassroomDashboard.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import type { Classroom, Player } from '@/db/schema' import { useTheme } from '@/contexts/ThemeContext' +import { useClassroomSocket } from '@/hooks/useClassroomSocket' import { css } from '../../../styled-system/css' import { ClassroomCodeShare } from './ClassroomCodeShare' import { ClassroomTab } from './ClassroomTab' @@ -32,6 +33,10 @@ export function ClassroomDashboard({ classroom, ownChildren = [] }: ClassroomDas const [activeTab, setActiveTab] = useState('classroom') const [showEnrollChild, setShowEnrollChild] = useState(false) + // Subscribe to real-time classroom presence updates via WebSocket + // This is at the dashboard level so events are received even when on other tabs + useClassroomSocket(classroom.id) + const tabs: { id: TabId; label: string; icon: string }[] = [ { id: 'classroom', label: 'Classroom', icon: '🏫' }, { id: 'students', label: 'Student Manager', icon: '👥' }, diff --git a/apps/web/src/components/classroom/ClassroomTab.tsx b/apps/web/src/components/classroom/ClassroomTab.tsx index 07cbdbcf..b8a8f50f 100644 --- a/apps/web/src/components/classroom/ClassroomTab.tsx +++ b/apps/web/src/components/classroom/ClassroomTab.tsx @@ -1,7 +1,10 @@ 'use client' +import { useCallback } from 'react' +import Link from 'next/link' import type { Classroom } from '@/db/schema' import { useTheme } from '@/contexts/ThemeContext' +import { useClassroomPresence, useLeaveClassroom, type PresenceStudent } from '@/hooks/useClassroom' import { css } from '../../../styled-system/css' import { ClassroomCodeShare } from './ClassroomCodeShare' @@ -13,13 +16,28 @@ interface ClassroomTabProps { * ClassroomTab - Shows live classroom view * * Displays students currently "present" in the classroom. - * For Phase 3, this is an empty state. - * Phase 6 will add presence functionality. + * Teachers can see who's actively practicing and remove students when needed. */ export function ClassroomTab({ classroom }: ClassroomTabProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' + // Fetch present students + // Note: WebSocket subscription is in ClassroomDashboard (parent) so it stays + // connected even when user switches tabs + const { data: presentStudents = [], isLoading } = useClassroomPresence(classroom.id) + const leaveClassroom = useLeaveClassroom() + + const handleRemoveStudent = useCallback( + (playerId: string) => { + leaveClassroom.mutate({ + classroomId: classroom.id, + playerId, + }) + }, + [classroom.id, leaveClassroom] + ) + return (
- {/* Empty state */} -
+ {/* Present students section */} + {isLoading ? (
- 🏫 + Loading classroom...
-

- No Students Present -

-

- When students join your classroom for practice, they'll appear here. Share your classroom - code to get started. -

+ ) : presentStudents.length > 0 ? ( +
+

+ + Students Present + + {presentStudents.length} + +

- -
+
+ {presentStudents.map((student) => ( + handleRemoveStudent(student.id)} + isRemoving={ + leaveClassroom.isPending && leaveClassroom.variables?.playerId === student.id + } + isDark={isDark} + /> + ))} +
+ + ) : ( + /* Empty state */ +
+
+ 🏫 +
+

+ No Students Present +

+

+ When students join your classroom for practice, they'll appear here. Share your + classroom code to get started. +

+ + +
+ )} {/* Instructions */}
  • Share your classroom code with parents
  • Parents enroll their child using the code
  • -
  • Students appear here when they start practicing
  • +
  • When practicing, students can "enter" the classroom
  • +
  • You'll see them appear here in real-time
  • ) } + +// ============================================================================ +// Sub-components +// ============================================================================ + +interface PresentStudentCardProps { + student: PresenceStudent + onRemove: () => void + isRemoving: boolean + isDark: boolean +} + +function PresentStudentCard({ student, onRemove, isRemoving, isDark }: PresentStudentCardProps) { + const enteredAt = new Date(student.enteredAt) + const timeAgo = getTimeAgo(enteredAt) + + return ( +
    + +
    + + {student.emoji} + + {/* Online indicator */} + +
    +
    +

    + {student.name} +

    +

    + Joined {timeAgo} +

    +
    + + + +
    + ) +} + +/** + * Format a date as a relative time string (e.g., "2 minutes ago") + */ +function getTimeAgo(date: Date): string { + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMinutes = Math.floor(diffMs / (1000 * 60)) + + if (diffMinutes < 1) return 'just now' + if (diffMinutes === 1) return '1 minute ago' + if (diffMinutes < 60) return `${diffMinutes} minutes ago` + + const diffHours = Math.floor(diffMinutes / 60) + if (diffHours === 1) return '1 hour ago' + if (diffHours < 24) return `${diffHours} hours ago` + + return 'over a day ago' +} diff --git a/apps/web/src/components/classroom/EnterClassroomButton.tsx b/apps/web/src/components/classroom/EnterClassroomButton.tsx new file mode 100644 index 00000000..c18de787 --- /dev/null +++ b/apps/web/src/components/classroom/EnterClassroomButton.tsx @@ -0,0 +1,382 @@ +'use client' + +import { useCallback, useState } from 'react' +import type { Classroom } from '@/db/schema' +import { useTheme } from '@/contexts/ThemeContext' +import { + useEnrolledClassrooms, + useStudentPresence, + useEnterClassroom, + useLeaveClassroom, +} from '@/hooks/useClassroom' +import { css } from '../../../styled-system/css' + +interface EnterClassroomButtonProps { + playerId: string + playerName: string +} + +/** + * EnterClassroomButton - Allows students to enter/leave enrolled classrooms + * + * Shows: + * - Current classroom presence (if in one) + * - List of enrolled classrooms to enter + * - Enter/leave actions + */ +export function EnterClassroomButton({ playerId, playerName }: EnterClassroomButtonProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + const [isOpen, setIsOpen] = useState(false) + + // Fetch enrolled classrooms and current presence + const { data: enrolledClassrooms = [], isLoading: loadingClassrooms } = + useEnrolledClassrooms(playerId) + const { data: currentPresence, isLoading: loadingPresence } = useStudentPresence(playerId) + + // Mutations + const enterClassroom = useEnterClassroom() + const leaveClassroom = useLeaveClassroom() + + const handleEnter = useCallback( + (classroomId: string) => { + enterClassroom.mutate( + { classroomId, playerId }, + { + onSuccess: () => { + setIsOpen(false) + }, + } + ) + }, + [enterClassroom, playerId] + ) + + const handleLeave = useCallback(() => { + if (!currentPresence) return + leaveClassroom.mutate({ + classroomId: currentPresence.classroomId, + playerId, + }) + }, [currentPresence, leaveClassroom, playerId]) + + // Don't show while loading or if not enrolled in any classrooms + if (loadingClassrooms || enrolledClassrooms.length === 0) { + return null + } + + const isInClassroom = !!currentPresence + const currentClassroom = currentPresence?.classroom + + return ( +
    + {/* Main button */} + + + {/* Dropdown menu */} + {isOpen && ( + <> + {/* Backdrop */} +
    setIsOpen(false)} + /> + + {/* Menu */} +
    +
    +

    + {playerName}'s Classrooms +

    +
    + + {loadingClassrooms || loadingPresence ? ( +
    + Loading... +
    + ) : ( +
    + {/* Current presence */} + {currentPresence && currentClassroom && ( +
    +
    +
    + + + Currently in + +
    + +
    +

    + {currentClassroom.name} +

    +
    + )} + + {/* Enrolled classrooms list */} + {enrolledClassrooms + .filter((c) => c.id !== currentPresence?.classroomId) + .map((classroom) => ( + handleEnter(classroom.id)} + isEntering={ + enterClassroom.isPending && + enterClassroom.variables?.classroomId === classroom.id + } + isDisabled={!!currentPresence} + isDark={isDark} + /> + ))} + + {enrolledClassrooms.length === 0 && ( +
    + Not enrolled in any classrooms +
    + )} +
    + )} +
    + + )} +
    + ) +} + +interface ClassroomMenuItemProps { + classroom: Classroom + onEnter: () => void + isEntering: boolean + isDisabled: boolean + isDark: boolean +} + +function ClassroomMenuItem({ + classroom, + onEnter, + isEntering, + isDisabled, + isDark, +}: ClassroomMenuItemProps) { + return ( +
    +
    +

    + {classroom.name} +

    +
    + + +
    + ) +} diff --git a/apps/web/src/components/classroom/PendingApprovalsSection.tsx b/apps/web/src/components/classroom/PendingApprovalsSection.tsx new file mode 100644 index 00000000..2ea4fa7a --- /dev/null +++ b/apps/web/src/components/classroom/PendingApprovalsSection.tsx @@ -0,0 +1,255 @@ +'use client' + +import { useCallback } from 'react' +import { useTheme } from '@/contexts/ThemeContext' +import { + usePendingApprovalsForParent, + useApproveEnrollmentRequestAsParent, + useDenyEnrollmentRequestAsParent, + type EnrollmentRequestWithRelations, +} from '@/hooks/useClassroom' +import { css } from '../../../styled-system/css' + +/** + * PendingApprovalsSection - Shows pending enrollment requests for parents + * + * When a teacher initiates enrollment for a parent's child, the parent + * sees it here and can approve or deny. + */ +export function PendingApprovalsSection() { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + const { data: requests = [], isLoading } = usePendingApprovalsForParent() + const approveRequest = useApproveEnrollmentRequestAsParent() + const denyRequest = useDenyEnrollmentRequestAsParent() + + const handleApprove = useCallback( + (requestId: string) => { + approveRequest.mutate(requestId) + }, + [approveRequest] + ) + + const handleDeny = useCallback( + (requestId: string) => { + denyRequest.mutate(requestId) + }, + [denyRequest] + ) + + // Don't render if no pending requests + if (isLoading || requests.length === 0) { + return null + } + + return ( +
    +

    + 📬 + Enrollment Requests + + {requests.length} + +

    + +

    + A teacher has requested to enroll your child in their classroom. +

    + +
    + {requests.map((request) => ( + handleApprove(request.id)} + onDeny={() => handleDeny(request.id)} + isApproving={approveRequest.isPending && approveRequest.variables === request.id} + isDenying={denyRequest.isPending && denyRequest.variables === request.id} + isDark={isDark} + /> + ))} +
    +
    + ) +} + +interface PendingApprovalCardProps { + request: EnrollmentRequestWithRelations + onApprove: () => void + onDeny: () => void + isApproving: boolean + isDenying: boolean + isDark: boolean +} + +function PendingApprovalCard({ + request, + onApprove, + onDeny, + isApproving, + isDenying, + isDark, +}: PendingApprovalCardProps) { + const player = request.player + const classroom = request.classroom + + return ( +
    +
    + + {player?.emoji ?? '?'} + +
    +

    + {player?.name ?? 'Unknown Student'} +

    +

    + Classroom: {classroom?.name ?? 'Unknown'} +

    +
    +
    + +
    + + +
    +
    + ) +} diff --git a/apps/web/src/components/classroom/StudentManagerTab.tsx b/apps/web/src/components/classroom/StudentManagerTab.tsx index 8a02f04a..efde9522 100644 --- a/apps/web/src/components/classroom/StudentManagerTab.tsx +++ b/apps/web/src/components/classroom/StudentManagerTab.tsx @@ -402,7 +402,7 @@ function EnrolledStudentCard({ })} > { + const res = await api('enrollment-requests/pending') + if (!res.ok) throw new Error('Failed to fetch pending approvals') + const data = await res.json() + return data.requests +} + +/** + * Approve enrollment request as parent + */ +async function approveRequestAsParent( + requestId: string +): Promise<{ request: EnrollmentRequest; enrolled: boolean }> { + const res = await api(`enrollment-requests/${requestId}/approve`, { method: 'POST' }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Failed to approve request') + } + return res.json() +} + +/** + * Deny enrollment request as parent + */ +async function denyRequestAsParent(requestId: string): Promise { + const res = await api(`enrollment-requests/${requestId}/deny`, { method: 'POST' }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Failed to deny request') + } + const data = await res.json() + return data.request +} + +// ============================================================================ +// Parent Enrollment Approval Hooks +// ============================================================================ + +/** + * Get pending enrollment requests for current user as parent + * + * These are requests initiated by teachers for the user's children, + * where parent approval hasn't been given yet. + */ +export function usePendingApprovalsForParent() { + return useQuery({ + queryKey: classroomKeys.pendingParentApprovals(), + queryFn: fetchPendingApprovalsForParent, + staleTime: 30 * 1000, // 30 seconds + }) +} + +/** + * Approve enrollment request as parent + */ +export function useApproveEnrollmentRequestAsParent() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: approveRequestAsParent, + onSuccess: (result) => { + // Invalidate pending parent approvals + queryClient.invalidateQueries({ + queryKey: classroomKeys.pendingParentApprovals(), + }) + // If fully approved, classroom enrollments will be updated too + // (but we don't know the classroomId from this response, so broader invalidation) + if (result.enrolled) { + queryClient.invalidateQueries({ + queryKey: ['classrooms'], + }) + } + }, + }) +} + +/** + * Deny enrollment request as parent + */ +export function useDenyEnrollmentRequestAsParent() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: denyRequestAsParent, + onSuccess: () => { + // Invalidate pending parent approvals + queryClient.invalidateQueries({ + queryKey: classroomKeys.pendingParentApprovals(), + }) + }, + }) +} + +// ============================================================================ +// Classroom Presence API Functions +// ============================================================================ + +export interface PresenceStudent extends Player { + enteredAt: string + enteredBy: string +} + +/** + * Fetch students currently present in a classroom + */ +async function fetchClassroomPresence(classroomId: string): Promise { + const res = await api(`classrooms/${classroomId}/presence`) + if (!res.ok) throw new Error('Failed to fetch classroom presence') + const data = await res.json() + return data.students +} + +/** + * Enter a student into a classroom + */ +async function enterClassroom(params: { + classroomId: string + playerId: string +}): Promise<{ success: boolean; error?: string }> { + const res = await api(`classrooms/${params.classroomId}/presence`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playerId: params.playerId }), + }) + const data = await res.json() + if (!res.ok) { + return { success: false, error: data.error || 'Failed to enter classroom' } + } + return { success: true } +} + +/** + * Remove a student from a classroom + */ +async function leaveClassroom(params: { classroomId: string; playerId: string }): Promise { + const res = await api(`classrooms/${params.classroomId}/presence/${params.playerId}`, { + method: 'DELETE', + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Failed to leave classroom') + } +} + +// ============================================================================ +// Classroom Presence Hooks +// ============================================================================ + +/** + * Get students currently present in a classroom + */ +export function useClassroomPresence(classroomId: string | undefined) { + return useQuery({ + queryKey: classroomKeys.presence(classroomId ?? ''), + queryFn: () => fetchClassroomPresence(classroomId!), + enabled: !!classroomId, + staleTime: 15 * 1000, // 15 seconds - presence changes frequently + refetchInterval: 30 * 1000, // Poll every 30 seconds for real-time feel + }) +} + +/** + * Enter a student into a classroom + */ +export function useEnterClassroom() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: enterClassroom, + onSuccess: (result, { classroomId, playerId }) => { + if (result.success) { + // Invalidate teacher's view of classroom presence + queryClient.invalidateQueries({ + queryKey: classroomKeys.presence(classroomId), + }) + // Invalidate student's view of their own presence + queryClient.invalidateQueries({ + queryKey: ['players', playerId, 'presence'], + }) + } + }, + }) +} + +/** + * Remove a student from a classroom + */ +export function useLeaveClassroom() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: leaveClassroom, + onSuccess: (_, { classroomId, playerId }) => { + // Invalidate teacher's view of classroom presence + queryClient.invalidateQueries({ + queryKey: classroomKeys.presence(classroomId), + }) + // Invalidate student's view of their own presence + queryClient.invalidateQueries({ + queryKey: ['players', playerId, 'presence'], + }) + }, + }) +} + +// ============================================================================ +// Student Enrollment API Functions +// ============================================================================ + +export interface PresenceInfo { + playerId: string + classroomId: string + enteredAt: string + enteredBy: string + classroom?: Classroom +} + +/** + * Fetch classrooms a student is enrolled in + */ +async function fetchEnrolledClassrooms(playerId: string): Promise { + const res = await api(`players/${playerId}/enrolled-classrooms`) + if (!res.ok) throw new Error('Failed to fetch enrolled classrooms') + const data = await res.json() + return data.classrooms +} + +/** + * Fetch student's current classroom presence + */ +async function fetchStudentPresence(playerId: string): Promise { + const res = await api(`players/${playerId}/presence`) + if (!res.ok) throw new Error('Failed to fetch student presence') + const data = await res.json() + return data.presence +} + +// ============================================================================ +// Student Enrollment Hooks +// ============================================================================ + +/** + * Get classrooms a student is enrolled in + */ +export function useEnrolledClassrooms(playerId: string | undefined) { + return useQuery({ + queryKey: ['players', playerId, 'enrolled-classrooms'], + queryFn: () => fetchEnrolledClassrooms(playerId!), + enabled: !!playerId, + staleTime: 60 * 1000, // 1 minute + }) +} + +/** + * Get student's current classroom presence + */ +export function useStudentPresence(playerId: string | undefined) { + return useQuery({ + queryKey: ['players', playerId, 'presence'], + queryFn: () => fetchStudentPresence(playerId!), + enabled: !!playerId, + staleTime: 30 * 1000, // 30 seconds + }) +} diff --git a/apps/web/src/hooks/useClassroomSocket.ts b/apps/web/src/hooks/useClassroomSocket.ts new file mode 100644 index 00000000..d0841f91 --- /dev/null +++ b/apps/web/src/hooks/useClassroomSocket.ts @@ -0,0 +1,74 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { io, type Socket } from 'socket.io-client' +import { useQueryClient } from '@tanstack/react-query' +import { classroomKeys } from '@/lib/queryKeys' +import type { StudentEnteredEvent, StudentLeftEvent } from '@/lib/classroom/socket-events' + +/** + * Hook for real-time classroom presence updates via WebSocket + * + * When a student enters or leaves the classroom, this hook receives the event + * and automatically invalidates the React Query cache so the UI updates. + * + * @param classroomId - The classroom to subscribe to + * @returns Whether the socket is connected + */ +export function useClassroomSocket(classroomId: string | undefined): { connected: boolean } { + const [connected, setConnected] = useState(false) + const socketRef = useRef(null) + const queryClient = useQueryClient() + + useEffect(() => { + if (!classroomId) return + + // Create socket connection + const socket = io({ + path: '/api/socket', + reconnection: true, + reconnectionDelay: 1000, + reconnectionAttempts: 5, + }) + socketRef.current = socket + + socket.on('connect', () => { + console.log('[ClassroomSocket] Connected') + setConnected(true) + // Join the classroom channel + socket.emit('join-classroom', { classroomId }) + }) + + socket.on('disconnect', () => { + console.log('[ClassroomSocket] Disconnected') + setConnected(false) + }) + + // Listen for student entered event + socket.on('student-entered', (data: StudentEnteredEvent) => { + console.log('[ClassroomSocket] Student entered:', data.playerName) + // Invalidate the presence query to refetch + queryClient.invalidateQueries({ + queryKey: classroomKeys.presence(classroomId), + }) + }) + + // Listen for student left event + socket.on('student-left', (data: StudentLeftEvent) => { + console.log('[ClassroomSocket] Student left:', data.playerName) + // Invalidate the presence query to refetch + queryClient.invalidateQueries({ + queryKey: classroomKeys.presence(classroomId), + }) + }) + + // Cleanup on unmount + return () => { + socket.emit('leave-classroom', { classroomId }) + socket.disconnect() + socketRef.current = null + } + }, [classroomId, queryClient]) + + return { connected } +} diff --git a/apps/web/src/hooks/usePlayerPresenceSocket.ts b/apps/web/src/hooks/usePlayerPresenceSocket.ts new file mode 100644 index 00000000..d9128ec3 --- /dev/null +++ b/apps/web/src/hooks/usePlayerPresenceSocket.ts @@ -0,0 +1,65 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { io, type Socket } from 'socket.io-client' +import { useQueryClient } from '@tanstack/react-query' +import type { PresenceRemovedEvent } from '@/lib/classroom/socket-events' + +/** + * Hook for real-time player presence updates via WebSocket + * + * When a student is removed from a classroom (by teacher or parent), + * this hook receives the event and invalidates the React Query cache + * so the UI updates without requiring a page reload. + * + * @param playerId - The player to subscribe to + * @returns Whether the socket is connected + */ +export function usePlayerPresenceSocket(playerId: string | undefined): { connected: boolean } { + const [connected, setConnected] = useState(false) + const socketRef = useRef(null) + const queryClient = useQueryClient() + + useEffect(() => { + if (!playerId) return + + // Create socket connection + const socket = io({ + path: '/api/socket', + reconnection: true, + reconnectionDelay: 1000, + reconnectionAttempts: 5, + }) + socketRef.current = socket + + socket.on('connect', () => { + console.log('[PlayerPresenceSocket] Connected') + setConnected(true) + // Join the player channel + socket.emit('join-player', { playerId }) + }) + + socket.on('disconnect', () => { + console.log('[PlayerPresenceSocket] Disconnected') + setConnected(false) + }) + + // Listen for presence removed event (when teacher or parent removes student from classroom) + socket.on('presence-removed', (data: PresenceRemovedEvent) => { + console.log('[PlayerPresenceSocket] Presence removed:', data) + // Invalidate the student's presence query to refetch + queryClient.invalidateQueries({ + queryKey: ['players', playerId, 'presence'], + }) + }) + + // Cleanup on unmount + return () => { + socket.emit('leave-player', { playerId }) + socket.disconnect() + socketRef.current = null + } + }, [playerId, queryClient]) + + return { connected } +} diff --git a/apps/web/src/lib/classroom/presence-manager.ts b/apps/web/src/lib/classroom/presence-manager.ts index c141c00f..a08ed75d 100644 --- a/apps/web/src/lib/classroom/presence-manager.ts +++ b/apps/web/src/lib/classroom/presence-manager.ts @@ -19,10 +19,12 @@ import { classroomEnrollments, classroomPresence, classrooms, + players, type ClassroomPresence, type Classroom, type Player, } from '@/db/schema' +import { getSocketIO } from '@/lib/socket-io' // ============================================================================ // Enter/Leave Classroom @@ -97,6 +99,20 @@ export async function enterClassroom(params: EnterClassroomParams): Promise { + // Get current presence before deleting (need classroomId for socket event) + const presence = await db.query.classroomPresence.findFirst({ + where: eq(classroomPresence.playerId, playerId), + }) + + if (!presence) return + await db.delete(classroomPresence).where(eq(classroomPresence.playerId, playerId)) + + // Emit socket event for real-time updates to teacher + const io = await getSocketIO() + if (io) { + const player = await db.query.players.findFirst({ + where: eq(players.id, playerId), + }) + io.to(`classroom:${presence.classroomId}`).emit('student-left', { + playerId, + playerName: player?.name ?? 'Unknown', + }) + } } /** * Remove a student from a specific classroom (if they're in it) + * + * @param removedBy - Who initiated the removal: 'teacher' or 'self' */ -export async function leaveSpecificClassroom(playerId: string, classroomId: string): Promise { - await db +export async function leaveSpecificClassroom( + playerId: string, + classroomId: string, + removedBy: 'teacher' | 'self' = 'self' +): Promise { + const deleted = await db .delete(classroomPresence) .where( and(eq(classroomPresence.playerId, playerId), eq(classroomPresence.classroomId, classroomId)) ) + .returning() + + // Only emit if something was actually deleted + if (deleted.length > 0) { + const io = await getSocketIO() + if (io) { + const player = await db.query.players.findFirst({ + where: eq(players.id, playerId), + }) + // Notify the classroom (for teacher's view) + io.to(`classroom:${classroomId}`).emit('student-left', { + playerId, + playerName: player?.name ?? 'Unknown', + }) + // Notify the player (for student's view) + io.to(`player:${playerId}`).emit('presence-removed', { + classroomId, + removedBy, + }) + } + } } /** diff --git a/apps/web/src/lib/classroom/socket-events.ts b/apps/web/src/lib/classroom/socket-events.ts index 188f5a21..dc1136e2 100644 --- a/apps/web/src/lib/classroom/socket-events.ts +++ b/apps/web/src/lib/classroom/socket-events.ts @@ -51,6 +51,15 @@ export interface StudentLeftEvent { playerName: string } +// ============================================================================ +// Player Presence Events (sent to player:${playerId} channel) +// ============================================================================ + +export interface PresenceRemovedEvent { + classroomId: string + removedBy: 'teacher' | 'self' +} + // ============================================================================ // Session Observation Events (sent to session:${sessionId} channel) // ============================================================================ @@ -108,10 +117,13 @@ export interface ClassroomServerToClientEvents { 'enrollment-approved': (data: EnrollmentApprovedEvent) => void 'enrollment-denied': (data: EnrollmentDeniedEvent) => void - // Presence events + // Presence events (classroom channel) 'student-entered': (data: StudentEnteredEvent) => void 'student-left': (data: StudentLeftEvent) => void + // Player presence events (player channel) + 'presence-removed': (data: PresenceRemovedEvent) => void + // Session observation events 'practice-state': (data: PracticeStateEvent) => void 'tutorial-state': (data: TutorialStateEvent) => void @@ -128,6 +140,8 @@ export interface ClassroomClientToServerEvents { // Channel subscriptions 'join-classroom': (data: { classroomId: string }) => void 'leave-classroom': (data: { classroomId: string }) => void + 'join-player': (data: { playerId: string }) => void + 'leave-player': (data: { playerId: string }) => void 'observe-session': (data: { sessionId: string; observerId: string }) => void 'stop-observing': (data: { sessionId: string }) => void diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts index 1a30eb89..2bc788cb 100644 --- a/apps/web/src/lib/queryKeys.ts +++ b/apps/web/src/lib/queryKeys.ts @@ -43,4 +43,5 @@ export const classroomKeys = { detail: (id: string) => [...classroomKeys.all, 'detail', id] as const, enrollments: (id: string) => [...classroomKeys.all, 'enrollments', id] as const, presence: (id: string) => [...classroomKeys.all, 'presence', id] as const, + pendingParentApprovals: () => [...classroomKeys.all, 'pendingParentApprovals'] as const, } diff --git a/apps/web/src/lib/viewer.ts b/apps/web/src/lib/viewer.ts index 218b4637..3c6fd4a7 100644 --- a/apps/web/src/lib/viewer.ts +++ b/apps/web/src/lib/viewer.ts @@ -1,5 +1,7 @@ +import { eq } from 'drizzle-orm' import { cookies, headers } from 'next/headers' import { auth } from '@/auth' +import { db, schema } from '@/db' import { GUEST_COOKIE_NAME, verifyGuestToken } from './guest-token' /** @@ -65,3 +67,89 @@ export async function getViewerId(): Promise { throw new Error('No valid viewer session found') } } + +/** + * Get or create a user record from a guestId + * + * This is the core function for converting a guest session identifier + * into a database user record. If no user exists for the guestId, + * one is created automatically. + * + * @param guestId - The guest session identifier + * @returns The user record from the database + */ +async function getOrCreateUserFromGuestId(guestId: string) { + let user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, guestId), + }) + + if (!user) { + const [newUser] = await db.insert(schema.users).values({ guestId }).returning() + user = newUser + } + + return user +} + +/** + * Get the database user.id for the current viewer + * + * IMPORTANT: This returns the actual database user.id, NOT the guestId. + * Use this when you need to pass a user ID to authorization functions + * like canPerformAction(), or any function that expects a database user.id. + * + * For authenticated users: returns session.user.id directly + * For guests: looks up or creates the user record by guestId, returns user.id + * For unknown: throws an error + * + * @throws Error if no valid viewer found + */ +export async function getDbUserId(): Promise { + const viewer = await getViewer() + + switch (viewer.kind) { + case 'user': + // Authenticated users already have a database user.id in their session + return viewer.session.user!.id + case 'guest': { + // Guests need to look up their user record by guestId + const user = await getOrCreateUserFromGuestId(viewer.guestId) + return user.id + } + case 'unknown': + throw new Error('No valid viewer session found') + } +} + +/** + * Get the full user record for the current viewer + * + * This returns the complete database user record, useful when you need + * more than just the user.id (e.g., for checking user properties). + * + * For authenticated users: looks up user by session.user.id + * For guests: looks up or creates user by guestId + * For unknown: throws an error + * + * @throws Error if no valid viewer found + */ +export async function getViewerUser() { + const viewer = await getViewer() + + switch (viewer.kind) { + case 'user': { + const user = await db.query.users.findFirst({ + where: eq(schema.users.id, viewer.session.user!.id), + }) + if (!user) { + throw new Error('Authenticated user not found in database') + } + return user + } + case 'guest': { + return getOrCreateUserFromGuestId(viewer.guestId) + } + case 'unknown': + throw new Error('No valid viewer session found') + } +} diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts index e6cbf502..d1227aa0 100644 --- a/apps/web/src/socket-server.ts +++ b/apps/web/src/socket-server.ts @@ -740,6 +740,26 @@ export function initializeSocketServer(httpServer: HTTPServer) { } }) + // Player: Join player channel (for students to receive their own presence updates) + socket.on('join-player', async ({ playerId }: { playerId: string }) => { + try { + await socket.join(`player:${playerId}`) + console.log(`👤 User joined player channel: ${playerId}`) + } catch (error) { + console.error('Error joining player channel:', error) + } + }) + + // Player: Leave player channel + socket.on('leave-player', async ({ playerId }: { playerId: string }) => { + try { + await socket.leave(`player:${playerId}`) + console.log(`👤 User left player channel: ${playerId}`) + } catch (error) { + console.error('Error leaving player channel:', error) + } + }) + // Session Observation: Start observing a practice session socket.on( 'observe-session',