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:
Thomas Hallock
2025-12-23 08:26:54 -06:00
parent 629bfcfc03
commit bbe0500fe9
14 changed files with 1035 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
export { AddStudentByFamilyCodeModal } from './AddStudentByFamilyCodeModal'
export { ClassroomCodeShare } from './ClassroomCodeShare'
export { ClassroomDashboard } from './ClassroomDashboard'
export { ClassroomTab } from './ClassroomTab'

View File

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

View File

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

View 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 }
}

View File

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

View File

@@ -48,6 +48,7 @@ export {
denyEnrollmentRequest,
cancelEnrollmentRequest,
getPendingRequestsForClassroom,
getRequestsAwaitingParentApproval,
getPendingRequestsForParent,
isEnrolled,
getEnrolledStudents,

View File

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