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={
+
+
+
+ {t('common.statistics')}
+
+
+ }
/>
{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('back')}
+
+
+
+
+
+ {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 (
+
+
+ {translate('ws-quizzes.question_status_title', 'Question Progress')}
+
+
+ {/* Optional Progress Overview */}
+
+
+ {answeredCount} / {questions.length}{' '}
+ {translate('ws-quizzes.answered_status_short', 'answered')}
+
+
+
0 ? (answeredCount / questions.length) * 100 : 0}%`,
+ }}
+ aria-valuenow={answeredCount}
+ aria-valuemin={0}
+ aria-valuemax={questions.length}
+ role="progressbar"
+ aria-label={translate(
+ 'ws-quizzes.quiz_progress_label',
+ 'Quiz Progress'
+ )}
+ >
+
+
+
+
+ {questions.map((q, idx) => {
+ const isAnswered = Boolean(selectedAnswers[q.quizId]);
+ const questionNumber = idx + 1;
+
+ return (
+ onQuestionJump(idx)} // Pass index or ID
+ aria-label={translate(
+ 'ws-quizzes.jump_to_question_aria',
+ `Question ${questionNumber}, ${isAnswered ? translate('ws-quizzes.answered_state', 'Answered') : translate('ws-quizzes.unanswered_state', 'Unanswered')}`,
+ { number: questionNumber }
+ )}
+ className={`flex h-9 w-full items-center justify-center rounded-md border text-xs font-medium transition-all duration-150 ease-in-out hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card focus-visible:outline-none active:opacity-60 ${
+ isAnswered
+ ? 'border-primary bg-primary text-primary-foreground'
+ : 'border-border bg-background text-muted-foreground hover:bg-muted'
+ } `}
+ >
+
+ {' '}
+ {/* Icon is decorative if main label is sufficient */}
+ {isAnswered
+ ? translate('ws-quizzes.answered_icon', '✓')
+ : translate('ws-quizzes.unanswered_icon', '⚪')}
+
+ {questionNumber}
+
+ );
+ })}
+
+
+ );
+};
+
+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'}
+
+ )}
+
+ {t('ws-quizzes.take_quiz') || 'Take Quiz'}
+
+
+ );
+}
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 && (
+
+ router.push(
+ `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes/${setId}/results`
+ )
+ }
+ >
+ {t('ws-quizzes.view_results') || 'View Results'}
+
+ )}
+
+ );
+}
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}
+
+
+ router.push(
+ `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes`
+ )
+ }
+ >
+ {t('ws-quizzes.done') || 'Done'}
+
+
+ );
+}
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.'}
+
+
+ router.push(
+ `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes/${setId}/results`
+ )
+ }
+ >
+ {t('ws-quizzes.view_results') || 'View Results'}
+
+ {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 ? (
+
+ router.push(
+ `/dashboard/${wsId}/courses/${courseId}/modules/${moduleId}/quizzes/${setId}/results`
+ )
+ }
+ >
+ {t('ws-quizzes.view_results') || 'View Results'}
+
+ ) : (
+
+ {t('ws-quizzes.no_attempts_left') || 'You have no attempts left.'}
+
+ )}
+
+ );
+ }
+
+ // ─── “Take Quiz” button ──────────────────────────────────────────────────────
+ if (!hasStarted) {
+ return (
+
+ );
+ }
+
+ // ─── QUIZ FORM ───────────────────────────────────────────────────────────────
+ const isCountdown = totalSeconds !== null;
+
+ return (
+
+
+
+ setSidebarVisible(!sidebarVisible)}
+ >
+
+
+
+
+
+ {sidebarVisible && quizMeta && (
+
+ )}
+
+
+
+
+ {quizMeta.setName}
+
+
+
+
+
+ );
+}
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'}
+
+
+ {isVisible ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
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: {