feat(observer): implement shareable session observation links
Add time-limited shareable links that allow anyone to observe a student's practice session without logging in. Links expire after 1 hour or 24 hours. Key features: - Parents can generate share links from the observation modal/page - Teachers cannot create share links (API enforces parent-only) - Shared observers are view-only (no pause/resume, no abacus control) - Links are automatically invalidated when sessions complete - QR code and copy buttons for easy sharing - View count and expiration tracking for active shares New files: - Session observation shares DB schema and migration - Token generation utilities (10-char base62, ~59 bits entropy) - Share CRUD API routes - Public observation page for token-based access - SessionShareButton component with popover UI Modified: - Socket server to accept token-based observer auth - useSessionObserver hook to support shareToken param - Session planner to revoke shares on session end - Observer modal/page to show share button (parents only) 🤖 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
8527f892e2
commit
3ac7b460ec
|
|
@ -0,0 +1,21 @@
|
|||
-- Session observation shares table
|
||||
-- Allows time-limited shareable links for observing practice sessions
|
||||
CREATE TABLE `session_observation_shares` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`session_id` text NOT NULL REFERENCES `session_plans`(`id`) ON DELETE CASCADE,
|
||||
`player_id` text NOT NULL REFERENCES `players`(`id`) ON DELETE CASCADE,
|
||||
`created_by` text NOT NULL,
|
||||
`created_at` integer NOT NULL DEFAULT (unixepoch()),
|
||||
`expires_at` integer NOT NULL,
|
||||
`status` text NOT NULL DEFAULT 'active',
|
||||
`view_count` integer NOT NULL DEFAULT 0,
|
||||
`last_viewed_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Index for cleanup when session ends
|
||||
CREATE INDEX `idx_session_observation_shares_session` ON `session_observation_shares`(`session_id`);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Index for listing active shares
|
||||
CREATE INDEX `idx_session_observation_shares_status` ON `session_observation_shares`(`status`);
|
||||
|
|
@ -323,6 +323,13 @@
|
|||
"when": 1766885087540,
|
||||
"tag": "0045_add_player_stats_table",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "6",
|
||||
"when": 1767052800000,
|
||||
"tag": "0046_session_observation_shares",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import { players, sessionPlans } from '@/db/schema'
|
||||
import { validateSessionShare } from '@/lib/session-share'
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/observe/[token]
|
||||
* Validate a share token and return session/player info
|
||||
*
|
||||
* This endpoint does NOT require authentication - anyone with the token can access it.
|
||||
*/
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
const { token } = await params
|
||||
|
||||
try {
|
||||
// Validate the token
|
||||
const validation = await validateSessionShare(token)
|
||||
|
||||
if (!validation.valid || !validation.share) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: validation.error || 'Invalid share link',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const share = validation.share
|
||||
|
||||
// Get the session
|
||||
const sessions = await db
|
||||
.select()
|
||||
.from(sessionPlans)
|
||||
.where(eq(sessionPlans.id, share.sessionId))
|
||||
.limit(1)
|
||||
|
||||
const session = sessions[0]
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: 'Session not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if session is still active
|
||||
if (session.completedAt) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: 'Session has ended',
|
||||
},
|
||||
{ status: 410 } // Gone
|
||||
)
|
||||
}
|
||||
|
||||
if (!session.startedAt) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: 'Session has not started yet',
|
||||
},
|
||||
{ status: 425 } // Too Early
|
||||
)
|
||||
}
|
||||
|
||||
// Get the player info
|
||||
const playerResults = await db
|
||||
.select({
|
||||
id: players.id,
|
||||
name: players.name,
|
||||
emoji: players.emoji,
|
||||
color: players.color,
|
||||
})
|
||||
.from(players)
|
||||
.where(eq(players.id, share.playerId))
|
||||
.limit(1)
|
||||
|
||||
const player = playerResults[0]
|
||||
if (!player) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: 'Player not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
session: {
|
||||
id: session.id,
|
||||
playerId: session.playerId,
|
||||
startedAt:
|
||||
session.startedAt instanceof Date ? session.startedAt.getTime() : session.startedAt,
|
||||
},
|
||||
player: {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
},
|
||||
expiresAt: share.expiresAt instanceof Date ? share.expiresAt.getTime() : share.expiresAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error validating share token:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: 'Failed to validate share link',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { canPerformAction, isParentOf } from '@/lib/classroom'
|
||||
import { getSessionPlan } from '@/lib/curriculum'
|
||||
import {
|
||||
createSessionShare,
|
||||
getActiveSharesForSession,
|
||||
revokeSessionShare,
|
||||
type ShareDuration,
|
||||
} from '@/lib/session-share'
|
||||
import { getDbUserId } from '@/lib/viewer'
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sessions/[sessionId]/share
|
||||
* Create a new share link for a session
|
||||
*
|
||||
* Body: { expiresIn: '1h' | '24h' }
|
||||
* Returns: { token, url, expiresAt }
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const { sessionId } = await params
|
||||
|
||||
try {
|
||||
// Get current user
|
||||
const userId = await getDbUserId()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the session to find the player ID
|
||||
const session = await getSessionPlan(sessionId)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check authorization - only parents can create share links (not teachers)
|
||||
const isParent = await isParentOf(userId, session.playerId)
|
||||
if (!isParent) {
|
||||
return NextResponse.json({ error: 'Only parents can create share links' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json()
|
||||
const expiresIn = body.expiresIn as ShareDuration
|
||||
if (expiresIn !== '1h' && expiresIn !== '24h') {
|
||||
return NextResponse.json({ error: 'Invalid expiresIn value' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create the share
|
||||
const share = await createSessionShare(sessionId, session.playerId, userId, expiresIn)
|
||||
|
||||
// Build the full URL
|
||||
const baseUrl = request.nextUrl.origin
|
||||
const url = `${baseUrl}/observe/${share.id}`
|
||||
|
||||
return NextResponse.json({
|
||||
token: share.id,
|
||||
url,
|
||||
expiresAt: share.expiresAt.getTime(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating session share:', error)
|
||||
return NextResponse.json({ error: 'Failed to create share link' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sessions/[sessionId]/share
|
||||
* List all active shares for a session
|
||||
*/
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
const { sessionId } = await params
|
||||
|
||||
try {
|
||||
// Get current user
|
||||
const userId = await getDbUserId()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the session to find the player ID
|
||||
const session = await getSessionPlan(sessionId)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
const canObserve = await canPerformAction(userId, session.playerId, 'observe')
|
||||
if (!canObserve) {
|
||||
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get active shares
|
||||
const shares = await getActiveSharesForSession(sessionId)
|
||||
|
||||
return NextResponse.json({
|
||||
shares: shares.map((s) => ({
|
||||
token: s.id,
|
||||
expiresAt: s.expiresAt instanceof Date ? s.expiresAt.getTime() : s.expiresAt,
|
||||
viewCount: s.viewCount,
|
||||
createdAt: s.createdAt instanceof Date ? s.createdAt.getTime() : s.createdAt,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error listing session shares:', error)
|
||||
return NextResponse.json({ error: 'Failed to list shares' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/sessions/[sessionId]/share?token=xxx
|
||||
* Revoke a specific share link
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const { sessionId } = await params
|
||||
const token = request.nextUrl.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current user
|
||||
const userId = await getDbUserId()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the session to find the player ID
|
||||
const session = await getSessionPlan(sessionId)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check authorization - only parents can revoke share links
|
||||
const isParent = await isParentOf(userId, session.playerId)
|
||||
if (!isParent) {
|
||||
return NextResponse.json({ error: 'Only parents can revoke share links' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Revoke the share
|
||||
await revokeSessionShare(token)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error revoking session share:', error)
|
||||
return NextResponse.json({ error: 'Failed to revoke share' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SessionObserverView } from '@/components/classroom'
|
||||
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
interface PublicObservationClientProps {
|
||||
session: ActiveSessionInfo
|
||||
shareToken: string
|
||||
student: {
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
}
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
function formatTimeRemaining(ms: number): string {
|
||||
if (ms <= 0) return 'Expired'
|
||||
const hours = Math.floor(ms / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m remaining`
|
||||
}
|
||||
return `${minutes}m remaining`
|
||||
}
|
||||
|
||||
export function PublicObservationClient({
|
||||
session,
|
||||
shareToken,
|
||||
student,
|
||||
expiresAt,
|
||||
}: PublicObservationClientProps) {
|
||||
const [navHeight, setNavHeight] = useState(20) // Minimal padding for public page (no nav)
|
||||
const [timeRemaining, setTimeRemaining] = useState(expiresAt - Date.now())
|
||||
|
||||
// Update countdown every minute
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimeRemaining(expiresAt - Date.now())
|
||||
}, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [expiresAt])
|
||||
|
||||
// Simple page without full nav (public access)
|
||||
return (
|
||||
<div
|
||||
data-component="public-observation-page"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
backgroundColor: 'gray.50',
|
||||
_dark: { backgroundColor: 'gray.900' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
})}
|
||||
style={{
|
||||
paddingTop: `${navHeight}px`,
|
||||
}}
|
||||
>
|
||||
{/* Expiration banner */}
|
||||
<div
|
||||
data-element="expiration-banner"
|
||||
className={css({
|
||||
backgroundColor: timeRemaining > 0 ? 'blue.50' : 'red.50',
|
||||
_dark: { backgroundColor: timeRemaining > 0 ? 'blue.900' : 'red.900' },
|
||||
padding: '8px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '0.875rem',
|
||||
color: timeRemaining > 0 ? 'blue.700' : 'red.700',
|
||||
_dark: { color: timeRemaining > 0 ? 'blue.200' : 'red.200' },
|
||||
borderBottom: '1px solid',
|
||||
borderColor: timeRemaining > 0 ? 'blue.200' : 'red.200',
|
||||
_dark: { borderColor: timeRemaining > 0 ? 'blue.800' : 'red.800' },
|
||||
})}
|
||||
>
|
||||
<span>👁️ View-only access</span>
|
||||
<span>•</span>
|
||||
<span>{formatTimeRemaining(timeRemaining)}</span>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<SessionObserverView
|
||||
session={session}
|
||||
student={student}
|
||||
observerId="" // Not used for token-based observation
|
||||
shareToken={shareToken}
|
||||
variant="page"
|
||||
isViewOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { notFound } from 'next/navigation'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import { players, sessionPlans } from '@/db/schema'
|
||||
import { validateSessionShare } from '@/lib/session-share'
|
||||
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
|
||||
import { PublicObservationClient } from './PublicObservationClient'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface PublicObservationPageProps {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
export default async function PublicObservationPage({ params }: PublicObservationPageProps) {
|
||||
const { token } = await params
|
||||
|
||||
// Validate the share token
|
||||
const validation = await validateSessionShare(token)
|
||||
if (!validation.valid || !validation.share) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const share = validation.share
|
||||
|
||||
// Get the session
|
||||
const sessions = await db
|
||||
.select()
|
||||
.from(sessionPlans)
|
||||
.where(eq(sessionPlans.id, share.sessionId))
|
||||
.limit(1)
|
||||
|
||||
const session = sessions[0]
|
||||
if (!session) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Check if session is still active
|
||||
if (session.completedAt || !session.startedAt) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Get the player
|
||||
const playerResults = await db
|
||||
.select()
|
||||
.from(players)
|
||||
.where(eq(players.id, share.playerId))
|
||||
.limit(1)
|
||||
|
||||
const player = playerResults[0]
|
||||
if (!player) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Calculate progress info
|
||||
const parts = session.parts as Array<{ slots: Array<unknown> }>
|
||||
const totalProblems = parts.reduce((sum, part) => sum + part.slots.length, 0)
|
||||
let completedProblems = 0
|
||||
for (let i = 0; i < session.currentPartIndex; i++) {
|
||||
completedProblems += parts[i]?.slots.length ?? 0
|
||||
}
|
||||
completedProblems += session.currentSlotIndex
|
||||
|
||||
const sessionInfo: ActiveSessionInfo = {
|
||||
sessionId: session.id,
|
||||
playerId: session.playerId,
|
||||
startedAt:
|
||||
session.startedAt instanceof Date
|
||||
? session.startedAt.toISOString()
|
||||
: String(session.startedAt),
|
||||
currentPartIndex: session.currentPartIndex,
|
||||
currentSlotIndex: session.currentSlotIndex,
|
||||
totalParts: parts.length,
|
||||
totalProblems,
|
||||
completedProblems,
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicObservationClient
|
||||
session={sessionInfo}
|
||||
shareToken={token}
|
||||
student={{
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
}}
|
||||
expiresAt={
|
||||
share.expiresAt instanceof Date ? share.expiresAt.getTime() : Number(share.expiresAt)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -657,6 +657,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
|
|||
color: observingStudent.color,
|
||||
}}
|
||||
observerId={userId}
|
||||
canShare={observingStudent.relationship.isMyChild}
|
||||
/>
|
||||
)}
|
||||
</PageWithNav>
|
||||
|
|
|
|||
|
|
@ -2864,6 +2864,7 @@ export function DashboardClient({
|
|||
color: player.color,
|
||||
}}
|
||||
observerId={userId}
|
||||
canShare={true}
|
||||
/>
|
||||
)}
|
||||
</PracticeErrorBoundary>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { SessionObserverView } from '@/components/classroom'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
|
||||
|
|
@ -16,10 +16,44 @@ interface ObservationClientProps {
|
|||
color: string
|
||||
}
|
||||
studentId: string
|
||||
/** Whether the observer is a parent of the student (can share session) */
|
||||
isParent?: boolean
|
||||
}
|
||||
|
||||
export function ObservationClient({ session, observerId, student, studentId }: ObservationClientProps) {
|
||||
export function ObservationClient({
|
||||
session,
|
||||
observerId,
|
||||
student,
|
||||
studentId,
|
||||
isParent = false,
|
||||
}: ObservationClientProps) {
|
||||
const router = useRouter()
|
||||
const [navHeight, setNavHeight] = useState(80) // Default fallback
|
||||
|
||||
useEffect(() => {
|
||||
// Measure the actual nav height from the fixed header
|
||||
const measureNavHeight = () => {
|
||||
const header = document.querySelector('header')
|
||||
if (header) {
|
||||
const rect = header.getBoundingClientRect()
|
||||
// Nav top position + nav height + small margin
|
||||
const calculatedHeight = rect.top + rect.height + 16
|
||||
setNavHeight(calculatedHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// Measure on mount and when window resizes
|
||||
measureNavHeight()
|
||||
window.addEventListener('resize', measureNavHeight)
|
||||
|
||||
// Also measure after a short delay to catch any late-rendering nav elements
|
||||
const timer = setTimeout(measureNavHeight, 100)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', measureNavHeight)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleExit = useCallback(() => {
|
||||
router.push(`/practice/${studentId}/dashboard`, { scroll: false })
|
||||
|
|
@ -34,25 +68,25 @@ export function ObservationClient({ session, observerId, student, studentId }: O
|
|||
backgroundColor: 'gray.50',
|
||||
_dark: { backgroundColor: 'gray.900' },
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: { base: '16px', md: '24px' },
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
})}
|
||||
style={{
|
||||
paddingTop: `${navHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
maxWidth: '960px',
|
||||
borderRadius: '16px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid',
|
||||
borderColor: { base: 'rgba(0,0,0,0.05)', _dark: 'rgba(255,255,255,0.08)' },
|
||||
})}
|
||||
>
|
||||
<SessionObserverView
|
||||
session={session}
|
||||
student={student}
|
||||
observerId={observerId}
|
||||
canShare={isParent}
|
||||
onClose={handleExit}
|
||||
variant="page"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
import { canPerformAction } from '@/lib/classroom'
|
||||
import { canPerformAction, isParentOf } from '@/lib/classroom'
|
||||
import { getActiveSessionPlan, getPlayer } from '@/lib/curriculum/server'
|
||||
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
|
||||
import { getDbUserId } from '@/lib/viewer'
|
||||
|
|
@ -23,7 +23,10 @@ export default async function PracticeObservationPage({ params }: ObservationPag
|
|||
notFound()
|
||||
}
|
||||
|
||||
const canObserve = await canPerformAction(observerId, studentId, 'observe')
|
||||
const [canObserve, isParent] = await Promise.all([
|
||||
canPerformAction(observerId, studentId, 'observe'),
|
||||
isParentOf(observerId, studentId),
|
||||
])
|
||||
if (!canObserve) {
|
||||
notFound()
|
||||
}
|
||||
|
|
@ -60,6 +63,7 @@ export default async function PracticeObservationPage({ params }: ObservationPag
|
|||
color: player.color,
|
||||
}}
|
||||
studentId={studentId}
|
||||
isParent={isParent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { ActiveSessionInfo } from '@/hooks/useClassroom'
|
|||
import { useSessionObserver } from '@/hooks/useSessionObserver'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { AbacusDock } from '../AbacusDock'
|
||||
import { SessionShareButton } from './SessionShareButton'
|
||||
import { LiveResultsPanel } from '../practice/LiveResultsPanel'
|
||||
import { LiveSessionReportInline } from '../practice/LiveSessionReportModal'
|
||||
import { PracticeFeedback } from '../practice/PracticeFeedback'
|
||||
|
|
@ -32,12 +33,20 @@ interface SessionObserverModalProps {
|
|||
}
|
||||
/** Observer ID (e.g., teacher's user ID) */
|
||||
observerId: string
|
||||
/** Whether the observer can share this session (parents only) */
|
||||
canShare?: boolean
|
||||
}
|
||||
|
||||
interface SessionObserverViewProps {
|
||||
session: ActiveSessionInfo
|
||||
student: SessionObserverModalProps['student']
|
||||
observerId: string
|
||||
/** Optional share token for public/guest observation (bypasses user auth) */
|
||||
shareToken?: string
|
||||
/** If true, hide all control buttons (pause/resume, dock abacus, share) */
|
||||
isViewOnly?: boolean
|
||||
/** Whether the observer can share this session (parents only) */
|
||||
canShare?: boolean
|
||||
onClose?: () => void
|
||||
onRequestFullscreen?: () => void
|
||||
renderCloseButton?: (button: ReactElement) => ReactElement
|
||||
|
|
@ -60,6 +69,7 @@ export function SessionObserverModal({
|
|||
session,
|
||||
student,
|
||||
observerId,
|
||||
canShare,
|
||||
}: SessionObserverModalProps) {
|
||||
const router = useRouter()
|
||||
|
||||
|
|
@ -107,6 +117,7 @@ export function SessionObserverModal({
|
|||
session={session}
|
||||
student={student}
|
||||
observerId={observerId}
|
||||
canShare={canShare}
|
||||
onClose={onClose}
|
||||
onRequestFullscreen={handleFullscreen}
|
||||
renderCloseButton={(button) => <Dialog.Close asChild>{button}</Dialog.Close>}
|
||||
|
|
@ -122,6 +133,9 @@ export function SessionObserverView({
|
|||
session,
|
||||
student,
|
||||
observerId,
|
||||
shareToken,
|
||||
isViewOnly = false,
|
||||
canShare = false,
|
||||
onClose,
|
||||
onRequestFullscreen,
|
||||
renderCloseButton,
|
||||
|
|
@ -133,7 +147,7 @@ export function SessionObserverView({
|
|||
|
||||
// Subscribe to the session's socket channel
|
||||
const { state, results, isConnected, isObserving, error, sendControl, sendPause, sendResume } =
|
||||
useSessionObserver(session.sessionId, observerId, session.playerId, true)
|
||||
useSessionObserver(session.sessionId, observerId, session.playerId, true, shareToken)
|
||||
|
||||
// Track if we've paused the session (teacher controls resume)
|
||||
const [hasPausedSession, setHasPausedSession] = useState(false)
|
||||
|
|
@ -339,15 +353,17 @@ export function SessionObserverView({
|
|||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)',
|
||||
color: isDark ? 'white' : 'gray.700',
|
||||
fontSize: '1rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
_hover: { backgroundColor: 'rgba(255, 255, 255, 0.3)' },
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
})}
|
||||
title="Open full-screen observation"
|
||||
>
|
||||
|
|
@ -578,74 +594,79 @@ export function SessionObserverView({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Teacher controls: pause/resume and dock abaci */}
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
{/* Pause/Resume button */}
|
||||
{isObserving && (
|
||||
<button
|
||||
type="button"
|
||||
data-action={hasPausedSession ? 'resume-session' : 'pause-session'}
|
||||
onClick={hasPausedSession ? handleResumeSession : handlePauseSession}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
backgroundColor: hasPausedSession
|
||||
? isDark
|
||||
? 'green.700'
|
||||
: 'green.100'
|
||||
: isDark
|
||||
? 'amber.700'
|
||||
: 'amber.100',
|
||||
color: hasPausedSession
|
||||
? isDark
|
||||
? 'green.200'
|
||||
: 'green.700'
|
||||
: isDark
|
||||
? 'amber.200'
|
||||
: 'amber.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
{/* Teacher controls: pause/resume and dock abaci (hidden for view-only observers) */}
|
||||
{!isViewOnly && (
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
{/* Pause/Resume button */}
|
||||
{isObserving && (
|
||||
<button
|
||||
type="button"
|
||||
data-action={hasPausedSession ? 'resume-session' : 'pause-session'}
|
||||
onClick={hasPausedSession ? handleResumeSession : handlePauseSession}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
backgroundColor: hasPausedSession
|
||||
? isDark
|
||||
? 'green.600'
|
||||
: 'green.200'
|
||||
? 'green.700'
|
||||
: 'green.100'
|
||||
: isDark
|
||||
? 'amber.600'
|
||||
: 'amber.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{hasPausedSession ? '▶️ Resume' : '⏸️ Pause'}
|
||||
</button>
|
||||
)}
|
||||
? 'amber.700'
|
||||
: 'amber.100',
|
||||
color: hasPausedSession
|
||||
? isDark
|
||||
? 'green.200'
|
||||
: 'green.700'
|
||||
: isDark
|
||||
? 'amber.200'
|
||||
: 'amber.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: hasPausedSession
|
||||
? isDark
|
||||
? 'green.600'
|
||||
: 'green.200'
|
||||
: isDark
|
||||
? 'amber.600'
|
||||
: 'amber.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{hasPausedSession ? '▶️ Resume' : '⏸️ Pause'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dock both abaci button */}
|
||||
{state && state.phase === 'problem' && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="dock-both-abaci"
|
||||
onClick={handleDockBothAbaci}
|
||||
disabled={!isObserving}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isDark ? 'blue.700' : 'blue.100',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
|
||||
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
🧮 Dock Abaci
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Dock both abaci button */}
|
||||
{state && state.phase === 'problem' && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="dock-both-abaci"
|
||||
onClick={handleDockBothAbaci}
|
||||
disabled={!isObserving}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isDark ? 'blue.700' : 'blue.100',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
|
||||
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
🧮 Dock Abaci
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Share session link button (parents only) */}
|
||||
{canShare && <SessionShareButton sessionId={session.sessionId} isDark={isDark} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,521 @@
|
|||
'use client'
|
||||
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { getShareUrl } from '@/lib/share/urls'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { AbacusQRCode } from '../common/AbacusQRCode'
|
||||
import { CopyButton } from '../common/CopyButton'
|
||||
|
||||
interface ShareInfo {
|
||||
token: string
|
||||
url: string
|
||||
expiresAt: number
|
||||
viewCount?: number
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
interface SessionShareButtonProps {
|
||||
sessionId: string
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function formatTimeRemaining(expiresAt: number): string {
|
||||
const remaining = expiresAt - Date.now()
|
||||
if (remaining <= 0) return 'Expired'
|
||||
const hours = Math.floor(remaining / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60))
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
function formatCreatedAt(createdAt: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - createdAt
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
|
||||
if (minutes < 1) return 'Just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return new Date(createdAt).toLocaleDateString()
|
||||
}
|
||||
|
||||
export function SessionShareButton({ sessionId, isDark }: SessionShareButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [expiresIn, setExpiresIn] = useState<'1h' | '24h'>('1h')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [generatedShare, setGeneratedShare] = useState<ShareInfo | null>(null)
|
||||
const [activeShares, setActiveShares] = useState<ShareInfo[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch active shares when popover opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchActiveShares()
|
||||
}
|
||||
}, [isOpen, sessionId])
|
||||
|
||||
const fetchActiveShares = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${sessionId}/share`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setActiveShares(
|
||||
data.shares.map(
|
||||
(s: { token: string; expiresAt: number; viewCount?: number; createdAt?: number }) => ({
|
||||
token: s.token,
|
||||
url: getShareUrl('observe', s.token),
|
||||
expiresAt: s.expiresAt,
|
||||
viewCount: s.viewCount,
|
||||
createdAt: s.createdAt,
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch active shares:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${sessionId}/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expiresIn }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to generate share link')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const share: ShareInfo = {
|
||||
token: data.token,
|
||||
url: data.url,
|
||||
expiresAt: data.expiresAt,
|
||||
createdAt: Date.now(),
|
||||
viewCount: 0,
|
||||
}
|
||||
setGeneratedShare(share)
|
||||
setActiveShares((prev) => [...prev, share])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate share link')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = useCallback(
|
||||
async (token: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${sessionId}/share?token=${token}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (response.ok) {
|
||||
setActiveShares((prev) => prev.filter((s) => s.token !== token))
|
||||
if (generatedShare?.token === token) {
|
||||
setGeneratedShare(null)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to revoke share:', err)
|
||||
}
|
||||
},
|
||||
[sessionId, generatedShare?.token]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-action="share-session"
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isDark ? 'purple.700' : 'purple.100',
|
||||
color: isDark ? 'purple.200' : 'purple.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'purple.600' : 'purple.200' },
|
||||
})}
|
||||
>
|
||||
🔗 Share
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-component="share-popover"
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.2)',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
width: 'min(520px, calc(100vw - 32px))',
|
||||
maxHeight: 'calc(100vh - 100px)',
|
||||
overflowY: 'auto',
|
||||
zIndex: Z_INDEX.DROPDOWN,
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
Share Session Link
|
||||
</h3>
|
||||
|
||||
{/* Generated link display - horizontal layout */}
|
||||
{generatedShare && (
|
||||
<div
|
||||
data-element="generated-share"
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'green.900/40' : 'green.50',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.700' : 'green.200',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{/* QR Code - left side */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
padding: '8px',
|
||||
bg: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<AbacusQRCode value={generatedShare.url} size={100} />
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.6875rem',
|
||||
color: isDark ? 'green.300' : 'green.600',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
⏱️ {formatTimeRemaining(generatedShare.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy buttons - right side */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'green.200' : 'green.700',
|
||||
fontWeight: 'medium',
|
||||
})}
|
||||
>
|
||||
✓ Link created!
|
||||
</div>
|
||||
<CopyButton
|
||||
text={generatedShare.token}
|
||||
label={generatedShare.token}
|
||||
copiedLabel="Token copied!"
|
||||
variant="code"
|
||||
/>
|
||||
<CopyButton
|
||||
text={generatedShare.url}
|
||||
label="🔗 Copy link"
|
||||
copiedLabel="Link copied!"
|
||||
variant="link"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGeneratedShare(null)}
|
||||
className={css({
|
||||
padding: '6px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
border: '1px dashed',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
_hover: { borderColor: isDark ? 'gray.500' : 'gray.400' },
|
||||
})}
|
||||
>
|
||||
+ Generate another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expiration selector - compact horizontal */}
|
||||
{!generatedShare && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<label
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
Expires in:
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: '6px' })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpiresIn('1h')}
|
||||
className={css({
|
||||
padding: '6px 12px',
|
||||
fontSize: '0.8125rem',
|
||||
backgroundColor:
|
||||
expiresIn === '1h'
|
||||
? isDark
|
||||
? 'purple.600'
|
||||
: 'purple.500'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.100',
|
||||
color: expiresIn === '1h' ? 'white' : isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
1h
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpiresIn('24h')}
|
||||
className={css({
|
||||
padding: '6px 12px',
|
||||
fontSize: '0.8125rem',
|
||||
backgroundColor:
|
||||
expiresIn === '24h'
|
||||
? isDark
|
||||
? 'purple.600'
|
||||
: 'purple.500'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.100',
|
||||
color: expiresIn === '24h' ? 'white' : isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
24h
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className={css({
|
||||
padding: '6px 16px',
|
||||
backgroundColor: isDark ? 'purple.600' : 'purple.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
_hover: { backgroundColor: isDark ? 'purple.500' : 'purple.600' },
|
||||
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
|
||||
})}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate'}
|
||||
</button>
|
||||
{error && (
|
||||
<div className={css({ fontSize: '0.75rem', color: 'red.500', width: '100%' })}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active shares list - scrollable, compact grid layout */}
|
||||
{activeShares.length > 0 && (
|
||||
<div
|
||||
data-element="active-shares"
|
||||
className={css({
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
paddingTop: '12px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
marginBottom: '8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<span>Active Links ({activeShares.length})</span>
|
||||
<span>
|
||||
{activeShares.reduce((sum, s) => sum + (s.viewCount ?? 0), 0)} total views
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: '8px',
|
||||
maxHeight: '180px',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '4px',
|
||||
})}
|
||||
>
|
||||
{activeShares.map((share) => (
|
||||
<div
|
||||
key={share.token}
|
||||
data-element="share-item"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 10px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.6875rem',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
{/* Share info - compact */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
minWidth: 0,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'purple.300' : 'purple.600',
|
||||
})}
|
||||
>
|
||||
...{share.token.slice(-6)}
|
||||
</span>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<span>⏱️ {formatTimeRemaining(share.expiresAt)}</span>
|
||||
<span>👁️ {share.viewCount ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions - inline */}
|
||||
<div className={css({ display: 'flex', gap: '4px', flexShrink: 0 })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(share.url)}
|
||||
title="Copy link"
|
||||
className={css({
|
||||
padding: '4px 6px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.625rem',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRevoke(share.token)}
|
||||
title="Revoke"
|
||||
className={css({
|
||||
padding: '4px 6px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'red.500',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'red.800' : 'red.200',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.625rem',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'red.900/50' : 'red.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Popover.Arrow
|
||||
className={css({
|
||||
fill: isDark ? 'gray.800' : 'white',
|
||||
})}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,4 +6,5 @@ export { EnrollChildModal } from './EnrollChildModal'
|
|||
export { EnterClassroomButton } from './EnterClassroomButton'
|
||||
export { PendingApprovalsSection } from './PendingApprovalsSection'
|
||||
export { SessionObserverModal, SessionObserverView } from './SessionObserverModal'
|
||||
export { SessionShareButton } from './SessionShareButton'
|
||||
export { TeacherEnrollmentSection } from './TeacherEnrollmentSection'
|
||||
|
|
|
|||
|
|
@ -690,7 +690,10 @@ export function useInteractionPhase(
|
|||
// Unambiguous intermediate prefix match (e.g., "03" for prefix sum 3)
|
||||
// Immediately enter help mode
|
||||
// Keep userAnswer during transition so it shows in answer boxes while fading out
|
||||
const helpContext = computeHelpContext(attempt.problem.terms, newPrefixMatch.helpTermIndex)
|
||||
const helpContext = computeHelpContext(
|
||||
attempt.problem.terms,
|
||||
newPrefixMatch.helpTermIndex
|
||||
)
|
||||
return {
|
||||
phase: 'helpMode',
|
||||
attempt: { ...updatedAttempt, userAnswer: '' },
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export * from './player-skill-mastery'
|
|||
export * from './player-stats'
|
||||
export * from './players'
|
||||
export * from './practice-sessions'
|
||||
export * from './session-observation-shares'
|
||||
export * from './session-plans'
|
||||
export * from './skill-tutorial-progress'
|
||||
export * from './room-bans'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { players } from './players'
|
||||
import { sessionPlans } from './session-plans'
|
||||
|
||||
/**
|
||||
* Shareable observation links for practice sessions
|
||||
*
|
||||
* Allows parents/teachers to share time-limited links that anyone can use
|
||||
* to observe a student's practice session without logging in.
|
||||
*/
|
||||
export const sessionObservationShares = sqliteTable(
|
||||
'session_observation_shares',
|
||||
{
|
||||
// 10-char base62 token (cryptographically random)
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
// Session being shared
|
||||
sessionId: text('session_id')
|
||||
.notNull()
|
||||
.references(() => sessionPlans.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Player being observed (denormalized for fast lookup)
|
||||
playerId: text('player_id')
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Who created the share link
|
||||
createdBy: text('created_by').notNull(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
||||
|
||||
// Status: active, expired (time-based), or revoked (manually)
|
||||
status: text('status', {
|
||||
enum: ['active', 'expired', 'revoked'],
|
||||
})
|
||||
.notNull()
|
||||
.default('active'),
|
||||
|
||||
// Analytics
|
||||
viewCount: integer('view_count').notNull().default(0),
|
||||
lastViewedAt: integer('last_viewed_at', { mode: 'timestamp' }),
|
||||
},
|
||||
(table) => ({
|
||||
// Index for cleanup when session ends
|
||||
sessionIdx: index('idx_session_observation_shares_session').on(table.sessionId),
|
||||
// Index for listing active shares
|
||||
statusIdx: index('idx_session_observation_shares_status').on(table.status),
|
||||
})
|
||||
)
|
||||
|
||||
export type SessionObservationShare = typeof sessionObservationShares.$inferSelect
|
||||
export type NewSessionObservationShare = typeof sessionObservationShares.$inferInsert
|
||||
|
|
@ -125,12 +125,14 @@ interface UseSessionObserverResult {
|
|||
* @param observerId - Unique identifier for this observer (e.g., teacher's/parent's user ID)
|
||||
* @param playerId - The player ID being observed (for authorization check)
|
||||
* @param enabled - Whether to start observing (default: true)
|
||||
* @param shareToken - Optional share token for public/guest observation (bypasses user auth)
|
||||
*/
|
||||
export function useSessionObserver(
|
||||
sessionId: string | undefined,
|
||||
observerId: string | undefined,
|
||||
playerId: string | undefined,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
shareToken?: string
|
||||
): UseSessionObserverResult {
|
||||
const [state, setState] = useState<ObservedSessionState | null>(null)
|
||||
const [results, setResults] = useState<ObservedResult[]>([])
|
||||
|
|
@ -156,7 +158,10 @@ export function useSessionObserver(
|
|||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !observerId || !playerId || !enabled) {
|
||||
// Need sessionId and either (observerId + playerId) or shareToken
|
||||
const hasAuthCredentials = observerId && playerId
|
||||
const hasShareToken = !!shareToken
|
||||
if (!sessionId || (!hasAuthCredentials && !hasShareToken) || !enabled) {
|
||||
// Clean up if disabled
|
||||
if (socketRef.current) {
|
||||
stopObserving()
|
||||
|
|
@ -178,8 +183,12 @@ export function useSessionObserver(
|
|||
setIsConnected(true)
|
||||
setError(null)
|
||||
|
||||
// Join the session channel as an observer (includes playerId for authorization)
|
||||
socket.emit('observe-session', { sessionId, observerId, playerId })
|
||||
// Join the session channel - use shareToken if available, otherwise authenticated flow
|
||||
if (shareToken) {
|
||||
socket.emit('observe-session', { sessionId, shareToken })
|
||||
} else {
|
||||
socket.emit('observe-session', { sessionId, observerId, playerId })
|
||||
}
|
||||
setIsObserving(true)
|
||||
})
|
||||
|
||||
|
|
@ -264,7 +273,7 @@ export function useSessionObserver(
|
|||
socket.disconnect()
|
||||
socketRef.current = null
|
||||
}
|
||||
}, [sessionId, observerId, playerId, enabled, stopObserving])
|
||||
}, [sessionId, observerId, playerId, enabled, stopObserving, shareToken])
|
||||
|
||||
// Send control action to student's abacus
|
||||
const sendControl = useCallback(
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
recordSkillAttemptsWithHelp,
|
||||
} from './progress-manager'
|
||||
import { getWeakSkillIds, type SessionMode } from './session-mode'
|
||||
import { revokeSharesForSession } from '@/lib/session-share'
|
||||
|
||||
// ============================================================================
|
||||
// Plan Generation
|
||||
|
|
@ -813,6 +814,16 @@ export async function recordSlotResult(
|
|||
}
|
||||
}
|
||||
|
||||
// Revoke any active share links when session completes
|
||||
if (isComplete) {
|
||||
try {
|
||||
await revokeSharesForSession(planId)
|
||||
} catch (shareError) {
|
||||
// Non-critical: log and continue (session already completed successfully)
|
||||
console.error(`[recordSlotResult] revokeSharesForSession FAILED:`, shareError)
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
|
|
@ -849,6 +860,13 @@ export async function completeSessionPlanEarly(
|
|||
.where(eq(schema.sessionPlans.id, planId))
|
||||
.returning()
|
||||
|
||||
// Revoke any active share links
|
||||
try {
|
||||
await revokeSharesForSession(planId)
|
||||
} catch (shareError) {
|
||||
console.error(`[completeSessionPlanEarly] revokeSharesForSession FAILED:`, shareError)
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
|
|
@ -864,6 +882,14 @@ export async function abandonSessionPlan(planId: string): Promise<SessionPlan> {
|
|||
})
|
||||
.where(eq(schema.sessionPlans.id, planId))
|
||||
.returning()
|
||||
|
||||
// Revoke any active share links
|
||||
try {
|
||||
await revokeSharesForSession(planId)
|
||||
} catch (shareError) {
|
||||
console.error(`[abandonSessionPlan] revokeSharesForSession FAILED:`, shareError)
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
import { and, eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
sessionObservationShares,
|
||||
type NewSessionObservationShare,
|
||||
type SessionObservationShare,
|
||||
} from '@/db/schema'
|
||||
|
||||
// ============================================================================
|
||||
// Token Generation
|
||||
// ============================================================================
|
||||
|
||||
const BASE62_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
const TOKEN_LENGTH = 10 // ~59 bits of entropy
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random 10-char base62 token
|
||||
*/
|
||||
export function generateShareToken(): string {
|
||||
let result = ''
|
||||
const randomBytes = new Uint8Array(TOKEN_LENGTH)
|
||||
crypto.getRandomValues(randomBytes)
|
||||
|
||||
for (let i = 0; i < TOKEN_LENGTH; i++) {
|
||||
result += BASE62_CHARS[randomBytes[i] % BASE62_CHARS.length]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token has valid format
|
||||
*/
|
||||
export function isValidShareToken(token: string): boolean {
|
||||
if (token.length !== TOKEN_LENGTH) return false
|
||||
|
||||
for (let i = 0; i < token.length; i++) {
|
||||
if (!BASE62_CHARS.includes(token[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Expiration Helpers
|
||||
// ============================================================================
|
||||
|
||||
export type ShareDuration = '1h' | '24h'
|
||||
|
||||
/**
|
||||
* Calculate expiration timestamp from duration
|
||||
*/
|
||||
export function getExpirationTime(duration: ShareDuration): Date {
|
||||
const now = new Date()
|
||||
switch (duration) {
|
||||
case '1h':
|
||||
return new Date(now.getTime() + 60 * 60 * 1000)
|
||||
case '24h':
|
||||
return new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new session share link
|
||||
*/
|
||||
export async function createSessionShare(
|
||||
sessionId: string,
|
||||
playerId: string,
|
||||
createdBy: string,
|
||||
duration: ShareDuration
|
||||
): Promise<SessionObservationShare> {
|
||||
const token = generateShareToken()
|
||||
const expiresAt = getExpirationTime(duration)
|
||||
|
||||
const newShare: NewSessionObservationShare = {
|
||||
id: token,
|
||||
sessionId,
|
||||
playerId,
|
||||
createdBy,
|
||||
expiresAt,
|
||||
status: 'active',
|
||||
viewCount: 0,
|
||||
}
|
||||
|
||||
await db.insert(sessionObservationShares).values(newShare)
|
||||
|
||||
return {
|
||||
...newShare,
|
||||
createdAt: new Date(),
|
||||
lastViewedAt: null,
|
||||
} as SessionObservationShare
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session share by token
|
||||
*/
|
||||
export async function getSessionShare(token: string): Promise<SessionObservationShare | null> {
|
||||
if (!isValidShareToken(token)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const shares = await db
|
||||
.select()
|
||||
.from(sessionObservationShares)
|
||||
.where(eq(sessionObservationShares.id, token))
|
||||
.limit(1)
|
||||
|
||||
return shares[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result for a share token
|
||||
*/
|
||||
export interface ShareValidation {
|
||||
valid: boolean
|
||||
error?: string
|
||||
share?: SessionObservationShare
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session share token
|
||||
* Checks: exists, not expired, not revoked, session still active
|
||||
*/
|
||||
export async function validateSessionShare(token: string): Promise<ShareValidation> {
|
||||
const share = await getSessionShare(token)
|
||||
|
||||
if (!share) {
|
||||
return { valid: false, error: 'Share link not found' }
|
||||
}
|
||||
|
||||
if (share.status === 'revoked') {
|
||||
return { valid: false, error: 'Share link has been revoked' }
|
||||
}
|
||||
|
||||
if (share.status === 'expired') {
|
||||
return { valid: false, error: 'Share link has expired' }
|
||||
}
|
||||
|
||||
// Check time-based expiration
|
||||
if (new Date() > share.expiresAt) {
|
||||
// Mark as expired in database
|
||||
await db
|
||||
.update(sessionObservationShares)
|
||||
.set({ status: 'expired' })
|
||||
.where(eq(sessionObservationShares.id, token))
|
||||
|
||||
return { valid: false, error: 'Share link has expired' }
|
||||
}
|
||||
|
||||
return { valid: true, share }
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the view count for a share
|
||||
*/
|
||||
export async function incrementShareViewCount(token: string): Promise<void> {
|
||||
const share = await getSessionShare(token)
|
||||
if (!share) return
|
||||
|
||||
await db
|
||||
.update(sessionObservationShares)
|
||||
.set({
|
||||
viewCount: share.viewCount + 1,
|
||||
lastViewedAt: new Date(),
|
||||
})
|
||||
.where(eq(sessionObservationShares.id, token))
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific share link
|
||||
*/
|
||||
export async function revokeSessionShare(token: string): Promise<void> {
|
||||
await db
|
||||
.update(sessionObservationShares)
|
||||
.set({ status: 'revoked' })
|
||||
.where(eq(sessionObservationShares.id, token))
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all active shares for a session (called when session ends)
|
||||
*/
|
||||
export async function revokeSharesForSession(sessionId: string): Promise<void> {
|
||||
await db
|
||||
.update(sessionObservationShares)
|
||||
.set({ status: 'expired' })
|
||||
.where(
|
||||
and(
|
||||
eq(sessionObservationShares.sessionId, sessionId),
|
||||
eq(sessionObservationShares.status, 'active')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active shares for a session
|
||||
*/
|
||||
export async function getActiveSharesForSession(
|
||||
sessionId: string
|
||||
): Promise<SessionObservationShare[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(sessionObservationShares)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionObservationShares.sessionId, sessionId),
|
||||
eq(sessionObservationShares.status, 'active')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* URL helpers for share codes
|
||||
*/
|
||||
|
||||
export type ShareType = 'classroom' | 'family' | 'room'
|
||||
export type ShareType = 'classroom' | 'family' | 'room' | 'observe'
|
||||
|
||||
/**
|
||||
* Get the base URL for the current environment
|
||||
|
|
@ -27,6 +27,8 @@ export function getShareUrl(type: ShareType, code: string): string {
|
|||
return `${base}/join/family/${code}`
|
||||
case 'room':
|
||||
return `${base}/arcade/join/${code}`
|
||||
case 'observe':
|
||||
return `${base}/observe/${code}`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,5 +43,7 @@ export function getShareTypeLabel(type: ShareType): string {
|
|||
return 'Family'
|
||||
case 'room':
|
||||
return 'Room'
|
||||
case 'observe':
|
||||
return 'Session'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { getValidator, type GameName } from './lib/arcade/validators'
|
|||
import type { GameMove } from './lib/arcade/validation/types'
|
||||
import { getGameConfig } from './lib/arcade/game-config-helpers'
|
||||
import { canPerformAction, isParentOf } from './lib/classroom'
|
||||
import { incrementShareViewCount, validateSessionShare } from './lib/session-share'
|
||||
|
||||
// Yjs server-side imports
|
||||
import * as Y from 'yjs'
|
||||
|
|
@ -772,19 +773,56 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
|||
})
|
||||
|
||||
// Session Observation: Start observing a practice session
|
||||
// Now requires playerId for authorization check (parent or teacher-present)
|
||||
// Supports both authenticated observers (parent/teacher) and token-based shared observers
|
||||
socket.on(
|
||||
'observe-session',
|
||||
async ({
|
||||
sessionId,
|
||||
observerId,
|
||||
playerId,
|
||||
shareToken,
|
||||
}: {
|
||||
sessionId: string
|
||||
observerId: string
|
||||
observerId?: string
|
||||
playerId?: string
|
||||
shareToken?: string
|
||||
}) => {
|
||||
try {
|
||||
// Token-based authentication (shareable links - no user login required)
|
||||
if (shareToken) {
|
||||
const validation = await validateSessionShare(shareToken)
|
||||
if (!validation.valid) {
|
||||
console.log(`⚠️ Share token validation failed: ${validation.error}`)
|
||||
socket.emit('observe-error', { error: validation.error || 'Invalid share link' })
|
||||
return
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
await incrementShareViewCount(shareToken)
|
||||
|
||||
// Mark this socket as a shared observer (view-only, no controls)
|
||||
socket.data.isSharedObserver = true
|
||||
socket.data.shareToken = shareToken
|
||||
|
||||
await socket.join(`session:${sessionId}`)
|
||||
console.log(
|
||||
`👁️ Shared observer joined session: ${sessionId} (token: ${shareToken.substring(0, 4)}...)`
|
||||
)
|
||||
|
||||
// Notify session that a guest observer joined
|
||||
socket.to(`session:${sessionId}`).emit('observer-joined', {
|
||||
observerId: 'guest',
|
||||
isGuest: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticated observer flow (parent or teacher-present)
|
||||
if (!observerId) {
|
||||
socket.emit('observe-error', { error: 'Observer ID required' })
|
||||
return
|
||||
}
|
||||
|
||||
// Authorization check: require 'observe' permission (parent or teacher-present)
|
||||
if (playerId) {
|
||||
const canObserve = await canPerformAction(observerId, playerId, 'observe')
|
||||
|
|
@ -797,6 +835,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
|||
}
|
||||
}
|
||||
|
||||
// Mark as authenticated observer (has controls)
|
||||
socket.data.isSharedObserver = false
|
||||
|
||||
await socket.join(`session:${sessionId}`)
|
||||
console.log(`👁️ Observer ${observerId} started watching session: ${sessionId}`)
|
||||
|
||||
|
|
@ -845,15 +886,22 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
|||
)
|
||||
|
||||
// Session Observation: Tutorial control from observer
|
||||
// Shared observers (via token) are view-only and cannot control
|
||||
socket.on(
|
||||
'tutorial-control',
|
||||
(data: { sessionId: string; action: 'skip' | 'next' | 'previous' }) => {
|
||||
// Reject if shared observer (view-only)
|
||||
if (socket.data.isSharedObserver) {
|
||||
console.log('[Socket] tutorial-control rejected - shared observer is view-only')
|
||||
return
|
||||
}
|
||||
// Send control command to student's client
|
||||
io!.to(`session:${data.sessionId}`).emit('tutorial-control', data)
|
||||
}
|
||||
)
|
||||
|
||||
// Session Observation: Abacus control from observer
|
||||
// Shared observers (via token) are view-only and cannot control
|
||||
socket.on(
|
||||
'abacus-control',
|
||||
(data: {
|
||||
|
|
@ -862,20 +910,37 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
|||
action: 'show' | 'hide' | 'set-value'
|
||||
value?: number
|
||||
}) => {
|
||||
// Reject if shared observer (view-only)
|
||||
if (socket.data.isSharedObserver) {
|
||||
console.log('[Socket] abacus-control rejected - shared observer is view-only')
|
||||
return
|
||||
}
|
||||
// Send control command to student's client
|
||||
io!.to(`session:${data.sessionId}`).emit('abacus-control', data)
|
||||
}
|
||||
)
|
||||
|
||||
// Session Observation: Pause command from observer (teacher pauses student's session)
|
||||
// Shared observers (via token) are view-only and cannot control
|
||||
socket.on('session-pause', (data: { sessionId: string; reason: string; message?: string }) => {
|
||||
// Reject if shared observer (view-only)
|
||||
if (socket.data.isSharedObserver) {
|
||||
console.log('[Socket] session-pause rejected - shared observer is view-only')
|
||||
return
|
||||
}
|
||||
console.log('[Socket] session-pause:', data.sessionId, data.message)
|
||||
// Forward pause command to student's client
|
||||
io!.to(`session:${data.sessionId}`).emit('session-paused', data)
|
||||
})
|
||||
|
||||
// Session Observation: Resume command from observer (teacher resumes student's session)
|
||||
// Shared observers (via token) are view-only and cannot control
|
||||
socket.on('session-resume', (data: { sessionId: string }) => {
|
||||
// Reject if shared observer (view-only)
|
||||
if (socket.data.isSharedObserver) {
|
||||
console.log('[Socket] session-resume rejected - shared observer is view-only')
|
||||
return
|
||||
}
|
||||
console.log('[Socket] session-resume:', data.sessionId)
|
||||
// Forward resume command to student's client
|
||||
io!.to(`session:${data.sessionId}`).emit('session-resumed', data)
|
||||
|
|
|
|||
Loading…
Reference in New Issue