feat(classroom): implement real-time enrollment updates
- Add socket events when parent approves enrollment request
- Emits enrollment-request-approved and enrollment-approved to classroom channel
- Teacher's view updates immediately without page reload
- Add socket events to notify parents of new enrollment requests
- Teacher adding student by family code now emits to parent's user channel
- Parent sees new pending approvals in real-time
- Create useParentSocket hook for parent real-time notifications
- Connects to user:${userId} channel
- Listens for enrollment-request-created events
- Invalidates pendingParentApprovals query
- Add "Awaiting Parent Approval" section in StudentManagerTab
- Shows teacher-initiated requests waiting for parent response
- Styled in blue to differentiate from pending requests
- Add useAwaitingParentApproval hook and query key
- Add AddStudentByFamilyCodeModal for teachers to add students
- Auto-approve requester's side when creating enrollment requests
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import {
|
||||
createEnrollmentRequest,
|
||||
getLinkedParentIds,
|
||||
getTeacherClassroom,
|
||||
isEnrolled,
|
||||
} from '@/lib/classroom'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
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<{ classroomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/classrooms/[classroomId]/enroll-by-family-code
|
||||
* Teacher looks up a student by family code and creates an enrollment request
|
||||
*
|
||||
* Body: { familyCode: string }
|
||||
* Returns: { success: true, request, player } or { success: false, error }
|
||||
*/
|
||||
export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { classroomId } = await params
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
const body = await req.json()
|
||||
|
||||
if (!body.familyCode) {
|
||||
return NextResponse.json({ success: false, error: 'Missing familyCode' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify user is the teacher of this classroom
|
||||
const classroom = await getTeacherClassroom(user.id)
|
||||
if (!classroom || classroom.id !== classroomId) {
|
||||
return NextResponse.json({ success: false, error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Look up player by family code
|
||||
const normalizedCode = body.familyCode.toUpperCase().trim()
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(schema.players.familyCode, normalizedCode),
|
||||
})
|
||||
|
||||
if (!player) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No student found with that family code' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if already enrolled
|
||||
const alreadyEnrolled = await isEnrolled(classroomId, player.id)
|
||||
if (alreadyEnrolled) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'This student is already enrolled in your classroom' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create enrollment request (teacher-initiated, requires parent approval)
|
||||
const request = await createEnrollmentRequest({
|
||||
classroomId,
|
||||
playerId: player.id,
|
||||
requestedBy: user.id,
|
||||
requestedByRole: 'teacher',
|
||||
})
|
||||
|
||||
// Emit socket event for real-time updates
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const eventData = {
|
||||
request: {
|
||||
id: request.id,
|
||||
classroomId,
|
||||
classroomName: classroom.name,
|
||||
playerId: player.id,
|
||||
playerName: player.name,
|
||||
requestedByRole: 'teacher',
|
||||
},
|
||||
}
|
||||
|
||||
// Emit to classroom channel (for teacher's view)
|
||||
io.to(`classroom:${classroomId}`).emit('enrollment-request-created', eventData)
|
||||
console.log(
|
||||
`[Enroll by Family Code API] Teacher created enrollment request for ${player.name}`
|
||||
)
|
||||
|
||||
// Also emit to parent's user channel so they see the pending approval
|
||||
const parentIds = await getLinkedParentIds(player.id)
|
||||
for (const parentId of parentIds) {
|
||||
io.to(`user:${parentId}`).emit('enrollment-request-created', eventData)
|
||||
console.log(`[Enroll by Family Code API] Notified parent ${parentId} of new request`)
|
||||
}
|
||||
} catch (socketError) {
|
||||
console.error('[Enroll by Family Code API] Failed to broadcast:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request,
|
||||
player: {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to enroll by family code:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create enrollment request' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import {
|
||||
createEnrollmentRequest,
|
||||
getLinkedParentIds,
|
||||
getPendingRequestsForClassroom,
|
||||
getRequestsAwaitingParentApproval,
|
||||
getTeacherClassroom,
|
||||
isParent,
|
||||
} from '@/lib/classroom'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
@@ -33,7 +36,9 @@ interface RouteParams {
|
||||
* GET /api/classrooms/[classroomId]/enrollment-requests
|
||||
* Get pending enrollment requests (teacher only)
|
||||
*
|
||||
* Returns: { requests: EnrollmentRequest[] }
|
||||
* Returns:
|
||||
* - requests: Requests needing teacher approval (parent-initiated)
|
||||
* - awaitingParentApproval: Requests needing parent approval (teacher-initiated)
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
@@ -47,9 +52,13 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const requests = await getPendingRequestsForClassroom(classroomId)
|
||||
// Fetch both types of pending requests in parallel
|
||||
const [requests, awaitingParentApproval] = await Promise.all([
|
||||
getPendingRequestsForClassroom(classroomId),
|
||||
getRequestsAwaitingParentApproval(classroomId),
|
||||
])
|
||||
|
||||
return NextResponse.json({ requests })
|
||||
return NextResponse.json({ requests, awaitingParentApproval })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch enrollment requests:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch enrollment requests' }, { status: 500 })
|
||||
@@ -95,6 +104,54 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
|
||||
requestedByRole,
|
||||
})
|
||||
|
||||
// Get classroom and player info for the 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, body.playerId))
|
||||
.limit(1)
|
||||
|
||||
// Emit socket event to the classroom channel for real-time updates
|
||||
const io = await getSocketIO()
|
||||
if (io && classroomInfo && playerInfo) {
|
||||
try {
|
||||
const eventData = {
|
||||
request: {
|
||||
id: request.id,
|
||||
classroomId,
|
||||
classroomName: classroomInfo.name,
|
||||
playerId: body.playerId,
|
||||
playerName: playerInfo.name,
|
||||
requestedByRole,
|
||||
},
|
||||
}
|
||||
|
||||
// Emit to classroom channel (for teacher's view)
|
||||
io.to(`classroom:${classroomId}`).emit('enrollment-request-created', eventData)
|
||||
console.log(
|
||||
`[Enrollment Request API] Emitted enrollment-request-created for classroom ${classroomId}`
|
||||
)
|
||||
|
||||
// If teacher-initiated, also emit to parent's user channel
|
||||
// so they see the new pending approval in real-time
|
||||
if (requestedByRole === 'teacher') {
|
||||
const parentIds = await getLinkedParentIds(body.playerId)
|
||||
for (const parentId of parentIds) {
|
||||
io.to(`user:${parentId}`).emit('enrollment-request-created', eventData)
|
||||
console.log(`[Enrollment Request API] Notified parent ${parentId} of new request`)
|
||||
}
|
||||
}
|
||||
} catch (socketError) {
|
||||
console.error('[Enrollment Request API] Failed to broadcast request:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ request }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Failed to create enrollment request:', error)
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
@@ -54,6 +55,41 @@ 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
|
||||
|
||||
// Always emit that the request was approved (removes from "Awaiting Parent Approval")
|
||||
io.to(`classroom:${classroomId}`).emit('enrollment-request-approved', {
|
||||
requestId,
|
||||
classroomId,
|
||||
})
|
||||
|
||||
// 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',
|
||||
})
|
||||
console.log(
|
||||
`[Parent Approve API] Student ${result.request.playerId} fully enrolled in classroom ${classroomId}`
|
||||
)
|
||||
}
|
||||
} catch (socketError) {
|
||||
console.error('[Parent Approve API] Failed to broadcast:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
request: result.request,
|
||||
enrolled: result.fullyApproved,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import {
|
||||
AddStudentByFamilyCodeModal,
|
||||
ClassroomDashboard,
|
||||
CreateClassroomForm,
|
||||
EnrollChildFlow,
|
||||
@@ -14,6 +15,7 @@ import { StudentFilterBar } from '@/components/practice/StudentFilterBar'
|
||||
import { StudentSelector, type StudentWithProgress } from '@/components/practice'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useMyClassroom } from '@/hooks/useClassroom'
|
||||
import { useParentSocket } from '@/hooks/useParentSocket'
|
||||
import { usePlayersWithSkillData, useUpdatePlayer } from '@/hooks/useUserPlayers'
|
||||
import type { StudentWithSkillData } from '@/utils/studentGrouping'
|
||||
import { filterStudents, getStudentsNeedingAttention, groupStudents } from '@/utils/studentGrouping'
|
||||
@@ -22,6 +24,10 @@ import { AddStudentModal } from './AddStudentModal'
|
||||
|
||||
interface PracticeClientProps {
|
||||
initialPlayers: StudentWithSkillData[]
|
||||
/** Viewer ID for session observation */
|
||||
viewerId: string
|
||||
/** Database user ID for parent socket notifications */
|
||||
userId: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,13 +37,18 @@ interface PracticeClientProps {
|
||||
* Manages filter state (search, skills, archived, edit mode) and passes
|
||||
* grouped/filtered students to StudentSelector.
|
||||
*/
|
||||
export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeClientProps) {
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Classroom state - check if user is a teacher
|
||||
const { data: classroom, isLoading: isLoadingClassroom } = useMyClassroom()
|
||||
|
||||
// Parent socket for real-time enrollment notifications
|
||||
// Only connect when user is NOT a teacher (classroom is null and not loading)
|
||||
const isParent = !isLoadingClassroom && !classroom
|
||||
useParentSocket(isParent ? userId : undefined)
|
||||
const [showCreateClassroom, setShowCreateClassroom] = useState(false)
|
||||
const [showEnrollChild, setShowEnrollChild] = useState(false)
|
||||
|
||||
@@ -48,9 +59,12 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Add student modal state
|
||||
// Add student modal state (parent mode - create new child)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
// Add student modal state (teacher mode - add by family code)
|
||||
const [showAddByFamilyCode, setShowAddByFamilyCode] = useState(false)
|
||||
|
||||
// Use React Query with initial data from server for instant render + live updates
|
||||
const { data: players = initialPlayers } = usePlayersWithSkillData({
|
||||
initialData: initialPlayers,
|
||||
@@ -205,7 +219,7 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
setShowEnrollChild(false)
|
||||
}, [])
|
||||
|
||||
// If user is a teacher, show the classroom dashboard
|
||||
// If user is a teacher, show the classroom dashboard with filter bar
|
||||
if (classroom) {
|
||||
return (
|
||||
<PageWithNav>
|
||||
@@ -214,9 +228,33 @@ export function PracticeClient({ initialPlayers }: PracticeClientProps) {
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? 'gray.900' : 'gray.50',
|
||||
paddingTop: '160px', // Nav height (80px) + Filter bar height (~80px)
|
||||
})}
|
||||
>
|
||||
<ClassroomDashboard classroom={classroom} ownChildren={players} />
|
||||
{/* Filter Bar for teachers */}
|
||||
<StudentFilterBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
skillFilters={skillFilters}
|
||||
onSkillFiltersChange={setSkillFilters}
|
||||
showArchived={showArchived}
|
||||
onShowArchivedChange={setShowArchived}
|
||||
editMode={editMode}
|
||||
onEditModeChange={handleEditModeChange}
|
||||
archivedCount={0}
|
||||
onAddStudent={() => setShowAddByFamilyCode(true)}
|
||||
selectedCount={selectedIds.size}
|
||||
onBulkArchive={undefined}
|
||||
/>
|
||||
|
||||
<ClassroomDashboard classroom={classroom} ownChildren={players} viewerId={viewerId} />
|
||||
|
||||
{/* Add Student by Family Code Modal */}
|
||||
<AddStudentByFamilyCodeModal
|
||||
isOpen={showAddByFamilyCode}
|
||||
onClose={() => setShowAddByFamilyCode(false)}
|
||||
classroomId={classroom.id}
|
||||
/>
|
||||
</main>
|
||||
</PageWithNav>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db, schema } from '@/db'
|
||||
import { getPlayersWithSkillData } from '@/lib/curriculum/server'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { PracticeClient } from './PracticeClient'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Practice page - Server Component
|
||||
*
|
||||
@@ -13,5 +32,11 @@ export default async function PracticePage() {
|
||||
// Fetch players with skill data directly on server - no HTTP round-trip
|
||||
const players = await getPlayersWithSkillData()
|
||||
|
||||
return <PracticeClient initialPlayers={players} />
|
||||
// Get viewer ID for session observation
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get database user ID for parent socket notifications
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
return <PracticeClient initialPlayers={players} viewerId={viewerId} userId={user.id} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
'use client'
|
||||
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
interface AddStudentByFamilyCodeModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
classroomId: string
|
||||
}
|
||||
|
||||
interface PlayerPreview {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for teachers to add a student to their classroom using a family code
|
||||
*
|
||||
* Flow:
|
||||
* 1. Teacher enters family code
|
||||
* 2. System looks up student and creates enrollment request
|
||||
* 3. Parent receives notification to approve
|
||||
*/
|
||||
export function AddStudentByFamilyCodeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
classroomId,
|
||||
}: AddStudentByFamilyCodeModalProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
const [familyCode, setFamilyCode] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [playerPreview, setPlayerPreview] = useState<PlayerPreview | null>(null)
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!familyCode.trim()) {
|
||||
setError('Please enter a family code')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/classrooms/${classroomId}/enroll-by-family-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ familyCode: familyCode.trim() }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
setError(data.error || 'Failed to add student')
|
||||
return
|
||||
}
|
||||
|
||||
setPlayerPreview(data.player)
|
||||
setSuccess(true)
|
||||
} catch (err) {
|
||||
setError('Failed to add student. Please try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [familyCode, classroomId])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setFamilyCode('')
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
setPlayerPreview(null)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: Z_INDEX.MODAL,
|
||||
})}
|
||||
/>
|
||||
<Dialog.Content
|
||||
data-component="add-student-by-family-code-modal"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '24px',
|
||||
width: 'calc(100% - 2rem)',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 20px 50px -12px rgba(0, 0, 0, 0.4)',
|
||||
zIndex: Z_INDEX.MODAL + 1,
|
||||
outline: 'none',
|
||||
})}
|
||||
>
|
||||
{success && playerPreview ? (
|
||||
/* Success state */
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2rem',
|
||||
margin: '0 auto 16px',
|
||||
})}
|
||||
style={{ backgroundColor: playerPreview.color }}
|
||||
>
|
||||
{playerPreview.emoji}
|
||||
</div>
|
||||
<Dialog.Title
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Enrollment Request Sent!
|
||||
</Dialog.Title>
|
||||
<Dialog.Description
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
<strong>{playerPreview.name}</strong> will be added to your classroom once their
|
||||
parent approves the request.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
data-action="close-success"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
backgroundColor: isDark ? 'green.700' : 'green.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'green.600' : 'green.600' },
|
||||
})}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
/* Input state */
|
||||
<>
|
||||
<Dialog.Title
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Add Student by Family Code
|
||||
</Dialog.Title>
|
||||
<Dialog.Description
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '20px',
|
||||
})}
|
||||
>
|
||||
Enter a student's family sharing code to send an enrollment request. Their parent
|
||||
will need to approve before they're added to your classroom.
|
||||
</Dialog.Description>
|
||||
|
||||
<div className={css({ marginBottom: '16px' })}>
|
||||
<label
|
||||
htmlFor="family-code"
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '6px',
|
||||
})}
|
||||
>
|
||||
Family Code
|
||||
</label>
|
||||
<input
|
||||
id="family-code"
|
||||
type="text"
|
||||
value={familyCode}
|
||||
onChange={(e) => {
|
||||
setFamilyCode(e.target.value.toUpperCase())
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="e.g., ABCD-1234"
|
||||
data-element="family-code-input"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
fontSize: '1.25rem',
|
||||
fontFamily: 'monospace',
|
||||
textAlign: 'center',
|
||||
letterSpacing: '0.1em',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
border: '2px solid',
|
||||
borderColor: error
|
||||
? isDark
|
||||
? 'red.500'
|
||||
: 'red.400'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
outline: 'none',
|
||||
_focus: {
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
_placeholder: {
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
data-element="error-message"
|
||||
className={css({
|
||||
padding: '12px',
|
||||
backgroundColor: isDark ? 'red.900/30' : 'red.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'red.700' : 'red.200',
|
||||
borderRadius: '8px',
|
||||
color: isDark ? 'red.300' : 'red.700',
|
||||
fontSize: '0.875rem',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={css({ display: 'flex', gap: '12px' })}>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
data-action="cancel"
|
||||
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>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !familyCode.trim()}
|
||||
data-action="submit"
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
backgroundColor: isDark ? 'blue.700' : 'blue.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.600' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add Student'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useTheme } from '@/contexts/ThemeContext'
|
||||
import {
|
||||
useEnrolledStudents,
|
||||
usePendingEnrollmentRequests,
|
||||
useAwaitingParentApproval,
|
||||
useApproveEnrollmentRequest,
|
||||
useDenyEnrollmentRequest,
|
||||
useUnenrollStudent,
|
||||
@@ -36,6 +37,9 @@ export function StudentManagerTab({ classroom }: StudentManagerTabProps) {
|
||||
const { data: pendingRequests = [], isLoading: loadingRequests } = usePendingEnrollmentRequests(
|
||||
classroom.id
|
||||
)
|
||||
const { data: awaitingParent = [], isLoading: loadingAwaitingParent } = useAwaitingParentApproval(
|
||||
classroom.id
|
||||
)
|
||||
|
||||
// Mutations
|
||||
const approveRequest = useApproveEnrollmentRequest()
|
||||
@@ -76,8 +80,9 @@ export function StudentManagerTab({ classroom }: StudentManagerTabProps) {
|
||||
[unenrollStudent, classroom.id]
|
||||
)
|
||||
|
||||
const isLoading = loadingStudents || loadingRequests
|
||||
const isEmpty = students.length === 0 && pendingRequests.length === 0
|
||||
const isLoading = loadingStudents || loadingRequests || loadingAwaitingParent
|
||||
const isEmpty =
|
||||
students.length === 0 && pendingRequests.length === 0 && awaitingParent.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -149,6 +154,57 @@ export function StudentManagerTab({ classroom }: StudentManagerTabProps) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Awaiting Parent Approval */}
|
||||
{awaitingParent.length > 0 && (
|
||||
<section
|
||||
data-section="awaiting-parent-approval"
|
||||
className={css({
|
||||
padding: '20px',
|
||||
backgroundColor: isDark ? 'blue.900/20' : 'blue.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.700' : 'blue.200',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'blue.300' : 'blue.700',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
<span>Awaiting Parent Approval</span>
|
||||
<span
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '22px',
|
||||
height: '22px',
|
||||
padding: '0 6px',
|
||||
borderRadius: '11px',
|
||||
backgroundColor: isDark ? 'blue.700' : 'blue.500',
|
||||
color: 'white',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{awaitingParent.length}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
{awaitingParent.map((request) => (
|
||||
<AwaitingParentCard key={request.id} request={request} isDark={isDark} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Enrolled Students */}
|
||||
{students.length > 0 && (
|
||||
<section data-section="enrolled-students">
|
||||
@@ -367,6 +423,96 @@ function EnrollmentRequestCard({
|
||||
)
|
||||
}
|
||||
|
||||
interface AwaitingParentCardProps {
|
||||
request: EnrollmentRequestWithRelations
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function AwaitingParentCard({ request, isDark }: AwaitingParentCardProps) {
|
||||
const player = request.player
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="awaiting-parent-card"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '14px 16px',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '12px' })}>
|
||||
<span
|
||||
className={css({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.25rem',
|
||||
})}
|
||||
style={{ backgroundColor: player?.color ?? '#ccc' }}
|
||||
>
|
||||
{player?.emoji ?? '?'}
|
||||
</span>
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'white' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{player?.name ?? 'Unknown Student'}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
})}
|
||||
>
|
||||
Waiting for parent to approve enrollment
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
backgroundColor: isDark ? 'blue.900/30' : 'blue.100',
|
||||
borderRadius: '16px',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isDark ? 'blue.400' : 'blue.500',
|
||||
animation: 'pulse 2s infinite',
|
||||
})}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'blue.300' : 'blue.700',
|
||||
})}
|
||||
>
|
||||
Pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EnrolledStudentCardProps {
|
||||
student: Player
|
||||
onUnenroll: () => void
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AddStudentByFamilyCodeModal } from './AddStudentByFamilyCodeModal'
|
||||
export { ClassroomCodeShare } from './ClassroomCodeShare'
|
||||
export { ClassroomDashboard } from './ClassroomDashboard'
|
||||
export { ClassroomTab } from './ClassroomTab'
|
||||
|
||||
@@ -150,6 +150,18 @@ async function fetchPendingRequests(
|
||||
return data.requests
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch requests awaiting parent approval for a classroom
|
||||
*/
|
||||
async function fetchAwaitingParentApproval(
|
||||
classroomId: string
|
||||
): Promise<EnrollmentRequestWithRelations[]> {
|
||||
const res = await api(`classrooms/${classroomId}/enrollment-requests`)
|
||||
if (!res.ok) throw new Error('Failed to fetch enrollment requests')
|
||||
const data = await res.json()
|
||||
return data.awaitingParentApproval || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enrollment request
|
||||
*/
|
||||
@@ -237,17 +249,29 @@ export function useEnrolledStudents(classroomId: string | undefined) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending enrollment requests for a classroom
|
||||
* Get pending enrollment requests for a classroom (needing teacher approval)
|
||||
*/
|
||||
export function usePendingEnrollmentRequests(classroomId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...classroomKeys.detail(classroomId ?? ''), 'pending-requests'],
|
||||
queryKey: classroomKeys.pendingRequests(classroomId ?? ''),
|
||||
queryFn: () => fetchPendingRequests(classroomId!),
|
||||
enabled: !!classroomId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get requests awaiting parent approval (teacher has approved, waiting on parent)
|
||||
*/
|
||||
export function useAwaitingParentApproval(classroomId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: classroomKeys.awaitingParentApproval(classroomId ?? ''),
|
||||
queryFn: () => fetchAwaitingParentApproval(classroomId!),
|
||||
enabled: !!classroomId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enrollment request mutation
|
||||
*
|
||||
@@ -259,9 +283,12 @@ export function useCreateEnrollmentRequest() {
|
||||
return useMutation({
|
||||
mutationFn: createEnrollmentRequest,
|
||||
onSuccess: (_, { classroomId }) => {
|
||||
// Invalidate pending requests for this classroom
|
||||
// Invalidate both pending requests queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...classroomKeys.detail(classroomId), 'pending-requests'],
|
||||
queryKey: classroomKeys.pendingRequests(classroomId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.awaitingParentApproval(classroomId),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -278,7 +305,7 @@ export function useApproveEnrollmentRequest() {
|
||||
onSuccess: (result, { classroomId }) => {
|
||||
// Invalidate pending requests
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...classroomKeys.detail(classroomId), 'pending-requests'],
|
||||
queryKey: classroomKeys.pendingRequests(classroomId),
|
||||
})
|
||||
// If fully approved, also invalidate enrollments
|
||||
if (result.fullyApproved) {
|
||||
@@ -301,7 +328,7 @@ export function useDenyEnrollmentRequest() {
|
||||
onSuccess: (_, { classroomId }) => {
|
||||
// Invalidate pending requests
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...classroomKeys.detail(classroomId), 'pending-requests'],
|
||||
queryKey: classroomKeys.pendingRequests(classroomId),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -536,6 +563,58 @@ export function useLeaveClassroom() {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Active Sessions API Functions and Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Active session information for a student
|
||||
*/
|
||||
export interface ActiveSessionInfo {
|
||||
/** Session plan ID (for observation) */
|
||||
sessionId: string
|
||||
/** Player ID */
|
||||
playerId: string
|
||||
/** When the session started */
|
||||
startedAt: string
|
||||
/** Current part index */
|
||||
currentPartIndex: number
|
||||
/** Current slot index within the part */
|
||||
currentSlotIndex: number
|
||||
/** Total parts in session */
|
||||
totalParts: number
|
||||
/** Total problems in session */
|
||||
totalProblems: number
|
||||
/** Number of completed problems */
|
||||
completedProblems: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch active practice sessions for students in a classroom
|
||||
*/
|
||||
async function fetchActiveSession(classroomId: string): Promise<ActiveSessionInfo[]> {
|
||||
const res = await api(`classrooms/${classroomId}/presence/active-sessions`)
|
||||
if (!res.ok) throw new Error('Failed to fetch active sessions')
|
||||
const data = await res.json()
|
||||
return data.sessions
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active practice sessions for students in a classroom
|
||||
*
|
||||
* Teachers can use this to see which students are currently practicing
|
||||
* and observe their sessions in real-time.
|
||||
*/
|
||||
export function useActiveSessionsInClassroom(classroomId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: classroomKeys.activeSessions(classroomId ?? ''),
|
||||
queryFn: () => fetchActiveSession(classroomId!),
|
||||
enabled: !!classroomId,
|
||||
staleTime: 10 * 1000, // 10 seconds - sessions change frequently
|
||||
refetchInterval: 15 * 1000, // Poll every 15 seconds for real-time updates
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Student Enrollment API Functions
|
||||
// ============================================================================
|
||||
|
||||
@@ -4,7 +4,13 @@ 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'
|
||||
import type {
|
||||
EnrollmentApprovedEvent,
|
||||
EnrollmentRequestApprovedEvent,
|
||||
EnrollmentRequestCreatedEvent,
|
||||
StudentEnteredEvent,
|
||||
StudentLeftEvent,
|
||||
} from '@/lib/classroom/socket-events'
|
||||
|
||||
/**
|
||||
* Hook for real-time classroom presence updates via WebSocket
|
||||
@@ -62,6 +68,42 @@ export function useClassroomSocket(classroomId: string | undefined): { connected
|
||||
})
|
||||
})
|
||||
|
||||
// Listen for enrollment request created event
|
||||
socket.on('enrollment-request-created', (data: EnrollmentRequestCreatedEvent) => {
|
||||
console.log('[ClassroomSocket] Enrollment request created for:', data.request.playerName)
|
||||
// Invalidate both pending request queries to refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.pendingRequests(classroomId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.awaitingParentApproval(classroomId),
|
||||
})
|
||||
})
|
||||
|
||||
// Listen for enrollment request approved event (removes from pending)
|
||||
socket.on('enrollment-request-approved', (data: EnrollmentRequestApprovedEvent) => {
|
||||
console.log('[ClassroomSocket] Enrollment request approved:', data.requestId)
|
||||
// Invalidate both pending request queries to refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.pendingRequests(classroomId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.awaitingParentApproval(classroomId),
|
||||
})
|
||||
})
|
||||
|
||||
// Listen for enrollment approved event (student fully enrolled)
|
||||
socket.on('enrollment-approved', (data: EnrollmentApprovedEvent) => {
|
||||
console.log('[ClassroomSocket] Student enrolled:', data.playerName)
|
||||
// Invalidate enrollments and remove from awaiting parent approval
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.enrollments(classroomId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.awaitingParentApproval(classroomId),
|
||||
})
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socket.emit('leave-classroom', { classroomId })
|
||||
|
||||
70
apps/web/src/hooks/useParentSocket.ts
Normal file
70
apps/web/src/hooks/useParentSocket.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
'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 { EnrollmentRequestCreatedEvent } from '@/lib/classroom/socket-events'
|
||||
|
||||
/**
|
||||
* Hook for real-time parent notifications via WebSocket
|
||||
*
|
||||
* When a teacher adds a student (that the parent is linked to) to their classroom,
|
||||
* this hook receives the event and automatically invalidates the React Query cache
|
||||
* so the UI updates to show the new pending approval.
|
||||
*
|
||||
* @param userId - The parent's user ID to subscribe to notifications for
|
||||
* @returns Whether the socket is connected
|
||||
*/
|
||||
export function useParentSocket(userId: string | undefined): { connected: boolean } {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return
|
||||
|
||||
// Create socket connection
|
||||
const socket = io({
|
||||
path: '/api/socket',
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 5,
|
||||
})
|
||||
socketRef.current = socket
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[ParentSocket] Connected')
|
||||
setConnected(true)
|
||||
// Join the user channel for parent notifications
|
||||
socket.emit('join-user-channel', { userId })
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[ParentSocket] Disconnected')
|
||||
setConnected(false)
|
||||
})
|
||||
|
||||
// Listen for enrollment request created event (teacher added student to classroom)
|
||||
socket.on('enrollment-request-created', (data: EnrollmentRequestCreatedEvent) => {
|
||||
console.log(
|
||||
'[ParentSocket] Enrollment request created for:',
|
||||
data.request.playerName,
|
||||
'in classroom:',
|
||||
data.request.classroomName
|
||||
)
|
||||
// Invalidate the pending parent approvals query to refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: classroomKeys.pendingParentApprovals(),
|
||||
})
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
socket.disconnect()
|
||||
socketRef.current = null
|
||||
}
|
||||
}, [userId, queryClient])
|
||||
|
||||
return { connected }
|
||||
}
|
||||
@@ -55,8 +55,15 @@ export async function createEnrollmentRequest(
|
||||
),
|
||||
})
|
||||
|
||||
// Auto-approve the requester's side (they implicitly approve by creating the request)
|
||||
const teacherApproval = requestedByRole === 'teacher' ? 'approved' : null
|
||||
const teacherApprovedAt = requestedByRole === 'teacher' ? new Date() : null
|
||||
const parentApproval = requestedByRole === 'parent' ? 'approved' : null
|
||||
const parentApprovedBy = requestedByRole === 'parent' ? requestedBy : null
|
||||
const parentApprovedAt = requestedByRole === 'parent' ? new Date() : null
|
||||
|
||||
if (existing) {
|
||||
// Upsert: reset to pending
|
||||
// Upsert: reset with auto-approval for requester's side
|
||||
const [updated] = await db
|
||||
.update(enrollmentRequests)
|
||||
.set({
|
||||
@@ -64,11 +71,11 @@ export async function createEnrollmentRequest(
|
||||
requestedBy,
|
||||
requestedByRole,
|
||||
requestedAt: new Date(),
|
||||
teacherApproval: null,
|
||||
teacherApprovedAt: null,
|
||||
parentApproval: null,
|
||||
parentApprovedBy: null,
|
||||
parentApprovedAt: null,
|
||||
teacherApproval,
|
||||
teacherApprovedAt,
|
||||
parentApproval,
|
||||
parentApprovedBy,
|
||||
parentApprovedAt,
|
||||
resolvedAt: null,
|
||||
})
|
||||
.where(eq(enrollmentRequests.id, existing.id))
|
||||
@@ -76,7 +83,7 @@ export async function createEnrollmentRequest(
|
||||
return updated
|
||||
}
|
||||
|
||||
// Create new request
|
||||
// Create new request with auto-approval for requester's side
|
||||
const [request] = await db
|
||||
.insert(enrollmentRequests)
|
||||
.values({
|
||||
@@ -85,6 +92,11 @@ export async function createEnrollmentRequest(
|
||||
playerId,
|
||||
requestedBy,
|
||||
requestedByRole,
|
||||
teacherApproval,
|
||||
teacherApprovedAt,
|
||||
parentApproval,
|
||||
parentApprovedBy,
|
||||
parentApprovedAt,
|
||||
})
|
||||
.returning()
|
||||
|
||||
@@ -220,7 +232,10 @@ export interface EnrollmentRequestWithRelations extends EnrollmentRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending requests for a teacher's classroom
|
||||
* Get pending requests for a teacher's classroom that need teacher approval
|
||||
*
|
||||
* Only returns requests where teacherApproval is null (not yet approved by teacher).
|
||||
* Teacher-initiated requests are auto-approved on teacher side, so they won't appear here.
|
||||
*/
|
||||
export async function getPendingRequestsForClassroom(
|
||||
classroomId: string
|
||||
@@ -228,7 +243,42 @@ export async function getPendingRequestsForClassroom(
|
||||
const requests = await db.query.enrollmentRequests.findMany({
|
||||
where: and(
|
||||
eq(enrollmentRequests.classroomId, classroomId),
|
||||
eq(enrollmentRequests.status, 'pending')
|
||||
eq(enrollmentRequests.status, 'pending'),
|
||||
isNull(enrollmentRequests.teacherApproval) // Only requests needing teacher approval
|
||||
),
|
||||
orderBy: [desc(enrollmentRequests.requestedAt)],
|
||||
})
|
||||
|
||||
// Fetch related players
|
||||
if (requests.length === 0) return []
|
||||
|
||||
const playerIds = [...new Set(requests.map((r) => r.playerId))]
|
||||
const players = await db.query.players.findMany({
|
||||
where: (players, { inArray }) => inArray(players.id, playerIds),
|
||||
})
|
||||
const playerMap = new Map(players.map((p) => [p.id, p]))
|
||||
|
||||
return requests.map((r) => ({
|
||||
...r,
|
||||
player: playerMap.get(r.playerId),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get requests awaiting parent approval (teacher has approved, parent hasn't)
|
||||
*
|
||||
* These are typically teacher-initiated requests where the teacher added a student
|
||||
* via family code and is waiting for the parent to approve.
|
||||
*/
|
||||
export async function getRequestsAwaitingParentApproval(
|
||||
classroomId: string
|
||||
): Promise<EnrollmentRequestWithRelations[]> {
|
||||
const requests = await db.query.enrollmentRequests.findMany({
|
||||
where: and(
|
||||
eq(enrollmentRequests.classroomId, classroomId),
|
||||
eq(enrollmentRequests.status, 'pending'),
|
||||
eq(enrollmentRequests.teacherApproval, 'approved'), // Teacher has approved
|
||||
isNull(enrollmentRequests.parentApproval) // Parent hasn't responded yet
|
||||
),
|
||||
orderBy: [desc(enrollmentRequests.requestedAt)],
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ export {
|
||||
denyEnrollmentRequest,
|
||||
cancelEnrollmentRequest,
|
||||
getPendingRequestsForClassroom,
|
||||
getRequestsAwaitingParentApproval,
|
||||
getPendingRequestsForParent,
|
||||
isEnrolled,
|
||||
getEnrolledStudents,
|
||||
|
||||
@@ -43,5 +43,9 @@ 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,
|
||||
activeSessions: (id: string) => [...classroomKeys.all, 'activeSessions', id] as const,
|
||||
pendingParentApprovals: () => [...classroomKeys.all, 'pendingParentApprovals'] as const,
|
||||
pendingRequests: (id: string) => [...classroomKeys.detail(id), 'pending-requests'] as const,
|
||||
awaitingParentApproval: (id: string) =>
|
||||
[...classroomKeys.detail(id), 'awaiting-parent-approval'] as const,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user