From 1e82a61fc66f36586a138f5e0fa7e2f10010575f Mon Sep 17 00:00:00 2001 From: theryanfo Date: Tue, 20 May 2025 15:52:51 -0700 Subject: [PATCH 01/11] prisma db pull --- prisma/schema.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c528779..760345c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -90,6 +90,7 @@ model CourseSession { startTime DateTime @default(now()) endTime DateTime? activeQuestionId Int? + paused Boolean? @default(false) course Course @relation(fields: [courseId], references: [id]) questions Question[] } From 873a3dfac0524f60449d14cef3a443019567f0dc Mon Sep 17 00:00:00 2001 From: theryanfo Date: Tue, 20 May 2025 16:37:57 -0700 Subject: [PATCH 02/11] csv export working --- app/api/export/[sessionId]/route.ts | 62 +++++++++++++++++++ .../course/[courseId]/questionnaire/page.tsx | 25 ++++++++ package-lock.json | 41 ++++++++++++ package.json | 1 + 4 files changed, 129 insertions(+) create mode 100644 app/api/export/[sessionId]/route.ts diff --git a/app/api/export/[sessionId]/route.ts b/app/api/export/[sessionId]/route.ts new file mode 100644 index 0000000..ec58ee1 --- /dev/null +++ b/app/api/export/[sessionId]/route.ts @@ -0,0 +1,62 @@ +import prisma from "@/lib/prisma"; +import { NextResponse } from "next/server"; +const { parse } = require("json2csv"); + +export async function GET( + req: Request, + { params }: { params: { sessionId: string } } +) { + const sessionId = parseInt(params.sessionId); + const url = new URL(req.url); + const mode = url.searchParams.get("mode") ?? "basic"; + + const responses = await prisma.response.findMany({ + where: { question: { sessionId } }, + include: { + user: true, + question: true, + option: true, + }, + }); + + const session = await prisma.courseSession.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + return new NextResponse("Session not found", { status: 404 }); + } + + const sessionDate = session.startTime.toISOString().split("T")[0]; + + const basicMap = new Map(); + const advancedRows = []; + + for (const res of responses) { + const email = res.user.email ?? "[unknown]"; + basicMap.set(email, (basicMap.get(email) || 0) + 1); + + advancedRows.push({ + email, + question: res.question.text, + answer: res.option.text, + is_correct: res.option.isCorrect, + date_of_session: sessionDate, + }); + } + + const basicRows = Array.from(basicMap.entries()).map(([email, count]) => ({ + email, + num_questions_answered: count, + date_of_session: sessionDate, + })); + + const csv = parse(mode === "advanced" ? advancedRows : basicRows); + + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename=${mode}_export.csv`, + }, + }); +} diff --git a/app/dashboard/course/[courseId]/questionnaire/page.tsx b/app/dashboard/course/[courseId]/questionnaire/page.tsx index 5a462f6..ac275e1 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/questionnaire/page.tsx @@ -87,6 +87,31 @@ export default function Page() { ) : ( )} + + {hasActiveSession && ( + + )} diff --git a/package-lock.json b/package-lock.json index 354cc3d..7cb7b05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "date-fns": "^3.6.0", "dayjs": "^1.11.13", "framer-motion": "^12.4.10", + "json2csv": "^6.0.0-alpha.2", "lucide-react": "^0.464.0", "next": "^15.2.3", "next-auth": "^4.24.11", @@ -2949,6 +2950,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@streamparser/json": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.6.tgz", + "integrity": "sha512-vL9EVn/v+OhZ+Wcs6O4iKE9EUpwHUqHmCtNUMWjqp+6dr85+XPOSGTEsqYNq1Vn04uk9SWlOVmx9J48ggJVT2Q==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -7059,6 +7066,33 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json2csv": { + "version": "6.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-6.0.0-alpha.2.tgz", + "integrity": "sha512-nJ3oP6QxN8z69IT1HmrJdfVxhU1kLTBVgMfRnNZc37YEY+jZ4nU27rBGxT4vaqM/KUCavLRhntmTuBFqZLBUcA==", + "license": "MIT", + "dependencies": { + "@streamparser/json": "^0.0.6", + "commander": "^6.2.0", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 12", + "npm": ">= 6.13.0" + } + }, + "node_modules/json2csv/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -7237,6 +7271,13 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", diff --git a/package.json b/package.json index cef643f..1048d2d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "date-fns": "^3.6.0", "dayjs": "^1.11.13", "framer-motion": "^12.4.10", + "json2csv": "^6.0.0-alpha.2", "lucide-react": "^0.464.0", "next": "^15.2.3", "next-auth": "^4.24.11", From 85de83fe5773a0fd6c6db0ba874977e9e195a273 Mon Sep 17 00:00:00 2001 From: theryanfo Date: Tue, 20 May 2025 16:53:01 -0700 Subject: [PATCH 03/11] fixed basic export --- app/api/export/[sessionId]/route.ts | 22 +++++++++++----- .../course/[courseId]/questionnaire/page.tsx | 26 +++++++++---------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/app/api/export/[sessionId]/route.ts b/app/api/export/[sessionId]/route.ts index ec58ee1..5e81727 100644 --- a/app/api/export/[sessionId]/route.ts +++ b/app/api/export/[sessionId]/route.ts @@ -29,12 +29,18 @@ export async function GET( const sessionDate = session.startTime.toISOString().split("T")[0]; - const basicMap = new Map(); + const userQuestionMap = new Map>(); const advancedRows = []; for (const res of responses) { const email = res.user.email ?? "[unknown]"; - basicMap.set(email, (basicMap.get(email) || 0) + 1); + const questionId = res.question.id; + + if (!userQuestionMap.has(email)) { + userQuestionMap.set(email, new Set()); + } + + userQuestionMap.get(email)!.add(questionId); advancedRows.push({ email, @@ -45,11 +51,13 @@ export async function GET( }); } - const basicRows = Array.from(basicMap.entries()).map(([email, count]) => ({ - email, - num_questions_answered: count, - date_of_session: sessionDate, - })); + const basicRows = Array.from(userQuestionMap.entries()).map( + ([email, questionSet]) => ({ + email, + num_questions_answered: questionSet.size, + date_of_session: sessionDate, + }) + ); const csv = parse(mode === "advanced" ? advancedRows : basicRows); diff --git a/app/dashboard/course/[courseId]/questionnaire/page.tsx b/app/dashboard/course/[courseId]/questionnaire/page.tsx index ac275e1..4e3b37c 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/questionnaire/page.tsx @@ -93,20 +93,20 @@ export default function Page() { variant="outline" className="h-[50px] w-48 text-base sm:text-xl font-normal rounded-xl" onClick={async () => { - try { - const res = await getCourseSessionByDate(courseId, formatDateToISO(new Date())); - if (!res?.id) { - toast({ variant: "destructive", description: "No session found." }); - return; - } + try { + const res = await getCourseSessionByDate(courseId, formatDateToISO(new Date())); + if (!res?.id) { + toast({ variant: "destructive", description: "No session found." }); + return; + } - const mode = confirm("Export advanced CSV? Click Cancel for basic.") ? "advanced" : "basic"; - const downloadUrl = `/api/export/${res.id}?mode=${mode}`; - window.open(downloadUrl, "_blank"); - } catch (err) { - toast({ variant: "destructive", description: "Export failed." }); - console.error("Export error:", err); - } + const mode = confirm("Export advanced CSV? Click Cancel for basic.") ? "advanced" : "basic"; + const downloadUrl = `/api/export/${res.id}?mode=${mode}`; + window.open(downloadUrl, "_blank"); + } catch (err) { + toast({ variant: "destructive", description: "Export failed." }); + console.error("Export error:", err); + } }} > Export CSV From 41f8e3e9971689c3850086c86d7021f6db774b81 Mon Sep 17 00:00:00 2001 From: theryanfo Date: Tue, 20 May 2025 17:16:03 -0700 Subject: [PATCH 04/11] formatting --- .../course/[courseId]/questionnaire/page.tsx | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/app/dashboard/course/[courseId]/questionnaire/page.tsx b/app/dashboard/course/[courseId]/questionnaire/page.tsx index 4e3b37c..ac9c196 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/questionnaire/page.tsx @@ -14,6 +14,8 @@ import { useToast } from "@/hooks/use-toast"; import { formatDateToISO } from "@/lib/utils"; import { getCourseWithId } from "@/services/course"; import { getCourseSessionByDate } from "@/services/session"; +import { Select, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"; +import { SelectTrigger } from "@radix-ui/react-select"; export default function Page() { const params = useParams(); @@ -24,6 +26,8 @@ export default function Page() { const router = useRouter(); const { toast } = useToast(); const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); + const [exportMode, setExportMode] = useState("basic"); + useEffect(() => { if (isAccessLoading) { @@ -87,31 +91,45 @@ export default function Page() { ) : ( )} - + {hasActiveSession && ( - + + )} + + From 72b715c9dd13298dd073b35596a80f7e183e5005 Mon Sep 17 00:00:00 2001 From: theryanfo Date: Sat, 31 May 2025 20:48:38 -0700 Subject: [PATCH 05/11] Allowed exports on any day, even finished sessions --- app/api/export/[sessionId]/route.ts | 103 +++++++++--------- .../course/[courseId]/questionnaire/page.tsx | 93 +++++++++------- .../course/[courseId]/start-session/page.tsx | 3 +- components/ui/SlidingCalendar.tsx | 19 +++- lib/constants.ts | 2 +- 5 files changed, 116 insertions(+), 104 deletions(-) diff --git a/app/api/export/[sessionId]/route.ts b/app/api/export/[sessionId]/route.ts index 5e81727..d6c80f3 100644 --- a/app/api/export/[sessionId]/route.ts +++ b/app/api/export/[sessionId]/route.ts @@ -1,70 +1,65 @@ -import prisma from "@/lib/prisma"; import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; const { parse } = require("json2csv"); -export async function GET( - req: Request, - { params }: { params: { sessionId: string } } -) { - const sessionId = parseInt(params.sessionId); - const url = new URL(req.url); - const mode = url.searchParams.get("mode") ?? "basic"; +export async function GET(req: Request, { params }: { params: { sessionId: string } }) { + const sessionId = parseInt(params.sessionId); + const url = new URL(req.url); + const mode = url.searchParams.get("mode") ?? "basic"; - const responses = await prisma.response.findMany({ - where: { question: { sessionId } }, - include: { - user: true, - question: true, - option: true, - }, - }); + const responses = await prisma.response.findMany({ + where: { question: { sessionId } }, + include: { + user: true, + question: true, + option: true, + }, + }); - const session = await prisma.courseSession.findUnique({ - where: { id: sessionId }, - }); + const session = await prisma.courseSession.findUnique({ + where: { id: sessionId }, + }); - if (!session) { - return new NextResponse("Session not found", { status: 404 }); - } + if (!session) { + return new NextResponse("Session not found", { status: 404 }); + } - const sessionDate = session.startTime.toISOString().split("T")[0]; + const sessionDate = session.startTime.toISOString().split("T")[0]; - const userQuestionMap = new Map>(); - const advancedRows = []; + const userQuestionMap = new Map>(); + const advancedRows = []; - for (const res of responses) { - const email = res.user.email ?? "[unknown]"; - const questionId = res.question.id; + for (const res of responses) { + const email = res.user.email ?? "[unknown]"; + const questionId = res.question.id; - if (!userQuestionMap.has(email)) { - userQuestionMap.set(email, new Set()); - } + if (!userQuestionMap.has(email)) { + userQuestionMap.set(email, new Set()); + } - userQuestionMap.get(email)!.add(questionId); + userQuestionMap.get(email)!.add(questionId); - advancedRows.push({ - email, - question: res.question.text, - answer: res.option.text, - is_correct: res.option.isCorrect, - date_of_session: sessionDate, - }); - } + advancedRows.push({ + email, + question: res.question.text, + answer: res.option.text, + is_correct: res.option.isCorrect, + date_of_session: sessionDate, + }); + } - const basicRows = Array.from(userQuestionMap.entries()).map( - ([email, questionSet]) => ({ - email, - num_questions_answered: questionSet.size, - date_of_session: sessionDate, - }) - ); + const basicRows = Array.from(userQuestionMap.entries()).map(([email, questionSet]) => ({ + email, + num_questions_answered: questionSet.size, + date_of_session: sessionDate, + })); - const csv = parse(mode === "advanced" ? advancedRows : basicRows); + const csv = parse(mode === "advanced" ? advancedRows : basicRows); - return new NextResponse(csv, { - headers: { - "Content-Type": "text/csv", - "Content-Disposition": `attachment; filename=${mode}_export.csv`, - }, - }); + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename=${mode}_export.csv`, + }, + }); } diff --git a/app/dashboard/course/[courseId]/questionnaire/page.tsx b/app/dashboard/course/[courseId]/questionnaire/page.tsx index ac9c196..247a0d1 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/questionnaire/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SelectTrigger } from "@radix-ui/react-select"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -9,13 +10,12 @@ import BeginPollDialog from "@/components/BeginPollDialog"; import SlidingCalendar from "@/components/ui/SlidingCalendar"; import { Button } from "@/components/ui/button"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; +import { Select, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"; import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; import { formatDateToISO } from "@/lib/utils"; import { getCourseWithId } from "@/services/course"; import { getCourseSessionByDate } from "@/services/session"; -import { Select, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"; -import { SelectTrigger } from "@radix-ui/react-select"; export default function Page() { const params = useParams(); @@ -27,7 +27,7 @@ export default function Page() { const { toast } = useToast(); const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); const [exportMode, setExportMode] = useState("basic"); - + const [selectedDate, setSelectedDate] = useState(new Date()); useEffect(() => { if (isAccessLoading) { @@ -78,7 +78,7 @@ export default function Page() { courseId={courseId} location="page" /> - + {hasActiveSession ? ( - - )} - + + + + } - + ); diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 19db950..ff9bcec 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -27,6 +27,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; +import { DEFAULT_SHOW_RESULTS } from "@/lib/constants"; import { addWildcardQuestion } from "@/lib/server-utils"; import { formatDateToISO } from "@/lib/utils"; import { ChartData } from "@/models/Chart"; @@ -38,8 +39,6 @@ import { getQuestionsForSession, } from "@/services/session"; -import { DEFAULT_SHOW_RESULTS } from "@/lib/constants"; - export default function StartSession() { const params = useParams(); const router = useRouter(); diff --git a/components/ui/SlidingCalendar.tsx b/components/ui/SlidingCalendar.tsx index 8f59c15..baf524a 100644 --- a/components/ui/SlidingCalendar.tsx +++ b/components/ui/SlidingCalendar.tsx @@ -21,9 +21,11 @@ import { formatDateToISO } from "@/lib/utils"; interface Props { courseId: number; + selectedDate?: Date; + onDateChange?: (date: Date) => void; } -function SlidingCalendar({ courseId }: Props) { +function SlidingCalendar({ courseId, selectedDate: selectedDateProp, onDateChange }: Props) { const [startDate, setStartDate] = useState(dayjs().startOf("week")); const [selectedDate, setSelectedDate] = useState(dayjs()); const [questions, setQuestions] = useState< @@ -62,10 +64,10 @@ function SlidingCalendar({ courseId }: Props) { }; useEffect(() => { - if (selectedDate) { - fetchQuestions(selectedDate.toDate()); - } - }, [selectedDate]); + const initialDate = selectedDateProp ? dayjs(selectedDateProp) : dayjs(); + setSelectedDate(initialDate); + fetchQuestions(initialDate.toDate()); + }, [selectedDateProp]); // fetch incorrect and correct options of selected question useEffect(() => { @@ -157,7 +159,12 @@ function SlidingCalendar({ courseId }: Props) { ? "bg-[#18328D] text-white" : "bg-white text-black" }`} - onClick={() => setSelectedDate(date)} + onClick={() => { + setSelectedDate(date); + if (onDateChange) { + onDateChange(date.toDate()); + } + }} > Date: Sat, 31 May 2025 21:08:23 -0700 Subject: [PATCH 06/11] merge conflicts fix --- .../course/[courseId]/questionnaire/page.tsx | 5 +++++ components/ui/SlidingCalendar.tsx | 14 +++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/dashboard/course/[courseId]/questionnaire/page.tsx b/app/dashboard/course/[courseId]/questionnaire/page.tsx index 247a0d1..90d90b8 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/questionnaire/page.tsx @@ -28,6 +28,10 @@ export default function Page() { const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); const [exportMode, setExportMode] = useState("basic"); const [selectedDate, setSelectedDate] = useState(new Date()); + const [refreshCalendar, setRefreshCalendar] = useState(false); + const handleQuestionUpdate = () => { + setRefreshCalendar(prev => !prev); + }; useEffect(() => { if (isAccessLoading) { @@ -143,6 +147,7 @@ export default function Page() { courseId={courseId} selectedDate={selectedDate} onDateChange={setSelectedDate} + refreshTrigger={refreshCalendar} /> diff --git a/components/ui/SlidingCalendar.tsx b/components/ui/SlidingCalendar.tsx index baf524a..c4e3b6b 100644 --- a/components/ui/SlidingCalendar.tsx +++ b/components/ui/SlidingCalendar.tsx @@ -23,9 +23,10 @@ interface Props { courseId: number; selectedDate?: Date; onDateChange?: (date: Date) => void; + refreshTrigger?: boolean; } -function SlidingCalendar({ courseId, selectedDate: selectedDateProp, onDateChange }: Props) { +function SlidingCalendar({ courseId, selectedDate: selectedDateProp, onDateChange, refreshTrigger }: Props) { const [startDate, setStartDate] = useState(dayjs().startOf("week")); const [selectedDate, setSelectedDate] = useState(dayjs()); const [questions, setQuestions] = useState< @@ -64,10 +65,13 @@ function SlidingCalendar({ courseId, selectedDate: selectedDateProp, onDateChang }; useEffect(() => { - const initialDate = selectedDateProp ? dayjs(selectedDateProp) : dayjs(); - setSelectedDate(initialDate); - fetchQuestions(initialDate.toDate()); - }, [selectedDateProp]); + const newDate = selectedDateProp ? dayjs(selectedDateProp) : selectedDate; + setSelectedDate(newDate); + if (newDate) { + fetchQuestions(newDate.toDate()); + } + }, [selectedDateProp, refreshTrigger]); + // fetch incorrect and correct options of selected question useEffect(() => { From df4389546ace0412335c36ef30a9fbd0d832f995 Mon Sep 17 00:00:00 2001 From: theryanfo Date: Sat, 31 May 2025 21:32:27 -0700 Subject: [PATCH 07/11] lint and build error --- app/api/export/[sessionId]/route.ts | 4 +- app/api/updateCourse/[id]/route.ts | 296 ++++++++-------- .../course/[courseId]/questionnaire/page.tsx | 2 +- .../course/[courseId]/start-session/page.tsx | 2 - components/AddEditCourseForm.tsx | 1 - components/AddEditQuestionForm.tsx | 1 - components/ui/CourseCard.tsx | 327 +++++++++--------- components/ui/SlidingCalendar.tsx | 13 +- components/ui/answerOptions.tsx | 1 - lib/utils.ts | 1 - services/course.ts | 10 +- 11 files changed, 326 insertions(+), 332 deletions(-) diff --git a/app/api/export/[sessionId]/route.ts b/app/api/export/[sessionId]/route.ts index d6c80f3..609b9a9 100644 --- a/app/api/export/[sessionId]/route.ts +++ b/app/api/export/[sessionId]/route.ts @@ -2,8 +2,8 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; const { parse } = require("json2csv"); -export async function GET(req: Request, { params }: { params: { sessionId: string } }) { - const sessionId = parseInt(params.sessionId); +export async function GET(req: Request, context: { params: { sessionId: string } }) { + const sessionId = parseInt(context.params.sessionId); const url = new URL(req.url); const mode = url.searchParams.get("mode") ?? "basic"; 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 785dda6..db51696 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/questionnaire/page.tsx @@ -30,7 +30,7 @@ export default function Page() { const [selectedDate, setSelectedDate] = useState(new Date()); 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 11784e3..4700d7f 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -53,8 +53,6 @@ export default function StartSession() { const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); const [isChangingQuestion, setIsChangingQuestion] = useState(false); // New state for question navigation - - useEffect(() => { async function fetchSessionData() { const session = await getCourseSessionByDate(courseId, utcDate); 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/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 cb212bc..6ec3359 100644 --- a/components/ui/SlidingCalendar.tsx +++ b/components/ui/SlidingCalendar.tsx @@ -26,7 +26,12 @@ interface Props { refreshTrigger?: boolean; } -function SlidingCalendar({ courseId, selectedDate: selectedDateProp, onDateChange, refreshTrigger }: Props) { +function SlidingCalendar({ + courseId, + selectedDate: selectedDateProp, + onDateChange, + refreshTrigger, +}: Props) { const [startDate, setStartDate] = useState(dayjs().startOf("week")); const [selectedDate, setSelectedDate] = useState(dayjs()); const [questions, setQuestions] = useState< @@ -262,7 +267,11 @@ function SlidingCalendar({ courseId, selectedDate: selectedDateProp, onDateChang 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/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 +} From ea7f688d96be3e7c719d015624cd0cf2c671b730 Mon Sep 17 00:00:00 2001 From: theryanfo Date: Sat, 31 May 2025 22:02:20 -0700 Subject: [PATCH 08/11] lint fixes --- app/api/export/[sessionId]/route.ts | 27 ++++++++++--- .../course/[courseId]/questionnaire/page.tsx | 38 ++++++++++--------- package-lock.json | 11 ++++++ package.json | 1 + 4 files changed, 54 insertions(+), 23 deletions(-) diff --git a/app/api/export/[sessionId]/route.ts b/app/api/export/[sessionId]/route.ts index 609b9a9..4359c7d 100644 --- a/app/api/export/[sessionId]/route.ts +++ b/app/api/export/[sessionId]/route.ts @@ -1,8 +1,8 @@ -import { NextResponse } from "next/server"; +import { parse } from "json2csv"; +import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -const { parse } = require("json2csv"); -export async function GET(req: Request, context: { params: { sessionId: string } }) { +export async function GET(req: NextRequest, context: { params: { sessionId: string } }) { const sessionId = parseInt(context.params.sessionId); const url = new URL(req.url); const mode = url.searchParams.get("mode") ?? "basic"; @@ -37,7 +37,7 @@ export async function GET(req: Request, context: { params: { sessionId: string } userQuestionMap.set(email, new Set()); } - userQuestionMap.get(email)!.add(questionId); + userQuestionMap.get(email)?.add(questionId); advancedRows.push({ email, @@ -54,7 +54,24 @@ export async function GET(req: Request, context: { params: { sessionId: string } date_of_session: sessionDate, })); - const csv = parse(mode === "advanced" ? advancedRows : basicRows); + const csv = + mode === "advanced" + ? parse( + advancedRows as { + email: string; + question: string; + answer: string; + is_correct: boolean; + date_of_session: string; + }[], + ) + : parse( + basicRows as { + email: string; + num_questions_answered: number; + date_of_session: string; + }[], + ); return new NextResponse(csv, { headers: { diff --git a/app/dashboard/course/[courseId]/questionnaire/page.tsx b/app/dashboard/course/[courseId]/questionnaire/page.tsx index db51696..36d5f4a 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/questionnaire/page.tsx @@ -114,28 +114,30 @@ export default function Page() { +
{/* Student Data Table */} diff --git a/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx index 3eae19e..44ef420 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx @@ -1,20 +1,17 @@ "use client"; -import { SelectTrigger } from "@radix-ui/react-select"; -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; import { AddEditQuestionForm } from "@/components/AddEditQuestionForm"; import { AddInstructorForm } from "@/components/AddInstuctorForm"; import BeginPollDialog from "@/components/BeginPollDialog"; import SlidingCalendar from "@/components/ui/SlidingCalendar"; import { Button } from "@/components/ui/button"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; -import { Select, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"; -import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; import { formatDateToISO } from "@/lib/utils"; import { getCourseSessionByDate } from "@/services/session"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; export default function Page() { const params = useParams(); @@ -23,8 +20,6 @@ export default function Page() { const [isLoading, setIsLoading] = useState(true); const router = useRouter(); const { toast } = useToast(); - const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); - const [exportMode, setExportMode] = useState("basic"); const [selectedDate, setSelectedDate] = useState(new Date()); const [refreshCalendar, setRefreshCalendar] = useState(false); const handleQuestionUpdate = () => { @@ -81,54 +76,6 @@ export default function Page() { ) : ( )} - - { -
- Export: - - - - -
- }
void; + label?: JSX.Element | string; +} + +export function ExportCSVDropdown({ onSelect, label }: ExportCSVDropdownProps) { + return ( + + + {label ?? ( + + )} + + + CSV Type + + + { + onSelect(ExportCSVType.BASIC); + }} + > + Basic CSV + + { + onSelect(ExportCSVType.ADVANCED); + }} + > + Advanced CSV + + + + + ); +} diff --git a/lib/constants.ts b/lib/constants.ts index 7191bda..0d9870f 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -114,3 +114,12 @@ export type Student = { firstName: string; lastName: string | null; }; + +export const csvBasicFieldNames = ["email", "num_questions_answered", "date_of_session"]; +export const csvAdvancedFieldNames = [ + "email", + "question", + "answer", + "is_correct", + "date_of_session", +]; diff --git a/types/ExportCSVType.ts b/types/ExportCSVType.ts new file mode 100644 index 0000000..003e96c --- /dev/null +++ b/types/ExportCSVType.ts @@ -0,0 +1,4 @@ +export enum ExportCSVType { + BASIC = 0, + ADVANCED = 1, +}