From 795bb8a6fb5b7c22de57942ca821cf7dbeba6907 Mon Sep 17 00:00:00 2001 From: Nhung Date: Thu, 29 May 2025 16:35:09 +0700 Subject: [PATCH 01/26] feat(Quizzes): Add create quizz set when finish creating quizzes - Check if same quiz-set name, then auto increase --- .../modules/[moduleId]/quizzes/client-ai.tsx | 14 +++++++ .../modules/[moduleId]/quizzes/page.tsx | 19 +++++++++- .../v1/workspaces/[wsId]/quiz-sets/route.ts | 38 ++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx index 75c187b8df..15258b970c 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx @@ -13,9 +13,11 @@ import { useState } from 'react'; export default function AIQuizzes({ wsId, moduleId, + moduleName, }: { wsId: string; moduleId: string; + moduleName: string; }) { const t = useTranslations(); const router = useRouter(); @@ -32,10 +34,22 @@ export default function AIQuizzes({ if (!object?.quizzes?.length) return; try { + // Step 1: Create a quiz set (if needed) + const quizSetRes = await fetch(`/api/v1/workspaces/${wsId}/quiz-sets`, { + method: 'POST', + body: JSON.stringify({ + name: 'Quizzes For ' + moduleName, + moduleId, + }), + }); + if (!quizSetRes.ok) throw new Error('Failed to create quiz set'); + const quizSet = await quizSetRes.json(); + const promises = object.quizzes.map((quiz) => fetch(`/api/v1/workspaces/${wsId}/quizzes`, { method: 'POST', body: JSON.stringify({ + setId: quizSet.id, moduleId, question: quiz?.question, quiz_options: quiz?.quiz_options, diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx index a802b0ad1f..f5bb102e7c 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx @@ -19,6 +19,7 @@ export default async function ModuleQuizzesPage({ params }: Props) { const { wsId, moduleId } = await params; const t = await getTranslations(); const quizzes = await getQuizzes(moduleId); + const moduleName = await getModuleName(moduleId); return (
@@ -47,7 +48,7 @@ export default async function ModuleQuizzesPage({ params }: Props) { )}
- +
@@ -68,3 +69,19 @@ const getQuizzes = async (moduleId: string) => { return data || []; }; + +const getModuleName = async (moduleId: string) => { + const supabase = await createClient(); + const { data, error } = await supabase + .from('workspace_course_modules') + .select('name') + .eq('id', moduleId) + .single(); + + if (error) { + console.error('Error fetching module name:', error); + throw error; + } + + return data.name as string; +}; diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts index 809aa30c2c..626510d14a 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts @@ -32,12 +32,48 @@ export async function POST(req: Request, { params }: Params) { const supabase = await createClient(); const { wsId: id } = await params; - const { moduleId, quiz_options, ...rest } = await req.json(); + const { moduleId, name, quiz_options, ...rest } = await req.json(); + // Quiz set name validation + if (!name || name.trim().length === 0) { + return NextResponse.json( + { message: 'Quiz set name is required' }, + { status: 400 } + ); + } + const formattedName = name.trim(); + const { data: quizSetName, error: quizSetNameError } = await supabase + .from('workspace_quiz_sets') + .select('name') + .eq('ws_id', id) + .eq('name', `${formattedName}%`); + + if (quizSetNameError) { + console.log(quizSetNameError); + return NextResponse.json( + { message: 'Error fetching workspace quiz set name' }, + { status: 500 } + ); + } + let renderedName = ""; + if (!quizSetName || quizSetName.length === 0) { + renderedName = formattedName; + } else { + const existingNames = quizSetName.map((d) => d.name); + const baseName = formattedName; + let suffix = 2; + let newName = `${baseName} ${suffix}`; + while (existingNames.includes(newName)) { + suffix++; + newName = `${baseName} ${suffix}`; + } + renderedName = newName; + } const { data, error } = await supabase .from('workspace_quiz_sets') .insert({ ...rest, + name: renderedName, ws_id: id, }) .select('id') From 054cc4abc0a045adb0356c85b95962e5d8fcf507 Mon Sep 17 00:00:00 2001 From: Nhung Date: Thu, 29 May 2025 16:54:48 +0700 Subject: [PATCH 02/26] fix(Quiz Sets): Quizzes not displayed in Quiz Sets after finishing creation --- .../[courseId]/modules/[moduleId]/quizzes/client-ai.tsx | 4 +++- .../src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx index 15258b970c..e091e721ac 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx @@ -45,11 +45,13 @@ export default function AIQuizzes({ if (!quizSetRes.ok) throw new Error('Failed to create quiz set'); const quizSet = await quizSetRes.json(); + console.log("Quiz Set Created:", quizSet); + const promises = object.quizzes.map((quiz) => fetch(`/api/v1/workspaces/${wsId}/quizzes`, { method: 'POST', body: JSON.stringify({ - setId: quizSet.id, + setId: quizSet.setId, moduleId, question: quiz?.question, quiz_options: quiz?.quiz_options, diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts index 626510d14a..c3c7e32c93 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts @@ -108,5 +108,5 @@ export async function POST(req: Request, { params }: Params) { .insert(quiz_options.map((o: any) => ({ ...o, quiz_id: data.id }))); } - return NextResponse.json({ message: 'success' }); + return NextResponse.json({ message: 'success', setId: data.id, name: renderedName }); } From 727be5f2a11586dba281187feb1d178841283bc7 Mon Sep 17 00:00:00 2001 From: Nhung Date: Thu, 29 May 2025 16:54:54 +0700 Subject: [PATCH 03/26] fix(Quiz Sets): Quizzes not displayed in Quiz Sets after finishing creation --- .../courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx index e091e721ac..512e7e9496 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx @@ -45,8 +45,6 @@ export default function AIQuizzes({ if (!quizSetRes.ok) throw new Error('Failed to create quiz set'); const quizSet = await quizSetRes.json(); - console.log("Quiz Set Created:", quizSet); - const promises = object.quizzes.map((quiz) => fetch(`/api/v1/workspaces/${wsId}/quizzes`, { method: 'POST', From ae8b50eb9e55e83ec0b727f9edfe05462f4cbfd1 Mon Sep 17 00:00:00 2001 From: Tran Phan Trong Phuc <117657788+Henry7482@users.noreply.github.com> Date: Fri, 30 May 2025 23:17:18 +0700 Subject: [PATCH 04/26] feat(quizset): add statistics page for each quiz set --- apps/upskii/messages/en.json | 45 +++ apps/upskii/messages/vi.json | 45 +++ .../[wsId]/quiz-sets/[setId]/page.tsx | 113 ++++---- .../quiz-sets/[setId]/statistics/page.tsx | 258 ++++++++++++++++++ 4 files changed, 408 insertions(+), 53 deletions(-) create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json index 1070d7045c..960033dab4 100644 --- a/apps/upskii/messages/en.json +++ b/apps/upskii/messages/en.json @@ -1,4 +1,48 @@ { + "quiz-set-statistics": { + "title": "Quiz Set Statistics", + "description": "View detailed statistics for your quizzes, including average scores, completion rates, and more.", + "average_pass_rate": "Average Pass Rate", + "average_score": "Average Score", + "total_participants": "Total Participants", + "total_quizzes": "Total Quizzes", + "active_quizzes": "Active quizzes in set", + "total_attempts": "Total Attempts", + "accross_all_quizzes": "Across all quizzes", + "individual_quiz_performance": "Individual Quiz Performance", + "back": "Back", + "pass_rate": "Pass Rate", + "active_students": "Active Students", + "unique_participants": "Unique participants", + "last_attempt": "Last Attempt", + "no_quizzes": "No Quiz Data Available", + "no_quizzes_description": "No quiz attempts found for this set. Students haven't started taking quizzes yet." + }, + "home-hero": { + "welcome": "Welcome back, {username}!", + "badge": "Your AI-Enhanced Learning Experience", + "title": "Explore smart tools, interactive lessons, and seamless collaboration", + "cards": { + "courses": { + "title": "Courses", + "description": "Learn the fundamentals and advanced techniques of prompt engineering through step-by-step lessons and practical examples." + }, + "quizzes": { + "title": "Quizzes", + "description": "Test your understanding with interactive quizzes designed to reinforce key concepts and sharpen your prompt design skills." + }, + "challenges": { + "title": "Challenges", + "description": "Take on creative challenges that push your limits and inspire innovative prompt solutions using AI." + }, + "ai-chat": { + "title": "AI Chat", + "description": "Engage in real-time conversations with AI to practice prompt engineering, get instant feedback, and refine your skills." + } + }, + "get-certificate": "Get Your Certificate" + }, +>>>>>>> Stashed changes "score-calculator": { "title": "Score Calculator", "subtitle": "Calculate your submission score based on test results and criteria evaluation", @@ -276,6 +320,7 @@ "events": "Events" }, "common": { + "statistics": "Statistics", "allow_manage_all_challenges": "Allow Manage All Challenges", "name_placeholder": "Enter name", "name": "Name", diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json index d71f83e698..f4f2056e46 100644 --- a/apps/upskii/messages/vi.json +++ b/apps/upskii/messages/vi.json @@ -1,4 +1,48 @@ { + "quiz-set-statistics": { + "title": "Thống Kê", + "description": "Xem thống kê chi tiết về các bài kiểm tra của bạn, bao gồm điểm trung bình, tỷ lệ hoàn thành và nhiều thông tin khác.", + "average_pass_rate": "Tỷ Lệ Đạt Trung Bình", + "average_score": "Điểm Trung Bình", + "total_participants": "Tổng Số Người Tham Gia", + "total_quizzes": "Tổng Số Bài Kiểm Tra", + "active_quizzes": "Bài kiểm tra hiện có", + "total_attempts": "Tổng Số Lượt Làm Bài", + "accross_all_quizzes": "Trên tất cả bài kiểm tra", + "individual_quiz_performance": "Số Liệu Từng Bài Kiểm Tra", + "back": "Quay Lại", + "pass_rate": "Tỷ Lệ Đạt", + "active_students": "Học Viên Đang Hoạt Động", + "unique_participants": "Người tham gia khác nhau", + "last_attempt": "Lần Làm Bài Gần Nhất", + "no_quizzes": "Không Có Dữ Liệu Bài Kiểm Tra", + "no_quizzes_description": "Không tìm thấy lượt làm bài nào cho bộ câu hỏi này. Học viên chưa bắt đầu làm bài kiểm tra." + }, + "home-hero": { + "welcome": "Xin chào, {username}!", + "badge": "Trải nghiệm học tập nâng cao với AI", + "title": "Khám phá các công cụ hiện đại, bài học sinh động và cộng tác dễ dàng", + "cards": { + "courses": { + "title": "Khóa học", + "description": "Nắm vững các kỹ thuật thiết kế prompt từ cơ bản đến nâng cao thông qua bài học từng bước và ví dụ thực tiễn." + }, + "quizzes": { + "title": "Câu hỏi ôn tập", + "description": "Kiểm tra mức độ hiểu biết của bạn với các câu hỏi tương tác giúp củng cố kiến thức và nâng cao kỹ năng thiết kế prompt." + }, + "challenges": { + "title": "Thử thách", + "description": "Tham gia các thử thách sáng tạo để vượt qua giới hạn và khám phá các giải pháp prompt đột phá cùng AI." + }, + "ai-chat": { + "title": "Trò chuyện với AI", + "description": "Luyện tập kỹ năng thiết kế prompt qua các cuộc đối thoại thời gian thực với AI, nhận phản hồi ngay lập tức và cải thiện hiệu quả." + } + }, + "get-certificate": "Nhận chứng chỉ của bạn" + }, +>>>>>>> Stashed changes "score-calculator": { "test_formula": "(Số Bài Đạt / Tổng Số Bài Kiểm Tra) × 10 × Trọng Số", "criteria_formula": "(Tổng Điểm Tiêu Chí / (Tổng Số Tiêu Chí × 10)) × 10 × Trọng Số", @@ -276,6 +320,7 @@ "events": "Sự kiện" }, "common": { + "statistics": "Thống kê", "allow_manage_all_challenges": "Cho phép quản lý tất cả các thử thách", "name_placeholder": "Nhập tên", "name": "Tên", diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx index da04461142..69b3831ecc 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx @@ -1,47 +1,54 @@ -import { getWorkspaceQuizColumns } from './columns'; -import QuizForm from './form'; -import { CustomDataTable } from '@/components/custom-data-table'; -import { createClient } from '@tuturuuu/supabase/next/server'; -import { WorkspaceQuiz } from '@tuturuuu/types/db'; -import FeatureSummary from '@tuturuuu/ui/custom/feature-summary'; -import { Separator } from '@tuturuuu/ui/separator'; -import { getTranslations } from 'next-intl/server'; - +import { CustomDataTable } from "@/components/custom-data-table" +import { createClient } from "@tuturuuu/supabase/next/server" +import type { WorkspaceQuiz } from "@tuturuuu/types/db" +import { Button } from "@tuturuuu/ui/button" +import FeatureSummary from "@tuturuuu/ui/custom/feature-summary" +import { BarChart3 } from "@tuturuuu/ui/icons" +import { Separator } from "@tuturuuu/ui/separator" +import { getTranslations } from "next-intl/server" +import Link from "next/link" +import { getWorkspaceQuizColumns } from "./columns" +import QuizForm from "./form" interface SearchParams { - q?: string; - page?: string; - pageSize?: string; - includedTags?: string | string[]; - excludedTags?: string | string[]; + q?: string + page?: string + pageSize?: string + includedTags?: string | string[] + excludedTags?: string | string[] } interface Props { params: Promise<{ - wsId: string; - setId: string; - }>; - searchParams: Promise; + wsId: string + setId: string + }> + searchParams: Promise } -export default async function WorkspaceQuizzesPage({ - params, - searchParams, -}: Props) { - const t = await getTranslations(); - const { wsId, setId } = await params; +export default async function WorkspaceQuizzesPage({ params, searchParams }: Props) { + const t = await getTranslations() + const { wsId, setId } = await params - const { data, count } = await getData(setId, await searchParams); + const { data, count } = await getData(setId, await searchParams) return ( <> - } - /> +
+ } + /> + +
- ); + ) } async function getData( setId: string, { q, - page = '1', - pageSize = '10', + page = "1", + pageSize = "10", retry = true, - }: { q?: string; page?: string; pageSize?: string; retry?: boolean } = {} + }: { q?: string; page?: string; pageSize?: string; retry?: boolean } = {}, ) { - const supabase = await createClient(); + const supabase = await createClient() const queryBuilder = supabase - .from('quiz_set_quizzes') - .select('...workspace_quizzes(*, quiz_options(*))', { - count: 'exact', + .from("quiz_set_quizzes") + .select("...workspace_quizzes(*, quiz_options(*))", { + count: "exact", }) - .eq('set_id', setId) - .order('created_at', { ascending: false }); + .eq("set_id", setId) + .order("created_at", { ascending: false }) - if (q) queryBuilder.ilike('name', `%${q}%`); + if (q) queryBuilder.ilike("name", `%${q}%`) if (page && pageSize) { - const parsedPage = parseInt(page); - const parsedSize = parseInt(pageSize); - const start = (parsedPage - 1) * parsedSize; - const end = parsedPage * parsedSize; - queryBuilder.range(start, end).limit(parsedSize); + const parsedPage = Number.parseInt(page) + const parsedSize = Number.parseInt(pageSize) + const start = (parsedPage - 1) * parsedSize + const end = parsedPage * parsedSize + queryBuilder.range(start, end).limit(parsedSize) } - const { data, error, count } = await queryBuilder; + const { data, error, count } = await queryBuilder if (error) { - if (!retry) throw error; - return getData(setId, { q, pageSize, retry: false }); + if (!retry) throw error + return getData(setId, { q, pageSize, retry: false }) } - return { data, count } as { data: WorkspaceQuiz[]; count: number }; + return { data, count } as { data: WorkspaceQuiz[]; count: number } } diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx new file mode 100644 index 0000000000..296ce5847a --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx @@ -0,0 +1,258 @@ +import { createClient } from "@tuturuuu/supabase/next/server" +import { Button } from "@tuturuuu/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@tuturuuu/ui/card" +import { ArrowLeft, BarChart3, Target, TrendingUp, Users } from "@tuturuuu/ui/icons" +import { Separator } from "@tuturuuu/ui/separator" +import { getTranslations } from "next-intl/server" +import Link from "next/link" + +interface QuizStats { + id: string + question: string + totalAttempts: number + uniqueStudents: number + averageScore: number + passRate: number + lastAttempt: string | null +} + +interface Props { + params: Promise<{ + wsId: string + setId: string + }> +} + +export default async function QuizSetStatisticsPage({ params }: Props) { + const t = await getTranslations("quiz-set-statistics") + const { wsId, setId } = await params + + const stats = await getQuizSetStatistics(setId) + + const overallStats = { + totalQuizzes: stats.length, + totalAttempts: stats.reduce((sum, s) => sum + s.totalAttempts, 0), + totalStudents: new Set(stats.flatMap((s) => Array(s.uniqueStudents).fill(0))).size, + averagePassRate: stats.length > 0 ? stats.reduce((sum, s) => sum + s.passRate, 0) / stats.length : 0, + } + + return ( +
+ {/* Header */} +
+ +
+

+ + {t("title")} +

+

Comprehensive analytics for all quizzes in this set

+
+
+ + + + {/* Overall Statistics Cards */} +
+ + + + + {t("total_quizzes")} + + + +
{overallStats.totalQuizzes}
+

{t("active_quizzes")}

+
+
+ + + + + + {t("total_attempts")} + + + +
{overallStats.totalAttempts}
+

{t("accross_all_quizzes")}

+
+
+ + + + + + {t("active_students")} + + + +
{overallStats.totalStudents}
+

{t("unique_participants")}

+
+
+ + + + + + {t("average_pass_rate")} + + + +
{overallStats.averagePassRate.toFixed(1)}%
+

70% passing threshold

+
+
+
+ + + + {/* Individual Quiz Performance */} +
+
+

{t("individual_quiz_performance")}

+

{stats.length} quizzes analyzed

+
+ + {stats.length === 0 ? ( + + + +

{t("no_quizzes")}

+

+ {t("no_quizzes_description")} +

+
+
+ ) : ( +
+ {stats.map((quiz, index) => ( + + +
+
+ + Quiz #{index + 1}: {quiz.question} + + Quiz ID: {quiz.id} +
+
+
= 80 + ? "bg-green-100 text-green-800" + : quiz.passRate >= 60 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {quiz.passRate >= 80 ? "Excellent" : quiz.passRate >= 60 ? "Good" : "Needs Attention"} +
+
+
+
+ +
+
+
{quiz.totalAttempts}
+
{t("total_attempts")}
+
+
+
{quiz.uniqueStudents}
+
{t("unique_participants")}
+
+
+
{quiz.averageScore}%
+
{t("average_score")}
+
+
+
= 70 + ? "text-green-600" + : quiz.passRate >= 50 + ? "text-yellow-600" + : "text-red-600" + }`} + > + {quiz.passRate}% +
+
{t("pass_rate")}
+
+
+
+ {quiz.lastAttempt ? new Date(quiz.lastAttempt).toLocaleDateString() : "Never"} +
+
{t("last_attempt")}
+
+
+
+
+ ))} +
+ )} +
+
+ ) +} + +async function getQuizSetStatistics(setId: string): Promise { + const supabase = await createClient() + + try { + // Get all quizzes in this set + const { data: quizzes } = await supabase + .from("quiz_set_quizzes") + .select("...workspace_quizzes(id, question)") + .eq("set_id", setId) + + if (!quizzes) return [] + + const quizStats: QuizStats[] = [] + + for (const quiz of quizzes) { + if (!quiz.id) continue + + // Get quiz attempts and scores + const { data: attempts } = await supabase + .from("workspace_quiz_attempts") + .select("user_id, score, created_at") + .eq("quiz_id", quiz.id) + + if (!attempts) continue + + const totalAttempts = attempts.length + const uniqueStudents = new Set(attempts.map((a) => a.user_id)).size + const averageScore = + attempts.length > 0 ? attempts.reduce((sum, a) => sum + (a.score || 0), 0) / attempts.length : 0 + const passRate = + attempts.length > 0 ? (attempts.filter((a) => (a.score || 0) >= 70).length / attempts.length) * 100 : 0 + const lastAttempt = + attempts.length > 0 + ? attempts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0].created_at + : null + + quizStats.push({ + id: quiz.id, + question: quiz.question || "Untitled Quiz", + totalAttempts, + uniqueStudents, + averageScore: Math.round(averageScore * 100) / 100, + passRate: Math.round(passRate * 100) / 100, + lastAttempt, + }) + } + + return quizStats + } catch (error) { + console.error("Error fetching quiz statistics:", error) + return [] + } +} From 96b89a011dbd52dfd55342101737cae2c463e538 Mon Sep 17 00:00:00 2001 From: Nhung Date: Wed, 4 Jun 2025 19:44:28 +0700 Subject: [PATCH 05/26] feat (UI - Quizzes); display Quizzes following Quiz-sets, add post method to quiz-sets if generate quizzes by AI --- .../[courseId]/modules/[moduleId]/page.tsx | 69 ++++++++- .../[moduleId]/quizzes/client-quizzes.tsx | 132 ++++++++++++++---- .../modules/[moduleId]/quizzes/page.tsx | 110 +++++++++++++-- .../[wsId]/quizzes/mock/quizzes-mock-data.ts | 34 ----- 4 files changed, 269 insertions(+), 76 deletions(-) delete mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quizzes/mock/quizzes-mock-data.ts diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx index cfc6a9ca67..b7adc7b434 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx @@ -40,7 +40,7 @@ export default async function UserGroupDetailsPage({ params }: Props) { const storagePath = `${wsId}/courses/${courseId}/modules/${moduleId}/resources/`; const resources = await getResources({ path: storagePath }); const flashcards = await getFlashcards(moduleId); - const quizzes = await getQuizzes(moduleId); + const quizSets = await getQuizzes(moduleId); const cards = flashcards.map((fc) => ({ id: fc.id, @@ -134,12 +134,12 @@ export default async function UserGroupDetailsPage({ params }: Props) { title={t('ws-quizzes.plural')} icon={} content={ - quizzes && quizzes.length > 0 ? ( + quizSets && quizSets.length > 0 ? (
@@ -229,20 +229,77 @@ const getFlashcards = async (moduleId: string) => { }; const getQuizzes = async (moduleId: string) => { + // created_at: "2025-05-29T08:12:16.653395+00:00" + // id: "426d031f-2dc4-4370-972d-756af04288fb" + // question: "What are the main building blocks of a NestJS application?" + // quiz_options: (4) [{…}, {…}, {…}, {…}] + // ws_id: "00000000-0000-0000-0000-000000000000" const supabase = await createClient(); const { data, error } = await supabase .from('course_module_quizzes') - .select('...workspace_quizzes(*, quiz_options(*))') + .select( + ` + quiz_id, + workspace_quizzes ( + id, + question, + created_at, + ws_id, + quiz_options(*), + quiz_set_quizzes( + set_id, + workspace_quiz_sets(name) + ) + ) + ` + ) .eq('module_id', moduleId); if (error) { - console.error('error', error); + console.error('Error fetching grouped quizzes:', error); + return []; } - return data || []; + const grouped = new Map< + string, + { + setId: string; + setName: string; + quizzes: any[]; + } + >(); + + for (const cmq of data || []) { + const quiz = cmq.workspace_quizzes; + const setData = quiz?.quiz_set_quizzes?.[0]; // assume only one set + + if (!quiz || !setData) continue; + + const setId = setData.set_id; + const setName = setData.workspace_quiz_sets?.name || 'Unnamed Set'; + + if (!grouped.has(setId)) { + grouped.set(setId, { + setId, + setName, + quizzes: [], + }); + } + + grouped.get(setId)!.quizzes.push({ + id: quiz.id, + question: quiz.question, + quiz_options: quiz.quiz_options, + created_at: quiz.created_at, + ws_id: quiz.ws_id, + }); + } + + return Array.from(grouped.values()); }; + async function getResources({ path }: { path: string }) { const supabase = await createDynamicClient(); diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx index 116e589ab9..e6aa15b3ec 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx @@ -1,6 +1,7 @@ 'use client'; import QuizForm from '../../../../../quizzes/form'; +import { RenderedQuizzesSets } from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page'; import { createClient } from '@tuturuuu/supabase/next/client'; import { AlertDialog, @@ -14,46 +15,61 @@ import { AlertDialogTrigger, } from '@tuturuuu/ui/alert-dialog'; import { Button } from '@tuturuuu/ui/button'; -import { Pencil, Trash, X } from '@tuturuuu/ui/icons'; +import { LucideBubbles, Pencil, Trash, X } from '@tuturuuu/ui/icons'; import { Separator } from '@tuturuuu/ui/separator'; import { cn } from '@tuturuuu/utils/format'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; +interface QuizzesListProps { + quizzes: RenderedQuizzesSets['quizzes'] | QuizzesListType; + previewMode?: boolean; + editingQuizId: string | null; + setEditingQuizId: (id: string | null) => void; + wsId: string; + moduleId: string; + onDelete: (id: string) => void; + idx?: number; +} + +type QuizzesListType = Array< + | { + created_at?: string; + id?: string; + question?: string; + ws_id?: string; + quiz_options?: ( + | { + created_at?: string; + id?: string; + is_correct?: boolean; + explanation?: string | null; + points?: number | null; + quiz_id?: string; + value?: string; + } + | undefined + )[]; + } + | undefined +>; + export default function ClientQuizzes({ wsId, moduleId, - quizzes, + quizSets, + quizzes = [], previewMode = false, }: { wsId: string; moduleId: string; - quizzes: Array< - | { - created_at?: string; - id?: string; - question?: string; - ws_id?: string; - quiz_options?: ( - | { - created_at?: string; - id?: string; - is_correct?: boolean; - explanation?: string | null; - points?: number | null; - quiz_id?: string; - value?: string; - } - | undefined - )[]; - } - | undefined - >; + quizSets?: RenderedQuizzesSets[]; + quizzes?: QuizzesListType; previewMode?: boolean; }) { const router = useRouter(); - const t = useTranslations(); + const supabase = createClient(); const [editingQuizId, setEditingQuizId] = useState(null); @@ -71,9 +87,73 @@ export default function ClientQuizzes({ router.refresh(); }; + if (quizSets) { + return ( + <> + {quizSets.map((set, idx) => ( +
+

+ + {set.setName} +

+
+ + +
+
+ ))} + + ); + } + if (quizzes && quizzes.length > 0) { + return ( + + ); + } +} + +const QuizzesList = ({ + quizzes, + previewMode, + editingQuizId, + setEditingQuizId, + wsId, + moduleId, + onDelete, + idx = 0, +}: QuizzesListProps) => { + const t = useTranslations(); + if (!quizzes || quizzes.length === 0) { + return ( +
+

{t('ws-quizzes.no_quizzes')}

+
+ ); + } return ( <> - {quizzes.map((quiz, idx) => ( + {quizzes.map((quiz) => (
); -} +}; diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx index f5bb102e7c..27b1cc1c2c 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx @@ -4,7 +4,6 @@ import ClientQuizzes from './client-quizzes'; import { createClient } from '@tuturuuu/supabase/next/server'; import FeatureSummary from '@tuturuuu/ui/custom/feature-summary'; import { ListTodo } from '@tuturuuu/ui/icons'; -import { Separator } from '@tuturuuu/ui/separator'; import { getTranslations } from 'next-intl/server'; interface Props { @@ -15,12 +14,37 @@ interface Props { }>; } +export interface RenderedQuizzesSets { + setId: string; + setName: string; + quizzes: + | Array<{ + id: string; + question: string; + quiz_options?: ( + | { + created_at?: string; + id?: string; + is_correct?: boolean; + explanation?: string | null; + points?: number | null; + quiz_id?: string; + value?: string; + } + | undefined + )[]; + created_at?: string; + ws_id?: string; + }> + | undefined; +} + export default async function ModuleQuizzesPage({ params }: Props) { const { wsId, moduleId } = await params; const t = await getTranslations(); - const quizzes = await getQuizzes(moduleId); + const quizSets = await getQuizzes(moduleId); + console.log('Quiz Sets:', quizSets); const moduleName = await getModuleName(moduleId); - return (
} /> -
- {quizzes && quizzes.length > 0 && ( +
+ +
+ +
+
+ {/*
+ {quizSets && quizSets.length > 0 && ( <> - + )} @@ -50,24 +84,80 @@ export default async function ModuleQuizzesPage({ params }: Props) {
-
+
*/}
); } const getQuizzes = async (moduleId: string) => { + // created_at: "2025-05-29T08:12:16.653395+00:00" + // id: "426d031f-2dc4-4370-972d-756af04288fb" + // question: "What are the main building blocks of a NestJS application?" + // quiz_options: (4) [{…}, {…}, {…}, {…}] + // ws_id: "00000000-0000-0000-0000-000000000000" const supabase = await createClient(); const { data, error } = await supabase .from('course_module_quizzes') - .select('...workspace_quizzes(*, quiz_options(*))') + .select( + ` + quiz_id, + workspace_quizzes ( + id, + question, + created_at, + ws_id, + quiz_options(*), + quiz_set_quizzes( + set_id, + workspace_quiz_sets(name) + ) + ) + ` + ) .eq('module_id', moduleId); if (error) { - console.error('error', error); + console.error('Error fetching grouped quizzes:', error); + return []; + } + + const grouped = new Map< + string, + { + setId: string; + setName: string; + quizzes: any[]; + } + >(); + + for (const cmq of data || []) { + const quiz = cmq.workspace_quizzes; + const setData = quiz?.quiz_set_quizzes?.[0]; // assume only one set + + if (!quiz || !setData) continue; + + const setId = setData.set_id; + const setName = setData.workspace_quiz_sets?.name || 'Unnamed Set'; + + if (!grouped.has(setId)) { + grouped.set(setId, { + setId, + setName, + quizzes: [], + }); + } + + grouped.get(setId)!.quizzes.push({ + id: quiz.id, + question: quiz.question, + quiz_options: quiz.quiz_options, + created_at: quiz.created_at, + ws_id: quiz.ws_id, + }); } - return data || []; + return Array.from(grouped.values()); }; const getModuleName = async (moduleId: string) => { diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quizzes/mock/quizzes-mock-data.ts b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quizzes/mock/quizzes-mock-data.ts deleted file mode 100644 index ecbfc86aad..0000000000 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quizzes/mock/quizzes-mock-data.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { WorkspaceQuiz } from '@tuturuuu/types/db'; - -export const mockQuizzes: WorkspaceQuiz[] = [ - { - created_at: '2023-10-01T12:00:00Z', - id: '1', - question: 'What is the capital of France?', - ws_id: 'ws_1', - }, - { - created_at: '2023-10-02T12:00:00Z', - id: '2', - question: 'What is the largest planet in our solar system?', - ws_id: 'ws_1', - }, - { - created_at: '2023-10-03T12:00:00Z', - id: '3', - question: 'What is the chemical symbol for gold?', - ws_id: 'ws_1', - }, - { - created_at: '2023-10-04T12:00:00Z', - id: '4', - question: 'What is the speed of light?', - ws_id: 'ws_1', - }, - { - created_at: '2023-10-05T12:00:00Z', - id: '5', - question: 'What is the largest mammal in the world?', - ws_id: 'ws_1', - }, -]; From fc1939df936e6f5e92614c89a9bdc000d0327805 Mon Sep 17 00:00:00 2001 From: Tran Phan Trong Phuc <117657788+Henry7482@users.noreply.github.com> Date: Wed, 4 Jun 2025 23:06:34 +0700 Subject: [PATCH 06/26] fix: move mutton for statistics --- apps/upskii/messages/en.json | 1 - apps/upskii/messages/vi.json | 1 - .../[wsId]/quiz-sets/[setId]/layout.tsx | 13 ++++++++++ .../[wsId]/quiz-sets/[setId]/page.tsx | 24 +++++++------------ 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json index 960033dab4..1c5f310996 100644 --- a/apps/upskii/messages/en.json +++ b/apps/upskii/messages/en.json @@ -42,7 +42,6 @@ }, "get-certificate": "Get Your Certificate" }, ->>>>>>> Stashed changes "score-calculator": { "title": "Score Calculator", "subtitle": "Calculate your submission score based on test results and criteria evaluation", diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json index f4f2056e46..ef30560551 100644 --- a/apps/upskii/messages/vi.json +++ b/apps/upskii/messages/vi.json @@ -42,7 +42,6 @@ }, "get-certificate": "Nhận chứng chỉ của bạn" }, ->>>>>>> Stashed changes "score-calculator": { "test_formula": "(Số Bài Đạt / Tổng Số Bài Kiểm Tra) × 10 × Trọng Số", "criteria_formula": "(Tổng Điểm Tiêu Chí / (Tổng Số Tiêu Chí × 10)) × 10 × Trọng Số", diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/layout.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/layout.tsx index 7d5d676634..bb5768eb48 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/layout.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/layout.tsx @@ -7,6 +7,11 @@ import { Separator } from '@tuturuuu/ui/separator'; import { getTranslations } from 'next-intl/server'; import { notFound } from 'next/navigation'; import { ReactNode } from 'react'; +import { Button } from "@tuturuuu/ui/button" +import Link from "next/link" +import { BarChart3 } from "@tuturuuu/ui/icons" + + interface Props { children: ReactNode; @@ -64,6 +69,14 @@ export default async function QuizSetDetailsLayout({
} + action={ + + } /> {children} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx index 69b3831ecc..fa595e8be0 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx @@ -33,22 +33,14 @@ export default async function WorkspaceQuizzesPage({ params, searchParams }: Pro return ( <> -
- } - /> - -
+ } + /> Date: Thu, 5 Jun 2025 20:33:47 +0700 Subject: [PATCH 07/26] feat(Taking Quiz): add taking page including backend routes, add temp result page --- .../20250604125645_new_migration.sql | 144 +++++ .../20250605064241_new_migration.sql | 20 + apps/upskii/messages/en.json | 31 +- apps/upskii/messages/vi.json | 31 +- .../[wsId]/quiz-sets/[setId]/result/page.tsx | 195 +++++++ .../[wsId]/quiz-sets/[setId]/take/page.tsx | 541 ++++++++++++++++++ .../[setId]/take/quiz-status-sidebar.tsx | 125 ++++ .../[setId]/take/time-elapsed-status.tsx | 62 ++ .../[wsId]/quiz-sets/[setId]/results/route.ts | 214 +++++++ .../[wsId]/quiz-sets/[setId]/submit/route.ts | 208 +++++++ .../[wsId]/quiz-sets/[setId]/take/route.ts | 146 +++++ packages/types/src/supabase.ts | 132 +++++ 12 files changed, 1847 insertions(+), 2 deletions(-) create mode 100644 apps/db/supabase/migrations/20250604125645_new_migration.sql create mode 100644 apps/db/supabase/migrations/20250605064241_new_migration.sql create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status.tsx create mode 100644 apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts create mode 100644 apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts create mode 100644 apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts diff --git a/apps/db/supabase/migrations/20250604125645_new_migration.sql b/apps/db/supabase/migrations/20250604125645_new_migration.sql new file mode 100644 index 0000000000..6a01712a70 --- /dev/null +++ b/apps/db/supabase/migrations/20250604125645_new_migration.sql @@ -0,0 +1,144 @@ +create table "public"."workspace_quiz_attempt_answers" ( + "id" uuid not null default gen_random_uuid(), + "attempt_id" uuid not null, + "quiz_id" uuid not null, + "selected_option_id" uuid not null, + "is_correct" boolean not null, + "score_awarded" real not null +); + + +create table "public"."workspace_quiz_attempts" ( + "id" uuid not null default gen_random_uuid(), + "user_id" uuid not null, + "set_id" uuid not null, + "attempt_number" integer not null, + "started_at" timestamp with time zone not null default now(), + "completed_at" timestamp with time zone, + "total_score" real +); + + +alter table "public"."workspace_quiz_sets" add column "attempt_limit" integer; + +alter table "public"."workspace_quiz_sets" add column "time_limit_minutes" integer; + +alter table "public"."workspace_quizzes" add column "score" integer not null default 1; + +CREATE UNIQUE INDEX workspace_quiz_attempts_pkey ON public.workspace_quiz_attempts USING btree (id); + +CREATE UNIQUE INDEX wq_answer_pkey ON public.workspace_quiz_attempt_answers USING btree (id); + +CREATE UNIQUE INDEX wq_attempts_unique ON public.workspace_quiz_attempts USING btree (user_id, set_id, attempt_number); + +alter table "public"."workspace_quiz_attempt_answers" add constraint "wq_answer_pkey" PRIMARY KEY using index "wq_answer_pkey"; + +alter table "public"."workspace_quiz_attempts" add constraint "workspace_quiz_attempts_pkey" PRIMARY KEY using index "workspace_quiz_attempts_pkey"; + +alter table "public"."workspace_quiz_attempt_answers" add constraint "wq_answer_attempt_fkey" FOREIGN KEY (attempt_id) REFERENCES workspace_quiz_attempts(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."workspace_quiz_attempt_answers" validate constraint "wq_answer_attempt_fkey"; + +alter table "public"."workspace_quiz_attempt_answers" add constraint "wq_answer_option_fkey" FOREIGN KEY (selected_option_id) REFERENCES quiz_options(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."workspace_quiz_attempt_answers" validate constraint "wq_answer_option_fkey"; + +alter table "public"."workspace_quiz_attempt_answers" add constraint "wq_answer_quiz_fkey" FOREIGN KEY (quiz_id) REFERENCES workspace_quizzes(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."workspace_quiz_attempt_answers" validate constraint "wq_answer_quiz_fkey"; + +alter table "public"."workspace_quiz_attempts" add constraint "wq_attempts_set_fkey" FOREIGN KEY (set_id) REFERENCES workspace_quiz_sets(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."workspace_quiz_attempts" validate constraint "wq_attempts_set_fkey"; + +alter table "public"."workspace_quiz_attempts" add constraint "wq_attempts_unique" UNIQUE using index "wq_attempts_unique"; + +alter table "public"."workspace_quiz_attempts" add constraint "wq_attempts_user_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."workspace_quiz_attempts" validate constraint "wq_attempts_user_fkey"; + +grant delete on table "public"."workspace_quiz_attempt_answers" to "anon"; + +grant insert on table "public"."workspace_quiz_attempt_answers" to "anon"; + +grant references on table "public"."workspace_quiz_attempt_answers" to "anon"; + +grant select on table "public"."workspace_quiz_attempt_answers" to "anon"; + +grant trigger on table "public"."workspace_quiz_attempt_answers" to "anon"; + +grant truncate on table "public"."workspace_quiz_attempt_answers" to "anon"; + +grant update on table "public"."workspace_quiz_attempt_answers" to "anon"; + +grant delete on table "public"."workspace_quiz_attempt_answers" to "authenticated"; + +grant insert on table "public"."workspace_quiz_attempt_answers" to "authenticated"; + +grant references on table "public"."workspace_quiz_attempt_answers" to "authenticated"; + +grant select on table "public"."workspace_quiz_attempt_answers" to "authenticated"; + +grant trigger on table "public"."workspace_quiz_attempt_answers" to "authenticated"; + +grant truncate on table "public"."workspace_quiz_attempt_answers" to "authenticated"; + +grant update on table "public"."workspace_quiz_attempt_answers" to "authenticated"; + +grant delete on table "public"."workspace_quiz_attempt_answers" to "service_role"; + +grant insert on table "public"."workspace_quiz_attempt_answers" to "service_role"; + +grant references on table "public"."workspace_quiz_attempt_answers" to "service_role"; + +grant select on table "public"."workspace_quiz_attempt_answers" to "service_role"; + +grant trigger on table "public"."workspace_quiz_attempt_answers" to "service_role"; + +grant truncate on table "public"."workspace_quiz_attempt_answers" to "service_role"; + +grant update on table "public"."workspace_quiz_attempt_answers" to "service_role"; + +grant delete on table "public"."workspace_quiz_attempts" to "anon"; + +grant insert on table "public"."workspace_quiz_attempts" to "anon"; + +grant references on table "public"."workspace_quiz_attempts" to "anon"; + +grant select on table "public"."workspace_quiz_attempts" to "anon"; + +grant trigger on table "public"."workspace_quiz_attempts" to "anon"; + +grant truncate on table "public"."workspace_quiz_attempts" to "anon"; + +grant update on table "public"."workspace_quiz_attempts" to "anon"; + +grant delete on table "public"."workspace_quiz_attempts" to "authenticated"; + +grant insert on table "public"."workspace_quiz_attempts" to "authenticated"; + +grant references on table "public"."workspace_quiz_attempts" to "authenticated"; + +grant select on table "public"."workspace_quiz_attempts" to "authenticated"; + +grant trigger on table "public"."workspace_quiz_attempts" to "authenticated"; + +grant truncate on table "public"."workspace_quiz_attempts" to "authenticated"; + +grant update on table "public"."workspace_quiz_attempts" to "authenticated"; + +grant delete on table "public"."workspace_quiz_attempts" to "service_role"; + +grant insert on table "public"."workspace_quiz_attempts" to "service_role"; + +grant references on table "public"."workspace_quiz_attempts" to "service_role"; + +grant select on table "public"."workspace_quiz_attempts" to "service_role"; + +grant trigger on table "public"."workspace_quiz_attempts" to "service_role"; + +grant truncate on table "public"."workspace_quiz_attempts" to "service_role"; + +grant update on table "public"."workspace_quiz_attempts" to "service_role"; + + diff --git a/apps/db/supabase/migrations/20250605064241_new_migration.sql b/apps/db/supabase/migrations/20250605064241_new_migration.sql new file mode 100644 index 0000000000..c158ed5dc2 --- /dev/null +++ b/apps/db/supabase/migrations/20250605064241_new_migration.sql @@ -0,0 +1,20 @@ +alter table "public"."workspace_quiz_sets" add column "allow_view_results" boolean not null default true; + +alter table "public"."workspace_quiz_sets" add column "release_at" timestamp with time zone; + +alter table "public"."workspace_quiz_sets" add column "release_points_immediately" boolean not null default true; + +set check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.sum_quiz_scores(p_set_id uuid) + RETURNS TABLE(sum numeric) + LANGUAGE sql +AS $function$ + SELECT COALESCE(SUM(wq.score), 0)::numeric + FROM quiz_set_quizzes qsq + JOIN workspace_quizzes wq ON qsq.quiz_id = wq.id + WHERE qsq.set_id = p_set_id; +$function$ +; + + diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json index 1070d7045c..31ef976190 100644 --- a/apps/upskii/messages/en.json +++ b/apps/upskii/messages/en.json @@ -3780,9 +3780,38 @@ "edit": "Edit quiz", "question": "Question", "answer": "Answer", + "question_status_title": "Question Progress", + "answered_status_short": "answered", + "quiz_progress_label": "Quiz Progress", + "question_navigation_label": "Question Navigation", + "jump_to_question_aria": "Question {{number}}, {{status}}", + "answered_state": "Answered", + "unanswered_state": "Unanswered", + "answered_icon": "✓", + "unanswered_icon": "⚪", + "time_elapsed": "Time Elapsed", + "hidden_time_elapsed": "Time Elapsed (hidden)", + "hidden_time_remaining": "Hidden Timer", "edit_description": "Edit an existing quiz", "generation_error": "An error has occured when generating quizzes.", - "generation_accepted": "AI-generated quizzes are accepted!" + "generation_accepted": "AI-generated quizzes are accepted!", + "please_answer_all": "Please answer all questions.", + "loading": "Loading...", + "results": "Results", + "attempt": "Attempt", + "of": "of", + "unlimited": "Unlimited Attempts", + "score": "Score", + "done": "Done", + "attempts": "Attempts", + "time_limit": "Time Limit", + "no_time_limit": "No Time Limit", + "minutes": "Minutes", + "take_quiz": "Take Quiz", + "time_remaining": "Time Remaining", + "points": "Points", + "submitting": "Submitting...", + "submit": "Submit" }, "ws-reports": { "report": "Report", diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json index d71f83e698..b0d17afec6 100644 --- a/apps/upskii/messages/vi.json +++ b/apps/upskii/messages/vi.json @@ -3780,9 +3780,38 @@ "edit": "Chỉnh sửa bộ trắc nghiệm", "question": "Câu hỏi", "answer": "Câu trả lời", + "question_status_title": "Tiến độ câu hỏi", + "answered_status_short": "đã trả lời", + "quiz_progress_label": "Tiến độ bài kiểm tra", + "question_navigation_label": "Điều hướng câu hỏi", + "jump_to_question_aria": "Câu hỏi {{number}}, {{status}}", + "answered_state": "Đã trả lời", + "unanswered_state": "Chưa trả lời", + "answered_icon": "✓", + "unanswered_icon": "⚪", + "time_elapsed": "Đã trôi qua", + "hidden_time_elapsed": "Đã ẩn thời gian", + "hidden_time_remaining": "Ẩn đếm ngược", "edit_description": "Chỉnh sửa bài kiểm tra hiện có", "generation_error": "Đã xảy ra lỗi khi tạo bộ câu hỏi.", - "generation_accepted": "Các bộ câu hỏi do AI tạo ra đã được chấp nhận!" + "generation_accepted": "Các bộ câu hỏi do AI tạo ra đã được chấp nhận!", + "please_answer_all": "Vui lòng trả lời tất cả các câu hỏi.", + "loading": "Đang tải...", + "results": "Kết quả", + "attempt": "Lần thử", + "of": "của", + "unlimited": "Không giới hạn số lần thi", + "score": "Điểm số", + "done": "Hoàn thành", + "attempts": "Số lần thử", + "time_limit": "Giới hạn thời gian", + "no_time_limit": "Không giới hạn thời gian", + "minutes": "Phút", + "take_quiz": "Làm bài kiểm tra", + "time_remaining": "Thời gian còn lại", + "points": "Điểm", + "submitting": "Đang gửi...", + "submit": "Nộp bài" }, "ws-reports": { "report": "Báo cáo", diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx new file mode 100644 index 0000000000..d37efdd9b2 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx @@ -0,0 +1,195 @@ +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/results/page.tsx +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { Button } from '@tuturuuu/ui/button'; + +type AttemptAnswer = { + quizId: string; + question: string; + selectedOption: string | null; // now can be null + correctOption: string; + isCorrect: boolean; + scoreAwarded: number; +}; + +type AttemptDTO = { + attemptId: string; + attemptNumber: number; + totalScore: number; + maxPossibleScore: number; + startedAt: string; + completedAt: string | null; + answers: AttemptAnswer[]; +}; + +export default function ViewResults({ + params, +}: { + params: { + wsId: string; + courseId: string; + moduleId: string; + setId: string; + }; +}) { + const { wsId, courseId, moduleId, setId } = params; + const t = useTranslations(); + const router = useRouter(); + + const [loading, setLoading] = useState(true); + const [errorMsg, setErrorMsg] = useState(null); + const [attempts, setAttempts] = useState(null); + + useEffect(() => { + async function fetchResults() { + setLoading(true); + try { + const res = await fetch( + `/api/v1/workspaces/${wsId}/quiz-sets/${setId}/results` + ); + const json: { attempts?: AttemptDTO[]; error?: string } = + await res.json(); + + if (!res.ok) { + setErrorMsg(json.error || 'Error loading results'); + setLoading(false); + return; + } + setAttempts(json.attempts || []); + } catch { + setErrorMsg('Network error loading results'); + } finally { + setLoading(false); + } + } + fetchResults(); + }, [wsId, setId]); + + if (loading) { + return ( +

+ {t('ws-quizzes.loading') || 'Loading results...'} +

+ ); + } + if (errorMsg) { + return ( +
+

{errorMsg}

+ +
+ ); + } + if (!attempts || attempts.length === 0) { + return ( +
+

{t('ws-quizzes.no_attempts_found') || 'No attempts found.'}

+ +
+ ); + } + + return ( +
+

+ {t('ws-quizzes.past_attempts') || 'Past Attempts'} +

+ + {attempts.map((att) => ( +
+
+

+ {t('ws-quizzes.attempt')} #{att.attemptNumber} +

+

+ {t('ws-quizzes.scored') || 'Scored'}:{' '} + {att.totalScore} / {att.maxPossibleScore} +

+
+

+ {t('ws-quizzes.started_at') || 'Started at'}:{' '} + {new Date(att.startedAt).toLocaleString()} + {att.completedAt && ( + <> + {' | '} + {t('ws-quizzes.completed_at') || 'Completed at'}:{' '} + {new Date(att.completedAt).toLocaleString()} + + )} +

+ + + + + + + + + + + + + {att.answers.map((ans, idx) => ( + + + + + + + + ))} + +
+ {t('ws-quizzes.#') || '#'} + + {t('ws-quizzes.question') || 'Question'} + + {t('ws-quizzes.your_answer') || 'Your Answer'} + + {t('ws-quizzes.correct_answer') || 'Correct Answer'} + + {t('ws-quizzes.points') || 'Points'} +
{idx + 1}{ans.question} + {ans.selectedOption === null + ? t('ws-quizzes.no_answer') || 'No Answer' + : ans.selectedOption} + {ans.correctOption} + {ans.scoreAwarded} +
+
+ ))} + + +
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx new file mode 100644 index 0000000000..eb2d06f110 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx @@ -0,0 +1,541 @@ +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx +'use client'; + +import QuizStatusSidebar, { + Question, +} from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar'; +import TimeElapsedStatus from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status'; +import { Button } from '@tuturuuu/ui/button'; +import { ListCheck } from '@tuturuuu/ui/icons'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; + +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + +// ─── TYPES ───────────────────────────────────────────────────────────────────── + +type TakeResponse = { + setId: string; + setName: string; + timeLimitMinutes: number | null; + attemptLimit: number | null; + attemptsSoFar: number; + allowViewResults: boolean; + questions: Question[]; +}; + +type SubmitResult = { + attemptId: string; + attemptNumber: number; + totalScore: number; + maxPossibleScore: number; +}; + +// ─── COMPONENT ───────────────────────────────────────────────────────────────── + +export default function TakeQuiz({ + params, +}: { + params: { + wsId: string; + courseId: string; + moduleId: string; + setId: string; + }; +}) { + const { wsId, courseId, moduleId, setId } = params; + const t = useTranslations(); + const router = useRouter(); + + // ─── STATE ─────────────────────────────────────────────────────────────────── + // Sidebar visibility (mobile only) + const [sidebarVisible, setSidebarVisible] = useState(false); + + // Metadata loading / error + const [loadingMeta, setLoadingMeta] = useState(true); + const [metaError, setMetaError] = useState(null); + + // Fetched quiz metadata (including questions, time limit, attempt counts, allowViewResults) + const [quizMeta, setQuizMeta] = useState(null); + + // Whether the student has clicked “Take Quiz” or resumed from localStorage + const [hasStarted, setHasStarted] = useState(false); + + // Time state: if timeLimitMinutes != null, this is “seconds left” (countdown). + // If timeLimitMinutes == null, this is “seconds elapsed” (count-up). + const [timeLeft, setTimeLeft] = useState(null); + const timerRef = useRef(null); + + // Selected answers (quizId → selectedOptionId) + const [selectedAnswers, setSelectedAnswers] = useState< + Record + >({}); + + // Submission state + const [submitting, setSubmitting] = useState(false); + const [submitResult, setSubmitResult] = useState(null); + const [submitError, setSubmitError] = useState(null); + + // ─── HELPERS ───────────────────────────────────────────────────────────────── + + const STORAGE_KEY = `quiz_start_${setId}`; + + // totalSeconds: if timeLimitMinutes is null → null, else minutes*60 + const totalSeconds = quizMeta?.timeLimitMinutes + ? quizMeta.timeLimitMinutes * 60 + : null; + + // Remove stored start time when done + const clearStartTimestamp = () => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + }; + + // Elapsed seconds since a given timestamp + const computeElapsedSeconds = (startTs: number) => { + const now = Date.now(); + return Math.floor((now - startTs) / 1000); + }; + + // Build submission payload (possibly empty array if unanswered) + const buildSubmissionPayload = () => ({ + answers: Object.entries(selectedAnswers).map(([quizId, optionId]) => ({ + quizId, + selectedOptionId: optionId, + })), + }); + + // ─── FETCH METADATA ON MOUNT ───────────────────────────────────────────────── + + useEffect(() => { + async function fetchMeta() { + setLoadingMeta(true); + try { + const res = await fetch( + `/api/v1/workspaces/${wsId}/quiz-sets/${setId}/take` + ); + const json: TakeResponse | { error: string } = await res.json(); + + if (!res.ok) { + setMetaError( + (json as any).error || 'Unknown error loading quiz metadata' + ); + setLoadingMeta(false); + return; + } + + setQuizMeta(json as TakeResponse); + + // Check localStorage for a prior start timestamp + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && totalSeconds !== undefined) { + const startTs = parseInt(stored, 10); + if (!isNaN(startTs)) { + if (totalSeconds !== null) { + // countdown scenario + const elapsed = computeElapsedSeconds(startTs); + if (elapsed >= totalSeconds) { + // expired already → auto‐submit + setHasStarted(true); + setTimeLeft(0); + } else { + setHasStarted(true); + setTimeLeft(totalSeconds - elapsed); + } + } else { + // no‐limit scenario → count up from elapsed + const elapsed = computeElapsedSeconds(startTs); + setHasStarted(true); + setTimeLeft(elapsed); + } + } + } + } catch { + setMetaError('Network error loading quiz metadata'); + } finally { + setLoadingMeta(false); + } + } + + fetchMeta(); + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + // Note: totalSeconds is derived from quizMeta, so on first mount it's undefined; we only want this effect once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setId]); + + // ─── START/RESUME TIMER ONCE `hasStarted` CHANGES ───────────────────────────── + + useEffect(() => { + if (!hasStarted || quizMeta === null) return; + + // If countdown ended immediately (timeLeft===0), do auto‐submit: + if (totalSeconds !== null && timeLeft === 0) { + handleSubmit(true); + return; + } + + // Clear any previous interval + if (timerRef.current) { + clearInterval(timerRef.current); + } + + // If there is a countdown (totalSeconds != null), decrement timeLeft. + if (totalSeconds !== null) { + timerRef.current = setInterval(() => { + setTimeLeft((prev) => { + if (prev === null) return null; + if (prev <= 1) { + clearInterval(timerRef.current!); + return 0; + } + return prev - 1; + }); + }, 1000); + } else { + // No time limit: run a count‐up timer (increment timeLeft each second) + timerRef.current = setInterval(() => { + setTimeLeft((prev) => (prev === null ? 1 : prev + 1)); + }, 1000); + } + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [hasStarted, quizMeta]); + + // ─── AUTO‐SUBMIT ON TIMEOUT ─────────────────────────────────────────────────── + + useEffect(() => { + // Only relevant if it's a countdown + if (hasStarted && totalSeconds !== null && timeLeft === 0) { + handleSubmit(true); + } + }, [timeLeft, hasStarted, totalSeconds]); + + // ─── HANDLE “TAKE QUIZ” BUTTON CLICK ────────────────────────────────────────── + + const onClickStart = () => { + if (!quizMeta) return; + + if (totalSeconds !== null) { + // Countdown case: save start timestamp + const now = Date.now(); + try { + localStorage.setItem(STORAGE_KEY, now.toString()); + } catch { + // ignore + } + setHasStarted(true); + setTimeLeft(totalSeconds); + } else { + // No time limit: count from zero + const now = Date.now(); + try { + localStorage.setItem(STORAGE_KEY, now.toString()); + } catch { + // ignore + } + setHasStarted(true); + setTimeLeft(0); + } + }; + + // ─── HANDLE SUBMISSION ──────────────────────────────────────────────────────── + + const handleSubmit = async (auto: boolean = false) => { + if (!quizMeta) return; + + // // If not auto‐submit, require all questions answered + // if (!auto) { + // const unanswered = quizMeta.questions.filter( + // (q) => !selectedAnswers[q.quizId] + // ); + // if (unanswered.length > 0) { + // alert( + // t('ws-quizzes.please_answer_all') || 'Please answer all questions.' + // ); + // return; + // } + // } + + setSubmitting(true); + setSubmitError(null); + + try { + const res = await fetch( + `/api/v1/workspaces/${wsId}/quiz-sets/${setId}/submit`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildSubmissionPayload()), + } + ); + const json: SubmitResult | { error: string } = await res.json(); + + if (!res.ok) { + setSubmitError((json as any).error || 'Submission failed.'); + setSubmitting(false); + return; + } + + // Success: clear timer, localStorage, and show results + clearStartTimestamp(); + setSubmitResult(json as SubmitResult); + setSubmitting(false); + } catch { + setSubmitError('Network error submitting.'); + setSubmitting(false); + } + }; + + // ─── RENDER LOGIC ───────────────────────────────────────────────────────────── + + // 1) Loading or metadata error + if (loadingMeta) { + return ( +

{t('ws-quizzes.loading') || 'Loading quiz...'}

+ ); + } + if (metaError) { + return

{metaError}

; + } + if (!quizMeta) { + return null; + } + + const { setName, attemptLimit, attemptsSoFar, allowViewResults, questions } = + quizMeta; + + // 2) If the student has already submitted, show results screen + if (submitResult) { + return ( +
+

+ {t('ws-quizzes.results') || 'Results'} +

+

+ {t('ws-quizzes.attempt')} #{submitResult.attemptNumber}{' '} + {t('ws-quizzes.of')} {attemptLimit ?? t('ws-quizzes.unlimited')} +

+

+ {t('ws-quizzes.score')}: {submitResult.totalScore} /{' '} + {submitResult.maxPossibleScore} +

+ +
+ ); + } + + // 3) If the student has NOT started yet… + if (!hasStarted) { + // If attempt limit is reached, show “View Results” (if allowed) or “No attempts left” + if (attemptLimit !== null && attemptsSoFar >= attemptLimit) { + return ( +
+

{setName}

+

+ {t('ws-quizzes.attempts') || 'Attempts'}: {attemptsSoFar} /{' '} + {attemptLimit} +

+ {allowViewResults ? ( + + ) : ( +

+ {t('ws-quizzes.no_attempts_left') || 'You have no attempts left.'} +

+ )} +
+ ); + } + + // Otherwise, show “Take Quiz” button + attempt/time-limit info + return ( +
+

{setName}

+ + {attemptLimit !== null ? ( +

+ {t('ws-quizzes.attempts') || 'Attempts'}: {attemptsSoFar} /{' '} + {attemptLimit} +

+ ) : ( +

+ {t('ws-quizzes.attempts') || 'Attempts'}: {attemptsSoFar} /{' '} + {t('ws-quizzes.unlimited')} +

+ )} + + {quizMeta.timeLimitMinutes !== null ? ( +

+ {t('ws-quizzes.time_limit') || 'Time Limit'}:{' '} + {quizMeta.timeLimitMinutes} {t('ws-quizzes.minutes') || 'minutes'} +

+ ) : ( +

+ {t('ws-quizzes.no_time_limit') || 'No time limit'} +

+ )} + + +
+ ); + } + + // 4) Once hasStarted = true, show timer, sidebar, and question form + + const isCountdown = totalSeconds !== null; + + return ( +
+ {/* ── STICKY HEADER ON MOBILE ───────────────────────────────── */} +
+
+ + +
+
+ {sidebarVisible && ( + + )} +
+
+ {/* ── MAIN CONTENT: Timer + Questions Form ───────────────────────────────── */} +
+

{setName}

+
{ + e.preventDefault(); + handleSubmit(false); + }} + className="space-y-8" + > + {questions.map((q, idx) => ( +
+
+ + {idx + 1}. {q.question}{' '} + + ({t('ws-quizzes.points') || 'Points'}: {q.score}) + + +
+
+ {q.options.map((opt) => ( + + ))} +
+
+ ))} + + {submitError &&

{submitError}

} + +
+ +
+
+
+ + +
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx new file mode 100644 index 0000000000..56d99f671d --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx @@ -0,0 +1,125 @@ +import { useCallback } from 'react'; + +const onQuestionJump = (questionIndex: number) => { + const element = document.getElementById(`question-${questionIndex}`); // or use questionId + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + element.focus(); // Optional: set focus to the question + } +}; +// ─── TYPES ───────────────────────────────────────────────────────────────────── + +type Option = { + id: string; + value: string; +}; + +export type Question = { + quizId: string; + question: string; + score: number; + options: Option[]; +}; + +interface QuizStatusSidebarProps { + questions: Question[]; + selectedAnswers: Record; + t: (key: string, options?: Record) => string; +} + +const QuizStatusSidebar = ({ + questions, + selectedAnswers, + t, +}: QuizStatusSidebarProps) => { + const answeredCount = questions.reduce((count, q) => { + return selectedAnswers[q.quizId] ? count + 1 : count; + }, 0); + + // Fallback for t function if not provided or key is missing + const translate = useCallback( + (key: string, defaultText: string, options: Record = {}) => { + if (typeof t === 'function') { + const translation = t(key, options); + // i18next might return the key if not found, so check against that too + return translation === key ? defaultText : translation || defaultText; + } + return defaultText; + }, + [t] + ); + + return ( + + ); +}; + +export default QuizStatusSidebar; diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status.tsx new file mode 100644 index 0000000000..2662f6606c --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status.tsx @@ -0,0 +1,62 @@ +import { Eye, EyeClosed } from '@tuturuuu/ui/icons'; +import { useState } from 'react'; + +// Format seconds as MM:SS +const formatSeconds = (sec: number) => { + const m = Math.floor(sec / 60) + .toString() + .padStart(2, '0'); + const s = (sec % 60).toString().padStart(2, '0'); + return `${m}:${s}`; +}; + +interface TimeElapsedStatusProps { + t: (key: string, options?: Record) => string; + isCountdown: boolean; + timeLeft: number | null; +} + +export default function TimeElapsedStatus({ + t, + isCountdown, + timeLeft, +}: TimeElapsedStatusProps) { + const [isVisible, setIsVisible] = useState(true); + + const toggleVisibility = () => setIsVisible((prev) => !prev); + + const timerLabel = isCountdown + ? t('ws-quizzes.time_remaining') || 'Time Remaining' + : t('ws-quizzes.time_elapsed') || 'Time Elapsed'; + + const timerColorClass = + isCountdown && timeLeft !== null && timeLeft <= 60 + ? 'text-destructive font-semibold' // red or warning + : 'text-foreground'; + + return ( +
+
+

+ {isVisible + ? `${timerLabel}: ${ + timeLeft !== null ? formatSeconds(timeLeft) : '--:--' + }` + : timeLeft !== null + ? t('ws-quizzes.hidden_time_remaining') || 'Time Hidden' + : t('ws-quizzes.hidden_time_elapsed') || 'Time Hidden'} +

+ +
+
+ ); +} diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts new file mode 100644 index 0000000000..8585989ae3 --- /dev/null +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts @@ -0,0 +1,214 @@ +// File: app/api/quiz-sets/[setId]/results/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@tuturuuu/supabase/next/server'; + +type QuestionInfo = { + quizId: string; + question: string; + correctOptionId: string; + correctOptionValue: string; + scoreWeight: number; +}; + +type AttemptAnswer = { + quizId: string; + question: string; + selectedOption: string | null; + correctOption: string; + isCorrect: boolean; + scoreAwarded: number; +}; + +type AttemptDTO = { + attemptId: string; + attemptNumber: number; + totalScore: number; // must be a number, not null + maxPossibleScore: number; + startedAt: string; + completedAt: string | null; + answers: AttemptAnswer[]; +}; + +export async function GET( + request: NextRequest, + { params }: { params: { setId: string } } +) { + const setId = params.setId; + const supabase = await createClient(); + + // 1) Authenticate + const { + data: { user }, + error: userErr, + } = await supabase.auth.getUser(); + if (userErr || !user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + const userId = user.id; + + // 2) Fetch quiz_set metadata (release rules) + const { data: setRow, error: setErr } = await supabase + .from('workspace_quiz_sets') + .select('release_points_immediately, release_at') + .eq('id', setId) + .maybeSingle(); + + if (setErr || !setRow) { + return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 }); + } + const { release_points_immediately, release_at } = setRow; + + // Compute whether results are visible + let allowView = false; + if (release_points_immediately) { + allowView = true; + } else if (release_at) { + const now = new Date(); + if (new Date(release_at) <= now) { + allowView = true; + } + } + if (!allowView) { + return NextResponse.json( + { error: 'Results are not yet released' }, + { status: 403 } + ); + } + + // 3) Fetch ALL questions in this set, with correct option and weight + const { data: questionsRaw, error: qErr } = await supabase + .from('quiz_set_quizzes') + .select(` + quiz_id, + workspace_quizzes ( + question, + score + ), + quiz_options!inner ( + id, + value + ) + `) + .eq('set_id', setId) + .eq('quiz_options.is_correct', true); + + if (qErr) { + return NextResponse.json({ error: 'Error fetching questions' }, { status: 500 }); + } + + // Build questionInfo array + const questionInfo: QuestionInfo[] = (questionsRaw || []).map((row: any) => ({ + quizId: row.quiz_id, + question: row.workspace_quizzes.question, + scoreWeight: row.workspace_quizzes.score, + correctOptionId: row.quiz_options.id, + correctOptionValue: row.quiz_options.value, + })); + const qMap = new Map(); + questionInfo.forEach((q) => { + qMap.set(q.quizId, q); + }); + + // 4) Fetch all attempts by this user for this set + const { data: attemptsData, error: attemptsErr } = await supabase + .from('workspace_quiz_attempts') + .select(` + id, + attempt_number, + total_score, + started_at, + completed_at + `) + .eq('user_id', userId) + .eq('set_id', setId) + .order('attempt_number', { ascending: false }); + + if (attemptsErr) { + return NextResponse.json({ error: 'Error fetching attempts' }, { status: 500 }); + } + const attempts = attemptsData || []; + + if (!attempts.length) { + return NextResponse.json({ error: 'No attempts found' }, { status: 404 }); + } + + // 5) Compute maxPossibleScore once + const maxPossibleScore = questionInfo.reduce((acc, q) => acc + q.scoreWeight, 0); + + // 6) For each attempt, fetch its answers and build AttemptDTO + const resultDTOs: AttemptDTO[] = []; + + for (const att of attempts) { + // 6a) Fetch all answers for this attempt + const { data: answerRows, error: ansErr } = await supabase + .from('workspace_quiz_attempt_answers') + .select(` + quiz_id, + selected_option_id, + is_correct, + score_awarded + `) + .eq('attempt_id', att.id); + + if (ansErr) { + return NextResponse.json( + { error: 'Error fetching attempt answers' }, + { status: 500 } + ); + } + + // Build a map: quizId → answerRow + const aMap = new Map(); + (answerRows || []).forEach((a: any) => { + aMap.set(a.quiz_id, a); + }); + + // 6b) For each question, assemble AttemptAnswer + const answers: AttemptAnswer[] = await Promise.all(questionInfo.map(async (qi) => { + const aRow = aMap.get(qi.quizId); + if (aRow) { + // Student answered the question + // Fetch selected option’s text + const { data: selOptRow, error: selErr } = await supabase + .from('quiz_options') + .select('value') + .eq('id', aRow.selected_option_id) + .maybeSingle(); + const selectedValue = selErr || !selOptRow ? '' : selOptRow.value; + + return { + quizId: qi.quizId, + question: qi.question, + selectedOption: selectedValue, + correctOption: qi.correctOptionValue, + isCorrect: aRow.is_correct, + scoreAwarded: aRow.score_awarded, + }; + } else { + // Student left it blank + return { + quizId: qi.quizId, + question: qi.question, + selectedOption: null, + correctOption: qi.correctOptionValue, + isCorrect: false, + scoreAwarded: 0, + }; + } + })); + + // 6c) Build the AttemptDTO, coercing total_score to 0 if null + resultDTOs.push({ + attemptId: att.id, + attemptNumber: att.attempt_number, + totalScore: att.total_score ?? 0, // <-- coerce null to 0 + maxPossibleScore, + startedAt: att.started_at, + completedAt: att.completed_at, + answers, + }); + } + + // 7) Return the assembled DTOs + return NextResponse.json({ attempts: resultDTOs }); +} diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts new file mode 100644 index 0000000000..b8b427790f --- /dev/null +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts @@ -0,0 +1,208 @@ +// File: app/api/quiz-sets/[setId]/submit/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@tuturuuu/supabase/next/server'; + +type SubmissionBody = { + answers: Array<{ + quizId: string; + selectedOptionId: string; + }>; +}; + +type RawRow = { + quiz_id: string; + workspace_quizzes: { + score: number; + quiz_options: Array<{ + id: string; + is_correct: boolean; + }>; + }; +}; + +export async function POST( + request: NextRequest, + { params }: { params: { setId: string } } +) { + const setId = params.setId; + const supabase = await createClient(); + + // 1) Get current user + const { + data: { user }, + error: userErr, + } = await supabase.auth.getUser(); + if (userErr || !user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + const userId = user.id; + + // 2) Parse request body + let body: SubmissionBody; + try { + body = await request.json(); + } catch (e) { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + const { answers } = body; + if (!Array.isArray(answers) || answers.length === 0) { + return NextResponse.json({ error: 'No answers provided' }, { status: 400 }); + } + + // 3) Re-compute attempt_count for this user/set + const { data: prevAttempts, error: attErr } = await supabase + .from('workspace_quiz_attempts') + .select('attempt_number', { count: 'exact', head: false }) + .eq('user_id', userId) + .eq('set_id', setId); + + if (attErr) { + return NextResponse.json({ error: 'Error counting attempts' }, { status: 500 }); + } + const attemptsCount = prevAttempts?.length || 0; + + // 4) Fetch attempt_limit for this quiz set + const { data: setRow, error: setErr } = await supabase + .from('workspace_quiz_sets') + .select('attempt_limit') + .eq('id', setId) + .maybeSingle(); + + if (setErr || !setRow) { + return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 }); + } + const { attempt_limit } = setRow; + if ( + attempt_limit !== null && + attempt_limit !== undefined && + attemptsCount >= attempt_limit + ) { + return NextResponse.json({ error: 'Maximum attempts reached' }, { status: 403 }); + } + + // 5) We will create a new attempt row with attempt_number = attemptsCount + 1 + const newAttemptNumber = attemptsCount + 1; + + // 6) Fetch "correct" answers + per-question score for each quiz in this set. + // Notice we nest `quiz_options` under `workspace_quizzes`: + const { data: correctRaw, error: corrErr } = await supabase + .from('quiz_set_quizzes') + .select(` + quiz_id, + workspace_quizzes ( + score, + quiz_options ( + id, + is_correct + ) + ) + `) + .eq('set_id', setId); + + if (corrErr) { + return NextResponse.json({ error: 'Error fetching correct answers' }, { status: 500 }); + } + + // 7) Tell TypeScript: "Trust me—this matches RawRow[]" + const correctRows = (correctRaw as unknown as RawRow[]) ?? []; + + // Build a map: quizId → { score: number, correctOptionId: string } + const quizMap = new Map(); + correctRows.forEach((row) => { + const qId = row.quiz_id; + const weight = row.workspace_quizzes.score; + + // Find exactly one correct option (is_correct = true) + const correctOption = row.workspace_quizzes.quiz_options.find( + (opt) => opt.is_correct + )?.id; + + quizMap.set(qId, { score: weight, correctOptionId: correctOption || '' }); + }); + + // 8) Loop through submitted answers, compare to correctOptionId, sum up total_score + let totalScore = 0; + const answerInserts: Array<{ + quiz_id: string; + selected_option_id: string; + is_correct: boolean; + score_awarded: number; + }> = []; + + for (const { quizId, selectedOptionId } of answers) { + const qInfo = quizMap.get(quizId); + if (!qInfo) { + // If the quizId isn't in our map, ignore it + continue; + } + const { score: weight, correctOptionId } = qInfo; + const isCorrect = selectedOptionId === correctOptionId; + const awarded = isCorrect ? weight : 0; + totalScore += awarded; + + answerInserts.push({ + quiz_id: quizId, + selected_option_id: selectedOptionId, + is_correct: isCorrect, + score_awarded: awarded, + }); + } + + // 9) Insert the attempt row + const { data: insertedAttempt, error: insErr } = await supabase + .from('workspace_quiz_attempts') + .insert([ + { + user_id: userId, + set_id: setId, + attempt_number: newAttemptNumber, + total_score: totalScore, + }, + ]) + .select('id') + .single(); + + if (insErr || !insertedAttempt) { + return NextResponse.json({ error: 'Error inserting attempt' }, { status: 500 }); + } + const attemptId = insertedAttempt.id; + + // 10) Insert each answer into workspace_quiz_attempt_answers + const { error: ansErr } = await supabase + .from('workspace_quiz_attempt_answers') + .insert( + answerInserts.map((a) => ({ + attempt_id: attemptId, + quiz_id: a.quiz_id, + selected_option_id: a.selected_option_id, + is_correct: a.is_correct, + score_awarded: a.score_awarded, + })) + ); + + if (ansErr) { + return NextResponse.json({ error: 'Error inserting answers' }, { status: 500 }); + } + + // 11) Mark the attempt’s completed_at timestamp + const { error: updErr } = await supabase + .from('workspace_quiz_attempts') + .update({ completed_at: new Date().toISOString() }) + .eq('id', attemptId); + + if (updErr) { + console.error('Warning: could not update completed_at', updErr); + // Not fatal—still return success + } + + // 12) Return the result to the client + return NextResponse.json({ + attemptId, + attemptNumber: newAttemptNumber, + totalScore, + maxPossibleScore: Array.from(quizMap.values()).reduce( + (acc, { score }) => acc + score, + 0 + ), + }); +} diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts new file mode 100644 index 0000000000..2d29fb2fb5 --- /dev/null +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts @@ -0,0 +1,146 @@ +// File: app/api/quiz-sets/[setId]/take/route.ts +import { createClient } from '@tuturuuu/supabase/next/server'; +import { NextRequest, NextResponse } from 'next/server'; + +type RawRow = { + quiz_id: string; + workspace_quizzes: { + question: string; + score: number; + quiz_options: Array<{ + id: string; + value: string; + }>; + }; +}; + +export async function GET( + _request: NextRequest, + { params }: { params: { setId: string } } +) { + const setId = params.setId; + const supabase = await createClient(); + + // 1) Authenticate + const { + data: { user }, + error: userErr, + } = await supabase.auth.getUser(); + if (userErr || !user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + const userId = user.id; + + // 2) Fetch quiz set metadata, including the new “release” fields + const { data: setRow, error: setErr } = await supabase + .from('workspace_quiz_sets') + .select( + 'id, name, time_limit_minutes, attempt_limit, release_points_immediately, release_at' + ) + .eq('id', setId) + .maybeSingle(); + + if (setErr || !setRow) { + return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 }); + } + + const { + name: setName, + time_limit_minutes, + attempt_limit, + release_points_immediately, + release_at, + } = setRow; + + // 3) Count how many attempts this user already has + const { data: prevAttempts, error: attErr } = await supabase + .from('workspace_quiz_attempts') + .select('attempt_number', { count: 'exact', head: false }) + .eq('user_id', userId) + .eq('set_id', setId); + + if (attErr) { + return NextResponse.json( + { error: 'Error counting attempts' }, + { status: 500 } + ); + } + const attemptsCount = prevAttempts?.length ?? 0; + + // 4) If attempt_limit is set and they’ve used them all, return 403 + if ( + attempt_limit !== null && + attempt_limit !== undefined && + attemptsCount >= attempt_limit + ) { + return NextResponse.json( + { + error: 'Maximum attempts reached', + attemptsSoFar: attemptsCount, + attemptLimit: attempt_limit, + allowViewResults: false, // if they maxed out before, still no results until release time + }, + { status: 403 } + ); + } + + // 5) Compute allowViewResults: + // True if either release_points_immediately = true, OR (release_at <= now). + let allowViewResults = false; + if (release_points_immediately) { + allowViewResults = true; + } else if (release_at) { + const now = new Date(); + const releaseDate = new Date(release_at); + if (releaseDate <= now) { + allowViewResults = true; + } + } + + // 6) Fetch all questions + nested options + score + const { data: rawData, error: quizErr } = await supabase + .from('quiz_set_quizzes') + .select( + ` + quiz_id, + workspace_quizzes ( + question, + score, + quiz_options ( + id, + value + ) + ) + ` + ) + .eq('set_id', setId); + + if (quizErr) { + return NextResponse.json( + { error: 'Error fetching quiz questions' }, + { status: 500 } + ); + } + + const quizRows = (rawData as unknown as RawRow[]) ?? []; + const questions = quizRows.map((row) => ({ + quizId: row.quiz_id, + question: row.workspace_quizzes.question, + score: row.workspace_quizzes.score, + options: row.workspace_quizzes.quiz_options.map((opt) => ({ + id: opt.id, + value: opt.value, + })), + })); + + // 7) Return everything + return NextResponse.json({ + setId, + setName, + timeLimitMinutes: time_limit_minutes, + attemptLimit: attempt_limit, + attemptsSoFar: attemptsCount, + allowViewResults, + questions, + }); +} diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index bb56d67fc3..5aec394870 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -5456,23 +5456,146 @@ export type Database = { }, ]; }; + workspace_quiz_attempt_answers: { + Row: { + attempt_id: string; + id: string; + is_correct: boolean; + quiz_id: string; + score_awarded: number; + selected_option_id: string; + }; + Insert: { + attempt_id: string; + id?: string; + is_correct: boolean; + quiz_id: string; + score_awarded: number; + selected_option_id: string; + }; + Update: { + attempt_id?: string; + id?: string; + is_correct?: boolean; + quiz_id?: string; + score_awarded?: number; + selected_option_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'wq_answer_attempt_fkey'; + columns: ['attempt_id']; + isOneToOne: false; + referencedRelation: 'workspace_quiz_attempts'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'wq_answer_option_fkey'; + columns: ['selected_option_id']; + isOneToOne: false; + referencedRelation: 'quiz_options'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'wq_answer_quiz_fkey'; + columns: ['quiz_id']; + isOneToOne: false; + referencedRelation: 'workspace_quizzes'; + referencedColumns: ['id']; + }, + ]; + }; + workspace_quiz_attempts: { + Row: { + attempt_number: number; + completed_at: string | null; + id: string; + set_id: string; + started_at: string; + total_score: number | null; + user_id: string; + }; + Insert: { + attempt_number: number; + completed_at?: string | null; + id?: string; + set_id: string; + started_at?: string; + total_score?: number | null; + user_id: string; + }; + Update: { + attempt_number?: number; + completed_at?: string | null; + id?: string; + set_id?: string; + started_at?: string; + total_score?: number | null; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'wq_attempts_set_fkey'; + columns: ['set_id']; + isOneToOne: false; + referencedRelation: 'workspace_quiz_sets'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'wq_attempts_user_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'nova_user_challenge_leaderboard'; + referencedColumns: ['user_id']; + }, + { + foreignKeyName: 'wq_attempts_user_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'nova_user_leaderboard'; + referencedColumns: ['user_id']; + }, + { + foreignKeyName: 'wq_attempts_user_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + ]; + }; workspace_quiz_sets: { Row: { + allow_view_results: boolean; + attempt_limit: number | null; created_at: string; id: string; name: string; + release_at: string | null; + release_points_immediately: boolean; + time_limit_minutes: number | null; ws_id: string | null; }; Insert: { + allow_view_results?: boolean; + attempt_limit?: number | null; created_at?: string; id?: string; name?: string; + release_at?: string | null; + release_points_immediately?: boolean; + time_limit_minutes?: number | null; ws_id?: string | null; }; Update: { + allow_view_results?: boolean; + attempt_limit?: number | null; created_at?: string; id?: string; name?: string; + release_at?: string | null; + release_points_immediately?: boolean; + time_limit_minutes?: number | null; ws_id?: string | null; }; Relationships: [ @@ -5490,18 +5613,21 @@ export type Database = { created_at: string; id: string; question: string; + score: number; ws_id: string; }; Insert: { created_at?: string; id?: string; question: string; + score?: number; ws_id: string; }; Update: { created_at?: string; id?: string; question?: string; + score?: number; ws_id?: string; }; Relationships: [ @@ -7241,6 +7367,12 @@ export type Database = { Args: { '': string }; Returns: string[]; }; + sum_quiz_scores: { + Args: { p_set_id: string }; + Returns: { + sum: number; + }[]; + }; transactions_have_same_abs_amount: { Args: { transaction_id_1: string; transaction_id_2: string }; Returns: boolean; From 08eeb57ab409988e9a85ff573dbf1619ebfdbdbe Mon Sep 17 00:00:00 2001 From: Nhung Date: Thu, 5 Jun 2025 20:34:47 +0700 Subject: [PATCH 08/26] refractor(Taking Quiz): Clean files --- .../[wsId]/quiz-sets/[setId]/result/page.tsx | 1 - .../(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx | 10 +--------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx index d37efdd9b2..59ae494faa 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx @@ -1,4 +1,3 @@ -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/results/page.tsx 'use client'; import { useEffect, useState } from 'react'; diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx index eb2d06f110..451f92dd2c 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx @@ -1,4 +1,3 @@ -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx 'use client'; import QuizStatusSidebar, { @@ -11,13 +10,6 @@ import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx - -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx - -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx - -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx // ─── TYPES ───────────────────────────────────────────────────────────────────── @@ -255,7 +247,7 @@ export default function TakeQuiz({ // ─── HANDLE SUBMISSION ──────────────────────────────────────────────────────── - const handleSubmit = async (auto: boolean = false) => { + const handleSubmit = async (_auto: boolean = false) => { if (!quizMeta) return; // // If not auto‐submit, require all questions answered From a062cbd42605715411c29e8a37ded4d96716e539 Mon Sep 17 00:00:00 2001 From: Tran Phan Trong Phuc <117657788+Henry7482@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:41:22 +0700 Subject: [PATCH 09/26] chore: remove unused components --- .../app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx index fa595e8be0..b44f929f0e 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx @@ -1,12 +1,9 @@ import { CustomDataTable } from "@/components/custom-data-table" import { createClient } from "@tuturuuu/supabase/next/server" import type { WorkspaceQuiz } from "@tuturuuu/types/db" -import { Button } from "@tuturuuu/ui/button" import FeatureSummary from "@tuturuuu/ui/custom/feature-summary" -import { BarChart3 } from "@tuturuuu/ui/icons" import { Separator } from "@tuturuuu/ui/separator" import { getTranslations } from "next-intl/server" -import Link from "next/link" import { getWorkspaceQuizColumns } from "./columns" import QuizForm from "./form" interface SearchParams { From 80005e3b49684d1565ca73a054974468ec889ab2 Mon Sep 17 00:00:00 2001 From: Tran Phan Trong Phuc <117657788+Henry7482@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:54:02 +0700 Subject: [PATCH 10/26] fix: temporarily bypass type error by casting workspace_quiz_attempts to any Supabase type definitions didn't include the workspace_quiz_attempts table, so used 'as any' to unblock the build. Needs proper type update later. --- .../(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx index 296ce5847a..644b58c6a6 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx @@ -222,7 +222,7 @@ async function getQuizSetStatistics(setId: string): Promise { // Get quiz attempts and scores const { data: attempts } = await supabase - .from("workspace_quiz_attempts") + .from("workspace_quiz_attempts" as any) .select("user_id, score, created_at") .eq("quiz_id", quiz.id) From 65ba00fa117aeb66283b60c77a6baed07c1dfa6f Mon Sep 17 00:00:00 2001 From: Henry7482 <117657788+Henry7482@users.noreply.github.com> Date: Fri, 6 Jun 2025 09:56:30 +0000 Subject: [PATCH 11/26] style: apply prettier formatting --- .../[wsId]/quiz-sets/[setId]/layout.tsx | 10 +- .../[wsId]/quiz-sets/[setId]/page.tsx | 96 +++---- .../quiz-sets/[setId]/statistics/page.tsx | 260 +++++++++++------- 3 files changed, 220 insertions(+), 146 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/layout.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/layout.tsx index bb5768eb48..15cb43a221 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/layout.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/layout.tsx @@ -1,17 +1,15 @@ import LinkButton from '@/app/[locale]/(dashboard)/_components/link-button'; import { createClient } from '@tuturuuu/supabase/next/server'; import { type WorkspaceQuizSet } from '@tuturuuu/types/db'; +import { Button } from '@tuturuuu/ui/button'; import FeatureSummary from '@tuturuuu/ui/custom/feature-summary'; import { Box, Eye, Paperclip } from '@tuturuuu/ui/icons'; +import { BarChart3 } from '@tuturuuu/ui/icons'; import { Separator } from '@tuturuuu/ui/separator'; import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; import { notFound } from 'next/navigation'; import { ReactNode } from 'react'; -import { Button } from "@tuturuuu/ui/button" -import Link from "next/link" -import { BarChart3 } from "@tuturuuu/ui/icons" - - interface Props { children: ReactNode; @@ -73,7 +71,7 @@ export default async function QuizSetDetailsLayout({ } diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx index b44f929f0e..7bed4f825a 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx @@ -1,41 +1,45 @@ -import { CustomDataTable } from "@/components/custom-data-table" -import { createClient } from "@tuturuuu/supabase/next/server" -import type { WorkspaceQuiz } from "@tuturuuu/types/db" -import FeatureSummary from "@tuturuuu/ui/custom/feature-summary" -import { Separator } from "@tuturuuu/ui/separator" -import { getTranslations } from "next-intl/server" -import { getWorkspaceQuizColumns } from "./columns" -import QuizForm from "./form" +import { getWorkspaceQuizColumns } from './columns'; +import QuizForm from './form'; +import { CustomDataTable } from '@/components/custom-data-table'; +import { createClient } from '@tuturuuu/supabase/next/server'; +import type { WorkspaceQuiz } from '@tuturuuu/types/db'; +import FeatureSummary from '@tuturuuu/ui/custom/feature-summary'; +import { Separator } from '@tuturuuu/ui/separator'; +import { getTranslations } from 'next-intl/server'; + interface SearchParams { - q?: string - page?: string - pageSize?: string - includedTags?: string | string[] - excludedTags?: string | string[] + q?: string; + page?: string; + pageSize?: string; + includedTags?: string | string[]; + excludedTags?: string | string[]; } interface Props { params: Promise<{ - wsId: string - setId: string - }> - searchParams: Promise + wsId: string; + setId: string; + }>; + searchParams: Promise; } -export default async function WorkspaceQuizzesPage({ params, searchParams }: Props) { - const t = await getTranslations() - const { wsId, setId } = await params +export default async function WorkspaceQuizzesPage({ + params, + searchParams, +}: Props) { + const t = await getTranslations(); + const { wsId, setId } = await params; - const { data, count } = await getData(setId, await searchParams) + const { data, count } = await getData(setId, await searchParams); return ( <> } /> @@ -50,43 +54,43 @@ export default async function WorkspaceQuizzesPage({ params, searchParams }: Pro }} /> - ) + ); } async function getData( setId: string, { q, - page = "1", - pageSize = "10", + page = '1', + pageSize = '10', retry = true, - }: { q?: string; page?: string; pageSize?: string; retry?: boolean } = {}, + }: { q?: string; page?: string; pageSize?: string; retry?: boolean } = {} ) { - const supabase = await createClient() + const supabase = await createClient(); const queryBuilder = supabase - .from("quiz_set_quizzes") - .select("...workspace_quizzes(*, quiz_options(*))", { - count: "exact", + .from('quiz_set_quizzes') + .select('...workspace_quizzes(*, quiz_options(*))', { + count: 'exact', }) - .eq("set_id", setId) - .order("created_at", { ascending: false }) + .eq('set_id', setId) + .order('created_at', { ascending: false }); - if (q) queryBuilder.ilike("name", `%${q}%`) + if (q) queryBuilder.ilike('name', `%${q}%`); if (page && pageSize) { - const parsedPage = Number.parseInt(page) - const parsedSize = Number.parseInt(pageSize) - const start = (parsedPage - 1) * parsedSize - const end = parsedPage * parsedSize - queryBuilder.range(start, end).limit(parsedSize) + const parsedPage = Number.parseInt(page); + const parsedSize = Number.parseInt(pageSize); + const start = (parsedPage - 1) * parsedSize; + const end = parsedPage * parsedSize; + queryBuilder.range(start, end).limit(parsedSize); } - const { data, error, count } = await queryBuilder + const { data, error, count } = await queryBuilder; if (error) { - if (!retry) throw error - return getData(setId, { q, pageSize, retry: false }) + if (!retry) throw error; + return getData(setId, { q, pageSize, retry: false }); } - return { data, count } as { data: WorkspaceQuiz[]; count: number } + return { data, count } as { data: WorkspaceQuiz[]; count: number }; } diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx index 644b58c6a6..a3f05d5330 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx @@ -1,113 +1,148 @@ -import { createClient } from "@tuturuuu/supabase/next/server" -import { Button } from "@tuturuuu/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@tuturuuu/ui/card" -import { ArrowLeft, BarChart3, Target, TrendingUp, Users } from "@tuturuuu/ui/icons" -import { Separator } from "@tuturuuu/ui/separator" -import { getTranslations } from "next-intl/server" -import Link from "next/link" +import { createClient } from '@tuturuuu/supabase/next/server'; +import { Button } from '@tuturuuu/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@tuturuuu/ui/card'; +import { + ArrowLeft, + BarChart3, + Target, + TrendingUp, + Users, +} from '@tuturuuu/ui/icons'; +import { Separator } from '@tuturuuu/ui/separator'; +import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; interface QuizStats { - id: string - question: string - totalAttempts: number - uniqueStudents: number - averageScore: number - passRate: number - lastAttempt: string | null + id: string; + question: string; + totalAttempts: number; + uniqueStudents: number; + averageScore: number; + passRate: number; + lastAttempt: string | null; } interface Props { params: Promise<{ - wsId: string - setId: string - }> + wsId: string; + setId: string; + }>; } export default async function QuizSetStatisticsPage({ params }: Props) { - const t = await getTranslations("quiz-set-statistics") - const { wsId, setId } = await params + const t = await getTranslations('quiz-set-statistics'); + const { wsId, setId } = await params; - const stats = await getQuizSetStatistics(setId) + const stats = await getQuizSetStatistics(setId); const overallStats = { totalQuizzes: stats.length, totalAttempts: stats.reduce((sum, s) => sum + s.totalAttempts, 0), - totalStudents: new Set(stats.flatMap((s) => Array(s.uniqueStudents).fill(0))).size, - averagePassRate: stats.length > 0 ? stats.reduce((sum, s) => sum + s.passRate, 0) / stats.length : 0, - } + totalStudents: new Set( + stats.flatMap((s) => Array(s.uniqueStudents).fill(0)) + ).size, + averagePassRate: + stats.length > 0 + ? stats.reduce((sum, s) => sum + s.passRate, 0) / stats.length + : 0, + }; return ( -
+
{/* Header */}
-

+

- {t("title")} + {t('title')}

-

Comprehensive analytics for all quizzes in this set

+

+ Comprehensive analytics for all quizzes in this set +

{/* Overall Statistics Cards */} -
+
- + - {t("total_quizzes")} + {t('total_quizzes')} -
{overallStats.totalQuizzes}
-

{t("active_quizzes")}

+
+ {overallStats.totalQuizzes} +
+

+ {t('active_quizzes')} +

- + - {t("total_attempts")} + {t('total_attempts')} -
{overallStats.totalAttempts}
-

{t("accross_all_quizzes")}

+
+ {overallStats.totalAttempts} +
+

+ {t('accross_all_quizzes')} +

- + - {t("active_students")} + {t('active_students')} -
{overallStats.totalStudents}
-

{t("unique_participants")}

+
+ {overallStats.totalStudents} +
+

+ {t('unique_participants')} +

- + - {t("average_pass_rate")} + {t('average_pass_rate')} -
{overallStats.averagePassRate.toFixed(1)}%
-

70% passing threshold

+
+ {overallStats.averagePassRate.toFixed(1)}% +
+

+ 70% passing threshold +

@@ -117,80 +152,106 @@ export default async function QuizSetStatisticsPage({ params }: Props) { {/* Individual Quiz Performance */}
-

{t("individual_quiz_performance")}

-

{stats.length} quizzes analyzed

+

+ {t('individual_quiz_performance')} +

+

+ {stats.length} quizzes analyzed +

{stats.length === 0 ? ( - -

{t("no_quizzes")}

+ +

{t('no_quizzes')}

- {t("no_quizzes_description")} + {t('no_quizzes_description')}

) : (
{stats.map((quiz, index) => ( - +
- + Quiz #{index + 1}: {quiz.question} Quiz ID: {quiz.id}
= 80 - ? "bg-green-100 text-green-800" + ? 'bg-green-100 text-green-800' : quiz.passRate >= 60 - ? "bg-yellow-100 text-yellow-800" - : "bg-red-100 text-red-800" + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' }`} > - {quiz.passRate >= 80 ? "Excellent" : quiz.passRate >= 60 ? "Good" : "Needs Attention"} + {quiz.passRate >= 80 + ? 'Excellent' + : quiz.passRate >= 60 + ? 'Good' + : 'Needs Attention'}
-
+
-
{quiz.totalAttempts}
-
{t("total_attempts")}
+
+ {quiz.totalAttempts} +
+
+ {t('total_attempts')} +
-
{quiz.uniqueStudents}
-
{t("unique_participants")}
+
+ {quiz.uniqueStudents} +
+
+ {t('unique_participants')} +
-
{quiz.averageScore}%
-
{t("average_score")}
+
+ {quiz.averageScore}% +
+
+ {t('average_score')} +
= 70 - ? "text-green-600" + ? 'text-green-600' : quiz.passRate >= 50 - ? "text-yellow-600" - : "text-red-600" + ? 'text-yellow-600' + : 'text-red-600' }`} > {quiz.passRate}%
-
{t("pass_rate")}
+
+ {t('pass_rate')} +
- {quiz.lastAttempt ? new Date(quiz.lastAttempt).toLocaleDateString() : "Never"} + {quiz.lastAttempt + ? new Date(quiz.lastAttempt).toLocaleDateString() + : 'Never'} +
+
+ {t('last_attempt')}
-
{t("last_attempt")}
@@ -200,59 +261,70 @@ export default async function QuizSetStatisticsPage({ params }: Props) { )}
- ) + ); } async function getQuizSetStatistics(setId: string): Promise { - const supabase = await createClient() + const supabase = await createClient(); try { // Get all quizzes in this set const { data: quizzes } = await supabase - .from("quiz_set_quizzes") - .select("...workspace_quizzes(id, question)") - .eq("set_id", setId) + .from('quiz_set_quizzes') + .select('...workspace_quizzes(id, question)') + .eq('set_id', setId); - if (!quizzes) return [] + if (!quizzes) return []; - const quizStats: QuizStats[] = [] + const quizStats: QuizStats[] = []; for (const quiz of quizzes) { - if (!quiz.id) continue + if (!quiz.id) continue; // Get quiz attempts and scores const { data: attempts } = await supabase - .from("workspace_quiz_attempts" as any) - .select("user_id, score, created_at") - .eq("quiz_id", quiz.id) + .from('workspace_quiz_attempts' as any) + .select('user_id, score, created_at') + .eq('quiz_id', quiz.id); - if (!attempts) continue + if (!attempts) continue; - const totalAttempts = attempts.length - const uniqueStudents = new Set(attempts.map((a) => a.user_id)).size + const totalAttempts = attempts.length; + const uniqueStudents = new Set(attempts.map((a) => a.user_id)).size; const averageScore = - attempts.length > 0 ? attempts.reduce((sum, a) => sum + (a.score || 0), 0) / attempts.length : 0 + attempts.length > 0 + ? attempts.reduce((sum, a) => sum + (a.score || 0), 0) / + attempts.length + : 0; const passRate = - attempts.length > 0 ? (attempts.filter((a) => (a.score || 0) >= 70).length / attempts.length) * 100 : 0 + attempts.length > 0 + ? (attempts.filter((a) => (a.score || 0) >= 70).length / + attempts.length) * + 100 + : 0; const lastAttempt = attempts.length > 0 - ? attempts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0].created_at - : null + ? attempts.sort( + (a, b) => + new Date(b.created_at).getTime() - + new Date(a.created_at).getTime() + )[0].created_at + : null; quizStats.push({ id: quiz.id, - question: quiz.question || "Untitled Quiz", + question: quiz.question || 'Untitled Quiz', totalAttempts, uniqueStudents, averageScore: Math.round(averageScore * 100) / 100, passRate: Math.round(passRate * 100) / 100, lastAttempt, - }) + }); } - return quizStats + return quizStats; } catch (error) { - console.error("Error fetching quiz statistics:", error) - return [] + console.error('Error fetching quiz statistics:', error); + return []; } } From f9ed408be801b18de7c1b62adba1545e7fb9c7c2 Mon Sep 17 00:00:00 2001 From: Nhung Date: Sun, 8 Jun 2025 12:28:43 +0700 Subject: [PATCH 12/26] feat (Taking Quiz); chang ebackend and frontend for more sets attributes --- .../20250606073849_new_migration.sql | 5 + .../20250608051026_new_migration.sql | 5 + apps/upskii/messages/en.json | 4 +- apps/upskii/messages/vi.json | 4 +- .../[wsId]/quiz-sets/[setId]/result/page.tsx | 57 +-- .../[setId]/take/before-take-quiz-section.tsx | 58 +++ .../[wsId]/quiz-sets/[setId]/take/page.tsx | 393 ++++++++---------- .../[setId]/take/past-due-section.tsx | 49 +++ .../take/show-result-summary-section.tsx | 57 +++ .../[wsId]/quiz-sets/[setId]/results/route.ts | 139 ++----- .../[wsId]/quiz-sets/[setId]/take/route.ts | 97 +++-- apps/upskii/src/lib/release-quiz-sets.ts | 44 ++ packages/types/src/supabase.ts | 6 +- 13 files changed, 508 insertions(+), 410 deletions(-) create mode 100644 apps/db/supabase/migrations/20250606073849_new_migration.sql create mode 100644 apps/db/supabase/migrations/20250608051026_new_migration.sql create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section.tsx create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/past-due-section.tsx create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/show-result-summary-section.tsx create mode 100644 apps/upskii/src/lib/release-quiz-sets.ts diff --git a/apps/db/supabase/migrations/20250606073849_new_migration.sql b/apps/db/supabase/migrations/20250606073849_new_migration.sql new file mode 100644 index 0000000000..a5ab4a65c1 --- /dev/null +++ b/apps/db/supabase/migrations/20250606073849_new_migration.sql @@ -0,0 +1,5 @@ +alter table "public"."workspace_quiz_sets" add column "due_date" timestamp with time zone not null default (now() + '7 days'::interval); + +alter table "public"."workspace_quiz_sets" add column "results_released" boolean not null default false; + + diff --git a/apps/db/supabase/migrations/20250608051026_new_migration.sql b/apps/db/supabase/migrations/20250608051026_new_migration.sql new file mode 100644 index 0000000000..8e1999e6c1 --- /dev/null +++ b/apps/db/supabase/migrations/20250608051026_new_migration.sql @@ -0,0 +1,5 @@ +alter table "public"."workspace_quiz_sets" drop column "release_at"; + +alter table "public"."workspace_quiz_sets" drop column "results_released"; + + diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json index 31ef976190..3d8203e50f 100644 --- a/apps/upskii/messages/en.json +++ b/apps/upskii/messages/en.json @@ -3811,7 +3811,9 @@ "time_remaining": "Time Remaining", "points": "Points", "submitting": "Submitting...", - "submit": "Submit" + "submit": "Submit", + "due_on": "Due on", + "quiz_past_due": "This quiz is past its due date." }, "ws-reports": { "report": "Report", diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json index b0d17afec6..0de600776e 100644 --- a/apps/upskii/messages/vi.json +++ b/apps/upskii/messages/vi.json @@ -3811,7 +3811,9 @@ "time_remaining": "Thời gian còn lại", "points": "Điểm", "submitting": "Đang gửi...", - "submit": "Nộp bài" + "submit": "Nộp bài", + "due_on": "Hạn nộp", + "quiz_past_due": "Bài kiểm tra đã quá hạn" }, "ws-reports": { "report": "Báo cáo", diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx index 59ae494faa..5fb156f7d1 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx @@ -1,3 +1,4 @@ +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/results/page.tsx 'use client'; import { useEffect, useState } from 'react'; @@ -5,15 +6,17 @@ import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { Button } from '@tuturuuu/ui/button'; +/** + * Client-side types matching AttemptDTO defined in the /results route. + */ type AttemptAnswer = { quizId: string; question: string; - selectedOption: string | null; // now can be null + selectedOption: string | null; correctOption: string; isCorrect: boolean; scoreAwarded: number; }; - type AttemptDTO = { attemptId: string; attemptNumber: number; @@ -47,10 +50,9 @@ export default function ViewResults({ setLoading(true); try { const res = await fetch( - `/api/v1/workspaces/${wsId}/quiz-sets/${setId}/results` + `/api/quiz-sets/${setId}/results` ); - const json: { attempts?: AttemptDTO[]; error?: string } = - await res.json(); + const json: { attempts?: AttemptDTO[]; error?: string } = await res.json(); if (!res.ok) { setErrorMsg(json.error || 'Error loading results'); @@ -68,11 +70,7 @@ export default function ViewResults({ }, [wsId, setId]); if (loading) { - return ( -

- {t('ws-quizzes.loading') || 'Loading results...'} -

- ); + return

{t('ws-quizzes.loading') || 'Loading results...'}

; } if (errorMsg) { return ( @@ -118,18 +116,15 @@ export default function ViewResults({ {t('ws-quizzes.attempt')} #{att.attemptNumber}

- {t('ws-quizzes.scored') || 'Scored'}:{' '} - {att.totalScore} / {att.maxPossibleScore} + {t('ws-quizzes.scored') || 'Scored'}: {att.totalScore} / {att.maxPossibleScore}

- {t('ws-quizzes.started_at') || 'Started at'}:{' '} - {new Date(att.startedAt).toLocaleString()} + {t('ws-quizzes.started_at') || 'Started at'}: {new Date(att.startedAt).toLocaleString()} {att.completedAt && ( <> {' | '} - {t('ws-quizzes.completed_at') || 'Completed at'}:{' '} - {new Date(att.completedAt).toLocaleString()} + {t('ws-quizzes.completed_at') || 'Completed at'}: {new Date(att.completedAt).toLocaleString()} )}

@@ -137,29 +132,16 @@ export default function ViewResults({ - - - - - + + + + + {att.answers.map((ans, idx) => ( - + - + ))} @@ -181,7 +161,6 @@ export default function ViewResults({ + + ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx index 451f92dd2c..eb18e8a4b7 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx @@ -1,8 +1,12 @@ +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx 'use client'; +import BeforeTakeQuizSection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section'; +import PastDueSection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/past-due-section'; import QuizStatusSidebar, { Question, } from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar'; +import ShowResultSummarySection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/show-result-summary-section'; import TimeElapsedStatus from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status'; import { Button } from '@tuturuuu/ui/button'; import { ListCheck } from '@tuturuuu/ui/icons'; @@ -10,8 +14,15 @@ import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx -// ─── TYPES ───────────────────────────────────────────────────────────────────── +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx type TakeResponse = { setId: string; @@ -21,6 +32,7 @@ type TakeResponse = { attemptsSoFar: number; allowViewResults: boolean; questions: Question[]; + dueDate: string | null; }; type SubmitResult = { @@ -30,8 +42,6 @@ type SubmitResult = { maxPossibleScore: number; }; -// ─── COMPONENT ───────────────────────────────────────────────────────────────── - export default function TakeQuiz({ params, }: { @@ -47,59 +57,42 @@ export default function TakeQuiz({ const router = useRouter(); // ─── STATE ─────────────────────────────────────────────────────────────────── - // Sidebar visibility (mobile only) const [sidebarVisible, setSidebarVisible] = useState(false); - // Metadata loading / error const [loadingMeta, setLoadingMeta] = useState(true); const [metaError, setMetaError] = useState(null); - - // Fetched quiz metadata (including questions, time limit, attempt counts, allowViewResults) const [quizMeta, setQuizMeta] = useState(null); - // Whether the student has clicked “Take Quiz” or resumed from localStorage const [hasStarted, setHasStarted] = useState(false); + const [isPastDue, setIsPastDue] = useState(false); + const [dueDateStr, setDueDateStr] = useState(null); - // Time state: if timeLimitMinutes != null, this is “seconds left” (countdown). - // If timeLimitMinutes == null, this is “seconds elapsed” (count-up). const [timeLeft, setTimeLeft] = useState(null); const timerRef = useRef(null); - // Selected answers (quizId → selectedOptionId) const [selectedAnswers, setSelectedAnswers] = useState< Record >({}); - // Submission state const [submitting, setSubmitting] = useState(false); const [submitResult, setSubmitResult] = useState(null); const [submitError, setSubmitError] = useState(null); // ─── HELPERS ───────────────────────────────────────────────────────────────── - const STORAGE_KEY = `quiz_start_${setId}`; - - // totalSeconds: if timeLimitMinutes is null → null, else minutes*60 const totalSeconds = quizMeta?.timeLimitMinutes ? quizMeta.timeLimitMinutes * 60 : null; - // Remove stored start time when done const clearStartTimestamp = () => { try { localStorage.removeItem(STORAGE_KEY); - } catch { - // ignore - } + } catch {} }; - // Elapsed seconds since a given timestamp - const computeElapsedSeconds = (startTs: number) => { - const now = Date.now(); - return Math.floor((now - startTs) / 1000); - }; + const computeElapsedSeconds = (startTs: number) => + Math.floor((Date.now() - startTs) / 1000); - // Build submission payload (possibly empty array if unanswered) const buildSubmissionPayload = () => ({ answers: Object.entries(selectedAnswers).map(([quizId, optionId]) => ({ quizId, @@ -107,8 +100,7 @@ export default function TakeQuiz({ })), }); - // ─── FETCH METADATA ON MOUNT ───────────────────────────────────────────────── - + // ─── FETCH METADATA ──────────────────────────────────────────────────────────── useEffect(() => { async function fetchMeta() { setLoadingMeta(true); @@ -119,150 +111,97 @@ export default function TakeQuiz({ const json: TakeResponse | { error: string } = await res.json(); if (!res.ok) { - setMetaError( - (json as any).error || 'Unknown error loading quiz metadata' - ); + setMetaError((json as any).error || 'Unknown error'); setLoadingMeta(false); return; } setQuizMeta(json as TakeResponse); + if ('dueDate' in json && json.dueDate) { + setDueDateStr(json.dueDate); + if (new Date(json.dueDate) < new Date()) { + setIsPastDue(true); + } + } - // Check localStorage for a prior start timestamp const stored = localStorage.getItem(STORAGE_KEY); - if (stored && totalSeconds !== undefined) { + if (stored) { const startTs = parseInt(stored, 10); if (!isNaN(startTs)) { if (totalSeconds !== null) { - // countdown scenario const elapsed = computeElapsedSeconds(startTs); - if (elapsed >= totalSeconds) { - // expired already → auto‐submit - setHasStarted(true); - setTimeLeft(0); - } else { - setHasStarted(true); - setTimeLeft(totalSeconds - elapsed); - } + setHasStarted(true); + setTimeLeft(elapsed >= totalSeconds ? 0 : totalSeconds - elapsed); } else { - // no‐limit scenario → count up from elapsed - const elapsed = computeElapsedSeconds(startTs); setHasStarted(true); - setTimeLeft(elapsed); + setTimeLeft(computeElapsedSeconds(startTs)); } } } } catch { - setMetaError('Network error loading quiz metadata'); + setMetaError('Network error'); } finally { setLoadingMeta(false); } } - fetchMeta(); - return () => { - if (timerRef.current) clearInterval(timerRef.current); + if (timerRef.current) { + clearInterval(timerRef.current); + } }; - // Note: totalSeconds is derived from quizMeta, so on first mount it's undefined; we only want this effect once. // eslint-disable-next-line react-hooks/exhaustive-deps }, [setId]); - // ─── START/RESUME TIMER ONCE `hasStarted` CHANGES ───────────────────────────── - + // ─── TIMER LOGIC ───────────────────────────────────────────────────────────── useEffect(() => { - if (!hasStarted || quizMeta === null) return; + if (!hasStarted || !quizMeta) return; - // If countdown ended immediately (timeLeft===0), do auto‐submit: if (totalSeconds !== null && timeLeft === 0) { - handleSubmit(true); + handleSubmit(); return; } - // Clear any previous interval - if (timerRef.current) { - clearInterval(timerRef.current); - } + timerRef.current && clearInterval(timerRef.current); - // If there is a countdown (totalSeconds != null), decrement timeLeft. if (totalSeconds !== null) { timerRef.current = setInterval(() => { - setTimeLeft((prev) => { - if (prev === null) return null; - if (prev <= 1) { - clearInterval(timerRef.current!); - return 0; - } - return prev - 1; - }); + setTimeLeft((prev) => + prev === null + ? null + : prev <= 1 + ? (clearInterval(timerRef.current!), 0) + : prev - 1 + ); }, 1000); } else { - // No time limit: run a count‐up timer (increment timeLeft each second) timerRef.current = setInterval(() => { setTimeLeft((prev) => (prev === null ? 1 : prev + 1)); }, 1000); } - return () => { - if (timerRef.current) clearInterval(timerRef.current); - }; + return () => void clearInterval(timerRef.current!); }, [hasStarted, quizMeta]); - // ─── AUTO‐SUBMIT ON TIMEOUT ─────────────────────────────────────────────────── - useEffect(() => { - // Only relevant if it's a countdown if (hasStarted && totalSeconds !== null && timeLeft === 0) { - handleSubmit(true); + handleSubmit(); } }, [timeLeft, hasStarted, totalSeconds]); - // ─── HANDLE “TAKE QUIZ” BUTTON CLICK ────────────────────────────────────────── - + // ─── EVENT HANDLERS ───────────────────────────────────────────────────────── const onClickStart = () => { if (!quizMeta) return; - - if (totalSeconds !== null) { - // Countdown case: save start timestamp - const now = Date.now(); - try { - localStorage.setItem(STORAGE_KEY, now.toString()); - } catch { - // ignore - } - setHasStarted(true); - setTimeLeft(totalSeconds); - } else { - // No time limit: count from zero - const now = Date.now(); - try { - localStorage.setItem(STORAGE_KEY, now.toString()); - } catch { - // ignore - } - setHasStarted(true); - setTimeLeft(0); - } + const nowMs = Date.now(); + try { + localStorage.setItem(STORAGE_KEY, nowMs.toString()); + } catch {} + setHasStarted(true); + setTimeLeft(totalSeconds ?? 0); }; - // ─── HANDLE SUBMISSION ──────────────────────────────────────────────────────── - - const handleSubmit = async (_auto: boolean = false) => { + async function handleSubmit() { if (!quizMeta) return; - - // // If not auto‐submit, require all questions answered - // if (!auto) { - // const unanswered = quizMeta.questions.filter( - // (q) => !selectedAnswers[q.quizId] - // ); - // if (unanswered.length > 0) { - // alert( - // t('ws-quizzes.please_answer_all') || 'Please answer all questions.' - // ); - // return; - // } - // } - setSubmitting(true); setSubmitError(null); @@ -279,27 +218,21 @@ export default function TakeQuiz({ if (!res.ok) { setSubmitError((json as any).error || 'Submission failed.'); - setSubmitting(false); - return; + return setSubmitting(false); } - // Success: clear timer, localStorage, and show results clearStartTimestamp(); setSubmitResult(json as SubmitResult); - setSubmitting(false); } catch { setSubmitError('Network error submitting.'); + } finally { setSubmitting(false); } - }; - - // ─── RENDER LOGIC ───────────────────────────────────────────────────────────── + } - // 1) Loading or metadata error + // ─── RENDER ─────────────────────────────────────────────────────────────────── if (loadingMeta) { - return ( -

{t('ws-quizzes.loading') || 'Loading quiz...'}

- ); + return

{t('ws-quizzes.loading') || 'Loading...'}

; } if (metaError) { return

{metaError}

; @@ -308,124 +241,141 @@ export default function TakeQuiz({ return null; } - const { setName, attemptLimit, attemptsSoFar, allowViewResults, questions } = - quizMeta; + // Past due? + if (isPastDue) { + return ( + + ); + } - // 2) If the student has already submitted, show results screen + // After submit: show result summary if (submitResult) { return ( -
-

- {t('ws-quizzes.results') || 'Results'} -

-

- {t('ws-quizzes.attempt')} #{submitResult.attemptNumber}{' '} - {t('ws-quizzes.of')} {attemptLimit ?? t('ws-quizzes.unlimited')} -

-

- {t('ws-quizzes.score')}: {submitResult.totalScore} /{' '} - {submitResult.maxPossibleScore} + + ); + } + + // ─── NEW: Immediate‐release case ───────────────────────────────────────────── + if (!hasStarted && quizMeta.allowViewResults && quizMeta.attemptsSoFar > 0) { + return ( +

+

{quizMeta.setName}

+

+ {t('ws-quizzes.results_available') || + 'Your previous attempt(s) have been scored.'}

+ {dueDateStr && ( +

+ {t('ws-quizzes.due_on') || 'Due on'}:{' '} + {new Date(dueDateStr).toLocaleString()} +

+ )}
); } - // 3) If the student has NOT started yet… - if (!hasStarted) { - // If attempt limit is reached, show “View Results” (if allowed) or “No attempts left” - if (attemptLimit !== null && attemptsSoFar >= attemptLimit) { - return ( -
-

{setName}

-

- {t('ws-quizzes.attempts') || 'Attempts'}: {attemptsSoFar} /{' '} - {attemptLimit} -

- {allowViewResults ? ( - - ) : ( -

- {t('ws-quizzes.no_attempts_left') || 'You have no attempts left.'} -

- )} -
- ); - } - - // Otherwise, show “Take Quiz” button + attempt/time-limit info + // ─── “Not started yet”: no attempts left? ──────────────────────────────────── + if ( + !hasStarted && + quizMeta.attemptLimit !== null && + quizMeta.attemptsSoFar >= quizMeta.attemptLimit + ) { return (
-

{setName}

- - {attemptLimit !== null ? ( -

- {t('ws-quizzes.attempts') || 'Attempts'}: {attemptsSoFar} /{' '} - {attemptLimit} -

- ) : ( -

- {t('ws-quizzes.attempts') || 'Attempts'}: {attemptsSoFar} /{' '} - {t('ws-quizzes.unlimited')} +

{quizMeta.setName}

+ {dueDateStr && ( +

+ {t('ws-quizzes.due_on') || 'Due on'}:{' '} + {new Date(dueDateStr).toLocaleString()}

)} - - {quizMeta.timeLimitMinutes !== null ? ( -

- {t('ws-quizzes.time_limit') || 'Time Limit'}:{' '} - {quizMeta.timeLimitMinutes} {t('ws-quizzes.minutes') || 'minutes'} -

+

+ {t('ws-quizzes.attempts') || 'Attempts'}: {quizMeta.attemptsSoFar} /{' '} + {quizMeta.attemptLimit} +

+ {quizMeta.allowViewResults ? ( + ) : ( -

- {t('ws-quizzes.no_time_limit') || 'No time limit'} +

+ {t('ws-quizzes.no_attempts_left') || 'You have no attempts left.'}

)} - -
); } - // 4) Once hasStarted = true, show timer, sidebar, and question form + // ─── “Take Quiz” button ────────────────────────────────────────────────────── + if (!hasStarted) { + return ( + + ); + } + // ─── QUIZ FORM ─────────────────────────────────────────────────────────────── const isCountdown = totalSeconds !== null; return (
- {/* ── STICKY HEADER ON MOBILE ───────────────────────────────── */} -
+
- {sidebarVisible && ( + {sidebarVisible && quizMeta && ( )}
- {/* ── MAIN CONTENT: Timer + Questions Form ───────────────────────────────── */} +
-

{setName}

+

{quizMeta.setName}

{ e.preventDefault(); - handleSubmit(false); + handleSubmit(); }} className="space-y-8" > - {questions.map((q, idx) => ( -
+ {quizMeta.questions.map((q, idx) => ( +
{idx + 1}. {q.question}{' '} @@ -497,7 +447,7 @@ export default function TakeQuiz({ + )} +
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/show-result-summary-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/show-result-summary-section.tsx new file mode 100644 index 0000000000..f71660b603 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/show-result-summary-section.tsx @@ -0,0 +1,57 @@ +import { Button } from "@tuturuuu/ui/button"; + +export default function ShowResultSummarySection({ + t, + submitResult, + quizMeta, + wsId, + courseId, + moduleId, + router, +}: { + t: (key: string) => string; + submitResult: { + attemptNumber: number; + totalScore: number; + maxPossibleScore: number; + }; + quizMeta: { + attemptLimit: number | null; + setName: string; + attemptsSoFar: number; + timeLimitMinutes: number | null; + }; + wsId: string; + courseId: string; + moduleId: string; + router: { + push: (url: string) => void; + }; +}) { + return ( +
+

+ {t('ws-quizzes.results') || 'Results'} +

+

+ {t('ws-quizzes.attempt')} #{submitResult.attemptNumber}{' '} + {t('ws-quizzes.of')}{' '} + {quizMeta.attemptLimit ?? t('ws-quizzes.unlimited')} +

+

+ {t('ws-quizzes.score')}: {submitResult.totalScore} /{' '} + {submitResult.maxPossibleScore} +

+ +
+ ); +} diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts index 8585989ae3..a7aae61a6f 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts @@ -2,14 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@tuturuuu/supabase/next/server'; -type QuestionInfo = { - quizId: string; - question: string; - correctOptionId: string; - correctOptionValue: string; - scoreWeight: number; -}; - type AttemptAnswer = { quizId: string; question: string; @@ -18,11 +10,10 @@ type AttemptAnswer = { isCorrect: boolean; scoreAwarded: number; }; - type AttemptDTO = { attemptId: string; attemptNumber: number; - totalScore: number; // must be a number, not null + totalScore: number; maxPossibleScore: number; startedAt: string; completedAt: string | null; @@ -33,10 +24,10 @@ export async function GET( request: NextRequest, { params }: { params: { setId: string } } ) { - const setId = params.setId; + const { setId } = params; const supabase = await createClient(); - // 1) Authenticate + // 1) Auth const { data: { user }, error: userErr, @@ -46,36 +37,21 @@ export async function GET( } const userId = user.id; - // 2) Fetch quiz_set metadata (release rules) + // 2) Always allow if they have any attempts—and if allow_view_results is true const { data: setRow, error: setErr } = await supabase .from('workspace_quiz_sets') - .select('release_points_immediately, release_at') + .select('allow_view_results') .eq('id', setId) .maybeSingle(); if (setErr || !setRow) { return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 }); } - const { release_points_immediately, release_at } = setRow; - - // Compute whether results are visible - let allowView = false; - if (release_points_immediately) { - allowView = true; - } else if (release_at) { - const now = new Date(); - if (new Date(release_at) <= now) { - allowView = true; - } - } - if (!allowView) { - return NextResponse.json( - { error: 'Results are not yet released' }, - { status: 403 } - ); + if (!setRow.allow_view_results) { + return NextResponse.json({ error: 'Viewing results is disabled' }, { status: 403 }); } - // 3) Fetch ALL questions in this set, with correct option and weight + // 3) Fetch question info (correct answers + weight) const { data: questionsRaw, error: qErr } = await supabase .from('quiz_set_quizzes') .select(` @@ -85,7 +61,6 @@ export async function GET( score ), quiz_options!inner ( - id, value ) `) @@ -96,20 +71,15 @@ export async function GET( return NextResponse.json({ error: 'Error fetching questions' }, { status: 500 }); } - // Build questionInfo array - const questionInfo: QuestionInfo[] = (questionsRaw || []).map((row: any) => ({ + const questionInfo = (questionsRaw || []).map((row: any) => ({ quizId: row.quiz_id, question: row.workspace_quizzes.question, scoreWeight: row.workspace_quizzes.score, - correctOptionId: row.quiz_options.id, correctOptionValue: row.quiz_options.value, })); - const qMap = new Map(); - questionInfo.forEach((q) => { - qMap.set(q.quizId, q); - }); + const maxPossibleScore = questionInfo.reduce((s, q) => s + q.scoreWeight, 0); - // 4) Fetch all attempts by this user for this set + // 4) Fetch all attempts by user const { data: attemptsData, error: attemptsErr } = await supabase .from('workspace_quiz_attempts') .select(` @@ -127,19 +97,14 @@ export async function GET( return NextResponse.json({ error: 'Error fetching attempts' }, { status: 500 }); } const attempts = attemptsData || []; - if (!attempts.length) { return NextResponse.json({ error: 'No attempts found' }, { status: 404 }); } - // 5) Compute maxPossibleScore once - const maxPossibleScore = questionInfo.reduce((acc, q) => acc + q.scoreWeight, 0); - - // 6) For each attempt, fetch its answers and build AttemptDTO + // 5) For each attempt, fetch its answers const resultDTOs: AttemptDTO[] = []; for (const att of attempts) { - // 6a) Fetch all answers for this attempt const { data: answerRows, error: ansErr } = await supabase .from('workspace_quiz_attempt_answers') .select(` @@ -151,57 +116,46 @@ export async function GET( .eq('attempt_id', att.id); if (ansErr) { - return NextResponse.json( - { error: 'Error fetching attempt answers' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Error fetching attempt answers' }, { status: 500 }); } - // Build a map: quizId → answerRow - const aMap = new Map(); - (answerRows || []).forEach((a: any) => { - aMap.set(a.quiz_id, a); - }); + const aMap = new Map(answerRows!.map((a: any) => [a.quiz_id, a])); + + const answers = await Promise.all( + questionInfo.map(async (qi) => { + const a = aMap.get(qi.quizId); + if (a) { + const { data: selOpt, error: selErr } = await supabase + .from('quiz_options') + .select('value') + .eq('id', a.selected_option_id) + .maybeSingle(); + + return { + quizId: qi.quizId, + question: qi.question, + selectedOption: selErr || !selOpt ? null : selOpt.value, + correctOption: qi.correctOptionValue, + isCorrect: a.is_correct, + scoreAwarded: a.score_awarded, + }; + } else { + return { + quizId: qi.quizId, + question: qi.question, + selectedOption: null, + correctOption: qi.correctOptionValue, + isCorrect: false, + scoreAwarded: 0, + }; + } + }) + ); - // 6b) For each question, assemble AttemptAnswer - const answers: AttemptAnswer[] = await Promise.all(questionInfo.map(async (qi) => { - const aRow = aMap.get(qi.quizId); - if (aRow) { - // Student answered the question - // Fetch selected option’s text - const { data: selOptRow, error: selErr } = await supabase - .from('quiz_options') - .select('value') - .eq('id', aRow.selected_option_id) - .maybeSingle(); - const selectedValue = selErr || !selOptRow ? '' : selOptRow.value; - - return { - quizId: qi.quizId, - question: qi.question, - selectedOption: selectedValue, - correctOption: qi.correctOptionValue, - isCorrect: aRow.is_correct, - scoreAwarded: aRow.score_awarded, - }; - } else { - // Student left it blank - return { - quizId: qi.quizId, - question: qi.question, - selectedOption: null, - correctOption: qi.correctOptionValue, - isCorrect: false, - scoreAwarded: 0, - }; - } - })); - - // 6c) Build the AttemptDTO, coercing total_score to 0 if null resultDTOs.push({ attemptId: att.id, attemptNumber: att.attempt_number, - totalScore: att.total_score ?? 0, // <-- coerce null to 0 + totalScore: att.total_score ?? 0, maxPossibleScore, startedAt: att.started_at, completedAt: att.completed_at, @@ -209,6 +163,5 @@ export async function GET( }); } - // 7) Return the assembled DTOs return NextResponse.json({ attempts: resultDTOs }); } diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts index 2d29fb2fb5..9a90237e1d 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts @@ -1,27 +1,24 @@ // File: app/api/quiz-sets/[setId]/take/route.ts -import { createClient } from '@tuturuuu/supabase/next/server'; import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@tuturuuu/supabase/next/server'; type RawRow = { quiz_id: string; workspace_quizzes: { question: string; score: number; - quiz_options: Array<{ - id: string; - value: string; - }>; + quiz_options: { id: string; value: string }[]; }; }; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: { setId: string } } ) { - const setId = params.setId; + const { setId } = params; const supabase = await createClient(); - // 1) Authenticate + // 1) Auth const { data: { user }, error: userErr, @@ -31,12 +28,17 @@ export async function GET( } const userId = user.id; - // 2) Fetch quiz set metadata, including the new “release” fields + // 2) Fetch quiz-set metadata const { data: setRow, error: setErr } = await supabase .from('workspace_quiz_sets') - .select( - 'id, name, time_limit_minutes, attempt_limit, release_points_immediately, release_at' - ) + .select(` + id, + name, + attempt_limit, + time_limit_minutes, + due_date, + release_points_immediately + `) .eq('id', setId) .maybeSingle(); @@ -46,13 +48,21 @@ export async function GET( const { name: setName, - time_limit_minutes, attempt_limit, + time_limit_minutes, + due_date, release_points_immediately, - release_at, } = setRow; - // 3) Count how many attempts this user already has + // 3) due_date enforcement + if (new Date(due_date) < new Date()) { + return NextResponse.json( + { error: 'Quiz is past its due date', dueDate: due_date }, + { status: 403 } + ); + } + + // 4) Count previous attempts const { data: prevAttempts, error: attErr } = await supabase .from('workspace_quiz_attempts') .select('attempt_number', { count: 'exact', head: false }) @@ -60,17 +70,13 @@ export async function GET( .eq('set_id', setId); if (attErr) { - return NextResponse.json( - { error: 'Error counting attempts' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Error counting attempts' }, { status: 500 }); } const attemptsCount = prevAttempts?.length ?? 0; - // 4) If attempt_limit is set and they’ve used them all, return 403 + // 5) If limit reached, block if ( attempt_limit !== null && - attempt_limit !== undefined && attemptsCount >= attempt_limit ) { return NextResponse.json( @@ -78,30 +84,27 @@ export async function GET( error: 'Maximum attempts reached', attemptsSoFar: attemptsCount, attemptLimit: attempt_limit, - allowViewResults: false, // if they maxed out before, still no results until release time + dueDate: due_date, + allowViewResults: false, }, { status: 403 } ); } - // 5) Compute allowViewResults: - // True if either release_points_immediately = true, OR (release_at <= now). - let allowViewResults = false; - if (release_points_immediately) { - allowViewResults = true; - } else if (release_at) { - const now = new Date(); - const releaseDate = new Date(release_at); - if (releaseDate <= now) { - allowViewResults = true; - } + // 6) If release is immediate AND they’ve already done ≥1 attempt, return past attempts directly + if (release_points_immediately && attemptsCount > 0) { + // Fetch and return their attempts (very basic summary; frontend can call /results for detail) + return NextResponse.json({ + message: 'Results are viewable immediately', + attemptsSoFar: attemptsCount, + allowViewResults: true, + }); } - // 6) Fetch all questions + nested options + score + // 7) Otherwise, return questions for taking const { data: rawData, error: quizErr } = await supabase .from('quiz_set_quizzes') - .select( - ` + .select(` quiz_id, workspace_quizzes ( question, @@ -111,36 +114,30 @@ export async function GET( value ) ) - ` - ) + `) .eq('set_id', setId); if (quizErr) { - return NextResponse.json( - { error: 'Error fetching quiz questions' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Error fetching questions' }, { status: 500 }); } - const quizRows = (rawData as unknown as RawRow[]) ?? []; - const questions = quizRows.map((row) => ({ + const questions = (rawData as RawRow[]).map((row) => ({ quizId: row.quiz_id, question: row.workspace_quizzes.question, score: row.workspace_quizzes.score, - options: row.workspace_quizzes.quiz_options.map((opt) => ({ - id: opt.id, - value: opt.value, + options: row.workspace_quizzes.quiz_options.map((o) => ({ + id: o.id, + value: o.value, })), })); - // 7) Return everything return NextResponse.json({ setId, setName, - timeLimitMinutes: time_limit_minutes, attemptLimit: attempt_limit, + timeLimitMinutes: time_limit_minutes, attemptsSoFar: attemptsCount, - allowViewResults, + dueDate: due_date, questions, }); } diff --git a/apps/upskii/src/lib/release-quiz-sets.ts b/apps/upskii/src/lib/release-quiz-sets.ts new file mode 100644 index 0000000000..7910da1d43 --- /dev/null +++ b/apps/upskii/src/lib/release-quiz-sets.ts @@ -0,0 +1,44 @@ +// File: lib/releaseQuizSets.ts +import { createClient } from '@tuturuuu/supabase/next/server'; + +/** + * Finds all quiz‐sets where: + * release_points_immediately = false, + * results_released = false, + * release_at <= now(), + * and sets results_released = true. + */ +export default async function releaseQuizSets() { + const supabase = await createClient(); + + // 1) Select all sets that need release + const { data: toRelease, error: findErr } = await supabase + .from('workspace_quiz_sets') + .select('id') + .eq('release_points_immediately', false) + .eq('results_released', false) + .lte('release_at', new Date().toISOString()); + + if (findErr) { + console.error('Error querying quiz‐sets to release:', findErr); + return { releasedCount: 0, error: findErr.message }; + } + if (!toRelease || toRelease.length === 0) { + return { releasedCount: 0 }; + } + + // 2) Update all of them: set results_released = true + const ids = toRelease.map((row) => row.id); + const { data: updated, error: updErr } = await supabase + .from('workspace_quiz_sets') + .update({ results_released: true }) + .in('id', ids) + .select('id'); + + if (updErr) { + console.error('Error updating quiz‐sets to release:', updErr); + return { releasedCount: 0, error: updErr.message }; + } + + return { releasedCount: updated?.length ?? 0 }; +} diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index 5aec394870..63a2792037 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -5569,9 +5569,9 @@ export type Database = { allow_view_results: boolean; attempt_limit: number | null; created_at: string; + due_date: string; id: string; name: string; - release_at: string | null; release_points_immediately: boolean; time_limit_minutes: number | null; ws_id: string | null; @@ -5580,9 +5580,9 @@ export type Database = { allow_view_results?: boolean; attempt_limit?: number | null; created_at?: string; + due_date?: string; id?: string; name?: string; - release_at?: string | null; release_points_immediately?: boolean; time_limit_minutes?: number | null; ws_id?: string | null; @@ -5591,9 +5591,9 @@ export type Database = { allow_view_results?: boolean; attempt_limit?: number | null; created_at?: string; + due_date?: string; id?: string; name?: string; - release_at?: string | null; release_points_immediately?: boolean; time_limit_minutes?: number | null; ws_id?: string | null; From 5275499809573a36aedd4ea6517b3966b832952b Mon Sep 17 00:00:00 2001 From: Tran Mai Nhung <32950625+Puppychan@users.noreply.github.com> Date: Sun, 8 Jun 2025 18:37:37 +0700 Subject: [PATCH 13/26] fix(taking quizz): fix deploy --- .../[wsId]/quiz-sets/[setId]/result/page.tsx | 173 ------------------ 1 file changed, 173 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx index 5fb156f7d1..e69de29bb2 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx @@ -1,173 +0,0 @@ -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/results/page.tsx -'use client'; - -import { useEffect, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { useRouter } from 'next/navigation'; -import { Button } from '@tuturuuu/ui/button'; - -/** - * Client-side types matching AttemptDTO defined in the /results route. - */ -type AttemptAnswer = { - quizId: string; - question: string; - selectedOption: string | null; - correctOption: string; - isCorrect: boolean; - scoreAwarded: number; -}; -type AttemptDTO = { - attemptId: string; - attemptNumber: number; - totalScore: number; - maxPossibleScore: number; - startedAt: string; - completedAt: string | null; - answers: AttemptAnswer[]; -}; - -export default function ViewResults({ - params, -}: { - params: { - wsId: string; - courseId: string; - moduleId: string; - setId: string; - }; -}) { - const { wsId, courseId, moduleId, setId } = params; - const t = useTranslations(); - const router = useRouter(); - - const [loading, setLoading] = useState(true); - const [errorMsg, setErrorMsg] = useState(null); - const [attempts, setAttempts] = useState(null); - - useEffect(() => { - async function fetchResults() { - setLoading(true); - try { - const res = await fetch( - `/api/quiz-sets/${setId}/results` - ); - const json: { attempts?: AttemptDTO[]; error?: string } = await res.json(); - - if (!res.ok) { - setErrorMsg(json.error || 'Error loading results'); - setLoading(false); - return; - } - setAttempts(json.attempts || []); - } catch { - setErrorMsg('Network error loading results'); - } finally { - setLoading(false); - } - } - fetchResults(); - }, [wsId, setId]); - - if (loading) { - return

{t('ws-quizzes.loading') || 'Loading results...'}

; - } - if (errorMsg) { - return ( -
-

{errorMsg}

- -
- ); - } - if (!attempts || attempts.length === 0) { - return ( -
-

{t('ws-quizzes.no_attempts_found') || 'No attempts found.'}

- -
- ); - } - - return ( -
-

- {t('ws-quizzes.past_attempts') || 'Past Attempts'} -

- - {attempts.map((att) => ( -
-
-

- {t('ws-quizzes.attempt')} #{att.attemptNumber} -

-

- {t('ws-quizzes.scored') || 'Scored'}: {att.totalScore} / {att.maxPossibleScore} -

-
-

- {t('ws-quizzes.started_at') || 'Started at'}: {new Date(att.startedAt).toLocaleString()} - {att.completedAt && ( - <> - {' | '} - {t('ws-quizzes.completed_at') || 'Completed at'}: {new Date(att.completedAt).toLocaleString()} - - )} -

- -
- {t('ws-quizzes.#') || '#'} - - {t('ws-quizzes.question') || 'Question'} - - {t('ws-quizzes.your_answer') || 'Your Answer'} - - {t('ws-quizzes.correct_answer') || 'Correct Answer'} - - {t('ws-quizzes.points') || 'Points'} - {t('ws-quizzes.#') || '#'}{t('ws-quizzes.question') || 'Question'}{t('ws-quizzes.your_answer') || 'Your Answer'}{t('ws-quizzes.correct_answer') || 'Correct Answer'}{t('ws-quizzes.points') || 'Points'}
{idx + 1} {ans.question} @@ -168,9 +150,7 @@ export default function ViewResults({ : ans.selectedOption} {ans.correctOption} - {ans.scoreAwarded} - {ans.scoreAwarded}
- - - - - - - - - - - {att.answers.map((ans, idx) => ( - - - - - - - - ))} - -
{t('ws-quizzes.#') || '#'}{t('ws-quizzes.question') || 'Question'}{t('ws-quizzes.your_answer') || 'Your Answer'}{t('ws-quizzes.correct_answer') || 'Correct Answer'}{t('ws-quizzes.points') || 'Points'}
{idx + 1}{ans.question} - {ans.selectedOption === null - ? t('ws-quizzes.no_answer') || 'No Answer' - : ans.selectedOption} - {ans.correctOption}{ans.scoreAwarded}
-
- ))} - - -
- ); -} From 2de148e75ecb4a4431808cba8c906a893cbbd6c1 Mon Sep 17 00:00:00 2001 From: Tran Mai Nhung <32950625+Puppychan@users.noreply.github.com> Date: Sun, 8 Jun 2025 18:42:31 +0700 Subject: [PATCH 14/26] fix<(taking quizz): fix deploy --- .../(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx index e69de29bb2..e738d2bf8a 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx @@ -0,0 +1,5 @@ +export default function Page() { + return ( +
Hello
+) +} \ No newline at end of file From 2db25ca4e5714c78fe60a8bddf8bdc85b1e37be3 Mon Sep 17 00:00:00 2001 From: Nhung Date: Mon, 9 Jun 2025 01:55:22 +0700 Subject: [PATCH 15/26] devs(TakingQuiz); Fix deploy --- .../[wsId]/quiz-sets/[setId]/take/page.tsx | 18 ++------ .../[wsId]/quiz-sets/[setId]/results/route.ts | 46 +++++++++++++------ .../[wsId]/quiz-sets/[setId]/submit/route.ts | 37 +++++++++++---- .../[wsId]/quiz-sets/[setId]/take/route.ts | 35 ++++++++------ apps/upskii/src/lib/release-quiz-sets.ts | 44 ------------------ 5 files changed, 84 insertions(+), 96 deletions(-) delete mode 100644 apps/upskii/src/lib/release-quiz-sets.ts diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx index eb18e8a4b7..4b9cdae6a1 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx @@ -14,16 +14,6 @@ import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx - -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx - -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx - -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx - -// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx - type TakeResponse = { setId: string; setName: string; @@ -42,17 +32,17 @@ type SubmitResult = { maxPossibleScore: number; }; -export default function TakeQuiz({ +export default async function TakeQuiz({ params, }: { - params: { + params: Promise<{ wsId: string; courseId: string; moduleId: string; setId: string; - }; + }>; }) { - const { wsId, courseId, moduleId, setId } = params; + const { wsId, courseId, moduleId, setId } = await params; const t = useTranslations(); const router = useRouter(); diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts index a7aae61a6f..5f301e808a 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts @@ -1,6 +1,6 @@ // File: app/api/quiz-sets/[setId]/results/route.ts -import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@tuturuuu/supabase/next/server'; +import { NextRequest, NextResponse } from 'next/server'; type AttemptAnswer = { quizId: string; @@ -21,10 +21,10 @@ type AttemptDTO = { }; export async function GET( - request: NextRequest, - { params }: { params: { setId: string } } + _request: NextRequest, + { params }: { params: Promise<{ setId: string }> } ) { - const { setId } = params; + const { setId } = await params; const supabase = await createClient(); // 1) Auth @@ -48,13 +48,17 @@ export async function GET( return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 }); } if (!setRow.allow_view_results) { - return NextResponse.json({ error: 'Viewing results is disabled' }, { status: 403 }); + return NextResponse.json( + { error: 'Viewing results is disabled' }, + { status: 403 } + ); } // 3) Fetch question info (correct answers + weight) const { data: questionsRaw, error: qErr } = await supabase .from('quiz_set_quizzes') - .select(` + .select( + ` quiz_id, workspace_quizzes ( question, @@ -63,12 +67,16 @@ export async function GET( quiz_options!inner ( value ) - `) + ` + ) .eq('set_id', setId) .eq('quiz_options.is_correct', true); if (qErr) { - return NextResponse.json({ error: 'Error fetching questions' }, { status: 500 }); + return NextResponse.json( + { error: 'Error fetching questions' }, + { status: 500 } + ); } const questionInfo = (questionsRaw || []).map((row: any) => ({ @@ -82,19 +90,24 @@ export async function GET( // 4) Fetch all attempts by user const { data: attemptsData, error: attemptsErr } = await supabase .from('workspace_quiz_attempts') - .select(` + .select( + ` id, attempt_number, total_score, started_at, completed_at - `) + ` + ) .eq('user_id', userId) .eq('set_id', setId) .order('attempt_number', { ascending: false }); if (attemptsErr) { - return NextResponse.json({ error: 'Error fetching attempts' }, { status: 500 }); + return NextResponse.json( + { error: 'Error fetching attempts' }, + { status: 500 } + ); } const attempts = attemptsData || []; if (!attempts.length) { @@ -107,16 +120,21 @@ export async function GET( for (const att of attempts) { const { data: answerRows, error: ansErr } = await supabase .from('workspace_quiz_attempt_answers') - .select(` + .select( + ` quiz_id, selected_option_id, is_correct, score_awarded - `) + ` + ) .eq('attempt_id', att.id); if (ansErr) { - return NextResponse.json({ error: 'Error fetching attempt answers' }, { status: 500 }); + return NextResponse.json( + { error: 'Error fetching attempt answers' }, + { status: 500 } + ); } const aMap = new Map(answerRows!.map((a: any) => [a.quiz_id, a])); diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts index b8b427790f..24e18542a4 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts @@ -1,6 +1,6 @@ // File: app/api/quiz-sets/[setId]/submit/route.ts -import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@tuturuuu/supabase/next/server'; +import { NextRequest, NextResponse } from 'next/server'; type SubmissionBody = { answers: Array<{ @@ -22,9 +22,9 @@ type RawRow = { export async function POST( request: NextRequest, - { params }: { params: { setId: string } } + { params }: { params: Promise<{ setId: string }> } ) { - const setId = params.setId; + const { setId } = await params; const supabase = await createClient(); // 1) Get current user @@ -57,7 +57,10 @@ export async function POST( .eq('set_id', setId); if (attErr) { - return NextResponse.json({ error: 'Error counting attempts' }, { status: 500 }); + return NextResponse.json( + { error: 'Error counting attempts' }, + { status: 500 } + ); } const attemptsCount = prevAttempts?.length || 0; @@ -77,7 +80,10 @@ export async function POST( attempt_limit !== undefined && attemptsCount >= attempt_limit ) { - return NextResponse.json({ error: 'Maximum attempts reached' }, { status: 403 }); + return NextResponse.json( + { error: 'Maximum attempts reached' }, + { status: 403 } + ); } // 5) We will create a new attempt row with attempt_number = attemptsCount + 1 @@ -87,7 +93,8 @@ export async function POST( // Notice we nest `quiz_options` under `workspace_quizzes`: const { data: correctRaw, error: corrErr } = await supabase .from('quiz_set_quizzes') - .select(` + .select( + ` quiz_id, workspace_quizzes ( score, @@ -96,11 +103,15 @@ export async function POST( is_correct ) ) - `) + ` + ) .eq('set_id', setId); if (corrErr) { - return NextResponse.json({ error: 'Error fetching correct answers' }, { status: 500 }); + return NextResponse.json( + { error: 'Error fetching correct answers' }, + { status: 500 } + ); } // 7) Tell TypeScript: "Trust me—this matches RawRow[]" @@ -163,7 +174,10 @@ export async function POST( .single(); if (insErr || !insertedAttempt) { - return NextResponse.json({ error: 'Error inserting attempt' }, { status: 500 }); + return NextResponse.json( + { error: 'Error inserting attempt' }, + { status: 500 } + ); } const attemptId = insertedAttempt.id; @@ -181,7 +195,10 @@ export async function POST( ); if (ansErr) { - return NextResponse.json({ error: 'Error inserting answers' }, { status: 500 }); + return NextResponse.json( + { error: 'Error inserting answers' }, + { status: 500 } + ); } // 11) Mark the attempt’s completed_at timestamp diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts index 9a90237e1d..0243052c49 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts @@ -1,6 +1,6 @@ // File: app/api/quiz-sets/[setId]/take/route.ts -import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@tuturuuu/supabase/next/server'; +import { NextRequest, NextResponse } from 'next/server'; type RawRow = { quiz_id: string; @@ -12,10 +12,10 @@ type RawRow = { }; export async function GET( - request: NextRequest, - { params }: { params: { setId: string } } + _request: NextRequest, + { params }: { params: Promise<{ setId: string }> } ) { - const { setId } = params; + const { setId } = await params; const supabase = await createClient(); // 1) Auth @@ -31,14 +31,16 @@ export async function GET( // 2) Fetch quiz-set metadata const { data: setRow, error: setErr } = await supabase .from('workspace_quiz_sets') - .select(` + .select( + ` id, name, attempt_limit, time_limit_minutes, due_date, release_points_immediately - `) + ` + ) .eq('id', setId) .maybeSingle(); @@ -70,15 +72,15 @@ export async function GET( .eq('set_id', setId); if (attErr) { - return NextResponse.json({ error: 'Error counting attempts' }, { status: 500 }); + return NextResponse.json( + { error: 'Error counting attempts' }, + { status: 500 } + ); } const attemptsCount = prevAttempts?.length ?? 0; // 5) If limit reached, block - if ( - attempt_limit !== null && - attemptsCount >= attempt_limit - ) { + if (attempt_limit !== null && attemptsCount >= attempt_limit) { return NextResponse.json( { error: 'Maximum attempts reached', @@ -104,7 +106,8 @@ export async function GET( // 7) Otherwise, return questions for taking const { data: rawData, error: quizErr } = await supabase .from('quiz_set_quizzes') - .select(` + .select( + ` quiz_id, workspace_quizzes ( question, @@ -114,11 +117,15 @@ export async function GET( value ) ) - `) + ` + ) .eq('set_id', setId); if (quizErr) { - return NextResponse.json({ error: 'Error fetching questions' }, { status: 500 }); + return NextResponse.json( + { error: 'Error fetching questions' }, + { status: 500 } + ); } const questions = (rawData as RawRow[]).map((row) => ({ diff --git a/apps/upskii/src/lib/release-quiz-sets.ts b/apps/upskii/src/lib/release-quiz-sets.ts deleted file mode 100644 index 7910da1d43..0000000000 --- a/apps/upskii/src/lib/release-quiz-sets.ts +++ /dev/null @@ -1,44 +0,0 @@ -// File: lib/releaseQuizSets.ts -import { createClient } from '@tuturuuu/supabase/next/server'; - -/** - * Finds all quiz‐sets where: - * release_points_immediately = false, - * results_released = false, - * release_at <= now(), - * and sets results_released = true. - */ -export default async function releaseQuizSets() { - const supabase = await createClient(); - - // 1) Select all sets that need release - const { data: toRelease, error: findErr } = await supabase - .from('workspace_quiz_sets') - .select('id') - .eq('release_points_immediately', false) - .eq('results_released', false) - .lte('release_at', new Date().toISOString()); - - if (findErr) { - console.error('Error querying quiz‐sets to release:', findErr); - return { releasedCount: 0, error: findErr.message }; - } - if (!toRelease || toRelease.length === 0) { - return { releasedCount: 0 }; - } - - // 2) Update all of them: set results_released = true - const ids = toRelease.map((row) => row.id); - const { data: updated, error: updErr } = await supabase - .from('workspace_quiz_sets') - .update({ results_released: true }) - .in('id', ids) - .select('id'); - - if (updErr) { - console.error('Error updating quiz‐sets to release:', updErr); - return { releasedCount: 0, error: updErr.message }; - } - - return { releasedCount: updated?.length ?? 0 }; -} From b3ae5e57b405b7310a930462de62ee29071f50a5 Mon Sep 17 00:00:00 2001 From: Nhung Date: Mon, 9 Jun 2025 01:59:59 +0700 Subject: [PATCH 16/26] devs(TakingQuiz); Fix deploy --- .../quiz-sets/mock/quiz-sets-mock-data.ts | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/mock/quiz-sets-mock-data.ts diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/mock/quiz-sets-mock-data.ts b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/mock/quiz-sets-mock-data.ts deleted file mode 100644 index 0067b384be..0000000000 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/mock/quiz-sets-mock-data.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { WorkspaceQuizSet } from '@tuturuuu/types/db'; - -export const mockQuizSets: WorkspaceQuizSet[] = [ - { - created_at: '2023-10-01T12:00:00Z', - id: '1', - name: 'Quiz Set 1', - ws_id: 'ws_1', - href: '/ws_1/quiz-sets/1', - }, - { - created_at: '2023-10-02T12:00:00Z', - id: '2', - name: 'Quiz Set 2', - ws_id: 'ws_1', - href: '/ws_1/quiz-sets/2', - }, - { - created_at: '2023-10-03T12:00:00Z', - id: '3', - name: 'Quiz Set 3', - ws_id: 'ws_1', - href: '/ws_1/quiz-sets/3', - }, - { - created_at: '2023-10-04T12:00:00Z', - id: '4', - name: 'Quiz Set 4', - ws_id: 'ws_1', - href: '/ws_1/quiz-sets/4', - }, - { - created_at: '2023-10-05T12:00:00Z', - id: '5', - name: 'Quiz Set 5', - ws_id: 'ws_1', - href: '/ws_1/quiz-sets/5', - }, -]; From 06e1d545c8fc0d7985b20df4a3a6e5b75e86cc52 Mon Sep 17 00:00:00 2001 From: Puppychan <32950625+Puppychan@users.noreply.github.com> Date: Sun, 8 Jun 2025 19:02:38 +0000 Subject: [PATCH 17/26] style: apply prettier formatting --- .../[courseId]/modules/[moduleId]/page.tsx | 1 - .../[moduleId]/quizzes/client-quizzes.tsx | 2 +- .../[wsId]/quiz-sets/[setId]/result/page.tsx | 6 ++-- .../[setId]/take/before-take-quiz-section.tsx | 28 +++++++++---------- .../[wsId]/quiz-sets/[setId]/take/page.tsx | 2 ++ .../take/show-result-summary-section.tsx | 2 +- .../v1/workspaces/[wsId]/quiz-sets/route.ts | 8 ++++-- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx index b7adc7b434..f08928f776 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx @@ -299,7 +299,6 @@ const getQuizzes = async (moduleId: string) => { return Array.from(grouped.values()); }; - async function getResources({ path }: { path: string }) { const supabase = await createDynamicClient(); diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx index e6aa15b3ec..c988aa0090 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx @@ -96,7 +96,7 @@ export default function ClientQuizzes({ className="col-span-full flex w-full flex-col gap-4" >

- + {set.setName}

diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx index e738d2bf8a..11df9bb334 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx @@ -1,5 +1,3 @@ export default function Page() { - return ( -
Hello
-) -} \ No newline at end of file + return
Hello
; +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section.tsx index 5897844479..b7041a97d2 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section.tsx @@ -2,20 +2,20 @@ import { Button } from '@tuturuuu/ui/button'; import React from 'react'; export default function BeforeTakeQuizSection({ - t, - quizMeta, - dueDateStr, - onClickStart, - }: { - t: (key: string, options?: Record) => string; - quizMeta: { - setName: string; - attemptsSoFar: number; - attemptLimit: number | null; - timeLimitMinutes: number | null; - }; - dueDateStr?: string | null; - onClickStart: () => void; + t, + quizMeta, + dueDateStr, + onClickStart, +}: { + t: (key: string, options?: Record) => string; + quizMeta: { + setName: string; + attemptsSoFar: number; + attemptLimit: number | null; + timeLimitMinutes: number | null; + }; + dueDateStr?: string | null; + onClickStart: () => void; }) { return (
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx index 4b9cdae6a1..d9d559cdae 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx @@ -14,6 +14,8 @@ import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + type TakeResponse = { setId: string; setName: string; diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/show-result-summary-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/show-result-summary-section.tsx index f71660b603..51113323e0 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/show-result-summary-section.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/show-result-summary-section.tsx @@ -1,4 +1,4 @@ -import { Button } from "@tuturuuu/ui/button"; +import { Button } from '@tuturuuu/ui/button'; export default function ShowResultSummarySection({ t, diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts index c3c7e32c93..5b307bd2e6 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/route.ts @@ -54,7 +54,7 @@ export async function POST(req: Request, { params }: Params) { { status: 500 } ); } - let renderedName = ""; + let renderedName = ''; if (!quizSetName || quizSetName.length === 0) { renderedName = formattedName; } else { @@ -108,5 +108,9 @@ export async function POST(req: Request, { params }: Params) { .insert(quiz_options.map((o: any) => ({ ...o, quiz_id: data.id }))); } - return NextResponse.json({ message: 'success', setId: data.id, name: renderedName }); + return NextResponse.json({ + message: 'success', + setId: data.id, + name: renderedName, + }); } From c0fe66c77088b60ffb04a6f87968507604fe9948 Mon Sep 17 00:00:00 2001 From: Puppychan <32950625+Puppychan@users.noreply.github.com> Date: Sun, 8 Jun 2025 19:16:50 +0000 Subject: [PATCH 18/26] style: apply prettier formatting --- .../[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx index d9d559cdae..28a8445333 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx @@ -16,6 +16,8 @@ import { useEffect, useRef, useState } from 'react'; // File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + type TakeResponse = { setId: string; setName: string; From 6315b248ef2c377ff173b368780d3fcfa08f1623 Mon Sep 17 00:00:00 2001 From: vhpx <81307469+vhpx@users.noreply.github.com> Date: Mon, 9 Jun 2025 01:54:22 +0000 Subject: [PATCH 19/26] style: apply prettier formatting --- .../[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx index 28a8445333..7605b41793 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx @@ -18,6 +18,8 @@ import { useEffect, useRef, useState } from 'react'; // File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + type TakeResponse = { setId: string; setName: string; From 84a571160ddffaf1df3d8d7b95c49dacf5e68803 Mon Sep 17 00:00:00 2001 From: Henry7482 <117657788+Henry7482@users.noreply.github.com> Date: Mon, 9 Jun 2025 08:21:27 +0000 Subject: [PATCH 20/26] style: apply prettier formatting --- .../[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx index 7605b41793..6b59445e91 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx @@ -20,6 +20,8 @@ import { useEffect, useRef, useState } from 'react'; // File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx +// File: app/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/[setId]/take/page.tsx + type TakeResponse = { setId: string; setName: string; From f8458609549636cd1d3f8a36691554a5f89b5963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=B5=20Ho=C3=A0ng=20Ph=C3=BAc?= Date: Mon, 9 Jun 2025 16:29:18 +0700 Subject: [PATCH 21/26] chore(db): consolidate migration files --- .../20250605064241_new_migration.sql | 20 --------------- .../20250606073849_new_migration.sql | 5 ---- .../20250608051026_new_migration.sql | 5 ---- ...l => 20250609092649_add_quiz_attempts.sql} | 25 +++++++++++++++++++ 4 files changed, 25 insertions(+), 30 deletions(-) delete mode 100644 apps/db/supabase/migrations/20250605064241_new_migration.sql delete mode 100644 apps/db/supabase/migrations/20250606073849_new_migration.sql delete mode 100644 apps/db/supabase/migrations/20250608051026_new_migration.sql rename apps/db/supabase/migrations/{20250604125645_new_migration.sql => 20250609092649_add_quiz_attempts.sql} (86%) diff --git a/apps/db/supabase/migrations/20250605064241_new_migration.sql b/apps/db/supabase/migrations/20250605064241_new_migration.sql deleted file mode 100644 index c158ed5dc2..0000000000 --- a/apps/db/supabase/migrations/20250605064241_new_migration.sql +++ /dev/null @@ -1,20 +0,0 @@ -alter table "public"."workspace_quiz_sets" add column "allow_view_results" boolean not null default true; - -alter table "public"."workspace_quiz_sets" add column "release_at" timestamp with time zone; - -alter table "public"."workspace_quiz_sets" add column "release_points_immediately" boolean not null default true; - -set check_function_bodies = off; - -CREATE OR REPLACE FUNCTION public.sum_quiz_scores(p_set_id uuid) - RETURNS TABLE(sum numeric) - LANGUAGE sql -AS $function$ - SELECT COALESCE(SUM(wq.score), 0)::numeric - FROM quiz_set_quizzes qsq - JOIN workspace_quizzes wq ON qsq.quiz_id = wq.id - WHERE qsq.set_id = p_set_id; -$function$ -; - - diff --git a/apps/db/supabase/migrations/20250606073849_new_migration.sql b/apps/db/supabase/migrations/20250606073849_new_migration.sql deleted file mode 100644 index a5ab4a65c1..0000000000 --- a/apps/db/supabase/migrations/20250606073849_new_migration.sql +++ /dev/null @@ -1,5 +0,0 @@ -alter table "public"."workspace_quiz_sets" add column "due_date" timestamp with time zone not null default (now() + '7 days'::interval); - -alter table "public"."workspace_quiz_sets" add column "results_released" boolean not null default false; - - diff --git a/apps/db/supabase/migrations/20250608051026_new_migration.sql b/apps/db/supabase/migrations/20250608051026_new_migration.sql deleted file mode 100644 index 8e1999e6c1..0000000000 --- a/apps/db/supabase/migrations/20250608051026_new_migration.sql +++ /dev/null @@ -1,5 +0,0 @@ -alter table "public"."workspace_quiz_sets" drop column "release_at"; - -alter table "public"."workspace_quiz_sets" drop column "results_released"; - - diff --git a/apps/db/supabase/migrations/20250604125645_new_migration.sql b/apps/db/supabase/migrations/20250609092649_add_quiz_attempts.sql similarity index 86% rename from apps/db/supabase/migrations/20250604125645_new_migration.sql rename to apps/db/supabase/migrations/20250609092649_add_quiz_attempts.sql index 6a01712a70..2678b3ad2f 100644 --- a/apps/db/supabase/migrations/20250604125645_new_migration.sql +++ b/apps/db/supabase/migrations/20250609092649_add_quiz_attempts.sql @@ -141,4 +141,29 @@ grant truncate on table "public"."workspace_quiz_attempts" to "service_role"; grant update on table "public"."workspace_quiz_attempts" to "service_role"; +alter table "public"."workspace_quiz_sets" add column "allow_view_results" boolean not null default true; +alter table "public"."workspace_quiz_sets" add column "release_at" timestamp with time zone; + +alter table "public"."workspace_quiz_sets" add column "release_points_immediately" boolean not null default true; + +set check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.sum_quiz_scores(p_set_id uuid) + RETURNS TABLE(sum numeric) + LANGUAGE sql +AS $function$ + SELECT COALESCE(SUM(wq.score), 0)::numeric + FROM quiz_set_quizzes qsq + JOIN workspace_quizzes wq ON qsq.quiz_id = wq.id + WHERE qsq.set_id = p_set_id; +$function$ +; + +alter table "public"."workspace_quiz_sets" add column "due_date" timestamp with time zone not null default (now() + '7 days'::interval); + +alter table "public"."workspace_quiz_sets" add column "results_released" boolean not null default false; + +alter table "public"."workspace_quiz_sets" drop column "release_at"; + +alter table "public"."workspace_quiz_sets" drop column "results_released"; \ No newline at end of file From 44831ea75cbf1a235247f3dbd75f33491294a49e Mon Sep 17 00:00:00 2001 From: vhpx <81307469+vhpx@users.noreply.github.com> Date: Mon, 9 Jun 2025 09:31:55 +0000 Subject: [PATCH 22/26] style: apply prettier formatting --- .../[setId]/take/before-take-quiz-section.tsx | 2 +- .../[wsId]/quiz-sets/[setId]/take/page.tsx | 10 ++++++---- .../quiz-sets/[setId]/take/quiz-status-sidebar.tsx | 12 ++++++------ .../quiz-sets/[setId]/take/time-elapsed-status.tsx | 4 ++-- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section.tsx index b7041a97d2..b867849daf 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/before-take-quiz-section.tsx @@ -48,7 +48,7 @@ export default function BeforeTakeQuizSection({

)}
-
+
{sidebarVisible && quizMeta && ( {submitting diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx index 56d99f671d..59d1da8b43 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx @@ -50,20 +50,20 @@ const QuizStatusSidebar = ({ ); return ( -