From 4690d02ea32d4edd2b07e79cbda81f40f966b9a3 Mon Sep 17 00:00:00 2001 From: Ulises Salinas Date: Tue, 20 May 2025 09:54:12 -0700 Subject: [PATCH 01/24] Set up simple websocket connection and testing area --- app/test-ws/page.tsx | 90 ++++++++++++++++++++ lib/websocket.ts | 46 +++++++++++ package-lock.json | 190 +++++++++++++++++++++++++++++++++++++++++++ package.json | 9 +- server.ts | 29 +++++++ tsconfig.server.json | 11 +++ 6 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 app/test-ws/page.tsx create mode 100644 lib/websocket.ts create mode 100644 server.ts create mode 100644 tsconfig.server.json diff --git a/app/test-ws/page.tsx b/app/test-ws/page.tsx new file mode 100644 index 0000000..88f8f3a --- /dev/null +++ b/app/test-ws/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export default function TestWebSocket() { + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(''); + const [ws, setWs] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + const socket = new WebSocket('ws://localhost:3000/ws'); + + socket.onopen = () => { + console.log('Connected to WebSocket'); + setMessages(prev => [...prev, 'Connected to WebSocket']); + setIsConnected(true); + }; + + socket.onmessage = (event) => { + console.log('Received:', event.data); + setMessages(prev => [...prev, event.data]); + }; + + socket.onclose = () => { + console.log('Disconnected from WebSocket'); + setMessages(prev => [...prev, 'Disconnected from WebSocket']); + setIsConnected(false); + }; + + socket.onerror = (error) => { + console.error('WebSocket error:', error); + setMessages(prev => [...prev, 'WebSocket error occurred']); + }; + + setWs(socket); + + return () => { + if (socket.readyState === WebSocket.OPEN) { + socket.close(); + } + }; + }, []); + + const sendMessage = () => { + if (ws && ws.readyState === WebSocket.OPEN && inputMessage) { + ws.send(inputMessage); + setInputMessage(''); + } + }; + + return ( +
+

WebSocket Test

+ +
+
+
+ {isConnected ? 'Connected' : 'Disconnected'} +
+ setInputMessage(e.target.value)} + className="border p-2 mr-2" + placeholder="Type a message..." + disabled={!isConnected} + /> + +
+ +
+

Messages:

+
+ {messages.map((msg, index) => ( +
+ {msg} +
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/lib/websocket.ts b/lib/websocket.ts new file mode 100644 index 0000000..861a913 --- /dev/null +++ b/lib/websocket.ts @@ -0,0 +1,46 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import { Server } from 'http'; + +let wss: WebSocketServer; + +export function initWebSocketServer(server: Server) { + wss = new WebSocketServer({ + server, + clientTracking: true, + perMessageDeflate: false + }); + + wss.on('connection', (ws: WebSocket) => { + console.log('Client connected'); + + // Send welcome message + ws.send('Connected to WebSocket server!'); + + ws.on('message', (message: Buffer) => { + try { + console.log('Received:', message.toString()); + ws.send(`Server received: ${message}`); + } catch (error) { + console.error('Error handling message:', error); + } + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + }); + + ws.on('close', (code: number, reason: Buffer) => { + console.log('Client disconnected:', code, reason.toString()); + }); + }); + + wss.on('error', (error) => { + console.error('WebSocket server error:', error); + }); + + return wss; +} + +export function getWebSocketServer() { + return wss; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b6e74db..effba28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@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", @@ -45,6 +46,7 @@ "swr": "^2.3.2", "tailwind-merge": "^2.5.5", "vaul": "^1.1.2", + "ws": "^8.18.2", "zod": "^3.24.2" }, "devDependencies": { @@ -64,6 +66,7 @@ "shadcn-ui": "^0.9.4", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", + "ts-node": "^10.9.2", "typescript": "^5" } }, @@ -555,6 +558,30 @@ "node": ">=6.9.0" } }, + "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", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "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", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", @@ -2992,6 +3019,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", @@ -3125,6 +3180,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", @@ -3363,6 +3427,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", @@ -4347,6 +4424,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", @@ -7329,6 +7413,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", @@ -10631,6 +10722,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", @@ -10927,6 +11079,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", @@ -11213,6 +11372,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", @@ -11231,6 +11411,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 44c9a6f..1876fe1 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", @@ -32,6 +32,7 @@ "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@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", @@ -53,6 +54,7 @@ "swr": "^2.3.2", "tailwind-merge": "^2.5.5", "vaul": "^1.1.2", + "ws": "^8.18.2", "zod": "^3.24.2" }, "devDependencies": { @@ -72,6 +74,7 @@ "shadcn-ui": "^0.9.4", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", + "ts-node": "^10.9.2", "typescript": "^5" } } diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..f5aea2f --- /dev/null +++ b/server.ts @@ -0,0 +1,29 @@ +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(); + +app.prepare().then(() => { + const server = createServer((req, res) => { + const parsedUrl = parse(req.url!, true); + + // handle WebSocket requests + if (parsedUrl.pathname === '/ws') { + res.writeHead(426); + res.end(); + return; + } + + handle(req, res, parsedUrl); + }); + + initWebSocketServer(server); + + server.listen(3000, () => { + console.log('> Ready on http://localhost:3000'); + }); +}); \ No newline at end of file diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..6dffeda --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist", + "target": "es2017", + "isolatedModules": false, + "noEmit": false + }, + "include": ["server.ts", "lib/websocket.ts"] +} \ No newline at end of file From e811128f44ea7d8d0577b5d966cfa540e95ec885 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 20 May 2025 14:58:33 -0700 Subject: [PATCH 02/24] established websocket to send student responses to server/db --- components/LivePoll.tsx | 530 +++++++++++++++++++++++++++++++++++----- lib/websocket.ts | 277 ++++++++++++++++++--- server.ts | 1 + 3 files changed, 704 insertions(+), 104 deletions(-) diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index eeac84f..acc989e 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -1,5 +1,279 @@ -// app/active-session/[course-session-id]/live-poll/page.tsx - +// // app/active-session/[course-session-id]/live-poll/page.tsx + +// "use client"; +// import { Option as PrismaOption, Question as PrismaQuestion } from "@prisma/client"; +// import { useParams, useRouter } from "next/navigation"; +// import { useCallback, useEffect, useRef, useState } from "react"; +// import AnswerOptions from "@/components/ui/answerOptions"; +// import BackButton from "@/components/ui/backButton"; +// import QuestionCard from "@/components/ui/questionCard"; +// import useAccess from "@/hooks/use-access"; +// import { useToast } from "@/hooks/use-toast"; + +// type QuestionWithOptions = PrismaQuestion & { +// options: PrismaOption[]; +// }; + +// type fetchCourseSessionQuestionResponse = { +// activeQuestionId: number; +// totalQuestions: number; +// }; + +// export default function LivePoll({ courseSessionId }: { courseSessionId: number }) { +// // Extract the course-session-id from the URL +// const params = useParams(); +// const router = useRouter(); +// const { toast } = useToast(); + +// const courseId = parseInt(params.courseId as string); +// const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "STUDENT" }); + +// const [currentQuestion, setCurrentQuestion] = useState(null); +// const [loading, setLoading] = useState(true); +// const [error, setError] = useState(null); +// const [submitting, setSubmitting] = useState(false); +// const [questionCount, setQuestionCount] = useState("1"); + +// // Use useRef for activeQuestionId to prevent unnecessary re-renders +// const activeQuestionIdRef = useRef(null); + +// // Unified state for selected values (either single number or array of numbers) +// const [selectedValues, setSelectedValues] = useState(null); + +// // Function to fetch active question - use useCallback to memoize +// const fetchActiveQuestion = useCallback(async () => { +// try { +// // First, get the session to get the activeQuestionId +// const sessionResponse = await fetch( +// `/api/fetchCourseSessionQuestion?sessionId=${courseSessionId}`, +// ); + +// if (!sessionResponse.ok) { +// return toast({ +// variant: "destructive", +// description: "Failed to fetch course session", +// }); +// } + +// const sessionData = +// (await sessionResponse.json()) as fetchCourseSessionQuestionResponse; +// const newActiveQuestionId = sessionData.activeQuestionId; +// // If the active question hasn't changed, don't re-fetch +// if (activeQuestionIdRef.current === newActiveQuestionId) { +// return; +// } +// setLoading(true); + +// // Update the ref +// activeQuestionIdRef.current = newActiveQuestionId; +// // If active question ID is 0 or null, no question is active +// if (!newActiveQuestionId) { +// setError("No active question at this time"); +// setLoading(false); +// return; +// } +// const questionResponse = await fetch( +// `/api/fetchQuestionById?questionId=${String(newActiveQuestionId)}`, +// ); + +// if (!questionResponse.ok) { +// toast({ variant: "destructive", description: "Failed to fetch question" }); +// router.refresh(); +// return; +// } + +// const questionData = (await questionResponse.json()) as QuestionWithOptions; +// setCurrentQuestion(questionData); + +// // Reset selected values based on question type +// setSelectedValues(questionData.type === "MCQ" ? null : []); + +// // Use the position directly from the question object +// // Add 1 since positions typically start at 0 but display to users starts at 1 +// const currentNumber = questionData.position + 1; + +// setQuestionCount(String(currentNumber)); +// } catch (err) { +// toast({ variant: "destructive", description: "An error occurre" }); +// console.error(err); +// } finally { +// setLoading(false); +// } +// }, [courseSessionId]); // Only depends on courseSessionId + +// // Initial fetch and polling setup +// useEffect(() => { +// if (!courseSessionId) return; +// if (isAccessLoading) { +// return; +// } +// if (!hasAccess) { +// toast({ variant: "destructive", description: "Access denied!" }); +// router.push("/dashboard"); +// return; +// } + +// let intervalId: NodeJS.Timeout | null = null; + +// // Create an async function to handle the initial fetch +// // const initialFetch = async () => { +// // try { +// // // Wait for the initial fetch to complete +// // // await fetchActiveQuestion(); + +// // // Once initial fetch is done, start polling +// // intervalId = setInterval(() => { +// // void fetchActiveQuestion(); +// // }, 5000); // Poll every 5 seconds +// // } catch (errorMessage) { +// // console.error("Error in initial fetch:", errorMessage); +// // } +// // }; +// // void initialFetch(); +// void fetchActiveQuestion(); + +// intervalId = setInterval(() => { +// void fetchActiveQuestion(); +// }, 5000); +// return () => { +// if (intervalId) { +// clearInterval(intervalId); +// } +// }; +// }, [isAccessLoading, hasAccess, courseSessionId]); // Dependency on memoized fetchActiveQuestion + +// // Handle loading state +// if ((loading && !currentQuestion) || isAccessLoading) { +// return ( +//
+//
+//
+//

Loading question...

+//
+//
+// ); +// } + +// if (error || !currentQuestion) { +// return ( +//
+//
+//

+// {error ?? "No active question at this time"} +//

+// +//
+//
+// ); +// } + +// // Handle answer selection (works for both MCQ and MSQ) +// const handleSelectionChange = (value: number | number[]) => { +// setSelectedValues(value); +// }; + +// const handleSubmit = async () => { +// if ( +// !selectedValues || +// (Array.isArray(selectedValues) && selectedValues.length === 0) || +// !currentQuestion +// ) { +// return; +// } +// const optionIds = Array.isArray(selectedValues) ? selectedValues : [selectedValues]; + +// try { +// 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 { +// setSubmitting(false); +// } +// }; + +// return ( +//
+//
+// {/* Back Button */} +//
+// +//
+ +// {/* Question header and count */} +//
+//
+//

Live Question:

+//
+// Question {questionCount} +//
+//
+//
+ +// {/* Loading indicator for refreshing questions */} +// {/* {loading && currentQuestion && ( +//
+//
+// Syncing... +//
+// )} */} + +// {/* Question Card */} +//
+// +//
+ +// {/* Answer Options */} +// + +// {/* Submit Button */} +// + +// {/* Submission Status */} +// {submitting && ( +//

Saving your answer...

+// )} + +// {/* Footer Message */} +//

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

+//
+//
+// ); +// } + +// Modified LivePoll.tsx with simplified WebSocket integration "use client"; import { Option as PrismaOption, Question as PrismaQuestion } from "@prisma/client"; import { useParams, useRouter } from "next/navigation"; @@ -33,9 +307,12 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [questionCount, setQuestionCount] = useState("1"); + const [isConnected, setIsConnected] = useState(false); + const [messages, setMessages] = useState([]); // Use useRef for activeQuestionId to prevent unnecessary re-renders const activeQuestionIdRef = useRef(null); + const wsRef = useRef(null); // Unified state for selected values (either single number or array of numbers) const [selectedValues, setSelectedValues] = useState(null); @@ -58,20 +335,24 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number const sessionData = (await sessionResponse.json()) as fetchCourseSessionQuestionResponse; const newActiveQuestionId = sessionData.activeQuestionId; + // If the active question hasn't changed, don't re-fetch if (activeQuestionIdRef.current === newActiveQuestionId) { return; } + setLoading(true); // Update the ref activeQuestionIdRef.current = newActiveQuestionId; + // If active question ID is 0 or null, no question is active if (!newActiveQuestionId) { setError("No active question at this time"); setLoading(false); return; } + const questionResponse = await fetch( `/api/fetchQuestionById?questionId=${String(newActiveQuestionId)}`, ); @@ -94,54 +375,131 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number setQuestionCount(String(currentNumber)); } catch (err) { - toast({ variant: "destructive", description: "An error occurre" }); + toast({ variant: "destructive", description: "An error occurred" }); console.error(err); } finally { setLoading(false); } }, [courseSessionId]); // Only depends on courseSessionId + // The improved WebSocket connection handling for LivePoll.tsx + + // This is the part that needs to be updated in your LivePoll.tsx file + // Replace the entire useEffect that sets up the WebSocket with this code: - // Initial fetch and polling setup + // Setup WebSocket connection useEffect(() => { if (!courseSessionId) return; - if (isAccessLoading) { - return; - } - if (!hasAccess) { - toast({ variant: "destructive", description: "Access denied!" }); - router.push("/dashboard"); - return; - } - let intervalId: NodeJS.Timeout | null = null; - - // Create an async function to handle the initial fetch - // const initialFetch = async () => { - // try { - // // Wait for the initial fetch to complete - // // await fetchActiveQuestion(); - - // // Once initial fetch is done, start polling - // intervalId = setInterval(() => { - // void fetchActiveQuestion(); - // }, 5000); // Poll every 5 seconds - // } catch (errorMessage) { - // console.error("Error in initial fetch:", errorMessage); - // } - // }; - // void initialFetch(); - void fetchActiveQuestion(); - - intervalId = setInterval(() => { - void fetchActiveQuestion(); - }, 5000); - return () => { - if (intervalId) { - clearInterval(intervalId); + // Generate a temporary user ID for testing + // In a real app, you would use the authenticated user's ID + const tempUserId = `test-user-${Math.floor(Math.random() * 1000)}`; + + // Create WebSocket connection + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket( + `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSessionId}&userId=${tempUserId}`, + ); + wsRef.current = ws; + + ws.onopen = () => { + console.log("WebSocket connection established"); + setIsConnected(true); + setMessages((prev) => [...prev, "Connected to WebSocket"]); + }; + + ws.onmessage = (event) => { + let data; + let messageText; + + // Display the raw message for debugging + console.log("Raw message received:", event.data); + + // Try to parse as JSON, but handle plain text too + try { + if (typeof event.data === "string") { + try { + // Try to parse as JSON + data = JSON.parse(event.data); + messageText = `Received JSON: ${JSON.stringify(data)}`; + console.log("Parsed JSON:", data); + + // Process valid JSON message + if (data && data.type) { + // Handle different message types + if (data.type === "question_changed" && data.questionId) { + // Refresh the question when the instructor changes it + activeQuestionIdRef.current = null; // Force refresh + fetchActiveQuestion(); + } else if (data.type === "response_saved") { + toast({ description: data.message || "Response saved" }); + setSubmitting(false); // Reset submitting state on success + } else if (data.type === "error") { + toast({ + variant: "destructive", + description: data.message || "Error occurred", + }); + setSubmitting(false); // Reset submitting state on error + } else if (data.type === "connected") { + console.log("WebSocket connection confirmed:", data.message); + } else if (data.type === "echo") { + console.log("Server echo:", data.message); + // This is likely a text response echoed back + // We can safely ignore this for the student response flow + } + } + } catch (e) { + // If it fails to parse as JSON, it's likely a non-JSON text message + console.log("Not valid JSON, treating as text:", e.message); + + // This is from the old server - we need to handle this format + const message = event.data; + messageText = `Received text: ${message}`; + + // Check if this is a response to our student submission + if (message.includes("student_response") && submitting) { + // This is likely a response to our student submission + toast({ description: "Your answer has been recorded" }); + setSubmitting(false); // Reset submitting state + } + } + } else { + // Handle binary data if needed + data = { type: "binary", message: "Binary data received" }; + messageText = "Received: Binary data"; + console.log("Received binary data"); + } + + // Add message to list for debugging + setMessages((prev) => [...prev, messageText]); + } catch (error) { + console.error("Error processing message:", error); + setMessages((prev) => [...prev, `Error processing message: ${error}`]); + setSubmitting(false); // Reset submitting state on error } }; - }, [isAccessLoading, hasAccess, courseSessionId]); // Dependency on memoized fetchActiveQuestion + ws.onclose = () => { + console.log("WebSocket connection closed"); + setIsConnected(false); + setMessages((prev) => [...prev, "Disconnected from WebSocket"]); + setSubmitting(false); // Reset submitting state when connection closes + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + setMessages((prev) => [...prev, "WebSocket error occurred"]); + setSubmitting(false); // Reset submitting state on error + }; + + // Initial fetch + fetchActiveQuestion(); + + return () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }; + }, [courseSessionId, fetchActiveQuestion]); // Handle loading state if ((loading && !currentQuestion) || isAccessLoading) { return ( @@ -161,6 +519,15 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number

{error ?? "No active question at this time"}

+
@@ -172,19 +539,43 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number setSelectedValues(value); }; - const handleSubmit = async () => { + // Submit response through WebSocket + const handleSubmit = () => { 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", { + + // Extract option IDs + const optionIds = Array.isArray(selectedValues) ? selectedValues : [selectedValues]; + + // Create message payload + const message = { + type: "student_response", + questionId: currentQuestion.id, + optionIds: optionIds, + }; + + // Log that we're submitting + console.log("Submitting answer:", message); + + // Send through WebSocket + wsRef.current.send(JSON.stringify(message)); + + // Add to local messages list + setMessages((prev) => [...prev, `Sent: ${JSON.stringify(message)}`]); + + // Also send via API as a fallback - make this async + fetch("/api/submitStudentResponse", { method: "POST", headers: { "Content-Type": "application/json", @@ -193,14 +584,27 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number questionId: currentQuestion.id, optionIds, }), - }); + }) + .then((response) => { + // If we don't get a WebSocket response in 2 seconds, reset submitting state + // (This is a fallback in case the WebSocket doesn't respond) + if (!response.ok) { + console.error("Failed to save answer via API"); + } + }) + .catch((error) => { + console.error("Error saving answer via API:", error); + // Reset submitting state after API error + setSubmitting(false); + }); - if (!response.ok) { - console.error("Failed to save answer"); - } + // Fallback timer in case WebSocket response is never received + setTimeout(() => { + setSubmitting(false); + }, 3000); } catch (submitError) { - console.error("Error saving answer:", submitError); - } finally { + console.error("Error submitting answer:", submitError); + toast({ variant: "destructive", description: "Failed to submit answer" }); setSubmitting(false); } }; @@ -213,6 +617,16 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number + {/* Connection status */} +
+
+
+ {isConnected ? "Connected" : "Disconnected"} +
+
+ {/* Question header and count */}
@@ -223,14 +637,6 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number
- {/* Loading indicator for refreshing questions */} - {/* {loading && currentQuestion && ( -
-
- Syncing... -
- )} */} - {/* Question Card */}
@@ -246,24 +652,18 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number {/* Submit Button */} - {/* Submission Status */} - {submitting && ( -

Saving your answer...

- )} - {/* Footer Message */}

Instructor will start the next question shortly... diff --git a/lib/websocket.ts b/lib/websocket.ts index 861a913..94ca0f2 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -1,46 +1,245 @@ -import { WebSocketServer, WebSocket } from 'ws'; -import { Server } from 'http'; - -let wss: WebSocketServer; - -export function initWebSocketServer(server: Server) { - wss = new WebSocketServer({ - server, - clientTracking: true, - perMessageDeflate: false - }); - - wss.on('connection', (ws: WebSocket) => { - console.log('Client connected'); - - // Send welcome message - ws.send('Connected to WebSocket server!'); - - ws.on('message', (message: Buffer) => { - try { - console.log('Received:', message.toString()); - ws.send(`Server received: ${message}`); - } catch (error) { - console.error('Error handling message:', error); - } - }); +import { WebSocketServer } from "ws"; - ws.on('error', (error) => { - console.error('WebSocket error:', error); - }); +// Store all active connections +const connections = new Map(); + +export function initWebSocketServer(server) { + const wss = new WebSocketServer({ noServer: true }); + + // Handle upgrade requests + server.on("upgrade", (request, socket, head) => { + try { + const { pathname, searchParams } = new URL( + request.url, + `http://${request.headers.host}`, + ); + + if (pathname === "/ws") { + // For test endpoint - keep this for backward compatibility + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); + } else if (pathname === "/ws/poll") { + // For poll connections + const sessionId = searchParams.get("sessionId"); + const userId = searchParams.get("userId"); - ws.on('close', (code: number, reason: Buffer) => { - console.log('Client disconnected:', code, reason.toString()); + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request, { sessionId, userId }); + }); + } else { + socket.destroy(); + } + } catch (error) { + console.error("Error in WebSocket upgrade:", error); + socket.destroy(); + } }); - }); - wss.on('error', (error) => { - console.error('WebSocket server error:', error); - }); + // Handle WebSocket connections + wss.on("connection", (ws, request, connectionParams = {}) => { + const { sessionId, userId } = connectionParams; + + // Handle test connections + if (!sessionId && !userId) { + console.log("Test WebSocket connection established"); + + // FIXED: Always use JSON format for all messages + ws.send( + JSON.stringify({ + type: "connected", + message: "Connected to WebSocket test server", + }), + ); + + ws.on("message", (message) => { + console.log("Test message received:", message.toString()); + + // Try to parse as JSON first + // Replace both parts in your websocket.js file with this version: + + try { + // Parse the message to see if it's valid JSON + const jsonData = JSON.parse(message.toString()); + + // If it is, echo it back with proper JSON response + ws.send( + JSON.stringify({ + type: "response_saved", // Change this from 'echo' to 'response_saved' + message: "Your message has been received", + data: jsonData, + }), + ); + } catch (e) { + // If not valid JSON, still respond with JSON format + ws.send( + JSON.stringify({ + type: "response_saved", // Change this from 'echo' to 'response_saved' + message: "Your message has been received", + data: { + originalMessage: message.toString(), + }, + }), + ); + } + }); + + return; + } + + // Handle poll connections + console.log( + `Poll WebSocket connection established: SessionID=${sessionId}, UserID=${userId}`, + ); + + // Store the connection + if (!connections.has(sessionId)) { + connections.set(sessionId, new Map()); + } + const sessionConnections = connections.get(sessionId); + sessionConnections.set(userId, ws); + + // Send connection confirmation + ws.send( + JSON.stringify({ + type: "connected", + message: "Connected to poll session", + }), + ); + + ws.on("message", (message) => { + try { + // Log the raw message first + console.log("Raw message received:", message.toString()); + + // Try to parse the message + const data = JSON.parse(message.toString()); + + // Log all incoming messages to terminal + console.log("\n===== STUDENT RESPONSE ====="); + console.log("Session ID:", sessionId); + console.log("User ID:", userId); + console.log("Message Data:", data); + console.log("===========================\n"); + + // If this is a student response + if (data.type === "student_response") { + // Extract the data + const { questionId, optionIds } = data; - return wss; + // Log to console for testing + console.log( + `Student response received: QuestionID=${questionId}, OptionIDs=${optionIds.join(", ")}`, + ); + + // Send confirmation back to student + ws.send( + JSON.stringify({ + type: "response_saved", + message: "Your answer has been recorded", + data: { + questionId, + optionIds, + }, + }), + ); + + // Broadcast to all clients in this session that a new response has been received + broadcastToSession(sessionId, { + type: "response_update", + questionId, + // We don't have actual counts, but for testing we can just increment + responseCount: Math.floor(Math.random() * 20) + 1, // Random count for testing + }); + } + + // If instructor is updating the active question + else if (data.type === "active_question_update") { + console.log(`Active question updated: QuestionID=${data.questionId}`); + + // Broadcast to all clients in this session + broadcastToSession(sessionId, { + type: "question_changed", + questionId: data.questionId, + }); + } + } catch (error) { + console.error("Error processing WebSocket message:", error); + + // Even on error, respond with proper JSON + ws.send( + JSON.stringify({ + type: "error", + message: "Invalid message format", + }), + ); + } + }); + + ws.on("error", (error) => { + console.error("WebSocket connection error:", error); + }); + + ws.on("close", () => { + console.log(`WebSocket connection closed: SessionID=${sessionId}, UserID=${userId}`); + + // Clean up the connection + if (sessionId && userId) { + const sessionConnections = connections.get(sessionId); + if (sessionConnections) { + sessionConnections.delete(userId); + + if (sessionConnections.size === 0) { + connections.delete(sessionId); + } + } + } + }); + }); + + return wss; } -export function getWebSocketServer() { - return wss; -} \ No newline at end of file +// Function to broadcast a message to all connections in a session +function broadcastToSession(sessionId, message) { + const sessionConnections = connections.get(sessionId); + if (!sessionConnections) return; + + console.log(`Broadcasting to session ${sessionId}:`, message); + + try { + // Ensure message is a proper object before stringifying + const messageObj = + typeof message === "string" + ? JSON.parse(message) // Convert string to object if it's JSON + : message; // Use as is if it's already an object + + const messageStr = JSON.stringify(messageObj); + + for (const connection of sessionConnections.values()) { + try { + connection.send(messageStr); + } catch (err) { + console.error("Error sending broadcast to client:", err); + } + } + } catch (error) { + console.error("Error broadcasting message:", error); + + // Fallback if message isn't valid JSON + if (typeof message === "string") { + const fallbackMsg = JSON.stringify({ + type: "text", + message: message, + }); + + for (const connection of sessionConnections.values()) { + try { + connection.send(fallbackMsg); + } catch (err) { + console.error("Error sending fallback broadcast:", err); + } + } + } + } +} diff --git a/server.ts b/server.ts index f5aea2f..e277bc5 100644 --- a/server.ts +++ b/server.ts @@ -1,3 +1,4 @@ + import { createServer } from 'http'; import { parse } from 'url'; import next from 'next'; From 270a58b9a7eebb859a8cb2cde816a4619cc4ce31 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 20 May 2025 16:05:08 -0700 Subject: [PATCH 03/24] got rid of debugging statements --- app/test-ws/page.tsx | 152 ++++++------- components/LivePoll.tsx | 433 +++++++++---------------------------- lib/websocket.ts | 459 +++++++++++++++++++++++----------------- server.ts | 62 +++--- tsconfig.server.json | 20 +- 5 files changed, 495 insertions(+), 631 deletions(-) diff --git a/app/test-ws/page.tsx b/app/test-ws/page.tsx index 88f8f3a..ea628dd 100644 --- a/app/test-ws/page.tsx +++ b/app/test-ws/page.tsx @@ -1,90 +1,94 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; +import { useEffect, useState } from "react"; export default function TestWebSocket() { - const [messages, setMessages] = useState([]); - const [inputMessage, setInputMessage] = useState(''); - const [ws, setWs] = useState(null); - const [isConnected, setIsConnected] = useState(false); + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(""); + const [ws, setWs] = useState(null); + const [isConnected, setIsConnected] = useState(false); - useEffect(() => { - const socket = new WebSocket('ws://localhost:3000/ws'); + useEffect(() => { + const socket = new WebSocket("ws://localhost:3000/ws"); - socket.onopen = () => { - console.log('Connected to WebSocket'); - setMessages(prev => [...prev, 'Connected to WebSocket']); - setIsConnected(true); - }; + socket.onopen = () => { + console.log("Connected to WebSocket"); + setMessages((prev) => [...prev, "Connected to WebSocket"]); + setIsConnected(true); + }; - socket.onmessage = (event) => { - console.log('Received:', event.data); - setMessages(prev => [...prev, event.data]); - }; + socket.onmessage = (event) => { + console.log("Received:", event.data); + setMessages((prev) => [...prev, event.data]); + }; - socket.onclose = () => { - console.log('Disconnected from WebSocket'); - setMessages(prev => [...prev, 'Disconnected from WebSocket']); - setIsConnected(false); - }; + socket.onclose = () => { + console.log("Disconnected from WebSocket"); + setMessages((prev) => [...prev, "Disconnected from WebSocket"]); + setIsConnected(false); + }; - socket.onerror = (error) => { - console.error('WebSocket error:', error); - setMessages(prev => [...prev, 'WebSocket error occurred']); - }; + socket.onerror = (error) => { + console.error("WebSocket error:", error); + setMessages((prev) => [...prev, "WebSocket error occurred"]); + }; - setWs(socket); + setWs(socket); - return () => { - if (socket.readyState === WebSocket.OPEN) { - socket.close(); - } + return () => { + if (socket.readyState === WebSocket.OPEN) { + socket.close(); + } + }; + }, []); + + const sendMessage = () => { + if (ws && ws.readyState === WebSocket.OPEN && inputMessage) { + ws.send(inputMessage); + setInputMessage(""); + } }; - }, []); - const sendMessage = () => { - if (ws && ws.readyState === WebSocket.OPEN && inputMessage) { - ws.send(inputMessage); - setInputMessage(''); - } - }; + return ( +

+

WebSocket Test

- return ( -
-

WebSocket Test

- -
-
-
- {isConnected ? 'Connected' : 'Disconnected'} -
- setInputMessage(e.target.value)} - className="border p-2 mr-2" - placeholder="Type a message..." - disabled={!isConnected} - /> - -
+
+
+
+ {isConnected ? "Connected" : "Disconnected"} +
+ { + setInputMessage(e.target.value); + }} + className="border p-2 mr-2" + placeholder="Type a message..." + disabled={!isConnected} + /> + +
-
-

Messages:

-
- {messages.map((msg, index) => ( -
- {msg} +
+

Messages:

+
+ {messages.map((msg, index) => ( +
+ {msg} +
+ ))} +
- ))}
-
-
- ); -} \ No newline at end of file + ); +} diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index acc989e..5847006 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -1,283 +1,7 @@ -// // app/active-session/[course-session-id]/live-poll/page.tsx - -// "use client"; -// import { Option as PrismaOption, Question as PrismaQuestion } from "@prisma/client"; -// import { useParams, useRouter } from "next/navigation"; -// import { useCallback, useEffect, useRef, useState } from "react"; -// import AnswerOptions from "@/components/ui/answerOptions"; -// import BackButton from "@/components/ui/backButton"; -// import QuestionCard from "@/components/ui/questionCard"; -// import useAccess from "@/hooks/use-access"; -// import { useToast } from "@/hooks/use-toast"; - -// type QuestionWithOptions = PrismaQuestion & { -// options: PrismaOption[]; -// }; - -// type fetchCourseSessionQuestionResponse = { -// activeQuestionId: number; -// totalQuestions: number; -// }; - -// export default function LivePoll({ courseSessionId }: { courseSessionId: number }) { -// // Extract the course-session-id from the URL -// const params = useParams(); -// const router = useRouter(); -// const { toast } = useToast(); - -// const courseId = parseInt(params.courseId as string); -// const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "STUDENT" }); - -// const [currentQuestion, setCurrentQuestion] = useState(null); -// const [loading, setLoading] = useState(true); -// const [error, setError] = useState(null); -// const [submitting, setSubmitting] = useState(false); -// const [questionCount, setQuestionCount] = useState("1"); - -// // Use useRef for activeQuestionId to prevent unnecessary re-renders -// const activeQuestionIdRef = useRef(null); - -// // Unified state for selected values (either single number or array of numbers) -// const [selectedValues, setSelectedValues] = useState(null); - -// // Function to fetch active question - use useCallback to memoize -// const fetchActiveQuestion = useCallback(async () => { -// try { -// // First, get the session to get the activeQuestionId -// const sessionResponse = await fetch( -// `/api/fetchCourseSessionQuestion?sessionId=${courseSessionId}`, -// ); - -// if (!sessionResponse.ok) { -// return toast({ -// variant: "destructive", -// description: "Failed to fetch course session", -// }); -// } - -// const sessionData = -// (await sessionResponse.json()) as fetchCourseSessionQuestionResponse; -// const newActiveQuestionId = sessionData.activeQuestionId; -// // If the active question hasn't changed, don't re-fetch -// if (activeQuestionIdRef.current === newActiveQuestionId) { -// return; -// } -// setLoading(true); - -// // Update the ref -// activeQuestionIdRef.current = newActiveQuestionId; -// // If active question ID is 0 or null, no question is active -// if (!newActiveQuestionId) { -// setError("No active question at this time"); -// setLoading(false); -// return; -// } -// const questionResponse = await fetch( -// `/api/fetchQuestionById?questionId=${String(newActiveQuestionId)}`, -// ); - -// if (!questionResponse.ok) { -// toast({ variant: "destructive", description: "Failed to fetch question" }); -// router.refresh(); -// return; -// } - -// const questionData = (await questionResponse.json()) as QuestionWithOptions; -// setCurrentQuestion(questionData); - -// // Reset selected values based on question type -// setSelectedValues(questionData.type === "MCQ" ? null : []); - -// // Use the position directly from the question object -// // Add 1 since positions typically start at 0 but display to users starts at 1 -// const currentNumber = questionData.position + 1; - -// setQuestionCount(String(currentNumber)); -// } catch (err) { -// toast({ variant: "destructive", description: "An error occurre" }); -// console.error(err); -// } finally { -// setLoading(false); -// } -// }, [courseSessionId]); // Only depends on courseSessionId - -// // Initial fetch and polling setup -// useEffect(() => { -// if (!courseSessionId) return; -// if (isAccessLoading) { -// return; -// } -// if (!hasAccess) { -// toast({ variant: "destructive", description: "Access denied!" }); -// router.push("/dashboard"); -// return; -// } - -// let intervalId: NodeJS.Timeout | null = null; - -// // Create an async function to handle the initial fetch -// // const initialFetch = async () => { -// // try { -// // // Wait for the initial fetch to complete -// // // await fetchActiveQuestion(); - -// // // Once initial fetch is done, start polling -// // intervalId = setInterval(() => { -// // void fetchActiveQuestion(); -// // }, 5000); // Poll every 5 seconds -// // } catch (errorMessage) { -// // console.error("Error in initial fetch:", errorMessage); -// // } -// // }; -// // void initialFetch(); -// void fetchActiveQuestion(); - -// intervalId = setInterval(() => { -// void fetchActiveQuestion(); -// }, 5000); -// return () => { -// if (intervalId) { -// clearInterval(intervalId); -// } -// }; -// }, [isAccessLoading, hasAccess, courseSessionId]); // Dependency on memoized fetchActiveQuestion - -// // Handle loading state -// if ((loading && !currentQuestion) || isAccessLoading) { -// return ( -//
-//
-//
-//

Loading question...

-//
-//
-// ); -// } - -// if (error || !currentQuestion) { -// return ( -//
-//
-//

-// {error ?? "No active question at this time"} -//

-// -//
-//
-// ); -// } - -// // Handle answer selection (works for both MCQ and MSQ) -// const handleSelectionChange = (value: number | number[]) => { -// setSelectedValues(value); -// }; - -// const handleSubmit = async () => { -// if ( -// !selectedValues || -// (Array.isArray(selectedValues) && selectedValues.length === 0) || -// !currentQuestion -// ) { -// return; -// } -// const optionIds = Array.isArray(selectedValues) ? selectedValues : [selectedValues]; - -// try { -// 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 { -// setSubmitting(false); -// } -// }; - -// return ( -//
-//
-// {/* Back Button */} -//
-// -//
- -// {/* Question header and count */} -//
-//
-//

Live Question:

-//
-// Question {questionCount} -//
-//
-//
- -// {/* Loading indicator for refreshing questions */} -// {/* {loading && currentQuestion && ( -//
-//
-// Syncing... -//
-// )} */} - -// {/* Question Card */} -//
-// -//
- -// {/* Answer Options */} -// - -// {/* Submit Button */} -// - -// {/* Submission Status */} -// {submitting && ( -//

Saving your answer...

-// )} - -// {/* Footer Message */} -//

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

-//
-//
-// ); -// } - -// Modified LivePoll.tsx with simplified WebSocket integration "use client"; import { Option as PrismaOption, Question as PrismaQuestion } from "@prisma/client"; import { useParams, useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import AnswerOptions from "@/components/ui/answerOptions"; import BackButton from "@/components/ui/backButton"; import QuestionCard from "@/components/ui/questionCard"; @@ -293,14 +17,60 @@ type fetchCourseSessionQuestionResponse = { totalQuestions: number; }; -export default function LivePoll({ courseSessionId }: { courseSessionId: number }) { +// Define proper types for WebSocket messages +type WebSocketMessageType = + | "connected" + | "response_saved" + | "question_changed" + | "response_update" + | "error" + | "echo" + | "binary" + | "student_response"; + +interface WebSocketMessageBase { + type: WebSocketMessageType; + message?: string; +} + +interface QuestionChangedMessage extends WebSocketMessageBase { + type: "question_changed"; + questionId: number; +} + +interface ResponseSavedMessage extends WebSocketMessageBase { + type: "response_saved"; + message?: string; +} + +interface StudentResponseMessage extends WebSocketMessageBase { + type: "student_response"; + questionId: number; + optionIds: number[]; +} + +// Union type for all message types +type WebSocketMessage = + | QuestionChangedMessage + | ResponseSavedMessage + | StudentResponseMessage + | WebSocketMessageBase; + +export default function LivePoll({ + courseSessionId, +}: { + courseSessionId: number; +}): React.JSX.Element { // Extract the course-session-id from the URL const params = useParams(); const router = useRouter(); const { toast } = useToast(); const courseId = parseInt(params.courseId as string); - const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "STUDENT" }); + const { hasAccess: _hasAccess, isLoading: isAccessLoading } = useAccess({ + courseId, + role: "STUDENT", + }); const [currentQuestion, setCurrentQuestion] = useState(null); const [loading, setLoading] = useState(true); @@ -308,7 +78,7 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number const [submitting, setSubmitting] = useState(false); const [questionCount, setQuestionCount] = useState("1"); const [isConnected, setIsConnected] = useState(false); - const [messages, setMessages] = useState([]); + const [_messages, setMessages] = useState([]); // Use useRef for activeQuestionId to prevent unnecessary re-renders const activeQuestionIdRef = useRef(null); @@ -380,11 +150,7 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number } finally { setLoading(false); } - }, [courseSessionId]); // Only depends on courseSessionId - // The improved WebSocket connection handling for LivePoll.tsx - - // This is the part that needs to be updated in your LivePoll.tsx file - // Replace the entire useEffect that sets up the WebSocket with this code: + }, [courseSessionId, toast, router]); // Added dependencies // Setup WebSocket connection useEffect(() => { @@ -408,8 +174,8 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number }; ws.onmessage = (event) => { - let data; - let messageText; + let data: WebSocketMessage | null = null; + let messageText = ""; // Display the raw message for debugging console.log("Raw message received:", event.data); @@ -419,24 +185,25 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number if (typeof event.data === "string") { try { // Try to parse as JSON - data = JSON.parse(event.data); + data = JSON.parse(event.data) as WebSocketMessage; messageText = `Received JSON: ${JSON.stringify(data)}`; console.log("Parsed JSON:", data); // Process valid JSON message - if (data && data.type) { - // Handle different message types - if (data.type === "question_changed" && data.questionId) { + if (data?.type) { + // Fixed with optional chaining + // Type guard for question_changed + if (data.type === "question_changed" && "questionId" in data) { // Refresh the question when the instructor changes it activeQuestionIdRef.current = null; // Force refresh - fetchActiveQuestion(); + void fetchActiveQuestion(); } else if (data.type === "response_saved") { - toast({ description: data.message || "Response saved" }); + toast({ description: data.message ?? "Response saved" }); // Fixed with nullish coalescing setSubmitting(false); // Reset submitting state on success } else if (data.type === "error") { toast({ variant: "destructive", - description: data.message || "Error occurred", + description: data.message ?? "Error occurred", // Fixed with nullish coalescing }); setSubmitting(false); // Reset submitting state on error } else if (data.type === "connected") { @@ -447,16 +214,18 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number // We can safely ignore this for the student response flow } } - } catch (e) { - // If it fails to parse as JSON, it's likely a non-JSON text message - console.log("Not valid JSON, treating as text:", e.message); - + } catch (_) { + // Fixed unused variable // This is from the old server - we need to handle this format const message = event.data; messageText = `Received text: ${message}`; // Check if this is a response to our student submission - if (message.includes("student_response") && submitting) { + if ( + typeof message === "string" && + message.includes("student_response") && + submitting + ) { // This is likely a response to our student submission toast({ description: "Your answer has been recorded" }); setSubmitting(false); // Reset submitting state @@ -464,16 +233,21 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number } } else { // Handle binary data if needed - data = { type: "binary", message: "Binary data received" }; + data = { + type: "binary", + message: "Binary data received", + }; messageText = "Received: Binary data"; console.log("Received binary data"); } // Add message to list for debugging setMessages((prev) => [...prev, messageText]); - } catch (error) { - console.error("Error processing message:", error); - setMessages((prev) => [...prev, `Error processing message: ${error}`]); + } catch (err: unknown) { + // Fixed catch callback variable type + const errorStr = err instanceof Error ? err.message : "Unknown error"; + console.error("Error processing message:", errorStr); + setMessages((prev) => [...prev, `Error processing message: ${errorStr}`]); setSubmitting(false); // Reset submitting state on error } }; @@ -485,21 +259,22 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number setSubmitting(false); // Reset submitting state when connection closes }; - ws.onerror = (error) => { - console.error("WebSocket error:", error); + ws.onerror = (wsError) => { + console.error("WebSocket error:", wsError); setMessages((prev) => [...prev, "WebSocket error occurred"]); setSubmitting(false); // Reset submitting state on error }; // Initial fetch - fetchActiveQuestion(); + void fetchActiveQuestion(); return () => { if (ws.readyState === WebSocket.OPEN) { ws.close(); } }; - }, [courseSessionId, fetchActiveQuestion]); + }, [courseSessionId, fetchActiveQuestion, toast, submitting]); + // Handle loading state if ((loading && !currentQuestion) || isAccessLoading) { return ( @@ -522,7 +297,7 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number + {/* Submission Status - crucial for visual feedback */} + {submitting &&

Submitting...

} + {/* Footer Message */}

Instructor will start the next question shortly... diff --git a/lib/websocket.ts b/lib/websocket.ts index 94ca0f2..6c97c0c 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -1,14 +1,133 @@ -import { WebSocketServer } from "ws"; +import { Server as HttpServer, IncomingMessage } from "http"; +import { WebSocket, WebSocketServer } from "ws"; + +// Define connection parameters type +type ConnectionParams = { + sessionId?: string; + userId?: string; +}; + +// Define message types +type StudentResponseMessage = { + type: "student_response"; + questionId: number; + optionIds: number[]; +}; + +type ActiveQuestionUpdateMessage = { + type: "active_question_update"; + questionId: number; + courseSessionId?: number; +}; + +type ResponseSavedMessage = { + type: "response_saved"; + message: string; + data?: { + questionId?: number; + optionIds?: number[]; + originalMessage?: string; + }; +}; + +type ResponseUpdateMessage = { + type: "response_update"; + questionId: number; + responseCount: number; +}; + +type QuestionChangedMessage = { + type: "question_changed"; + questionId: number; +}; + +type ConnectedMessage = { + type: "connected"; + message: string; +}; + +type ErrorMessage = { + type: "error"; + message: string; +}; + +type TextMessage = { + type: "text"; + message: string; +}; + +// Union type for all message types +type WebSocketMessage = + | StudentResponseMessage + | ActiveQuestionUpdateMessage + | ResponseSavedMessage + | ResponseUpdateMessage + | QuestionChangedMessage + | ConnectedMessage + | ErrorMessage + | TextMessage; + +// Type for unknown parsed data +type UnknownData = Record; // Store all active connections -const connections = new Map(); +const connections = new Map>(); -export function initWebSocketServer(server) { +// Function declaration moved to fix "used before defined" error +function broadcastToSession(sessionId: string, message: WebSocketMessage): void { + const sessConnections = connections.get(sessionId); + if (!sessConnections) return; + + console.log(`Broadcasting to session ${sessionId}`); + + try { + // Ensure message is a proper object before stringifying + const messageObj: WebSocketMessage = + typeof message === "string" + ? (JSON.parse(message) as WebSocketMessage) // Convert string to object if it's JSON + : message; // Use as is if it's already an object + + const messageStr = JSON.stringify(messageObj); + + for (const connection of sessConnections.values()) { + try { + connection.send(messageStr); + } catch (err) { + console.error("Error sending broadcast to client:", err); + } + } + } catch (error) { + console.error("Error broadcasting message:", error); + + // Fallback if message isn't valid JSON + if (typeof message === "string") { + const fallbackMsg = JSON.stringify({ + type: "text", + message, + } as TextMessage); + + for (const connection of sessConnections.values()) { + try { + connection.send(fallbackMsg); + } catch (err) { + console.error("Error sending fallback broadcast:", err); + } + } + } + } +} + +export function initWebSocketServer(server: HttpServer): WebSocketServer { const wss = new WebSocketServer({ noServer: true }); // Handle upgrade requests - server.on("upgrade", (request, socket, head) => { + server.on("upgrade", (request: IncomingMessage, socket, head) => { try { + if (!request.url) { + socket.destroy(); + return; + } + const { pathname, searchParams } = new URL( request.url, `http://${request.headers.host}`, @@ -37,209 +156,167 @@ export function initWebSocketServer(server) { }); // Handle WebSocket connections - wss.on("connection", (ws, request, connectionParams = {}) => { - const { sessionId, userId } = connectionParams; - - // Handle test connections - if (!sessionId && !userId) { - console.log("Test WebSocket connection established"); - - // FIXED: Always use JSON format for all messages - ws.send( - JSON.stringify({ - type: "connected", - message: "Connected to WebSocket test server", - }), - ); - - ws.on("message", (message) => { - console.log("Test message received:", message.toString()); - - // Try to parse as JSON first - // Replace both parts in your websocket.js file with this version: - - try { - // Parse the message to see if it's valid JSON - const jsonData = JSON.parse(message.toString()); - - // If it is, echo it back with proper JSON response - ws.send( - JSON.stringify({ - type: "response_saved", // Change this from 'echo' to 'response_saved' - message: "Your message has been received", - data: jsonData, - }), - ); - } catch (e) { - // If not valid JSON, still respond with JSON format - ws.send( - JSON.stringify({ - type: "response_saved", // Change this from 'echo' to 'response_saved' - message: "Your message has been received", - data: { - originalMessage: message.toString(), - }, - }), - ); - } - }); - - return; - } - - // Handle poll connections - console.log( - `Poll WebSocket connection established: SessionID=${sessionId}, UserID=${userId}`, - ); - - // Store the connection - if (!connections.has(sessionId)) { - connections.set(sessionId, new Map()); - } - const sessionConnections = connections.get(sessionId); - sessionConnections.set(userId, ws); - - // Send connection confirmation - ws.send( - JSON.stringify({ - type: "connected", - message: "Connected to poll session", - }), - ); - - ws.on("message", (message) => { - try { - // Log the raw message first - console.log("Raw message received:", message.toString()); - - // Try to parse the message - const data = JSON.parse(message.toString()); - - // Log all incoming messages to terminal - console.log("\n===== STUDENT RESPONSE ====="); - console.log("Session ID:", sessionId); - console.log("User ID:", userId); - console.log("Message Data:", data); - console.log("===========================\n"); - - // If this is a student response - if (data.type === "student_response") { - // Extract the data - const { questionId, optionIds } = data; - - // Log to console for testing - console.log( - `Student response received: QuestionID=${questionId}, OptionIDs=${optionIds.join(", ")}`, - ); - - // Send confirmation back to student - ws.send( - JSON.stringify({ - type: "response_saved", - message: "Your answer has been recorded", - data: { - questionId, - optionIds, - }, - }), - ); - - // Broadcast to all clients in this session that a new response has been received - broadcastToSession(sessionId, { - type: "response_update", - questionId, - // We don't have actual counts, but for testing we can just increment - responseCount: Math.floor(Math.random() * 20) + 1, // Random count for testing - }); - } - - // If instructor is updating the active question - else if (data.type === "active_question_update") { - console.log(`Active question updated: QuestionID=${data.questionId}`); - - // Broadcast to all clients in this session - broadcastToSession(sessionId, { - type: "question_changed", - questionId: data.questionId, - }); - } - } catch (error) { - console.error("Error processing WebSocket message:", error); - - // Even on error, respond with proper JSON + wss.on( + "connection", + (ws: WebSocket, request: IncomingMessage, connectionParams: ConnectionParams = {}) => { + const { sessionId, userId } = connectionParams; + + // Handle test connections + if (!sessionId && !userId) { + // FIXED: Always use JSON format for all messages ws.send( JSON.stringify({ - type: "error", - message: "Invalid message format", - }), + type: "connected", + message: "Connected to WebSocket test server", + } as ConnectedMessage), ); - } - }); - ws.on("error", (error) => { - console.error("WebSocket connection error:", error); - }); + ws.on("message", (message: Buffer) => { + try { + // Parse the message to see if it's valid JSON + const jsonData = JSON.parse(message.toString()) as UnknownData; + + // If it is, echo it back with proper JSON response + ws.send( + JSON.stringify({ + type: "response_saved", + message: "Your message has been received", + data: jsonData, + } as ResponseSavedMessage), + ); + } catch (_parseError) { + // If not valid JSON, still respond with JSON format + ws.send( + JSON.stringify({ + type: "response_saved", + message: "Your message has been received", + data: { + originalMessage: message.toString(), + }, + } as ResponseSavedMessage), + ); + } + }); - ws.on("close", () => { - console.log(`WebSocket connection closed: SessionID=${sessionId}, UserID=${userId}`); + return; + } - // Clean up the connection + // Handle poll connections + console.log(`WebSocket connection: SessionID=${sessionId}, UserID=${userId}`); + + // Store the connection - Check for null/undefined if (sessionId && userId) { + if (!connections.has(sessionId)) { + connections.set(sessionId, new Map()); + } const sessionConnections = connections.get(sessionId); if (sessionConnections) { - sessionConnections.delete(userId); - - if (sessionConnections.size === 0) { - connections.delete(sessionId); - } + sessionConnections.set(userId, ws); } - } - }); - }); - return wss; -} + // Send connection confirmation + ws.send( + JSON.stringify({ + type: "connected", + message: "Connected to poll session", + } as ConnectedMessage), + ); -// Function to broadcast a message to all connections in a session -function broadcastToSession(sessionId, message) { - const sessionConnections = connections.get(sessionId); - if (!sessionConnections) return; + ws.on("message", (message: Buffer) => { + try { + // Try to parse the message + const data = JSON.parse(message.toString()) as UnknownData; + + // If this is a student response + if (data.type === "student_response") { + // Type checking and extraction + const typedData = data as StudentResponseMessage; + const questionId = typedData.questionId; + const optionIds = typedData.optionIds; + + // Validate required fields + if (typeof questionId !== "number" || !Array.isArray(optionIds)) { + throw new Error("Invalid student_response format"); + } + + // Single essential log for student response + console.log( + `Student response: Session=${sessionId}, Question=${questionId}, Options=${optionIds.join(", ")}`, + ); + + // Send confirmation back to student + ws.send( + JSON.stringify({ + type: "response_saved", + message: "Your answer has been recorded", + data: { + questionId, + optionIds, + }, + } as ResponseSavedMessage), + ); + + // Broadcast to all clients in this session that a new response has been received + broadcastToSession(sessionId, { + type: "response_update", + questionId, + // We don't have actual counts, but for testing we can just increment + responseCount: Math.floor(Math.random() * 20) + 1, // Random count for testing + } as ResponseUpdateMessage); + } + + // If instructor is updating the active question + else if (data.type === "active_question_update") { + // Type checking + const typedData = data as ActiveQuestionUpdateMessage; + const questionId = typedData.questionId; + + // Validate required fields + if (typeof questionId !== "number") { + throw new Error("Invalid active_question_update format"); + } + + console.log(`Active question updated: QuestionID=${questionId}`); + + // Broadcast to all clients in this session + broadcastToSession(sessionId, { + type: "question_changed", + questionId, + } as QuestionChangedMessage); + } + } catch (error) { + console.error("Error processing WebSocket message:", error); + + // Even on error, respond with proper JSON + ws.send( + JSON.stringify({ + type: "error", + message: "Invalid message format", + } as ErrorMessage), + ); + } + }); - console.log(`Broadcasting to session ${sessionId}:`, message); + ws.on("error", (error) => { + console.error("WebSocket connection error:", error); + }); - try { - // Ensure message is a proper object before stringifying - const messageObj = - typeof message === "string" - ? JSON.parse(message) // Convert string to object if it's JSON - : message; // Use as is if it's already an object + ws.on("close", () => { + console.log(`WebSocket connection closed: SessionID=${sessionId}`); - const messageStr = JSON.stringify(messageObj); + // Clean up the connection + const localSessionConnections = connections.get(sessionId); + if (localSessionConnections) { + localSessionConnections.delete(userId); - for (const connection of sessionConnections.values()) { - try { - connection.send(messageStr); - } catch (err) { - console.error("Error sending broadcast to client:", err); + if (localSessionConnections.size === 0) { + connections.delete(sessionId); + } + } + }); } - } - } catch (error) { - console.error("Error broadcasting message:", error); + }, + ); - // Fallback if message isn't valid JSON - if (typeof message === "string") { - const fallbackMsg = JSON.stringify({ - type: "text", - message: message, - }); - - for (const connection of sessionConnections.values()) { - try { - connection.send(fallbackMsg); - } catch (err) { - console.error("Error sending fallback broadcast:", err); - } - } - } - } + return wss; } diff --git a/server.ts b/server.ts index e277bc5..586a0fa 100644 --- a/server.ts +++ b/server.ts @@ -1,30 +1,44 @@ +import { createServer } from "http"; +import { parse } from "url"; +import next from "next"; +import { initWebSocketServer } from "./lib/websocket"; -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 dev = process.env.NODE_ENV !== "production"; const app = next({ dev }); const handle = app.getRequestHandler(); -app.prepare().then(() => { - const server = createServer((req, res) => { - const parsedUrl = parse(req.url!, true); - - // handle WebSocket requests - if (parsedUrl.pathname === '/ws') { - res.writeHead(426); - res.end(); - return; - } - - handle(req, res, parsedUrl); - }); +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; + } + + // handle WebSocket requests + if (parsedUrl.pathname === "/ws") { + res.writeHead(426); + res.end(); + return; + } + + handle(req, res, parsedUrl); + }); - initWebSocketServer(server); + initWebSocketServer(server); - server.listen(3000, () => { - console.log('> Ready on http://localhost:3000'); - }); -}); \ No newline at end of file + // 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) => { + console.error("Error preparing Next.js app:", error); + process.exit(1); + }); diff --git a/tsconfig.server.json b/tsconfig.server.json index 6dffeda..bc7d1d3 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -1,11 +1,11 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "outDir": "dist", - "target": "es2017", - "isolatedModules": false, - "noEmit": false - }, - "include": ["server.ts", "lib/websocket.ts"] -} \ No newline at end of file + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist", + "target": "es2017", + "isolatedModules": false, + "noEmit": false + }, + "include": ["server.ts", "lib/websocket.ts"] +} From 5e848830bd13e47d9992c1b1b5d5b166e4285d13 Mon Sep 17 00:00:00 2001 From: Ulises Salinas Date: Tue, 20 May 2025 09:54:12 -0700 Subject: [PATCH 04/24] Set up simple websocket connection and testing area --- app/test-ws/page.tsx | 90 ++++++++++++++++++++ lib/websocket.ts | 46 +++++++++++ package-lock.json | 190 +++++++++++++++++++++++++++++++++++++++++++ package.json | 9 +- server.ts | 29 +++++++ tsconfig.server.json | 11 +++ 6 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 app/test-ws/page.tsx create mode 100644 lib/websocket.ts create mode 100644 server.ts create mode 100644 tsconfig.server.json diff --git a/app/test-ws/page.tsx b/app/test-ws/page.tsx new file mode 100644 index 0000000..88f8f3a --- /dev/null +++ b/app/test-ws/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export default function TestWebSocket() { + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(''); + const [ws, setWs] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + const socket = new WebSocket('ws://localhost:3000/ws'); + + socket.onopen = () => { + console.log('Connected to WebSocket'); + setMessages(prev => [...prev, 'Connected to WebSocket']); + setIsConnected(true); + }; + + socket.onmessage = (event) => { + console.log('Received:', event.data); + setMessages(prev => [...prev, event.data]); + }; + + socket.onclose = () => { + console.log('Disconnected from WebSocket'); + setMessages(prev => [...prev, 'Disconnected from WebSocket']); + setIsConnected(false); + }; + + socket.onerror = (error) => { + console.error('WebSocket error:', error); + setMessages(prev => [...prev, 'WebSocket error occurred']); + }; + + setWs(socket); + + return () => { + if (socket.readyState === WebSocket.OPEN) { + socket.close(); + } + }; + }, []); + + const sendMessage = () => { + if (ws && ws.readyState === WebSocket.OPEN && inputMessage) { + ws.send(inputMessage); + setInputMessage(''); + } + }; + + return ( +

+

WebSocket Test

+ +
+
+
+ {isConnected ? 'Connected' : 'Disconnected'} +
+ setInputMessage(e.target.value)} + className="border p-2 mr-2" + placeholder="Type a message..." + disabled={!isConnected} + /> + +
+ +
+

Messages:

+
+ {messages.map((msg, index) => ( +
+ {msg} +
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/lib/websocket.ts b/lib/websocket.ts new file mode 100644 index 0000000..861a913 --- /dev/null +++ b/lib/websocket.ts @@ -0,0 +1,46 @@ +import { WebSocketServer, WebSocket } from 'ws'; +import { Server } from 'http'; + +let wss: WebSocketServer; + +export function initWebSocketServer(server: Server) { + wss = new WebSocketServer({ + server, + clientTracking: true, + perMessageDeflate: false + }); + + wss.on('connection', (ws: WebSocket) => { + console.log('Client connected'); + + // Send welcome message + ws.send('Connected to WebSocket server!'); + + ws.on('message', (message: Buffer) => { + try { + console.log('Received:', message.toString()); + ws.send(`Server received: ${message}`); + } catch (error) { + console.error('Error handling message:', error); + } + }); + + ws.on('error', (error) => { + console.error('WebSocket error:', error); + }); + + ws.on('close', (code: number, reason: Buffer) => { + console.log('Client disconnected:', code, reason.toString()); + }); + }); + + wss.on('error', (error) => { + console.error('WebSocket server error:', error); + }); + + return wss; +} + +export function getWebSocketServer() { + return wss; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 82c96dd..2c0f183 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@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", @@ -46,6 +47,7 @@ "swr": "^2.3.2", "tailwind-merge": "^2.5.5", "vaul": "^1.1.2", + "ws": "^8.18.2", "zod": "^3.24.2" }, "devDependencies": { @@ -65,6 +67,7 @@ "shadcn-ui": "^0.9.4", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", + "ts-node": "^10.9.2", "typescript": "^5" } }, @@ -556,6 +559,30 @@ "node": ">=6.9.0" } }, + "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", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "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", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", @@ -3531,6 +3558,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", @@ -3664,6 +3719,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", @@ -3902,6 +3966,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", @@ -4886,6 +4963,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", @@ -7868,6 +7952,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", @@ -11170,6 +11261,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", @@ -11466,6 +11618,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", @@ -11752,6 +11911,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", @@ -11770,6 +11950,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 5507b09..e2394da 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", @@ -33,6 +33,7 @@ "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@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", @@ -54,6 +55,7 @@ "swr": "^2.3.2", "tailwind-merge": "^2.5.5", "vaul": "^1.1.2", + "ws": "^8.18.2", "zod": "^3.24.2" }, "devDependencies": { @@ -73,6 +75,7 @@ "shadcn-ui": "^0.9.4", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", + "ts-node": "^10.9.2", "typescript": "^5" } } diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..f5aea2f --- /dev/null +++ b/server.ts @@ -0,0 +1,29 @@ +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(); + +app.prepare().then(() => { + const server = createServer((req, res) => { + const parsedUrl = parse(req.url!, true); + + // handle WebSocket requests + if (parsedUrl.pathname === '/ws') { + res.writeHead(426); + res.end(); + return; + } + + handle(req, res, parsedUrl); + }); + + initWebSocketServer(server); + + server.listen(3000, () => { + console.log('> Ready on http://localhost:3000'); + }); +}); \ No newline at end of file diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..6dffeda --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist", + "target": "es2017", + "isolatedModules": false, + "noEmit": false + }, + "include": ["server.ts", "lib/websocket.ts"] +} \ No newline at end of file From 5844fad88a57df3c5dd5f6c642fa058c8df10154 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 20 May 2025 14:58:33 -0700 Subject: [PATCH 05/24] established websocket to send student responses to server/db --- components/LivePoll.tsx | 557 ++++++++++++++++++++++++++++++++++------ lib/websocket.ts | 277 +++++++++++++++++--- server.ts | 1 + 3 files changed, 714 insertions(+), 121 deletions(-) diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index aadcf1c..e2ab29a 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -1,5 +1,279 @@ -// app/active-session/[course-session-id]/live-poll/page.tsx - +// // app/active-session/[course-session-id]/live-poll/page.tsx + +// "use client"; +// import { Option as PrismaOption, Question as PrismaQuestion } from "@prisma/client"; +// import { useParams, useRouter } from "next/navigation"; +// import { useCallback, useEffect, useRef, useState } from "react"; +// import AnswerOptions from "@/components/ui/answerOptions"; +// import BackButton from "@/components/ui/backButton"; +// import QuestionCard from "@/components/ui/questionCard"; +// import useAccess from "@/hooks/use-access"; +// import { useToast } from "@/hooks/use-toast"; + +// type QuestionWithOptions = PrismaQuestion & { +// options: PrismaOption[]; +// }; + +// type fetchCourseSessionQuestionResponse = { +// activeQuestionId: number; +// totalQuestions: number; +// }; + +// export default function LivePoll({ courseSessionId }: { courseSessionId: number }) { +// // Extract the course-session-id from the URL +// const params = useParams(); +// const router = useRouter(); +// const { toast } = useToast(); + +// const courseId = parseInt(params.courseId as string); +// const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "STUDENT" }); + +// const [currentQuestion, setCurrentQuestion] = useState(null); +// const [loading, setLoading] = useState(true); +// const [error, setError] = useState(null); +// const [submitting, setSubmitting] = useState(false); +// const [questionCount, setQuestionCount] = useState("1"); + +// // Use useRef for activeQuestionId to prevent unnecessary re-renders +// const activeQuestionIdRef = useRef(null); + +// // Unified state for selected values (either single number or array of numbers) +// const [selectedValues, setSelectedValues] = useState(null); + +// // Function to fetch active question - use useCallback to memoize +// const fetchActiveQuestion = useCallback(async () => { +// try { +// // First, get the session to get the activeQuestionId +// const sessionResponse = await fetch( +// `/api/fetchCourseSessionQuestion?sessionId=${courseSessionId}`, +// ); + +// if (!sessionResponse.ok) { +// return toast({ +// variant: "destructive", +// description: "Failed to fetch course session", +// }); +// } + +// const sessionData = +// (await sessionResponse.json()) as fetchCourseSessionQuestionResponse; +// const newActiveQuestionId = sessionData.activeQuestionId; +// // If the active question hasn't changed, don't re-fetch +// if (activeQuestionIdRef.current === newActiveQuestionId) { +// return; +// } +// setLoading(true); + +// // Update the ref +// activeQuestionIdRef.current = newActiveQuestionId; +// // If active question ID is 0 or null, no question is active +// if (!newActiveQuestionId) { +// setError("No active question at this time"); +// setLoading(false); +// return; +// } +// const questionResponse = await fetch( +// `/api/fetchQuestionById?questionId=${String(newActiveQuestionId)}`, +// ); + +// if (!questionResponse.ok) { +// toast({ variant: "destructive", description: "Failed to fetch question" }); +// router.refresh(); +// return; +// } + +// const questionData = (await questionResponse.json()) as QuestionWithOptions; +// setCurrentQuestion(questionData); + +// // Reset selected values based on question type +// setSelectedValues(questionData.type === "MCQ" ? null : []); + +// // Use the position directly from the question object +// // Add 1 since positions typically start at 0 but display to users starts at 1 +// const currentNumber = questionData.position + 1; + +// setQuestionCount(String(currentNumber)); +// } catch (err) { +// toast({ variant: "destructive", description: "An error occurre" }); +// console.error(err); +// } finally { +// setLoading(false); +// } +// }, [courseSessionId]); // Only depends on courseSessionId + +// // Initial fetch and polling setup +// useEffect(() => { +// if (!courseSessionId) return; +// if (isAccessLoading) { +// return; +// } +// if (!hasAccess) { +// toast({ variant: "destructive", description: "Access denied!" }); +// router.push("/dashboard"); +// return; +// } + +// let intervalId: NodeJS.Timeout | null = null; + +// // Create an async function to handle the initial fetch +// // const initialFetch = async () => { +// // try { +// // // Wait for the initial fetch to complete +// // // await fetchActiveQuestion(); + +// // // Once initial fetch is done, start polling +// // intervalId = setInterval(() => { +// // void fetchActiveQuestion(); +// // }, 5000); // Poll every 5 seconds +// // } catch (errorMessage) { +// // console.error("Error in initial fetch:", errorMessage); +// // } +// // }; +// // void initialFetch(); +// void fetchActiveQuestion(); + +// intervalId = setInterval(() => { +// void fetchActiveQuestion(); +// }, 5000); +// return () => { +// if (intervalId) { +// clearInterval(intervalId); +// } +// }; +// }, [isAccessLoading, hasAccess, courseSessionId]); // Dependency on memoized fetchActiveQuestion + +// // Handle loading state +// if ((loading && !currentQuestion) || isAccessLoading) { +// return ( +//
+//
+//
+//

Loading question...

+//
+//
+// ); +// } + +// if (error || !currentQuestion) { +// return ( +//
+//
+//

+// {error ?? "No active question at this time"} +//

+// +//
+//
+// ); +// } + +// // Handle answer selection (works for both MCQ and MSQ) +// const handleSelectionChange = (value: number | number[]) => { +// setSelectedValues(value); +// }; + +// const handleSubmit = async () => { +// if ( +// !selectedValues || +// (Array.isArray(selectedValues) && selectedValues.length === 0) || +// !currentQuestion +// ) { +// return; +// } +// const optionIds = Array.isArray(selectedValues) ? selectedValues : [selectedValues]; + +// try { +// 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 { +// setSubmitting(false); +// } +// }; + +// return ( +//
+//
+// {/* Back Button */} +//
+// +//
+ +// {/* Question header and count */} +//
+//
+//

Live Question:

+//
+// Question {questionCount} +//
+//
+//
+ +// {/* Loading indicator for refreshing questions */} +// {/* {loading && currentQuestion && ( +//
+//
+// Syncing... +//
+// )} */} + +// {/* Question Card */} +//
+// +//
+ +// {/* Answer Options */} +// + +// {/* Submit Button */} +// + +// {/* Submission Status */} +// {submitting && ( +//

Saving your answer...

+// )} + +// {/* Footer Message */} +//

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

+//
+//
+// ); +// } + +// Modified LivePoll.tsx with simplified WebSocket integration "use client"; import { Option as PrismaOption, Question as PrismaQuestion } from "@prisma/client"; import { useParams, useRouter } from "next/navigation"; @@ -36,9 +310,12 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); const [questionCount, setQuestionCount] = useState("1"); + const [isConnected, setIsConnected] = useState(false); + const [messages, setMessages] = useState([]); // Use useRef for activeQuestionId to prevent unnecessary re-renders const activeQuestionIdRef = useRef(null); + const wsRef = useRef(null); // Unified state for selected values (either single number or array of numbers) const [selectedValues, setSelectedValues] = useState(null); @@ -61,20 +338,24 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number const sessionData = (await sessionResponse.json()) as fetchCourseSessionQuestionResponse; const newActiveQuestionId = sessionData.activeQuestionId; + // If the active question hasn't changed, don't re-fetch if (activeQuestionIdRef.current === newActiveQuestionId) { return; } + setLoading(true); // Update the ref activeQuestionIdRef.current = newActiveQuestionId; + // If active question ID is 0 or null, no question is active if (!newActiveQuestionId) { setError("No active question at this time"); setLoading(false); return; } + const questionResponse = await fetch( `/api/fetchQuestionById?questionId=${String(newActiveQuestionId)}`, ); @@ -97,61 +378,131 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number setQuestionCount(String(currentNumber)); } catch (err) { - toast({ variant: "destructive", description: "An error occurre" }); + toast({ variant: "destructive", description: "An error occurred" }); console.error(err); } finally { setLoading(false); } }, [courseSessionId]); // Only depends on courseSessionId + // The improved WebSocket connection handling for LivePoll.tsx - // retrieve poll pause state every second - const { data: isPaused } = useQuery( - ["pollPauseState", courseSessionId], - () => (courseSessionId ? getSessionPauseState(courseSessionId) : Promise.resolve(null)), - { refetchInterval: 1000, enabled: !!courseSessionId }, - ); + // This is the part that needs to be updated in your LivePoll.tsx file + // Replace the entire useEffect that sets up the WebSocket with this code: - // Initial fetch and polling setup + // Setup WebSocket connection useEffect(() => { if (!courseSessionId) return; - if (isAccessLoading) { - return; - } - if (!hasAccess) { - toast({ variant: "destructive", description: "Access denied!" }); - router.push("/dashboard"); - return; - } - let intervalId: NodeJS.Timeout | null = null; - - // Create an async function to handle the initial fetch - // const initialFetch = async () => { - // try { - // // Wait for the initial fetch to complete - // // await fetchActiveQuestion(); - - // // Once initial fetch is done, start polling - // intervalId = setInterval(() => { - // void fetchActiveQuestion(); - // }, 5000); // Poll every 5 seconds - // } catch (errorMessage) { - // console.error("Error in initial fetch:", errorMessage); - // } - // }; - // void initialFetch(); - void fetchActiveQuestion(); - - intervalId = setInterval(() => { - void fetchActiveQuestion(); - }, 5000); - return () => { - if (intervalId) { - clearInterval(intervalId); + // Generate a temporary user ID for testing + // In a real app, you would use the authenticated user's ID + const tempUserId = `test-user-${Math.floor(Math.random() * 1000)}`; + + // Create WebSocket connection + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket( + `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSessionId}&userId=${tempUserId}`, + ); + wsRef.current = ws; + + ws.onopen = () => { + console.log("WebSocket connection established"); + setIsConnected(true); + setMessages((prev) => [...prev, "Connected to WebSocket"]); + }; + + ws.onmessage = (event) => { + let data; + let messageText; + + // Display the raw message for debugging + console.log("Raw message received:", event.data); + + // Try to parse as JSON, but handle plain text too + try { + if (typeof event.data === "string") { + try { + // Try to parse as JSON + data = JSON.parse(event.data); + messageText = `Received JSON: ${JSON.stringify(data)}`; + console.log("Parsed JSON:", data); + + // Process valid JSON message + if (data && data.type) { + // Handle different message types + if (data.type === "question_changed" && data.questionId) { + // Refresh the question when the instructor changes it + activeQuestionIdRef.current = null; // Force refresh + fetchActiveQuestion(); + } else if (data.type === "response_saved") { + toast({ description: data.message || "Response saved" }); + setSubmitting(false); // Reset submitting state on success + } else if (data.type === "error") { + toast({ + variant: "destructive", + description: data.message || "Error occurred", + }); + setSubmitting(false); // Reset submitting state on error + } else if (data.type === "connected") { + console.log("WebSocket connection confirmed:", data.message); + } else if (data.type === "echo") { + console.log("Server echo:", data.message); + // This is likely a text response echoed back + // We can safely ignore this for the student response flow + } + } + } catch (e) { + // If it fails to parse as JSON, it's likely a non-JSON text message + console.log("Not valid JSON, treating as text:", e.message); + + // This is from the old server - we need to handle this format + const message = event.data; + messageText = `Received text: ${message}`; + + // Check if this is a response to our student submission + if (message.includes("student_response") && submitting) { + // This is likely a response to our student submission + toast({ description: "Your answer has been recorded" }); + setSubmitting(false); // Reset submitting state + } + } + } else { + // Handle binary data if needed + data = { type: "binary", message: "Binary data received" }; + messageText = "Received: Binary data"; + console.log("Received binary data"); + } + + // Add message to list for debugging + setMessages((prev) => [...prev, messageText]); + } catch (error) { + console.error("Error processing message:", error); + setMessages((prev) => [...prev, `Error processing message: ${error}`]); + setSubmitting(false); // Reset submitting state on error } }; - }, [isAccessLoading, hasAccess, courseSessionId]); // Dependency on memoized fetchActiveQuestion + ws.onclose = () => { + console.log("WebSocket connection closed"); + setIsConnected(false); + setMessages((prev) => [...prev, "Disconnected from WebSocket"]); + setSubmitting(false); // Reset submitting state when connection closes + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + setMessages((prev) => [...prev, "WebSocket error occurred"]); + setSubmitting(false); // Reset submitting state on error + }; + + // Initial fetch + fetchActiveQuestion(); + + return () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }; + }, [courseSessionId, fetchActiveQuestion]); // Handle loading state if ((loading && !currentQuestion) || isAccessLoading) { return ( @@ -171,6 +522,15 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number

{error ?? "No active question at this time"}

+
@@ -182,19 +542,43 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number setSelectedValues(value); }; - const handleSubmit = async () => { + // Submit response through WebSocket + const handleSubmit = () => { 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", { + + // Extract option IDs + const optionIds = Array.isArray(selectedValues) ? selectedValues : [selectedValues]; + + // Create message payload + const message = { + type: "student_response", + questionId: currentQuestion.id, + optionIds: optionIds, + }; + + // Log that we're submitting + console.log("Submitting answer:", message); + + // Send through WebSocket + wsRef.current.send(JSON.stringify(message)); + + // Add to local messages list + setMessages((prev) => [...prev, `Sent: ${JSON.stringify(message)}`]); + + // Also send via API as a fallback - make this async + fetch("/api/submitStudentResponse", { method: "POST", headers: { "Content-Type": "application/json", @@ -203,14 +587,27 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number questionId: currentQuestion.id, optionIds, }), - }); + }) + .then((response) => { + // If we don't get a WebSocket response in 2 seconds, reset submitting state + // (This is a fallback in case the WebSocket doesn't respond) + if (!response.ok) { + console.error("Failed to save answer via API"); + } + }) + .catch((error) => { + console.error("Error saving answer via API:", error); + // Reset submitting state after API error + setSubmitting(false); + }); - if (!response.ok) { - console.error("Failed to save answer"); - } + // Fallback timer in case WebSocket response is never received + setTimeout(() => { + setSubmitting(false); + }, 3000); } catch (submitError) { - console.error("Error saving answer:", submitError); - } finally { + console.error("Error submitting answer:", submitError); + toast({ variant: "destructive", description: "Failed to submit answer" }); setSubmitting(false); } }; @@ -223,6 +620,16 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number
+ {/* Connection status */} +
+
+
+ {isConnected ? "Connected" : "Disconnected"} +
+
+ {/* Question header and count */}
@@ -233,14 +640,6 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number
- {/* Loading indicator for refreshing questions */} - {/* {loading && currentQuestion && ( -
-
- Syncing... -
- )} */} - {/* Question Card */}
@@ -256,36 +655,30 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number {/* Submit Button */} - {/* Submission Status */} - {submitting && ( -

Saving your answer...

- )} - {/* 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/lib/websocket.ts b/lib/websocket.ts index 861a913..94ca0f2 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -1,46 +1,245 @@ -import { WebSocketServer, WebSocket } from 'ws'; -import { Server } from 'http'; - -let wss: WebSocketServer; - -export function initWebSocketServer(server: Server) { - wss = new WebSocketServer({ - server, - clientTracking: true, - perMessageDeflate: false - }); - - wss.on('connection', (ws: WebSocket) => { - console.log('Client connected'); - - // Send welcome message - ws.send('Connected to WebSocket server!'); - - ws.on('message', (message: Buffer) => { - try { - console.log('Received:', message.toString()); - ws.send(`Server received: ${message}`); - } catch (error) { - console.error('Error handling message:', error); - } - }); +import { WebSocketServer } from "ws"; - ws.on('error', (error) => { - console.error('WebSocket error:', error); - }); +// Store all active connections +const connections = new Map(); + +export function initWebSocketServer(server) { + const wss = new WebSocketServer({ noServer: true }); + + // Handle upgrade requests + server.on("upgrade", (request, socket, head) => { + try { + const { pathname, searchParams } = new URL( + request.url, + `http://${request.headers.host}`, + ); + + if (pathname === "/ws") { + // For test endpoint - keep this for backward compatibility + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); + } else if (pathname === "/ws/poll") { + // For poll connections + const sessionId = searchParams.get("sessionId"); + const userId = searchParams.get("userId"); - ws.on('close', (code: number, reason: Buffer) => { - console.log('Client disconnected:', code, reason.toString()); + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request, { sessionId, userId }); + }); + } else { + socket.destroy(); + } + } catch (error) { + console.error("Error in WebSocket upgrade:", error); + socket.destroy(); + } }); - }); - wss.on('error', (error) => { - console.error('WebSocket server error:', error); - }); + // Handle WebSocket connections + wss.on("connection", (ws, request, connectionParams = {}) => { + const { sessionId, userId } = connectionParams; + + // Handle test connections + if (!sessionId && !userId) { + console.log("Test WebSocket connection established"); + + // FIXED: Always use JSON format for all messages + ws.send( + JSON.stringify({ + type: "connected", + message: "Connected to WebSocket test server", + }), + ); + + ws.on("message", (message) => { + console.log("Test message received:", message.toString()); + + // Try to parse as JSON first + // Replace both parts in your websocket.js file with this version: + + try { + // Parse the message to see if it's valid JSON + const jsonData = JSON.parse(message.toString()); + + // If it is, echo it back with proper JSON response + ws.send( + JSON.stringify({ + type: "response_saved", // Change this from 'echo' to 'response_saved' + message: "Your message has been received", + data: jsonData, + }), + ); + } catch (e) { + // If not valid JSON, still respond with JSON format + ws.send( + JSON.stringify({ + type: "response_saved", // Change this from 'echo' to 'response_saved' + message: "Your message has been received", + data: { + originalMessage: message.toString(), + }, + }), + ); + } + }); + + return; + } + + // Handle poll connections + console.log( + `Poll WebSocket connection established: SessionID=${sessionId}, UserID=${userId}`, + ); + + // Store the connection + if (!connections.has(sessionId)) { + connections.set(sessionId, new Map()); + } + const sessionConnections = connections.get(sessionId); + sessionConnections.set(userId, ws); + + // Send connection confirmation + ws.send( + JSON.stringify({ + type: "connected", + message: "Connected to poll session", + }), + ); + + ws.on("message", (message) => { + try { + // Log the raw message first + console.log("Raw message received:", message.toString()); + + // Try to parse the message + const data = JSON.parse(message.toString()); + + // Log all incoming messages to terminal + console.log("\n===== STUDENT RESPONSE ====="); + console.log("Session ID:", sessionId); + console.log("User ID:", userId); + console.log("Message Data:", data); + console.log("===========================\n"); + + // If this is a student response + if (data.type === "student_response") { + // Extract the data + const { questionId, optionIds } = data; - return wss; + // Log to console for testing + console.log( + `Student response received: QuestionID=${questionId}, OptionIDs=${optionIds.join(", ")}`, + ); + + // Send confirmation back to student + ws.send( + JSON.stringify({ + type: "response_saved", + message: "Your answer has been recorded", + data: { + questionId, + optionIds, + }, + }), + ); + + // Broadcast to all clients in this session that a new response has been received + broadcastToSession(sessionId, { + type: "response_update", + questionId, + // We don't have actual counts, but for testing we can just increment + responseCount: Math.floor(Math.random() * 20) + 1, // Random count for testing + }); + } + + // If instructor is updating the active question + else if (data.type === "active_question_update") { + console.log(`Active question updated: QuestionID=${data.questionId}`); + + // Broadcast to all clients in this session + broadcastToSession(sessionId, { + type: "question_changed", + questionId: data.questionId, + }); + } + } catch (error) { + console.error("Error processing WebSocket message:", error); + + // Even on error, respond with proper JSON + ws.send( + JSON.stringify({ + type: "error", + message: "Invalid message format", + }), + ); + } + }); + + ws.on("error", (error) => { + console.error("WebSocket connection error:", error); + }); + + ws.on("close", () => { + console.log(`WebSocket connection closed: SessionID=${sessionId}, UserID=${userId}`); + + // Clean up the connection + if (sessionId && userId) { + const sessionConnections = connections.get(sessionId); + if (sessionConnections) { + sessionConnections.delete(userId); + + if (sessionConnections.size === 0) { + connections.delete(sessionId); + } + } + } + }); + }); + + return wss; } -export function getWebSocketServer() { - return wss; -} \ No newline at end of file +// Function to broadcast a message to all connections in a session +function broadcastToSession(sessionId, message) { + const sessionConnections = connections.get(sessionId); + if (!sessionConnections) return; + + console.log(`Broadcasting to session ${sessionId}:`, message); + + try { + // Ensure message is a proper object before stringifying + const messageObj = + typeof message === "string" + ? JSON.parse(message) // Convert string to object if it's JSON + : message; // Use as is if it's already an object + + const messageStr = JSON.stringify(messageObj); + + for (const connection of sessionConnections.values()) { + try { + connection.send(messageStr); + } catch (err) { + console.error("Error sending broadcast to client:", err); + } + } + } catch (error) { + console.error("Error broadcasting message:", error); + + // Fallback if message isn't valid JSON + if (typeof message === "string") { + const fallbackMsg = JSON.stringify({ + type: "text", + message: message, + }); + + for (const connection of sessionConnections.values()) { + try { + connection.send(fallbackMsg); + } catch (err) { + console.error("Error sending fallback broadcast:", err); + } + } + } + } +} diff --git a/server.ts b/server.ts index f5aea2f..e277bc5 100644 --- a/server.ts +++ b/server.ts @@ -1,3 +1,4 @@ + import { createServer } from 'http'; import { parse } from 'url'; import next from 'next'; From 615abde230b928a9f9410a7f51ae7406cfb12d1e Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 20 May 2025 16:05:08 -0700 Subject: [PATCH 06/24] got rid of debugging statements --- app/test-ws/page.tsx | 152 ++++++------- components/LivePoll.tsx | 434 +++++++++---------------------------- lib/websocket.ts | 459 +++++++++++++++++++++++----------------- server.ts | 62 +++--- tsconfig.server.json | 20 +- 5 files changed, 495 insertions(+), 632 deletions(-) diff --git a/app/test-ws/page.tsx b/app/test-ws/page.tsx index 88f8f3a..ea628dd 100644 --- a/app/test-ws/page.tsx +++ b/app/test-ws/page.tsx @@ -1,90 +1,94 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; +import { useEffect, useState } from "react"; export default function TestWebSocket() { - const [messages, setMessages] = useState([]); - const [inputMessage, setInputMessage] = useState(''); - const [ws, setWs] = useState(null); - const [isConnected, setIsConnected] = useState(false); + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(""); + const [ws, setWs] = useState(null); + const [isConnected, setIsConnected] = useState(false); - useEffect(() => { - const socket = new WebSocket('ws://localhost:3000/ws'); + useEffect(() => { + const socket = new WebSocket("ws://localhost:3000/ws"); - socket.onopen = () => { - console.log('Connected to WebSocket'); - setMessages(prev => [...prev, 'Connected to WebSocket']); - setIsConnected(true); - }; + socket.onopen = () => { + console.log("Connected to WebSocket"); + setMessages((prev) => [...prev, "Connected to WebSocket"]); + setIsConnected(true); + }; - socket.onmessage = (event) => { - console.log('Received:', event.data); - setMessages(prev => [...prev, event.data]); - }; + socket.onmessage = (event) => { + console.log("Received:", event.data); + setMessages((prev) => [...prev, event.data]); + }; - socket.onclose = () => { - console.log('Disconnected from WebSocket'); - setMessages(prev => [...prev, 'Disconnected from WebSocket']); - setIsConnected(false); - }; + socket.onclose = () => { + console.log("Disconnected from WebSocket"); + setMessages((prev) => [...prev, "Disconnected from WebSocket"]); + setIsConnected(false); + }; - socket.onerror = (error) => { - console.error('WebSocket error:', error); - setMessages(prev => [...prev, 'WebSocket error occurred']); - }; + socket.onerror = (error) => { + console.error("WebSocket error:", error); + setMessages((prev) => [...prev, "WebSocket error occurred"]); + }; - setWs(socket); + setWs(socket); - return () => { - if (socket.readyState === WebSocket.OPEN) { - socket.close(); - } + return () => { + if (socket.readyState === WebSocket.OPEN) { + socket.close(); + } + }; + }, []); + + const sendMessage = () => { + if (ws && ws.readyState === WebSocket.OPEN && inputMessage) { + ws.send(inputMessage); + setInputMessage(""); + } }; - }, []); - const sendMessage = () => { - if (ws && ws.readyState === WebSocket.OPEN && inputMessage) { - ws.send(inputMessage); - setInputMessage(''); - } - }; + return ( +
+

WebSocket Test

- return ( -
-

WebSocket Test

- -
-
-
- {isConnected ? 'Connected' : 'Disconnected'} -
- setInputMessage(e.target.value)} - className="border p-2 mr-2" - placeholder="Type a message..." - disabled={!isConnected} - /> - -
+
+
+
+ {isConnected ? "Connected" : "Disconnected"} +
+ { + setInputMessage(e.target.value); + }} + className="border p-2 mr-2" + placeholder="Type a message..." + disabled={!isConnected} + /> + +
-
-

Messages:

-
- {messages.map((msg, index) => ( -
- {msg} +
+

Messages:

+
+ {messages.map((msg, index) => ( +
+ {msg} +
+ ))} +
- ))}
-
-
- ); -} \ No newline at end of file + ); +} diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index e2ab29a..b74f24f 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -1,284 +1,7 @@ -// // app/active-session/[course-session-id]/live-poll/page.tsx - -// "use client"; -// import { Option as PrismaOption, Question as PrismaQuestion } from "@prisma/client"; -// import { useParams, useRouter } from "next/navigation"; -// import { useCallback, useEffect, useRef, useState } from "react"; -// import AnswerOptions from "@/components/ui/answerOptions"; -// import BackButton from "@/components/ui/backButton"; -// import QuestionCard from "@/components/ui/questionCard"; -// import useAccess from "@/hooks/use-access"; -// import { useToast } from "@/hooks/use-toast"; - -// type QuestionWithOptions = PrismaQuestion & { -// options: PrismaOption[]; -// }; - -// type fetchCourseSessionQuestionResponse = { -// activeQuestionId: number; -// totalQuestions: number; -// }; - -// export default function LivePoll({ courseSessionId }: { courseSessionId: number }) { -// // Extract the course-session-id from the URL -// const params = useParams(); -// const router = useRouter(); -// const { toast } = useToast(); - -// const courseId = parseInt(params.courseId as string); -// const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "STUDENT" }); - -// const [currentQuestion, setCurrentQuestion] = useState(null); -// const [loading, setLoading] = useState(true); -// const [error, setError] = useState(null); -// const [submitting, setSubmitting] = useState(false); -// const [questionCount, setQuestionCount] = useState("1"); - -// // Use useRef for activeQuestionId to prevent unnecessary re-renders -// const activeQuestionIdRef = useRef(null); - -// // Unified state for selected values (either single number or array of numbers) -// const [selectedValues, setSelectedValues] = useState(null); - -// // Function to fetch active question - use useCallback to memoize -// const fetchActiveQuestion = useCallback(async () => { -// try { -// // First, get the session to get the activeQuestionId -// const sessionResponse = await fetch( -// `/api/fetchCourseSessionQuestion?sessionId=${courseSessionId}`, -// ); - -// if (!sessionResponse.ok) { -// return toast({ -// variant: "destructive", -// description: "Failed to fetch course session", -// }); -// } - -// const sessionData = -// (await sessionResponse.json()) as fetchCourseSessionQuestionResponse; -// const newActiveQuestionId = sessionData.activeQuestionId; -// // If the active question hasn't changed, don't re-fetch -// if (activeQuestionIdRef.current === newActiveQuestionId) { -// return; -// } -// setLoading(true); - -// // Update the ref -// activeQuestionIdRef.current = newActiveQuestionId; -// // If active question ID is 0 or null, no question is active -// if (!newActiveQuestionId) { -// setError("No active question at this time"); -// setLoading(false); -// return; -// } -// const questionResponse = await fetch( -// `/api/fetchQuestionById?questionId=${String(newActiveQuestionId)}`, -// ); - -// if (!questionResponse.ok) { -// toast({ variant: "destructive", description: "Failed to fetch question" }); -// router.refresh(); -// return; -// } - -// const questionData = (await questionResponse.json()) as QuestionWithOptions; -// setCurrentQuestion(questionData); - -// // Reset selected values based on question type -// setSelectedValues(questionData.type === "MCQ" ? null : []); - -// // Use the position directly from the question object -// // Add 1 since positions typically start at 0 but display to users starts at 1 -// const currentNumber = questionData.position + 1; - -// setQuestionCount(String(currentNumber)); -// } catch (err) { -// toast({ variant: "destructive", description: "An error occurre" }); -// console.error(err); -// } finally { -// setLoading(false); -// } -// }, [courseSessionId]); // Only depends on courseSessionId - -// // Initial fetch and polling setup -// useEffect(() => { -// if (!courseSessionId) return; -// if (isAccessLoading) { -// return; -// } -// if (!hasAccess) { -// toast({ variant: "destructive", description: "Access denied!" }); -// router.push("/dashboard"); -// return; -// } - -// let intervalId: NodeJS.Timeout | null = null; - -// // Create an async function to handle the initial fetch -// // const initialFetch = async () => { -// // try { -// // // Wait for the initial fetch to complete -// // // await fetchActiveQuestion(); - -// // // Once initial fetch is done, start polling -// // intervalId = setInterval(() => { -// // void fetchActiveQuestion(); -// // }, 5000); // Poll every 5 seconds -// // } catch (errorMessage) { -// // console.error("Error in initial fetch:", errorMessage); -// // } -// // }; -// // void initialFetch(); -// void fetchActiveQuestion(); - -// intervalId = setInterval(() => { -// void fetchActiveQuestion(); -// }, 5000); -// return () => { -// if (intervalId) { -// clearInterval(intervalId); -// } -// }; -// }, [isAccessLoading, hasAccess, courseSessionId]); // Dependency on memoized fetchActiveQuestion - -// // Handle loading state -// if ((loading && !currentQuestion) || isAccessLoading) { -// return ( -//
-//
-//
-//

Loading question...

-//
-//
-// ); -// } - -// if (error || !currentQuestion) { -// return ( -//
-//
-//

-// {error ?? "No active question at this time"} -//

-// -//
-//
-// ); -// } - -// // Handle answer selection (works for both MCQ and MSQ) -// const handleSelectionChange = (value: number | number[]) => { -// setSelectedValues(value); -// }; - -// const handleSubmit = async () => { -// if ( -// !selectedValues || -// (Array.isArray(selectedValues) && selectedValues.length === 0) || -// !currentQuestion -// ) { -// return; -// } -// const optionIds = Array.isArray(selectedValues) ? selectedValues : [selectedValues]; - -// try { -// 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 { -// setSubmitting(false); -// } -// }; - -// return ( -//
-//
-// {/* Back Button */} -//
-// -//
- -// {/* Question header and count */} -//
-//
-//

Live Question:

-//
-// Question {questionCount} -//
-//
-//
- -// {/* Loading indicator for refreshing questions */} -// {/* {loading && currentQuestion && ( -//
-//
-// Syncing... -//
-// )} */} - -// {/* Question Card */} -//
-// -//
- -// {/* Answer Options */} -// - -// {/* Submit Button */} -// - -// {/* Submission Status */} -// {submitting && ( -//

Saving your answer...

-// )} - -// {/* Footer Message */} -//

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

-//
-//
-// ); -// } - -// Modified LivePoll.tsx with simplified WebSocket integration "use client"; import { Option as PrismaOption, Question as PrismaQuestion } from "@prisma/client"; import { useParams, useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useQuery } from "react-query"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import AnswerOptions from "@/components/ui/answerOptions"; import BackButton from "@/components/ui/backButton"; import QuestionCard from "@/components/ui/questionCard"; @@ -296,14 +19,60 @@ type fetchCourseSessionQuestionResponse = { totalQuestions: number; }; -export default function LivePoll({ courseSessionId }: { courseSessionId: number }) { +// Define proper types for WebSocket messages +type WebSocketMessageType = + | "connected" + | "response_saved" + | "question_changed" + | "response_update" + | "error" + | "echo" + | "binary" + | "student_response"; + +interface WebSocketMessageBase { + type: WebSocketMessageType; + message?: string; +} + +interface QuestionChangedMessage extends WebSocketMessageBase { + type: "question_changed"; + questionId: number; +} + +interface ResponseSavedMessage extends WebSocketMessageBase { + type: "response_saved"; + message?: string; +} + +interface StudentResponseMessage extends WebSocketMessageBase { + type: "student_response"; + questionId: number; + optionIds: number[]; +} + +// Union type for all message types +type WebSocketMessage = + | QuestionChangedMessage + | ResponseSavedMessage + | StudentResponseMessage + | WebSocketMessageBase; + +export default function LivePoll({ + courseSessionId, +}: { + courseSessionId: number; +}): React.JSX.Element { // Extract the course-session-id from the URL const params = useParams(); const router = useRouter(); const { toast } = useToast(); const courseId = parseInt(params.courseId as string); - const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "STUDENT" }); + const { hasAccess: _hasAccess, isLoading: isAccessLoading } = useAccess({ + courseId, + role: "STUDENT", + }); const [currentQuestion, setCurrentQuestion] = useState(null); const [loading, setLoading] = useState(true); @@ -311,7 +80,7 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number const [submitting, setSubmitting] = useState(false); const [questionCount, setQuestionCount] = useState("1"); const [isConnected, setIsConnected] = useState(false); - const [messages, setMessages] = useState([]); + const [_messages, setMessages] = useState([]); // Use useRef for activeQuestionId to prevent unnecessary re-renders const activeQuestionIdRef = useRef(null); @@ -383,11 +152,7 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number } finally { setLoading(false); } - }, [courseSessionId]); // Only depends on courseSessionId - // The improved WebSocket connection handling for LivePoll.tsx - - // This is the part that needs to be updated in your LivePoll.tsx file - // Replace the entire useEffect that sets up the WebSocket with this code: + }, [courseSessionId, toast, router]); // Added dependencies // Setup WebSocket connection useEffect(() => { @@ -411,8 +176,8 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number }; ws.onmessage = (event) => { - let data; - let messageText; + let data: WebSocketMessage | null = null; + let messageText = ""; // Display the raw message for debugging console.log("Raw message received:", event.data); @@ -422,24 +187,25 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number if (typeof event.data === "string") { try { // Try to parse as JSON - data = JSON.parse(event.data); + data = JSON.parse(event.data) as WebSocketMessage; messageText = `Received JSON: ${JSON.stringify(data)}`; console.log("Parsed JSON:", data); // Process valid JSON message - if (data && data.type) { - // Handle different message types - if (data.type === "question_changed" && data.questionId) { + if (data?.type) { + // Fixed with optional chaining + // Type guard for question_changed + if (data.type === "question_changed" && "questionId" in data) { // Refresh the question when the instructor changes it activeQuestionIdRef.current = null; // Force refresh - fetchActiveQuestion(); + void fetchActiveQuestion(); } else if (data.type === "response_saved") { - toast({ description: data.message || "Response saved" }); + toast({ description: data.message ?? "Response saved" }); // Fixed with nullish coalescing setSubmitting(false); // Reset submitting state on success } else if (data.type === "error") { toast({ variant: "destructive", - description: data.message || "Error occurred", + description: data.message ?? "Error occurred", // Fixed with nullish coalescing }); setSubmitting(false); // Reset submitting state on error } else if (data.type === "connected") { @@ -450,16 +216,18 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number // We can safely ignore this for the student response flow } } - } catch (e) { - // If it fails to parse as JSON, it's likely a non-JSON text message - console.log("Not valid JSON, treating as text:", e.message); - + } catch (_) { + // Fixed unused variable // This is from the old server - we need to handle this format const message = event.data; messageText = `Received text: ${message}`; // Check if this is a response to our student submission - if (message.includes("student_response") && submitting) { + if ( + typeof message === "string" && + message.includes("student_response") && + submitting + ) { // This is likely a response to our student submission toast({ description: "Your answer has been recorded" }); setSubmitting(false); // Reset submitting state @@ -467,16 +235,21 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number } } else { // Handle binary data if needed - data = { type: "binary", message: "Binary data received" }; + data = { + type: "binary", + message: "Binary data received", + }; messageText = "Received: Binary data"; console.log("Received binary data"); } // Add message to list for debugging setMessages((prev) => [...prev, messageText]); - } catch (error) { - console.error("Error processing message:", error); - setMessages((prev) => [...prev, `Error processing message: ${error}`]); + } catch (err: unknown) { + // Fixed catch callback variable type + const errorStr = err instanceof Error ? err.message : "Unknown error"; + console.error("Error processing message:", errorStr); + setMessages((prev) => [...prev, `Error processing message: ${errorStr}`]); setSubmitting(false); // Reset submitting state on error } }; @@ -488,21 +261,22 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number setSubmitting(false); // Reset submitting state when connection closes }; - ws.onerror = (error) => { - console.error("WebSocket error:", error); + ws.onerror = (wsError) => { + console.error("WebSocket error:", wsError); setMessages((prev) => [...prev, "WebSocket error occurred"]); setSubmitting(false); // Reset submitting state on error }; // Initial fetch - fetchActiveQuestion(); + void fetchActiveQuestion(); return () => { if (ws.readyState === WebSocket.OPEN) { ws.close(); } }; - }, [courseSessionId, fetchActiveQuestion]); + }, [courseSessionId, fetchActiveQuestion, toast, submitting]); + // Handle loading state if ((loading && !currentQuestion) || isAccessLoading) { return ( @@ -525,7 +299,7 @@ export default function LivePoll({ courseSessionId }: { courseSessionId: number + {/* Submission Status - crucial for visual feedback */} + {submitting &&

Submitting...

} + {/* Footer Message */}

Instructor will start the next question shortly... diff --git a/lib/websocket.ts b/lib/websocket.ts index 94ca0f2..6c97c0c 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -1,14 +1,133 @@ -import { WebSocketServer } from "ws"; +import { Server as HttpServer, IncomingMessage } from "http"; +import { WebSocket, WebSocketServer } from "ws"; + +// Define connection parameters type +type ConnectionParams = { + sessionId?: string; + userId?: string; +}; + +// Define message types +type StudentResponseMessage = { + type: "student_response"; + questionId: number; + optionIds: number[]; +}; + +type ActiveQuestionUpdateMessage = { + type: "active_question_update"; + questionId: number; + courseSessionId?: number; +}; + +type ResponseSavedMessage = { + type: "response_saved"; + message: string; + data?: { + questionId?: number; + optionIds?: number[]; + originalMessage?: string; + }; +}; + +type ResponseUpdateMessage = { + type: "response_update"; + questionId: number; + responseCount: number; +}; + +type QuestionChangedMessage = { + type: "question_changed"; + questionId: number; +}; + +type ConnectedMessage = { + type: "connected"; + message: string; +}; + +type ErrorMessage = { + type: "error"; + message: string; +}; + +type TextMessage = { + type: "text"; + message: string; +}; + +// Union type for all message types +type WebSocketMessage = + | StudentResponseMessage + | ActiveQuestionUpdateMessage + | ResponseSavedMessage + | ResponseUpdateMessage + | QuestionChangedMessage + | ConnectedMessage + | ErrorMessage + | TextMessage; + +// Type for unknown parsed data +type UnknownData = Record; // Store all active connections -const connections = new Map(); +const connections = new Map>(); -export function initWebSocketServer(server) { +// Function declaration moved to fix "used before defined" error +function broadcastToSession(sessionId: string, message: WebSocketMessage): void { + const sessConnections = connections.get(sessionId); + if (!sessConnections) return; + + console.log(`Broadcasting to session ${sessionId}`); + + try { + // Ensure message is a proper object before stringifying + const messageObj: WebSocketMessage = + typeof message === "string" + ? (JSON.parse(message) as WebSocketMessage) // Convert string to object if it's JSON + : message; // Use as is if it's already an object + + const messageStr = JSON.stringify(messageObj); + + for (const connection of sessConnections.values()) { + try { + connection.send(messageStr); + } catch (err) { + console.error("Error sending broadcast to client:", err); + } + } + } catch (error) { + console.error("Error broadcasting message:", error); + + // Fallback if message isn't valid JSON + if (typeof message === "string") { + const fallbackMsg = JSON.stringify({ + type: "text", + message, + } as TextMessage); + + for (const connection of sessConnections.values()) { + try { + connection.send(fallbackMsg); + } catch (err) { + console.error("Error sending fallback broadcast:", err); + } + } + } + } +} + +export function initWebSocketServer(server: HttpServer): WebSocketServer { const wss = new WebSocketServer({ noServer: true }); // Handle upgrade requests - server.on("upgrade", (request, socket, head) => { + server.on("upgrade", (request: IncomingMessage, socket, head) => { try { + if (!request.url) { + socket.destroy(); + return; + } + const { pathname, searchParams } = new URL( request.url, `http://${request.headers.host}`, @@ -37,209 +156,167 @@ export function initWebSocketServer(server) { }); // Handle WebSocket connections - wss.on("connection", (ws, request, connectionParams = {}) => { - const { sessionId, userId } = connectionParams; - - // Handle test connections - if (!sessionId && !userId) { - console.log("Test WebSocket connection established"); - - // FIXED: Always use JSON format for all messages - ws.send( - JSON.stringify({ - type: "connected", - message: "Connected to WebSocket test server", - }), - ); - - ws.on("message", (message) => { - console.log("Test message received:", message.toString()); - - // Try to parse as JSON first - // Replace both parts in your websocket.js file with this version: - - try { - // Parse the message to see if it's valid JSON - const jsonData = JSON.parse(message.toString()); - - // If it is, echo it back with proper JSON response - ws.send( - JSON.stringify({ - type: "response_saved", // Change this from 'echo' to 'response_saved' - message: "Your message has been received", - data: jsonData, - }), - ); - } catch (e) { - // If not valid JSON, still respond with JSON format - ws.send( - JSON.stringify({ - type: "response_saved", // Change this from 'echo' to 'response_saved' - message: "Your message has been received", - data: { - originalMessage: message.toString(), - }, - }), - ); - } - }); - - return; - } - - // Handle poll connections - console.log( - `Poll WebSocket connection established: SessionID=${sessionId}, UserID=${userId}`, - ); - - // Store the connection - if (!connections.has(sessionId)) { - connections.set(sessionId, new Map()); - } - const sessionConnections = connections.get(sessionId); - sessionConnections.set(userId, ws); - - // Send connection confirmation - ws.send( - JSON.stringify({ - type: "connected", - message: "Connected to poll session", - }), - ); - - ws.on("message", (message) => { - try { - // Log the raw message first - console.log("Raw message received:", message.toString()); - - // Try to parse the message - const data = JSON.parse(message.toString()); - - // Log all incoming messages to terminal - console.log("\n===== STUDENT RESPONSE ====="); - console.log("Session ID:", sessionId); - console.log("User ID:", userId); - console.log("Message Data:", data); - console.log("===========================\n"); - - // If this is a student response - if (data.type === "student_response") { - // Extract the data - const { questionId, optionIds } = data; - - // Log to console for testing - console.log( - `Student response received: QuestionID=${questionId}, OptionIDs=${optionIds.join(", ")}`, - ); - - // Send confirmation back to student - ws.send( - JSON.stringify({ - type: "response_saved", - message: "Your answer has been recorded", - data: { - questionId, - optionIds, - }, - }), - ); - - // Broadcast to all clients in this session that a new response has been received - broadcastToSession(sessionId, { - type: "response_update", - questionId, - // We don't have actual counts, but for testing we can just increment - responseCount: Math.floor(Math.random() * 20) + 1, // Random count for testing - }); - } - - // If instructor is updating the active question - else if (data.type === "active_question_update") { - console.log(`Active question updated: QuestionID=${data.questionId}`); - - // Broadcast to all clients in this session - broadcastToSession(sessionId, { - type: "question_changed", - questionId: data.questionId, - }); - } - } catch (error) { - console.error("Error processing WebSocket message:", error); - - // Even on error, respond with proper JSON + wss.on( + "connection", + (ws: WebSocket, request: IncomingMessage, connectionParams: ConnectionParams = {}) => { + const { sessionId, userId } = connectionParams; + + // Handle test connections + if (!sessionId && !userId) { + // FIXED: Always use JSON format for all messages ws.send( JSON.stringify({ - type: "error", - message: "Invalid message format", - }), + type: "connected", + message: "Connected to WebSocket test server", + } as ConnectedMessage), ); - } - }); - ws.on("error", (error) => { - console.error("WebSocket connection error:", error); - }); + ws.on("message", (message: Buffer) => { + try { + // Parse the message to see if it's valid JSON + const jsonData = JSON.parse(message.toString()) as UnknownData; + + // If it is, echo it back with proper JSON response + ws.send( + JSON.stringify({ + type: "response_saved", + message: "Your message has been received", + data: jsonData, + } as ResponseSavedMessage), + ); + } catch (_parseError) { + // If not valid JSON, still respond with JSON format + ws.send( + JSON.stringify({ + type: "response_saved", + message: "Your message has been received", + data: { + originalMessage: message.toString(), + }, + } as ResponseSavedMessage), + ); + } + }); - ws.on("close", () => { - console.log(`WebSocket connection closed: SessionID=${sessionId}, UserID=${userId}`); + return; + } - // Clean up the connection + // Handle poll connections + console.log(`WebSocket connection: SessionID=${sessionId}, UserID=${userId}`); + + // Store the connection - Check for null/undefined if (sessionId && userId) { + if (!connections.has(sessionId)) { + connections.set(sessionId, new Map()); + } const sessionConnections = connections.get(sessionId); if (sessionConnections) { - sessionConnections.delete(userId); - - if (sessionConnections.size === 0) { - connections.delete(sessionId); - } + sessionConnections.set(userId, ws); } - } - }); - }); - return wss; -} + // Send connection confirmation + ws.send( + JSON.stringify({ + type: "connected", + message: "Connected to poll session", + } as ConnectedMessage), + ); -// Function to broadcast a message to all connections in a session -function broadcastToSession(sessionId, message) { - const sessionConnections = connections.get(sessionId); - if (!sessionConnections) return; + ws.on("message", (message: Buffer) => { + try { + // Try to parse the message + const data = JSON.parse(message.toString()) as UnknownData; + + // If this is a student response + if (data.type === "student_response") { + // Type checking and extraction + const typedData = data as StudentResponseMessage; + const questionId = typedData.questionId; + const optionIds = typedData.optionIds; + + // Validate required fields + if (typeof questionId !== "number" || !Array.isArray(optionIds)) { + throw new Error("Invalid student_response format"); + } + + // Single essential log for student response + console.log( + `Student response: Session=${sessionId}, Question=${questionId}, Options=${optionIds.join(", ")}`, + ); + + // Send confirmation back to student + ws.send( + JSON.stringify({ + type: "response_saved", + message: "Your answer has been recorded", + data: { + questionId, + optionIds, + }, + } as ResponseSavedMessage), + ); + + // Broadcast to all clients in this session that a new response has been received + broadcastToSession(sessionId, { + type: "response_update", + questionId, + // We don't have actual counts, but for testing we can just increment + responseCount: Math.floor(Math.random() * 20) + 1, // Random count for testing + } as ResponseUpdateMessage); + } + + // If instructor is updating the active question + else if (data.type === "active_question_update") { + // Type checking + const typedData = data as ActiveQuestionUpdateMessage; + const questionId = typedData.questionId; + + // Validate required fields + if (typeof questionId !== "number") { + throw new Error("Invalid active_question_update format"); + } + + console.log(`Active question updated: QuestionID=${questionId}`); + + // Broadcast to all clients in this session + broadcastToSession(sessionId, { + type: "question_changed", + questionId, + } as QuestionChangedMessage); + } + } catch (error) { + console.error("Error processing WebSocket message:", error); + + // Even on error, respond with proper JSON + ws.send( + JSON.stringify({ + type: "error", + message: "Invalid message format", + } as ErrorMessage), + ); + } + }); - console.log(`Broadcasting to session ${sessionId}:`, message); + ws.on("error", (error) => { + console.error("WebSocket connection error:", error); + }); - try { - // Ensure message is a proper object before stringifying - const messageObj = - typeof message === "string" - ? JSON.parse(message) // Convert string to object if it's JSON - : message; // Use as is if it's already an object + ws.on("close", () => { + console.log(`WebSocket connection closed: SessionID=${sessionId}`); - const messageStr = JSON.stringify(messageObj); + // Clean up the connection + const localSessionConnections = connections.get(sessionId); + if (localSessionConnections) { + localSessionConnections.delete(userId); - for (const connection of sessionConnections.values()) { - try { - connection.send(messageStr); - } catch (err) { - console.error("Error sending broadcast to client:", err); + if (localSessionConnections.size === 0) { + connections.delete(sessionId); + } + } + }); } - } - } catch (error) { - console.error("Error broadcasting message:", error); + }, + ); - // Fallback if message isn't valid JSON - if (typeof message === "string") { - const fallbackMsg = JSON.stringify({ - type: "text", - message: message, - }); - - for (const connection of sessionConnections.values()) { - try { - connection.send(fallbackMsg); - } catch (err) { - console.error("Error sending fallback broadcast:", err); - } - } - } - } + return wss; } diff --git a/server.ts b/server.ts index e277bc5..586a0fa 100644 --- a/server.ts +++ b/server.ts @@ -1,30 +1,44 @@ +import { createServer } from "http"; +import { parse } from "url"; +import next from "next"; +import { initWebSocketServer } from "./lib/websocket"; -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 dev = process.env.NODE_ENV !== "production"; const app = next({ dev }); const handle = app.getRequestHandler(); -app.prepare().then(() => { - const server = createServer((req, res) => { - const parsedUrl = parse(req.url!, true); - - // handle WebSocket requests - if (parsedUrl.pathname === '/ws') { - res.writeHead(426); - res.end(); - return; - } - - handle(req, res, parsedUrl); - }); +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; + } + + // handle WebSocket requests + if (parsedUrl.pathname === "/ws") { + res.writeHead(426); + res.end(); + return; + } + + handle(req, res, parsedUrl); + }); - initWebSocketServer(server); + initWebSocketServer(server); - server.listen(3000, () => { - console.log('> Ready on http://localhost:3000'); - }); -}); \ No newline at end of file + // 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) => { + console.error("Error preparing Next.js app:", error); + process.exit(1); + }); diff --git a/tsconfig.server.json b/tsconfig.server.json index 6dffeda..bc7d1d3 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -1,11 +1,11 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "outDir": "dist", - "target": "es2017", - "isolatedModules": false, - "noEmit": false - }, - "include": ["server.ts", "lib/websocket.ts"] -} \ No newline at end of file + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist", + "target": "es2017", + "isolatedModules": false, + "noEmit": false + }, + "include": ["server.ts", "lib/websocket.ts"] +} From 8e359333dc36058cbba16a80a26b9feb5d5577b0 Mon Sep 17 00:00:00 2001 From: Ulises Salinas Date: Tue, 27 May 2025 15:32:20 -0700 Subject: [PATCH 07/24] feat: implement real-time poll updates with WebSocket - Add WebSocket server initialization and connection handling - Implement real-time response updates for live polls - Replace polling mechanism with WebSocket for chart updates - Add response count tracking per option - Update instructor view to use WebSocket for live updates - Add proper error handling and connection managemen --- .../course/[courseId]/start-session/page.tsx | 193 ++++++++---- components/LivePoll.tsx | 37 ++- lib/prisma.ts | 4 + lib/websocket.ts | 293 ++++++++++-------- server.ts | 7 +- tsconfig.server.json | 8 +- 6 files changed, 346 insertions(+), 196 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 11784e3..b4746a9 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -3,7 +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 React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useQuery } from "react-query"; import { Bar, BarChart, LabelList, ResponsiveContainer, XAxis, YAxis } from "recharts"; import { LetteredYAxisTick } from "@/components/YAxisTick"; @@ -37,6 +37,8 @@ import { getQuestionById, getQuestionsForSession, } from "@/services/session"; +import prisma from "@/lib/prisma"; +import { useSession } from "next-auth/react"; export default function StartSession() { const params = useParams(); @@ -49,6 +51,10 @@ export default function StartSession() { const [activeQuestionId, setActiveQuestionId] = useState(null); const [isAddingQuestion, setIsAddingQuestion] = useState(false); const [isEndingSession, setIsEndingSession] = useState(false); + const [responseCounts, setResponseCounts] = useState>({}); + const [totalResponses, setTotalResponses] = useState(0); + const wsRef = useRef(null); + const session = useSession(); const [isPaused, setIsPaused] = useState(false); const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); const [isChangingQuestion, setIsChangingQuestion] = useState(false); // New state for question navigation @@ -73,6 +79,79 @@ export default function StartSession() { void fetchSessionData(); }, [courseId, utcDate, router, toast]); + const { data: questionData } = useQuery( + ["question", activeQuestionId], + () => (activeQuestionId ? getQuestionById(activeQuestionId) : Promise.resolve(null)), + { + enabled: !!activeQuestionId, + onSuccess: async (data) => { + if (data && activeQuestionId) { + // Fetch initial response counts + const responseCounts = await prisma.response.groupBy({ + by: ['optionId'], + where: { + questionId: activeQuestionId + }, + _count: { + optionId: true + } + }); + + // Update state with initial counts + setResponseCounts(responseCounts.reduce((acc, curr) => ({ + ...acc, + [curr.optionId]: curr._count.optionId + }), {})); + setTotalResponses(responseCounts.reduce((acc, curr) => acc + curr._count.optionId, 0)); + } + } + } + ); + + // Setup WebSocket connection + useEffect(() => { + if (!courseSession || !session.data?.user?.id) return; + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket( + `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSession.id}&userId=${session.data.user.id}`, + ); + wsRef.current = ws; + + ws.onopen = () => { + console.log("WebSocket connection established"); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + console.log("Received WebSocket message:", data); + + if (data.type === "response_update" && data.questionId === activeQuestionId) { + console.log("Updating response counts:", data.optionCounts); + setResponseCounts(data.optionCounts); + setTotalResponses(data.responseCount); + } + } catch (error) { + console.error("Error processing WebSocket message:", error); + } + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + }; + + ws.onclose = () => { + console.log("WebSocket connection closed"); + }; + + return () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }; + }, [courseSession, activeQuestionId, session.data?.user?.id]); + // fetch session questions const { data: questions, @@ -103,13 +182,6 @@ export default function StartSession() { } }, [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; @@ -251,7 +323,6 @@ export default function StartSession() { } const activeQuestion = questions ? questions.find((q) => q.id === activeQuestionId) : null; - const totalVotes = chartData.reduce((sum, item) => sum + item.Votes, 0); return (

@@ -279,57 +350,58 @@ export default function StartSession() {
- - + {chartData.length > 0 ? ( + + {showResults ? ( - - - } - tickLine={false} - axisLine={false} - tickMargin={8} - style={{ fill: "#000" }} - /> - } - /> - - + } + 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 }} + /> + + ) : (
@@ -338,8 +410,13 @@ export default function StartSession() {

)} -
-
+
+
+ ) : ( +
+ No responses yet +
+ )}
diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index b74f24f..d5054b2 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -7,6 +7,7 @@ import BackButton from "@/components/ui/backButton"; import QuestionCard from "@/components/ui/questionCard"; import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; +import { useSession } from "next-auth/react"; import { getSessionPauseState } from "@/services/courseSession"; @@ -51,6 +52,14 @@ interface StudentResponseMessage extends WebSocketMessageBase { optionIds: number[]; } +// Add new type for response updates +interface ResponseUpdateMessage extends WebSocketMessageBase { + type: "response_update"; + questionId: number; + responseCount: number; + optionCounts: Record; +} + // Union type for all message types type WebSocketMessage = | QuestionChangedMessage @@ -67,6 +76,7 @@ export default function LivePoll({ const params = useParams(); const router = useRouter(); const { toast } = useToast(); + const { data: session } = useSession(); const courseId = parseInt(params.courseId as string); const { hasAccess: _hasAccess, isLoading: isAccessLoading } = useAccess({ @@ -156,16 +166,12 @@ export default function LivePoll({ // Setup WebSocket connection useEffect(() => { - if (!courseSessionId) return; - - // Generate a temporary user ID for testing - // In a real app, you would use the authenticated user's ID - const tempUserId = `test-user-${Math.floor(Math.random() * 1000)}`; + if (!courseSessionId || !session?.user?.id) return; // Create WebSocket connection const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket( - `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSessionId}&userId=${tempUserId}`, + `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSessionId}&userId=${session.user.id}`, ); wsRef.current = ws; @@ -275,7 +281,7 @@ export default function LivePoll({ ws.close(); } }; - }, [courseSessionId, fetchActiveQuestion, toast, submitting]); + }, [courseSessionId, session?.user?.id, fetchActiveQuestion, toast, submitting]); // Handle loading state if ((loading && !currentQuestion) || isAccessLoading) { @@ -387,11 +393,18 @@ export default function LivePoll({ {/* Connection status */}
-
-
- {isConnected ? "Connected" : "Disconnected"} +
+
+
+ {isConnected ? "Connected" : "Disconnected"} +
+ {session?.user && ( +
+ Connected as: {session.user.firstName} {session.user.lastName} +
+ )}
diff --git a/lib/prisma.ts b/lib/prisma.ts index 82ee860..20f4f43 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -2,6 +2,10 @@ import { PrismaClient } from "@prisma/client"; let prisma: PrismaClient; +declare global { + var prisma: PrismaClient | undefined; +} + if (process.env.NODE_ENV === "production") { prisma = new PrismaClient(); } else { diff --git a/lib/websocket.ts b/lib/websocket.ts index 6c97c0c..081dbfd 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -1,5 +1,6 @@ import { Server as HttpServer, IncomingMessage } from "http"; import { WebSocket, WebSocketServer } from "ws"; +import prisma from "./prisma"; // Define connection parameters type type ConnectionParams = { @@ -34,6 +35,7 @@ type ResponseUpdateMessage = { type: "response_update"; questionId: number; responseCount: number; + optionCounts: Record; }; type QuestionChangedMessage = { @@ -70,8 +72,43 @@ type WebSocketMessage = // Type for unknown parsed data type UnknownData = Record; -// Store all active connections -const connections = new Map>(); +// Add new type for authenticated connection +type AuthenticatedConnection = { + userId: string; + sessionId: string; + ws: WebSocket; +}; + +// Store all active connections with authentication info +const connections = new Map>(); + +// Function to validate user session +async function validateUserSession(userId: string, sessionId: string): Promise { + try { + // Check if user exists and has access to the session + 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 (error) { + console.error("Error validating user session:", error); + return false; + } +} // Function declaration moved to fix "used before defined" error function broadcastToSession(sessionId: string, message: WebSocketMessage): void { @@ -91,7 +128,7 @@ function broadcastToSession(sessionId: string, message: WebSocketMessage): void for (const connection of sessConnections.values()) { try { - connection.send(messageStr); + connection.ws.send(messageStr); } catch (err) { console.error("Error sending broadcast to client:", err); } @@ -108,7 +145,7 @@ function broadcastToSession(sessionId: string, message: WebSocketMessage): void for (const connection of sessConnections.values()) { try { - connection.send(fallbackMsg); + connection.ws.send(fallbackMsg); } catch (err) { console.error("Error sending fallback broadcast:", err); } @@ -121,7 +158,7 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { const wss = new WebSocketServer({ noServer: true }); // Handle upgrade requests - server.on("upgrade", (request: IncomingMessage, socket, head) => { + server.on("upgrade", async (request: IncomingMessage, socket, head) => { try { if (!request.url) { socket.destroy(); @@ -134,15 +171,26 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { ); if (pathname === "/ws") { - // For test endpoint - keep this for backward compatibility + // For test endpoint - needed for backward compatibility wss.handleUpgrade(request, socket, head, (ws) => { wss.emit("connection", ws, request); }); } else if (pathname === "/ws/poll") { - // For poll connections const sessionId = searchParams.get("sessionId"); const userId = searchParams.get("userId"); + if (!sessionId || !userId) { + socket.destroy(); + return; + } + + // Validate user session + const isValid = await validateUserSession(userId, sessionId); + if (!isValid) { + socket.destroy(); + return; + } + wss.handleUpgrade(request, socket, head, (ws) => { wss.emit("connection", ws, request, { sessionId, userId }); }); @@ -161,83 +209,75 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { (ws: WebSocket, request: IncomingMessage, connectionParams: ConnectionParams = {}) => { const { sessionId, userId } = connectionParams; - // Handle test connections - if (!sessionId && !userId) { - // FIXED: Always use JSON format for all messages - ws.send( - JSON.stringify({ - type: "connected", - message: "Connected to WebSocket test server", - } as ConnectedMessage), - ); - - ws.on("message", (message: Buffer) => { - try { - // Parse the message to see if it's valid JSON - const jsonData = JSON.parse(message.toString()) as UnknownData; - - // If it is, echo it back with proper JSON response - ws.send( - JSON.stringify({ - type: "response_saved", - message: "Your message has been received", - data: jsonData, - } as ResponseSavedMessage), - ); - } catch (_parseError) { - // If not valid JSON, still respond with JSON format - ws.send( - JSON.stringify({ - type: "response_saved", - message: "Your message has been received", - data: { - originalMessage: message.toString(), - }, - } as ResponseSavedMessage), - ); - } - }); - + if (!sessionId || !userId) { + ws.close(1008, "Missing session or user ID"); return; } - // Handle poll connections console.log(`WebSocket connection: SessionID=${sessionId}, UserID=${userId}`); - // Store the connection - Check for null/undefined - if (sessionId && userId) { - if (!connections.has(sessionId)) { - connections.set(sessionId, new Map()); - } - const sessionConnections = connections.get(sessionId); - if (sessionConnections) { - sessionConnections.set(userId, ws); - } + // Store the authenticated connection + if (!connections.has(sessionId)) { + connections.set(sessionId, new Map()); + } + const sessionConnections = connections.get(sessionId); + if (sessionConnections) { + sessionConnections.set(userId, { + userId, + sessionId, + ws + }); + } - // Send connection confirmation - ws.send( - JSON.stringify({ - type: "connected", - message: "Connected to poll session", - } as ConnectedMessage), - ); - - ws.on("message", (message: Buffer) => { - try { - // Try to parse the message - const data = JSON.parse(message.toString()) as UnknownData; - - // If this is a student response - if (data.type === "student_response") { - // Type checking and extraction - const typedData = data as StudentResponseMessage; - const questionId = typedData.questionId; - const optionIds = typedData.optionIds; - - // Validate required fields - if (typeof questionId !== "number" || !Array.isArray(optionIds)) { - throw new Error("Invalid student_response format"); - } + // Send connection confirmation + ws.send( + JSON.stringify({ + type: "connected", + message: "Connected to poll session", + } as ConnectedMessage), + ); + + ws.on("message", async (message: Buffer) => { + try { + // Try to parse the message + const data = JSON.parse(message.toString()) as UnknownData; + + // If this is a student response + if (data.type === "student_response") { + // Type checking and extraction + const typedData = data as StudentResponseMessage; + const questionId = typedData.questionId; + const optionIds = typedData.optionIds; + + // Validate required fields + if (typeof questionId !== "number" || !Array.isArray(optionIds)) { + throw new Error("Invalid student_response format"); + } + + try { + // Save responses to database + await Promise.all( + optionIds.map(optionId => + prisma.response.create({ + data: { + userId, + questionId, + optionId, + } + }) + ) + ); + + // Get response counts per option for this question + const responseCounts = await prisma.response.groupBy({ + by: ['optionId'], + where: { + questionId: questionId + }, + _count: { + optionId: true + } + }); // Single essential log for student response console.log( @@ -260,61 +300,72 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { broadcastToSession(sessionId, { type: "response_update", questionId, - // We don't have actual counts, but for testing we can just increment - responseCount: Math.floor(Math.random() * 20) + 1, // Random count for testing + responseCount: responseCounts.reduce((acc, curr) => acc + curr._count.optionId, 0), + optionCounts: responseCounts.reduce((acc, curr) => ({ + ...acc, + [curr.optionId]: curr._count.optionId + }), {}) } as ResponseUpdateMessage); + } catch (error) { + console.error("Error saving response:", error); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to save response", + } as ErrorMessage), + ); } + } - // If instructor is updating the active question - else if (data.type === "active_question_update") { - // Type checking - const typedData = data as ActiveQuestionUpdateMessage; - const questionId = typedData.questionId; + // If instructor is updating the active question + else if (data.type === "active_question_update") { + // Type checking + const typedData = data as ActiveQuestionUpdateMessage; + const questionId = typedData.questionId; - // Validate required fields - if (typeof questionId !== "number") { - throw new Error("Invalid active_question_update format"); - } + // Validate required fields + if (typeof questionId !== "number") { + throw new Error("Invalid active_question_update format"); + } - console.log(`Active question updated: QuestionID=${questionId}`); + console.log(`Active question updated: QuestionID=${questionId}`); - // Broadcast to all clients in this session - broadcastToSession(sessionId, { - type: "question_changed", - questionId, - } as QuestionChangedMessage); - } - } catch (error) { - console.error("Error processing WebSocket message:", error); - - // Even on error, respond with proper JSON - ws.send( - JSON.stringify({ - type: "error", - message: "Invalid message format", - } as ErrorMessage), - ); + // Broadcast to all clients in this session + broadcastToSession(sessionId, { + type: "question_changed", + questionId, + } as QuestionChangedMessage); } - }); + } catch (error) { + console.error("Error processing WebSocket message:", error); + + // Even on error, respond with proper JSON + ws.send( + JSON.stringify({ + type: "error", + message: "Invalid message format", + } as ErrorMessage), + ); + } + }); - ws.on("error", (error) => { - console.error("WebSocket connection error:", error); - }); + ws.on("error", (error) => { + console.error("WebSocket connection error:", error); + }); - ws.on("close", () => { - console.log(`WebSocket connection closed: SessionID=${sessionId}`); + ws.on("close", () => { + console.log(`WebSocket connection closed: SessionID=${sessionId}, UserID=${userId}`); - // Clean up the connection - const localSessionConnections = connections.get(sessionId); - if (localSessionConnections) { - localSessionConnections.delete(userId); + // Clean up the connection + const localSessionConnections = connections.get(sessionId); + if (localSessionConnections) { + localSessionConnections.delete(userId); - if (localSessionConnections.size === 0) { - connections.delete(sessionId); - } + if (localSessionConnections.size === 0) { + connections.delete(sessionId); } - }); - } + } + }); }, ); diff --git a/server.ts b/server.ts index 586a0fa..5eac00f 100644 --- a/server.ts +++ b/server.ts @@ -21,8 +21,8 @@ void app return; } - // handle WebSocket requests - if (parsedUrl.pathname === "/ws") { + // Let the WebSocket server handle WebSocket requests + if (parsedUrl.pathname?.startsWith("/ws")) { res.writeHead(426); res.end(); return; @@ -31,7 +31,8 @@ void app handle(req, res, parsedUrl); }); - initWebSocketServer(server); + // 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, () => { diff --git a/tsconfig.server.json b/tsconfig.server.json index bc7d1d3..60087c7 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -5,7 +5,11 @@ "outDir": "dist", "target": "es2017", "isolatedModules": false, - "noEmit": false + "noEmit": false, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } }, - "include": ["server.ts", "lib/websocket.ts"] + "include": ["server.ts", "lib/websocket.ts", "lib/server-prisma.ts"] } From c7643e12892cb808d815b776accec109fd6c2fd7 Mon Sep 17 00:00:00 2001 From: Ulises Salinas Date: Fri, 30 May 2025 06:57:48 -0700 Subject: [PATCH 08/24] Finalized websocket for singular question updates --- app/api/getResponseCounts/route.ts | 32 ++ .../course/[courseId]/start-session/page.tsx | 74 ++-- components/LivePoll.tsx | 241 ++++++------ lib/websocket.ts | 357 ++++++------------ 4 files changed, 305 insertions(+), 399 deletions(-) create mode 100644 app/api/getResponseCounts/route.ts diff --git a/app/api/getResponseCounts/route.ts b/app/api/getResponseCounts/route.ts new file mode 100644 index 0000000..db290f6 --- /dev/null +++ b/app/api/getResponseCounts/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +export async function GET(request: NextRequest) { + try { + 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 b4746a9..ae425ef 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -3,7 +3,8 @@ 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 React, { useCallback, useEffect, useMemo, useState, useRef } from "react"; +import { useSession } from "next-auth/react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "react-query"; import { Bar, BarChart, LabelList, ResponsiveContainer, XAxis, YAxis } from "recharts"; import { LetteredYAxisTick } from "@/components/YAxisTick"; @@ -37,8 +38,6 @@ import { getQuestionById, getQuestionsForSession, } from "@/services/session"; -import prisma from "@/lib/prisma"; -import { useSession } from "next-auth/react"; export default function StartSession() { const params = useParams(); @@ -82,30 +81,7 @@ export default function StartSession() { const { data: questionData } = useQuery( ["question", activeQuestionId], () => (activeQuestionId ? getQuestionById(activeQuestionId) : Promise.resolve(null)), - { - enabled: !!activeQuestionId, - onSuccess: async (data) => { - if (data && activeQuestionId) { - // Fetch initial response counts - const responseCounts = await prisma.response.groupBy({ - by: ['optionId'], - where: { - questionId: activeQuestionId - }, - _count: { - optionId: true - } - }); - - // Update state with initial counts - setResponseCounts(responseCounts.reduce((acc, curr) => ({ - ...acc, - [curr.optionId]: curr._count.optionId - }), {})); - setTotalResponses(responseCounts.reduce((acc, curr) => acc + curr._count.optionId, 0)); - } - } - } + { enabled: !!activeQuestionId }, ); // Setup WebSocket connection @@ -126,7 +102,7 @@ export default function StartSession() { try { const data = JSON.parse(event.data); console.log("Received WebSocket message:", data); - + if (data.type === "response_update" && data.questionId === activeQuestionId) { console.log("Updating response counts:", data.optionCounts); setResponseCounts(data.optionCounts); @@ -187,20 +163,23 @@ export default function StartSession() { 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 = shuffledOptions.map(option => ({ + option: option.text, + Votes: responseCounts?.[option.id] || 0, +})); - const chartData = questionData - ? shuffledOptions.map((option) => ({ - option: option.text, - Votes: questionData.responses.filter((resp) => resp.optionId === option.id).length, - })) - : []; + const totalVotes = chartData.reduce((sum, item) => sum + item.Votes, 0); - // Create a reusable function for updating the active question - const updateActiveQuestion = useCallback( - async (questionId: number, sessionId: string) => { + const handleNextQuestion = useCallback(async () => { + if (questions && activeIndex !== -1 && activeIndex < totalQuestions - 1 && courseSession) { + const nextQuestionID = questions[activeIndex + 1].id; + setActiveQuestionId(nextQuestionID); try { const response = await fetch(`/api/session/${sessionId}/activeQuestion`, { method: "PATCH", @@ -318,6 +297,21 @@ 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) => { + setResponseCounts(data.optionCounts || {}); + setTotalResponses(data.responseCount || 0); + }) + .catch((err) => { + console.error("Failed to fetch response counts:", err); + }); + }, [activeQuestionId]); + if (!courseSession || questionsLoading) { return ; } diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index d5054b2..fce7351 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -1,13 +1,13 @@ "use client"; import { Option as PrismaOption, Question as PrismaQuestion } from "@prisma/client"; import { useParams, useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import AnswerOptions from "@/components/ui/answerOptions"; import BackButton from "@/components/ui/backButton"; import QuestionCard from "@/components/ui/questionCard"; import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; -import { useSession } from "next-auth/react"; import { getSessionPauseState } from "@/services/courseSession"; @@ -168,120 +168,133 @@ export default function LivePoll({ useEffect(() => { if (!courseSessionId || !session?.user?.id) return; - // Create WebSocket connection - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const ws = new WebSocket( - `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSessionId}&userId=${session.user.id}`, - ); - wsRef.current = ws; + let reconnectAttempts = 0; + const maxReconnectAttempts = 5; + const reconnectDelay = 1000; // Start with 1 second - ws.onopen = () => { - console.log("WebSocket connection established"); - setIsConnected(true); - setMessages((prev) => [...prev, "Connected to WebSocket"]); - }; + const connectWebSocket = () => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket( + `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSessionId}&userId=${session.user.id}`, + ); + wsRef.current = ws; + + ws.onopen = () => { + console.log("WebSocket connection established"); + setIsConnected(true); + setMessages((prev) => [...prev, "Connected to WebSocket"]); + reconnectAttempts = 0; // Reset reconnect attempts on successful connection + }; - ws.onmessage = (event) => { - let data: WebSocketMessage | null = null; - let messageText = ""; - - // Display the raw message for debugging - console.log("Raw message received:", event.data); - - // Try to parse as JSON, but handle plain text too - try { - if (typeof event.data === "string") { - try { - // Try to parse as JSON - data = JSON.parse(event.data) as WebSocketMessage; - messageText = `Received JSON: ${JSON.stringify(data)}`; - console.log("Parsed JSON:", data); - - // Process valid JSON message - if (data?.type) { - // Fixed with optional chaining - // Type guard for question_changed - if (data.type === "question_changed" && "questionId" in data) { - // Refresh the question when the instructor changes it - activeQuestionIdRef.current = null; // Force refresh - void fetchActiveQuestion(); - } else if (data.type === "response_saved") { - toast({ description: data.message ?? "Response saved" }); // Fixed with nullish coalescing - setSubmitting(false); // Reset submitting state on success - } else if (data.type === "error") { - toast({ - variant: "destructive", - description: data.message ?? "Error occurred", // Fixed with nullish coalescing - }); - setSubmitting(false); // Reset submitting state on error - } else if (data.type === "connected") { - console.log("WebSocket connection confirmed:", data.message); - } else if (data.type === "echo") { - console.log("Server echo:", data.message); - // This is likely a text response echoed back - // We can safely ignore this for the student response flow + ws.onmessage = (event) => { + let data: WebSocketMessage | null = null; + let messageText = ""; + + // Display the raw message for debugging + console.log("Raw message received:", event.data); + + // Try to parse as JSON, but handle plain text too + try { + if (typeof event.data === "string") { + try { + // Try to parse as JSON + data = JSON.parse(event.data) as WebSocketMessage; + messageText = `Received JSON: ${JSON.stringify(data)}`; + console.log("Parsed JSON:", data); + + // Process valid JSON message + if (data?.type) { + if (data.type === "question_changed" && "questionId" in data) { + activeQuestionIdRef.current = null; + void fetchActiveQuestion(); + } else if (data.type === "response_saved") { + toast({ description: data.message ?? "Response saved" }); + setSubmitting(false); + } else if (data.type === "error") { + toast({ + variant: "destructive", + description: data.message ?? "Error occurred", + }); + setSubmitting(false); + } else if (data.type === "connected") { + console.log("WebSocket connection confirmed:", data.message); + } else if (data.type === "echo") { + console.log("Server echo:", data.message); + } + } + } catch (_) { + const message = event.data; + messageText = `Received text: ${message}`; + + // Check if this is a response to our student submission + if ( + typeof message === "string" && + message.includes("student_response") && + submitting + ) { + // likely a response to our student submission + toast({ description: "Your answer has been recorded" }); + setSubmitting(false); } } - } catch (_) { - // Fixed unused variable - // This is from the old server - we need to handle this format - const message = event.data; - messageText = `Received text: ${message}`; - - // Check if this is a response to our student submission - if ( - typeof message === "string" && - message.includes("student_response") && - submitting - ) { - // This is likely a response to our student submission - toast({ description: "Your answer has been recorded" }); - setSubmitting(false); // Reset submitting state - } + } else { + data = { + type: "binary", + message: "Binary data received", + }; + messageText = "Received: Binary data"; + console.log("Received binary data"); } - } else { - // Handle binary data if needed - data = { - type: "binary", - message: "Binary data received", - }; - messageText = "Received: Binary data"; - console.log("Received binary data"); + + setMessages((prev) => [...prev, messageText]); + } catch (err: unknown) { + const errorStr = err instanceof Error ? err.message : "Unknown error"; + console.error("Error processing message:", errorStr); + setMessages((prev) => [...prev, `Error processing message: ${errorStr}`]); + setSubmitting(false); } + }; - // Add message to list for debugging - setMessages((prev) => [...prev, messageText]); - } catch (err: unknown) { - // Fixed catch callback variable type - const errorStr = err instanceof Error ? err.message : "Unknown error"; - console.error("Error processing message:", errorStr); - setMessages((prev) => [...prev, `Error processing message: ${errorStr}`]); - setSubmitting(false); // Reset submitting state on error - } - }; + ws.onclose = () => { + console.log("WebSocket connection closed"); + setIsConnected(false); + setMessages((prev) => [...prev, "Disconnected from WebSocket"]); + setSubmitting(false); - ws.onclose = () => { - console.log("WebSocket connection closed"); - setIsConnected(false); - setMessages((prev) => [...prev, "Disconnected from WebSocket"]); - setSubmitting(false); // Reset submitting state when connection closes - }; + // Attempt to reconnect if we haven't exceeded max attempts + if (reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++; + const delay = reconnectDelay * Math.pow(2, reconnectAttempts - 1); // Exponential backoff + console.log( + `Attempting to reconnect in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`, + ); + setTimeout(connectWebSocket, delay); + } else { + toast({ + variant: "destructive", + description: "Lost connection to server. Please refresh the page.", + }); + } + }; - ws.onerror = (wsError) => { - console.error("WebSocket error:", wsError); - setMessages((prev) => [...prev, "WebSocket error occurred"]); - setSubmitting(false); // Reset submitting state on error + ws.onerror = (wsError) => { + console.error("WebSocket error:", wsError); + setMessages((prev) => [...prev, "WebSocket error occurred"]); + setSubmitting(false); + }; + + // Initial fetch + void fetchActiveQuestion(); }; - // Initial fetch - void fetchActiveQuestion(); + connectWebSocket(); return () => { - if (ws.readyState === WebSocket.OPEN) { - ws.close(); + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.close(); } }; - }, [courseSessionId, session?.user?.id, fetchActiveQuestion, toast, submitting]); + }, [courseSessionId, session?.user?.id, fetchActiveQuestion, toast]); // Handle loading state if ((loading && !currentQuestion) || isAccessLoading) { @@ -334,8 +347,6 @@ export default function LivePoll({ } try { - toast({ description: "Your answer has been submitted" }); - // Set submitting to true BEFORE we do anything else setSubmitting(true); @@ -355,27 +366,17 @@ export default function LivePoll({ // Add to local messages list setMessages((prev) => [...prev, `Sent: ${JSON.stringify(message)}`]); - // Also send via API as a fallback - make this async - void fetch("/api/submitStudentResponse", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - questionId: currentQuestion.id, - optionIds, - }), - }).catch((apiError) => { - console.error("API error:", apiError); - }); - // Fallback timer in case WebSocket response is never received setTimeout(() => { - setSubmitting(false); - console.log("Fallback timer: Setting submitting state to false"); // Add this log - }, 3000); + if (submitting) { + setSubmitting(false); + toast({ + variant: "destructive", + description: "Response may not have been saved. Please try again.", + }); + } + }, 5000); } catch (submitError: unknown) { - // Fixed catch callback variable type const errorStr = submitError instanceof Error ? submitError.message : "Unknown error"; console.error("Error submitting answer:", errorStr); toast({ variant: "destructive", description: "Failed to submit answer" }); diff --git a/lib/websocket.ts b/lib/websocket.ts index 081dbfd..f145724 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -2,13 +2,13 @@ import { Server as HttpServer, IncomingMessage } from "http"; import { WebSocket, WebSocketServer } from "ws"; import prisma from "./prisma"; -// Define connection parameters type +// connection parameters type ConnectionParams = { sessionId?: string; userId?: string; }; -// Define message types +// message type StudentResponseMessage = { type: "student_response"; questionId: number; @@ -58,7 +58,6 @@ type TextMessage = { message: string; }; -// Union type for all message types type WebSocketMessage = | StudentResponseMessage | ActiveQuestionUpdateMessage @@ -69,23 +68,18 @@ type WebSocketMessage = | ErrorMessage | TextMessage; -// Type for unknown parsed data type UnknownData = Record; -// Add new type for authenticated connection type AuthenticatedConnection = { userId: string; sessionId: string; ws: WebSocket; }; -// Store all active connections with authentication info const connections = new Map>(); -// Function to validate user session async function validateUserSession(userId: string, sessionId: string): Promise { try { - // Check if user exists and has access to the session const user = await prisma.user.findUnique({ where: { id: userId }, include: { @@ -93,63 +87,30 @@ async function validateUserSession(userId: string, sessionId: string): Promise 0; - } catch (error) { - console.error("Error validating user session:", error); + } catch (err) { + console.error("Error validating user session:", err); return false; } } -// Function declaration moved to fix "used before defined" error function broadcastToSession(sessionId: string, message: WebSocketMessage): void { - const sessConnections = connections.get(sessionId); - if (!sessConnections) return; - - console.log(`Broadcasting to session ${sessionId}`); + const sessConns = connections.get(sessionId); + if (!sessConns) return; - try { - // Ensure message is a proper object before stringifying - const messageObj: WebSocketMessage = - typeof message === "string" - ? (JSON.parse(message) as WebSocketMessage) // Convert string to object if it's JSON - : message; // Use as is if it's already an object - - const messageStr = JSON.stringify(messageObj); - - for (const connection of sessConnections.values()) { - try { - connection.ws.send(messageStr); - } catch (err) { - console.error("Error sending broadcast to client:", err); - } - } - } catch (error) { - console.error("Error broadcasting message:", error); - - // Fallback if message isn't valid JSON - if (typeof message === "string") { - const fallbackMsg = JSON.stringify({ - type: "text", - message, - } as TextMessage); - - for (const connection of sessConnections.values()) { - try { - connection.ws.send(fallbackMsg); - } catch (err) { - console.error("Error sending fallback broadcast:", err); - } - } + const msgStr = JSON.stringify(message); + for (const { ws } of sessConns.values()) { + try { + ws.send(msgStr); + } catch (err) { + console.error("Error broadcasting to client:", err); } } } @@ -157,217 +118,135 @@ function broadcastToSession(sessionId: string, message: WebSocketMessage): void export function initWebSocketServer(server: HttpServer): WebSocketServer { const wss = new WebSocketServer({ noServer: true }); - // Handle upgrade requests - server.on("upgrade", async (request: IncomingMessage, socket, head) => { + server.on("upgrade", async (req, socket, head) => { try { - if (!request.url) { - socket.destroy(); - return; - } + if (!req.url) return socket.destroy(); + const { pathname, searchParams } = new URL(req.url, `http://${req.headers.host}`); - const { pathname, searchParams } = new URL( - request.url, - `http://${request.headers.host}`, - ); - - if (pathname === "/ws") { - // For test endpoint - needed for backward compatibility - wss.handleUpgrade(request, socket, head, (ws) => { - wss.emit("connection", ws, request); - }); - } else if (pathname === "/ws/poll") { + if (pathname === "/ws/poll") { const sessionId = searchParams.get("sessionId"); const userId = searchParams.get("userId"); + if (!sessionId || !userId) return socket.destroy(); - if (!sessionId || !userId) { - socket.destroy(); - return; + if (!(await validateUserSession(userId, sessionId))) { + return socket.destroy(); } - // Validate user session - const isValid = await validateUserSession(userId, sessionId); - if (!isValid) { - socket.destroy(); - return; - } - - wss.handleUpgrade(request, socket, head, (ws) => { - wss.emit("connection", ws, request, { sessionId, userId }); + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req, { sessionId, userId }); }); } else { socket.destroy(); } - } catch (error) { - console.error("Error in WebSocket upgrade:", error); + } catch (err) { + console.error("Upgrade error:", err); socket.destroy(); } }); - // Handle WebSocket connections - wss.on( - "connection", - (ws: WebSocket, request: IncomingMessage, connectionParams: ConnectionParams = {}) => { - const { sessionId, userId } = connectionParams; - - if (!sessionId || !userId) { - ws.close(1008, "Missing session or user ID"); - return; - } + 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; + } - console.log(`WebSocket connection: SessionID=${sessionId}, UserID=${userId}`); + // register + if (!connections.has(sessionId)) connections.set(sessionId, new Map()); + connections.get(sessionId)!.set(userId, { userId, sessionId, ws }); - // Store the authenticated connection - 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), + ); - // Send connection confirmation - ws.send( - JSON.stringify({ - type: "connected", - message: "Connected to poll session", - } as ConnectedMessage), - ); - - ws.on("message", async (message: Buffer) => { - try { - // Try to parse the message - const data = JSON.parse(message.toString()) as UnknownData; - - // If this is a student response - if (data.type === "student_response") { - // Type checking and extraction - const typedData = data as StudentResponseMessage; - const questionId = typedData.questionId; - const optionIds = typedData.optionIds; - - // Validate required fields - if (typeof questionId !== "number" || !Array.isArray(optionIds)) { - throw new Error("Invalid student_response format"); - } - - try { - // Save responses to database - await Promise.all( - optionIds.map(optionId => - prisma.response.create({ - data: { - userId, - questionId, - optionId, - } - }) - ) - ); - - // Get response counts per option for this question - const responseCounts = await prisma.response.groupBy({ - by: ['optionId'], - where: { - questionId: questionId - }, - _count: { - optionId: true - } - }); - - // Single essential log for student response - console.log( - `Student response: Session=${sessionId}, Question=${questionId}, Options=${optionIds.join(", ")}`, - ); - - // Send confirmation back to student - ws.send( - JSON.stringify({ - type: "response_saved", - message: "Your answer has been recorded", - data: { - questionId, - optionIds, - }, - } as ResponseSavedMessage), - ); - - // Broadcast to all clients in this session that a new response has been received - broadcastToSession(sessionId, { - type: "response_update", - questionId, - responseCount: responseCounts.reduce((acc, curr) => acc + curr._count.optionId, 0), - optionCounts: responseCounts.reduce((acc, curr) => ({ - ...acc, - [curr.optionId]: curr._count.optionId - }), {}) - } as ResponseUpdateMessage); - } catch (error) { - console.error("Error saving response:", error); - ws.send( - JSON.stringify({ - type: "error", - message: "Failed to save response", - } as ErrorMessage), - ); - } - } + ws.on("message", async (raw) => { + try { + const data = JSON.parse(raw.toString()) as UnknownData; - // If instructor is updating the active question - else if (data.type === "active_question_update") { - // Type checking - const typedData = data as ActiveQuestionUpdateMessage; - const questionId = typedData.questionId; + if (data.type === "student_response") { + const { questionId, optionIds } = data as StudentResponseMessage; - // Validate required fields - if (typeof questionId !== "number") { - throw new Error("Invalid active_question_update format"); - } + if (typeof questionId !== "number" || !Array.isArray(optionIds)) { + throw new Error("Invalid student_response format"); + } - console.log(`Active question updated: QuestionID=${questionId}`); + // 1) delete old answers + const deleteResult = await prisma.response.deleteMany({ + where: { userId, questionId }, + }); - // Broadcast to all clients in this session - broadcastToSession(sessionId, { - type: "question_changed", + // 2) bulk insert new answers + const createResult = await prisma.response.createMany({ + data: optionIds.map((optId) => ({ + userId, questionId, - } as QuestionChangedMessage); - } - } catch (error) { - console.error("Error processing WebSocket message:", error); - - // Even on error, respond with proper JSON + optionId: optId, + })), + skipDuplicates: true, + }); + + // 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: "error", - message: "Invalid message format", - } as ErrorMessage), + type: "response_saved", + message: "Your answer has been recorded", + data: { questionId, optionIds }, + } as ResponseSavedMessage), ); - } - }); - ws.on("error", (error) => { - console.error("WebSocket connection error:", error); - }); - - ws.on("close", () => { - console.log(`WebSocket connection closed: SessionID=${sessionId}, UserID=${userId}`); + // broadcast update + broadcastToSession(sessionId, { + type: "response_update", + questionId, + responseCount: total, + optionCounts, + } as ResponseUpdateMessage); + } else if (data.type === "active_question_update") { + const { questionId } = data as ActiveQuestionUpdateMessage; + broadcastToSession(sessionId, { + type: "question_changed", + questionId, + } as QuestionChangedMessage); + } + } catch (err) { + console.error("WS message error:", err); + ws.send( + JSON.stringify({ + type: "error", + message: "Invalid message format", + } as ErrorMessage), + ); + } + }); - // Clean up the connection - const localSessionConnections = connections.get(sessionId); - if (localSessionConnections) { - localSessionConnections.delete(userId); + ws.on("close", () => { + const sessConns = connections.get(sessionId)!; + sessConns.delete(userId); + if (sessConns.size === 0) connections.delete(sessionId); + }); - if (localSessionConnections.size === 0) { - connections.delete(sessionId); - } - } - }); - }, - ); + ws.on("error", (e) => { + console.error("WS error:", e); + }); + }); return wss; } From f8619d93f029993045e52b9001eb6cf94c04f478 Mon Sep 17 00:00:00 2001 From: Ulises Salinas Date: Fri, 30 May 2025 07:50:01 -0700 Subject: [PATCH 09/24] Implemented next question logic to work with websockets and set up pause poll logic --- .../course/[courseId]/start-session/page.tsx | 17 +- components/LivePoll.tsx | 20 +- lib/websocket.ts | 12 +- package-lock.json | 575 ++---------------- package.json | 2 +- 5 files changed, 90 insertions(+), 536 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index ae425ef..75db1e5 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -55,10 +55,6 @@ export default function StartSession() { const wsRef = useRef(null); const session = useSession(); const [isPaused, setIsPaused] = useState(false); - const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); - const [isChangingQuestion, setIsChangingQuestion] = useState(false); // New state for question navigation - - useEffect(() => { async function fetchSessionData() { @@ -190,7 +186,17 @@ export default function StartSession() { if (!response.ok) { toast({ variant: "destructive", description: "Error updating question" }); console.error("Failed to update active question in DB", response); - return false; + } else { + // Notify all students of the question change + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: "active_question_update", + questionId: nextQuestionID, + courseSessionId: courseSession.id, + })); + } else { + console.warn("WebSocket not open, cannot send active_question_update"); + } } return true; } catch (err: unknown) { @@ -279,6 +285,7 @@ export default function StartSession() { setIsPaused(pauseState); try { await pauseOrResumeCourseSession(courseSession.id, pauseState); + wsRef.current?.send(JSON.stringify({ type: "pause_poll", paused: pauseState })); } catch (error) { toast({ variant: "destructive", diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index fce7351..60161fd 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -29,7 +29,8 @@ type WebSocketMessageType = | "error" | "echo" | "binary" - | "student_response"; + | "student_response" + | "poll_paused"; interface WebSocketMessageBase { type: WebSocketMessageType; @@ -60,11 +61,17 @@ interface ResponseUpdateMessage extends WebSocketMessageBase { optionCounts: Record; } +interface PollPausedMessage extends WebSocketMessageBase { + type: "poll_paused"; + paused: boolean; +} + // Union type for all message types type WebSocketMessage = | QuestionChangedMessage | ResponseSavedMessage | StudentResponseMessage + | PollPausedMessage | WebSocketMessageBase; export default function LivePoll({ @@ -91,6 +98,7 @@ export default function LivePoll({ const [questionCount, setQuestionCount] = useState("1"); const [isConnected, setIsConnected] = useState(false); const [_messages, setMessages] = useState([]); + const [isPaused, setIsPaused] = useState(false); // Use useRef for activeQuestionId to prevent unnecessary re-renders const activeQuestionIdRef = useRef(null); @@ -220,6 +228,10 @@ export default function LivePoll({ console.log("WebSocket connection confirmed:", data.message); } else if (data.type === "echo") { console.log("Server echo:", data.message); + } else if (data.type === "poll_paused") { + if ('paused' in data) { + setIsPaused(data.paused); + } } } } catch (_) { @@ -436,7 +448,7 @@ export default function LivePoll({ {/* Submission Status - crucial for visual feedback */} {submitting &&

Submitting...

} + {isPaused &&

Poll is currently paused.

} + {/* Footer Message */}

Instructor will start the next question shortly... diff --git a/lib/websocket.ts b/lib/websocket.ts index f145724..e75f057 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -58,6 +58,11 @@ type TextMessage = { message: string; }; +type PausePollMessage = { + type: "pause_poll"; + paused: boolean; +}; + type WebSocketMessage = | StudentResponseMessage | ActiveQuestionUpdateMessage @@ -66,7 +71,9 @@ type WebSocketMessage = | QuestionChangedMessage | ConnectedMessage | ErrorMessage - | TextMessage; + | TextMessage + | PausePollMessage + | { type: "poll_paused"; paused: boolean }; type UnknownData = Record; @@ -225,6 +232,9 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { type: "question_changed", questionId, } as QuestionChangedMessage); + } else if (data.type === "pause_poll") { + const { paused } = data as PausePollMessage; + broadcastToSession(sessionId, { type: "poll_paused", paused }); } } catch (err) { console.error("WS message error:", err); diff --git a/package-lock.json b/package-lock.json index 2c0f183..9a173a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,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", @@ -583,431 +583,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -1609,44 +1184,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": { @@ -1705,13 +1300,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": { @@ -5628,60 +5223,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", @@ -9278,16 +8819,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" @@ -9295,9 +8835,6 @@ "engines": { "node": ">=18.18" }, - "optionalDependencies": { - "fsevents": "2.3.3" - }, "peerDependencies": { "typescript": ">=5.1.0" }, @@ -9362,20 +8899,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", diff --git a/package.json b/package.json index e2394da..c39b2db 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,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", From 9439cf8117723cb825dc463f8780b2a130b14602 Mon Sep 17 00:00:00 2001 From: Ulises Salinas Date: Fri, 30 May 2025 08:54:09 -0700 Subject: [PATCH 10/24] Handled rebase conflicts, pause poll with websocket, and removed testing UI --- .../course/[courseId]/start-session/page.tsx | 87 +++++++++++-------- components/LivePoll.tsx | 17 ---- package-lock.json | 76 ++++++++-------- package.json | 2 +- 4 files changed, 91 insertions(+), 91 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 75db1e5..a4c5618 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -55,6 +55,9 @@ export default function StartSession() { const wsRef = useRef(null); const session = useSession(); const [isPaused, setIsPaused] = useState(false); + const [isChangingQuestion, setIsChangingQuestion] = useState(false); + const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); + useEffect(() => { async function fetchSessionData() { @@ -64,7 +67,6 @@ export default function StartSession() { if (session.activeQuestionId !== null) { setActiveQuestionId(session.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?) @@ -98,7 +100,6 @@ export default function StartSession() { try { const data = JSON.parse(event.data); console.log("Received WebSocket message:", data); - if (data.type === "response_update" && data.questionId === activeQuestionId) { console.log("Updating response counts:", data.optionCounts); setResponseCounts(data.optionCounts); @@ -122,7 +123,7 @@ export default function StartSession() { ws.close(); } }; - }, [courseSession, activeQuestionId, session.data?.user?.id]); + }, [courseSession, session.data?.user?.id]); // fetch session questions const { @@ -174,59 +175,59 @@ export default function StartSession() { 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); - } else { - // Notify all students of the question change + if (response.ok) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "active_question_update", questionId: nextQuestionID, courseSessionId: courseSession.id, })); - } else { - console.warn("WebSocket not open, cannot send active_question_update"); + console.log("Sent active_question_update via WebSocket (next)"); } } - return true; - } catch (err: unknown) { + } catch (err) { 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]); 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) { + if (wsRef.current && 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 (err) { + toast({ variant: "destructive", description: "Error updating question" }); + } setIsChangingQuestion(false); } - }, [activeIndex, questions, courseSession]); + }, [activeIndex, questions, courseSession, toast]); const handleQuestionSelect = useCallback( async (questionId: string) => { @@ -234,11 +235,29 @@ 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) { + if (wsRef.current && 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 (err) { + toast({ variant: "destructive", description: "Error updating question" }); + } setIsChangingQuestion(false); } }, - [courseSession], + [courseSession, toast], ); const handleAddWildcard = useCallback( @@ -464,9 +483,7 @@ export default function StartSession() {

diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index 60161fd..40d5013 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -404,23 +404,6 @@ export default function LivePoll({
- {/* Connection status */} -
-
-
-
- {isConnected ? "Connected" : "Disconnected"} -
- {session?.user && ( -
- Connected as: {session.user.firstName} {session.user.lastName} -
- )} -
-
- {/* Question header and count */}
diff --git a/package-lock.json b/package-lock.json index 9a173a7..25d2abe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@next-auth/prisma-adapter": "^1.0.7", "@playwright/test": "^1.49.0", "@prisma/client": "^6.4.0", - "@radix-ui/react-alert-dialog": "^1.1.13", + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", @@ -1489,17 +1489,17 @@ "license": "MIT" }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.13.tgz", - "integrity": "sha512-/uPs78OwxGxslYOG5TKeUsv9fZC0vo376cXSADdKirTmsLJU2au6L3n34c3p6W26rFDDDze/hwy4fYeNd0qdGA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz", + "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dialog": "1.1.13", - "@radix-ui/react-primitive": "2.1.2", - "@radix-ui/react-slot": "1.2.2" + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -1553,12 +1553,12 @@ } }, "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz", - "integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.2" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -1673,22 +1673,22 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.13.tgz", - "integrity": "sha512-ARFmqUyhIVS3+riWzwGTe7JLjqwqgnODBUZdqpWar/z1WFs9z76fuOs/2BOWCR+YboRn4/WN9aoaGVwqNRr8VA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.9", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.8", + "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.2", - "@radix-ui/react-slot": "1.2.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" @@ -1745,14 +1745,14 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.9.tgz", - "integrity": "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, @@ -1787,13 +1787,13 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.6.tgz", - "integrity": "sha512-r9zpYNUQY+2jWHWZGyddQLL9YHkM/XvSFHVcWs7bdVuxMAnCwTAuy6Pf47Z4nw7dYcUou1vg/VgjjrrH03VeBw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { @@ -1830,12 +1830,12 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.8.tgz", - "integrity": "sha512-hQsTUIn7p7fxCPvao/q6wpbxmCwgLrlz+nOrJgC+RwfZqWY/WN+UMqkXzrtKbPrF82P43eCTl3ekeKuyAQbFeg==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { @@ -1878,12 +1878,12 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz", - "integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.2" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2627,9 +2627,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" diff --git a/package.json b/package.json index c39b2db..6762c5f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@next-auth/prisma-adapter": "^1.0.7", "@playwright/test": "^1.49.0", "@prisma/client": "^6.4.0", - "@radix-ui/react-alert-dialog": "^1.1.13", + "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", From c5394b361504e2fb68cde74b4766f168a77e023d Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Fri, 30 May 2025 18:48:36 -0700 Subject: [PATCH 11/24] fixed linting issues --- .../course/[courseId]/start-session/page.tsx | 77 ++++--- app/test-ws/page.tsx | 10 +- components/LivePoll.tsx | 13 +- lib/prisma.ts | 1 + lib/websocket.ts | 208 ++++++++++-------- server.ts | 8 +- services/courseSession.ts | 8 +- 7 files changed, 185 insertions(+), 140 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index a4c5618..2c1399d 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -39,6 +39,18 @@ import { getQuestionsForSession, } from "@/services/session"; +interface WebSocketMessage { + type: string; + questionId?: number; + optionCounts?: Record; + responseCount?: number; +} + +interface ResponseCountsData { + optionCounts?: Record; + responseCount?: number; +} + export default function StartSession() { const params = useParams(); const router = useRouter(); @@ -51,9 +63,9 @@ export default function StartSession() { const [isAddingQuestion, setIsAddingQuestion] = useState(false); const [isEndingSession, setIsEndingSession] = useState(false); const [responseCounts, setResponseCounts] = useState>({}); - const [totalResponses, setTotalResponses] = useState(0); + const [_totalResponses, setTotalResponses] = useState(0); const wsRef = useRef(null); - const session = useSession(); + const sessionData = useSession(); const [isPaused, setIsPaused] = useState(false); const [isChangingQuestion, setIsChangingQuestion] = useState(false); const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); @@ -61,11 +73,11 @@ export default function StartSession() { 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); } } else { toast({ description: "No session found" }); @@ -84,11 +96,11 @@ export default function StartSession() { // Setup WebSocket connection useEffect(() => { - if (!courseSession || !session.data?.user?.id) return; + if (!courseSession || !sessionData.data?.user?.id) return; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket( - `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSession.id}&userId=${session.data.user.id}`, + `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSession.id}&userId=${sessionData.data.user.id}`, ); wsRef.current = ws; @@ -98,12 +110,16 @@ export default function StartSession() { ws.onmessage = (event) => { try { - const data = JSON.parse(event.data); + const data = JSON.parse(event.data as string) as WebSocketMessage; console.log("Received WebSocket message:", data); if (data.type === "response_update" && data.questionId === activeQuestionId) { console.log("Updating response counts:", data.optionCounts); - setResponseCounts(data.optionCounts); - setTotalResponses(data.responseCount); + if (data.optionCounts) { + setResponseCounts(data.optionCounts); + } + if (data.responseCount) { + setTotalResponses(data.responseCount); + } } } catch (error) { console.error("Error processing WebSocket message:", error); @@ -123,7 +139,7 @@ export default function StartSession() { ws.close(); } }; - }, [courseSession, session.data?.user?.id]); + }, [courseSession, sessionData.data?.user?.id]); // fetch session questions const { @@ -148,8 +164,8 @@ 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); }); } } @@ -194,7 +210,7 @@ export default function StartSession() { console.log("Sent active_question_update via WebSocket (next)"); } } - } catch (err) { + } catch (_error) { toast({ variant: "destructive", description: "Error updating question" }); } setIsChangingQuestion(false); @@ -222,7 +238,7 @@ export default function StartSession() { console.log("Sent active_question_update via WebSocket (prev)"); } } - } catch (err) { + } catch (_error) { toast({ variant: "destructive", description: "Error updating question" }); } setIsChangingQuestion(false); @@ -251,7 +267,7 @@ export default function StartSession() { console.log("Sent active_question_update via WebSocket (select)"); } } - } catch (err) { + } catch (_error) { toast({ variant: "destructive", description: "Error updating question" }); } setIsChangingQuestion(false); @@ -329,12 +345,16 @@ export default function StartSession() { fetch(`/api/getResponseCounts?questionId=${activeQuestionId}`) .then((res) => res.json()) - .then((data) => { - setResponseCounts(data.optionCounts || {}); - setTotalResponses(data.responseCount || 0); + .then((data: ResponseCountsData) => { + if (data.optionCounts) { + setResponseCounts(data.optionCounts); + } + if (data.responseCount) { + setTotalResponses(data.responseCount); + } }) - .catch((err) => { - console.error("Failed to fetch response counts:", err); + .catch((error: unknown) => { + console.error("Failed to fetch response counts:", error); }); }, [activeQuestionId]); @@ -483,8 +503,9 @@ export default function StartSession() {
@@ -526,7 +547,9 @@ export default function StartSession() { ) : (
); -} +} \ No newline at end of file diff --git a/app/test-ws/page.tsx b/app/test-ws/page.tsx index ea628dd..04e16f2 100644 --- a/app/test-ws/page.tsx +++ b/app/test-ws/page.tsx @@ -13,24 +13,24 @@ export default function TestWebSocket() { socket.onopen = () => { console.log("Connected to WebSocket"); - setMessages((prev) => [...prev, "Connected to WebSocket"]); + setMessages((prev: string[]) => [...prev, "Connected to WebSocket"]); setIsConnected(true); }; socket.onmessage = (event) => { console.log("Received:", event.data); - setMessages((prev) => [...prev, event.data]); + setMessages((prev: string[]) => [...prev, String(event.data)]); }; socket.onclose = () => { console.log("Disconnected from WebSocket"); - setMessages((prev) => [...prev, "Disconnected from WebSocket"]); + setMessages((prev: string[]) => [...prev, "Disconnected from WebSocket"]); setIsConnected(false); }; socket.onerror = (error) => { console.error("WebSocket error:", error); - setMessages((prev) => [...prev, "WebSocket error occurred"]); + setMessages((prev: string[]) => [...prev, "WebSocket error occurred"]); }; setWs(socket); @@ -91,4 +91,4 @@ export default function TestWebSocket() {
); -} +} \ No newline at end of file diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index 2b81b85..38929f7 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -9,7 +9,6 @@ import QuestionCard from "@/components/ui/questionCard"; import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; -import { getSessionPauseState } from "@/services/courseSession"; type QuestionWithOptions = PrismaQuestion & { options: PrismaOption[]; @@ -54,12 +53,12 @@ interface StudentResponseMessage extends WebSocketMessageBase { } // Add new type for response updates -interface ResponseUpdateMessage extends WebSocketMessageBase { - type: "response_update"; - questionId: number; - responseCount: number; - optionCounts: Record; -} +// interface ResponseUpdateMessage extends WebSocketMessageBase { +// type: "response_update"; +// questionId: number; +// responseCount: number; +// optionCounts: Record; +// } interface PollPausedMessage extends WebSocketMessageBase { type: "poll_paused"; diff --git a/lib/prisma.ts b/lib/prisma.ts index 20f4f43..7b773a8 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -3,6 +3,7 @@ import { PrismaClient } from "@prisma/client"; let prisma: PrismaClient; declare global { + // eslint-disable-next-line no-var var prisma: PrismaClient | undefined; } diff --git a/lib/websocket.ts b/lib/websocket.ts index e75f057..afe52fd 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -125,30 +125,32 @@ function broadcastToSession(sessionId: string, message: WebSocketMessage): void export function initWebSocketServer(server: HttpServer): WebSocketServer { const wss = new WebSocketServer({ noServer: true }); - server.on("upgrade", async (req, socket, head) => { - try { - if (!req.url) return socket.destroy(); - const { pathname, searchParams } = new URL(req.url, `http://${req.headers.host}`); + 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 (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(); - } + if (!(await validateUserSession(userId, sessionId))) { + return socket.destroy(); + } - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit("connection", ws, req, { sessionId, userId }); - }); - } else { + 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(); } - } catch (err) { - console.error("Upgrade error:", err); - socket.destroy(); - } + })(); }); wss.on("connection", (ws: WebSocket, _req: IncomingMessage, params: ConnectionParams = {}) => { @@ -160,7 +162,10 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { // register if (!connections.has(sessionId)) connections.set(sessionId, new Map()); - connections.get(sessionId)!.set(userId, { userId, sessionId, ws }); + const sessionConnections = connections.get(sessionId); + if (sessionConnections) { + sessionConnections.set(userId, { userId, sessionId, ws }); + } // confirm ws.send( @@ -170,87 +175,104 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { } as ConnectedMessage), ); - ws.on("message", async (raw) => { - try { - const data = JSON.parse(raw.toString()) as UnknownData; - - if (data.type === "student_response") { - const { questionId, optionIds } = data as StudentResponseMessage; - - if (typeof questionId !== "number" || !Array.isArray(optionIds)) { - throw new Error("Invalid student_response format"); + 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'); } - - // 1) delete old answers - const deleteResult = await prisma.response.deleteMany({ - where: { userId, questionId }, - }); - - // 2) bulk insert new answers - const createResult = await prisma.response.createMany({ - data: optionIds.map((optId) => ({ - userId, + const data = JSON.parse(rawString) as UnknownData; + + if (data.type === "student_response") { + const { questionId, optionIds } = data as StudentResponseMessage; + + if (typeof questionId !== "number" || !Array.isArray(optionIds)) { + throw new Error("Invalid student_response format"); + } + + // 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, - optionId: optId, - })), - skipDuplicates: true, - }); - - // 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 + responseCount: total, + optionCounts, + } as ResponseUpdateMessage); + } else if (data.type === "active_question_update") { + const { questionId } = data as ActiveQuestionUpdateMessage; + console.log("Broadcasting question change:", questionId); + // Ensure all clients get the question change notification + const message: QuestionChangedMessage = { + type: "question_changed", + questionId + }; + broadcastToSession(sessionId, message); + } else if (data.type === "pause_poll") { + const { paused } = data as PausePollMessage; + broadcastToSession(sessionId, { type: "poll_paused", paused }); + } + } catch (err) { + console.error("WS message error:", err); ws.send( JSON.stringify({ - type: "response_saved", - message: "Your answer has been recorded", - data: { questionId, optionIds }, - } as ResponseSavedMessage), + type: "error", + message: "Invalid message format", + } as ErrorMessage), ); - - // broadcast update - broadcastToSession(sessionId, { - type: "response_update", - questionId, - responseCount: total, - optionCounts, - } as ResponseUpdateMessage); - } else if (data.type === "active_question_update") { - const { questionId } = data as ActiveQuestionUpdateMessage; - broadcastToSession(sessionId, { - type: "question_changed", - questionId, - } as QuestionChangedMessage); - } else if (data.type === "pause_poll") { - const { paused } = data as PausePollMessage; - broadcastToSession(sessionId, { type: "poll_paused", paused }); } - } 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)!; - sessConns.delete(userId); - if (sessConns.size === 0) connections.delete(sessionId); + const sessConns = connections.get(sessionId); + if (sessConns) { + sessConns.delete(userId); + if (sessConns.size === 0) connections.delete(sessionId); + } }); ws.on("error", (e) => { @@ -259,4 +281,4 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { }); return wss; -} +} \ No newline at end of file diff --git a/server.ts b/server.ts index 5eac00f..8b3883b 100644 --- a/server.ts +++ b/server.ts @@ -28,18 +28,18 @@ void app return; } - handle(req, res, parsedUrl); + void handle(req, res, parsedUrl); }); // Initialize WebSocket server - const wss = initWebSocketServer(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) => { + .catch((error: unknown) => { console.error("Error preparing Next.js app:", error); process.exit(1); - }); + }); \ No newline at end of file diff --git a/services/courseSession.ts b/services/courseSession.ts index 94a9977..8d59ca5 100644 --- a/services/courseSession.ts +++ b/services/courseSession.ts @@ -103,16 +103,16 @@ 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"); } -} +} \ No newline at end of file From 49089ae67eacc410a9bb290f19f1b4228c60c409 Mon Sep 17 00:00:00 2001 From: Ulises Salinas Date: Tue, 3 Jun 2025 05:27:07 -0700 Subject: [PATCH 12/24] Ran lint-fix and fixed manual refresh issue for updating chart through responses --- app/api/updateCourse/[id]/route.ts | 296 ++++++++-------- .../course/[courseId]/questionnaire/page.tsx | 2 +- .../course/[courseId]/start-session/page.tsx | 125 ++++--- app/test-ws/page.tsx | 2 +- components/AddEditCourseForm.tsx | 1 - components/AddEditQuestionForm.tsx | 1 - components/LivePoll.tsx | 7 +- components/ui/CourseCard.tsx | 327 +++++++++--------- components/ui/SlidingCalendar.tsx | 6 +- components/ui/answerOptions.tsx | 1 - lib/utils.ts | 1 - lib/websocket.ts | 12 +- server.ts | 2 +- services/course.ts | 10 +- services/courseSession.ts | 2 +- 15 files changed, 411 insertions(+), 384 deletions(-) diff --git a/app/api/updateCourse/[id]/route.ts b/app/api/updateCourse/[id]/route.ts index 33c230a..36cecc4 100644 --- a/app/api/updateCourse/[id]/route.ts +++ b/app/api/updateCourse/[id]/route.ts @@ -5,173 +5,167 @@ import { authOptions } from "@/lib/auth"; import prisma from "@/lib/prisma"; const updateSchema = z.object({ - title: z.string().min(2), - color: z.string().length(7), - days: z.array(z.string()).min(1), - startTime: z.string(), - endTime: z.string(), + title: z.string().min(2), + color: z.string().length(7), + days: z.array(z.string()).min(1), + startTime: z.string(), + endTime: z.string(), }); function getCourseId(request: Request): number { - const url = new URL(request.url); - const id = url.pathname.split('/').pop(); - const courseId = parseInt(id ?? ''); - - if (isNaN(courseId)) { - throw new Error('Invalid course ID'); - } - return courseId; + const url = new URL(request.url); + const id = url.pathname.split("/").pop(); + const courseId = parseInt(id ?? ""); + + if (isNaN(courseId)) { + throw new Error("Invalid course ID"); + } + return courseId; } // PUT - Update Course export async function PUT(request: Request) { - const session = await getServerSession(authOptions); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const courseId = getCourseId(request); - - // Verify user has permission - const userCourse = await prisma.userCourse.findUnique({ - where: { - userId_courseId: { - userId: session.user.id, - courseId, - }, - }, - }); - - if (!userCourse || userCourse.role !== "LECTURER") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Validate request body - const body: unknown = await request.json(); - const parsed = updateSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: "Invalid input", details: parsed.error.flatten() }, - { status: 400 } - ); - } + try { + const courseId = getCourseId(request); - const { title, color, days, startTime, endTime } = parsed.data; - - // Update course and schedule in transaction - const [updatedCourse] = await prisma.$transaction([ - prisma.course.update({ - where: { id: courseId }, - data: { title, color }, - }), - prisma.schedule.updateMany({ - where: { courseId }, - data: { dayOfWeek: days, startTime, endTime }, - }), - ]); - - return NextResponse.json(updatedCourse); - } catch (error) { - if (error instanceof Error && error.message === 'Invalid course ID') { - return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + // Verify user has permission + const userCourse = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId: session.user.id, + courseId, + }, + }, + }); + + if (!userCourse || userCourse.role !== "LECTURER") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Validate request body + const body: unknown = await request.json(); + const parsed = updateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid input", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { title, color, days, startTime, endTime } = parsed.data; + + // Update course and schedule in transaction + const [updatedCourse] = await prisma.$transaction([ + prisma.course.update({ + where: { id: courseId }, + data: { title, color }, + }), + prisma.schedule.updateMany({ + where: { courseId }, + data: { dayOfWeek: days, startTime, endTime }, + }), + ]); + + return NextResponse.json(updatedCourse); + } catch (error) { + if (error instanceof Error && error.message === "Invalid course ID") { + return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + } + + console.error("Error updating course:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } - - console.error("Error updating course:", error); - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 } - ); - } } // DELETE - Delete Course export async function DELETE(request: Request) { - const session = await getServerSession(authOptions); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const courseId = getCourseId(request); - - // Verify user has permission - const userCourse = await prisma.userCourse.findUnique({ - where: { - userId_courseId: { - userId: session.user.id, - courseId, - }, - }, - }); - - if (!userCourse) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (userCourse.role === "LECTURER") { - // Lecturer - delete all related records in proper order - await prisma.$transaction([ - prisma.response.deleteMany({ - where: { - question: { - session: { - courseId, - }, - }, - }, - }), - prisma.option.deleteMany({ - where: { - question: { - session: { - courseId, - }, - }, - }, - }), - prisma.question.deleteMany({ - where: { - session: { - courseId, - }, - }, - }), - prisma.courseSession.deleteMany({ - where: { courseId }, - }), - prisma.schedule.deleteMany({ - where: { courseId }, - }), - prisma.userCourse.deleteMany({ - where: { courseId }, - }), - prisma.course.delete({ - where: { id: courseId }, - }), - ]); - } else { - // Student - only remove their association - await prisma.userCourse.delete({ - where: { - userId_courseId: { - userId: session.user.id, - courseId, - }, - }, - }); - } + try { + const courseId = getCourseId(request); - return NextResponse.json({ success: true }); - } catch (error) { - if (error instanceof Error && error.message === 'Invalid course ID') { - return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + // Verify user has permission + const userCourse = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId: session.user.id, + courseId, + }, + }, + }); + + if (!userCourse) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (userCourse.role === "LECTURER") { + // Lecturer - delete all related records in proper order + await prisma.$transaction([ + prisma.response.deleteMany({ + where: { + question: { + session: { + courseId, + }, + }, + }, + }), + prisma.option.deleteMany({ + where: { + question: { + session: { + courseId, + }, + }, + }, + }), + prisma.question.deleteMany({ + where: { + session: { + courseId, + }, + }, + }), + prisma.courseSession.deleteMany({ + where: { courseId }, + }), + prisma.schedule.deleteMany({ + where: { courseId }, + }), + prisma.userCourse.deleteMany({ + where: { courseId }, + }), + prisma.course.delete({ + where: { id: courseId }, + }), + ]); + } else { + // Student - only remove their association + await prisma.userCourse.delete({ + where: { + userId_courseId: { + userId: session.user.id, + courseId, + }, + }, + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Invalid course ID") { + return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + } + + console.error("Error deleting course:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } - - console.error("Error deleting course:", error); - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 } - ); - } -} \ No newline at end of file +} diff --git a/app/dashboard/course/[courseId]/questionnaire/page.tsx b/app/dashboard/course/[courseId]/questionnaire/page.tsx index 7b56b38..40ea7d1 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/questionnaire/page.tsx @@ -26,7 +26,7 @@ export default function Page() { const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); const [refreshCalendar, setRefreshCalendar] = useState(false); const handleQuestionUpdate = () => { - setRefreshCalendar(prev => !prev); + setRefreshCalendar((prev) => !prev); }; useEffect(() => { diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 2c1399d..8fa943d 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -62,20 +62,24 @@ export default function StartSession() { const [activeQuestionId, setActiveQuestionId] = useState(null); const [isAddingQuestion, setIsAddingQuestion] = useState(false); const [isEndingSession, setIsEndingSession] = useState(false); - const [responseCounts, setResponseCounts] = useState>({}); const [_totalResponses, setTotalResponses] = useState(0); const wsRef = useRef(null); const sessionData = useSession(); const [isPaused, setIsPaused] = useState(false); const [isChangingQuestion, setIsChangingQuestion] = useState(false); const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); - + const [allResponseCounts, setAllResponseCounts] = useState< + Record> + >({}); useEffect(() => { async function fetchSessionData() { const sessionResult = await getCourseSessionByDate(courseId, utcDate); if (sessionResult) { - setCourseSession({ id: sessionResult.id, activeQuestionId: sessionResult.activeQuestionId }); + setCourseSession({ + id: sessionResult.id, + activeQuestionId: sessionResult.activeQuestionId, + }); if (sessionResult.activeQuestionId !== null) { setActiveQuestionId(sessionResult.activeQuestionId); } @@ -112,10 +116,21 @@ export default function StartSession() { try { const data = JSON.parse(event.data as string) as WebSocketMessage; console.log("Received WebSocket message:", data); - if (data.type === "response_update" && data.questionId === activeQuestionId) { + + if (data.type === "response_update") { console.log("Updating response counts:", data.optionCounts); - if (data.optionCounts) { - setResponseCounts(data.optionCounts); + if ( + data.optionCounts && + data.questionId !== undefined && + data.questionId !== null + ) { + setAllResponseCounts( + (prev) => + ({ + ...prev, + [String(data.questionId)]: data.optionCounts!, + }) as Record>, + ); } if (data.responseCount) { setTotalResponses(data.responseCount); @@ -178,14 +193,14 @@ export default function StartSession() { // Update chart data to use WebSocket updates const shuffledOptions = useMemo( - () => questionData ? shuffleArray(questionData.options) : [], - [activeQuestionId, questionData?.options] + () => (questionData ? shuffleArray(questionData.options) : []), + [activeQuestionId, questionData?.options], ); - const chartData = shuffledOptions.map(option => ({ - option: option.text, - Votes: responseCounts?.[option.id] || 0, -})); + const chartData = shuffledOptions.map((option) => ({ + option: option.text, + Votes: (activeQuestionId && allResponseCounts[String(activeQuestionId)]?.[option.id]) || 0, + })); const totalVotes = chartData.reduce((sum, item) => sum + item.Votes, 0); @@ -202,11 +217,13 @@ export default function StartSession() { }); if (response.ok) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - type: "active_question_update", - questionId: nextQuestionID, - courseSessionId: courseSession.id, - })); + wsRef.current.send( + JSON.stringify({ + type: "active_question_update", + questionId: nextQuestionID, + courseSessionId: courseSession.id, + }), + ); console.log("Sent active_question_update via WebSocket (next)"); } } @@ -230,11 +247,13 @@ export default function StartSession() { }); if (response.ok) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - type: "active_question_update", - questionId: prevQuestionID, - courseSessionId: courseSession.id, - })); + wsRef.current.send( + JSON.stringify({ + type: "active_question_update", + questionId: prevQuestionID, + courseSessionId: courseSession.id, + }), + ); console.log("Sent active_question_update via WebSocket (prev)"); } } @@ -252,18 +271,23 @@ export default function StartSession() { const selectedQuestionId = parseInt(questionId); setActiveQuestionId(selectedQuestionId); try { - const response = await fetch(`/api/session/${courseSession.id}/activeQuestion`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ activeQuestionId: selectedQuestionId }), - }); + const response = await fetch( + `/api/session/${courseSession.id}/activeQuestion`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activeQuestionId: selectedQuestionId }), + }, + ); if (response.ok) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - type: "active_question_update", - questionId: selectedQuestionId, - courseSessionId: courseSession.id, - })); + wsRef.current.send( + JSON.stringify({ + type: "active_question_update", + questionId: selectedQuestionId, + courseSessionId: courseSession.id, + }), + ); console.log("Sent active_question_update via WebSocket (select)"); } } @@ -346,8 +370,18 @@ export default function StartSession() { fetch(`/api/getResponseCounts?questionId=${activeQuestionId}`) .then((res) => res.json()) .then((data: ResponseCountsData) => { - if (data.optionCounts) { - setResponseCounts(data.optionCounts); + if ( + data.optionCounts && + typeof activeQuestionId === "number" && + !isNaN(activeQuestionId) + ) { + setAllResponseCounts( + (prev) => + ({ + ...prev, + [String(activeQuestionId)]: data.optionCounts!, + }) as Record>, + ); } if (data.responseCount) { setTotalResponses(data.responseCount); @@ -396,7 +430,7 @@ export default function StartSession() { className="w-full text-base md:text-lg" > - {showResults ? ( + {showResults ? ( - ) : ( -
- -

- Poll results are hidden -

-
- )} + ) : ( +
+ +

+ Poll results are hidden +

+
+ )}
) : ( @@ -505,7 +539,8 @@ export default function StartSession() {
@@ -559,4 +594,4 @@ export default function StartSession() {
); -} \ No newline at end of file +} diff --git a/app/test-ws/page.tsx b/app/test-ws/page.tsx index 04e16f2..5d1ae80 100644 --- a/app/test-ws/page.tsx +++ b/app/test-ws/page.tsx @@ -91,4 +91,4 @@ export default function TestWebSocket() {
); -} \ No newline at end of file +} diff --git a/components/AddEditCourseForm.tsx b/components/AddEditCourseForm.tsx index 0745b16..5b88ef5 100644 --- a/components/AddEditCourseForm.tsx +++ b/components/AddEditCourseForm.tsx @@ -62,7 +62,6 @@ export const AddEditCourseForm = ({ }, }); - const handleSubmit = async (values: z.infer) => { const { title, color, days, endTime, startTime } = values; setLoading(true); diff --git a/components/AddEditQuestionForm.tsx b/components/AddEditQuestionForm.tsx index d6d7d1d..250a96d 100644 --- a/components/AddEditQuestionForm.tsx +++ b/components/AddEditQuestionForm.tsx @@ -130,7 +130,6 @@ export const AddEditQuestionForm: React.FC = ({ }); } }, [prevData]); - useEffect(() => { if (!defaultDate) return; diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index 38929f7..93da7a5 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -9,7 +9,6 @@ import QuestionCard from "@/components/ui/questionCard"; import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; - type QuestionWithOptions = PrismaQuestion & { options: PrismaOption[]; }; @@ -228,7 +227,7 @@ export default function LivePoll({ } else if (data.type === "echo") { console.log("Server echo:", data.message); } else if (data.type === "poll_paused") { - if ('paused' in data) { + if ("paused" in data) { setIsPaused(data.paused); } } @@ -461,7 +460,9 @@ export default function LivePoll({ {/* Submission Status - crucial for visual feedback */} {submitting &&

Submitting...

} - {isPaused &&

Poll is currently paused.

} + {isPaused && ( +

Poll is currently paused.

+ )} {/* Footer Message */}

diff --git a/components/ui/CourseCard.tsx b/components/ui/CourseCard.tsx index a3ab2d5..0ff6e98 100644 --- a/components/ui/CourseCard.tsx +++ b/components/ui/CourseCard.tsx @@ -5,187 +5,186 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { MoreVertical, Edit, Trash2 } from "lucide-react"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AddEditCourseForm } from "@/components/AddEditCourseForm"; import { dayLabels } from "@/lib/constants"; export type CourseCardProps = { - color: string; - days: string[]; - title: string; - timeStart: string; - timeEnd: string; - code: string; - role: Role; - id: number; - onEdit?: () => void; - onDelete?: () => void; + color: string; + days: string[]; + title: string; + timeStart: string; + timeEnd: string; + code: string; + role: Role; + id: number; + onEdit?: () => void; + onDelete?: () => void; }; export default function CourseCard({ - color, - days, - title, - timeStart, - timeEnd, - code, - role, - id, - onEdit, - onDelete, + color, + days, + title, + timeStart, + timeEnd, + code, + role, + id, + onEdit, + onDelete, }: CourseCardProps) { - const router = useRouter(); - const [isEditOpen, setIsEditOpen] = useState(false); + const router = useRouter(); + const [isEditOpen, setIsEditOpen] = useState(false); - const shortDays = days - .map((fullDay) => { - const entry = Object.entries(dayLabels).find(([, label]) => label === fullDay); - return entry ? entry[0] : undefined; - }) - .filter(Boolean) as ("M" | "T" | "W" | "Th" | "F")[]; + const shortDays = days + .map((fullDay) => { + const entry = Object.entries(dayLabels).find(([, label]) => label === fullDay); + return entry ? entry[0] : undefined; + }) + .filter(Boolean) as ("M" | "T" | "W" | "Th" | "F")[]; - const handleCardClick = () => { - if (!isEditOpen) { - router.push( - role === "LECTURER" - ? `/dashboard/course/${id}/questionnaire` - : `/dashboard/course/${id}/live-poll` - ); - } - }; + const handleCardClick = () => { + if (!isEditOpen) { + router.push( + role === "LECTURER" + ? `/dashboard/course/${id}/questionnaire` + : `/dashboard/course/${id}/live-poll`, + ); + } + }; - const handleEdit = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsEditOpen(true); - }; + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsEditOpen(true); + }; - const handleDelete = (e: React.MouseEvent) => { - e.stopPropagation(); - onDelete?.(); - }; + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete?.(); + }; - const handleEditClose = () => { - setIsEditOpen(false); - onEdit?.(); - }; + const handleEditClose = () => { + setIsEditOpen(false); + onEdit?.(); + }; - const CardContent = () => ( - <> -

-
-

- Time: {timeStart} - {timeEnd} -

-

{title}

- -

- Code:{" "} - - {(+code).toLocaleString("en-US", { - minimumIntegerDigits: 6, - useGrouping: false, - })} - -

-

- Role: {role.toLocaleLowerCase()} -

-
-

{days.join(", ")}

-
- - ); + const CardContent = () => ( + <> +
+
+

+ Time: {timeStart} - {timeEnd} +

+

{title}

+ +

+ Code:{" "} + + {(+code).toLocaleString("en-US", { + minimumIntegerDigits: 6, + useGrouping: false, + })} + +

+

+ Role: {role.toLocaleLowerCase()} +

+
+

{days.join(", ")}

+
+ + ); - const MobileCardContent = () => ( - <> -
-
-

- Time: {timeStart} - {timeEnd} -

-

{title}

-
-

{days.join(", ")}

- - ); + const MobileCardContent = () => ( + <> +
+
+

+ Time: {timeStart} - {timeEnd} +

+

{title}

+
+

{days.join(", ")}

+ + ); - const MenuDropdown = () => ( - - - - - - {role === "LECTURER" && ( - { - event.preventDefault(); - handleEdit(event as unknown as React.MouseEvent); - }} - > - - Edit - - )} - handleDelete(event as unknown as React.MouseEvent)}> - - {role === "LECTURER" ? "Delete" : "Leave"} - - - - ); + const MenuDropdown = () => ( + + + + + + {role === "LECTURER" && ( + { + event.preventDefault(); + handleEdit(event as unknown as React.MouseEvent); + }} + > + + Edit + + )} + handleDelete(event as unknown as React.MouseEvent)} + > + + {role === "LECTURER" ? "Delete" : "Leave"} + + + + ); - return ( - <> - {/* Mobile View */} -
- - -
+ return ( + <> + {/* Mobile View */} +
+ + +
- {/* Desktop View */} -
- - -
+ {/* Desktop View */} +
+ + +
- !open && handleEditClose()} - defaultValues={{ - title, - color, - days: shortDays, - startTime: timeStart, - endTime: timeEnd, - }} - onSuccess={handleEditClose} - /> - - ); -} \ No newline at end of file + !open && handleEditClose()} + defaultValues={{ + title, + color, + days: shortDays, + startTime: timeStart, + endTime: timeEnd, + }} + onSuccess={handleEditClose} + /> + + ); +} diff --git a/components/ui/SlidingCalendar.tsx b/components/ui/SlidingCalendar.tsx index f5252ef..82aa56c 100644 --- a/components/ui/SlidingCalendar.tsx +++ b/components/ui/SlidingCalendar.tsx @@ -253,7 +253,11 @@ function SlidingCalendar({ courseId, refreshTrigger }: Props) { courseId={courseId} location="page" questionId={question.id} - onUpdate={() => fetchQuestions(selectedDate.toDate())} + onUpdate={() => + fetchQuestions( + selectedDate.toDate(), + ) + } prevData={{ question: question.text, selectedQuestionType: diff --git a/components/ui/answerOptions.tsx b/components/ui/answerOptions.tsx index ecd56f8..9467497 100644 --- a/components/ui/answerOptions.tsx +++ b/components/ui/answerOptions.tsx @@ -16,7 +16,6 @@ interface AnswerOptionsProps { onSelectionChange: (value: number | number[]) => void; } - const AnswerOptions: React.FC = ({ options, questionType, diff --git a/lib/utils.ts b/lib/utils.ts index 012c43a..14677e9 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -32,7 +32,6 @@ export function formatDateToISO(date: Date) { return new Date(date.setHours(0, 0, 0, 0)).toISOString(); } - export function shuffleArray(array: T[]): T[] { const copy = [...array]; for (let i = copy.length - 1; i > 0; i--) { diff --git a/lib/websocket.ts b/lib/websocket.ts index afe52fd..82a6136 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -181,10 +181,10 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { let rawString: string; if (raw instanceof Buffer) { rawString = raw.toString(); - } else if (typeof raw === 'string') { + } else if (typeof raw === "string") { rawString = raw; } else { - throw new Error('Unsupported message format'); + throw new Error("Unsupported message format"); } const data = JSON.parse(rawString) as UnknownData; @@ -199,7 +199,7 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { const _deleteResult = await prisma.response.deleteMany({ where: { userId, questionId }, }); - console.log(_deleteResult) + console.log(_deleteResult); // 2) bulk insert new answers const _createResult = await prisma.response.createMany({ @@ -210,7 +210,7 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { })), skipDuplicates: true, }); - console.log(_createResult) + console.log(_createResult); // 3) re-aggregate and broadcast const groups = await prisma.response.groupBy({ @@ -248,7 +248,7 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { // Ensure all clients get the question change notification const message: QuestionChangedMessage = { type: "question_changed", - questionId + questionId, }; broadcastToSession(sessionId, message); } else if (data.type === "pause_poll") { @@ -281,4 +281,4 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { }); return wss; -} \ No newline at end of file +} diff --git a/server.ts b/server.ts index 8b3883b..9f5f782 100644 --- a/server.ts +++ b/server.ts @@ -42,4 +42,4 @@ void app .catch((error: unknown) => { console.error("Error preparing Next.js app:", error); process.exit(1); - }); \ No newline at end of file + }); diff --git a/services/course.ts b/services/course.ts index 544a326..9ef88b4 100644 --- a/services/course.ts +++ b/services/course.ts @@ -115,7 +115,7 @@ type UpdateCourseParams = { export async function updateCourse( courseId: number, - data: UpdateCourseParams + data: UpdateCourseParams, ): Promise { try { // First update the course details @@ -148,10 +148,8 @@ export async function updateCourse( return updatedCourse; } catch (error) { console.error("Error updating course:", error); - return { - error: error instanceof Error - ? error.message - : "Failed to update course" + return { + error: error instanceof Error ? error.message : "Failed to update course", }; } -} \ No newline at end of file +} diff --git a/services/courseSession.ts b/services/courseSession.ts index 8d59ca5..d1441c8 100644 --- a/services/courseSession.ts +++ b/services/courseSession.ts @@ -115,4 +115,4 @@ export async function getSessionPauseState(sessionId: number): Promise console.error("Error getting session pause state:", error); throw new Error("Failed to get session pause state"); } -} \ No newline at end of file +} From 92f7194512dac9949d5c253750035b30fd05ff4a Mon Sep 17 00:00:00 2001 From: Ulises Salinas Date: Tue, 3 Jun 2025 05:31:52 -0700 Subject: [PATCH 13/24] Addressed lint errors --- .../course/[courseId]/start-session/page.tsx | 12 ++++++------ components/LivePoll.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 8fa943d..790df7f 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -128,7 +128,7 @@ export default function StartSession() { (prev) => ({ ...prev, - [String(data.questionId)]: data.optionCounts!, + [String(data.questionId)]: data.optionCounts ?? {}, }) as Record>, ); } @@ -199,7 +199,7 @@ export default function StartSession() { const chartData = shuffledOptions.map((option) => ({ option: option.text, - Votes: (activeQuestionId && allResponseCounts[String(activeQuestionId)]?.[option.id]) || 0, + Votes: (activeQuestionId && allResponseCounts[String(activeQuestionId)]?.[option.id]) ?? 0, })); const totalVotes = chartData.reduce((sum, item) => sum + item.Votes, 0); @@ -227,7 +227,7 @@ export default function StartSession() { console.log("Sent active_question_update via WebSocket (next)"); } } - } catch (_error) { + } catch { toast({ variant: "destructive", description: "Error updating question" }); } setIsChangingQuestion(false); @@ -257,7 +257,7 @@ export default function StartSession() { console.log("Sent active_question_update via WebSocket (prev)"); } } - } catch (_error) { + } catch { toast({ variant: "destructive", description: "Error updating question" }); } setIsChangingQuestion(false); @@ -291,7 +291,7 @@ export default function StartSession() { console.log("Sent active_question_update via WebSocket (select)"); } } - } catch (_error) { + } catch { toast({ variant: "destructive", description: "Error updating question" }); } setIsChangingQuestion(false); @@ -379,7 +379,7 @@ export default function StartSession() { (prev) => ({ ...prev, - [String(activeQuestionId)]: data.optionCounts!, + [String(activeQuestionId)]: data.optionCounts ?? {}, }) as Record>, ); } diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index 93da7a5..9410f6e 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -232,7 +232,7 @@ export default function LivePoll({ } } } - } catch (_) { + } catch { const message = event.data; messageText = `Received text: ${message}`; From 81ae4154841aa52ca48c8568ccdb1cda99696a4a Mon Sep 17 00:00:00 2001 From: Ulises Salinas Date: Tue, 3 Jun 2025 05:44:20 -0700 Subject: [PATCH 14/24] Removed testing file --- app/test-ws/page.tsx | 94 -------------------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 app/test-ws/page.tsx diff --git a/app/test-ws/page.tsx b/app/test-ws/page.tsx deleted file mode 100644 index 5d1ae80..0000000 --- a/app/test-ws/page.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -export default function TestWebSocket() { - const [messages, setMessages] = useState([]); - const [inputMessage, setInputMessage] = useState(""); - const [ws, setWs] = useState(null); - const [isConnected, setIsConnected] = useState(false); - - useEffect(() => { - const socket = new WebSocket("ws://localhost:3000/ws"); - - socket.onopen = () => { - console.log("Connected to WebSocket"); - setMessages((prev: string[]) => [...prev, "Connected to WebSocket"]); - setIsConnected(true); - }; - - socket.onmessage = (event) => { - console.log("Received:", event.data); - setMessages((prev: string[]) => [...prev, String(event.data)]); - }; - - socket.onclose = () => { - console.log("Disconnected from WebSocket"); - setMessages((prev: string[]) => [...prev, "Disconnected from WebSocket"]); - setIsConnected(false); - }; - - socket.onerror = (error) => { - console.error("WebSocket error:", error); - setMessages((prev: string[]) => [...prev, "WebSocket error occurred"]); - }; - - setWs(socket); - - return () => { - if (socket.readyState === WebSocket.OPEN) { - socket.close(); - } - }; - }, []); - - const sendMessage = () => { - if (ws && ws.readyState === WebSocket.OPEN && inputMessage) { - ws.send(inputMessage); - setInputMessage(""); - } - }; - - return ( -
-

WebSocket Test

- -
-
-
- {isConnected ? "Connected" : "Disconnected"} -
- { - setInputMessage(e.target.value); - }} - className="border p-2 mr-2" - placeholder="Type a message..." - disabled={!isConnected} - /> - -
- -
-

Messages:

-
- {messages.map((msg, index) => ( -
- {msg} -
- ))} -
-
-
- ); -} From bc614050d5f6cbe9cff98b0c795ea411bb6bc1c1 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 3 Jun 2025 18:27:26 -0700 Subject: [PATCH 15/24] added auth to getResponseCount/route.ts --- app/api/getResponseCounts/route.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/api/getResponseCounts/route.ts b/app/api/getResponseCounts/route.ts index db290f6..c7bd8e0 100644 --- a/app/api/getResponseCounts/route.ts +++ b/app/api/getResponseCounts/route.ts @@ -1,8 +1,16 @@ 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))) { @@ -11,6 +19,7 @@ export async function GET(request: NextRequest) { { status: 400 }, ); } + const groups = await prisma.response.groupBy({ by: ["optionId"], where: { questionId: Number(questionId) }, From 4a33a1a8b699cea100896de4978ce730330fe294 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 3 Jun 2025 18:45:10 -0700 Subject: [PATCH 16/24] moved livepoll.tsx imports to websockets.ts --- components/LivePoll.tsx | 63 ++--------- lib/websocket.ts | 232 +++++++++++++++++++++------------------- 2 files changed, 133 insertions(+), 162 deletions(-) diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index 9410f6e..41db786 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -8,6 +8,15 @@ import BackButton from "@/components/ui/backButton"; import QuestionCard from "@/components/ui/questionCard"; import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; +import type { + WebSocketMessage, + WebSocketMessageType, + StudentResponseMessage, + QuestionChangedMessage, + ResponseSavedMessage, + PollPausedMessage, + WebSocketMessageBase +} from "@/lib/websocket"; type QuestionWithOptions = PrismaQuestion & { options: PrismaOption[]; @@ -18,60 +27,6 @@ type fetchCourseSessionQuestionResponse = { totalQuestions: number; }; -// Define proper types for WebSocket messages -type WebSocketMessageType = - | "connected" - | "response_saved" - | "question_changed" - | "response_update" - | "error" - | "echo" - | "binary" - | "student_response" - | "poll_paused"; - -interface WebSocketMessageBase { - type: WebSocketMessageType; - message?: string; -} - -interface QuestionChangedMessage extends WebSocketMessageBase { - type: "question_changed"; - questionId: number; -} - -interface ResponseSavedMessage extends WebSocketMessageBase { - type: "response_saved"; - message?: string; -} - -interface StudentResponseMessage extends WebSocketMessageBase { - type: "student_response"; - questionId: number; - optionIds: number[]; -} - -// Add new type for response updates -// interface ResponseUpdateMessage extends WebSocketMessageBase { -// type: "response_update"; -// questionId: number; -// responseCount: number; -// optionCounts: Record; -// } - -interface PollPausedMessage extends WebSocketMessageBase { - type: "poll_paused"; - paused: boolean; -} - -// Union type for all message types -type WebSocketMessage = - | QuestionChangedMessage - | ResponseSavedMessage - | StudentResponseMessage - | PollPausedMessage - | WebSocketMessageBase; - export default function LivePoll({ courseSessionId, }: { diff --git a/lib/websocket.ts b/lib/websocket.ts index 82a6136..73c0497 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -8,72 +8,81 @@ type ConnectionParams = { userId?: string; }; -// message -type StudentResponseMessage = { - type: "student_response"; - questionId: number; - optionIds: number[]; -}; +// 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; +} -type ActiveQuestionUpdateMessage = { - type: "active_question_update"; +export interface QuestionChangedMessage extends WebSocketMessageBase { + type: "question_changed"; questionId: number; - courseSessionId?: number; -}; +} -type ResponseSavedMessage = { +export interface ResponseSavedMessage extends WebSocketMessageBase { type: "response_saved"; - message: string; + message?: string; data?: { questionId?: number; optionIds?: number[]; originalMessage?: string; }; -}; +} -type ResponseUpdateMessage = { +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; -}; +} -type QuestionChangedMessage = { - type: "question_changed"; - questionId: number; -}; +export interface PollPausedMessage extends WebSocketMessageBase { + type: "poll_paused"; + paused: boolean; +} -type ConnectedMessage = { +export interface ConnectedMessage extends WebSocketMessageBase { type: "connected"; - message: string; -}; +} -type ErrorMessage = { +export interface ErrorMessage extends WebSocketMessageBase { type: "error"; - message: string; -}; - -type TextMessage = { - type: "text"; - message: string; -}; +} -type PausePollMessage = { - type: "pause_poll"; - paused: boolean; -}; +export interface ActiveQuestionUpdateMessage extends WebSocketMessageBase { + type: "active_question_update"; + questionId: number; + courseSessionId?: number; +} -type WebSocketMessage = +// Union type for all message types +export type WebSocketMessage = + | QuestionChangedMessage + | ResponseSavedMessage | StudentResponseMessage + | PollPausedMessage | ActiveQuestionUpdateMessage - | ResponseSavedMessage - | ResponseUpdateMessage - | QuestionChangedMessage | ConnectedMessage | ErrorMessage - | TextMessage - | PausePollMessage - | { type: "poll_paused"; paused: boolean }; + | WebSocketMessageBase; type UnknownData = Record; @@ -186,74 +195,81 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { } else { throw new Error("Unsupported message format"); } - const data = JSON.parse(rawString) as UnknownData; - - if (data.type === "student_response") { - const { questionId, optionIds } = data as StudentResponseMessage; - - if (typeof questionId !== "number" || !Array.isArray(optionIds)) { - throw new Error("Invalid student_response format"); - } - - // 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, + const rawData = JSON.parse(rawString); + + if (rawData && typeof rawData === "object" && "type" in rawData) { + if (rawData.type === "student_response" && + "questionId" in rawData && + "optionIds" in rawData && + typeof rawData.questionId === "number" && + Array.isArray(rawData.optionIds)) { + const data = rawData as StudentResponseMessage; + const { questionId, optionIds } = data; + + // 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, - 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 (data.type === "active_question_update") { - const { questionId } = data as ActiveQuestionUpdateMessage; - console.log("Broadcasting question change:", questionId); - // Ensure all clients get the question change notification - const message: QuestionChangedMessage = { - type: "question_changed", - questionId, - }; - broadcastToSession(sessionId, message); - } else if (data.type === "pause_poll") { - const { paused } = data as PausePollMessage; - broadcastToSession(sessionId, { type: "poll_paused", paused }); + responseCount: total, + optionCounts, + } as ResponseUpdateMessage); + } else if (rawData.type === "active_question_update" && + "questionId" in rawData && + typeof rawData.questionId === "number") { + const data = rawData as ActiveQuestionUpdateMessage; + console.log("Broadcasting question change:", data.questionId); + // Ensure all clients get the question change notification + const message: QuestionChangedMessage = { + type: "question_changed", + questionId: data.questionId, + }; + broadcastToSession(sessionId, message); + } else if (rawData.type === "poll_paused" && + "paused" in rawData && + typeof rawData.paused === "boolean") { + const data = rawData as PollPausedMessage; + broadcastToSession(sessionId, { type: "poll_paused", paused: data.paused }); + } } } catch (err) { console.error("WS message error:", err); From 3cd9bcfffb3b6d9f72a34d7ddaa36a14eb7c3c09 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 3 Jun 2025 18:49:04 -0700 Subject: [PATCH 17/24] moved start-session type definition to websocket.ts --- app/dashboard/course/[courseId]/start-session/page.tsx | 9 ++------- lib/websocket.ts | 7 +++++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 790df7f..c40193d 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -39,12 +39,7 @@ import { getQuestionsForSession, } from "@/services/session"; -interface WebSocketMessage { - type: string; - questionId?: number; - optionCounts?: Record; - responseCount?: number; -} +import { StartSessionWebSocketMessage } from "@/lib/websocket" interface ResponseCountsData { optionCounts?: Record; @@ -114,7 +109,7 @@ export default function StartSession() { ws.onmessage = (event) => { try { - const data = JSON.parse(event.data as string) as WebSocketMessage; + const data = JSON.parse(event.data as string) as StartSessionWebSocketMessage; console.log("Received WebSocket message:", data); if (data.type === "response_update") { diff --git a/lib/websocket.ts b/lib/websocket.ts index 73c0497..80f9aaf 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -86,6 +86,13 @@ export type WebSocketMessage = type UnknownData = Record; +export type StartSessionWebSocketMessage { + type: string; + questionId?: number; + optionCounts?: Record; + responseCount?: number; +} + type AuthenticatedConnection = { userId: string; sessionId: string; From 9caf1a3b0cd75e5281454d368a84c7934fbc49fb Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 3 Jun 2025 19:25:22 -0700 Subject: [PATCH 18/24] created custom hook for livepoll --- .../course/[courseId]/start-session/page.tsx | 2 +- components/LivePoll.tsx | 39 +++++++++++- hooks/use-poll-socket.ts | 63 +++++++++++++++++++ lib/websocket.ts | 2 +- 4 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 hooks/use-poll-socket.ts diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index c40193d..dd98e12 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -339,7 +339,7 @@ export default function StartSession() { setIsPaused(pauseState); try { await pauseOrResumeCourseSession(courseSession.id, pauseState); - wsRef.current?.send(JSON.stringify({ type: "pause_poll", paused: pauseState })); + wsRef.current?.send(JSON.stringify({ type: "poll_paused", paused: pauseState })); } catch (error) { toast({ variant: "destructive", diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index 41db786..e1d3287 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -17,6 +17,7 @@ import type { PollPausedMessage, WebSocketMessageBase } from "@/lib/websocket"; +import { usePollSocket } from "@/hooks/use-poll-socket"; type QuestionWithOptions = PrismaQuestion & { options: PrismaOption[]; @@ -125,7 +126,43 @@ export default function LivePoll({ } }, [courseSessionId, toast, router]); // Added dependencies - // Setup WebSocket connection + // Add this new handler but keep existing code + const handleWebSocketMessage = useCallback((data: WebSocketMessage) => { + if (data?.type) { + if (data.type === "question_changed" && "questionId" in data) { + activeQuestionIdRef.current = null; + void fetchActiveQuestion(); + } else if (data.type === "response_saved") { + toast({ description: data.message ?? "Response saved" }); + setSubmitting(false); + } else if (data.type === "error") { + toast({ + variant: "destructive", + description: data.message ?? "Error occurred", + }); + setSubmitting(false); + } else if (data.type === "connected") { + console.log("WebSocket connection confirmed:", data.message); + } else if (data.type === "poll_paused" && "paused" in data) { + setIsPaused(data.paused); + } + } + }, [fetchActiveQuestion, toast]); + + // Add this alongside existing WebSocket setup + const newWsRef = usePollSocket({ + courseSessionId, + userId: session?.user?.id ?? "", + onMessage: handleWebSocketMessage, + onConnect: () => { + console.log("New WebSocket connected"); + }, + onDisconnect: () => { + console.log("New WebSocket disconnected"); + }, + }); + + // Keep existing WebSocket setup useEffect(() => { if (!courseSessionId || !session?.user?.id) return; diff --git a/hooks/use-poll-socket.ts b/hooks/use-poll-socket.ts new file mode 100644 index 0000000..3a78e54 --- /dev/null +++ b/hooks/use-poll-socket.ts @@ -0,0 +1,63 @@ +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, + onDisconnect, +}: 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; +} \ No newline at end of file diff --git a/lib/websocket.ts b/lib/websocket.ts index 80f9aaf..25a0814 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -86,7 +86,7 @@ export type WebSocketMessage = type UnknownData = Record; -export type StartSessionWebSocketMessage { +export type StartSessionWebSocketMessage = { type: string; questionId?: number; optionCounts?: Record; From decd76acd3d6c5e2f20f51df52d91ef07d51c710 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 3 Jun 2025 20:15:17 -0700 Subject: [PATCH 19/24] added custom hook to start session --- .../course/[courseId]/start-session/page.tsx | 173 ++++++++---------- 1 file changed, 74 insertions(+), 99 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index dd98e12..9058b1d 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -40,6 +40,7 @@ import { } from "@/services/session"; import { StartSessionWebSocketMessage } from "@/lib/websocket" +import { usePollSocket } from "@/hooks/use-poll-socket" interface ResponseCountsData { optionCounts?: Record; @@ -57,15 +58,13 @@ export default function StartSession() { const [activeQuestionId, setActiveQuestionId] = useState(null); const [isAddingQuestion, setIsAddingQuestion] = useState(false); const [isEndingSession, setIsEndingSession] = useState(false); - const [_totalResponses, setTotalResponses] = useState(0); - const wsRef = useRef(null); - const sessionData = useSession(); const [isPaused, setIsPaused] = useState(false); const [isChangingQuestion, setIsChangingQuestion] = useState(false); const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); - const [allResponseCounts, setAllResponseCounts] = useState< - Record> - >({}); + const [allResponseCounts, setAllResponseCounts] = useState>>({}); + const [_totalResponses, setTotalResponses] = useState(0); + const sessionData = useSession(); + const [isConnected, setIsConnected] = useState(false); useEffect(() => { async function fetchSessionData() { @@ -80,7 +79,6 @@ export default function StartSession() { } } 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`); } } @@ -93,64 +91,6 @@ export default function StartSession() { { enabled: !!activeQuestionId }, ); - // Setup WebSocket connection - useEffect(() => { - if (!courseSession || !sessionData.data?.user?.id) return; - - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const ws = new WebSocket( - `${protocol}//${window.location.host}/ws/poll?sessionId=${courseSession.id}&userId=${sessionData.data.user.id}`, - ); - wsRef.current = ws; - - ws.onopen = () => { - console.log("WebSocket connection established"); - }; - - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data as string) as 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) => - ({ - ...prev, - [String(data.questionId)]: data.optionCounts ?? {}, - }) as Record>, - ); - } - if (data.responseCount) { - setTotalResponses(data.responseCount); - } - } - } catch (error) { - console.error("Error processing WebSocket message:", error); - } - }; - - ws.onerror = (error) => { - console.error("WebSocket error:", error); - }; - - ws.onclose = () => { - console.log("WebSocket connection closed"); - }; - - return () => { - if (ws.readyState === WebSocket.OPEN) { - ws.close(); - } - }; - }, [courseSession, sessionData.data?.user?.id]); - // fetch session questions const { data: questions, @@ -210,17 +150,15 @@ export default function StartSession() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ activeQuestionId: nextQuestionID }), }); - if (response.ok) { - if (wsRef.current && 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)"); - } + 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)"); } } catch { toast({ variant: "destructive", description: "Error updating question" }); @@ -240,17 +178,15 @@ export default function StartSession() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ activeQuestionId: prevQuestionID }), }); - if (response.ok) { - if (wsRef.current && 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)"); - } + 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" }); @@ -274,17 +210,15 @@ export default function StartSession() { body: JSON.stringify({ activeQuestionId: selectedQuestionId }), }, ); - if (response.ok) { - if (wsRef.current && 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)"); - } + 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" }); @@ -339,7 +273,9 @@ export default function StartSession() { setIsPaused(pauseState); try { await pauseOrResumeCourseSession(courseSession.id, pauseState); - wsRef.current?.send(JSON.stringify({ type: "poll_paused", paused: pauseState })); + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "poll_paused", paused: pauseState })); + } } catch (error) { toast({ variant: "destructive", @@ -387,6 +323,45 @@ export default function StartSession() { }); }, [activeQuestionId]); + 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, + }); + if (!courseSession || questionsLoading) { return ; } From 420be4cb99df99f872eef91392bb601b00942426 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 3 Jun 2025 20:20:09 -0700 Subject: [PATCH 20/24] linted websocket.ts --- lib/websocket.ts | 169 ++++++++++++++++++++++++++--------------------- 1 file changed, 95 insertions(+), 74 deletions(-) diff --git a/lib/websocket.ts b/lib/websocket.ts index 25a0814..4cf6221 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -84,8 +84,6 @@ export type WebSocketMessage = | ErrorMessage | WebSocketMessageBase; -type UnknownData = Record; - export type StartSessionWebSocketMessage = { type: string; questionId?: number; @@ -138,6 +136,42 @@ function broadcastToSession(sessionId: string, message: WebSocketMessage): void } } +// 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 }); @@ -204,79 +238,66 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { } const rawData = JSON.parse(rawString); - if (rawData && typeof rawData === "object" && "type" in rawData) { - if (rawData.type === "student_response" && - "questionId" in rawData && - "optionIds" in rawData && - typeof rawData.questionId === "number" && - Array.isArray(rawData.optionIds)) { - const data = rawData as StudentResponseMessage; - const { questionId, optionIds } = data; - - // 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", + if (isStudentResponseMessage(rawData)) { + const { questionId, optionIds } = rawData; + + // 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, - responseCount: total, - optionCounts, - } as ResponseUpdateMessage); - } else if (rawData.type === "active_question_update" && - "questionId" in rawData && - typeof rawData.questionId === "number") { - const data = rawData as ActiveQuestionUpdateMessage; - console.log("Broadcasting question change:", data.questionId); - // Ensure all clients get the question change notification - const message: QuestionChangedMessage = { - type: "question_changed", - questionId: data.questionId, - }; - broadcastToSession(sessionId, message); - } else if (rawData.type === "poll_paused" && - "paused" in rawData && - typeof rawData.paused === "boolean") { - const data = rawData as PollPausedMessage; - broadcastToSession(sessionId, { type: "poll_paused", paused: data.paused }); - } + 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(rawData)) { + console.log("Broadcasting question change:", rawData.questionId); + // Ensure all clients get the question change notification + const message: QuestionChangedMessage = { + type: "question_changed", + questionId: rawData.questionId, + }; + broadcastToSession(sessionId, message); + } else if (isPollPausedMessage(rawData)) { + broadcastToSession(sessionId, { type: "poll_paused", paused: rawData.paused }); } } catch (err) { console.error("WS message error:", err); From b8e87efa3ea7e0185383938a5d2976d0880ad22e Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 3 Jun 2025 20:28:00 -0700 Subject: [PATCH 21/24] fixed linting in livepoll --- .../course/[courseId]/start-session/page.tsx | 96 +++++++++---------- components/LivePoll.tsx | 17 ++-- 2 files changed, 59 insertions(+), 54 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 9058b1d..f29c4f6 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -4,13 +4,14 @@ 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, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useQuery } from "react-query"; import { Bar, BarChart, LabelList, ResponsiveContainer, XAxis, YAxis } from "recharts"; 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, @@ -27,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 { @@ -39,9 +42,6 @@ import { getQuestionsForSession, } from "@/services/session"; -import { StartSessionWebSocketMessage } from "@/lib/websocket" -import { usePollSocket } from "@/hooks/use-poll-socket" - interface ResponseCountsData { optionCounts?: Record; responseCount?: number; @@ -64,7 +64,46 @@ export default function StartSession() { const [allResponseCounts, setAllResponseCounts] = useState>>({}); const [_totalResponses, setTotalResponses] = useState(0); const sessionData = useSession(); - const [isConnected, setIsConnected] = useState(false); + 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() { @@ -165,7 +204,7 @@ export default function StartSession() { } setIsChangingQuestion(false); } - }, [activeIndex, questions, totalQuestions, courseSession, toast]); + }, [activeIndex, questions, totalQuestions, courseSession, toast, wsRef]); const handlePreviousQuestion = useCallback(async () => { if (questions && activeIndex > 0 && courseSession) { @@ -193,7 +232,7 @@ export default function StartSession() { } setIsChangingQuestion(false); } - }, [activeIndex, questions, courseSession, toast]); + }, [activeIndex, questions, courseSession, toast, wsRef]); const handleQuestionSelect = useCallback( async (questionId: string) => { @@ -226,7 +265,7 @@ export default function StartSession() { setIsChangingQuestion(false); } }, - [courseSession, toast], + [courseSession, toast, wsRef], ); const handleAddWildcard = useCallback( @@ -284,7 +323,7 @@ export default function StartSession() { console.error(error); } }, - [courseSession, toast], + [courseSession, toast, wsRef], ); const chartConfig: ChartConfig = { @@ -323,45 +362,6 @@ export default function StartSession() { }); }, [activeQuestionId]); - 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, - }); - if (!courseSession || questionsLoading) { return ; } diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index e1d3287..799150c 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -7,17 +7,22 @@ import AnswerOptions from "@/components/ui/answerOptions"; import BackButton from "@/components/ui/backButton"; import QuestionCard from "@/components/ui/questionCard"; import useAccess from "@/hooks/use-access"; +import { usePollSocket } from "@/hooks/use-poll-socket"; import { useToast } from "@/hooks/use-toast"; +// import type { +// WebSocketMessage, +// WebSocketMessageType, +// StudentResponseMessage, +// QuestionChangedMessage, +// ResponseSavedMessage, +// PollPausedMessage, +// WebSocketMessageBase +// } from "@/lib/websocket"; + import type { WebSocketMessage, - WebSocketMessageType, StudentResponseMessage, - QuestionChangedMessage, - ResponseSavedMessage, - PollPausedMessage, - WebSocketMessageBase } from "@/lib/websocket"; -import { usePollSocket } from "@/hooks/use-poll-socket"; type QuestionWithOptions = PrismaQuestion & { options: PrismaOption[]; From d662cf534837d50786fa2e78dc2602b2b81c4a89 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 3 Jun 2025 20:31:32 -0700 Subject: [PATCH 22/24] lint start-session --- components/LivePoll.tsx | 4 ++-- lib/websocket.ts | 27 +++++++++++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index 799150c..437f428 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -20,8 +20,8 @@ import { useToast } from "@/hooks/use-toast"; // } from "@/lib/websocket"; import type { - WebSocketMessage, StudentResponseMessage, + WebSocketMessage, } from "@/lib/websocket"; type QuestionWithOptions = PrismaQuestion & { @@ -155,7 +155,7 @@ export default function LivePoll({ }, [fetchActiveQuestion, toast]); // Add this alongside existing WebSocket setup - const newWsRef = usePollSocket({ + const _newWsRef = usePollSocket({ courseSessionId, userId: session?.user?.id ?? "", onMessage: handleWebSocketMessage, diff --git a/lib/websocket.ts b/lib/websocket.ts index 4cf6221..7aebff2 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -236,10 +236,13 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { } else { throw new Error("Unsupported message format"); } - const rawData = JSON.parse(rawString); + + // Parse and validate the data with unknown type first + const parsedData = JSON.parse(rawString) as unknown; - if (isStudentResponseMessage(rawData)) { - const { questionId, optionIds } = rawData; + // 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({ @@ -288,16 +291,24 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { responseCount: total, optionCounts, } as ResponseUpdateMessage); - } else if (isActiveQuestionUpdateMessage(rawData)) { - console.log("Broadcasting question change:", rawData.questionId); + } 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: rawData.questionId, + questionId: parsedData.questionId, }; broadcastToSession(sessionId, message); - } else if (isPollPausedMessage(rawData)) { - broadcastToSession(sessionId, { type: "poll_paused", paused: rawData.paused }); + } 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); From da01d181ea365b26c25e07c1548ad2175ff949ce Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Tue, 3 Jun 2025 20:33:22 -0700 Subject: [PATCH 23/24] changed formatting for lint --- .../course/[courseId]/start-session/page.tsx | 8 ++-- components/LivePoll.tsx | 48 +++++++++---------- hooks/use-poll-socket.ts | 2 +- lib/websocket.ts | 9 ++-- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index f29c4f6..84f6773 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -28,12 +28,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { usePollSocket } from "@/hooks/use-poll-socket" +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 type { StartSessionWebSocketMessage } from "@/lib/websocket"; import { CourseSessionData, QuestionData } from "@/models/CourseSession"; import { endCourseSession, pauseOrResumeCourseSession } from "@/services/courseSession"; import { @@ -61,7 +61,9 @@ export default function StartSession() { const [isPaused, setIsPaused] = useState(false); const [isChangingQuestion, setIsChangingQuestion] = useState(false); const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); - const [allResponseCounts, setAllResponseCounts] = useState>>({}); + const [allResponseCounts, setAllResponseCounts] = useState< + Record> + >({}); const [_totalResponses, setTotalResponses] = useState(0); const sessionData = useSession(); const [_isConnected, setIsConnected] = useState(false); diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index 437f428..92a056f 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -19,10 +19,7 @@ import { useToast } from "@/hooks/use-toast"; // WebSocketMessageBase // } from "@/lib/websocket"; -import type { - StudentResponseMessage, - WebSocketMessage, -} from "@/lib/websocket"; +import type { StudentResponseMessage, WebSocketMessage } from "@/lib/websocket"; type QuestionWithOptions = PrismaQuestion & { options: PrismaOption[]; @@ -132,27 +129,30 @@ export default function LivePoll({ }, [courseSessionId, toast, router]); // Added dependencies // Add this new handler but keep existing code - const handleWebSocketMessage = useCallback((data: WebSocketMessage) => { - if (data?.type) { - if (data.type === "question_changed" && "questionId" in data) { - activeQuestionIdRef.current = null; - void fetchActiveQuestion(); - } else if (data.type === "response_saved") { - toast({ description: data.message ?? "Response saved" }); - setSubmitting(false); - } else if (data.type === "error") { - toast({ - variant: "destructive", - description: data.message ?? "Error occurred", - }); - setSubmitting(false); - } else if (data.type === "connected") { - console.log("WebSocket connection confirmed:", data.message); - } else if (data.type === "poll_paused" && "paused" in data) { - setIsPaused(data.paused); + const handleWebSocketMessage = useCallback( + (data: WebSocketMessage) => { + if (data?.type) { + if (data.type === "question_changed" && "questionId" in data) { + activeQuestionIdRef.current = null; + void fetchActiveQuestion(); + } else if (data.type === "response_saved") { + toast({ description: data.message ?? "Response saved" }); + setSubmitting(false); + } else if (data.type === "error") { + toast({ + variant: "destructive", + description: data.message ?? "Error occurred", + }); + setSubmitting(false); + } else if (data.type === "connected") { + console.log("WebSocket connection confirmed:", data.message); + } else if (data.type === "poll_paused" && "paused" in data) { + setIsPaused(data.paused); + } } - } - }, [fetchActiveQuestion, toast]); + }, + [fetchActiveQuestion, toast], + ); // Add this alongside existing WebSocket setup const _newWsRef = usePollSocket({ diff --git a/hooks/use-poll-socket.ts b/hooks/use-poll-socket.ts index 3a78e54..4e9add9 100644 --- a/hooks/use-poll-socket.ts +++ b/hooks/use-poll-socket.ts @@ -60,4 +60,4 @@ export function usePollSocket({ }, [courseSessionId, userId, onConnect, onDisconnect, onMessage]); return wsRef; -} \ No newline at end of file +} diff --git a/lib/websocket.ts b/lib/websocket.ts index 7aebff2..a321e93 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -89,7 +89,7 @@ export type StartSessionWebSocketMessage = { questionId?: number; optionCounts?: Record; responseCount?: number; -} +}; type AuthenticatedConnection = { userId: string; @@ -236,7 +236,7 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { } else { throw new Error("Unsupported message format"); } - + // Parse and validate the data with unknown type first const parsedData = JSON.parse(rawString) as unknown; @@ -300,7 +300,10 @@ export function initWebSocketServer(server: HttpServer): WebSocketServer { }; broadcastToSession(sessionId, message); } else if (isPollPausedMessage(parsedData)) { - broadcastToSession(sessionId, { type: "poll_paused", paused: parsedData.paused }); + broadcastToSession(sessionId, { + type: "poll_paused", + paused: parsedData.paused, + }); } else { console.warn("Received unhandled message type:", parsedData); ws.send( From dfe13d17339e9232230464ecceaf7a6485c68e58 Mon Sep 17 00:00:00 2001 From: Jerry Date: Thu, 5 Jun 2025 11:04:05 -0700 Subject: [PATCH 24/24] minor refactors --- .../course/[courseId]/start-session/page.tsx | 40 +++++++++---------- components/LivePoll.tsx | 6 --- hooks/use-poll-socket.ts | 12 ++++-- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 84f6773..bf229dd 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -396,13 +396,13 @@ export default function StartSession() {
- {chartData.length > 0 ? ( - - - {showResults ? ( + + + {showResults ? ( + chartData.length > 0 ? ( ) : ( -
- -

- Poll results are hidden -

+
+ No responses yet
- )} - - - ) : ( -
- No responses yet -
- )} + ) + ) : ( +
+ +

+ Poll results are hidden +

+
+ )} + +
diff --git a/components/LivePoll.tsx b/components/LivePoll.tsx index 92a056f..7bad726 100644 --- a/components/LivePoll.tsx +++ b/components/LivePoll.tsx @@ -159,12 +159,6 @@ export default function LivePoll({ courseSessionId, userId: session?.user?.id ?? "", onMessage: handleWebSocketMessage, - onConnect: () => { - console.log("New WebSocket connected"); - }, - onDisconnect: () => { - console.log("New WebSocket disconnected"); - }, }); // Keep existing WebSocket setup diff --git a/hooks/use-poll-socket.ts b/hooks/use-poll-socket.ts index 4e9add9..e0b15bf 100644 --- a/hooks/use-poll-socket.ts +++ b/hooks/use-poll-socket.ts @@ -5,16 +5,20 @@ interface UsePollSocketProps { courseSessionId: number; userId: string; onMessage: (data: WebSocketMessage) => void; - onConnect: () => void; - onDisconnect: () => void; + onConnect?: () => void; + onDisconnect?: () => void; } export function usePollSocket({ courseSessionId, userId, onMessage, - onConnect, - onDisconnect, + onConnect = () => { + console.log("New WebSocket connected"); + }, + onDisconnect = () => { + console.log("New WebSocket disconnected"); + }, }: UsePollSocketProps) { const wsRef = useRef(null);