diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 460ae61..6e3dc27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,3 +13,8 @@ jobs: steps: - uses: actions/checkout@v2 - run: cd client && yarn --frozen-lockfile && yarn lint + lint_server: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: cd server && yarn --frozen-lockfile && yarn lint diff --git a/.husky/pre-commit b/.husky/pre-commit index ea2e0e0..2a952ae 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,3 +3,6 @@ cd client npx lint-staged + +cd ../server +yarn lint \ No newline at end of file diff --git a/README.md b/README.md index 0fd17ca..270384e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ Note: Only the core files & directories are listed below | |── routes | | |── pollRoute.ts | | └── userRoutes.ts +| |── types +| | └── pollController.types.ts | |── redis.ts # Redis related configurations to setup the professor list | |── server.ts | └── socket.ts @@ -205,7 +207,7 @@ utorid3 2. Setting up the server `.env` file (Placed in the root of your `server` folder) ``` -PPORT=3001 +PORT=3001 MONGODB_URL="mongodb://localhost:27018/quiz" FRONTEND_URL="http://localhost:3000" REDIS_URL="redis://default:password@localhost:6379" diff --git a/client/src/components/ErrorAlert.tsx b/client/src/components/ErrorAlert.tsx new file mode 100644 index 0000000..b116912 --- /dev/null +++ b/client/src/components/ErrorAlert.tsx @@ -0,0 +1,93 @@ +import React from "react"; + +interface ErrorAlertProps { + title: string; + text?: string; + onClose: () => void; + enabled: boolean; + buttonText?: string; +} + +export const ErrorAlert = ({ + title, + text, + onClose, + enabled, + buttonText, +}: ErrorAlertProps) => { + return ( +
+ {/* Background backdrop, show/hide based on modal state. */} + + {/* Entering: "ease-out duration-300" */} + {/* From: "opacity-0" */} + {/* To: "opacity-100" */} + {/* Leaving: "ease-in duration-200" */} + {/* From: "opacity-100" */} + {/* To: "opacity-0" */} +
+ +
+
+ {/* Modal panel, show/hide based on modal state. */} + + {/* Entering: "ease-out duration-300" */} + {/* From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" */} + {/* To: "opacity-100 translate-y-0 sm:scale-100" */} + {/* Leaving: "ease-in duration-200" */} + {/* From: "opacity-100 translate-y-0 sm:scale-100" */} + {/* To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" */} +
+
+
+
+ {/* Heroicon name: outline/exclamation-triangle */} + +
+
+ +
+

{text}

+
+
+
+
+
+ +
+
+
+
+
+ ); +}; diff --git a/client/src/components/PollOptionButton.tsx b/client/src/components/PollOptionButton.tsx index 82b0b78..c14debe 100644 --- a/client/src/components/PollOptionButton.tsx +++ b/client/src/components/PollOptionButton.tsx @@ -18,11 +18,9 @@ export const PollOptionButton = ({ disabled={disabled} onClick={onClick} className={`m-2 py-2 px-40 inline-block ${ - selected ? "bg-hover" : "bg-primary" - } ${ - disabled - ? "cursor-not-allowed opacity-50" - : "hover:bg-hover cursor-pointer" + selected ? "bg-selected" : "bg-primary" + } ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"} ${ + !selected && !disabled ? "hover:bg-hover" : "" }`} >
{name}
diff --git a/client/src/pages/VotePage.tsx b/client/src/pages/VotePage.tsx index 5e5f4ae..be66990 100644 --- a/client/src/pages/VotePage.tsx +++ b/client/src/pages/VotePage.tsx @@ -9,33 +9,41 @@ import { questionStarted, } from "../constants/constants"; import { socket } from "../socket"; +import { ErrorAlert } from "../components/ErrorAlert"; export const VotePage = () => { const history = useHistory(); const cookies = new Cookies(); const [pollCode] = useState(cookies.get(pollCodeCookie)); const [started, setStarted] = useState(false); + const [hasAllowedNotif, setAllowedNotif] = useState(false); + const [lastNotif, setLastNotif] = useState(null); + + const [loading, setLoading] = useState(false); + const [timeoutCode, setTimeoutCode] = useState(0); + const [timeoutError, setTimeoutError] = useState(false); const [errorCode, setErrorCode] = useState(0); const [selectedOption, setSelectionOption] = useState(""); - const [isFocus, setFocus] = useState(true); - - const onBlur = () => { - setFocus(false); - }; const onFocus = () => { - document.title = mcsPollVoting; - setFocus(true); + if (document.visibilityState === "visible") { + document.title = mcsPollVoting; + } }; useEffect(() => { socket.emit("join", pollCode); - window.addEventListener("blur", onBlur); - window.addEventListener("focus", onFocus); + try { + Notification.requestPermission().then((permission) => + setAllowedNotif(permission === "granted") + ); + } catch (e) { + /* notifications probably not supported */ + } + document.addEventListener("visibilitychange", onFocus); return () => { - window.removeEventListener("blur", onBlur); - window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onFocus); }; }, []); @@ -57,8 +65,20 @@ export const VotePage = () => { const audio = new Audio("/newQuestion.wav"); audio.play(); setSelectionOption(""); - if (!isFocus) { + if (document.visibilityState === "hidden") { document.title = questionStarted; + if (hasAllowedNotif) { + /* Close old notification to prevent spam */ + if (lastNotif != null) { + lastNotif.close(); + } + /* send new notification */ + const notification = new Notification("New Question Started!", { + icon: "/favicon.ico", + tag: "new-question", + }); + setLastNotif(notification); + } } } }; @@ -73,6 +93,12 @@ export const VotePage = () => { const voteAckHandler = (data: any) => { setSelectionOption(String.fromCharCode(data + 64)); + if (lastNotif != null) { + lastNotif.close(); + setLastNotif(null); + } + clearTimeout(timeoutCode); + setLoading(false); }; socket.on("ack", voteAckHandler); @@ -82,12 +108,21 @@ export const VotePage = () => { socket.off("end", pollClosedHandler); socket.off("ack", voteAckHandler); }; - }, [errorCode, started, selectedOption]); + }, [errorCode, started, selectedOption, hasAllowedNotif, lastNotif]); const pollButtonHandler = (selectedOption: string) => { + setLoading(true); + setTimeoutCode( + setTimeout(() => { + triggerTimeOutError(); + setLoading(false); + }, 10000) as unknown as number + ); socket.emit("vote", (selectedOption.charCodeAt(0) % 65) + 1); }; - + const triggerTimeOutError = () => { + setTimeoutError(true); + }; const optionButtons = () => { const pollOptionButtons = []; for (let i = 65; i < 70; i++) { @@ -110,8 +145,50 @@ export const VotePage = () => { ) : (
-
-
{optionButtons()}
+
0 ? selectedOption : "None" + }`} + /> +
+
{optionButtons()}
+ {loading ? ( +
+
+ + Loading... +
+
+ ) : null} +
+ { + setTimeoutError(false); + }} + />
); }; diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 52d469f..5abea22 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -17,6 +17,7 @@ module.exports = { primary: "#00204E", secondary: "#FFFFFF", hover: "#00B5B5", + selected: "#0171B7", background: "#F5F5F5", }), }, diff --git a/client/yarn.lock b/client/yarn.lock index 10f0cc4..a8c1f23 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1937,9 +1937,9 @@ integrity sha512-8MLkBIYQMuhRBQzGN9875bYsOhPnf/0rgXGo66S2FemHkhbn9qtsz9ywV1iCG+vbjigE4WUNVvw37Dx+L0qsPg== "@types/node@^12.0.0": - version "12.20.33" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.33.tgz#24927446e8b7669d10abacedd16077359678f436" - integrity sha512-5XmYX2GECSa+CxMYaFsr2mrql71Q4EvHjKS+ox/SiwSdaASMoBIWE6UmZqFO+VX1jIcsYLStI4FFoB6V7FeIYw== + version "12.20.55" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" + integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== "@types/normalize-package-data@^2.4.0": version "2.4.1" @@ -10825,7 +10825,7 @@ source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, sourc source-map@^0.5.0, source-map@^0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== source-map@^0.7.3, source-map@~0.7.2: version "0.7.3" diff --git a/server/.eslintignore b/server/.eslintignore new file mode 100644 index 0000000..3881e74 --- /dev/null +++ b/server/.eslintignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.idea diff --git a/server/.eslintrc.json b/server/.eslintrc.json new file mode 100644 index 0000000..09213d7 --- /dev/null +++ b/server/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "env": { + "node": true + }, + "extends": [ + "standard", + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 13, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "no-use-before-define": "off", + "no-unused-vars": "warn", + "no-console": "off", + "camelcase": "off", + "no-throw-literal": "warn" + }, + "settings": { + } +} diff --git a/server/package.json b/server/package.json index 33fdf3c..273b04f 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,9 @@ "prestart": "tsc", "start": "node .", "dev": "nodemon --watch src -e ts,ejs --exec yarn run start", - "build": "tsc --project ./" + "build": "tsc --project ./", + "lint": "eslint --ext .ts .", + "lint:fix": "eslint --ext .ts --fix ." }, "repository": "git+https://github.com/hiimchrislim/QuizVotingSystem.git", "author": "Chris, Akshit, Shubh, Ilir", @@ -42,6 +44,7 @@ "eslint-plugin-import": "^2.25.2", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^5.1.1", + "husky": "^8.0.1", "nodemon": "^2.0.14", "prettier": "^2.4.1", "socket.io-client": "^4.3.2", diff --git a/server/src/controllers/pollController.ts b/server/src/controllers/pollController.ts index ad06951..440a2f5 100644 --- a/server/src/controllers/pollController.ts +++ b/server/src/controllers/pollController.ts @@ -1,76 +1,79 @@ -import { Poll } from "../db/schema"; -import { PollModel, StudentModel } from "../db/mogoose"; -import { io } from "../socket"; -import { client } from "../redis"; -import { customAlphabet } from "nanoid/async"; -import { pollResult } from "./socketController"; -const nanoid = customAlphabet("qwertyuiopasdfghjklzxcvbnm1234567890", 6); +import { Poll } from '../db/schema' +import { PollModel, StudentModel } from '../db/mogoose' +import { io } from '../socket' +import { client } from '../redis' +import { customAlphabet } from 'nanoid/async' +import { pollResult, getRoomById } from './socketController' +import { AggregatedStudent } from '../types/pollController.types' +import { UserType } from '../types/user.types' +const nanoid = customAlphabet('QWERTYUIOPASDFGHJKLZXCVBNM', 6) // set poll code expiry to 1 day -const expiry = 60 * 60 * 24; +const expiry = 60 * 60 * 24 -async function createPoll(poll: Poll) { - if (!poll.hasOwnProperty("courseCode")) - return { status: 400, data: { message: "courseCode is required" } }; - const promise = [nanoid(), PollModel.create(poll)] as const; - const result = await Promise.all(promise); - const pollId = result[1]["_id"].toString(); - const pollCode = result[0]; - await client.set(pollCode, pollId, { EX: expiry, NX: true }); +async function createPoll (poll: Poll) { + if (!poll.courseCode) { return { status: 400, data: { message: 'courseCode is required' } } } + const promise = [nanoid(), PollModel.create(poll)] as const + const result = await Promise.all(promise) + const pollId = result[1]._id.toString() + const pollCode = result[0] + await client.set(pollCode, pollId, { EX: expiry, NX: true }) - return { status: 201, data: { pollCode, pollId } }; + return { status: 201, data: { pollCode, pollId } } } -async function changePollStatus(pollId: string, hasStarted: boolean) { - if (typeof hasStarted !== "boolean") +async function changePollStatus (pollId: string, hasStarted: boolean) { + if (typeof hasStarted !== 'boolean') { return { status: 400, - data: { message: "hasStarted should be boolean" }, - }; - const currSequence = await client.get(pollId); - console.log("currSequence", currSequence); - let newSequence; + data: { message: 'hasStarted should be boolean' } + } + } + const currSequence = await client.get(pollId) + console.log('currSequence', currSequence) + let newSequence // on every new start increment the sequence counter if (hasStarted) { - if (currSequence == null) newSequence = 0; - else newSequence = parseInt(currSequence); - if (newSequence < 0) newSequence *= -1; - newSequence++; - console.log("newSequence", newSequence); + if (currSequence == null) newSequence = 0 + else newSequence = parseInt(currSequence) + if (newSequence < 0) newSequence *= -1 + newSequence++ + console.log('newSequence', newSequence) await client.set(pollId, newSequence.toString(), { - EX: expiry, - }); - const result = await pollResult(pollId, newSequence); - io.to(pollId).emit("result", result); - } - // for every stop make the current counter negative to indicate that it is not an active sequence - else { + EX: expiry + }) + const result = await pollResult(pollId, newSequence) + io.to(getRoomById(pollId, UserType.INSTRUCTOR)).emit('result', result) + } else { + // for every stop make the current counter negative to indicate that it is not an active sequence if (currSequence != null) { - newSequence = parseInt(currSequence) * -1; + newSequence = parseInt(currSequence) * -1 await client.set(pollId, newSequence.toString(), { - EX: expiry, - }); + EX: expiry + }) } } - io.to(pollId).emit("pollStarted", hasStarted); + io.to(pollId).emit('pollStarted', hasStarted) - return { status: 200, data: { message: "poll status successfully changed" } }; + return { status: 200, data: { message: 'poll status successfully changed' } } } -async function getStudents(courseCode: string, startTime: Date, endTime: Date) { +async function getStudents (courseCode: string, startTime: Date, endTime: Date) { + // TODO Already addressed in TODO bellow + // eslint-disable-next-line no-useless-catch try { - const pollDoc = await PollModel.find({ courseCode }); - let promises: Promise[] = []; - let responses: any[] = []; + const pollDoc = await PollModel.find({ courseCode }) + const promises: Promise[] = [] + const responses: AggregatedStudent[] = [] pollDoc.forEach((element) => { promises.push( - StudentModel.aggregate([ + StudentModel.aggregate([ { $match: { pollId: element._id.toString(), - timestamp: { $gte: startTime, $lte: endTime }, - }, + timestamp: { $gte: startTime, $lte: endTime } + } }, { $project: { @@ -81,57 +84,54 @@ async function getStudents(courseCode: string, startTime: Date, endTime: Date) { utorid: 1, timestamp: { $dateToString: { - date: "$timestamp", - timezone: "America/Toronto" - }, + date: '$timestamp', + timezone: 'America/Toronto' + } }, pollName: element.name, description: element.description, - answer: 1, - }, - }, + answer: 1 + } + } ]).then((data) => { data.forEach((val) => { - responses.push(val); - }); + responses.push(val) + }) }) - ); - }); - await Promise.all(promises); - console.log(responses); - return { responses }; + ) + }) + await Promise.all(promises) + console.log(responses) + return { responses } } catch (err) { /** * TODO: Add error handler */ - throw err; + throw err } } -async function getPollStatus(pollId: any) { - if (pollId === null || pollId === undefined || typeof pollId !== "string") - return { status: 400, data: { message: "Invalid poll Id" } }; - const result = await client.get(pollId); - const pollStarted = result === null ? false : parseInt(result) > 0; - return { status: 200, data: { pollStarted } }; +async function getPollStatus (pollId: string) { + if (pollId.trim().length === 0) { return { status: 400, data: { message: 'Invalid poll Id' } } } + const result = await client.get(pollId) + const pollStarted = result === null ? false : parseInt(result) > 0 + return { status: 200, data: { pollStarted } } } -async function getResult(pollId: any) { - if (pollId === null || pollId === undefined || typeof pollId !== "string") - return { status: 400, data: { message: "Invalid poll Id" } }; - const currSequence = await client.get(pollId); - const result = await pollResult(pollId, parseInt(currSequence)); - return { status: 200, data: { ...result } }; +async function getResult (pollId: string) { + if (pollId.trim().length === 0) { return { status: 400, data: { message: 'Invalid poll Id' } } } + const currSequence = await client.get(pollId) + const result = await pollResult(pollId, parseInt(currSequence)) + return { status: 200, data: { ...result } } } -async function endForever(pollCode: string) { - if (pollCode === null || pollCode === undefined) - return { status: 400, data: { message: "Invalid poll code" } }; - const pollId = await client.get(pollCode); - await Promise.all([client.del(pollCode), client.del(pollId)]); - io.to(pollId).emit("end", true); - io.of("/").in(pollId).disconnectSockets(); - return { status: 200, data: { message: "Poll closed" } }; +async function endForever (pollCode: string) { + if (pollCode === null || pollCode === undefined) { return { status: 400, data: { message: 'Invalid poll code' } } } + const pollId = await client.get(pollCode) + await Promise.all([client.del(pollCode), client.del(pollId)]) + io.to(pollId).emit('end', true) + io.of('/').in(pollId).disconnectSockets() + return { status: 200, data: { message: 'Poll closed' } } } export { @@ -140,5 +140,5 @@ export { getStudents, getPollStatus, getResult, - endForever, -}; + endForever +} diff --git a/server/src/controllers/socketController.ts b/server/src/controllers/socketController.ts index ad7b023..915b347 100644 --- a/server/src/controllers/socketController.ts +++ b/server/src/controllers/socketController.ts @@ -1,75 +1,78 @@ -import { StudentModel } from "../db/mogoose"; -import { io } from "../socket"; -import { Socket } from "socket.io"; -import { client } from "../redis"; +import { StudentModel } from '../db/mogoose' +import { io } from '../socket' +import { Socket } from 'socket.io' +import { client } from '../redis' +import { UserType } from '../types/user.types' -async function join(socket: Socket, pollCode: string) { +function getRoomById (pollId: string, userType?: UserType): string { + if (userType) { return userType + '-' + pollId } else { return pollId.toString() } +} + +async function join (socket: Socket, pollCode: string) { try { - console.log(`join: ${socket.id}`); - if (pollCode === null || pollCode === undefined) - throw { code: 1, message: "Invalid poll code" }; - const pollId = await client.get(pollCode); - console.log(pollId); - if (pollId === null) throw { code: 1, message: "Invalid poll code" }; + console.log(`join: ${socket.id}`) + if (pollCode === null || pollCode === undefined) { throw { code: 1, message: 'Invalid poll code' } } + const pollId = await client.get(pollCode) + console.log(pollId) + if (pollId === null) throw { code: 1, message: 'Invalid poll code' } // ensure that socket is connected to 1 room (other than the default room) socket.rooms.forEach((room) => { - if (room !== socket.id) socket.leave(room); - }); + if (room !== socket.id) socket.leave(room) + }) - const currSequence = await client.get(pollId); + const currSequence = await client.get(pollId) const hasStarted = - currSequence == null ? false : parseInt(currSequence) > 0; - console.log("Has Started", hasStarted); - socket.join(pollId); - socket.data["pollId"] = pollId; - io.to(socket.id).emit("pollStarted", hasStarted); + currSequence == null ? false : parseInt(currSequence) > 0 + console.log('Has Started', hasStarted) + socket.join(getRoomById(pollId)) + // allow targeting specific user types + socket.join(getRoomById(pollId, socket.data.userType)) + socket.data.pollId = pollId + io.to(socket.id).emit('pollStarted', hasStarted) } catch (err) { - console.log(err); - io.to(socket.id).emit("error", err); + console.log(err) + io.to(socket.id).emit('error', err) } } -async function pollResult(pollId: string, sequence: number) { +async function pollResult (pollId: string, sequence: number) { try { const result = await StudentModel.aggregate([ { $match: { pollId, sequence } }, { $facet: { - result: [{ $group: { _id: "$answer", count: { $sum: 1 } } }], - totalVotes: [{ $count: "totalVotes" }], - }, - }, + result: [{ $group: { _id: '$answer', count: { $sum: 1 } } }], + totalVotes: [{ $count: 'totalVotes' }] + } + } ]).then((data: any): any => { return { result: data[0].result, totalVotes: - data[0].totalVotes.length > 0 ? data[0].totalVotes[0].totalVotes : 0, - }; - }); - console.log(result); - return result; + data[0].totalVotes.length > 0 ? data[0].totalVotes[0].totalVotes : 0 + } + }) + console.log(result) + return result } catch (err) { - console.log(err); + console.log(err) } } -async function vote(socket: Socket, answer: number, utorid: string) { +async function vote (socket: Socket, answer: number, utorid: string) { try { - console.log(`vote: ${socket.id}`); - let pollId = socket.data.pollId; - if (pollId === null || pollId === undefined) - throw { code: 1, message: "haven't joined any room" }; - const currSequence = await client.get(pollId); - console.log(currSequence); + console.log(`vote: ${socket.id}`) + const pollId = socket.data.pollId + if (pollId === null || pollId === undefined) { throw { code: 1, message: "haven't joined any room" } } + const currSequence = await client.get(pollId) + console.log(currSequence) if (currSequence === null || parseInt(currSequence) < 0) { - throw { code: 2, message: "Poll not live yet" }; + throw { code: 2, message: 'Poll not live yet' } } - if (utorid === undefined || utorid === null) - throw { code: 1, message: "Invalid utorid" }; - if (answer === undefined || answer === null) - throw { code: 2, message: "Invalid answer" }; + if (utorid === undefined || utorid === null) { throw { code: 1, message: 'Invalid utorid' } } + if (answer === undefined || answer === null) { throw { code: 2, message: 'Invalid answer' } } await StudentModel.updateOne( { @@ -81,19 +84,19 @@ async function vote(socket: Socket, answer: number, utorid: string) { utorid, sequence: parseInt(currSequence), answer, - timestamp: new Date(), + timestamp: new Date() }, { upsert: true } - ); + ) pollResult(pollId, parseInt(currSequence)).then((data) => { - io.to(pollId).emit("result", data); - }); - io.to(socket.id).emit("ack", answer); - return; + io.to(getRoomById(pollId, UserType.INSTRUCTOR)).emit('result', data) + }) + io.to(socket.id).emit('ack', answer) + return } catch (err) { - console.log(err); - io.to(socket.id).emit("error", err); + console.log(err) + io.to(socket.id).emit('error', err) } } -export { vote, join, pollResult }; +export { vote, join, pollResult, getRoomById } diff --git a/server/src/controllers/userController.ts b/server/src/controllers/userController.ts index 82d4388..63cba24 100644 --- a/server/src/controllers/userController.ts +++ b/server/src/controllers/userController.ts @@ -1,11 +1,14 @@ -import { client } from "../redis"; +import { UserType } from '../types/user.types' +import { client } from '../redis' -async function getUser(utorid: any) { - if (utorid === null || utorid === undefined || typeof utorid !== "string") - return { status: 400, data: { message: "Invalid utorid" } }; - let userType = await client.get(utorid); - if (userType == null) userType = "student"; - return { status: 200, data: { userType } }; +async function getUser (utorid: string): Promise<{ status: number; data: { + message?: string, + userType?: UserType +}}> { + if (utorid.trim().length === 0) { return { status: 400, data: { message: 'Invalid utorid' } } } + let userType = await client.get(utorid) as UserType + if (userType == null) userType = UserType.STUDENT + return { status: 200, data: { userType } } } -export { getUser }; +export { getUser } diff --git a/server/src/db/mogoose.ts b/server/src/db/mogoose.ts index ead7cf7..3b759fa 100644 --- a/server/src/db/mogoose.ts +++ b/server/src/db/mogoose.ts @@ -1,16 +1,19 @@ -import { model, connect, connection } from "mongoose"; +import { model, connect, connection } from 'mongoose' import { PollDocument, pollSchema, StudentDocument, - studentSchema, -} from "./schema"; + studentSchema +} from './schema' -export const PollModel = model("Poll", pollSchema); -export const StudentModel = model("Student", studentSchema); +export const PollModel = model('Poll', pollSchema) +export const StudentModel = model('Student', studentSchema) -connect(process.env.MONGODB_URL).catch((err) => { - throw err; -}); +export async function connectMongo () { + await connect(process.env.MONGODB_URL) + db.on('open', () => { + console.log('Connected to mongo') + }) +} -export const db = connection; +export const db = connection diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 4f36ea4..2a9d99f 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -1,4 +1,4 @@ -import { Schema, Document, Types } from "mongoose"; +import { Schema, Document, Types } from 'mongoose' /** * student subdocument. Used as nested objects in PollResults schema @@ -18,25 +18,25 @@ export interface StudentDocument extends Student, Document {} export const studentSchema = new Schema({ utorid: { type: String, - required: true, + required: true }, answer: { type: Number, - required: true, + required: true }, timestamp: { type: Date, - required: true, + required: true }, sequence: { type: Number, - required: true, + required: true }, pollId: { type: String, - required: true, - }, -}); + required: true + } +}) /** * Poll schema. Represents how a single poll will look like @@ -64,7 +64,7 @@ export const pollSchema = new Schema({ description: String, courseCode: { type: String, required: true }, created: { type: Date, required: true }, - options: Number, -}); + options: Number +}) -export const ObjectId = Types.ObjectId; +export const ObjectId = Types.ObjectId diff --git a/server/src/redis.ts b/server/src/redis.ts index 3bf6d41..982a9d5 100644 --- a/server/src/redis.ts +++ b/server/src/redis.ts @@ -1,30 +1,32 @@ -import { createClient } from "redis"; -import fs from "fs"; -import path from "path"; -import readline from "readline"; +import { createClient } from 'redis' +import fs from 'fs' +import path from 'path' +import readline from 'readline' +import { UserType } from './types/user.types' + const client = createClient({ - url: process.env.REDIS_URL, -}); + url: process.env.REDIS_URL +}) -(async () => { - client.on("error", (err) => console.log("Redis Client Error", err)); +async function connectRedis () { + client.on('error', (err) => console.log('Redis Client Error', err)) - await client.connect(); - console.log("connected to redis"); + await client.connect() + console.log('connected to redis') const readstream = fs.createReadStream( path.join(__dirname, process.env.WHITELIST) - ); + ) const rl = readline.createInterface({ input: readstream, - crlfDelay: Infinity, - }); + crlfDelay: Infinity + }) for await (const line of rl) { - console.log(line.trim()); - client.set(line.trim(), "instructor"); + console.log(line.trim()) + client.set(line.trim(), UserType.INSTRUCTOR) } -})(); +} -export { client }; +export { client, connectRedis } diff --git a/server/src/routes/pollRoute.ts b/server/src/routes/pollRoute.ts index 7a58775..bebd377 100644 --- a/server/src/routes/pollRoute.ts +++ b/server/src/routes/pollRoute.ts @@ -1,92 +1,98 @@ -import { Router } from "express"; +import { Router } from 'express' import { changePollStatus, createPoll, getPollStatus, getResult, getStudents, - endForever, -} from "../controllers/pollController"; + endForever +} from '../controllers/pollController' -const pollRouter = Router(); +const pollRouter = Router() -pollRouter.post("/", async (req, res) => { - const { name, description, courseCode, options } = req.body; +pollRouter.post('/', async (req, res) => { + const { name, description, courseCode, options } = req.body try { const poll = { name, description, courseCode: courseCode.toUpperCase(), options, - created: new Date(), - }; - const result = await createPoll(poll); - return res.status(result.status).send(result.data); + created: new Date() + } + const result = await createPoll(poll) + return res.status(result.status).send(result.data) } catch (err) { - console.log(err); - return res.status(500).send({ message: "Internal Server Error" }); + console.log(err) + return res.status(500).send({ message: 'Internal Server Error' }) } -}); +}) -pollRouter.patch("/:pollId", async (req, res) => { - const { pollId } = req.params; - const { hasStarted } = req.body; +pollRouter.patch('/:pollId', async (req, res) => { + const { pollId } = req.params + const { hasStarted } = req.body try { - const result = await changePollStatus(pollId, hasStarted); - return res.status(result.status).send(result.data); + const result = await changePollStatus(pollId, hasStarted) + return res.status(result.status).send(result.data) } catch (err) { - console.log(err); - return res.status(500).send({ message: "Internal Server Error" }); + console.log(err) + return res.status(500).send({ message: 'Internal Server Error' }) } -}); +}) -pollRouter.get("/students", async (req, res) => { - const courseCode = req.query.courseCode as string; - const startTime = req.query.startTime as string; - const endTime = req.query.endTime as string; - //const { startTime, endTime } = req.body; - console.log(req.query); - console.log(startTime, endTime); +pollRouter.get('/students', async (req, res) => { + const courseCode = req.query.courseCode as string + const startTime = req.query.startTime as string + const endTime = req.query.endTime as string + // const { startTime, endTime } = req.body; + console.log(req.query) + console.log(startTime, endTime) try { const result = await getStudents( courseCode.toUpperCase(), new Date(startTime), new Date(endTime) - ); - return res.status(200).send(result); + ) + return res.status(200).send(result) } catch (err) { - return res.status(500).send({ message: "Failed to find students" }); + return res.status(500).send({ message: 'Failed to find students' }) } -}); +}) -pollRouter.get("/status", async (req, res) => { - const { pollId } = req.query; +pollRouter.get('/status', async (req, res) => { + const { pollId } = req.query try { - const result = await getPollStatus(pollId); - return res.status(result.status).send(result.data); + if (typeof pollId !== 'string') { + return res.status(400).send({ message: 'Invalid utorid' }) + } + const result = await getPollStatus(pollId) + return res.status(result.status).send(result.data) } catch (err) { - return res.status(500).send({ message: "Internal Server Error" }); + return res.status(500).send({ message: 'Internal Server Error' }) } -}); +}) -pollRouter.get("/result", async (req, res) => { - const { pollId } = req.query; +pollRouter.get('/result', async (req, res) => { + const { pollId } = req.query try { - const result = await getResult(pollId); - return res.status(result.status).send(result.data); + if (typeof pollId !== 'string') { + return res.status(400).send({ message: 'Invalid utorid' }) + } + const result = await getResult(pollId) + return res.status(result.status).send(result.data) } catch (err) { - return res.status(500).send({ message: "Internal Server Error" }); + return res.status(500).send({ message: 'Internal Server Error' }) } -}); +}) -pollRouter.patch("/end/:pollCode", async (req, res) => { - const { pollCode } = req.params; +pollRouter.patch('/end/:pollCode', async (req, res) => { + const { pollCode } = req.params try { - const result = await endForever(pollCode); - return res.status(result.status).send(result.data); + const result = await endForever(pollCode) + return res.status(result.status).send(result.data) } catch (err) { - return res.status(500).send({ message: "Internal Server Error" }); + return res.status(500).send({ message: 'Internal Server Error' }) } -}); +}) -export default pollRouter; +export default pollRouter diff --git a/server/src/routes/userRoutes.ts b/server/src/routes/userRoutes.ts index 0057905..34f88cf 100644 --- a/server/src/routes/userRoutes.ts +++ b/server/src/routes/userRoutes.ts @@ -1,16 +1,19 @@ -import { Router } from "express"; -import { getUser } from "../controllers/userController"; +import { Router } from 'express' +import { getUser } from '../controllers/userController' -const userRouter = Router(); +const userRouter = Router() -userRouter.get("/", async (req, res) => { +userRouter.get('/', async (req, res) => { try { - const result = await getUser(req.headers.utorid); - return res.status(result.status).send(result.data); + if (typeof req.headers.utorid !== 'string') { + return res.status(400).send({ message: 'Invalid utorid' }) + } + const result = await getUser(req.headers.utorid) + return res.status(result.status).send(result.data) } catch (err) { - console.log(err); - return res.status(500).send({ message: "Internal Server Error" }); + console.log(err) + return res.status(500).send({ message: 'Internal Server Error' }) } -}); +}) -export default userRouter; +export default userRouter diff --git a/server/src/server.ts b/server/src/server.ts index 5314d71..a499672 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,62 +1,71 @@ -"use strict"; +'use strict' -import * as dotenv from "dotenv"; -dotenv.config(); -import express from "express"; -import cors from "cors"; -import cookieParser from "cookie-parser"; -import { io } from "./socket"; -import pollRouter from "./routes/pollRoute"; -import { db } from "./db/mogoose"; -import userRouter from "./routes/userRoutes"; -import { getUser } from "./controllers/userController"; -db.on("open", () => { - console.log("Connected to mongo"); -}); +import * as dotenv from 'dotenv' +import express from 'express' +import cors from 'cors' +import cookieParser from 'cookie-parser' +import { io } from './socket' +import pollRouter from './routes/pollRoute' +import { connectMongo } from './db/mogoose' +import userRouter from './routes/userRoutes' +import { getUser } from './controllers/userController' +import { UserType } from './types/user.types' +import { connectRedis } from './redis' +dotenv.config() // starting the express server -const app = express(); -const port = process.env.PORT || 5000; +const app = express() +const port = process.env.PORT || 5000 // parse cookies and body and enable cors app.use( cors({ origin: process.env.FRONTEND_URL, credentials: true, - methods: "GET,POST,DELETE,PATCH", + methods: 'GET,POST,DELETE,PATCH' }) -); -app.use(express.urlencoded({ extended: true })); -app.use(express.json()); -app.use(cookieParser()); -app.use("/user", userRouter); +) +app.use(express.urlencoded({ extended: true })) +app.use(express.json()) +app.use(cookieParser()) +app.use('/user', userRouter) app.use(async (req, res, next) => { try { - const userType = await getUser(req.headers.utorid); - if (userType.data.userType === "instructor") next(); - else next(new Error("Forbidden User")); + if (typeof req.headers.utorid !== 'string') { + return next(new Error('Invalid utorid')) + } + const userType = await getUser(req.headers.utorid) + if (userType.data.userType === UserType.INSTRUCTOR) next() + else next(new Error('Forbidden User')) } catch (err) { - next(new Error("Forbidden User")); + next(new Error('Forbidden User')) } -}); -app.use("/poll", pollRouter); +}) +app.use('/poll', pollRouter) const server = app.listen(port, () => { - console.log("Listening on http://localhost:" + port); - io.attach(server); + console.log('Listening on http://localhost:' + port) + connectRedis() + connectMongo() + io.attach(server) io.use(async (socket, next) => { try { - if (socket.handshake.headers.utorid != undefined) { - socket.data["utorid"] = socket.handshake.headers.utorid; - next(); - return; - } else if (socket.data.utorid != undefined) { - next(); - return; + if (socket.handshake.headers.utorid) { + socket.data.utorid = socket.handshake.headers.utorid + next() + return + } else if (socket.data.utorid) { + next() + return } - next(new Error("Not Authorized")); + next(new Error('Not Authorized')) } catch (err) { - next(new Error("Not Authorized")); + next(new Error('Not Authorized')) } - }); -}); + }) + io.use(async (socket, next) => { + const userType = await getUser(socket.data.utorid) + socket.data.userType = userType.data.userType + next() + }) +}) diff --git a/server/src/socket.ts b/server/src/socket.ts index 09dac3a..781e940 100644 --- a/server/src/socket.ts +++ b/server/src/socket.ts @@ -1,42 +1,42 @@ // socket setup -import { Server, Socket } from "socket.io"; -import { vote, join } from "./controllers/socketController"; -import { RateLimiterMemory } from "rate-limiter-flexible"; +import { Server, Socket } from 'socket.io' +import { vote, join } from './controllers/socketController' +import { RateLimiterMemory } from 'rate-limiter-flexible' const rateLimiter = new RateLimiterMemory({ points: 5, // 5 points connections - duration: 3, // per second -}); + duration: 3 // per second +}) const io = new Server({ - path: "/socket.io", - cors: { origin: process.env.FRONTEND_URL, credentials: true }, -}); + path: '/socket.io', + cors: { origin: process.env.FRONTEND_URL, credentials: true } +}) // log the socket id when client socket connects for the first time -io.on("connection", (socket: Socket) => { - console.log(`connect: ${socket.id}`); +io.on('connection', (socket: Socket) => { + console.log(`connect: ${socket.id}`) // let the socket join rooms once connected - socket.on("join", async (pollCode: string) => { + socket.on('join', async (pollCode: string) => { try { - await rateLimiter.consume(socket.data.utorid); - await join(socket, pollCode); + await rateLimiter.consume(socket.data.utorid) + await join(socket, pollCode) // let the socket vote in the connected room - socket.on("vote", async (answer: number) => { + socket.on('vote', async (answer: number) => { try { - await rateLimiter.consume(socket.data.utorid); - await vote(socket, answer, socket.data.utorid); + await rateLimiter.consume(socket.data.utorid) + await vote(socket, answer, socket.data.utorid) } catch (err) { - socket.emit("error", { code: 2, message: "retry again later" }); + socket.emit('error', { code: 2, message: 'retry again later' }) } - }); + }) } catch (err) { - socket.emit("error", { code: 2, message: "retry again later" }); + socket.emit('error', { code: 2, message: 'retry again later' }) } - }); -}); + }) +}) -export { io }; +export { io } diff --git a/server/src/types/pollController.types.ts b/server/src/types/pollController.types.ts new file mode 100644 index 0000000..3f885ea --- /dev/null +++ b/server/src/types/pollController.types.ts @@ -0,0 +1,10 @@ +export type AggregatedStudent = { + pollId: string, + courseCode: string, + sequence: number, + utorid: string, + timestamp: string, + pollName: string, + description: string, + answer: number +} diff --git a/server/src/types/user.types.ts b/server/src/types/user.types.ts new file mode 100644 index 0000000..5339d8f --- /dev/null +++ b/server/src/types/user.types.ts @@ -0,0 +1,4 @@ +export enum UserType { + INSTRUCTOR = 'instructor', + STUDENT = 'student' +} diff --git a/server/yarn.lock b/server/yarn.lock index 877a989..9e3ae79 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1434,6 +1434,11 @@ http-errors@~1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" +husky@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.1.tgz#511cb3e57de3e3190514ae49ed50f6bc3f50b3e9" + integrity sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw== + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"