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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
69
apps/web/src/hooks/usePlayerEnrollmentSocket.ts
Normal file
69
apps/web/src/hooks/usePlayerEnrollmentSocket.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user