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:
Thomas Hallock 2025-12-28 17:46:34 -06:00
parent 8527f892e2
commit 3ac7b460ec
21 changed files with 1552 additions and 88 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -657,6 +657,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli
color: observingStudent.color,
}}
observerId={userId}
canShare={observingStudent.relationship.isMyChild}
/>
)}
</PageWithNav>

View File

@ -2864,6 +2864,7 @@ export function DashboardClient({
color: player.color,
}}
observerId={userId}
canShare={true}
/>
)}
</PracticeErrorBoundary>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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