diff --git a/app/api/classes/[classId]/live-session/route.ts b/app/api/classes/[classId]/live-session/route.ts index 79a26eb..e9e5b44 100644 --- a/app/api/classes/[classId]/live-session/route.ts +++ b/app/api/classes/[classId]/live-session/route.ts @@ -13,6 +13,7 @@ type RouteContext = { type LiveSessionRequestBody = { action?: "end" + liveSessionId?: string roomName?: string } @@ -80,12 +81,14 @@ async function endLiveSession({ roomName, supabase, userId, + liveSessionId, }: { canManage: boolean classId: string roomName: string supabase: SupabaseClient userId: string + liveSessionId?: string }) { const now = new Date().toISOString() let query = supabase @@ -97,15 +100,30 @@ async function endLiveSession({ }) .eq("class_id", classId) .eq("room_name", roomName) - .eq("status", "live") + .in("status", ["pending", "live"]) + + if (liveSessionId) { + query = query.eq("live_session_id", liveSessionId) + } if (!canManage) { query = query.eq("started_by_user_id", userId) } - const { error } = await query + const { data, error } = await query.select("id").maybeSingle() - return error + if (error) { + return { message: error.message, status: 500 as const } + } + + if (!data) { + return { + message: "No matching live session is active.", + status: 409 as const, + } + } + + return null } export async function POST(request: Request, context: RouteContext) { @@ -123,13 +141,17 @@ export async function POST(request: Request, context: RouteContext) { const error = await endLiveSession({ canManage: false, classId, + liveSessionId: body.liveSessionId, roomName: routeRoomName, supabase, userId: user.id, }) if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ) } return NextResponse.json({ ok: true }) @@ -153,58 +175,44 @@ export async function POST(request: Request, context: RouteContext) { ) } - const now = new Date().toISOString() - const staleBefore = Date.now() - LIVE_SESSION_STALE_MS - const { data: existingSession, error: existingError } = await supabase - .from("class_live_sessions") - .select("id, status, last_seen_at") - .eq("class_id", result.classData.id) - .maybeSingle() - - if (existingError) { - return NextResponse.json({ error: existingError.message }, { status: 500 }) - } - - const hasFreshLiveSession = - existingSession?.status === "live" && - Date.parse(existingSession.last_seen_at) > staleBefore - - if (hasFreshLiveSession) { - const { error } = await supabase - .from("class_live_sessions") - .update({ - room_name: roomName, - started_by_user_id: user.id, - last_seen_at: now, - ended_at: null, - }) - .eq("id", existingSession.id) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ ok: true }) + if (!body?.liveSessionId) { + return NextResponse.json( + { error: "A liveSessionId is required to mark a session live." }, + { status: 400 }, + ) } - const { error } = await supabase.from("class_live_sessions").upsert( - { - organization_id: result.classData.organization_id, - class_id: result.classData.id, + const now = new Date().toISOString() + const staleBefore = new Date(Date.now() - LIVE_SESSION_STALE_MS).toISOString() + const { data: liveSession, error } = await supabase + .from("class_live_sessions") + .update({ room_name: roomName, started_by_user_id: user.id, status: "live", - started_at: now, last_seen_at: now, ended_at: null, - }, - { onConflict: "class_id" }, - ) + }) + .eq("class_id", result.classData.id) + .eq("room_name", roomName) + .eq("live_session_id", body.liveSessionId) + .in("status", ["pending", "live"]) + .is("ended_at", null) + .gt("last_seen_at", staleBefore) + .select("live_session_id") + .maybeSingle() if (error) { return NextResponse.json({ error: error.message }, { status: 500 }) } + if (!liveSession) { + return NextResponse.json( + { error: "No matching claimed live session is active." }, + { status: 409 }, + ) + } + if (result.classData.teacher_user_id === user.id) { const cooldownBucket = Math.floor(Date.now() / (10 * 60 * 1000)) @@ -227,7 +235,10 @@ export async function POST(request: Request, context: RouteContext) { }).catch(() => null) } - return NextResponse.json({ ok: true }) + return NextResponse.json({ + ok: true, + liveSessionId: liveSession.live_session_id, + }) } export async function PATCH(request: Request, context: RouteContext) { @@ -248,7 +259,7 @@ export async function PATCH(request: Request, context: RouteContext) { const body = await readLiveSessionBody(request) const roomName = body?.roomName || `class-${result.classData.id}` - const { error } = await supabase + let query = supabase .from("class_live_sessions") .update({ last_seen_at: new Date().toISOString() }) .eq("class_id", result.classData.id) @@ -256,10 +267,23 @@ export async function PATCH(request: Request, context: RouteContext) { .eq("status", "live") .is("ended_at", null) + if (body?.liveSessionId) { + query = query.eq("live_session_id", body.liveSessionId) + } + + const { data, error } = await query.select("id").maybeSingle() + if (error) { return NextResponse.json({ error: error.message }, { status: 500 }) } + if (!data) { + return NextResponse.json( + { error: "No matching live session is active." }, + { status: 409 }, + ) + } + return NextResponse.json({ ok: true }) } @@ -282,13 +306,14 @@ export async function DELETE(request: Request, context: RouteContext) { const error = await endLiveSession({ canManage: result.canManage, classId: result.classData.id, + liveSessionId: body?.liveSessionId, roomName, supabase, userId: user.id, }) if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ error: error.message }, { status: error.status }) } return NextResponse.json({ ok: true }) diff --git a/app/api/livekit/token/route.ts b/app/api/livekit/token/route.ts index 4ac1140..157f571 100644 --- a/app/api/livekit/token/route.ts +++ b/app/api/livekit/token/route.ts @@ -8,6 +8,7 @@ const LIVE_SESSION_STALE_MS = 5 * 60 * 1000 interface TokenRequestBody { classId?: string + liveSessionId?: string user?: { id?: string name?: string @@ -111,6 +112,7 @@ export async function POST(request: Request) { const classId = classData.id const roomName = `class-${classId}` + let liveSessionId = body.liveSessionId if (!canManage) { const staleBefore = new Date( @@ -118,7 +120,7 @@ export async function POST(request: Request) { ).toISOString() const { data: liveSession, error: liveSessionError } = await supabase .from("class_live_sessions") - .select("id") + .select("id, live_session_id") .eq("class_id", classId) .eq("room_name", roomName) .eq("status", "live") @@ -139,12 +141,41 @@ export async function POST(request: Request) { { status: 409 }, ) } + + if (liveSessionId && liveSession.live_session_id !== liveSessionId) { + return NextResponse.json( + { error: "This live session is no longer active." }, + { status: 409 }, + ) + } + + liveSessionId = liveSession.live_session_id + } else { + const staleBefore = new Date( + Date.now() - LIVE_SESSION_STALE_MS, + ).toISOString() + const { data: claimedLiveSession, error: claimError } = await supabase + .rpc("claim_class_live_session", { + target_org_id: classData.organization_id, + target_class_id: classId, + target_room_name: roomName, + stale_before: staleBefore, + }) + .single() + + if (claimError) { + return NextResponse.json({ error: claimError.message }, { status: 500 }) + } + + const liveSession = claimedLiveSession as { live_session_id: string } + liveSessionId = liveSession.live_session_id } const metadata = JSON.stringify({ avatar: body.user.avatar ?? body.user.name.slice(0, 2).toUpperCase(), role: body.user.role ?? "student", classId, + liveSessionId, }) const token = new AccessToken(apiKey, apiSecret, { @@ -164,6 +195,7 @@ export async function POST(request: Request) { return NextResponse.json({ serverUrl, roomName, + liveSessionId, participantToken: await token.toJwt(), }) } diff --git a/features/session/live-session-provider.tsx b/features/session/live-session-provider.tsx index 3b9588d..ebd20ed 100644 --- a/features/session/live-session-provider.tsx +++ b/features/session/live-session-provider.tsx @@ -24,6 +24,7 @@ type LiveSessionContextValue = { activeClass: Class | null hasJoinedSession: boolean sessionActive: boolean + whiteboardResetKey: number liveSession: LiveSessionState joinSession: (cls: Class) => void leaveSession: () => void @@ -38,9 +39,11 @@ function getRoomName(classId: string) { async function syncClassLiveSession({ classId, + liveSessionId, method, }: { classId: string + liveSessionId?: string | null method: "POST" | "PATCH" | "DELETE" }) { const response = await fetch( @@ -50,7 +53,7 @@ async function syncClassLiveSession({ headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ roomName: getRoomName(classId) }), + body: JSON.stringify({ liveSessionId, roomName: getRoomName(classId) }), }, ) @@ -61,15 +64,21 @@ async function syncClassLiveSession({ throw new Error(payload?.error ?? "Could not update live session.") } + + return (await response.json().catch(() => null)) as { + liveSessionId?: string + ok?: boolean + } | null } function terminateClassLiveSession( classId: string, - options: { useBeacon?: boolean } = {}, + options: { liveSessionId?: string | null; useBeacon?: boolean } = {}, ) { const url = `/api/classes/${encodeURIComponent(classId)}/live-session` const body = JSON.stringify({ action: "end", + liveSessionId: options.liveSessionId, roomName: getRoomName(classId), }) @@ -96,24 +105,67 @@ function terminateClassLiveSession( } export function LiveSessionProvider({ children }: { children: ReactNode }) { - const { activeOrganization, currentUser, refreshClassLiveSessions } = useApp() + const { + activeOrganization, + classLiveSessions, + classLiveSessionsStatus, + currentUser, + refreshClassLiveSessions, + } = useApp() const [activeClass, setActiveClass] = useState(null) const [sessionActive, setSessionActive] = useState(false) const [hasJoinedSession, setHasJoinedSession] = useState(false) const [sessionScope, setSessionScope] = useState(null) + const [liveSessionId, setLiveSessionId] = useState(null) + const [whiteboardResetKey, setWhiteboardResetKey] = useState(0) const activeTeacherSessionRef = useRef(null) const disconnectRef = useRef<() => void>(() => {}) + const liveSessionIdRef = useRef(null) + const mountedRef = useRef(false) + const sessionPresenceRef = useRef({ + activeClassId: null as string | null, + isTeacher: false, + sessionActive: false, + }) const isTeacher = currentUser.role === "teacher" const currentSessionScope = `${activeOrganization?.id ?? ""}:${currentUser.id}:${currentUser.role}` + const resetLocalWhiteboards = useCallback(() => { + setWhiteboardResetKey((key) => key + 1) + }, []) + const handleLiveSessionIdResolved = useCallback( + (nextLiveSessionId: string) => { + setLiveSessionId(nextLiveSessionId) + }, + [], + ) + const handleRemoteSessionEnded = useCallback(() => { + resetLocalWhiteboards() + setLiveSessionId(null) + setSessionActive(false) + setSessionScope(null) + setHasJoinedSession(true) + void refreshClassLiveSessions({ force: true }).catch(() => {}) + }, [refreshClassLiveSessions, resetLocalWhiteboards]) const liveSession = useLiveSession({ classId: activeClass?.id ?? "", currentUser, enabled: Boolean( activeClass && sessionActive && sessionScope === currentSessionScope, ), + liveSessionId, + onLiveSessionIdResolved: handleLiveSessionIdResolved, + onSessionEnded: handleRemoteSessionEnded, }) const connected = liveSession.connectionState === ConnectionState.Connected + useEffect(() => { + mountedRef.current = true + + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => { activeTeacherSessionRef.current = activeClass && sessionActive && isTeacher ? activeClass.id : null @@ -123,20 +175,107 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { disconnectRef.current = liveSession.disconnect }, [liveSession.disconnect]) + useEffect(() => { + liveSessionIdRef.current = liveSessionId + }, [liveSessionId]) + + useEffect(() => { + sessionPresenceRef.current = { + activeClassId: activeClass?.id ?? null, + isTeacher, + sessionActive, + } + }, [activeClass?.id, isTeacher, sessionActive]) + + useEffect(() => { + const activeClassId = activeClass?.id + + if ( + isTeacher || + !activeClassId || + !sessionActive || + classLiveSessionsStatus !== "ready" + ) { + return + } + + const sessionIsStillLive = classLiveSessions.some( + (session) => session.class_id === activeClassId, + ) + + if (sessionIsStillLive) { + return + } + + let cancelled = false + let recheckStarted = false + const recheckTimer = window.setTimeout(() => { + recheckStarted = true + void refreshClassLiveSessions({ force: true }) + .then((freshSessions) => { + const currentPresence = sessionPresenceRef.current + + if ( + cancelled || + !mountedRef.current || + currentPresence.activeClassId !== activeClassId || + currentPresence.isTeacher || + !currentPresence.sessionActive || + freshSessions.some((session) => session.class_id === activeClassId) + ) { + return + } + + disconnectRef.current() + resetLocalWhiteboards() + setLiveSessionId(null) + setSessionActive(false) + setSessionScope(null) + setHasJoinedSession(true) + }) + .catch(() => {}) + }, 1500) + + return () => { + window.clearTimeout(recheckTimer) + + if (!recheckStarted) { + cancelled = true + } + } + }, [ + activeClass?.id, + classLiveSessions, + classLiveSessionsStatus, + isTeacher, + refreshClassLiveSessions, + sessionActive, + ]) + const joinSession = useCallback( (cls: Class) => { const currentTeacherClassId = activeTeacherSessionRef.current + const existingLiveSessionId = + classLiveSessions.find((session) => session.class_id === cls.id) + ?.live_session_id ?? null + + if (!isTeacher && !existingLiveSessionId) { + return + } if (currentTeacherClassId && currentTeacherClassId !== cls.id) { - void terminateClassLiveSession(currentTeacherClassId) + void terminateClassLiveSession(currentTeacherClassId, { + liveSessionId: liveSessionIdRef.current, + }) } setActiveClass(cls) + setLiveSessionId(existingLiveSessionId) setSessionScope(currentSessionScope) setHasJoinedSession(true) setSessionActive(true) }, - [currentSessionScope], + [classLiveSessions, currentSessionScope, isTeacher], ) const leaveSession = useCallback(() => { @@ -154,25 +293,44 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { return } + if (isTeacher) { + await liveSession.clearWhiteboards().catch(() => false) + await liveSession.endSessionForEveryone().catch(() => false) + resetLocalWhiteboards() + } + liveSession.disconnect() + setLiveSessionId(null) setSessionActive(false) setSessionScope(null) setHasJoinedSession(true) if (isTeacher) { - await syncClassLiveSession({ classId, method: "DELETE" }).catch(() => {}) + await syncClassLiveSession({ + classId, + liveSessionId, + method: "DELETE", + }).catch(() => {}) await refreshClassLiveSessions({ force: true }).catch(() => {}) } }, [ activeClass?.id, isTeacher, leaveSession, + liveSessionId, liveSession, refreshClassLiveSessions, + resetLocalWhiteboards, ]) useEffect(() => { - if (!activeClass || !sessionActive || !isTeacher || !connected) { + if ( + !activeClass || + !liveSessionId || + !sessionActive || + !isTeacher || + !connected + ) { return } @@ -183,7 +341,12 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { await syncClassLiveSession({ classId: activeClass.id, + liveSessionId, method: "POST", + }).then((payload) => { + if (!cancelled && payload?.liveSessionId) { + setLiveSessionId(payload.liveSessionId) + } }) if (!cancelled) { await refreshClassLiveSessions({ force: true }).catch(() => {}) @@ -195,6 +358,7 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { const heartbeat = window.setInterval(() => { void syncClassLiveSession({ classId: activeClass.id, + liveSessionId, method: "PATCH", }).catch(() => {}) }, 60_000) @@ -207,6 +371,7 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { activeClass, connected, isTeacher, + liveSessionId, refreshClassLiveSessions, sessionActive, ]) @@ -219,7 +384,9 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { const classId = activeTeacherSessionRef.current if (classId) { - void terminateClassLiveSession(classId) + void terminateClassLiveSession(classId, { + liveSessionId: liveSessionIdRef.current, + }) .catch(() => {}) .finally(() => { void refreshClassLiveSessions({ force: true }).catch(() => {}) @@ -228,6 +395,7 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { disconnectRef.current() setActiveClass(null) + setLiveSessionId(null) setSessionActive(false) setHasJoinedSession(false) setSessionScope(null) @@ -238,7 +406,10 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { const classId = activeTeacherSessionRef.current if (classId) { - void terminateClassLiveSession(classId, options) + void terminateClassLiveSession(classId, { + ...options, + liveSessionId: liveSessionIdRef.current, + }) .catch(() => {}) .finally(() => { if (!options?.useBeacon) { @@ -264,6 +435,7 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { activeClass, hasJoinedSession, sessionActive, + whiteboardResetKey, liveSession, joinSession, leaveSession, @@ -277,6 +449,7 @@ export function LiveSessionProvider({ children }: { children: ReactNode }) { leaveSession, liveSession, sessionActive, + whiteboardResetKey, ], ) diff --git a/features/session/live-session-types.ts b/features/session/live-session-types.ts index ceba989..2a21fdb 100644 --- a/features/session/live-session-types.ts +++ b/features/session/live-session-types.ts @@ -115,7 +115,9 @@ export type WhiteboardOperation = delta: WhiteboardPoint } -export type LiveSessionWhiteboardMessage = +export type LiveSessionWhiteboardMessage = { + liveSessionId: string +} & ( | { id: string senderId: string @@ -197,15 +199,36 @@ export type LiveSessionWhiteboardMessage = senderId: string boardId?: string type: "state:request" + requestId: string + requesterRole?: Role } | { id: string senderId: string boardId?: string type: "state:sync" + requestId?: string version: number operations: WhiteboardOperation[] } + | { + id: string + senderId: string + type: "session:clear" + } + | { + id: string + senderId: string + type: "session:end" + } +) + +export type LiveSessionWhiteboardMessagePayload = + LiveSessionWhiteboardMessage extends infer Message + ? Message extends LiveSessionWhiteboardMessage + ? Omit + : never + : never export interface LiveSessionChatMessage { id: string @@ -236,9 +259,11 @@ export interface LiveSessionState { toggleCamera: () => Promise toggleScreenShare: () => Promise sendWhiteboardMessage: ( - message: LiveSessionWhiteboardMessage, + message: LiveSessionWhiteboardMessagePayload, options?: { reliable?: boolean }, ) => Promise + clearWhiteboards: () => Promise + endSessionForEveryone: () => Promise sendChatMessage: (content: string) => Promise dismissNotice: (noticeId: string) => void disconnect: () => void diff --git a/features/session/session-screen.tsx b/features/session/session-screen.tsx index c8fe309..d0ab26d 100644 --- a/features/session/session-screen.tsx +++ b/features/session/session-screen.tsx @@ -135,23 +135,30 @@ function SessionNoticeStack({ } export function SessionScreen({ cls }: { cls: Class }) { - const { currentUser } = useApp() + const { classLiveSessions, currentUser } = useApp() const { activeClass, endSession, - hasJoinedSession, joinSession, leaveSession, liveSession, sessionActive, + whiteboardResetKey, } = usePersistentLiveSession() const [rightPanel, setRightPanel] = useState<"participants" | "chat" | null>( "participants", ) const isTeacher = currentUser.role === "teacher" + const canStartSession = currentUser.role !== "student" + const classHasLiveSession = classLiveSessions.some( + (session) => session.class_id === cls.id, + ) + const canJoinSession = canStartSession || classHasLiveSession const isThisClassSession = activeClass?.id === cls.id && sessionActive - const hasJoinedThisClass = activeClass?.id === cls.id && hasJoinedSession + const isTeacherPreviewSession = isTeacher && !isThisClassSession + const teacherReconnectAvailable = + isTeacherPreviewSession && classHasLiveSession const presentationStageRef = useRef(null) const [videoAspectRatio, setVideoAspectRatio] = useState() const [presentationStageSize, setPresentationStageSize] = useState<{ @@ -159,7 +166,7 @@ export function SessionScreen({ cls }: { cls: Class }) { height: number } | null>(null) const presentationSessionId = liveSession.presentation - ? `presentation:${liveSession.presentation.publication.trackSid}` + ? `presentation:${cls.id}` : null const presentationDimensions = liveSession.presentation?.publication.dimensions @@ -181,6 +188,7 @@ export function SessionScreen({ cls }: { cls: Class }) { participantCount, overlayActive: Boolean(liveSession.presentation), overlayAspectRatio: presentationAspectRatio, + resetKey: whiteboardResetKey, syncEnabled: connected, sendMessage: liveSession.sendWhiteboardMessage, }) @@ -198,7 +206,11 @@ export function SessionScreen({ cls }: { cls: Class }) { ? "Connecting" : liveSession.connectionState === ConnectionState.Connected ? "Live Session" - : "Offline" + : teacherReconnectAvailable + ? "Disconnected" + : isTeacherPreviewSession + ? "Not Live" + : "Offline" const micBusy = isBusyMediaState(liveSession.media.microphone.state) const cameraBusy = isBusyMediaState(liveSession.media.camera.state) const screenBusy = isBusyMediaState(liveSession.media.screen.state) @@ -270,26 +282,44 @@ export function SessionScreen({ cls }: { cls: Class }) { } }, [liveSession.presentation, presentationAspectRatio]) - if (!isThisClassSession) { - const title = hasJoinedThisClass ? "Session ended" : "Ready to join" - const description = hasJoinedThisClass - ? `You left the live session for ${cls.name}.` - : `Join the live session for ${cls.name} when you are ready.` - const buttonLabel = hasJoinedThisClass ? "Rejoin" : "Join session" + if (!isThisClassSession && !isTeacher) { + const title = classHasLiveSession + ? "Live session is open" + : "Waiting for teacher" + const description = classHasLiveSession + ? `Join the live session for ${cls.name}.` + : `The teacher has not started the live session for ${cls.name} yet.` + const buttonLabel = classHasLiveSession + ? "Join live session" + : "Not live yet" return ( -
- -

{title}

-

{description}

- +
+
+ + +
+

{title}

+

{description}

+
+ +
) } @@ -347,16 +377,27 @@ export function SessionScreen({ cls }: { cls: Class }) { /> ) : null} - - {isTeacher ? ( + {isTeacherPreviewSession ? ( + + ) : ( + + )} + {isTeacher && !isTeacherPreviewSession ? (
diff --git a/features/session/use-live-session.ts b/features/session/use-live-session.ts index ffdb8da..6b68d9e 100644 --- a/features/session/use-live-session.ts +++ b/features/session/use-live-session.ts @@ -21,6 +21,7 @@ import type { LiveSessionNotice, LiveSessionState, LiveSessionWhiteboardMessage, + LiveSessionWhiteboardMessagePayload, SessionParticipant, SessionPresentation, WhiteboardOperation, @@ -44,6 +45,7 @@ const CHAT_STORAGE_PREFIX = "eduverse:session-chat:v1" const MAX_CHAT_MESSAGES = 200 type MediaDeviceKind = "microphone" | "camera" | "screen" +type SessionTokenUser = Pick type LiveSessionError = | Error | string @@ -164,6 +166,10 @@ function isRole(value: string | undefined): value is Role { return value === "student" || value === "teacher" || value === "admin" } +function isConnectedRoom(room: Room | null): room is Room { + return room?.state === ConnectionState.Connected +} + function getInitials(name: string) { return name .split(" ") @@ -423,6 +429,7 @@ function isWhiteboardMessage( !isJsonObject(value) || typeof value.id !== "string" || typeof value.senderId !== "string" || + typeof value.liveSessionId !== "string" || typeof value.type !== "string" || (value.boardId !== undefined && typeof value.boardId !== "string") ) { @@ -430,11 +437,21 @@ function isWhiteboardMessage( } if (value.type === "state:request") { - return true + return ( + typeof value.requestId === "string" && + (value.requesterRole === undefined || + (typeof value.requesterRole === "string" && + isRole(value.requesterRole))) + ) + } + + if (value.type === "session:clear" || value.type === "session:end") { + return value.boardId === undefined } if ( value.type === "state:sync" && + (value.requestId === undefined || typeof value.requestId === "string") && typeof value.version === "number" && isWhiteboardOperationList(value.operations) ) { @@ -799,7 +816,11 @@ function mapParticipant( } satisfies SessionParticipant } -async function fetchSessionToken(classId: string, user: User) { +async function fetchSessionToken( + classId: string, + liveSessionId: string | null, + user: SessionTokenUser, +) { const response = await fetch("/api/livekit/token", { method: "POST", headers: { @@ -807,6 +828,7 @@ async function fetchSessionToken(classId: string, user: User) { }, body: JSON.stringify({ classId, + liveSessionId, user: { id: user.id, name: user.name, @@ -818,25 +840,41 @@ async function fetchSessionToken(classId: string, user: User) { const payload = (await response.json().catch(() => null)) as { error?: string + liveSessionId?: string participantToken?: string serverUrl?: string } | null - if (!response.ok || !payload?.participantToken || !payload.serverUrl) { + if ( + !response.ok || + !payload?.liveSessionId || + !payload.participantToken || + !payload.serverUrl + ) { throw new Error(payload?.error ?? "Unable to create a live session token.") } - return payload as { participantToken: string; serverUrl: string } + return payload as { + liveSessionId: string + participantToken: string + serverUrl: string + } } export function useLiveSession({ classId, currentUser, enabled, + liveSessionId, + onLiveSessionIdResolved, + onSessionEnded, }: { classId: string currentUser: User enabled: boolean + liveSessionId: string | null + onLiveSessionIdResolved?: (liveSessionId: string) => void + onSessionEnded?: () => void }): LiveSessionState { const roomRef = useRef(null) const [participants, setParticipants] = useState([]) @@ -852,6 +890,10 @@ export function useLiveSession({ const [whiteboardMessages, setWhiteboardMessages] = useState< LiveSessionWhiteboardMessage[] >([]) + const currentUserId = currentUser.id + const currentUserName = currentUser.name + const currentUserAvatar = currentUser.avatar + const currentUserRole = currentUser.role const upsertNotice = useCallback((notice: LiveSessionNotice) => { setNotices((prev) => @@ -876,6 +918,29 @@ export function useLiveSession({ [], ) + const disconnectRoom = useCallback((room: Room | null = roomRef.current) => { + if (!room) { + return + } + + if (roomRef.current === room) { + roomRef.current = null + } + + void room.disconnect().catch(() => {}) + }, []) + + const noticeMediaNotConnected = useCallback(() => { + upsertNotice({ + id: "media-not-connected", + scope: "session", + severity: "info", + title: "Session is reconnecting", + description: "Media controls are available after reconnection.", + nextStep: "Wait a moment, then try again.", + }) + }, [upsertNotice]) + useEffect(() => { if (!chatStorageKey) { return @@ -940,8 +1005,7 @@ export function useLiveSession({ useEffect(() => { if (!enabled) { - roomRef.current?.disconnect() - roomRef.current = null + disconnectRoom() setParticipants([]) setConnectionState(ConnectionState.Disconnected) setIsConnecting(false) @@ -996,6 +1060,17 @@ export function useLiveSession({ return } + if (parsed.liveSessionId !== liveSessionId) { + return + } + + if (parsed.type === "session:end") { + setWhiteboardMessages((prev) => [...prev.slice(-199), parsed]) + disconnectRoom(room) + onSessionEnded?.() + return + } + setWhiteboardMessages((prev) => [...prev.slice(-199), parsed]) } catch { upsertNotice({ @@ -1078,15 +1153,26 @@ export function useLiveSession({ const connect = async () => { try { - const { participantToken, serverUrl } = await fetchSessionToken( - classId, - currentUser, - ) + const { + liveSessionId: resolvedLiveSessionId, + participantToken, + serverUrl, + } = await fetchSessionToken(classId, liveSessionId, { + id: currentUserId, + name: currentUserName, + avatar: currentUserAvatar, + role: currentUserRole, + }) if (isCancelled) { return } + if (resolvedLiveSessionId !== liveSessionId) { + onLiveSessionIdResolved?.(resolvedLiveSessionId) + return + } + await room.connect(serverUrl, participantToken, ROOM_CONNECT_OPTIONS) if (isCancelled) { @@ -1102,13 +1188,13 @@ export function useLiveSession({ if (roomSid) { const nextChatStorageKey = getSessionChatStorageKey({ classId, - userId: currentUser.id, + userId: currentUserId, roomSid, }) pruneStoredSessionChats({ classId, - userId: currentUser.id, + userId: currentUserId, activeStorageKey: nextChatStorageKey, }) setChatStorageKey(nextChatStorageKey) @@ -1138,7 +1224,7 @@ export function useLiveSession({ setIsConnecting(false) setConnectionState(ConnectionState.Disconnected) setMedia(INITIAL_MEDIA_STATUS) - room.disconnect() + disconnectRoom(room) } } @@ -1160,15 +1246,19 @@ export function useLiveSession({ room.off(RoomEvent.DataReceived, handleDataReceived) room.off(RoomEvent.MediaDevicesError, handleMediaDevicesError) room.unregisterTextStreamHandler(CHAT_TOPIC) - room.disconnect() - if (roomRef.current === room) { - roomRef.current = null - } + disconnectRoom(room) } }, [ classId, - currentUser, + currentUserAvatar, + currentUserId, + currentUserName, + currentUserRole, + disconnectRoom, enabled, + liveSessionId, + onLiveSessionIdResolved, + onSessionEnded, syncParticipants, updateMediaDevice, upsertNotice, @@ -1176,17 +1266,19 @@ export function useLiveSession({ const sendWhiteboardMessage = useCallback( async ( - message: LiveSessionWhiteboardMessage, + message: LiveSessionWhiteboardMessagePayload, options?: { reliable?: boolean }, ) => { const room = roomRef.current - if (!room || room.state !== ConnectionState.Connected) { + if (!liveSessionId || !room || room.state !== ConnectionState.Connected) { return false } try { - const encoded = new TextEncoder().encode(JSON.stringify(message)) + const encoded = new TextEncoder().encode( + JSON.stringify({ ...message, liveSessionId }), + ) await room.localParticipant.publishData(encoded, { reliable: options?.reliable ?? true, topic: WHITEBOARD_TOPIC, @@ -1206,9 +1298,31 @@ export function useLiveSession({ return false } }, - [upsertNotice], + [liveSessionId, upsertNotice], ) + const clearWhiteboards = useCallback(() => { + return sendWhiteboardMessage( + { + id: createChatMessageId(currentUserId), + senderId: currentUserId, + type: "session:clear", + }, + { reliable: true }, + ) + }, [currentUserId, sendWhiteboardMessage]) + + const endSessionForEveryone = useCallback(() => { + return sendWhiteboardMessage( + { + id: createChatMessageId(currentUserId), + senderId: currentUserId, + type: "session:end", + }, + { reliable: true }, + ) + }, [currentUserId, sendWhiteboardMessage]) + const sendChatMessage = useCallback( async (content: string) => { const trimmed = content.trim() @@ -1286,7 +1400,8 @@ export function useLiveSession({ const toggleMic = useCallback(async () => { const room = roomRef.current - if (!room) { + if (!isConnectedRoom(room)) { + noticeMediaNotConnected() return } @@ -1326,12 +1441,18 @@ export function useLiveSession({ upsertNotice(notice) setError(notice.description) } - }, [syncParticipants, updateMediaDevice, upsertNotice]) + }, [ + noticeMediaNotConnected, + syncParticipants, + updateMediaDevice, + upsertNotice, + ]) const toggleCamera = useCallback(async () => { const room = roomRef.current - if (!room) { + if (!isConnectedRoom(room)) { + noticeMediaNotConnected() return } @@ -1368,12 +1489,18 @@ export function useLiveSession({ upsertNotice(notice) setError(notice.description) } - }, [syncParticipants, updateMediaDevice, upsertNotice]) + }, [ + noticeMediaNotConnected, + syncParticipants, + updateMediaDevice, + upsertNotice, + ]) const toggleScreenShare = useCallback(async () => { const room = roomRef.current - if (!room) { + if (!isConnectedRoom(room)) { + noticeMediaNotConnected() return } @@ -1417,11 +1544,15 @@ export function useLiveSession({ upsertNotice(notice) setError(notice.description) } - }, [syncParticipants, updateMediaDevice, upsertNotice]) + }, [ + noticeMediaNotConnected, + syncParticipants, + updateMediaDevice, + upsertNotice, + ]) const disconnect = useCallback(() => { - roomRef.current?.disconnect() - roomRef.current = null + disconnectRoom() setParticipants([]) setConnectionState(ConnectionState.Disconnected) setIsConnecting(false) @@ -1431,7 +1562,7 @@ export function useLiveSession({ setChatMessages([]) setChatStorageKey(null) setWhiteboardMessages([]) - }, []) + }, [disconnectRoom]) const localParticipant = participants.find( (participant) => participant.isLocal, @@ -1469,6 +1600,8 @@ export function useLiveSession({ toggleCamera, toggleScreenShare, sendWhiteboardMessage, + clearWhiteboards, + endSessionForEveryone, sendChatMessage, dismissNotice, disconnect, diff --git a/features/session/use-whiteboard.ts b/features/session/use-whiteboard.ts index 3c06401..3441ca7 100644 --- a/features/session/use-whiteboard.ts +++ b/features/session/use-whiteboard.ts @@ -6,6 +6,7 @@ import type { } from "react" import type { LiveSessionWhiteboardMessage, + LiveSessionWhiteboardMessagePayload, WhiteboardOperation, WhiteboardPoint, WhiteboardShape, @@ -39,7 +40,7 @@ type OperationBounds = { type OutgoingWhiteboardMessage = LiveSessionWhiteboardMessage extends infer Message ? Message extends LiveSessionWhiteboardMessage - ? Omit + ? Omit : never : never @@ -74,9 +75,10 @@ interface WhiteboardOptions { participantCount: number overlayActive: boolean overlayAspectRatio?: number + resetKey: number syncEnabled: boolean sendMessage: ( - message: LiveSessionWhiteboardMessage, + message: LiveSessionWhiteboardMessagePayload, options?: { reliable?: boolean }, ) => Promise } @@ -98,6 +100,16 @@ function createMessageId() { return `${Date.now()}-${Math.random().toString(36).slice(2)}` } +function getStateResponseDelay(userId: string) { + let hash = 0 + + for (let index = 0; index < userId.length; index += 1) { + hash = (hash * 31 + userId.charCodeAt(index)) % 997 + } + + return 120 + (hash % 600) +} + function createStrokeId() { return `stroke-${createMessageId()}` } @@ -135,6 +147,10 @@ function createEmptyBoardState(): BoardState { } function getMessageBoardId(message: LiveSessionWhiteboardMessage) { + if (message.type === "session:clear" || message.type === "session:end") { + return REGULAR_WHITEBOARD_BOARD_ID + } + return message.boardId ?? REGULAR_WHITEBOARD_BOARD_ID } @@ -726,6 +742,7 @@ export function useWhiteboard({ participantCount, overlayActive, overlayAspectRatio, + resetKey, syncEnabled, sendMessage, }: WhiteboardOptions): WhiteboardState { @@ -753,11 +770,21 @@ export function useWhiteboard({ const boardStates = useRef(new Map()) const activeBoardId = useRef(boardId) const lastParticipantCount = useRef(participantCount) + const lastResetKey = useRef(resetKey) const stateRequested = useRef(new Set()) + const stateRequestIds = useRef(new Map()) + const stateResponseTimers = useRef(new Set()) const operations = useRef([]) const redoOperations = useRef([]) const boardVersion = useRef(0) + const clearStateResponseTimers = useCallback(() => { + stateResponseTimers.current.forEach((timerId) => { + window.clearTimeout(timerId) + }) + stateResponseTimers.current.clear() + }, []) + const getContext = useCallback(() => { const canvas = canvasRef.current const ctx = canvas?.getContext("2d") @@ -880,23 +907,27 @@ export function useWhiteboard({ senderId: currentUserId, boardId, ...message, - } as LiveSessionWhiteboardMessage, + } as LiveSessionWhiteboardMessagePayload, options, ) }, [boardId, currentUserId, sendMessage, syncEnabled], ) - const sendStateSync = useCallback(() => { - sendWhiteboardMessage( - { - type: "state:sync", - version: boardVersion.current, - operations: getStateSyncOperations(operations.current), - }, - { reliable: true }, - ) - }, [sendWhiteboardMessage]) + const sendStateSync = useCallback( + (requestId?: string) => { + sendWhiteboardMessage( + { + type: "state:sync", + ...(requestId ? { requestId } : {}), + version: boardVersion.current, + operations: getStateSyncOperations(operations.current), + }, + { reliable: true }, + ) + }, + [sendWhiteboardMessage], + ) const commitOperation = useCallback((operation: WhiteboardOperation) => { operations.current = [...operations.current, operation] @@ -1158,6 +1189,31 @@ export function useWhiteboard({ pendingStrokePoints.current = [] }, []) + const clearAllBoards = useCallback(() => { + boardStates.current.clear() + operations.current = [] + redoOperations.current = [] + boardVersion.current = 0 + selectedOperationIds.current = new Set() + remoteStrokes.current.clear() + stateRequested.current.clear() + stateRequestIds.current.clear() + clearStateResponseTimers() + setHasSelection(false) + setRedoCount(0) + resetDrawingState() + resetBoard() + }, [clearStateResponseTimers, resetBoard, resetDrawingState]) + + useEffect(() => { + if (lastResetKey.current === resetKey) { + return + } + + lastResetKey.current = resetKey + clearAllBoards() + }, [clearAllBoards, resetKey]) + const handlePointerDown = (event: ReactPointerEvent) => { if ( !isTeacher || @@ -1680,20 +1736,39 @@ export function useWhiteboard({ if (strokeFlushFrame.current !== null) { cancelAnimationFrame(strokeFlushFrame.current) } + clearStateResponseTimers() } - }, []) + }, [clearStateResponseTimers]) useEffect(() => { if (!syncEnabled) { stateRequested.current.clear() + stateRequestIds.current.clear() + clearStateResponseTimers() return } - if (!isTeacher && !stateRequested.current.has(boardId)) { + if (!stateRequested.current.has(boardId)) { + const requestId = createMessageId() + stateRequested.current.add(boardId) - sendWhiteboardMessage({ type: "state:request" }, { reliable: true }) + stateRequestIds.current.set(boardId, requestId) + sendWhiteboardMessage( + { + type: "state:request", + requestId, + requesterRole: isTeacher ? "teacher" : "student", + }, + { reliable: true }, + ) } - }, [boardId, isTeacher, sendWhiteboardMessage, syncEnabled]) + }, [ + boardId, + clearStateResponseTimers, + isTeacher, + sendWhiteboardMessage, + syncEnabled, + ]) useEffect(() => { if (!isTeacher || !syncEnabled) { @@ -1723,24 +1798,63 @@ export function useWhiteboard({ continue } + processedMessageIds.current.add(message.id) + + if (message.type === "session:clear" || message.type === "session:end") { + clearAllBoards() + continue + } + if (getMessageBoardId(message) !== boardId) { continue } - processedMessageIds.current.add(message.id) + if (message.type === "state:request") { + const hasBoardState = + operations.current.length > 0 || boardVersion.current > 0 - if (isTeacher) { - if (message.type === "state:request") { - sendStateSync() + if (!hasBoardState) { + continue + } + + if (isTeacher) { + sendStateSync(message.requestId) + continue } + + if (message.requesterRole === "teacher") { + const timerId = window.setTimeout(() => { + stateResponseTimers.current.delete(timerId) + sendStateSync(message.requestId) + }, getStateResponseDelay(currentUserId)) + + stateResponseTimers.current.add(timerId) + } + continue } if (message.type === "state:sync") { - if (message.version < boardVersion.current) { + const currentRequestId = stateRequestIds.current.get(boardId) + const isRequestedSync = Boolean( + message.requestId && message.requestId === currentRequestId, + ) + + if (message.requestId && !isRequestedSync) { + continue + } + + if ( + message.version < boardVersion.current || + (!isRequestedSync && message.version === boardVersion.current) + ) { continue } + if (isRequestedSync) { + stateRequestIds.current.delete(boardId) + } + boardVersion.current = message.version operations.current = message.operations redoOperations.current = [] @@ -1881,6 +1995,7 @@ export function useWhiteboard({ } }, [ boardId, + clearAllBoards, currentUserId, getContext, incomingMessages, diff --git a/lib/store.tsx b/lib/store.tsx index 13ce12e..5c2f14a 100644 --- a/lib/store.tsx +++ b/lib/store.tsx @@ -70,6 +70,7 @@ export type ClassLiveSessionRow = { organization_id: string class_id: string room_name: string + live_session_id: string started_by_user_id: string status: "live" | "ended" started_at: string @@ -854,7 +855,7 @@ async function loadClassLiveSessions(organizationId: string) { const { data, error } = await supabase .from("class_live_sessions") .select( - "id, organization_id, class_id, room_name, started_by_user_id, status, started_at, last_seen_at, ended_at", + "id, organization_id, class_id, room_name, live_session_id, started_by_user_id, status, started_at, last_seen_at, ended_at", ) .eq("organization_id", organizationId) .eq("status", "live") diff --git a/supabase/migrations/20260505120000_add_live_session_generation.sql b/supabase/migrations/20260505120000_add_live_session_generation.sql new file mode 100644 index 0000000..d4ff9b1 --- /dev/null +++ b/supabase/migrations/20260505120000_add_live_session_generation.sql @@ -0,0 +1,5 @@ +alter table public.class_live_sessions + add column if not exists live_session_id uuid not null default gen_random_uuid(); + +create index if not exists idx_class_live_sessions_live_session_id + on public.class_live_sessions (live_session_id); diff --git a/supabase/migrations/20260505121000_claim_class_live_session.sql b/supabase/migrations/20260505121000_claim_class_live_session.sql new file mode 100644 index 0000000..ef3115e --- /dev/null +++ b/supabase/migrations/20260505121000_claim_class_live_session.sql @@ -0,0 +1,99 @@ +create or replace function public.claim_class_live_session( + target_org_id uuid, + target_class_id uuid, + target_room_name text, + stale_before timestamptz +) +returns table (live_session_id uuid) +language plpgsql +security definer +set search_path = public +as $$ +declare + current_session public.class_live_sessions%rowtype; + next_live_session_id uuid; +begin + if not public.can_manage_class(target_org_id, target_class_id) then + raise exception 'Only class managers can start live sessions.'; + end if; + + loop + select * + into current_session + from public.class_live_sessions + where class_id = target_class_id + for update; + + if found then + if + current_session.status = 'live' + and current_session.ended_at is null + and current_session.last_seen_at > stale_before + then + update public.class_live_sessions + set room_name = target_room_name, + started_by_user_id = auth.uid(), + last_seen_at = now(), + ended_at = null + where id = current_session.id + returning public.class_live_sessions.live_session_id + into live_session_id; + + return next; + end if; + + next_live_session_id := gen_random_uuid(); + + update public.class_live_sessions + set room_name = target_room_name, + live_session_id = next_live_session_id, + started_by_user_id = auth.uid(), + status = 'live', + started_at = now(), + last_seen_at = now(), + ended_at = null + where id = current_session.id + returning public.class_live_sessions.live_session_id + into live_session_id; + + return next; + end if; + + begin + next_live_session_id := gen_random_uuid(); + + insert into public.class_live_sessions ( + organization_id, + class_id, + room_name, + live_session_id, + started_by_user_id, + status, + started_at, + last_seen_at, + ended_at + ) + values ( + target_org_id, + target_class_id, + target_room_name, + next_live_session_id, + auth.uid(), + 'live', + now(), + now(), + null + ) + returning public.class_live_sessions.live_session_id + into live_session_id; + + return next; + exception + when unique_violation then + end; + end loop; +end; +$$; + +grant execute on function public.claim_class_live_session(uuid, uuid, text, timestamptz) + to authenticated; diff --git a/supabase/migrations/20260505122000_harden_live_session_claim.sql b/supabase/migrations/20260505122000_harden_live_session_claim.sql new file mode 100644 index 0000000..31b1447 --- /dev/null +++ b/supabase/migrations/20260505122000_harden_live_session_claim.sql @@ -0,0 +1,115 @@ +alter table public.class_live_sessions + drop constraint if exists class_live_sessions_status_valid; + +alter table public.class_live_sessions + add constraint class_live_sessions_status_valid + check (status in ('pending', 'live', 'ended')); + +create or replace function public.claim_class_live_session( + target_org_id uuid, + target_class_id uuid, + target_room_name text, + stale_before timestamptz +) +returns table (live_session_id uuid) +language plpgsql +security definer +set search_path = public +as $$ +declare + class_org_id uuid; + current_session public.class_live_sessions%rowtype; + next_live_session_id uuid; +begin + select organization_id + into class_org_id + from public.classes + where id = target_class_id + and organization_id = target_org_id + and is_archived = false; + + if class_org_id is null then + raise exception 'Class not found.'; + end if; + + if not public.can_manage_class(class_org_id, target_class_id) then + raise exception 'Only class managers can start live sessions.'; + end if; + + loop + select * + into current_session + from public.class_live_sessions + where class_id = target_class_id + for update; + + if found then + if + current_session.status in ('pending', 'live') + and current_session.ended_at is null + and current_session.last_seen_at > stale_before + then + update public.class_live_sessions + set room_name = target_room_name, + started_by_user_id = auth.uid(), + last_seen_at = now(), + ended_at = null + where id = current_session.id + returning public.class_live_sessions.live_session_id + into live_session_id; + + return next; + end if; + + next_live_session_id := gen_random_uuid(); + + update public.class_live_sessions + set room_name = target_room_name, + live_session_id = next_live_session_id, + started_by_user_id = auth.uid(), + status = 'pending', + started_at = now(), + last_seen_at = now(), + ended_at = null + where id = current_session.id + returning public.class_live_sessions.live_session_id + into live_session_id; + + return next; + end if; + + begin + next_live_session_id := gen_random_uuid(); + + insert into public.class_live_sessions ( + organization_id, + class_id, + room_name, + live_session_id, + started_by_user_id, + status, + started_at, + last_seen_at, + ended_at + ) + values ( + class_org_id, + target_class_id, + target_room_name, + next_live_session_id, + auth.uid(), + 'pending', + now(), + now(), + null + ) + returning public.class_live_sessions.live_session_id + into live_session_id; + + return next; + exception + when unique_violation then + end; + end loop; +end; +$$; diff --git a/supabase/migrations/20260505123000_restrict_claim_live_session_execute.sql b/supabase/migrations/20260505123000_restrict_claim_live_session_execute.sql new file mode 100644 index 0000000..e2ac793 --- /dev/null +++ b/supabase/migrations/20260505123000_restrict_claim_live_session_execute.sql @@ -0,0 +1,5 @@ +revoke all on function public.claim_class_live_session(uuid, uuid, text, timestamptz) + from public; + +grant execute on function public.claim_class_live_session(uuid, uuid, text, timestamptz) + to authenticated; diff --git a/supabase/migrations/20260505124000_fix_claim_live_session_return.sql b/supabase/migrations/20260505124000_fix_claim_live_session_return.sql new file mode 100644 index 0000000..8181896 --- /dev/null +++ b/supabase/migrations/20260505124000_fix_claim_live_session_return.sql @@ -0,0 +1,117 @@ +create or replace function public.claim_class_live_session( + target_org_id uuid, + target_class_id uuid, + target_room_name text, + stale_before timestamptz +) +returns table (live_session_id uuid) +language plpgsql +security definer +set search_path = public +as $$ +declare + class_org_id uuid; + current_session public.class_live_sessions%rowtype; + next_live_session_id uuid; +begin + select organization_id + into class_org_id + from public.classes + where id = target_class_id + and organization_id = target_org_id + and is_archived = false; + + if class_org_id is null then + raise exception 'Class not found.'; + end if; + + if not public.can_manage_class(class_org_id, target_class_id) then + raise exception 'Only class managers can start live sessions.'; + end if; + + loop + select * + into current_session + from public.class_live_sessions + where class_id = target_class_id + for update; + + if found then + if + current_session.status in ('pending', 'live') + and current_session.ended_at is null + and current_session.last_seen_at > stale_before + then + update public.class_live_sessions + set room_name = target_room_name, + started_by_user_id = auth.uid(), + last_seen_at = now(), + ended_at = null + where id = current_session.id + returning public.class_live_sessions.live_session_id + into live_session_id; + + return next; + return; + end if; + + next_live_session_id := gen_random_uuid(); + + update public.class_live_sessions + set room_name = target_room_name, + live_session_id = next_live_session_id, + started_by_user_id = auth.uid(), + status = 'pending', + started_at = now(), + last_seen_at = now(), + ended_at = null + where id = current_session.id + returning public.class_live_sessions.live_session_id + into live_session_id; + + return next; + return; + end if; + + begin + next_live_session_id := gen_random_uuid(); + + insert into public.class_live_sessions ( + organization_id, + class_id, + room_name, + live_session_id, + started_by_user_id, + status, + started_at, + last_seen_at, + ended_at + ) + values ( + class_org_id, + target_class_id, + target_room_name, + next_live_session_id, + auth.uid(), + 'pending', + now(), + now(), + null + ) + returning public.class_live_sessions.live_session_id + into live_session_id; + + return next; + return; + exception + when unique_violation then + end; + end loop; +end; +$$; + +revoke all on function public.claim_class_live_session(uuid, uuid, text, timestamptz) + from public; + +grant execute on function public.claim_class_live_session(uuid, uuid, text, timestamptz) + to authenticated;