feat(classroom): implement real-time presence with WebSocket (Phase 6)

- Add classroom presence management (enter/leave classroom)
- Implement WebSocket events for real-time presence updates
- Add useClassroomSocket hook for teachers to receive student enter/leave events
- Add usePlayerPresenceSocket hook for students to receive removal notifications
- Create EnterClassroomButton component for students to join classroom
- Add PendingApprovalsSection for parents to approve enrollment requests
- Fix React Query cache invalidation for enter/leave mutations
- Move socket subscription to ClassroomDashboard for cross-tab persistence
- Add getDbUserId() helper to fix viewerId vs user.id bug pattern
- Delete legacy configure/resume pages (consolidated into dashboard)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-22 22:14:24 -06:00
parent 1952a412ed
commit 629bfcfc03
27 changed files with 1654 additions and 1253 deletions

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 })
}
}

View File

@@ -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 })
}

View File

@@ -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) {
)}
</header>
{/* Pending Enrollment Approvals - for parents to approve teacher-initiated requests */}
<PendingApprovalsSection />
{/* Needs Attention Section - uses same bucket styling as other sections */}
{studentsNeedingAttention.length > 0 && (
<div data-bucket="attention" data-component="needs-attention-bucket">

View File

@@ -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`)
}

View File

@@ -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<TabId>(initialTab)
@@ -2687,6 +2693,17 @@ export function DashboardClient({
})}
>
<div className={css({ maxWidth: '900px', margin: '0 auto' })}>
{/* Classroom presence - allows entering enrolled classrooms for live practice */}
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
marginBottom: '0.75rem',
})}
>
<EnterClassroomButton playerId={studentId} playerName={player.name} />
</div>
{/* Session mode banner - renders in-flow, projects to nav on scroll */}
<ContentBannerSlot
stickyOffset={STICKY_HEADER_OFFSET}

View File

@@ -17,7 +17,7 @@ interface StudentPracticePageProps {
*
* Guards/Redirects:
* - No active session → /dashboard (show progress, start new session)
* - Draft/approved session (not started) → /configure (approve and start)
* - Draft/approved session (not started) → /dashboard (modal handles configuration)
* - In_progress session → SHOW PROBLEM (this is the only state we render here)
* - Completed session → /summary (show results)
*
@@ -42,9 +42,9 @@ export default async function StudentPracticePage({ params }: StudentPracticePag
redirect(`/practice/${studentId}/dashboard`)
}
// Draft or approved but not started → configure page
// Draft or approved but not started → dashboard (modal handles configuration)
if (!activeSession.startedAt) {
redirect(`/practice/${studentId}/configure`)
redirect(`/practice/${studentId}/dashboard`)
}
// Session is completed → summary page

View File

@@ -1,116 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { ContinueSessionCard, 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 { useAbandonSession, useActiveSessionPlan } from '@/hooks/useSessionPlan'
import { css } from '../../../../../styled-system/css'
interface ResumeClientProps {
studentId: string
player: Player
initialSession: SessionPlan
}
/**
* Client component for the Resume page
*
* Shows the "Welcome back" card for students returning to an in-progress session.
* Uses React Query to get the most up-to-date session data (from cache if available,
* otherwise uses server-provided initial data).
*/
export function ResumeClient({ studentId, player, initialSession }: ResumeClientProps) {
const router = useRouter()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const abandonSession = useAbandonSession()
// Use React Query to get fresh session data
// If there's cached data from in-progress session, use that; otherwise use server props
const { data: fetchedSession } = useActiveSessionPlan(studentId, initialSession)
const session = fetchedSession ?? initialSession
// Handle continuing the session - navigate to main practice page
const handleContinue = useCallback(() => {
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 (
<PageWithNav>
{/* Practice Sub-Navigation */}
<PracticeSubNav student={player} pageContext="resume" />
<main
data-component="resume-practice-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
paddingTop: '2rem',
paddingLeft: '2rem',
paddingRight: '2rem',
paddingBottom: '2rem',
})}
>
<div
className={css({
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header */}
<header
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<h1
className={css({
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '0.25rem',
})}
>
Welcome Back!
</h1>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Continue where you left off
</p>
</header>
{/* Continue Session Card */}
<ContinueSessionCard
studentName={player.name}
studentEmoji={player.emoji}
studentColor={player.color}
session={session}
onContinue={handleContinue}
onStartFresh={handleStartFresh}
/>
</div>
</main>
</PageWithNav>
)
}

View File

@@ -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}`)
}

View File

@@ -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<TabId>('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: '👥' },

View File

@@ -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 (
<div
data-component="classroom-tab"
@@ -29,51 +47,123 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
gap: '24px',
})}
>
{/* Empty state */}
<div
className={css({
textAlign: 'center',
padding: '48px 24px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
borderRadius: '16px',
border: '2px dashed',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Present students section */}
{isLoading ? (
<div
className={css({
fontSize: '3rem',
marginBottom: '16px',
textAlign: 'center',
padding: '24px',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
🏫
Loading classroom...
</div>
<h3
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
No Students Present
</h3>
<p
className={css({
fontSize: '0.9375rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '24px',
maxWidth: '400px',
marginLeft: 'auto',
marginRight: 'auto',
})}
>
When students join your classroom for practice, they'll appear here. Share your classroom
code to get started.
</p>
) : presentStudents.length > 0 ? (
<section data-section="present-students">
<h3
className={css({
fontSize: '1rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '10px',
})}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: 'green.500',
animation: 'pulse 2s infinite',
})}
/>
<span>Students Present</span>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '24px',
height: '24px',
padding: '0 8px',
borderRadius: '12px',
backgroundColor: isDark ? 'green.700' : 'green.500',
color: 'white',
fontSize: '0.8125rem',
fontWeight: 'bold',
})}
>
{presentStudents.length}
</span>
</h3>
<ClassroomCodeShare code={classroom.code} />
</div>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
{presentStudents.map((student) => (
<PresentStudentCard
key={student.id}
student={student}
onRemove={() => handleRemoveStudent(student.id)}
isRemoving={
leaveClassroom.isPending && leaveClassroom.variables?.playerId === student.id
}
isDark={isDark}
/>
))}
</div>
</section>
) : (
/* Empty state */
<div
className={css({
textAlign: 'center',
padding: '48px 24px',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
borderRadius: '16px',
border: '2px dashed',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div
className={css({
fontSize: '3rem',
marginBottom: '16px',
})}
>
🏫
</div>
<h3
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
No Students Present
</h3>
<p
className={css({
fontSize: '0.9375rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '24px',
maxWidth: '400px',
marginLeft: 'auto',
marginRight: 'auto',
})}
>
When students join your classroom for practice, they'll appear here. Share your
classroom code to get started.
</p>
<ClassroomCodeShare code={classroom.code} />
</div>
)}
{/* Instructions */}
<div
@@ -108,9 +198,149 @@ export function ClassroomTab({ classroom }: ClassroomTabProps) {
>
<li>Share your classroom code with parents</li>
<li>Parents enroll their child using the code</li>
<li>Students appear here when they start practicing</li>
<li>When practicing, students can "enter" the classroom</li>
<li>You'll see them appear here in real-time</li>
</ol>
</div>
</div>
)
}
// ============================================================================
// 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 (
<div
data-element="present-student-card"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 16px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'green.800' : 'green.200',
boxShadow: isDark ? 'none' : '0 1px 3px rgba(0,0,0,0.05)',
})}
>
<Link
href={`/practice/${student.id}/dashboard`}
className={css({
display: 'flex',
alignItems: 'center',
gap: '14px',
textDecoration: 'none',
flex: 1,
_hover: { opacity: 0.8 },
})}
>
<div className={css({ position: 'relative' })}>
<span
className={css({
width: '44px',
height: '44px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.375rem',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</span>
{/* Online indicator */}
<span
className={css({
position: 'absolute',
bottom: '0',
right: '0',
width: '14px',
height: '14px',
borderRadius: '50%',
backgroundColor: 'green.500',
border: '2px solid',
borderColor: isDark ? 'gray.800' : 'white',
})}
/>
</div>
<div>
<p
className={css({
fontWeight: 'medium',
color: isDark ? 'white' : 'gray.800',
})}
>
{student.name}
</p>
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Joined {timeAgo}
</p>
</div>
</Link>
<button
type="button"
onClick={onRemove}
disabled={isRemoving}
data-action="remove-from-classroom"
className={css({
padding: '8px 14px',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.500',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.300',
borderRadius: '6px',
fontSize: '0.8125rem',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderColor: isDark ? 'gray.600' : 'gray.400',
color: isDark ? 'gray.300' : 'gray.700',
},
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isRemoving ? 'Removing...' : 'Remove'}
</button>
</div>
)
}
/**
* 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'
}

View File

@@ -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 (
<div data-component="enter-classroom-button" className={css({ position: 'relative' })}>
{/* Main button */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
data-action="toggle-classroom-menu"
className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 16px',
backgroundColor: isInClassroom
? isDark
? 'green.900/30'
: 'green.50'
: isDark
? 'gray.700'
: 'gray.100',
color: isInClassroom
? isDark
? 'green.400'
: 'green.700'
: isDark
? 'gray.300'
: 'gray.700',
border: '1px solid',
borderColor: isInClassroom
? isDark
? 'green.700'
: 'green.300'
: isDark
? 'gray.600'
: 'gray.300',
borderRadius: '10px',
fontSize: '0.9375rem',
fontWeight: 'medium',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isInClassroom
? isDark
? 'green.900/50'
: 'green.100'
: isDark
? 'gray.600'
: 'gray.200',
},
})}
>
{isInClassroom ? (
<>
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'green.500',
})}
/>
<span>In {currentClassroom?.name || 'Classroom'}</span>
</>
) : (
<>
<span>🏫</span>
<span>Enter Classroom</span>
</>
)}
</button>
{/* Dropdown menu */}
{isOpen && (
<>
{/* Backdrop */}
<div
className={css({
position: 'fixed',
inset: 0,
zIndex: 999,
})}
onClick={() => setIsOpen(false)}
/>
{/* Menu */}
<div
data-element="classroom-menu"
className={css({
position: 'absolute',
top: '100%',
right: 0,
marginTop: '8px',
minWidth: '280px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)',
zIndex: 1000,
overflow: 'hidden',
})}
>
<div
className={css({
padding: '12px 16px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<p
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
})}
>
{playerName}&apos;s Classrooms
</p>
</div>
{loadingClassrooms || loadingPresence ? (
<div
className={css({
padding: '16px',
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
fontSize: '0.875rem',
})}
>
Loading...
</div>
) : (
<div className={css({ maxHeight: '300px', overflowY: 'auto' })}>
{/* Current presence */}
{currentPresence && currentClassroom && (
<div
className={css({
padding: '12px 16px',
backgroundColor: isDark ? 'green.900/20' : 'green.50',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'green.500',
})}
/>
<span
className={css({
fontSize: '0.8125rem',
fontWeight: 'medium',
color: isDark ? 'green.400' : 'green.700',
})}
>
Currently in
</span>
</div>
<button
type="button"
onClick={handleLeave}
disabled={leaveClassroom.isPending}
data-action="leave-classroom"
className={css({
padding: '4px 10px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.75rem',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{leaveClassroom.isPending ? 'Leaving...' : 'Leave'}
</button>
</div>
<p
className={css({
fontWeight: 'medium',
color: isDark ? 'white' : 'gray.800',
})}
>
{currentClassroom.name}
</p>
</div>
)}
{/* Enrolled classrooms list */}
{enrolledClassrooms
.filter((c) => c.id !== currentPresence?.classroomId)
.map((classroom) => (
<ClassroomMenuItem
key={classroom.id}
classroom={classroom}
onEnter={() => handleEnter(classroom.id)}
isEntering={
enterClassroom.isPending &&
enterClassroom.variables?.classroomId === classroom.id
}
isDisabled={!!currentPresence}
isDark={isDark}
/>
))}
{enrolledClassrooms.length === 0 && (
<div
className={css({
padding: '16px',
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
fontSize: '0.875rem',
})}
>
Not enrolled in any classrooms
</div>
)}
</div>
)}
</div>
</>
)}
</div>
)
}
interface ClassroomMenuItemProps {
classroom: Classroom
onEnter: () => void
isEntering: boolean
isDisabled: boolean
isDark: boolean
}
function ClassroomMenuItem({
classroom,
onEnter,
isEntering,
isDisabled,
isDark,
}: ClassroomMenuItemProps) {
return (
<div
data-element="classroom-menu-item"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.100',
_last: { borderBottom: 'none' },
})}
>
<div>
<p
className={css({
fontWeight: 'medium',
color: isDark ? 'white' : 'gray.800',
fontSize: '0.9375rem',
})}
>
{classroom.name}
</p>
</div>
<button
type="button"
onClick={onEnter}
disabled={isEntering || isDisabled}
data-action="enter-classroom"
className={css({
padding: '6px 14px',
backgroundColor: isDisabled
? isDark
? 'gray.700'
: 'gray.200'
: isDark
? 'green.700'
: 'green.500',
color: isDisabled ? (isDark ? 'gray.500' : 'gray.400') : 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor:
isDisabled || isEntering ? undefined : isDark ? 'green.600' : 'green.600',
},
_disabled: { opacity: isDisabled ? 0.5 : 1, cursor: 'not-allowed' },
})}
>
{isEntering ? 'Entering...' : isDisabled ? 'Leave first' : 'Enter'}
</button>
</div>
)
}

View File

@@ -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 (
<section
data-component="pending-approvals-section"
className={css({
padding: '20px',
backgroundColor: isDark ? 'yellow.900/20' : 'yellow.50',
borderRadius: '16px',
border: '2px solid',
borderColor: isDark ? 'yellow.700' : 'yellow.200',
marginBottom: '24px',
})}
>
<h2
className={css({
fontSize: '1.125rem',
fontWeight: 'bold',
color: isDark ? 'yellow.300' : 'yellow.700',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '10px',
})}
>
<span>📬</span>
<span>Enrollment Requests</span>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '24px',
height: '24px',
padding: '0 8px',
borderRadius: '12px',
backgroundColor: isDark ? 'yellow.700' : 'yellow.500',
color: 'white',
fontSize: '0.8125rem',
fontWeight: 'bold',
})}
>
{requests.length}
</span>
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'yellow.400' : 'yellow.700',
marginBottom: '16px',
})}
>
A teacher has requested to enroll your child in their classroom.
</p>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
{requests.map((request) => (
<PendingApprovalCard
key={request.id}
request={request}
onApprove={() => handleApprove(request.id)}
onDeny={() => handleDeny(request.id)}
isApproving={approveRequest.isPending && approveRequest.variables === request.id}
isDenying={denyRequest.isPending && denyRequest.variables === request.id}
isDark={isDark}
/>
))}
</div>
</section>
)
}
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 (
<div
data-element="pending-approval-card"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '16px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
'@media (min-width: 640px)': {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '14px' })}>
<span
className={css({
width: '48px',
height: '48px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
flexShrink: 0,
})}
style={{ backgroundColor: player?.color ?? '#ccc' }}
>
{player?.emoji ?? '?'}
</span>
<div>
<p
className={css({
fontWeight: 'bold',
fontSize: '1rem',
color: isDark ? 'white' : 'gray.800',
})}
>
{player?.name ?? 'Unknown Student'}
</p>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Classroom: <strong>{classroom?.name ?? 'Unknown'}</strong>
</p>
</div>
</div>
<div
className={css({
display: 'flex',
gap: '10px',
'@media (max-width: 639px)': {
width: '100%',
},
})}
>
<button
type="button"
onClick={onDeny}
disabled={isDenying || isApproving}
data-action="deny-enrollment-parent"
className={css({
flex: 1,
padding: '10px 18px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
border: 'none',
borderRadius: '8px',
fontSize: '0.9375rem',
fontWeight: 'medium',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
'@media (min-width: 640px)': {
flex: 'none',
},
})}
>
{isDenying ? 'Denying...' : 'Deny'}
</button>
<button
type="button"
onClick={onApprove}
disabled={isApproving || isDenying}
data-action="approve-enrollment-parent"
className={css({
flex: 1,
padding: '10px 18px',
backgroundColor: isDark ? 'green.700' : 'green.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '0.9375rem',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: { backgroundColor: isDark ? 'green.600' : 'green.600' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
'@media (min-width: 640px)': {
flex: 'none',
},
})}
>
{isApproving ? 'Approving...' : 'Approve'}
</button>
</div>
</div>
)
}

View File

@@ -402,7 +402,7 @@ function EnrolledStudentCard({
})}
>
<Link
href={`/practice/${student.id}/resume`}
href={`/practice/${student.id}/dashboard`}
className={css({
display: 'flex',
alignItems: 'center',

View File

@@ -3,4 +3,6 @@ export { ClassroomDashboard } from './ClassroomDashboard'
export { ClassroomTab } from './ClassroomTab'
export { CreateClassroomForm } from './CreateClassroomForm'
export { EnrollChildFlow } from './EnrollChildFlow'
export { EnterClassroomButton } from './EnterClassroomButton'
export { PendingApprovalsSection } from './PendingApprovalsSection'
export { StudentManagerTab } from './StudentManagerTab'

View File

@@ -323,3 +323,275 @@ export function useUnenrollStudent() {
},
})
}
// ============================================================================
// Parent Enrollment Approval API Functions
// ============================================================================
/**
* Fetch pending enrollment requests for current user as parent
*/
async function fetchPendingApprovalsForParent(): Promise<EnrollmentRequestWithRelations[]> {
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<EnrollmentRequest> {
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<PresenceStudent[]> {
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<void> {
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<Classroom[]> {
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<PresenceInfo | null> {
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
})
}

View File

@@ -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<Socket | null>(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 }
}

View File

@@ -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<Socket | null>(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 }
}

View File

@@ -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<Ente
})
.returning()
// Emit socket event for real-time updates to teacher
const io = await getSocketIO()
if (io) {
// Get player name for the event
const player = await db.query.players.findFirst({
where: eq(players.id, playerId),
})
io.to(`classroom:${classroomId}`).emit('student-entered', {
playerId,
playerName: player?.name ?? 'Unknown',
enteredBy,
})
}
return { success: true, presence: inserted }
}
@@ -104,18 +120,64 @@ export async function enterClassroom(params: EnterClassroomParams): Promise<Ente
* Remove a student from their current classroom
*/
export async function leaveClassroom(playerId: string): Promise<void> {
// 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<void> {
await db
export async function leaveSpecificClassroom(
playerId: string,
classroomId: string,
removedBy: 'teacher' | 'self' = 'self'
): Promise<void> {
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,
})
}
}
}
/**

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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<string> {
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<string> {
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')
}
}

View File

@@ -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',