feat(classroom): add real-time enrollment/unenrollment reactivity
- Add studentUnenrolled event to socket system for real-time updates - Emit socket events when teacher or parent unenrolls a student - Handle enrollment-approved events in usePlayerPresenceSocket - Add EnrollChildModal with reactive success state and auto-close - Detects when teacher approves while modal is open - Shows countdown progress bar in Done button - Smooth fade-out transition on close 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
77336bea5b
commit
a0693e9084
|
|
@ -1,7 +1,8 @@
|
|||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { unenrollStudent, getTeacherClassroom, isParent } from '@/lib/classroom'
|
||||
import { getLinkedParentIds, getTeacherClassroom, isParent, unenrollStudent } from '@/lib/classroom'
|
||||
import { emitStudentUnenrolled } from '@/lib/classroom/socket-emitter'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
|
|
@ -50,6 +51,44 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) {
|
|||
|
||||
await unenrollStudent(classroomId, playerId)
|
||||
|
||||
// Emit socket event for real-time updates
|
||||
try {
|
||||
// Get classroom and player info for socket event
|
||||
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, playerId))
|
||||
.limit(1)
|
||||
|
||||
if (classroomInfo && playerInfo) {
|
||||
// Get parent IDs to notify
|
||||
const parentIds = await getLinkedParentIds(playerId)
|
||||
|
||||
await emitStudentUnenrolled(
|
||||
{
|
||||
classroomId,
|
||||
classroomName: classroomInfo.name,
|
||||
playerId,
|
||||
playerName: playerInfo.name,
|
||||
unenrolledBy: isTeacher ? 'teacher' : 'parent',
|
||||
},
|
||||
{
|
||||
classroomId, // Teacher sees student removed
|
||||
userIds: parentIds, // Parents see child is no longer enrolled
|
||||
playerIds: [playerId], // Student sees they're no longer in classroom
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (socketError) {
|
||||
console.error('[Unenroll] Failed to emit socket event:', socketError)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to unenroll student:', error)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,502 @@
|
|||
'use client'
|
||||
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import {
|
||||
useClassroomByCode,
|
||||
useCreateEnrollmentRequest,
|
||||
useEnrolledClassrooms,
|
||||
} from '@/hooks/useClassroom'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
interface EnrollChildModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
playerId: string
|
||||
playerName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for enrolling a specific child in a classroom
|
||||
*
|
||||
* Flow:
|
||||
* 1. Enter classroom code
|
||||
* 2. See classroom info (name, teacher)
|
||||
* 3. Submit enrollment request
|
||||
* 4. Success message
|
||||
*
|
||||
* Unlike EnrollChildFlow, this modal already knows which child
|
||||
* to enroll (playerId/playerName passed as props).
|
||||
*/
|
||||
export function EnrollChildModal({ isOpen, onClose, playerId, playerName }: EnrollChildModalProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Form state
|
||||
const [code, setCode] = useState('')
|
||||
|
||||
// Look up classroom by code
|
||||
const { data: classroom, isLoading: lookingUp } = useClassroomByCode(code)
|
||||
|
||||
// Enrollment mutation
|
||||
const createRequest = useCreateEnrollmentRequest()
|
||||
|
||||
// Check enrolled classrooms to detect if teacher already approved
|
||||
const { data: enrolledClassrooms } = useEnrolledClassrooms(playerId)
|
||||
|
||||
// Check if the classroom we just requested enrollment for is now in the enrolled list
|
||||
// This means the teacher approved it while we were waiting
|
||||
const isNowEnrolled =
|
||||
createRequest.isSuccess && classroom && enrolledClassrooms?.some((c) => c.id === classroom.id)
|
||||
|
||||
// Auto-close countdown when enrollment is complete
|
||||
const AUTO_CLOSE_SECONDS = 3
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
const countdownStartedRef = useRef(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
|
||||
// Reset state when modal closes
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
// Start closing animation
|
||||
setIsClosing(true)
|
||||
// After animation completes, actually close
|
||||
setTimeout(() => {
|
||||
setCode('')
|
||||
setCountdown(null)
|
||||
countdownStartedRef.current = false
|
||||
setIsClosing(false)
|
||||
createRequest.reset()
|
||||
onClose()
|
||||
}, 200) // Match CSS transition duration
|
||||
}
|
||||
},
|
||||
[onClose, createRequest]
|
||||
)
|
||||
|
||||
// Start countdown when enrollment is confirmed
|
||||
useEffect(() => {
|
||||
if (isNowEnrolled && !countdownStartedRef.current) {
|
||||
countdownStartedRef.current = true
|
||||
setCountdown(AUTO_CLOSE_SECONDS)
|
||||
}
|
||||
}, [isNowEnrolled])
|
||||
|
||||
// Tick down the countdown
|
||||
useEffect(() => {
|
||||
if (countdown === null || countdown <= 0) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCountdown((c) => (c !== null ? c - 1 : null))
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
// Auto-close when countdown reaches 0
|
||||
useEffect(() => {
|
||||
if (countdown === 0) {
|
||||
handleOpenChange(false)
|
||||
}
|
||||
}, [countdown, handleOpenChange])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!classroom) return
|
||||
|
||||
try {
|
||||
await createRequest.mutateAsync({
|
||||
classroomId: classroom.id,
|
||||
playerId,
|
||||
})
|
||||
} catch {
|
||||
// Error handled by mutation state
|
||||
}
|
||||
}, [classroom, playerId, createRequest])
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: Z_INDEX.MODAL,
|
||||
transition: 'opacity 0.2s ease-out',
|
||||
opacity: isClosing ? 0 : 1,
|
||||
})}
|
||||
/>
|
||||
<Dialog.Content
|
||||
data-component="enroll-child-modal"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: isClosing
|
||||
? 'translate(-50%, -50%) scale(0.95)'
|
||||
: 'translate(-50%, -50%) scale(1)',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '24px',
|
||||
width: 'calc(100% - 2rem)',
|
||||
maxWidth: '420px',
|
||||
boxShadow: '0 20px 50px -12px rgba(0, 0, 0, 0.4)',
|
||||
zIndex: Z_INDEX.MODAL + 1,
|
||||
outline: 'none',
|
||||
transition: 'opacity 0.2s ease-out, transform 0.2s ease-out',
|
||||
opacity: isClosing ? 0 : 1,
|
||||
})}
|
||||
>
|
||||
<Dialog.Title
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Enroll {playerName} in a Classroom
|
||||
</Dialog.Title>
|
||||
<Dialog.Description
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '20px',
|
||||
})}
|
||||
>
|
||||
Enter the classroom code from the teacher.
|
||||
</Dialog.Description>
|
||||
|
||||
{/* Success state */}
|
||||
{createRequest.isSuccess ? (
|
||||
<div data-section="enrollment-success">
|
||||
<div
|
||||
className={css({
|
||||
padding: '20px',
|
||||
backgroundColor: isDark ? 'green.900/20' : 'green.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.200',
|
||||
marginBottom: '20px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{isNowEnrolled ? (
|
||||
<>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.125rem',
|
||||
color: isDark ? 'green.300' : 'green.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Enrolled!
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'green.400' : 'green.600',
|
||||
})}
|
||||
>
|
||||
{playerName} is now enrolled in {classroom?.name}.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'green.300' : 'green.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Enrollment Request Sent!
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'green.400' : 'green.600',
|
||||
})}
|
||||
>
|
||||
The teacher will review and approve the enrollment.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
data-action="done"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
backgroundColor: isDark ? 'green.900' : 'green.100',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
_hover: {
|
||||
'& > span:first-child': {
|
||||
backgroundColor: isDark ? 'green.600' : 'green.600',
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Progress bar fill */}
|
||||
<span
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
backgroundColor: isDark ? 'green.700' : 'green.500',
|
||||
transition: 'width 1s linear',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
style={{
|
||||
width:
|
||||
countdown !== null ? `${(countdown / AUTO_CLOSE_SECONDS) * 100}%` : '100%',
|
||||
}}
|
||||
/>
|
||||
{/* Button text */}
|
||||
<span
|
||||
className={css({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
})}
|
||||
>
|
||||
Done
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Code input */}
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<label
|
||||
htmlFor="classroom-code-input"
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '6px',
|
||||
})}
|
||||
>
|
||||
Classroom Code
|
||||
</label>
|
||||
<input
|
||||
id="classroom-code-input"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="e.g., ABC123"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
data-input="classroom-code"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '0.15em',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.15s ease',
|
||||
_focus: {
|
||||
borderColor: isDark ? 'blue.500' : 'blue.400',
|
||||
},
|
||||
_placeholder: {
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
fontWeight: 'normal',
|
||||
letterSpacing: 'normal',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Lookup status */}
|
||||
{code.length >= 4 && (
|
||||
<div className={css({ marginTop: '12px' })}>
|
||||
{lookingUp && (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
Looking up classroom...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!lookingUp && !classroom && code.length >= 6 && (
|
||||
<p className={css({ fontSize: '0.875rem', color: 'red.500' })}>
|
||||
No classroom found with this code
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Classroom found */}
|
||||
{classroom && (
|
||||
<div
|
||||
data-section="classroom-found"
|
||||
className={css({
|
||||
padding: '16px',
|
||||
backgroundColor: isDark ? 'green.900/20' : 'green.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.200',
|
||||
marginBottom: '20px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: '1.25rem' })}>🏫</span>
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'green.300' : 'green.700',
|
||||
})}
|
||||
>
|
||||
{classroom.name}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'green.400' : 'green.600',
|
||||
})}
|
||||
>
|
||||
Teacher:{' '}
|
||||
{(classroom as { teacher?: { name?: string } }).teacher?.name ?? 'Teacher'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{createRequest.error && (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: 'red.500',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
{createRequest.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css({ display: 'flex', gap: '12px' })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={createRequest.isPending}
|
||||
data-action="cancel-enroll"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!classroom || createRequest.isPending}
|
||||
data-action="submit-enrollment"
|
||||
className={css({
|
||||
flex: 2,
|
||||
padding: '12px',
|
||||
backgroundColor: classroom
|
||||
? isDark
|
||||
? 'green.700'
|
||||
: 'green.500'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.300',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
cursor: classroom ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.15s ease',
|
||||
_hover: {
|
||||
backgroundColor: classroom ? (isDark ? 'green.600' : 'green.600') : undefined,
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{createRequest.isPending ? 'Enrolling...' : `Enroll ${playerName}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-enroll-modal"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
padding: '8px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
_hover: {
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
EnrollmentRequestDeniedEvent,
|
||||
StudentEnteredEvent,
|
||||
StudentLeftEvent,
|
||||
StudentUnenrolledEvent,
|
||||
} from '@/lib/classroom/socket-events'
|
||||
|
||||
/**
|
||||
|
|
@ -99,6 +100,20 @@ export function useClassroomSocket(classroomId: string | undefined): { connected
|
|||
})
|
||||
})
|
||||
|
||||
// Listen for student unenrolled event
|
||||
socket.on('student-unenrolled', (data: StudentUnenrolledEvent) => {
|
||||
console.log(
|
||||
'[ClassroomSocket] Student unenrolled:',
|
||||
data.playerName,
|
||||
'by:',
|
||||
data.unenrolledBy
|
||||
)
|
||||
invalidateForEvent(queryClient, 'studentUnenrolled', {
|
||||
classroomId,
|
||||
playerId: data.playerId,
|
||||
})
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socket.emit('leave-classroom', { classroomId })
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
EnrollmentRequestApprovedEvent,
|
||||
EnrollmentRequestCreatedEvent,
|
||||
EnrollmentRequestDeniedEvent,
|
||||
StudentUnenrolledEvent,
|
||||
} from '@/lib/classroom/socket-events'
|
||||
|
||||
/**
|
||||
|
|
@ -101,6 +102,15 @@ export function useParentSocket(userId: string | undefined): { connected: boolea
|
|||
})
|
||||
})
|
||||
|
||||
// Listen for student unenrolled event (child removed from classroom)
|
||||
socket.on('student-unenrolled', (data: StudentUnenrolledEvent) => {
|
||||
console.log('[ParentSocket] Child unenrolled:', data.playerName, 'from:', data.classroomName)
|
||||
invalidateForEvent(queryClient, 'studentUnenrolled', {
|
||||
classroomId: data.classroomId,
|
||||
playerId: data.playerId,
|
||||
})
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socket.disconnect()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
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'
|
||||
import { invalidateForEvent } from '@/lib/classroom/query-invalidations'
|
||||
import type {
|
||||
EnrollmentApprovedEvent,
|
||||
PresenceRemovedEvent,
|
||||
StudentUnenrolledEvent,
|
||||
} from '@/lib/classroom/socket-events'
|
||||
|
||||
/**
|
||||
* Hook for real-time player presence updates via WebSocket
|
||||
|
|
@ -53,6 +58,24 @@ export function usePlayerPresenceSocket(playerId: string | undefined): { connect
|
|||
})
|
||||
})
|
||||
|
||||
// Listen for student unenrolled event (student removed from classroom entirely)
|
||||
socket.on('student-unenrolled', (data: StudentUnenrolledEvent) => {
|
||||
console.log('[PlayerPresenceSocket] Student unenrolled from:', data.classroomName)
|
||||
invalidateForEvent(queryClient, 'studentUnenrolled', {
|
||||
classroomId: data.classroomId,
|
||||
playerId,
|
||||
})
|
||||
})
|
||||
|
||||
// Listen for enrollment completed event (student now enrolled in classroom)
|
||||
socket.on('enrollment-approved', (data: EnrollmentApprovedEvent) => {
|
||||
console.log('[PlayerPresenceSocket] Enrolled in classroom:', data.classroomName)
|
||||
invalidateForEvent(queryClient, 'enrollmentCompleted', {
|
||||
classroomId: data.classroomId,
|
||||
playerId,
|
||||
})
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socket.emit('leave-player', { playerId })
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
* - requestApproved: One side (teacher or parent) approved their part of the request
|
||||
* - requestDenied: A request was denied by either side
|
||||
* - enrollmentCompleted: Both sides approved, student is now fully enrolled
|
||||
* - studentUnenrolled: Student was removed from a classroom (by teacher or parent)
|
||||
* - studentEntered: Student entered a classroom
|
||||
* - studentLeft: Student left a classroom
|
||||
*/
|
||||
|
|
@ -26,6 +27,7 @@ export type ClassroomEventType =
|
|||
| 'requestApproved'
|
||||
| 'requestDenied'
|
||||
| 'enrollmentCompleted'
|
||||
| 'studentUnenrolled'
|
||||
| 'studentEntered'
|
||||
| 'studentLeft'
|
||||
|
||||
|
|
@ -128,6 +130,27 @@ export function invalidateForEvent(
|
|||
})
|
||||
break
|
||||
|
||||
case 'studentUnenrolled':
|
||||
// Teacher sees student removed from enrollments and presence
|
||||
if (classroomId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.enrollments(classroomId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.presence(classroomId),
|
||||
})
|
||||
}
|
||||
// Student/parent sees classroom removed from enrolled list and presence cleared
|
||||
if (playerId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: playerKeys.enrolledClassrooms(playerId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: playerKeys.presence(playerId),
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'studentEntered':
|
||||
// Teacher sees updated presence
|
||||
if (classroomId) {
|
||||
|
|
@ -202,6 +225,17 @@ export function getInvalidationKeys(
|
|||
keys.push(classroomKeys.pendingParentApprovals())
|
||||
break
|
||||
|
||||
case 'studentUnenrolled':
|
||||
if (classroomId) {
|
||||
keys.push(classroomKeys.enrollments(classroomId))
|
||||
keys.push(classroomKeys.presence(classroomId))
|
||||
}
|
||||
if (playerId) {
|
||||
keys.push(playerKeys.enrolledClassrooms(playerId))
|
||||
keys.push(playerKeys.presence(playerId))
|
||||
}
|
||||
break
|
||||
|
||||
case 'studentEntered':
|
||||
if (classroomId) {
|
||||
keys.push(classroomKeys.presence(classroomId))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
EnrollmentRequestApprovedEvent,
|
||||
EnrollmentRequestCreatedEvent,
|
||||
EnrollmentRequestDeniedEvent,
|
||||
StudentUnenrolledEvent,
|
||||
} from './socket-events'
|
||||
|
||||
/**
|
||||
|
|
@ -138,6 +139,30 @@ export async function emitEnrollmentCompleted(
|
|||
emitToRecipients(io, 'enrollment-approved', eventData, recipients, 'enrollment-completed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a student unenrolled event
|
||||
*
|
||||
* Use when: A student is removed from a classroom (by teacher or parent).
|
||||
* This also implies their presence was removed if they were in the classroom.
|
||||
*/
|
||||
export async function emitStudentUnenrolled(
|
||||
payload: Omit<EnrollmentEventPayload, 'requestId'> & { unenrolledBy: 'teacher' | 'parent' },
|
||||
recipients: SocketRecipients
|
||||
): Promise<void> {
|
||||
const io = await getSocketIO()
|
||||
if (!io) return
|
||||
|
||||
const eventData: StudentUnenrolledEvent = {
|
||||
classroomId: payload.classroomId,
|
||||
classroomName: payload.classroomName,
|
||||
playerId: payload.playerId,
|
||||
playerName: payload.playerName,
|
||||
unenrolledBy: payload.unenrolledBy,
|
||||
}
|
||||
|
||||
emitToRecipients(io, 'student-unenrolled', eventData, recipients, 'student-unenrolled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to emit to all specified recipients
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -68,6 +68,23 @@ export interface EnrollmentApprovedEvent {
|
|||
playerName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent when a student is removed from a classroom (unenrolled).
|
||||
* This also removes their presence if they were in the classroom.
|
||||
*
|
||||
* Sent to:
|
||||
* - classroom:${classroomId} - Teacher sees student removed
|
||||
* - player:${playerId} - Student sees they're no longer enrolled
|
||||
* - user:${parentIds} - Parents see child is no longer enrolled
|
||||
*/
|
||||
export interface StudentUnenrolledEvent {
|
||||
classroomId: string
|
||||
classroomName: string
|
||||
playerId: string
|
||||
playerName: string
|
||||
unenrolledBy: 'teacher' | 'parent'
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use EnrollmentRequestDeniedEvent instead
|
||||
*/
|
||||
|
|
@ -158,6 +175,7 @@ export interface ClassroomServerToClientEvents {
|
|||
'enrollment-request-approved': (data: EnrollmentRequestApprovedEvent) => void
|
||||
'enrollment-request-denied': (data: EnrollmentRequestDeniedEvent) => void
|
||||
'enrollment-approved': (data: EnrollmentApprovedEvent) => void
|
||||
'student-unenrolled': (data: StudentUnenrolledEvent) => void
|
||||
'enrollment-denied': (data: EnrollmentDeniedEvent) => void // deprecated
|
||||
|
||||
// Presence events (classroom channel)
|
||||
|
|
|
|||
Loading…
Reference in New Issue