diff --git a/apps/web/drizzle/0046_session_observation_shares.sql b/apps/web/drizzle/0046_session_observation_shares.sql new file mode 100644 index 00000000..2a3e6f19 --- /dev/null +++ b/apps/web/drizzle/0046_session_observation_shares.sql @@ -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`); diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index 2b928f28..732d6a70 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/apps/web/src/app/api/observe/[token]/route.ts b/apps/web/src/app/api/observe/[token]/route.ts new file mode 100644 index 00000000..0b7a1437 --- /dev/null +++ b/apps/web/src/app/api/observe/[token]/route.ts @@ -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 } + ) + } +} diff --git a/apps/web/src/app/api/sessions/[sessionId]/share/route.ts b/apps/web/src/app/api/sessions/[sessionId]/share/route.ts new file mode 100644 index 00000000..21fa5b48 --- /dev/null +++ b/apps/web/src/app/api/sessions/[sessionId]/share/route.ts @@ -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 }) + } +} diff --git a/apps/web/src/app/observe/[token]/PublicObservationClient.tsx b/apps/web/src/app/observe/[token]/PublicObservationClient.tsx new file mode 100644 index 00000000..a9f272d5 --- /dev/null +++ b/apps/web/src/app/observe/[token]/PublicObservationClient.tsx @@ -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 ( +
+ {/* Expiration banner */} +
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' }, + })} + > + 👁️ View-only access + + {formatTimeRemaining(timeRemaining)} +
+ + {/* Main content */} +
+ +
+
+ ) +} diff --git a/apps/web/src/app/observe/[token]/page.tsx b/apps/web/src/app/observe/[token]/page.tsx new file mode 100644 index 00000000..05349b38 --- /dev/null +++ b/apps/web/src/app/observe/[token]/page.tsx @@ -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 }> + 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 ( + + ) +} diff --git a/apps/web/src/app/practice/PracticeClient.tsx b/apps/web/src/app/practice/PracticeClient.tsx index 45eac5aa..466e3ecb 100644 --- a/apps/web/src/app/practice/PracticeClient.tsx +++ b/apps/web/src/app/practice/PracticeClient.tsx @@ -657,6 +657,7 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli color: observingStudent.color, }} observerId={userId} + canShare={observingStudent.relationship.isMyChild} /> )} diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx index fde4ec40..9fec47e3 100644 --- a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx +++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx @@ -2864,6 +2864,7 @@ export function DashboardClient({ color: player.color, }} observerId={userId} + canShare={true} /> )} diff --git a/apps/web/src/app/practice/[studentId]/observe/ObservationClient.tsx b/apps/web/src/app/practice/[studentId]/observe/ObservationClient.tsx index 6f3f2d2a..da71f8d5 100644 --- a/apps/web/src/app/practice/[studentId]/observe/ObservationClient.tsx +++ b/apps/web/src/app/practice/[studentId]/observe/ObservationClient.tsx @@ -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`, + }} >
diff --git a/apps/web/src/app/practice/[studentId]/observe/page.tsx b/apps/web/src/app/practice/[studentId]/observe/page.tsx index a7bd994e..4d1893ce 100644 --- a/apps/web/src/app/practice/[studentId]/observe/page.tsx +++ b/apps/web/src/app/practice/[studentId]/observe/page.tsx @@ -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} /> ) } diff --git a/apps/web/src/components/classroom/SessionObserverModal.tsx b/apps/web/src/components/classroom/SessionObserverModal.tsx index 6c8b41d5..191226e6 100644 --- a/apps/web/src/components/classroom/SessionObserverModal.tsx +++ b/apps/web/src/components/classroom/SessionObserverModal.tsx @@ -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) => {button}} @@ -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({
- {/* Teacher controls: pause/resume and dock abaci */} -
- {/* Pause/Resume button */} - {isObserving && ( - - )} + ? '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'} + + )} - {/* Dock both abaci button */} - {state && state.phase === 'problem' && ( - - )} -
+ {/* Dock both abaci button */} + {state && state.phase === 'problem' && ( + + )} + + {/* Share session link button (parents only) */} + {canShare && } + + )} ) diff --git a/apps/web/src/components/classroom/SessionShareButton.tsx b/apps/web/src/components/classroom/SessionShareButton.tsx new file mode 100644 index 00000000..eb9cc903 --- /dev/null +++ b/apps/web/src/components/classroom/SessionShareButton.tsx @@ -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(null) + const [activeShares, setActiveShares] = useState([]) + const [error, setError] = useState(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 ( + + + + + + + +
+

+ Share Session Link +

+ + {/* Generated link display - horizontal layout */} + {generatedShare && ( +
+ {/* QR Code - left side */} +
+
+ +
+
+ ⏱️ {formatTimeRemaining(generatedShare.expiresAt)} +
+
+ + {/* Copy buttons - right side */} +
+
+ ✓ Link created! +
+ + + +
+
+ )} + + {/* Expiration selector - compact horizontal */} + {!generatedShare && ( +
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} +
+ )} + + {/* Active shares list - scrollable, compact grid layout */} + {activeShares.length > 0 && ( +
+
+ Active Links ({activeShares.length}) + + {activeShares.reduce((sum, s) => sum + (s.viewCount ?? 0), 0)} total views + +
+
+ {activeShares.map((share) => ( +
+ {/* Share info - compact */} +
+ + ...{share.token.slice(-6)} + +
+ ⏱️ {formatTimeRemaining(share.expiresAt)} + 👁️ {share.viewCount ?? 0} +
+
+ + {/* Actions - inline */} +
+ + +
+
+ ))} +
+
+ )} +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/components/classroom/index.ts b/apps/web/src/components/classroom/index.ts index 6e5dfe08..a46e2ec4 100644 --- a/apps/web/src/components/classroom/index.ts +++ b/apps/web/src/components/classroom/index.ts @@ -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' diff --git a/apps/web/src/components/practice/hooks/useInteractionPhase.ts b/apps/web/src/components/practice/hooks/useInteractionPhase.ts index b14554a0..daf07620 100644 --- a/apps/web/src/components/practice/hooks/useInteractionPhase.ts +++ b/apps/web/src/components/practice/hooks/useInteractionPhase.ts @@ -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: '' }, diff --git a/apps/web/src/db/schema/index.ts b/apps/web/src/db/schema/index.ts index d5286444..b7a452a3 100644 --- a/apps/web/src/db/schema/index.ts +++ b/apps/web/src/db/schema/index.ts @@ -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' diff --git a/apps/web/src/db/schema/session-observation-shares.ts b/apps/web/src/db/schema/session-observation-shares.ts new file mode 100644 index 00000000..8a64fa9f --- /dev/null +++ b/apps/web/src/db/schema/session-observation-shares.ts @@ -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 diff --git a/apps/web/src/hooks/useSessionObserver.ts b/apps/web/src/hooks/useSessionObserver.ts index 26b78498..6360a9c3 100644 --- a/apps/web/src/hooks/useSessionObserver.ts +++ b/apps/web/src/hooks/useSessionObserver.ts @@ -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(null) const [results, setResults] = useState([]) @@ -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( diff --git a/apps/web/src/lib/curriculum/session-planner.ts b/apps/web/src/lib/curriculum/session-planner.ts index 8d53f963..bd9ea9ed 100644 --- a/apps/web/src/lib/curriculum/session-planner.ts +++ b/apps/web/src/lib/curriculum/session-planner.ts @@ -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 { }) .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 } diff --git a/apps/web/src/lib/session-share.ts b/apps/web/src/lib/session-share.ts new file mode 100644 index 00000000..a487e5f9 --- /dev/null +++ b/apps/web/src/lib/session-share.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return db + .select() + .from(sessionObservationShares) + .where( + and( + eq(sessionObservationShares.sessionId, sessionId), + eq(sessionObservationShares.status, 'active') + ) + ) +} diff --git a/apps/web/src/lib/share/urls.ts b/apps/web/src/lib/share/urls.ts index 187a421d..bdb237d3 100644 --- a/apps/web/src/lib/share/urls.ts +++ b/apps/web/src/lib/share/urls.ts @@ -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' } } diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts index 67e7843e..9d6aacce 100644 --- a/apps/web/src/socket-server.ts +++ b/apps/web/src/socket-server.ts @@ -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)