diff --git a/apps/db/supabase/migrations/20250609092649_add_quiz_attempts.sql b/apps/db/supabase/migrations/20250609092649_add_quiz_attempts.sql new file mode 100644 index 0000000000..2678b3ad2f --- /dev/null +++ b/apps/db/supabase/migrations/20250609092649_add_quiz_attempts.sql @@ -0,0 +1,169 @@ +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"; + +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 diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json index d63692663c..8abf5cdbe9 100644 --- a/apps/upskii/messages/en.json +++ b/apps/upskii/messages/en.json @@ -1,4 +1,23 @@ { + "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", @@ -6,19 +25,19 @@ "cards": { "courses": { "title": "Courses", - "description": "Explore diverse subjects through structured lessons that build knowledge step-by-step." + "description": "Learn the fundamentals and advanced techniques of prompt engineering through step-by-step lessons and practical examples." }, "quizzes": { "title": "Quizzes", - "description": "Strengthen what you’ve learned with interactive quizzes across various topics." + "description": "Test your understanding with interactive quizzes designed to reinforce key concepts and sharpen your prompt design skills." }, "challenges": { "title": "Challenges", - "description": "Put your knowledge to the test with fun and creative real-world challenges." + "description": "Take on creative challenges that push your limits and inspire innovative prompt solutions using AI." }, "ai-chat": { "title": "AI Chat", - "description": "Chat with AI for instant help, study tips, and personalized learning support." + "description": "Engage in real-time conversations with AI to practice prompt engineering, get instant feedback, and refine your skills." } }, "get-certificate": "Get Your Certificate" @@ -300,6 +319,7 @@ "events": "Events" }, "common": { + "statistics": "Statistics", "allow_manage_all_challenges": "Allow Manage All Challenges", "name_placeholder": "Enter name", "name": "Name", @@ -3818,9 +3838,40 @@ "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", + "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 ac27cbed2a..4c45c0e5f9 100644 --- a/apps/upskii/messages/vi.json +++ b/apps/upskii/messages/vi.json @@ -1,4 +1,23 @@ { + "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", @@ -6,19 +25,19 @@ "cards": { "courses": { "title": "Khóa học", - "description": "Khám phá các chủ đề khác nhau qua các bài học được thiết kế từng bước rõ ràng." + "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": "Trắc nghiệm", - "description": "Củng cố kiến thức qua các bài trắc nghiệm tương tác trên nhiều chủ đề." + "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": "Vận dụng kiến thức với các thử thách thực tế sáng tạo và thú vị." + "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": "Trò chuyện cùng AI để nhận hỗ trợ học tập và lời khuyên hữu ích." + "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" @@ -300,6 +319,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", @@ -3819,9 +3839,40 @@ "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", + "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]/courses/[courseId]/modules/[moduleId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx index ee52e3bece..0bacb2c0af 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,18 +229,74 @@ 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 }) { 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..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 @@ -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.setId, moduleId, question: quiz?.question, quiz_options: quiz?.quiz_options, 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..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 @@ -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 a802b0ad1f..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,11 +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 && ( <> - + )}
- +
-
+
*/}
); } 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 Array.from(grouped.values()); +}; + +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 || []; + return data.name as string; }; 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 73cf68f1e1..e001f0b3bc 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,10 +1,13 @@ 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'; @@ -64,6 +67,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 da04461142..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 @@ -2,7 +2,7 @@ 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 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'; @@ -79,8 +79,8 @@ async function getData( if (q) queryBuilder.ilike('name', `%${q}%`); if (page && pageSize) { - const parsedPage = parseInt(page); - const parsedSize = parseInt(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); 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..11df9bb334 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Hello
; +} 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..2d8fa964ee --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/statistics/page.tsx @@ -0,0 +1,394 @@ +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 { attemptedQuizzes: stats, totalQuizCount } = + await getQuizSetStatistics(setId); + + const overallStats = { + totalQuizzes: totalQuizCount, // Use total count from all quizzes + 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<{ + attemptedQuizzes: QuizStats[]; + totalQuizCount: number; +}> { + const supabase = await createClient(); + + try { + // Get all quizzes in this set with their details (following route.ts pattern) + const { data: questionsRaw, error: qErr } = await supabase + .from('quiz_set_quizzes') + .select( + ` + quiz_id, + workspace_quizzes ( + question, + score + ) + ` + ) + .eq('set_id', setId); + + if (qErr || !questionsRaw) { + console.error('Error fetching questions:', qErr); + return { attemptedQuizzes: [], totalQuizCount: 0 }; + } + + const quizStats: QuizStats[] = []; + + for (const row of questionsRaw) { + const quizId = row.quiz_id; + const question = row.workspace_quizzes?.question || 'Untitled Quiz'; + + // Get all attempts for this specific quiz in this set + const { data: attempts, error: attemptsErr } = await supabase + .from('workspace_quiz_attempts') + .select( + ` + user_id, + total_score, + started_at, + completed_at + ` + ) + .eq('set_id', setId) + .not('completed_at', 'is', null); // Only count completed attempts + + if (attemptsErr) { + console.error('Error fetching attempts for quiz:', quizId, attemptsErr); + continue; + } + + if (!attempts || attempts.length === 0) { + // Skip quizzes with no attempts + continue; + } + + // Now we know attempts is defined and has length > 0 + const totalAttempts = attempts.length; + const uniqueStudents = new Set(attempts.map((a) => a.user_id)).size; + + // Calculate average score as percentage + const averageScore = + attempts.reduce((sum, a) => sum + (a.total_score || 0), 0) / + attempts.length; + + // Calculate pass rate (assuming 70% is passing threshold) + // We need to get the max possible score for this quiz set + const { data: maxScoreData } = await supabase + .from('quiz_set_quizzes') + .select( + ` + workspace_quizzes ( + score + ) + ` + ) + .eq('set_id', setId); + + const maxPossibleScore = + maxScoreData?.reduce( + (sum, q) => sum + (q.workspace_quizzes?.score || 0), + 0 + ) || 100; + + const passRate = + (attempts.filter((a) => { + const scorePercentage = + ((a.total_score || 0) / maxPossibleScore) * 100; + return scorePercentage >= 70; + }).length / + attempts.length) * + 100; + + // Get the most recent attempt + const sortedAttempts = [...attempts].sort( + (a, b) => + new Date(b.started_at).getTime() - new Date(a.started_at).getTime() + ); + const lastAttempt = sortedAttempts[0]?.started_at || null; + + // Convert average score to percentage + const averageScorePercentage = (averageScore / maxPossibleScore) * 100; + + // Only add quizzes that have at least one attempt + if (totalAttempts > 0) { + quizStats.push({ + id: quizId, + question, + totalAttempts, + uniqueStudents, + averageScore: Math.round(averageScorePercentage * 100) / 100, + passRate: Math.round(passRate * 100) / 100, + lastAttempt, + }); + } + } + + // Return both attempted quizzes and total count + return { + attemptedQuizzes: quizStats, + totalQuizCount: questionsRaw.length, + }; + } catch (error) { + console.error('Error fetching quiz statistics:', error); + return { attemptedQuizzes: [], totalQuizCount: 0 }; + } +} 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..ee238a7aab --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx @@ -0,0 +1,23 @@ +import TakingQuizClient from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client'; + +export default async function TakeQuiz({ + params, +}: { + params: Promise<{ + wsId: string; + courseId: string; + moduleId: string; + setId: string; + }>; +}) { + const { wsId, courseId, moduleId, setId } = await params; + + return ( + + ); +} 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..59d1da8b43 --- /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/sections/before-take-quiz-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx new file mode 100644 index 0000000000..b867849daf --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx @@ -0,0 +1,58 @@ +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; +}) { + return ( +
+ {dueDateStr && ( +

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

+ )} +

{quizMeta.setName}

+ {quizMeta.attemptLimit !== null ? ( +

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

+ ) : ( +

+ {t('ws-quizzes.attempts') || 'Attempts'}: {quizMeta.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'} +

+ )} + +
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section.tsx new file mode 100644 index 0000000000..1a5e2e4c54 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section.tsx @@ -0,0 +1,49 @@ +import { Button } from '@tuturuuu/ui/button'; +import React from 'react'; + +export default function PastDueSection({ + t, + quizMeta, + dueDateStr, + wsId, + courseId, + moduleId, + setId, + router, +}: { + t: (key: string) => string; + quizMeta: { + setName: string; + allowViewResults: boolean; + }; + dueDateStr?: string | null; + wsId: string; + courseId: string; + moduleId: string; + setId: string; + router: { + push: (url: string) => void; + }; +}) { + return ( +
+

{quizMeta.setName}

+

+ {t('ws-quizzes.quiz_past_due') || + `This quiz was due on ${new Date(dueDateStr!).toLocaleString()}.`} +

+ {quizMeta.allowViewResults && ( + + )} +
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/show-result-summary-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/show-result-summary-section.tsx new file mode 100644 index 0000000000..51113323e0 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/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/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client.tsx new file mode 100644 index 0000000000..da5b095547 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client.tsx @@ -0,0 +1,469 @@ +'use client'; + +import QuizStatusSidebar, { + Question, +} from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar'; +import BeforeTakeQuizSection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section'; +import PastDueSection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section'; +import ShowResultSummarySection from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/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'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; + +type TakeResponse = { + setId: string; + setName: string; + timeLimitMinutes: number | null; + attemptLimit: number | null; + attemptsSoFar: number; + allowViewResults: boolean; + questions: Question[]; + dueDate: string | null; +}; + +type SubmitResult = { + attemptId: string; + attemptNumber: number; + totalScore: number; + maxPossibleScore: number; +}; + +export default function TakingQuizClient({ + wsId, + courseId, + moduleId, + setId, +}: { + wsId: string; + courseId: string; + moduleId: string; + setId: string; +}) { + const t = useTranslations(); + const router = useRouter(); + + // ─── STATE ─────────────────────────────────────────────────────────────────── + const [sidebarVisible, setSidebarVisible] = useState(false); + + const [loadingMeta, setLoadingMeta] = useState(true); + const [metaError, setMetaError] = useState(null); + const [quizMeta, setQuizMeta] = useState(null); + + const [hasStarted, setHasStarted] = useState(false); + const [isPastDue, setIsPastDue] = useState(false); + const [dueDateStr, setDueDateStr] = useState(null); + + const [timeLeft, setTimeLeft] = useState(null); + const timerRef = useRef(null); + + const [selectedAnswers, setSelectedAnswers] = useState< + Record + >({}); + + const [submitting, setSubmitting] = useState(false); + const [submitResult, setSubmitResult] = useState(null); + const [submitError, setSubmitError] = useState(null); + + // ─── HELPERS ───────────────────────────────────────────────────────────────── + const STORAGE_KEY = `quiz_start_${setId}`; + const totalSeconds = quizMeta?.timeLimitMinutes + ? quizMeta.timeLimitMinutes * 60 + : null; + + const clearStartTimestamp = () => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch {} + }; + + const computeElapsedSeconds = (startTs: number) => + Math.floor((Date.now() - startTs) / 1000); + + const buildSubmissionPayload = () => ({ + answers: Object.entries(selectedAnswers).map(([quizId, optionId]) => ({ + quizId, + selectedOptionId: optionId, + })), + }); + + // ─── FETCH METADATA ──────────────────────────────────────────────────────────── + 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'); + 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); + } + } + + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const startTs = parseInt(stored, 10); + if (!isNaN(startTs)) { + if (totalSeconds !== null) { + const elapsed = computeElapsedSeconds(startTs); + setHasStarted(true); + setTimeLeft(elapsed >= totalSeconds ? 0 : totalSeconds - elapsed); + } else { + setHasStarted(true); + setTimeLeft(computeElapsedSeconds(startTs)); + } + } + } + } catch { + setMetaError('Network error'); + } finally { + setLoadingMeta(false); + } + } + fetchMeta(); + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setId]); + + // ─── TIMER LOGIC ───────────────────────────────────────────────────────────── + useEffect(() => { + if (!hasStarted || !quizMeta) return; + + if (totalSeconds !== null && timeLeft === 0) { + handleSubmit(); + return; + } + + timerRef.current && clearInterval(timerRef.current); + + if (totalSeconds !== null) { + timerRef.current = setInterval(() => { + setTimeLeft((prev) => + prev === null + ? null + : prev <= 1 + ? (clearInterval(timerRef.current!), 0) + : prev - 1 + ); + }, 1000); + } else { + timerRef.current = setInterval(() => { + setTimeLeft((prev) => (prev === null ? 1 : prev + 1)); + }, 1000); + } + + return () => void clearInterval(timerRef.current!); + }, [hasStarted, quizMeta]); + + useEffect(() => { + if (hasStarted && totalSeconds !== null && timeLeft === 0) { + handleSubmit(); + } + }, [timeLeft, hasStarted, totalSeconds]); + + // ─── EVENT HANDLERS ───────────────────────────────────────────────────────── + const onClickStart = () => { + if (!quizMeta) return; + const nowMs = Date.now(); + try { + localStorage.setItem(STORAGE_KEY, nowMs.toString()); + } catch {} + setHasStarted(true); + setTimeLeft(totalSeconds ?? 0); + }; + + async function handleSubmit() { + if (!quizMeta) 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.'); + return setSubmitting(false); + } + + clearStartTimestamp(); + setSubmitResult(json as SubmitResult); + } catch { + setSubmitError('Network error submitting.'); + } finally { + setSubmitting(false); + } + } + + // ─── RENDER ─────────────────────────────────────────────────────────────────── + if (loadingMeta) { + return

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

; + } + if (metaError) { + return

{metaError}

; + } + if (!quizMeta) { + return null; + } + + // Past due? + if (isPastDue) { + return ( + + ); + } + + // After submit: show result summary + if (submitResult) { + return ( + + ); + } + + // ─── 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()} +

+ )} +
+ ); + } + + // ─── “Not started yet”: no attempts left? ──────────────────────────────────── + if ( + !hasStarted && + quizMeta.attemptLimit !== null && + quizMeta.attemptsSoFar >= quizMeta.attemptLimit + ) { + return ( +
+

{quizMeta.setName}

+ {dueDateStr && ( +

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

+ )} +

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

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

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

+ )} +
+ ); + } + + // ─── “Take Quiz” button ────────────────────────────────────────────────────── + if (!hasStarted) { + return ( + + ); + } + + // ─── QUIZ FORM ─────────────────────────────────────────────────────────────── + const isCountdown = totalSeconds !== null; + + return ( +
+
+
+ + +
+
+ {sidebarVisible && quizMeta && ( + + )} +
+
+ +
+

{quizMeta.setName}

+
{ + e.preventDefault(); + handleSubmit(); + }} + className="space-y-8" + > + {quizMeta.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/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..cdc961a8cf --- /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/[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', - }, -]; 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', - }, -]; 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..0e783cd6db --- /dev/null +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts @@ -0,0 +1,184 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { NextRequest, NextResponse } from 'next/server'; + +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 async function GET( + _request: NextRequest, + { params }: { params: Promise<{ setId: string }> } +) { + const { setId } = await params; + const supabase = await createClient(); + + // 1) Auth + 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) 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('allow_view_results') + .eq('id', setId) + .maybeSingle(); + + if (setErr || !setRow) { + 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 } + ); + } + + // 3) Fetch question info (correct answers + weight) + const { data: questionsRaw, error: qErr } = await supabase + .from('quiz_set_quizzes') + .select( + ` + quiz_id, + workspace_quizzes ( + question, + score + ), + 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 } + ); + } + + const questionInfo = (questionsRaw || []).map((row: any) => ({ + quizId: row.quiz_id, + question: row.workspace_quizzes.question, + scoreWeight: row.workspace_quizzes.score, + correctOptionValue: row.quiz_options.value, + })); + const maxPossibleScore = questionInfo.reduce((s, q) => s + q.scoreWeight, 0); + + // 4) Fetch all attempts by user + 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) For each attempt, fetch its answers + const resultDTOs: AttemptDTO[] = []; + + for (const att of attempts) { + 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 } + ); + } + + 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, + }; + } + }) + ); + + resultDTOs.push({ + attemptId: att.id, + attemptNumber: att.attempt_number, + totalScore: att.total_score ?? 0, + maxPossibleScore, + startedAt: att.started_at, + completedAt: att.completed_at, + answers, + }); + } + + 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..afd904b887 --- /dev/null +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts @@ -0,0 +1,224 @@ +import { createClient } from '@tuturuuu/supabase/next/server'; +import { NextRequest, NextResponse } from '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: Promise<{ setId: string }> } +) { + const { setId } = await params; + 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..4158938bf2 --- /dev/null +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts @@ -0,0 +1,149 @@ +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: { id: string; value: string }[]; + }; +}; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ setId: string }> } +) { + const { setId } = await params; + const supabase = await createClient(); + + // 1) Auth + 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 + const { data: setRow, error: setErr } = await supabase + .from('workspace_quiz_sets') + .select( + ` + id, + name, + attempt_limit, + time_limit_minutes, + due_date, + release_points_immediately + ` + ) + .eq('id', setId) + .maybeSingle(); + + if (setErr || !setRow) { + return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 }); + } + + const { + name: setName, + attempt_limit, + time_limit_minutes, + due_date, + release_points_immediately, + } = setRow; + + // 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 }) + .eq('user_id', userId) + .eq('set_id', setId); + + if (attErr) { + 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) { + return NextResponse.json( + { + error: 'Maximum attempts reached', + attemptsSoFar: attemptsCount, + attemptLimit: attempt_limit, + dueDate: due_date, + allowViewResults: false, + }, + { status: 403 } + ); + } + + // 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, + }); + } + + // 7) Otherwise, return questions for taking + 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 questions' }, + { status: 500 } + ); + } + + 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((o) => ({ + id: o.id, + value: o.value, + })), + })); + + return NextResponse.json({ + setId, + setName, + attemptLimit: attempt_limit, + timeLimitMinutes: time_limit_minutes, + attemptsSoFar: attemptsCount, + dueDate: due_date, + questions, + }); +} 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..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 @@ -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') @@ -72,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' }); + return NextResponse.json({ + message: 'success', + setId: data.id, + name: renderedName, + }); } diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index cd3fa404ba..f20cecdf18 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -5796,23 +5796,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; + due_date: string; id: string; name: string; + 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; + due_date?: string; id?: string; name?: string; + 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; + due_date?: string; id?: string; name?: string; + release_points_immediately?: boolean; + time_limit_minutes?: number | null; ws_id?: string | null; }; Relationships: [ @@ -5830,18 +5953,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: [ @@ -7213,23 +7339,23 @@ export type Database = { Returns: number; }; create_ai_chat: { - Args: { model: string; title: string; message: string }; + Args: { title: string; message: string; model: string }; Returns: string; }; generate_cross_app_token: { Args: | { + p_user_id: string; p_origin_app: string; p_target_app: string; p_expiry_seconds?: number; - p_session_data?: Json; - p_user_id: string; } | { p_user_id: string; p_origin_app: string; p_target_app: string; p_expiry_seconds?: number; + p_session_data?: Json; }; Returns: string; }; @@ -7243,17 +7369,17 @@ export type Database = { get_daily_income_expense: { Args: { _ws_id: string; past_days?: number }; Returns: { - total_expense: number; - total_income: number; day: string; + total_income: number; + total_expense: number; }[]; }; get_daily_prompt_completion_tokens: { Args: { past_days?: number }; Returns: { - total_completion_tokens: number; day: string; total_prompt_tokens: number; + total_completion_tokens: number; }[]; }; get_finance_invoices_count: { @@ -7279,9 +7405,9 @@ export type Database = { get_hourly_prompt_completion_tokens: { Args: { past_hours?: number }; Returns: { - total_completion_tokens: number; - total_prompt_tokens: number; hour: string; + total_prompt_tokens: number; + total_completion_tokens: number; }[]; }; get_inventory_batches_count: { @@ -7300,7 +7426,6 @@ export type Database = { _has_unit?: boolean; }; Returns: { - amount: number; id: string; name: string; manufacturer: string; @@ -7308,6 +7433,7 @@ export type Database = { unit_id: string; category: string; price: number; + amount: number; ws_id: string; created_at: string; }[]; @@ -7329,7 +7455,7 @@ export type Database = { Returns: number; }; get_monthly_income_expense: { - Args: { past_months?: number; _ws_id: string }; + Args: { _ws_id: string; past_months?: number }; Returns: { month: string; total_income: number; @@ -7339,9 +7465,9 @@ export type Database = { get_monthly_prompt_completion_tokens: { Args: { past_months?: number }; Returns: { + month: string; total_prompt_tokens: number; total_completion_tokens: number; - month: string; }[]; }; get_pending_event_participants: { @@ -7351,14 +7477,14 @@ export type Database = { get_possible_excluded_groups: { Args: { _ws_id: string; included_groups: string[] }; Returns: { + id: string; name: string; ws_id: string; amount: number; - id: string; }[]; }; get_possible_excluded_tags: { - Args: { included_tags: string[]; _ws_id: string }; + Args: { _ws_id: string; included_tags: string[] }; Returns: { id: string; name: string; @@ -7369,11 +7495,11 @@ export type Database = { get_session_statistics: { Args: Record; Returns: { - latest_session_date: string; + total_count: number; unique_users_count: number; active_count: number; completed_count: number; - total_count: number; + latest_session_date: string; }[]; }; get_session_templates: { @@ -7399,38 +7525,38 @@ export type Database = { get_submission_statistics: { Args: Record; Returns: { - unique_users_count: number; total_count: number; latest_submission_date: string; + unique_users_count: number; }[]; }; get_transaction_categories_with_amount: { Args: Record; Returns: { - ws_id: string; id: string; name: string; is_expense: boolean; + ws_id: string; created_at: string; amount: number; }[]; }; get_user_role: { - Args: { ws_id: string; user_id: string }; + Args: { user_id: string; ws_id: string }; Returns: string; }; get_user_tasks: { Args: { _board_id: string }; Returns: { - priority: number; id: string; name: string; - board_id: string; - list_id: string; - end_date: string; + description: string; + priority: number; completed: boolean; start_date: string; - description: string; + end_date: string; + list_id: string; + board_id: string; }[]; }; get_workspace_drive_size: { @@ -7446,7 +7572,7 @@ export type Database = { Returns: number; }; get_workspace_transactions_count: { - Args: { ws_id: string; end_date?: string; start_date?: string }; + Args: { ws_id: string; start_date?: string; end_date?: string }; Returns: number; }; get_workspace_user_groups: { @@ -7457,13 +7583,13 @@ export type Database = { search_query: string; }; Returns: { - created_at: string; id: string; name: string; notes: string; ws_id: string; tags: string[]; tag_count: number; + created_at: string; }[]; }; get_workspace_user_groups_count: { @@ -7472,16 +7598,17 @@ export type Database = { }; get_workspace_users: { Args: { - search_query: string; _ws_id: string; included_groups: string[]; excluded_groups: string[]; + search_query: string; }; Returns: { id: string; avatar_url: string; full_name: string; display_name: string; + email: string; phone: string; gender: string; birthday: string; @@ -7497,7 +7624,6 @@ export type Database = { linked_users: Json; created_at: string; updated_at: string; - email: string; }[]; }; get_workspace_users_count: { @@ -7513,7 +7639,7 @@ export type Database = { Returns: number; }; get_workspace_wallets_income: { - Args: { ws_id: string; end_date?: string; start_date?: string }; + Args: { ws_id: string; start_date?: string; end_date?: string }; Returns: number; }; has_other_owner: { @@ -7521,7 +7647,7 @@ export type Database = { Returns: boolean; }; insert_ai_chat_message: { - Args: { chat_id: string; message: string; source: string }; + Args: { message: string; chat_id: string; source: string }; Returns: undefined; }; is_list_accessible: { @@ -7529,7 +7655,7 @@ export type Database = { Returns: boolean; }; is_member_invited: { - Args: { _org_id: string; _user_id: string }; + Args: { _user_id: string; _org_id: string }; Returns: boolean; }; is_nova_challenge_manager: { @@ -7541,7 +7667,7 @@ export type Database = { Returns: boolean; }; is_nova_user_email_in_team: { - Args: { _team_id: string; _user_email: string }; + Args: { _user_email: string; _team_id: string }; Returns: boolean; }; is_nova_user_id_in_team: { @@ -7561,11 +7687,11 @@ export type Database = { Returns: boolean; }; is_task_board_member: { - Args: { _board_id: string; _user_id: string }; + Args: { _user_id: string; _board_id: string }; Returns: boolean; }; is_user_task_in_board: { - Args: { _task_id: string; _user_id: string }; + Args: { _user_id: string; _task_id: string }; Returns: boolean; }; nova_get_all_challenges_with_user_stats: { @@ -7573,7 +7699,7 @@ export type Database = { Returns: Json; }; nova_get_challenge_with_user_stats: { - Args: { user_id: string; challenge_id: string }; + Args: { challenge_id: string; user_id: string }; Returns: Json; }; nova_get_user_daily_sessions: { @@ -7590,7 +7716,7 @@ export type Database = { }; search_users: { Args: - | { page_size: number; page_number: number; search_query: string } + | { search_query: string; page_number: number; page_size: number } | { search_query: string; page_number: number; @@ -7599,6 +7725,7 @@ export type Database = { enabled_filter?: boolean; }; Returns: { + id: string; display_name: string; deleted: boolean; avatar_url: string; @@ -7609,26 +7736,31 @@ export type Database = { enabled: boolean; allow_challenge_management: boolean; allow_manage_all_challenges: boolean; - team_name: string[]; - id: string; allow_role_management: boolean; email: string; new_email: string; birthday: string; + team_name: string[]; }[]; }; search_users_by_name: { Args: { - min_similarity?: number; search_query: string; result_limit?: number; + min_similarity?: number; }; Returns: { id: string; handle: string; + display_name: string; avatar_url: string; relevance: number; - display_name: string; + }[]; + }; + sum_quiz_scores: { + Args: { p_set_id: string }; + Returns: { + sum: number; }[]; }; transactions_have_same_abs_amount: {