feat(classroom): complete reactivity fixes (Steps 7-11)

- Fix parent enrollment creation cache invalidation (Step 7)
- Add usePlayerEnrollmentSocket for student-side enrollment updates (Step 8)
- Update parent approval route to use socket-emitter helper (Step 8)
- Fix enter/leave classroom to use centralized playerKeys (Step 9)
- Add session started/ended socket events for active sessions (Step 11)
- Teacher sees real-time session updates when students in classroom

🤖 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-25 07:54:57 -06:00
parent a0693e9084
commit 2015494c0e
9 changed files with 302 additions and 34 deletions

View File

@@ -1,5 +1,10 @@
import { type NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import { players } from '@/db/schema'
import type { SessionPlan, SlotResult } from '@/db/schema/session-plans'
import { getStudentPresence } from '@/lib/classroom'
import { emitSessionEnded, emitSessionStarted } from '@/lib/classroom/socket-emitter'
import {
abandonSessionPlan,
approveSessionPlan,
@@ -56,7 +61,7 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
* - reason?: string (for 'end_early' action)
*/
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const { planId } = await params
const { playerId, planId } = await params
try {
const body = await request.json()
@@ -71,6 +76,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
case 'start':
plan = await startSessionPlan(planId)
// Emit session started event if student is in a classroom
await emitSessionEventIfPresent(playerId, planId, 'start')
break
case 'record':
@@ -85,10 +92,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
case 'end_early':
plan = await completeSessionPlanEarly(planId, reason)
// Emit session ended event if student is in a classroom
await emitSessionEventIfPresent(playerId, planId, 'end_early')
break
case 'abandon':
plan = await abandonSessionPlan(planId)
// Emit session ended event if student is in a classroom
await emitSessionEventIfPresent(playerId, planId, 'abandon')
break
default:
@@ -106,3 +117,36 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ error: 'Failed to update plan' }, { status: 500 })
}
}
/**
* Helper to emit session socket events if the student is present in a classroom
*/
async function emitSessionEventIfPresent(
playerId: string,
sessionId: string,
action: 'start' | 'end_early' | 'abandon'
): Promise<void> {
try {
// Check if student is in a classroom
const presence = await getStudentPresence(playerId)
if (!presence) return
// Get player name
const player = await db.query.players.findFirst({
where: eq(players.id, playerId),
})
const playerName = player?.name ?? 'Unknown'
const classroomId = presence.classroomId
if (action === 'start') {
await emitSessionStarted({ sessionId, playerId, playerName }, classroomId)
} else {
const reason = action === 'end_early' ? 'ended_early' : 'abandoned'
await emitSessionEnded({ sessionId, playerId, playerName, reason }, classroomId)
}
} catch (error) {
// Don't fail the request if socket emission fails
console.error('[SessionPlan] Failed to emit session event:', error)
}
}

View File

@@ -3,7 +3,10 @@ import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { enrollmentRequests } from '@/db/schema'
import { approveEnrollmentRequest, isParent } from '@/lib/classroom'
import { getSocketIO } from '@/lib/socket-io'
import {
emitEnrollmentCompleted,
emitEnrollmentRequestApproved,
} from '@/lib/classroom/socket-emitter'
import { getViewerId } from '@/lib/viewer'
/**
@@ -55,39 +58,48 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
const result = await approveEnrollmentRequest(requestId, user.id, 'parent')
// Emit socket events for real-time updates to the classroom channel
const io = await getSocketIO()
if (io && result.request.classroomId) {
try {
const classroomId = result.request.classroomId
// Emit socket events for real-time updates
try {
const classroomId = result.request.classroomId
// Always emit that the request was approved (removes from "Awaiting Parent Approval")
io.to(`classroom:${classroomId}`).emit('enrollment-request-approved', {
// Get classroom and player info for socket events
const [classroomInfo] = await db
.select({ name: schema.classrooms.name })
.from(schema.classrooms)
.where(eq(schema.classrooms.id, classroomId))
.limit(1)
const [playerInfo] = await db
.select({ name: schema.players.name })
.from(schema.players)
.where(eq(schema.players.id, result.request.playerId))
.limit(1)
if (classroomInfo && playerInfo) {
const payload = {
requestId,
classroomId,
})
classroomName: classroomInfo.name,
playerId: result.request.playerId,
playerName: playerInfo.name,
}
// If fully enrolled, also emit enrollment-approved to update enrolled students list
if (result.fullyApproved && result.request.playerId) {
// Get player name for the event
const [playerInfo] = await db
.select({ name: schema.players.name })
.from(schema.players)
.where(eq(schema.players.id, result.request.playerId))
.limit(1)
io.to(`classroom:${classroomId}`).emit('enrollment-approved', {
classroomId,
playerId: result.request.playerId,
playerName: playerInfo?.name || 'Unknown',
if (result.fullyApproved) {
// Both sides approved - notify teacher and student
await emitEnrollmentCompleted(payload, {
classroomId, // Teacher sees the update
playerIds: [result.request.playerId], // Student's enrolled classrooms list updates
})
console.log(
`[Parent Approve API] Student ${result.request.playerId} fully enrolled in classroom ${classroomId}`
} else {
// Only parent approved - notify teacher that parent approved their request
await emitEnrollmentRequestApproved(
{ ...payload, approvedBy: 'parent' },
{ classroomId }
)
}
} catch (socketError) {
console.error('[Parent Approve API] Failed to broadcast:', socketError)
}
} catch (socketError) {
console.error('[Parent Approve] Failed to emit socket event:', socketError)
}
return NextResponse.json({

View File

@@ -9,6 +9,7 @@ import {
useEnterClassroom,
useLeaveClassroom,
} from '@/hooks/useClassroom'
import { usePlayerEnrollmentSocket } from '@/hooks/usePlayerEnrollmentSocket'
import { EnrollChildModal } from './EnrollChildModal'
import { css } from '../../../styled-system/css'
@@ -37,6 +38,9 @@ export function EnterClassroomButton({ playerId, playerName }: EnterClassroomBut
useEnrolledClassrooms(playerId)
const { data: currentPresence, isLoading: loadingPresence } = useStudentPresence(playerId)
// Subscribe to real-time enrollment updates
usePlayerEnrollmentSocket(playerId)
// Mutations
const enterClassroom = useEnterClassroom()
const leaveClassroom = useLeaveClassroom()

View File

@@ -3,10 +3,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { Classroom, EnrollmentRequest, Player, User } from '@/db/schema'
import { api } from '@/lib/queryClient'
import { classroomKeys } from '@/lib/queryKeys'
import { classroomKeys, playerKeys } from '@/lib/queryKeys'
// Re-export query keys for consumers
export { classroomKeys } from '@/lib/queryKeys'
export { classroomKeys, playerKeys } from '@/lib/queryKeys'
// ============================================================================
// Types
@@ -283,13 +283,17 @@ export function useCreateEnrollmentRequest() {
return useMutation({
mutationFn: createEnrollmentRequest,
onSuccess: (_, { classroomId }) => {
// Invalidate both pending requests queries
// Invalidate teacher's pending requests queries
queryClient.invalidateQueries({
queryKey: classroomKeys.pendingRequests(classroomId),
})
queryClient.invalidateQueries({
queryKey: classroomKeys.awaitingParentApproval(classroomId),
})
// Invalidate parent's own pending approvals list so they see their new request
queryClient.invalidateQueries({
queryKey: classroomKeys.pendingParentApprovals(),
})
},
})
}
@@ -535,7 +539,7 @@ export function useEnterClassroom() {
})
// Invalidate student's view of their own presence
queryClient.invalidateQueries({
queryKey: ['players', playerId, 'presence'],
queryKey: playerKeys.presence(playerId),
})
}
},
@@ -557,7 +561,7 @@ export function useLeaveClassroom() {
})
// Invalidate student's view of their own presence
queryClient.invalidateQueries({
queryKey: ['players', playerId, 'presence'],
queryKey: playerKeys.presence(playerId),
})
},
})
@@ -656,7 +660,7 @@ async function fetchStudentPresence(playerId: string): Promise<PresenceInfo | nu
*/
export function useEnrolledClassrooms(playerId: string | undefined) {
return useQuery({
queryKey: ['players', playerId, 'enrolled-classrooms'],
queryKey: playerKeys.enrolledClassrooms(playerId!),
queryFn: () => fetchEnrolledClassrooms(playerId!),
enabled: !!playerId,
staleTime: 60 * 1000, // 1 minute
@@ -668,7 +672,7 @@ export function useEnrolledClassrooms(playerId: string | undefined) {
*/
export function useStudentPresence(playerId: string | undefined) {
return useQuery({
queryKey: ['players', playerId, 'presence'],
queryKey: playerKeys.presence(playerId!),
queryFn: () => fetchStudentPresence(playerId!),
enabled: !!playerId,
staleTime: 30 * 1000, // 30 seconds

View File

@@ -9,6 +9,8 @@ import type {
EnrollmentRequestApprovedEvent,
EnrollmentRequestCreatedEvent,
EnrollmentRequestDeniedEvent,
SessionEndedEvent,
SessionStartedEvent,
StudentEnteredEvent,
StudentLeftEvent,
StudentUnenrolledEvent,
@@ -114,6 +116,25 @@ export function useClassroomSocket(classroomId: string | undefined): { connected
})
})
// Listen for session started event
socket.on('session-started', (data: SessionStartedEvent) => {
console.log('[ClassroomSocket] Session started:', data.playerName, 'session:', data.sessionId)
invalidateForEvent(queryClient, 'sessionStarted', { classroomId })
})
// Listen for session ended event
socket.on('session-ended', (data: SessionEndedEvent) => {
console.log(
'[ClassroomSocket] Session ended:',
data.playerName,
'reason:',
data.reason,
'session:',
data.sessionId
)
invalidateForEvent(queryClient, 'sessionEnded', { classroomId })
})
// Cleanup on unmount
return () => {
socket.emit('leave-classroom', { classroomId })

View File

@@ -0,0 +1,69 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import { useQueryClient } from '@tanstack/react-query'
import { invalidateForEvent } from '@/lib/classroom/query-invalidations'
import type { EnrollmentApprovedEvent } from '@/lib/classroom/socket-events'
/**
* Hook for real-time player enrollment updates via WebSocket
*
* When a student becomes fully enrolled in a classroom (both teacher and parent approved),
* this hook receives the event and invalidates the enrolled classrooms query so the
* dropdown updates without requiring a page reload.
*
* @param playerId - The player ID to subscribe to enrollment notifications for
* @returns Whether the socket is connected
*/
export function usePlayerEnrollmentSocket(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('[PlayerEnrollmentSocket] Connected')
setConnected(true)
// Join the player channel for enrollment notifications
socket.emit('join-player', { playerId })
})
socket.on('disconnect', () => {
console.log('[PlayerEnrollmentSocket] Disconnected')
setConnected(false)
})
// Listen for enrollment completed event (student fully enrolled in a classroom)
socket.on('enrollment-approved', (data: EnrollmentApprovedEvent) => {
console.log(
'[PlayerEnrollmentSocket] Enrollment completed for classroom:',
data.classroomName
)
invalidateForEvent(queryClient, 'enrollmentCompleted', {
classroomId: data.classroomId,
playerId,
})
})
// Cleanup on unmount
return () => {
socket.emit('leave-player', { playerId })
socket.disconnect()
socketRef.current = null
}
}, [playerId, queryClient])
return { connected }
}

View File

@@ -30,6 +30,8 @@ export type ClassroomEventType =
| 'studentUnenrolled'
| 'studentEntered'
| 'studentLeft'
| 'sessionStarted'
| 'sessionEnded'
/**
* Parameters for invalidation - each event type may need different params
@@ -169,6 +171,16 @@ export function invalidateForEvent(
}
break
case 'sessionStarted':
case 'sessionEnded':
// Teacher sees updated active sessions list
if (classroomId) {
queryClient.invalidateQueries({
queryKey: classroomKeys.activeSessions(classroomId),
})
}
break
default: {
// Exhaustive check - if we hit this, we're missing a case
const _exhaustive: never = event
@@ -248,6 +260,13 @@ export function getInvalidationKeys(
}
break
case 'sessionStarted':
case 'sessionEnded':
if (classroomId) {
keys.push(classroomKeys.activeSessions(classroomId))
}
break
default: {
const _exhaustive: never = event
console.error('[QueryInvalidations] Unknown event type:', _exhaustive)

View File

@@ -16,6 +16,8 @@ import type {
EnrollmentRequestApprovedEvent,
EnrollmentRequestCreatedEvent,
EnrollmentRequestDeniedEvent,
SessionEndedEvent,
SessionStartedEvent,
StudentUnenrolledEvent,
} from './socket-events'
@@ -201,3 +203,71 @@ function emitToRecipients<T>(
console.error(`[SocketEmitter] Failed to emit ${logLabel}:`, error)
}
}
// ============================================================================
// Session Events
// ============================================================================
/**
* Session event payload
*/
export interface SessionEventPayload {
sessionId: string
playerId: string
playerName: string
}
/**
* Emit a session started event
*
* Use when: A student starts a practice session while present in a classroom.
* This notifies the teacher so they can see the session in their active sessions view.
*/
export async function emitSessionStarted(
payload: SessionEventPayload,
classroomId: string
): Promise<void> {
const io = await getSocketIO()
if (!io) return
const eventData: SessionStartedEvent = {
sessionId: payload.sessionId,
playerId: payload.playerId,
playerName: payload.playerName,
}
try {
io.to(`classroom:${classroomId}`).emit('session-started', eventData)
console.log(`[SocketEmitter] session-started -> classroom:${classroomId}`)
} catch (error) {
console.error('[SocketEmitter] Failed to emit session-started:', error)
}
}
/**
* Emit a session ended event
*
* Use when: A student's practice session ends (completed, ended early, or abandoned)
* while they are present in a classroom.
*/
export async function emitSessionEnded(
payload: SessionEventPayload & { reason: 'completed' | 'ended_early' | 'abandoned' },
classroomId: string
): Promise<void> {
const io = await getSocketIO()
if (!io) return
const eventData: SessionEndedEvent = {
sessionId: payload.sessionId,
playerId: payload.playerId,
playerName: payload.playerName,
reason: payload.reason,
}
try {
io.to(`classroom:${classroomId}`).emit('session-ended', eventData)
console.log(`[SocketEmitter] session-ended (${payload.reason}) -> classroom:${classroomId}`)
} catch (error) {
console.error('[SocketEmitter] Failed to emit session-ended:', error)
}
}

View File

@@ -162,6 +162,27 @@ export interface SessionPausedEvent {
reason: string
}
/**
* Sent when a student starts a practice session while present in a classroom.
* Allows teacher to see session status update in real-time.
*/
export interface SessionStartedEvent {
sessionId: string
playerId: string
playerName: string
}
/**
* Sent when a student's practice session ends (completed or abandoned)
* while present in a classroom.
*/
export interface SessionEndedEvent {
sessionId: string
playerId: string
playerName: string
reason: 'completed' | 'ended_early' | 'abandoned'
}
// ============================================================================
// Client-Side Event Map (for typed socket.io client)
// ============================================================================
@@ -192,6 +213,10 @@ export interface ClassroomServerToClientEvents {
'abacus-control': (data: AbacusControlEvent) => void
'observer-joined': (data: ObserverJoinedEvent) => void
'session-paused': (data: SessionPausedEvent) => void
// Session status events (classroom channel - for teacher's active sessions view)
'session-started': (data: SessionStartedEvent) => void
'session-ended': (data: SessionEndedEvent) => void
}
/**