diff --git a/app/api/getResponseCounts/route.ts b/app/api/getResponseCounts/route.ts new file mode 100644 index 0000000..c7bd8e0 --- /dev/null +++ b/app/api/getResponseCounts/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; +import prisma from "@/lib/prisma"; + +export async function GET(request: NextRequest) { + try { + // Authenticate the request + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const questionId = searchParams.get("questionId"); + if (!questionId || isNaN(Number(questionId))) { + return NextResponse.json( + { error: "Invalid or missing questionId parameter" }, + { status: 400 }, + ); + } + + const groups = await prisma.response.groupBy({ + by: ["optionId"], + where: { questionId: Number(questionId) }, + _count: { optionId: true }, + }); + const optionCounts = groups.reduce>((acc, g) => { + acc[g.optionId] = g._count.optionId; + return acc; + }, {}); + const total = Object.values(optionCounts).reduce((sum, c) => sum + c, 0); + return NextResponse.json({ optionCounts, responseCount: total }); + } catch (error) { + console.error("Error fetching response counts:", error); + return NextResponse.json( + { error: "An error occurred while fetching response counts" }, + { status: 500 }, + ); + } +} diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 4700d7f..bf229dd 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -3,6 +3,7 @@ import { QuestionType } from "@prisma/client"; import type { Question } from "@prisma/client"; import { EyeOff, PauseCircleIcon, PlayCircleIcon } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useQuery } from "react-query"; import { Bar, BarChart, LabelList, ResponsiveContainer, XAxis, YAxis } from "recharts"; @@ -10,6 +11,7 @@ import { LetteredYAxisTick } from "@/components/YAxisTick"; import BackButton from "@/components/ui/backButton"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; + import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ChartConfig, @@ -26,10 +28,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { usePollSocket } from "@/hooks/use-poll-socket"; import { useToast } from "@/hooks/use-toast"; import { DEFAULT_SHOW_RESULTS } from "@/lib/constants"; import { addWildcardQuestion } from "@/lib/server-utils"; import { formatDateToISO, shuffleArray } from "@/lib/utils"; +import type { StartSessionWebSocketMessage } from "@/lib/websocket"; import { CourseSessionData, QuestionData } from "@/models/CourseSession"; import { endCourseSession, pauseOrResumeCourseSession } from "@/services/courseSession"; import { @@ -38,6 +42,11 @@ import { getQuestionsForSession, } from "@/services/session"; +interface ResponseCountsData { + optionCounts?: Record; + responseCount?: number; +} + export default function StartSession() { const params = useParams(); const router = useRouter(); @@ -50,27 +59,79 @@ export default function StartSession() { const [isAddingQuestion, setIsAddingQuestion] = useState(false); const [isEndingSession, setIsEndingSession] = useState(false); const [isPaused, setIsPaused] = useState(false); + const [isChangingQuestion, setIsChangingQuestion] = useState(false); const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); - const [isChangingQuestion, setIsChangingQuestion] = useState(false); // New state for question navigation + const [allResponseCounts, setAllResponseCounts] = useState< + Record> + >({}); + const [_totalResponses, setTotalResponses] = useState(0); + const sessionData = useSession(); + const [_isConnected, setIsConnected] = useState(false); + + const handleWebSocketMessage = useCallback((data: StartSessionWebSocketMessage) => { + console.log("Received WebSocket message:", data); + + if (data.type === "response_update") { + console.log("Updating response counts:", data.optionCounts); + if (data.optionCounts && data.questionId !== undefined && data.questionId !== null) { + setAllResponseCounts( + (prev: Record>) => + ({ + ...prev, + [String(data.questionId)]: data.optionCounts ?? {}, + }) as Record>, + ); + } + if (data.responseCount) { + setTotalResponses(data.responseCount); + } + } + }, []); + + const handleWebSocketConnect = useCallback(() => { + console.log("WebSocket connection established"); + setIsConnected(true); + }, []); + + const handleWebSocketDisconnect = useCallback(() => { + console.log("WebSocket connection closed"); + setIsConnected(false); + }, []); + + // Setup WebSocket connection using the custom hook + const wsRef = usePollSocket({ + courseSessionId: courseSession?.id ?? 0, + userId: sessionData.data?.user?.id ?? "", + onMessage: handleWebSocketMessage, + onConnect: handleWebSocketConnect, + onDisconnect: handleWebSocketDisconnect, + }); useEffect(() => { async function fetchSessionData() { - const session = await getCourseSessionByDate(courseId, utcDate); - if (session) { - setCourseSession({ id: session.id, activeQuestionId: session.activeQuestionId }); - if (session.activeQuestionId !== null) { - setActiveQuestionId(session.activeQuestionId); + const sessionResult = await getCourseSessionByDate(courseId, utcDate); + if (sessionResult) { + setCourseSession({ + id: sessionResult.id, + activeQuestionId: sessionResult.activeQuestionId, + }); + if (sessionResult.activeQuestionId !== null) { + setActiveQuestionId(sessionResult.activeQuestionId); } - if (session.paused) setIsPaused(session.paused); } else { toast({ description: "No session found" }); - // subject to change (just put this for now goes to 404 maybe it should go to /dashboard?) router.push(`/dashboard/course/${courseId}/questionnaire`); } } void fetchSessionData(); }, [courseId, utcDate, router, toast]); + const { data: questionData } = useQuery( + ["question", activeQuestionId], + () => (activeQuestionId ? getQuestionById(activeQuestionId) : Promise.resolve(null)), + { enabled: !!activeQuestionId }, + ); + // fetch session questions const { data: questions, @@ -94,80 +155,86 @@ export default function StartSession() { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ activeQuestionId: questions[0].id }), - }).catch((err: unknown) => { - console.error("Error updating active question:", err); + }).catch((error: unknown) => { + console.error("Error updating active question:", error); }); } } }, [questions, activeQuestionId, courseSession]); - // retrieve details of the active question - const { data: questionData } = useQuery( - ["question", activeQuestionId], - () => (activeQuestionId ? getQuestionById(activeQuestionId) : Promise.resolve(null)), - { refetchInterval: 2000, enabled: !!activeQuestionId }, - ); - const totalQuestions = questions?.length ?? 0; const activeIndex = questions ? questions.findIndex((q) => q.id === activeQuestionId) : -1; const isLastQuestion = activeIndex === totalQuestions - 1; - const shuffledOptions = useMemo(() => { - return questionData ? shuffleArray(questionData.options) : []; - }, [activeQuestionId, questionData?.options]); + // Update chart data to use WebSocket updates + const shuffledOptions = useMemo( + () => (questionData ? shuffleArray(questionData.options) : []), + [activeQuestionId, questionData?.options], + ); - const chartData = questionData - ? shuffledOptions.map((option) => ({ - option: option.text, - Votes: questionData.responses.filter((resp) => resp.optionId === option.id).length, - })) - : []; + const chartData = shuffledOptions.map((option) => ({ + option: option.text, + Votes: (activeQuestionId && allResponseCounts[String(activeQuestionId)]?.[option.id]) ?? 0, + })); - // Create a reusable function for updating the active question - const updateActiveQuestion = useCallback( - async (questionId: number, sessionId: string) => { + const totalVotes = chartData.reduce((sum, item) => sum + item.Votes, 0); + + const handleNextQuestion = useCallback(async () => { + if (questions && activeIndex !== -1 && activeIndex < totalQuestions - 1 && courseSession) { + setIsChangingQuestion(true); + const nextQuestionID = questions[activeIndex + 1].id; + setActiveQuestionId(nextQuestionID); try { - const response = await fetch(`/api/session/${sessionId}/activeQuestion`, { + const response = await fetch(`/api/session/${courseSession.id}/activeQuestion`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ activeQuestionId: questionId }), + body: JSON.stringify({ activeQuestionId: nextQuestionID }), }); - - if (!response.ok) { - toast({ variant: "destructive", description: "Error updating question" }); - console.error("Failed to update active question in DB", response); - return false; + if (response.ok && wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "active_question_update", + questionId: nextQuestionID, + courseSessionId: courseSession.id, + }), + ); + console.log("Sent active_question_update via WebSocket (next)"); } - return true; - } catch (err: unknown) { + } catch { toast({ variant: "destructive", description: "Error updating question" }); - console.error("Error updating active question:", err); - return false; } - }, - [toast], - ); - - const handleNextQuestion = useCallback(async () => { - if (questions && activeIndex !== -1 && activeIndex < totalQuestions - 1 && courseSession) { - setIsChangingQuestion(true); - const nextQuestionID = questions[activeIndex + 1].id; - setActiveQuestionId(nextQuestionID); - await updateActiveQuestion(nextQuestionID, String(courseSession.id)); setIsChangingQuestion(false); } - }, [activeIndex, questions, totalQuestions, courseSession]); + }, [activeIndex, questions, totalQuestions, courseSession, toast, wsRef]); const handlePreviousQuestion = useCallback(async () => { if (questions && activeIndex > 0 && courseSession) { setIsChangingQuestion(true); const prevQuestionID = questions[activeIndex - 1].id; setActiveQuestionId(prevQuestionID); - await updateActiveQuestion(prevQuestionID, String(courseSession.id)); + try { + const response = await fetch(`/api/session/${courseSession.id}/activeQuestion`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activeQuestionId: prevQuestionID }), + }); + if (response.ok && wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "active_question_update", + questionId: prevQuestionID, + courseSessionId: courseSession.id, + }), + ); + console.log("Sent active_question_update via WebSocket (prev)"); + } + } catch { + toast({ variant: "destructive", description: "Error updating question" }); + } setIsChangingQuestion(false); } - }, [activeIndex, questions, courseSession]); + }, [activeIndex, questions, courseSession, toast, wsRef]); const handleQuestionSelect = useCallback( async (questionId: string) => { @@ -175,11 +242,32 @@ export default function StartSession() { setIsChangingQuestion(true); const selectedQuestionId = parseInt(questionId); setActiveQuestionId(selectedQuestionId); - await updateActiveQuestion(selectedQuestionId, String(courseSession.id)); + try { + const response = await fetch( + `/api/session/${courseSession.id}/activeQuestion`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activeQuestionId: selectedQuestionId }), + }, + ); + if (response.ok && wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "active_question_update", + questionId: selectedQuestionId, + courseSessionId: courseSession.id, + }), + ); + console.log("Sent active_question_update via WebSocket (select)"); + } + } catch { + toast({ variant: "destructive", description: "Error updating question" }); + } setIsChangingQuestion(false); } }, - [courseSession], + [courseSession, toast, wsRef], ); const handleAddWildcard = useCallback( @@ -226,6 +314,9 @@ export default function StartSession() { setIsPaused(pauseState); try { await pauseOrResumeCourseSession(courseSession.id, pauseState); + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "poll_paused", paused: pauseState })); + } } catch (error) { toast({ variant: "destructive", @@ -234,7 +325,7 @@ export default function StartSession() { console.error(error); } }, - [courseSession, toast], + [courseSession, toast, wsRef], ); const chartConfig: ChartConfig = { @@ -244,12 +335,40 @@ export default function StartSession() { }, }; + // Updates chart if professor window refreshes + useEffect(() => { + if (!activeQuestionId) return; + + fetch(`/api/getResponseCounts?questionId=${activeQuestionId}`) + .then((res) => res.json()) + .then((data: ResponseCountsData) => { + if ( + data.optionCounts && + typeof activeQuestionId === "number" && + !isNaN(activeQuestionId) + ) { + setAllResponseCounts( + (prev) => + ({ + ...prev, + [String(activeQuestionId)]: data.optionCounts ?? {}, + }) as Record>, + ); + } + if (data.responseCount) { + setTotalResponses(data.responseCount); + } + }) + .catch((error: unknown) => { + console.error("Failed to fetch response counts:", error); + }); + }, [activeQuestionId]); + if (!courseSession || questionsLoading) { return ; } const activeQuestion = questions ? questions.find((q) => q.id === activeQuestionId) : null; - const totalVotes = chartData.reduce((sum, item) => sum + item.Votes, 0); return (
@@ -283,51 +402,57 @@ export default function StartSession() { > {showResults ? ( - - - } - tickLine={false} - axisLine={false} - tickMargin={8} - style={{ fill: "#000" }} - /> - } - /> - 0 ? ( + - + } + tickLine={false} + axisLine={false} + tickMargin={8} + style={{ fill: "#000" }} + /> + } + /> + { - if (!totalVotes || !value) return "0%"; - const percent = (value / totalVotes) * 100; - return `${percent.toFixed(1)}%`; + fill="#F3AB7E" + barSize={30} + radius={[5, 5, 5, 5]} + background={{ + fill: "#fff", + stroke: "#959595", + strokeWidth: 0.5, + radius: 5, }} - style={{ fill: "#000", fontSize: 12 }} - /> - - + > + { + if (!totalVotes || !value) return "0%"; + const percent = (value / totalVotes) * 100; + return `${percent.toFixed(1)}%`; + }} + style={{ fill: "#000", fontSize: 12 }} + /> + + + ) : ( +
+ No responses yet +
+ ) ) : (
@@ -385,7 +510,7 @@ export default function StartSession() {
) : (
@@ -178,39 +332,55 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number } // Handle answer selection (works for both MCQ and MSQ) - const handleSelectionChange = (value: number | number[]) => { + const handleSelectionChange = (value: number | number[]): void => { setSelectedValues(value); }; - const handleSubmit = async () => { + const handleSubmit = (): void => { if ( !selectedValues || (Array.isArray(selectedValues) && selectedValues.length === 0) || - !currentQuestion + !currentQuestion || + !wsRef.current || + wsRef.current.readyState !== WebSocket.OPEN ) { return; } - const optionIds = Array.isArray(selectedValues) ? selectedValues : [selectedValues]; try { + // Set submitting to true BEFORE we do anything else setSubmitting(true); - const response = await fetch("/api/submitStudentResponse", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - questionId: currentQuestion.id, - optionIds, - }), - }); - - if (!response.ok) { - console.error("Failed to save answer"); - } - } catch (submitError) { - console.error("Error saving answer:", submitError); - } finally { + + // Extract option IDs + const optionIds = Array.isArray(selectedValues) ? selectedValues : [selectedValues]; + + // Create message payload + const message: StudentResponseMessage = { + type: "student_response", + questionId: currentQuestion.id, + optionIds, + }; + + // Send through WebSocket + wsRef.current.send(JSON.stringify(message)); + + // Add to local messages list + setMessages((prev) => [...prev, `Sent: ${JSON.stringify(message)}`]); + + // Fallback timer in case WebSocket response is never received + setTimeout(() => { + if (submitting) { + setSubmitting(false); + toast({ + variant: "destructive", + description: "Response may not have been saved. Please try again.", + }); + } + }, 5000); + } catch (submitError: unknown) { + const errorStr = submitError instanceof Error ? submitError.message : "Unknown error"; + console.error("Error submitting answer:", errorStr); + toast({ variant: "destructive", description: "Failed to submit answer" }); setSubmitting(false); } }; @@ -223,6 +393,16 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number + {/* Connection status */} +
+
+
+ {isConnected ? "Connected" : "Disconnected"} +
+
+ {/* Question header and count */}
@@ -233,14 +413,6 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number
- {/* Loading indicator for refreshing questions */} - {/* {loading && currentQuestion && ( -
-
- Syncing... -
- )} */} - {/* Question Card */}
@@ -256,36 +428,37 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number {/* Submit Button */} - {/* Submission Status */} - {submitting && ( -

Saving your answer...

+ {/* Submission Status - crucial for visual feedback */} + {submitting &&

Submitting...

} + + {isPaused && ( +

Poll is currently paused.

)} {/* Footer Message */} - {isPaused !== undefined && isPaused !== null && isPaused ? ( -

- The poll is currently paused. -

- ) : ( -

- Instructor will start the next question shortly... -

- )} -

+

+ Instructor will start the next question shortly... +

); diff --git a/hooks/use-poll-socket.ts b/hooks/use-poll-socket.ts new file mode 100644 index 0000000..e0b15bf --- /dev/null +++ b/hooks/use-poll-socket.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef } from "react"; +import type { WebSocketMessage } from "@/lib/websocket"; + +interface UsePollSocketProps { + courseSessionId: number; + userId: string; + onMessage: (data: WebSocketMessage) => void; + onConnect?: () => void; + onDisconnect?: () => void; +} + +export function usePollSocket({ + courseSessionId, + userId, + onMessage, + onConnect = () => { + console.log("New WebSocket connected"); + }, + onDisconnect = () => { + console.log("New WebSocket disconnected"); + }, +}: UsePollSocketProps) { + const wsRef = useRef(null); + + useEffect(() => { + if (!courseSessionId || !userId) return; + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket( + `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSessionId}&userId=${userId}`, + ); + wsRef.current = ws; + + ws.onopen = () => { + console.log("WebSocket connection established"); + onConnect(); + }; + + ws.onmessage = (event) => { + try { + if (typeof event.data === "string") { + const data = JSON.parse(event.data) as WebSocketMessage; + onMessage(data); + } + } catch (err) { + console.error("Error processing message:", err); + } + }; + + ws.onclose = () => { + console.log("WebSocket connection closed"); + onDisconnect(); + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + }; + + return () => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.close(); + } + }; + }, [courseSessionId, userId, onConnect, onDisconnect, onMessage]); + + return wsRef; +} diff --git a/lib/prisma.ts b/lib/prisma.ts index 82ee860..7b773a8 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -2,6 +2,11 @@ import { PrismaClient } from "@prisma/client"; let prisma: PrismaClient; +declare global { + // eslint-disable-next-line no-var + var prisma: PrismaClient | undefined; +} + if (process.env.NODE_ENV === "production") { prisma = new PrismaClient(); } else { diff --git a/lib/websocket.ts b/lib/websocket.ts new file mode 100644 index 0000000..a321e93 --- /dev/null +++ b/lib/websocket.ts @@ -0,0 +1,342 @@ +import { Server as HttpServer, IncomingMessage } from "http"; +import { WebSocket, WebSocketServer } from "ws"; +import prisma from "./prisma"; + +// connection parameters +type ConnectionParams = { + sessionId?: string; + userId?: string; +}; + +// Define proper types for WebSocket messages +export type WebSocketMessageType = + | "connected" + | "response_saved" + | "question_changed" + | "response_update" + | "error" + | "echo" + | "binary" + | "student_response" + | "poll_paused" + | "active_question_update"; + +export interface WebSocketMessageBase { + type: WebSocketMessageType; + message?: string; +} + +export interface QuestionChangedMessage extends WebSocketMessageBase { + type: "question_changed"; + questionId: number; +} + +export interface ResponseSavedMessage extends WebSocketMessageBase { + type: "response_saved"; + message?: string; + data?: { + questionId?: number; + optionIds?: number[]; + originalMessage?: string; + }; +} + +export interface StudentResponseMessage extends WebSocketMessageBase { + type: "student_response"; + questionId: number; + optionIds: number[]; +} + +export interface ResponseUpdateMessage extends WebSocketMessageBase { + type: "response_update"; + questionId: number; + responseCount: number; + optionCounts: Record; +} + +export interface PollPausedMessage extends WebSocketMessageBase { + type: "poll_paused"; + paused: boolean; +} + +export interface ConnectedMessage extends WebSocketMessageBase { + type: "connected"; +} + +export interface ErrorMessage extends WebSocketMessageBase { + type: "error"; +} + +export interface ActiveQuestionUpdateMessage extends WebSocketMessageBase { + type: "active_question_update"; + questionId: number; + courseSessionId?: number; +} + +// Union type for all message types +export type WebSocketMessage = + | QuestionChangedMessage + | ResponseSavedMessage + | StudentResponseMessage + | PollPausedMessage + | ActiveQuestionUpdateMessage + | ConnectedMessage + | ErrorMessage + | WebSocketMessageBase; + +export type StartSessionWebSocketMessage = { + type: string; + questionId?: number; + optionCounts?: Record; + responseCount?: number; +}; + +type AuthenticatedConnection = { + userId: string; + sessionId: string; + ws: WebSocket; +}; + +const connections = new Map>(); + +async function validateUserSession(userId: string, sessionId: string): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + courses: { + where: { + course: { + sessions: { + some: { id: parseInt(sessionId) }, + }, + }, + }, + }, + }, + }); + return !!user && user.courses.length > 0; + } catch (err) { + console.error("Error validating user session:", err); + return false; + } +} + +function broadcastToSession(sessionId: string, message: WebSocketMessage): void { + const sessConns = connections.get(sessionId); + if (!sessConns) return; + + const msgStr = JSON.stringify(message); + for (const { ws } of sessConns.values()) { + try { + ws.send(msgStr); + } catch (err) { + console.error("Error broadcasting to client:", err); + } + } +} + +// Add type guard functions +function isStudentResponseMessage(data: unknown): data is StudentResponseMessage { + return ( + typeof data === "object" && + data !== null && + "type" in data && + data.type === "student_response" && + "questionId" in data && + typeof data.questionId === "number" && + "optionIds" in data && + Array.isArray((data as StudentResponseMessage).optionIds) + ); +} + +function isActiveQuestionUpdateMessage(data: unknown): data is ActiveQuestionUpdateMessage { + return ( + typeof data === "object" && + data !== null && + "type" in data && + data.type === "active_question_update" && + "questionId" in data && + typeof data.questionId === "number" + ); +} + +function isPollPausedMessage(data: unknown): data is PollPausedMessage { + return ( + typeof data === "object" && + data !== null && + "type" in data && + data.type === "poll_paused" && + "paused" in data && + typeof data.paused === "boolean" + ); +} + +export function initWebSocketServer(server: HttpServer): WebSocketServer { + const wss = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (req, socket, head) => { + void (async () => { + try { + if (!req.url) return socket.destroy(); + const { pathname, searchParams } = new URL(req.url, `http://${req.headers.host}`); + + if (pathname === "/ws/poll") { + const sessionId = searchParams.get("sessionId"); + const userId = searchParams.get("userId"); + if (!sessionId || !userId) return socket.destroy(); + + if (!(await validateUserSession(userId, sessionId))) { + return socket.destroy(); + } + + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req, { sessionId, userId }); + }); + } else { + socket.destroy(); + } + } catch (err) { + console.error("Upgrade error:", err); + socket.destroy(); + } + })(); + }); + + wss.on("connection", (ws: WebSocket, _req: IncomingMessage, params: ConnectionParams = {}) => { + const { sessionId, userId } = params; + if (!sessionId || !userId) { + ws.close(1008, "Missing session or user ID"); + return; + } + + // register + if (!connections.has(sessionId)) connections.set(sessionId, new Map()); + const sessionConnections = connections.get(sessionId); + if (sessionConnections) { + sessionConnections.set(userId, { userId, sessionId, ws }); + } + + // confirm + ws.send( + JSON.stringify({ + type: "connected", + message: "Connected to poll session", + } as ConnectedMessage), + ); + + ws.on("message", (raw) => { + void (async () => { + try { + let rawString: string; + if (raw instanceof Buffer) { + rawString = raw.toString(); + } else if (typeof raw === "string") { + rawString = raw; + } else { + throw new Error("Unsupported message format"); + } + + // Parse and validate the data with unknown type first + const parsedData = JSON.parse(rawString) as unknown; + + // Now use our type guards to safely handle the data + if (isStudentResponseMessage(parsedData)) { + const { questionId, optionIds } = parsedData; + + // 1) delete old answers + const _deleteResult = await prisma.response.deleteMany({ + where: { userId, questionId }, + }); + console.log(_deleteResult); + + // 2) bulk insert new answers + const _createResult = await prisma.response.createMany({ + data: optionIds.map((optId) => ({ + userId, + questionId, + optionId: optId, + })), + skipDuplicates: true, + }); + console.log(_createResult); + + // 3) re-aggregate and broadcast + const groups = await prisma.response.groupBy({ + by: ["optionId"], + where: { questionId }, + _count: { optionId: true }, + }); + + const optionCounts = groups.reduce>((acc, g) => { + acc[g.optionId] = g._count.optionId; + return acc; + }, {}); + + const total = Object.values(optionCounts).reduce((sum, c) => sum + c, 0); + + // confirmation + ws.send( + JSON.stringify({ + type: "response_saved", + message: "Your answer has been recorded", + data: { questionId, optionIds }, + } as ResponseSavedMessage), + ); + + // broadcast update + broadcastToSession(sessionId, { + type: "response_update", + questionId, + responseCount: total, + optionCounts, + } as ResponseUpdateMessage); + } else if (isActiveQuestionUpdateMessage(parsedData)) { + console.log("Broadcasting question change:", parsedData.questionId); + // Ensure all clients get the question change notification + const message: QuestionChangedMessage = { + type: "question_changed", + questionId: parsedData.questionId, + }; + broadcastToSession(sessionId, message); + } else if (isPollPausedMessage(parsedData)) { + broadcastToSession(sessionId, { + type: "poll_paused", + paused: parsedData.paused, + }); + } else { + console.warn("Received unhandled message type:", parsedData); + ws.send( + JSON.stringify({ + type: "error", + message: "Unhandled message type", + } as ErrorMessage), + ); + } + } catch (err) { + console.error("WS message error:", err); + ws.send( + JSON.stringify({ + type: "error", + message: "Invalid message format", + } as ErrorMessage), + ); + } + })(); + }); + + ws.on("close", () => { + const sessConns = connections.get(sessionId); + if (sessConns) { + sessConns.delete(userId); + if (sessConns.size === 0) connections.delete(sessionId); + } + }); + + ws.on("error", (e) => { + console.error("WS error:", e); + }); + }); + + return wss; +} diff --git a/package-lock.json b/package-lock.json index 5834966..c26f41b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-visually-hidden": "^1.2.2", "@shadcn/ui": "^0.0.4", + "@types/ws": "^8.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -48,6 +49,7 @@ "swr": "^2.3.2", "tailwind-merge": "^2.5.5", "vaul": "^1.1.2", + "ws": "^8.18.2", "zod": "^3.24.2" }, "devDependencies": { @@ -64,10 +66,11 @@ "husky": "^9.1.7", "postcss": "^8", "prettier": "^3.3.3", - "prisma": "^6.4.0", + "prisma": "^6.8.2", "shadcn-ui": "^0.9.4", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", + "ts-node": "^10.9.2", "typescript": "^5.8.3" } }, @@ -569,429 +572,28 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1955,44 +1557,64 @@ } } }, + "node_modules/@prisma/config": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.8.2.tgz", + "integrity": "sha512-ZJY1fF4qRBPdLQ/60wxNtX+eu89c3AkYEcP7L3jkp0IPXCNphCYxikTg55kPJLDOG6P0X+QG5tCv6CmsBRZWFQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "jiti": "2.4.2" + } + }, + "node_modules/@prisma/config/node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/@prisma/debug": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.4.1.tgz", - "integrity": "sha512-Q9xk6yjEGIThjSD8zZegxd5tBRNHYd13GOIG0nLsanbTXATiPXCLyvlYEfvbR2ft6dlRsziQXfQGxAgv7zcMUA==", + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.8.2.tgz", + "integrity": "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.4.1.tgz", - "integrity": "sha512-KldENzMHtKYwsOSLThghOIdXOBEsfDuGSrxAZjMnimBiDKd3AE4JQ+Kv+gBD/x77WoV9xIPf25GXMWffXZ17BA==", + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.8.2.tgz", + "integrity": "sha512-XqAJ//LXjqYRQ1RRabs79KOY4+v6gZOGzbcwDQl0D6n9WBKjV7qdrbd042CwSK0v0lM9MSHsbcFnU2Yn7z8Zlw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.4.1", - "@prisma/engines-version": "6.4.0-29.a9055b89e58b4b5bfb59600785423b1db3d0e75d", - "@prisma/fetch-engine": "6.4.1", - "@prisma/get-platform": "6.4.1" + "@prisma/debug": "6.8.2", + "@prisma/engines-version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e", + "@prisma/fetch-engine": "6.8.2", + "@prisma/get-platform": "6.8.2" } }, "node_modules/@prisma/engines-version": { - "version": "6.4.0-29.a9055b89e58b4b5bfb59600785423b1db3d0e75d", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.4.0-29.a9055b89e58b4b5bfb59600785423b1db3d0e75d.tgz", - "integrity": "sha512-Xq54qw55vaCGrGgIJqyDwOq0TtjZPJEWsbQAHugk99hpDf2jcEeQhUcF+yzEsSqegBaDNLA4IC8Nn34sXmkiTQ==", + "version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e.tgz", + "integrity": "sha512-Rkik9lMyHpFNGaLpPF3H5q5TQTkm/aE7DsGM5m92FZTvWQsvmi6Va8On3pWvqLHOt5aPUvFb/FeZTmphI4CPiQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.4.1.tgz", - "integrity": "sha512-uZ5hVeTmDspx7KcaRCNoXmcReOD+84nwlO2oFvQPRQh9xiFYnnUKDz7l9bLxp8t4+25CsaNlgrgilXKSQwrIGQ==", + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.8.2.tgz", + "integrity": "sha512-lCvikWOgaLOfqXGacEKSNeenvj0n3qR5QvZUOmPE2e1Eh8cMYSobxonCg9rqM6FSdTfbpqp9xwhSAOYfNqSW0g==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.4.1", - "@prisma/engines-version": "6.4.0-29.a9055b89e58b4b5bfb59600785423b1db3d0e75d", - "@prisma/get-platform": "6.4.1" + "@prisma/debug": "6.8.2", + "@prisma/engines-version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e", + "@prisma/get-platform": "6.8.2" } }, "node_modules/@prisma/generator-helper": { @@ -2051,13 +1673,13 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/@prisma/get-platform": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.4.1.tgz", - "integrity": "sha512-gXqZaDI5scDkBF8oza7fOD3Q3QMD0e0rBynlzDDZdTWbWmzjuW58PRZtj+jkvKje2+ZigCWkH8SsWZAsH6q1Yw==", + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.8.2.tgz", + "integrity": "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.4.1" + "@prisma/debug": "6.8.2" } }, "node_modules/@prisma/internals": { @@ -3437,9 +3059,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", - "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -4071,6 +3693,34 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cross-spawn": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz", @@ -4215,6 +3865,15 @@ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.15.0.tgz", @@ -4453,6 +4112,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", @@ -5439,6 +5111,13 @@ "node": ">= 10" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6098,60 +5777,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", - "devOptional": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -8457,6 +8082,13 @@ "semver": "bin/semver.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/make-event-props": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.6.2.tgz", @@ -9776,16 +9408,15 @@ "license": "MIT" }, "node_modules/prisma": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.4.1.tgz", - "integrity": "sha512-q2uJkgXnua/jj66mk6P9bX/zgYJFI/jn4Yp0aS6SPRrjH/n6VyOV7RDe1vHD0DX8Aanx4MvgmUPPoYnR6MJnPg==", + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz", + "integrity": "sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/engines": "6.4.1", - "esbuild": ">=0.12 <1", - "esbuild-register": "3.6.0" + "@prisma/config": "6.8.2", + "@prisma/engines": "6.8.2" }, "bin": { "prisma": "build/index.js" @@ -9793,9 +9424,6 @@ "engines": { "node": ">=18.18" }, - "optionalDependencies": { - "fsevents": "2.3.3" - }, "peerDependencies": { "typescript": ">=5.1.0" }, @@ -9860,20 +9488,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prisma/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -11764,6 +11378,67 @@ "code-block-writer": "^12.0.0" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/ts-pattern": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-4.3.0.tgz", @@ -12062,6 +11737,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -12348,6 +12030,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -12366,6 +12069,16 @@ "node": ">= 14" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 9516f5d..acbac4a 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "prisma generate && next build", - "start": "next start", + "dev": "ts-node --project tsconfig.server.json server.ts", + "build": "next build", + "start": "NODE_ENV=production ts-node --project tsconfig.server.json server.ts", "lint": "next lint", "test:e2e": "playwright test", "check-git-hooks": "node .secret-scan/secret-scan.js -- --check-git-hooks", @@ -34,6 +34,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-visually-hidden": "^1.2.2", "@shadcn/ui": "^0.0.4", + "@types/ws": "^8.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -56,6 +57,7 @@ "swr": "^2.3.2", "tailwind-merge": "^2.5.5", "vaul": "^1.1.2", + "ws": "^8.18.2", "zod": "^3.24.2" }, "devDependencies": { @@ -72,7 +74,7 @@ "husky": "^9.1.7", "postcss": "^8", "prettier": "^3.3.3", - "prisma": "^6.4.0", + "prisma": "^6.8.2", "shadcn-ui": "^0.9.4", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..9f5f782 --- /dev/null +++ b/server.ts @@ -0,0 +1,45 @@ +import { createServer } from "http"; +import { parse } from "url"; +import next from "next"; +import { initWebSocketServer } from "./lib/websocket"; + +const dev = process.env.NODE_ENV !== "production"; +const app = next({ dev }); +const handle = app.getRequestHandler(); + +void app + .prepare() + .then(() => { + const server = createServer((req, res) => { + // Fix for no-non-null-assertion: Add check for req.url + const parsedUrl = req.url ? parse(req.url, true) : null; + + // Handle case when parsedUrl is null + if (!parsedUrl) { + res.writeHead(400); + res.end("Bad Request: Missing URL"); + return; + } + + // Let the WebSocket server handle WebSocket requests + if (parsedUrl.pathname?.startsWith("/ws")) { + res.writeHead(426); + res.end(); + return; + } + + void handle(req, res, parsedUrl); + }); + + // Initialize WebSocket server + const _wss = initWebSocketServer(server); + + // Fix for no-floating-promises: Add void operator to indicate promise is intentionally not awaited + void server.listen(3000, () => { + console.log("> Ready on http://localhost:3000"); + }); + }) + .catch((error: unknown) => { + console.error("Error preparing Next.js app:", error); + process.exit(1); + }); diff --git a/services/courseSession.ts b/services/courseSession.ts index 94a9977..d1441c8 100644 --- a/services/courseSession.ts +++ b/services/courseSession.ts @@ -103,15 +103,15 @@ export async function pauseOrResumeCourseSession(sessionId: number, paused: bool } } -export async function getSessionPauseState(sessionId: number) { +export async function getSessionPauseState(sessionId: number): Promise { try { const session = await prisma.courseSession.findUnique({ where: { id: sessionId, }, }); - return session?.paused ?? false; - } catch (error) { + return Boolean(session?.paused ?? false); + } catch (error: unknown) { console.error("Error getting session pause state:", error); throw new Error("Failed to get session pause state"); } diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..60087c7 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist", + "target": "es2017", + "isolatedModules": false, + "noEmit": false, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["server.ts", "lib/websocket.ts", "lib/server-prisma.ts"] +}