diff --git a/apps/db/supabase/migrations/20250618174721_enhance_quiz_taking.sql b/apps/db/supabase/migrations/20250618174721_enhance_quiz_taking.sql new file mode 100644 index 0000000000..ba0ff91923 --- /dev/null +++ b/apps/db/supabase/migrations/20250618174721_enhance_quiz_taking.sql @@ -0,0 +1,17 @@ +alter table "public"."workspace_quiz_attempts" add column "duration_seconds" integer; + +alter table "public"."workspace_quiz_attempts" add column "submitted_at" timestamp with time zone not null default now(); + +alter table "public"."workspace_quiz_sets" add column "available_date" timestamp with time zone not null default now(); + +alter table "public"."workspace_quiz_sets" add column "explanation_mode" smallint not null default 0; + +alter table "public"."workspace_quiz_sets" add column "instruction" jsonb; + +alter table "public"."workspace_quizzes" add column "instruction" jsonb; + +alter table "public"."workspace_quiz_sets" add column "results_released" boolean not null default false; + +alter table "public"."workspace_quiz_sets" drop column "release_points_immediately"; + +alter table "public"."workspace_quiz_sets" add column "allow_view_old_attempts" boolean not null default true; \ No newline at end of file diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json index 07fb31007b..8dbc853b53 100644 --- a/apps/upskii/messages/en.json +++ b/apps/upskii/messages/en.json @@ -3846,40 +3846,153 @@ "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!", - "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." + "quiz-status": { + "sidebar_aria": "Quiz progress sidebar", + "question_status_title": "Question Progress", + "answered_status_short": "answered", + "quiz_progress_label": "Quiz Progress", + "question_navigation_label": "Jump to question", + "answered_state": "Answered", + "unanswered_state": "Unanswered", + "jump_to_question": "Jump to question" + }, + "time": { + "remaining": "Time Remaining", + "elapsed": "Time Elapsed", + "hidden_remaining": "Time Hidden", + "hidden_elapsed": "Time Hidden", + "hour": "hour", + "hours": "hours", + "minute": "minute", + "second": "second" + }, + "errors": { + "error-type-submit": "Error when submitting test", + "unknown-error": "Unknown error", + "network-error": "Network error", + "submission-failed": "Submission failed.", + "network-error-submitting": "Network error submitting" + }, + "quiz": { + "take-quiz": "Take Quiz", + "start-quiz": "Start Quiz", + "starting-quiz": "Starting Quiz...", + "quiz-instructions": "Quiz Instructions", + "attempt-limit": "Attempt Limit", + "due-date": "Due Date", + "available-date": "Available Date", + "attempts-remaining": "Attempts Remaining", + "past-due": "This quiz is past due", + "not-available": "This quiz is not yet available", + "unlimited": "Unlimited", + "review-info": "Review the information below before starting your exam", + "click-to-begin": "Click to begin your attempt", + "results-out": "Results are out—no further attempts", + "quiz-past-due": "Quiz is past due", + "quiz-not-available": "Quiz not yet available", + "no-attempts-remaining": "No attempts remaining", + "view-result": "View Result", + "view-final-total-score": "You got {score} over {maxScore} for your recent attempt.", + "view-final-attempt": "You got {score} over {maxScore} for your recent attempt. Click `View Result` to view your score details", + "quiz-not-available-message": "This quiz is not yet available. Please check back later.", + "quiz-past-due-message": "This quiz is past its due date. You cannot start a new attempt at this time.", + "no-attempts-message": "You have no attempts remaining for this quiz." + }, + "info": { + "quiz-information": "Quiz Information", + "quiz-id": "Quiz ID", + "time-limit": "Time Limit", + "no-time-limit": "No time limit", + "minutes": "Minutes", + "attempts-used": "Attempts Used", + "explanations": "Explanations", + "schedule": "Schedule", + "available-from": "Available From", + "due-date": "Due Date", + "attempt-remaining": "attempt remaining", + "attempts-remaining": "attempts remaining", + "explanation-modes": { + "none": "None", + "correct-after-release": "Correct only after release", + "all-after-release": "All after release" + } + }, + "instructions": { + "title": "Instructions", + "subtitle": "Read before you begin", + "default": { + "stable-connection": "Make sure you have a stable internet connection", + "cannot-pause": "You cannot pause the quiz once started", + "answer-all": "You should answer all questions before submitting", + "auto-save": "Your progress will be automatically saved", + "time-limit": "You have {minutes} minutes" + } + }, + "past-attempts": { + "title": "Past Attempts", + "view-answers": "Click \"View Details\" to view your answers", + "view-results": "Click \"View Details\" to view your results", + "results-pending": "Results pending release", + "view-results-button": "View Results", + "view-details-button": "View Details", + "attempt-info": " at {date} ({duration})" + }, + "summary": { + "title": "Attempt Summary", + "description": "Review your quiz attempt details and responses", + "back_to_quiz": "Back to Quiz", + "attempt_number": "Attempt #{number}", + "submitted": "Submitted", + "duration": "Duration", + "completion": "Completion", + "answered_of_total": "{answered} of {total} questions", + "progress": "Progress", + "questions_and_responses": "Questions & Responses", + "questions_count": "{count} questions", + "answered": "Answered", + "skipped": "Skipped", + "your_response": "Your Response:", + "no_answer": "No answer provided", + "available_options": "Available Options:", + "summary_stats": { + "attempt_number": "Attempt Number", + "answered": "Questions Answered", + "skipped": "Questions Skipped", + "time_taken": "Time Taken" + } + }, + "results": { + "quiz-completed": "Quiz Completed!", + "your-score": "Your Score", + "attempt": "Attempt", + "of": "of", + "unlimited": "Unlimited", + "attempts-remaining": "Attempts Remaining", + "left": "left", + "excellent-work": "Excellent Work!", + "outstanding-performance": "Outstanding performance! You have mastered this material.", + "good-job": "Good Job!", + "solid-understanding": "You show a solid understanding of the material.", + "keep-practicing": "Keep Practicing!", + "room-for-improvement": "There's room for improvement. Consider reviewing the material.", + "needs-improvement": "Needs Improvement", + "review-recommended": "We recommend reviewing the material and trying again.", + "back-take-quiz": "Back to Quiz Page", + "quiz-completed-at": "Quiz completed at", + "time-limit": "Time limit", + "minutes": "minutes", + "started_at": "Started at", + "completed_at": "Completed at", + "duration": "Duration", + "points": "Points", + "correct": "(Correct)", + "your_answer": "(Your answer)", + "correct_option": "(Correct)", + "score_awarded": "Score Awarded" + } }, "ws-reports": { "report": "Report", diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json index d3e1ebc3e7..63ba3e665b 100644 --- a/apps/upskii/messages/vi.json +++ b/apps/upskii/messages/vi.json @@ -3847,40 +3847,155 @@ "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!", - "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" + "points": "Points", + "submitting": "Submitting...", + "submit": "Submit", + "quiz-status": { + "sidebar_aria": "Thanh bên tiến độ bài kiểm tra", + "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": "Chuyển đến câu hỏi", + "answered_state": "Đã trả lời", + "unanswered_state": "Chưa trả lời", + "jump_to_question": "Chuyển đến câu hỏi" + }, + "time": { + "remaining": "Thời gian còn lại", + "elapsed": "Thời gian đã trôi qua", + "hidden_remaining": "Đã ẩn thời gian còn lại", + "hidden_elapsed": "Đã ẩn thời gian trôi qua", + "minutes": "phút", + "seconds": "giây", + "hour": "giờ", + "hours": "giờ", + "minute": "phút", + "second": "giấy" + }, + "errors": { + "error-type-submit": "Đã có lỗi xảy ra khi nộp bài", + "unknown-error": "Lỗi không xác định", + "network-error": "Lỗi mạng", + "submission-failed": "Nộp bài thất bại", + "network-error-submitting": "Lỗi mạng khi nộp bài" + }, + "quiz": { + "take-quiz": "Làm bài kiểm tra", + "start-quiz": "Bắt đầu làm bài", + "starting-quiz": "Đang bắt đầu...", + "quiz-instructions": "Hướng dẫn làm bài", + "attempt-limit": "Giới hạn lần làm", + "due-date": "Hạn nộp", + "available-date": "Ngày mở", + "attempts-remaining": "Số lần còn lại", + "past-due": "Bài kiểm tra đã quá hạn", + "not-available": "Bài kiểm tra chưa mở", + "unlimited": "Không giới hạn", + "review-info": "Xem kỹ thông tin bên dưới trước khi bắt đầu bài thi", + "click-to-begin": "Nhấp để bắt đầu lần làm bài của bạn", + "results-out": "Kết quả đã công bố—không thể làm thêm", + "quiz-past-due": "Bài kiểm tra đã quá hạn", + "quiz-not-available": "Bài kiểm tra chưa mở", + "no-attempts-remaining": "Không còn lần làm bài nào", + "view-result": "Xem kết quả", + "view-final-total-score": "Bạn đạt được {score} trên {maxScore}.", + "view-final-attempt": "Bạn đạt được {score} trên {maxScore}. Xem kết quả lần làm bài gần đây nhất", + "quiz-not-available-message": "Bài kiểm tra này chưa mở. Vui lòng quay lại sau.", + "quiz-past-due-message": "Bài kiểm tra này đã quá hạn nộp. Bạn không thể bắt đầu lần làm bài mới vào lúc này.", + "no-attempts-message": "Bạn không còn lần làm bài nào cho bài kiểm tra này." + }, + "info": { + "quiz-information": "Thông tin bài kiểm tra", + "quiz-id": "Mã bài kiểm tra", + "time-limit": "Giới hạn thời gian", + "no-time-limit": "Không giới hạn thời gian", + "minutes": "Phút", + "attempts-used": "Số lần đã làm", + "explanations": "Giải thích", + "schedule": "Lịch trình", + "available-from": "Mở từ", + "due-date": "Hạn nộp", + "attempt-remaining": "lần còn lại", + "attempts-remaining": "lần còn lại", + "explanation-modes": { + "none": "Không có", + "correct-after-release": "Chỉ đáp án đúng sau khi công bố", + "all-after-release": "Tất cả sau khi công bố" + } + }, + "instructions": { + "title": "Hướng dẫn", + "subtitle": "Đọc trước khi bắt đầu", + "default": { + "stable-connection": "Đảm bảo bạn có kết nối internet ổn định", + "cannot-pause": "Bạn không thể tạm dừng bài kiểm tra sau khi bắt đầu", + "answer-all": "Bạn nên trả lời tất cả câu hỏi trước khi nộp bài", + "auto-save": "Tiến trình của bạn sẽ được tự động lưu", + "time-limit": "Bạn có {minutes} phút" + } + }, + "past-attempts": { + "title": "Lần làm bài trước", + "view-answers": "Nhấp \"Xem chi tiết\" để xem câu trả lời của bạn", + "view-results": "Nhấp \"Xem kết qủa\" để xem kết quả của bạn", + "results-pending": "Kết quả đang chờ công bố", + "view-results-button": "Xem kết quả", + "view-details-button": "Xem chi tiết", + "attempt-info": " lúc {date} ({duration})" + }, + "summary": { + "title": "Tóm tắt lần làm bài", + "description": "Xem lại chi tiết và câu trả lời trong lần làm bài kiểm tra của bạn", + "back_to_quiz": "Quay lại làm bài", + "attempt_number": "Lần làm #{number}", + "submitted": "Đã nộp", + "duration": "Thời lượng", + "completion": "Hoàn thành", + "answered_of_total": "{answered} / {total} câu hỏi", + "progress": "Tiến độ", + "questions_and_responses": "Câu hỏi & Câu trả lời", + "questions_count": "{count} câu hỏi", + "answered": "Đã trả lời", + "skipped": "Bỏ qua", + "your_response": "Câu trả lời của bạn:", + "no_answer": "Chưa chọn câu trả lời", + "available_options": "Tùy chọn có sẵn:", + "summary_stats": { + "attempt_number": "Số lần làm", + "answered": "Câu hỏi đã trả lời", + "skipped": "Câu hỏi đã bỏ qua", + "time_taken": "Thời gian làm bài" + } + }, + "results": { + "quiz-completed": "Đã hoàn thành bài kiểm tra!", + "your-score": "Điểm số của bạn", + "attempt": "Lần làm", + "of": "trên", + "unlimited": "Không giới hạn", + "attempts-remaining": "Số lần còn lại", + "left": "lần", + "excellent-work": "Làm rất tốt!", + "outstanding-performance": "Hiệu suất xuất sắc! Bạn đã nắm vững kiến thức này.", + "good-job": "Làm tốt lắm!", + "solid-understanding": "Bạn hiểu khá vững kiến thức.", + "keep-practicing": "Tiếp tục luyện tập!", + "room-for-improvement": "Còn chỗ cần cải thiện. Hãy xem lại nội dung.", + "needs-improvement": "Cần cải thiện", + "review-recommended": "Chúng tôi khuyên bạn nên ôn tập lại và thử lại.", + "back-take-quiz": "Quay lại trang làm bài", + "quiz-completed-at": "Hoàn thành bài kiểm tra lúc", + "time-limit": "Giới hạn thời gian", + "minutes": "phút", + "started_at": "Bắt đầu lúc", + "completed_at": "Hoàn thành lúc", + "duration": "Thời gian làm bài", + "points": "Điểm", + "correct": "(Đáp án đúng)", + "your_answer": "(Câu trả lời của bạn)", + "correct_option": "(Đáp án đúng)", + "score_awarded": "Điểm đạt được" + } }, "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 0bacb2c0af..4924d5e5ff 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 @@ -138,6 +138,7 @@ export default async function UserGroupDetailsPage({ params }: Props) {
; + }>; +} + +export interface ShowAttemptDetailProps { + detail: AttemptDetailDTO; +} + +export default function ShowAttemptDetailSection({ + detail, +}: ShowAttemptDetailProps) { + const t = useTranslations('ws-quizzes'); + const fmtDate = (iso: string | null) => + iso ? new Date(iso).toLocaleString() : '—'; + const fmtDuration = (secs: number) => { + const m = Math.floor(secs / 60) + .toString() + .padStart(2, '0'); + const s = (secs % 60).toString().padStart(2, '0'); + return `${m}:${s}`; + }; + + return ( +
+ {/* Metadata */} +
+
+ {t('results.started_at') || 'Started at'}: {fmtDate(detail.startedAt)} +
+ {detail.completedAt && ( +
+ {t('results.completed_at') || 'Completed at'}:{' '} + {fmtDate(detail.completedAt)} +
+ )} +
+ {t('results.duration') || 'Duration'}:{' '} + {fmtDuration(detail.durationSeconds)} +
+
+ + {/* Questions */} + {detail.questions.map((q, idx) => { + const selId = q.selectedOptionId ?? null; + return ( + + + + {idx + 1}. {q.question}{' '} + + ({t('results.points') || 'Points'}: {q.scoreWeight}) + + + + + {q.options.map((opt) => { + const chosen = selId === opt.id; + return ( +
+ {opt.isCorrect ? ( + + ) : chosen ? ( + + ) : ( + + )} +
+ + {opt.value}{' '} + {opt.isCorrect && ( + + {t('results.correct') || '(Correct)'} + + )} + {chosen && !opt.isCorrect && ( + + {t('results.your_answer') || '(Your answer)'} + + )} + + {opt.explanation && ( +

+ {opt.explanation} +

+ )} +
+
+ ); + })} + +
+ {t('results.score_awarded') || 'Score Awarded'}:{' '} + + {q.scoreAwarded} / {q.scoreWeight} + +
+
+
+ ); + })} +
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-result-summary-section.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-result-summary-section.tsx new file mode 100644 index 0000000000..dbf7433488 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-result-summary-section.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { Badge } from '@tuturuuu/ui/badge'; +import { Button } from '@tuturuuu/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; +import { Progress } from '@tuturuuu/ui/progress'; +import { Separator } from '@tuturuuu/ui/separator'; +import { CheckCircle, RotateCcw, Target, Trophy } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; + +export interface ShowResultSummarySectionProps { + submitResult: { + attemptNumber: number; + totalScore: number; + maxPossibleScore: number; + }; + quizMeta: { + attemptLimit: number | null; + setName: string; + attemptsSoFar: number; + timeLimitMinutes: number | null; + completedAt: string | null; // Optional, if you want to show when the quiz was completed + }; + wsId: string; + courseId: string; + moduleId: string; + setId: string; +} + +export default function ShowResultSummarySection({ + submitResult, + quizMeta, + wsId, + courseId, + moduleId, + setId, +}: ShowResultSummarySectionProps) { + const t = useTranslations('ws-quizzes'); + const router = useRouter(); + + const scorePercentage = Math.round( + (submitResult.totalScore / submitResult.maxPossibleScore) * 100 + ); + const attemptsRemaining = quizMeta.attemptLimit + ? quizMeta.attemptLimit - quizMeta.attemptsSoFar + : null; + // const canRetake = attemptsRemaining === null || attemptsRemaining > 0; + + const getScoreColor = (percentage: number) => { + if (percentage >= 90) return 'text-dynamic-green'; + if (percentage >= 70) return 'text-dynamic-purple'; + if (percentage >= 50) return 'text-yellow-600'; + return 'text-rose-600'; + }; + + const getScoreBadgeVariant = (percentage: number) => { + if (percentage >= 90) return 'default'; + if (percentage >= 70) return 'secondary'; + if (percentage >= 50) return 'outline'; + return 'destructive'; + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ {t('results.quiz-completed') || 'Quiz Completed!'} +

+

{quizMeta.setName}

+
+ + {/* Score Card */} + + + + + {t('results.your-score') || 'Your Score'} + + + +
+
+ {submitResult.totalScore} / {submitResult.maxPossibleScore} +
+ + {scorePercentage}% + +
+ + + +
+ 0% + 50% + 100% +
+
+
+ + {/* Attempt Information */} + + +
+
+
+ +
+
+

+ {t('results.attempt') || 'Attempt'} +

+

+ #{submitResult.attemptNumber} {t('results.of') || 'of'}{' '} + {quizMeta.attemptLimit ?? + (t('results.unlimited') || 'Unlimited')} +

+
+
+ + {attemptsRemaining !== null && ( +
+
+ +
+
+

+ {t('results.attempts-remaining') || 'Attempts Remaining'} +

+

+ {attemptsRemaining} {t('results.left') || 'left'} +

+
+
+ )} +
+
+
+ + {/* Performance Feedback */} + + +
+

+ {scorePercentage >= 90 + ? t('results.excellent-work') || 'Excellent Work!' + : scorePercentage >= 70 + ? t('results.good-job') || 'Good Job!' + : scorePercentage >= 50 + ? t('results.keep-practicing') || 'Keep Practicing!' + : t('results.needs-improvement') || 'Needs Improvement'} +

+

+ {scorePercentage >= 90 + ? t('results.outstanding-performance') || + 'Outstanding performance! You have mastered this material.' + : scorePercentage >= 70 + ? t('results.solid-understanding') || + 'You show a solid understanding of the material.' + : scorePercentage >= 50 + ? t('results.room-for-improvement') || + "There's room for improvement. Consider reviewing the material." + : t('results.review-recommended') || + 'We recommend reviewing the material and trying again.'} +

+
+
+
+ + + + {/* Action Buttons */} +
+ +
+ + {/* Additional Info */} +
+ {quizMeta.completedAt && ( +

+ {t('results.quiz-completed-at') || 'Quiz completed at'}{' '} + {new Date(quizMeta.completedAt).toLocaleString()} +

+ )} + {quizMeta.timeLimitMinutes && ( +

+ {t('results.time-limit') || 'Time limit'}:{' '} + {quizMeta.timeLimitMinutes} {t('results.minutes') || 'minutes'} +

+ )} +
+
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view.tsx new file mode 100644 index 0000000000..61923c2b39 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view.tsx @@ -0,0 +1,371 @@ +'use client'; + +import { Badge } from '@tuturuuu/ui/badge'; +import { Button } from '@tuturuuu/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; +import { Progress } from '@tuturuuu/ui/progress'; +import { Separator } from '@tuturuuu/ui/separator'; +import { + AlertCircle, + Calendar, + CheckCircle, + FileText, + Hash, + Timer, + XCircle, +} from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +export interface AttemptSummaryDTO { + attemptId: string; + attemptNumber: number; + submittedAt: string | null; + durationSeconds: number; + questions: Array<{ + quizId: string; + question: string; + selectedOptionId: string | null; + options: Array<{ id: string; value: string }>; + }>; +} + +export default function AttemptSummaryView({ + summary, + backToTakeQuiz, +}: { + summary: AttemptSummaryDTO; + backToTakeQuiz: () => void; +}) { + const t = useTranslations('ws-quizzes'); + const fmtDate = (iso: string | null) => + iso + ? new Date(iso).toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : '–'; + + const fmtDur = (secs: number) => { + const hours = Math.floor(secs / 3600); + const minutes = Math.floor((secs % 3600) / 60); + const seconds = secs % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } else { + return `${seconds}s`; + } + }; + + const getSelectedOptionText = ( + question: AttemptSummaryDTO['questions'][0] + ) => { + if (!question.selectedOptionId) return null; + const selectedOption = question.options.find( + (opt) => opt.id === question.selectedOptionId + ); + return selectedOption?.value || null; + }; + + const answeredQuestions = summary.questions.filter( + (q) => q.selectedOptionId !== null + ).length; + const totalQuestions = summary.questions.length; + const completionRate = + totalQuestions > 0 + ? Math.round((answeredQuestions / totalQuestions) * 100) + : 0; + + return ( +
+ {/* Header */} +
+

+ {t('summary.title')} +

+

{t('summary.description')}

+ +
+ + {/* Attempt Overview */} + + + + + {t('summary.attempt_number', { + number: summary.attemptNumber, + })} + + + +
+ {/* Submission Info */} +
+
+ +
+
+

+ {t('summary.submitted')} +

+

+ {fmtDate(summary.submittedAt)} +

+
+
+ + {/* Duration */} +
+
+ +
+
+

+ {t('summary.duration')} +

+

+ {fmtDur(summary.durationSeconds)} +

+
+
+ + {/* Completion Rate */} +
+
+ +
+
+

+ {t('summary.completion')} +

+

+ {/* {answeredQuestions} of {totalQuestions} questions */} + {t('summary.answered_of_total', { + answered: answeredQuestions, + total: totalQuestions, + })} +

+
+
+
+ + {/* Progress Bar */} +
+
+ + {t('summary.progress')} + + + {completionRate}% + +
+ +
+
+
+ + {/* Questions Section */} + {summary.questions && summary.questions.length > 0 && ( +
+
+ +

+ {t('summary.questions_and_responses')} +

+ + {t('summary.questions_count', { + count: summary.questions.length, + })} + +
+ +
+ {summary.questions.map((q, index) => { + const isAnswered = q.selectedOptionId !== null; + const selectedOptionText = getSelectedOptionText(q); + + return ( + + +
+ + + Q{index + 1}. + + {q.question} + +
+ {isAnswered ? ( +
+ + + {t('summary.answered')} + +
+ ) : ( +
+ + + {t('summary.skipped')} + +
+ )} +
+
+
+ + + + {/* Selected Answer */} +
+ + {t('summary.your_response')} + + {isAnswered && selectedOptionText ? ( +
+
+ +
+

+ {selectedOptionText} +

+
+
+
+ ) : ( +
+
+ + + {t('summary.no_answer')} + +
+
+ )} +
+ + {/* All Options */} +
+ + {t('summary.available_options')} + +
+ {q.options.map((option) => { + const isSelected = option.id === q.selectedOptionId; + return ( +
+
+
+

+ {option.value} +

+
+ {isSelected && ( + + )} +
+ ); + })} +
+
+ + + ); + })} +
+
+ )} + + {/* Summary Stats */} + + +
+
+
+ {summary.attemptNumber} +
+
+ {t('summary.summary_stats.attempt_number', { + number: summary.attemptNumber, + })} +
+
+
+
+ {answeredQuestions} +
+
+ {t('summary.summary_stats.answered')} +
+
+
+
+ {totalQuestions - answeredQuestions} +
+
+ {t('summary.summary_stats.skipped')} +
+
+
+
+ {fmtDur(summary.durationSeconds)} +
+
+ {t('summary.summary_stats.time_taken')} +
+
+
+
+
+
+ +
+
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx new file mode 100644 index 0000000000..5a50e27ad8 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/page.tsx @@ -0,0 +1,22 @@ +import QuizResultClient from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client'; + +export default async function QuizResultPage({ + 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]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client.tsx new file mode 100644 index 0000000000..ca2c747c73 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/quiz-result-client.tsx @@ -0,0 +1,294 @@ +'use client'; + +import ShowAttemptDetailSection, { + AttemptDetailDTO, +} from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-attempt-detail-section'; +import ShowResultSummarySection from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-results/show-result-summary-section'; +import AttemptSummaryView, { + AttemptSummaryDTO, +} from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/result/display-summary-attempts/attempt-summary-view'; +import { Alert, AlertDescription } from '@tuturuuu/ui/alert'; +import { Button } from '@tuturuuu/ui/button'; +import { LoadingIndicator } from '@tuturuuu/ui/custom/loading-indicator'; +import { useTranslations } from 'next-intl'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +// const dummyAttemptDetail: AttemptDetailDTO = { +// attemptId: "att_123456", +// attemptNumber: 1, +// totalScore: 75, +// maxPossibleScore: 100, +// startedAt: "2024-03-20T10:00:00Z", +// completedAt: "2024-03-20T10:15:30Z", +// durationSeconds: 930, // 15 minutes and 30 seconds +// explanationMode: 2, // 0: no explanations, 1: only correct answers, 2: all explanations +// questions: [ +// { +// quizId: "q_1", +// question: "What is the capital of France?", +// scoreWeight: 25, +// selectedOptionId: "opt_2", +// isCorrect: true, +// scoreAwarded: 25, +// options: [ +// { +// id: "opt_1", +// value: "London", +// isCorrect: false, +// explanation: "London is the capital of the United Kingdom, not France." +// }, +// { +// id: "opt_2", +// value: "Paris", +// isCorrect: true, +// explanation: "Paris is the capital and largest city of France." +// }, +// { +// id: "opt_3", +// value: "Berlin", +// isCorrect: false, +// explanation: "Berlin is the capital of Germany, not France." +// }, +// { +// id: "opt_4", +// value: "Madrid", +// isCorrect: false, +// explanation: "Madrid is the capital of Spain, not France." +// } +// ] +// }, +// { +// quizId: "q_2", +// question: "Which planet is known as the Red Planet?", +// scoreWeight: 25, +// selectedOptionId: "opt_6", +// isCorrect: false, +// scoreAwarded: 25, +// options: [ +// { +// id: "opt_5", +// value: "Mars", +// isCorrect: true, +// explanation: "Mars is called the Red Planet because of its reddish appearance due to iron oxide on its surface." +// }, +// { +// id: "opt_6", +// value: "Venus", +// isCorrect: false, +// explanation: "Venus is often called Earth's twin due to its similar size and mass." +// }, +// { +// id: "opt_7", +// value: "Jupiter", +// isCorrect: false, +// explanation: "Jupiter is the largest planet in our solar system." +// } +// ] +// }, +// { +// quizId: "q_3", +// question: "What is the chemical symbol for gold?", +// scoreWeight: 25, +// selectedOptionId: "opt_9", +// isCorrect: true, +// scoreAwarded: 25, +// options: [ +// { +// id: "opt_8", +// value: "Ag", +// isCorrect: false, +// explanation: "Ag is the chemical symbol for silver." +// }, +// { +// id: "opt_9", +// value: "Au", +// isCorrect: true, +// explanation: "Au comes from the Latin word 'aurum' meaning gold." +// }, +// { +// id: "opt_10", +// value: "Fe", +// isCorrect: false, +// explanation: "Fe is the chemical symbol for iron." +// } +// ] +// }, +// { +// quizId: "q_4", +// question: "Who painted the Mona Lisa?", +// scoreWeight: 25, +// selectedOptionId: null, +// isCorrect: false, +// scoreAwarded: 0, +// options: [ +// { +// id: "opt_11", +// value: "Leonardo da Vinci", +// isCorrect: true, +// explanation: "Leonardo da Vinci painted the Mona Lisa between 1503 and 1519." +// }, +// { +// id: "opt_12", +// value: "Vincent van Gogh", +// isCorrect: false, +// explanation: "Van Gogh is known for works like 'Starry Night' and 'Sunflowers'." +// }, +// { +// id: "opt_13", +// value: "Pablo Picasso", +// isCorrect: false, +// explanation: "Picasso is known for works like 'Guernica' and pioneering Cubism." +// } +// ] +// } +// ] +// }; + +type ApiResponse = AttemptSummaryDTO | AttemptDetailDTO; + +export default function QuizResultClient({ + wsId, + courseId, + moduleId, + setId, +}: { + wsId: string; + courseId: string; + moduleId: string; + setId: string; +}) { + const t = useTranslations(); + const router = useRouter(); + const search = useSearchParams(); + const attemptId = search.get('attemptId'); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(null); + + useEffect(() => { + if (!attemptId) { + setError(t('ws-quizzes.no_attempt_specified') || 'No attempt specified'); + setLoading(false); + return; + } + + async function load() { + setLoading(true); + setError(null); + + try { + const res = await fetch( + `/api/v1/workspaces/${wsId}/quiz-sets/${setId}/attempts/${attemptId}`, + { cache: 'no-store' } + ); + const json = await res.json(); + + if (!res.ok) { + setError( + json.error || + t('ws-quizzes.failed_load') || + 'Failed to load results' + ); + } else { + setDetail(json); + } + } catch { + setError(t('ws-quizzes.network_error') || 'Network error'); + } finally { + setLoading(false); + } + } + + load(); + }, [attemptId, wsId, setId, t]); + + const backToTakeQuizPage = () => { + router.push( + `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${setId}/take` + ); + }; + + if (loading) { + return ; + } + + if (error) { + return ( +
+ + {error} + + +
+ ); + } + + if (!detail) { + // Shouldn't happen, but guard anyway + return null; + } + + if ('totalScore' in detail) { + return ( +
+ {/* Summary */} + + + {/* Detailed per-question breakdown */} + +
+ ); + } + + return ( + + ); + + // return ( + //
+ // {/* Summary */} + // + + // {/* Detailed per-question breakdown */} + // + //
+ // ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx new file mode 100644 index 0000000000..9ca924fcc7 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx @@ -0,0 +1,442 @@ +'use client'; + +import { Alert, AlertDescription } from '@tuturuuu/ui/alert'; +import { Badge } from '@tuturuuu/ui/badge'; +import { Button } from '@tuturuuu/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@tuturuuu/ui/card'; +import { Separator } from '@tuturuuu/ui/separator'; +import { RichTextEditor } from '@tuturuuu/ui/text-editor/editor'; +import { JSONContent } from '@tuturuuu/ui/tiptap'; +import { + AlertTriangle, + Calendar, + CheckCircle, + Clock, + FileChartColumnIncreasing, + Info, + Play, + RotateCcw, + TriangleAlert, +} from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +export interface AttemptSummary { + attemptId: string; + attemptNumber: number; + submittedAt: string; // ISO timestamp + totalScore: number | null; // null if not graded yet + durationSeconds: number; +} + +interface QuizData { + setId: string; + setName: string; + availableDate: string | null; + dueDate: string | null; + attemptLimit: number | null; + attemptsSoFar: number; + timeLimitMinutes: number | null; + allowViewOldAttempts: boolean; + explanationMode: 0 | 1 | 2; + instruction: string | null; + resultsReleased: boolean; + maxScore: number; + attempts: AttemptSummary[]; +} + +interface BeforeTakingQuizWholeProps { + wsId: string; + courseId: string; + moduleId: string; + setId: string; + quizData: QuizData; + isPastDue: boolean; + isAvailable: boolean; + onStart: () => void; +} + +function formatDate(dateString: string | null) { + if (!dateString) return '—'; + return new Date(dateString).toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatDuration(sec: number) { + const m = Math.floor(sec / 60) + .toString() + .padStart(2, '0'); + const s = (sec % 60).toString().padStart(2, '0'); + return `${m}:${s}`; +} + +export default function BeforeTakingQuizWhole({ + quizData, + isPastDue, + isAvailable, + onStart, + wsId, + courseId, + moduleId, +}: BeforeTakingQuizWholeProps) { + const t = useTranslations('ws-quizzes'); + const router = useRouter(); + const [isStarting, setIsStarting] = useState(false); + + const attemptsRemaining = quizData.attemptLimit + ? quizData.attemptLimit - quizData.attemptsSoFar + : null; + + // Determine if the quiz can be retaken + const canRetake = + isAvailable && + !isPastDue && + (attemptsRemaining == null || attemptsRemaining > 0); + // && !quizData.resultsReleased; + + // MARK: modify if logic changes to (can view result even not past due and attempt remain > 0) + // At that time, add 1 variable to check view old attempts with result released + const canViewResult = isAvailable && quizData.resultsReleased; + // (isPastDue || (!isPastDue && attemptsRemaining == 0)); + + // Can view old attempts details (but no points or only can view total points only) + const canViewOldAttemptsNoResults = + quizData.attemptsSoFar > 0 && quizData.allowViewOldAttempts; + // !quizData.resultsReleased; + + const canViewTotalPointsOnly = quizData.resultsReleased; + + console.log('Test', quizData.attempts[0]); + + // const canViewOldAttemptsResults = quizData.resultsReleased; + // can view attempts with points in detailed explanation + const canViewOldAttemptsResults = + isAvailable && + quizData.resultsReleased && + (quizData.allowViewOldAttempts || + (!quizData.allowViewOldAttempts && isPastDue)); + + const handleStartQuiz = () => { + setIsStarting(true); + setTimeout(() => { + setIsStarting(false); + onStart(); + }, 500); + }; + + const viewAttempt = (att: AttemptSummary) => { + router.push( + `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${quizData.setId}/result?attemptId=${att.attemptId}` + ); + }; + + return ( +
+
+ {/* Header */} +
+

+ {quizData.setName} +

+

{t('quiz.review-info')}

+ + {/* Start Button */} + {canRetake && ( +
+ +

+ {t('quiz.click-to-begin')} +

+
+ )} + + {quizData.attempts[0] && + quizData.attempts[0].totalScore != null && + canViewTotalPointsOnly && ( + + +
+

+ {quizData.attempts[0].totalScore} / {quizData.maxScore} +

+ +

+ {t( + canViewResult + ? 'quiz.view-final-attempt' + : 'quiz.view-final-total-score', + { + score: quizData.attempts[0]?.totalScore ?? '—', + maxScore: quizData.maxScore, + } + )} +

+
+ {canViewResult && ( + + )} +
+
+ )} + {!isAvailable ? ( + + + {t('quiz.quiz-not-available-message')} + + + ) : ( + !quizData.resultsReleased && + (isPastDue || attemptsRemaining == 0) && ( + + + + {isPastDue + ? t('quiz.quiz-past-due-message') + : t('quiz.no-attempts-message')} + + + ) + )} +
+ + {/* Info & Schedule */} +
+ {/* Quiz Information */} + + + + + {t('info.quiz-information')} + + + +
+ + {t('info.quiz-id')} + + {quizData.setId} +
+ + +
+ + {t('info.time-limit')} + +
+ + + {quizData.timeLimitMinutes + ? `${quizData.timeLimitMinutes} ${t('info.minutes')}` + : t('info.no-time-limit')} + +
+
+
+ + {t('info.attempts-used')} + +
+ + + {quizData.attemptsSoFar} /{' '} + {quizData.attemptLimit || '∞'}{' '} + +
+
+
+ + {t('info.explanations')} + + + {quizData.explanationMode === 0 + ? t('info.explanation-modes.none') + : quizData.explanationMode === 1 + ? t('info.explanation-modes.correct-after-release') + : t('info.explanation-modes.all-after-release')} + +
+
+
+ + + + + + {t('info.schedule')} + + + +
+
+ + {t('info.available-from')} + + {isAvailable && ( + + )} +
+

{formatDate(quizData.availableDate)}

+
+ +
+
+ + {t('info.due-date')} + + {isPastDue && ( + + )} +
+

{formatDate(quizData.dueDate)}

+
+ {attemptsRemaining != null && ( + <> + +

+ {attemptsRemaining}{' '} + {attemptsRemaining !== 1 + ? t('info.attempts-remaining') + : t('info.attempt-remaining')} +

+ + )} +
+
+
+ + {/* Instructions */} + + + {t('instructions.title')} + {t('instructions.subtitle')} + + + {quizData.instruction ? ( + //

{quizData.instruction}

+ + ) : ( +
+

• {t('instructions.default.stable-connection')}

+

• {t('instructions.default.cannot-pause')}

+

• {t('instructions.default.answer-all')}

+

• {t('instructions.default.auto-save')}

+ {quizData.timeLimitMinutes && ( +

+ •{' '} + {t('instructions.default.time-limit', { + minutes: quizData.timeLimitMinutes, + })} +

+ )} +
+ )} +
+
+ + {/* Past Attempts */} + {quizData.attemptsSoFar > 0 && ( + + + {t('past-attempts.title')} + + {canViewOldAttemptsNoResults + ? t('past-attempts.view-answers') + : canViewOldAttemptsResults + ? t('past-attempts.view-results') + : t('past-attempts.results-pending')} + + + + {quizData.attempts.map((att) => ( +
+
+

+ {canViewTotalPointsOnly ? ( + <> + + {att.totalScore} / {quizData.maxScore} + {' '} + + ) : ( + t('past-attempts.results-pending') + )} +

+

+ + #{att.attemptNumber} + + {t('past-attempts.attempt-info', { + number: att.attemptNumber, + date: formatDate(att.submittedAt), + duration: formatDuration(att.durationSeconds), + })} +

+
+ {(canViewOldAttemptsResults || + canViewOldAttemptsNoResults) && ( + + )} +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/page.tsx similarity index 84% rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx rename to apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/page.tsx index ee238a7aab..239b31bc1b 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/page.tsx @@ -1,4 +1,4 @@ -import TakingQuizClient from '@/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client'; +import TakingQuizClient from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client'; export default async function TakeQuiz({ params, diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx new file mode 100644 index 0000000000..f2c9c2c1ec --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { CheckIcon, Circle } from '@tuturuuu/ui/icons'; +import { useTranslations } from 'next-intl'; +import React, { useCallback } from 'react'; + +export type Question = { + quizId: string; + question: string; + score: number; + options: { id: string; value: string }[]; +}; + +interface QuizStatusSidebarProps { + questions: Question[]; + selectedAnswers: Record; +} + +export default function QuizStatusSidebar({ + questions, + selectedAnswers, +}: QuizStatusSidebarProps) { + const t = useTranslations('ws-quizzes.quiz-status'); + // Count how many questions have at least one selected answer + const answeredCount = questions.reduce((count, q) => { + const sel = selectedAnswers[q.quizId]; + if (Array.isArray(sel) ? sel.length > 0 : Boolean(sel)) { + return count + 1; + } + return count; + }, 0); + + // Scroll smoothly to the question container + const onQuestionJump = useCallback((idx: number) => { + const el = document.getElementById(`quiz-${idx}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.focus(); + } + }, []); + + const pct = questions.length + ? Math.round((answeredCount / questions.length) * 100) + : 0; + + return ( + + ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx new file mode 100644 index 0000000000..c41da2658b --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/taking-quiz-client.tsx @@ -0,0 +1,472 @@ +'use client'; + +import BeforeTakingQuizWhole, { + AttemptSummary, +} from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole'; +import QuizStatusSidebar from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/quiz-status-sidebar'; +import TimeElapsedStatus from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status'; +import { Json } from '@tuturuuu/types/supabase'; +import { Button } from '@tuturuuu/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card'; +import { Checkbox } from '@tuturuuu/ui/checkbox'; +import { LoadingIndicator } from '@tuturuuu/ui/custom/loading-indicator'; +import { ListCheck, TriangleAlert } from '@tuturuuu/ui/icons'; +import { Label } from '@tuturuuu/ui/label'; +import { RadioGroup, RadioGroupItem } from '@tuturuuu/ui/radio-group'; +import { toast } from '@tuturuuu/ui/sonner'; +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; + allowViewOldAttempts: boolean; + availableDate: string | null; + dueDate: string | null; + resultsReleased: boolean; + explanationMode: 0 | 1 | 2; + instruction: any; + attempts: AttemptSummary[]; + maxScore: number; + questions: Array<{ + quizId: string; + question: string; + instruction: Json; + score: number; + multiple: boolean; + options: { id: string; value: string }[]; + }>; +}; + +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('ws-quizzes'); + 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 [isAvailable, setIsAvailable] = useState(true); + + const [timeLeft, setTimeLeft] = useState(null); + // eslint-disable-next-line no-undef + const timerRef = useRef(null); + + // Now can be string (radio) or string[] (checkbox) + const [selectedAnswers, setSelectedAnswers] = useState< + Record + >({}); + + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + // ─── HELPERS ──────────────────────────────────────────────────────────────── + const STORAGE_KEY = `quiz_start_${setId}`; + const ANSWERS_KEY = `quiz_answers_${setId}`; + const totalSeconds = quizMeta?.timeLimitMinutes + ? quizMeta.timeLimitMinutes * 60 + : null; + + const clearStartTimestamp = () => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore + } + }; + + const computeElapsedSeconds = (startTs: number) => + Math.floor((Date.now() - startTs) / 1000); + + const buildSubmissionPayload = () => ({ + answers: Object.entries(selectedAnswers) + .map(([quizId, val]) => { + if (Array.isArray(val)) { + return val.map((v) => ({ quizId, selectedOptionId: v })); + } + return { quizId, selectedOptionId: val }; + }) + .flat(), + durationSeconds: computeElapsedSeconds( + Number(localStorage.getItem(STORAGE_KEY)) + ), + }); + + // Only for debugging purposes + // useEffect(() => { + // localStorage.removeItem(ANSWERS_KEY); + // localStorage.removeItem(STORAGE_KEY); + // clearStartTimestamp(); + // }, [setId]); + + // ─── 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 || t('errors.unknown-error')); + return setLoadingMeta(false); + } + + setQuizMeta(json as TakeResponse); + + // restore answers + const saved = localStorage.getItem(ANSWERS_KEY); + if (saved) { + try { + setSelectedAnswers(JSON.parse(saved)); + } catch { + // Ignore JSON parse errors + } + } + // due date + if ('dueDate' in json && json.dueDate) { + if (new Date(json.dueDate) < new Date()) { + setIsPastDue(true); + } + } + + // available date + if ('availableDate' in json && json.availableDate) { + setIsAvailable(new Date(json.availableDate) <= new Date()); + } + + // resume timer + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const startTs = parseInt(stored, 10); + if (!isNaN(startTs)) { + setHasStarted(true); + if (totalSeconds != null) { + const elapsed = computeElapsedSeconds(startTs); + setTimeLeft(elapsed >= totalSeconds ? 0 : totalSeconds - elapsed); + } else { + setTimeLeft(computeElapsedSeconds(startTs)); + } + } + } + } catch { + setMetaError(t('errors.network-error')); + } finally { + setLoadingMeta(false); + } + } + fetchMeta(); + return () => { + timerRef.current && clearInterval(timerRef.current); + }; + }, [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]); + useEffect(() => { + if (submitError) { + toast(t('errors.error-type-submit'), { + description: submitError, + action: { + label: 'X', + onClick: () => console.log('Close'), + }, + }); + } + }, [submitError]); + + // ─── EVENT HANDLERS ───────────────────────────────────────────────────────── + const onClickStart = () => { + if (!quizMeta) return; + const nowMs = Date.now(); + try { + localStorage.setItem(STORAGE_KEY, nowMs.toString()); + } catch { + // Ignore localStorage errors + // Fallback: use session storage + } + 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 || t('errors.submission-failed')); + return setSubmitting(false); + } + + localStorage.removeItem(ANSWERS_KEY); + localStorage.removeItem(STORAGE_KEY); + clearStartTimestamp(); + if ('attemptId' in json) { + router.push( + `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${setId}/result?attemptId=${json.attemptId}` + ); + } + // setSubmitResult(json as SubmitResult); + } catch { + setSubmitError(t('errors.network-error-submitting')); + } finally { + setSubmitting(false); + } + } + + // ─── RENDER ───────────────────────────────────────────────────────────────── + if (loadingMeta) return ; + if (metaError) + return ( +
+ +

{metaError}

+
+ ); + if (!quizMeta) return null; + + // Take Quiz button + instruction + if (!hasStarted) { + return ( + + ); + } + + // Quiz form + const isCountdown = totalSeconds != null; + + return ( +
+ {/* Mobile header */} +
+
+ + +
+ {sidebarVisible && quizMeta && ( + + )} +
+ +
+

{quizMeta.setName}

+
{ + e.preventDefault(); + handleSubmit(); + }} + className="space-y-8" + > + {quizMeta.questions.map((q, idx) => { + const sel = selectedAnswers[q.quizId]; + const selArray = q.multiple ? (Array.isArray(sel) ? sel : []) : []; + + return ( + + + + {idx + 1}. {q.question}{' '} + + ({t('points')}: {q.score}) + + + + + {q.multiple ? ( + // Multiple‐choice + q.options.map((opt) => ( +
+ { + let nextArr = selArray.slice(); + if (checked) { + nextArr.push(opt.id); + } else { + nextArr = nextArr.filter((x) => x !== opt.id); + } + const nextState = { + ...selectedAnswers, + [q.quizId]: nextArr, + }; + setSelectedAnswers(nextState); + try { + localStorage.setItem( + ANSWERS_KEY, + JSON.stringify(nextState) + ); + } catch { + // Ignore localStorage errors + } + }} + /> + +
+ )) + ) : ( + // Single‐choice + { + const next = { ...selectedAnswers, [q.quizId]: value }; + setSelectedAnswers(next); + try { + localStorage.setItem( + ANSWERS_KEY, + JSON.stringify(next) + ); + } catch { + // Ignore localStorage errors + } + }} + className="space-y-2" + > + {q.options.map((opt) => ( +
+ + +
+ ))} +
+ )} +
+
+ ); + })} + +
+ +
+
+
+ + +
+ ); +} 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]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status.tsx similarity index 83% rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status.tsx rename to apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status.tsx index cdc961a8cf..8cbc110f32 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/time-elapsed-status.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/time-elapsed-status.tsx @@ -1,4 +1,5 @@ import { Eye, EyeClosed } from '@tuturuuu/ui/icons'; +import { useTranslations } from 'next-intl'; import { useState } from 'react'; // Format seconds as MM:SS @@ -11,23 +12,22 @@ const formatSeconds = (sec: number) => { }; interface TimeElapsedStatusProps { - t: (key: string, options?: Record) => string; isCountdown: boolean; timeLeft: number | null; } export default function TimeElapsedStatus({ - t, isCountdown, timeLeft, }: TimeElapsedStatusProps) { + const t = useTranslations('ws-quizzes.time'); 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'; + ? t('remaining') || 'Time Remaining' + : t('elapsed') || 'Time Elapsed'; const timerColorClass = isCountdown && timeLeft !== null && timeLeft <= 60 @@ -43,8 +43,8 @@ export default function TimeElapsedStatus({ timeLeft !== null ? formatSeconds(timeLeft) : '--:--' }` : timeLeft !== null - ? t('ws-quizzes.hidden_time_remaining') || 'Time Hidden' - : t('ws-quizzes.hidden_time_elapsed') || 'Time Hidden'} + ? t('hidden_remaining') || 'Time Hidden' + : t('hidden_elapsed') || 'Time Hidden'}

@@ -64,9 +63,19 @@ export default async function ModuleQuizzesPage({ params }: Props) { />
- +
- +
{/*
@@ -82,7 +91,7 @@ export default async function ModuleQuizzesPage({ params }: Props) { )}
- +
*/}
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 deleted file mode 100644 index 11df9bb334..0000000000 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/result/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Page() { - return
Hello
; -} 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 deleted file mode 100644 index 59d1da8b43..0000000000 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/quiz-status-sidebar.tsx +++ /dev/null @@ -1,125 +0,0 @@ -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 deleted file mode 100644 index b867849daf..0000000000 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/before-take-quiz-section.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 1a5e2e4c54..0000000000 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/past-due-section.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 51113323e0..0000000000 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/sections/show-result-summary-section.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index da5b095547..0000000000 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/take/taking-quiz-client.tsx +++ /dev/null @@ -1,469 +0,0 @@ -'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/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/route.ts new file mode 100644 index 0000000000..85f048bef1 --- /dev/null +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/attempts/[attemptId]/route.ts @@ -0,0 +1,219 @@ +// app/api/quiz-sets/[setId]/attempts/[attemptId]/route.ts +import { createClient } from '@tuturuuu/supabase/next/server'; +import { NextRequest, NextResponse } from 'next/server'; + +interface Params { + params: Promise<{ + setId: string; + attemptId: string; + }>; +} + +export async function GET(_req: NextRequest, { params }: Params) { + const { setId, attemptId } = await params; + const sb = await createClient(); + + // 1) Authenticate + const { + data: { user }, + error: uErr, + } = await sb.auth.getUser(); + if (uErr || !user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + const userId = user.id; + + // 2) Load our two flags + const { data: setRow, error: sErr } = await sb + .from('workspace_quiz_sets') + .select('allow_view_old_attempts, results_released, explanation_mode') + .eq('id', setId) + .maybeSingle(); + if (sErr || !setRow) { + return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 }); + } + const { + allow_view_old_attempts: allowViewOldAttempts, + results_released: allowViewResult, + explanation_mode, + } = setRow; + + // 3) Load attempt header + const { data: attRow, error: attErr } = await sb + .from('workspace_quiz_attempts') + .select('id, attempt_number, started_at, completed_at') + .eq('id', attemptId) + .eq('user_id', userId) + .eq('set_id', setId) + .maybeSingle(); + if (attErr || !attRow) { + return NextResponse.json({ error: 'Attempt not found' }, { status: 404 }); + } + const startedMs = new Date(attRow.started_at).getTime(); + const completedMs = attRow.completed_at + ? new Date(attRow.completed_at).getTime() + : Date.now(); + const durationSeconds = Math.round((completedMs - startedMs) / 1000); + + // 4) Branch #1: neither summary‐only nor full detail allowed ⇒ return only summary + if (!allowViewOldAttempts && !allowViewResult) { + return NextResponse.json({ + attemptId: attRow.id, + attemptNumber: attRow.attempt_number, + submittedAt: attRow.completed_at, + durationSeconds, + }); + } + + const { data: ansRows, error: ansErr } = await sb + .from('workspace_quiz_attempt_answers') + .select('quiz_id, selected_option_id, is_correct, score_awarded') + .eq('attempt_id', attemptId); + if (ansErr || !ansRows) { + return NextResponse.json( + { error: 'Error fetching answers' }, + { status: 500 } + ); + } + const ansMap = new Map(ansRows.map((a) => [a.quiz_id, a])); + + // 6) Branch #2: summary‐only allowed (old attempts) ⇒ show question+user‐answer, but no correctness or points + if (allowViewOldAttempts && !allowViewResult) { + // re-fetch questions including options + const { data: sumQRaw, error: sumQErr } = await sb + .from('quiz_set_quizzes') + .select( + ` + quiz_id, + workspace_quizzes ( + question, + quiz_options ( + id, + value + ) + ) + ` + ) + .eq('set_id', setId); + if (sumQErr || !sumQRaw) { + return NextResponse.json( + { error: 'Error fetching summary questions' }, + { status: 500 } + ); + } + + type SumRow = { + quiz_id: string; + workspace_quizzes: { + question: string; + quiz_options: Array<{ + id: string; + value: string; + }>; + }; + }; + + const questions = (sumQRaw as SumRow[]).map((r) => { + const a = ansMap.get(r.quiz_id); + return { + quizId: r.quiz_id, + question: r.workspace_quizzes.question, + selectedOptionId: a?.selected_option_id ?? null, + options: r.workspace_quizzes.quiz_options.map((opt) => ({ + id: opt.id, + value: opt.value, + })), + }; + }); + + return NextResponse.json({ + attemptId: attRow.id, + attemptNumber: attRow.attempt_number, + submittedAt: attRow.completed_at, + durationSeconds, + questions, + }); + } + + // 7) Branch #3: full detail allowed (results_released) ⇒ include correctness, score, explanations + // 7a) we need each question’s options, weights, explanations + const { data: fullQRaw, error: fullQErr } = await sb + .from('quiz_set_quizzes') + .select( + ` + quiz_id, + workspace_quizzes ( + question, + score, + quiz_options ( + id, + value, + is_correct, + explanation + ) + ) + ` + ) + .eq('set_id', setId); + if (fullQErr || !fullQRaw) { + return NextResponse.json( + { error: 'Error fetching full questions' }, + { status: 500 } + ); + } + type FullRow = { + quiz_id: string; + workspace_quizzes: { + question: string; + score: number; + quiz_options: Array<{ + id: string; + value: string; + is_correct: boolean; + explanation: string | null; + }>; + }; + }; + const detailedQuestions = (fullQRaw as FullRow[]).map((r) => { + const a = ansMap.get(r.quiz_id); + return { + quizId: r.quiz_id, + question: r.workspace_quizzes.question, + scoreWeight: r.workspace_quizzes.score, + selectedOptionId: a?.selected_option_id ?? null, + isCorrect: a?.is_correct ?? false, + scoreAwarded: a?.score_awarded ?? 0, + options: r.workspace_quizzes.quiz_options.map((opt) => { + let explanation: string | null = null; + if (explanation_mode === 2) { + explanation = opt.explanation; + } else if (explanation_mode === 1 && opt.is_correct) { + explanation = opt.explanation; + } + return { + id: opt.id, + value: opt.value, + isCorrect: opt.is_correct, + explanation, + }; + }), + }; + }); + + const maxPossibleScore = detailedQuestions.reduce( + (sum, q) => sum + q.scoreWeight, + 0 + ); + + return NextResponse.json({ + attemptId: attRow.id, + attemptNumber: attRow.attempt_number, + totalScore: ansRows.reduce((sum, a) => sum + (a.score_awarded ?? 0), 0), + maxPossibleScore, + startedAt: attRow.started_at, + completedAt: attRow.completed_at, + durationSeconds, + explanationMode: explanation_mode, + questions: detailedQuestions, + }); +} diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts index 0e783cd6db..5e4fc826f9 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/results/route.ts @@ -1,184 +1,121 @@ +// app/api/quiz-sets/[setId]/results/route.ts 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[]; -}; +interface Params { + params: Promise<{ + setId: string; + }>; +} -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ setId: string }> } -) { +export async function GET(_req: NextRequest, { params }: Params) { const { setId } = await params; - const supabase = await createClient(); + const sb = await createClient(); - // 1) Auth + // Auth const { data: { user }, - error: userErr, - } = await supabase.auth.getUser(); - if (userErr || !user) { + error: uErr, + } = await sb.auth.getUser(); + if (uErr || !user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - const userId = user.id; + const uid = user.id; - // 2) Always allow if they have any attempts—and if allow_view_results is true - const { data: setRow, error: setErr } = await supabase + // Check allow_view_results + const { data: s, error: sErr } = await sb .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 } - ); + if (sErr || !s) + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + if (!s.allow_view_results) { + return NextResponse.json({ error: 'Viewing disabled' }, { status: 403 }); } - // 3) Fetch question info (correct answers + weight) - const { data: questionsRaw, error: qErr } = await supabase + // Fetch correct answers & weight + const { data: qRaw, error: qErr } = await sb .from('quiz_set_quizzes') .select( ` quiz_id, - workspace_quizzes ( - question, - score - ), - quiz_options!inner ( - value - ) + workspace_quizzes(score), + quiz_options!inner(value) -- only correct by join filter ` ) .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, + if (qErr) + return NextResponse.json({ error: 'Q fetch error' }, { status: 500 }); + + type Q = { + quiz_id: string; + workspace_quizzes: { score: number }; + quiz_options: { value: string }; + }; + const info = (qRaw as unknown as Q[]).map((r) => ({ + quizId: r.quiz_id, + weight: r.workspace_quizzes.score, + correct: r.quiz_options.value, })); - const maxPossibleScore = questionInfo.reduce((s, q) => s + q.scoreWeight, 0); + const maxScore = info.reduce((a, c) => a + c.weight, 0); - // 4) Fetch all attempts by user - const { data: attemptsData, error: attemptsErr } = await supabase + // Fetch attempts + const { data: aData, error: aErr } = await sb .from('workspace_quiz_attempts') .select( ` id, attempt_number, total_score, - started_at, - completed_at + submitted_at, + duration_seconds ` ) - .eq('user_id', userId) + .eq('user_id', uid) .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 }); + if (aErr) + return NextResponse.json({ error: 'Attempt fetch error' }, { status: 500 }); + + const results = await Promise.all( + aData!.map(async (att) => { + const { data: ansRows } = await sb + .from('workspace_quiz_attempt_answers') + .select('quiz_id,selected_option_id,is_correct,score_awarded') + .eq('attempt_id', att.id); + + const ansMap = new Map(ansRows!.map((r) => [r.quiz_id, r])); + const answers = info.map(async (qi) => { + const ar = ansMap.get(qi.quizId); + return { + quizId: qi.quizId, + correctOption: qi.correct, + selectedOption: ar + ? await (() => + sb + .from('quiz_options') + .select('value') + .eq('id', ar.selected_option_id) + .maybeSingle() + .then((r) => r.data?.value || null))() + : null, + isCorrect: ar?.is_correct ?? false, + scoreAwarded: ar?.score_awarded ?? 0, + }; + }); + + return { + attemptId: att.id, + attemptNumber: att.attempt_number, + totalScore: att.total_score ?? 0, + maxPossibleScore: maxScore, + submittedAt: att.submitted_at, + durationSeconds: att.duration_seconds, + answers, + }; + }) + ); + + return NextResponse.json({ attempts: results }); } diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts index afd904b887..ffab2dfa3c 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/submit/route.ts @@ -6,91 +6,106 @@ type SubmissionBody = { quizId: string; selectedOptionId: string; }>; + durationSeconds: number; }; -type RawRow = { - quiz_id: string; - workspace_quizzes: { - score: number; - quiz_options: Array<{ - id: string; - is_correct: boolean; - }>; - }; -}; +interface Params { + params: Promise<{ + setId: string; + }>; +} -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ setId: string }> } -) { +export async function POST(request: NextRequest, { params }: Params) { const { setId } = await params; - const supabase = await createClient(); + const sb = await createClient(); - // 1) Get current user + // 1) Authenticate const { data: { user }, - error: userErr, - } = await supabase.auth.getUser(); - if (userErr || !user) { + error: uErr, + } = await sb.auth.getUser(); + if (uErr || !user) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } const userId = user.id; - // 2) Parse request body + // 2) Parse and validate body let body: SubmissionBody; try { body = await request.json(); - } catch (e) { + } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } - const { answers } = body; - if (!Array.isArray(answers) || answers.length === 0) { + if (!Array.isArray(body.answers) || body.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 + // 3) Count existing attempts + const { data: prevAtt, error: countErr } = await sb .from('workspace_quiz_attempts') .select('attempt_number', { count: 'exact', head: false }) .eq('user_id', userId) .eq('set_id', setId); - - if (attErr) { + if (countErr) { return NextResponse.json( { error: 'Error counting attempts' }, { status: 500 } ); } - const attemptsCount = prevAttempts?.length || 0; + const attemptsCount = prevAtt?.length ?? 0; - // 4) Fetch attempt_limit for this quiz set - const { data: setRow, error: setErr } = await supabase + // 4) Load quiz-set constraints & flags + const { data: setRow, error: setErr } = await sb .from('workspace_quiz_sets') - .select('attempt_limit') + .select( + ` + attempt_limit, + time_limit_minutes, + available_date, + due_date, + allow_view_old_attempts, + results_released, + explanation_mode + ` + ) .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 - ) { + const { + attempt_limit, + time_limit_minutes, + available_date, + due_date, + allow_view_old_attempts, + results_released, + explanation_mode, + } = setRow; + + // 5) Enforce limits & dates + const now = new Date(); + if (new Date(available_date) > now) { + return NextResponse.json( + { error: 'Quiz not yet available', availableDate: available_date }, + { status: 403 } + ); + } + if (new Date(due_date) < now) { return NextResponse.json( - { error: 'Maximum attempts reached' }, + { error: 'Quiz past due', dueDate: due_date }, + { status: 403 } + ); + } + if (attempt_limit !== null && attemptsCount >= attempt_limit) { + return NextResponse.json( + { error: 'Maximum attempts reached', attemptsSoFar: attemptsCount }, { 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 + // 6) Fetch correct answers & weights + const { data: correctRaw, error: corrErr } = await sb .from('quiz_set_quizzes') .select( ` @@ -105,94 +120,74 @@ export async function POST( ` ) .eq('set_id', setId); - if (corrErr) { return NextResponse.json( - { error: 'Error fetching correct answers' }, + { error: 'Error fetching 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 || '' }); + type R = { + quiz_id: string; + workspace_quizzes: { + score: number; + quiz_options: Array<{ id: string; is_correct: boolean }>; + }; + }; + const quizMap = new Map(); + (correctRaw as R[]).forEach((r) => { + const correctOpt = + r.workspace_quizzes.quiz_options.find((o) => o.is_correct)?.id || ''; + quizMap.set(r.quiz_id, { + score: r.workspace_quizzes.score, + correctId: correctOpt, + }); }); - // 8) Loop through submitted answers, compare to correctOptionId, sum up total_score + // 7) Score each submitted answer 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; + const answersToInsert = body.answers.map(({ quizId, selectedOptionId }) => { + const info = quizMap.get(quizId); + const isCorrect = info?.correctId === selectedOptionId; + const awarded = isCorrect ? info!.score : 0; totalScore += awarded; - - answerInserts.push({ + return { 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 + // 8) Create attempt + const newAttemptNumber = attemptsCount + 1; + const { data: insAtt, error: insErr } = await sb .from('workspace_quiz_attempts') - .insert([ - { - user_id: userId, - set_id: setId, - attempt_number: newAttemptNumber, - total_score: totalScore, - }, - ]) - .select('id') + .insert({ + user_id: userId, + set_id: setId, + attempt_number: newAttemptNumber, + total_score: totalScore, + // duration_seconds: 0, // we’ll patch this below + duration_seconds: body.durationSeconds, // we’ll patch this below + }) + .select('id, started_at') .single(); - - if (insErr || !insertedAttempt) { + if (insErr || !insAtt) { 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 + // 9) Insert answers + const { error: ansErr } = await sb .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, + answersToInsert.map((a) => ({ + attempt_id: insAtt.id, + ...a, })) ); - if (ansErr) { return NextResponse.json( { error: 'Error inserting answers' }, @@ -200,25 +195,42 @@ export async function POST( ); } - // 11) Mark the attempt’s completed_at timestamp - const { error: updErr } = await supabase + // 10) Mark completed_at & compute duration + const completedAt = new Date().toISOString(); + await sb .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 + .update({ + completed_at: completedAt, + // duration_seconds: Math.floor( + // (Date.now() - new Date(insAtt.started_at).getTime()) / 1000 + // ), + }) + .eq('id', insAtt.id); + + // 11) Build the full response DTO return NextResponse.json({ - attemptId, + // attempt meta + attemptId: insAtt.id, attemptNumber: newAttemptNumber, totalScore, maxPossibleScore: Array.from(quizMap.values()).reduce( - (acc, { score }) => acc + score, + (sum, q) => sum + q.score, 0 ), + startedAt: insAtt.started_at, + completedAt, + durationSeconds: Math.floor( + (Date.now() - new Date(insAtt.started_at).getTime()) / 1000 + ), + + // quiz­set context + attemptLimit: attempt_limit, + attemptsSoFar: newAttemptNumber, + timeLimitMinutes: time_limit_minutes, + availableDate: available_date, + dueDate: due_date, + allowViewOldAttempts: allow_view_old_attempts, + resultsReleased: results_released, + explanationMode: explanation_mode, }); } diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts index 4158938bf2..a286747d30 100644 --- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts +++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quiz-sets/[setId]/take/route.ts @@ -1,34 +1,64 @@ import { createClient } from '@tuturuuu/supabase/next/server'; +import { Json } from '@tuturuuu/types/supabase'; import { NextRequest, NextResponse } from 'next/server'; -type RawRow = { - quiz_id: string; - workspace_quizzes: { +export type AttemptSummary = { + attemptId: string; + attemptNumber: number; + submittedAt: string; + totalScore: number | null; + durationSeconds: number; +}; + +export interface TakeResponse { + setId: string; + setName: string; + timeLimitMinutes: number | null; + attemptLimit: number | null; + attemptsSoFar: number; + allowViewOldAttempts: boolean; + availableDate: string | null; + dueDate: string | null; + resultsReleased: boolean; + explanationMode: 0 | 1 | 2; + instruction: any; + attempts: AttemptSummary[]; + maxScore: number; + questions: Array<{ + quizId: string; question: string; + instruction: Json; score: number; - quiz_options: { id: string; value: string }[]; - }; -}; + multiple: boolean; + options: { id: string; value: string }[]; + }>; + isAvailable: boolean; + isPastDue: boolean; + hasReachedMax: boolean; +} + +interface Params { + params: Promise<{ + setId: string; + }>; +} -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ setId: string }> } -) { +export async function GET(_req: NextRequest, { params }: Params) { const { setId } = await params; - const supabase = await createClient(); + const sb = await createClient(); // 1) Auth const { data: { user }, error: userErr, - } = await supabase.auth.getUser(); + } = await sb.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 + // 2) Fetch metadata + const { data: setRow, error: sErr } = await sb .from('workspace_quiz_sets') .select( ` @@ -36,74 +66,74 @@ export async function GET( name, attempt_limit, time_limit_minutes, + available_date, due_date, - release_points_immediately + allow_view_old_attempts, + results_released, + explanation_mode, + instruction ` ) .eq('id', setId) .maybeSingle(); - - if (setErr || !setRow) { + if (sErr || !setRow) { return NextResponse.json({ error: 'Quiz set not found' }, { status: 404 }); } - const { name: setName, - attempt_limit, - time_limit_minutes, - due_date, - release_points_immediately, + attempt_limit: attemptLimit, + time_limit_minutes: timeLimitMinutes, + available_date: availableDate, + due_date: dueDate, + allow_view_old_attempts: allowViewOldAttempts, + results_released: resultsReleased, + explanation_mode: explanationMode, + instruction, } = 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 + // 3) Count attempts + const { data: prev, error: aErr } = await sb .from('workspace_quiz_attempts') - .select('attempt_number', { count: 'exact', head: false }) + .select('attempt_number', { head: false }) .eq('user_id', userId) .eq('set_id', setId); - - if (attErr) { + if (aErr) { return NextResponse.json( { error: 'Error counting attempts' }, { status: 500 } ); } - const attemptsCount = prevAttempts?.length ?? 0; + const attemptsSoFar = prev?.length ?? 0; - // 5) If limit reached, block - if (attempt_limit !== null && attemptsCount >= attempt_limit) { + // 4) Summaries + const { data: rawAttempts, error: attErr } = await sb + .from('workspace_quiz_attempts') + .select('id,attempt_number,started_at,completed_at,total_score') + .eq('user_id', userId) + .eq('set_id', setId) + .order('attempt_number', { ascending: false }); + if (attErr) { return NextResponse.json( - { - error: 'Maximum attempts reached', - attemptsSoFar: attemptsCount, - attemptLimit: attempt_limit, - dueDate: due_date, - allowViewResults: false, - }, - { status: 403 } + { error: 'Error fetching attempts' }, + { status: 500 } ); } + const attempts = (rawAttempts || []).map((row) => { + const startMs = new Date(row.started_at).getTime(); + const endMs = row.completed_at + ? new Date(row.completed_at).getTime() + : Date.now(); + return { + attemptId: row.id, + totalScore: resultsReleased ? row.total_score : null, + attemptNumber: row.attempt_number, + submittedAt: row.completed_at ?? row.started_at, + durationSeconds: Math.floor((endMs - startMs) / 1000), + }; + }); - // 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 + // 5) Questions + const { data: rawQ, error: qErr } = await sb .from('quiz_set_quizzes') .select( ` @@ -111,39 +141,63 @@ export async function GET( workspace_quizzes ( question, score, + instruction, quiz_options ( id, - value + value, + is_correct ) ) ` ) .eq('set_id', setId); - - if (quizErr) { + if (qErr) { 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) => ({ + const questions = (rawQ || []).map((r) => ({ + quizId: r.quiz_id, + question: r.workspace_quizzes.question, + score: r.workspace_quizzes.score, + instruction: r.workspace_quizzes.instruction, + multiple: + r.workspace_quizzes.quiz_options.filter((o) => o.is_correct).length > 1, + options: r.workspace_quizzes.quiz_options.map((o) => ({ id: o.id, value: o.value, })), })); - return NextResponse.json({ + const maxScore = questions.reduce((a, c) => a + c.score, 0); + + // 6) Flags + const now = new Date(); + const isAvailable = !availableDate || new Date(availableDate) <= now; + const isPastDue = !!dueDate && new Date(dueDate) < now; + const hasReachedMax = attemptLimit !== null && attemptsSoFar >= attemptLimit; + + // 7) Payload + const payload: TakeResponse = { setId, setName, - attemptLimit: attempt_limit, - timeLimitMinutes: time_limit_minutes, - attemptsSoFar: attemptsCount, - dueDate: due_date, + timeLimitMinutes, + attemptLimit, + attemptsSoFar, + allowViewOldAttempts, + availableDate, + dueDate, + resultsReleased, + explanationMode: explanationMode as 0 | 1 | 2, + instruction, + attempts, + maxScore, questions, - }); + isAvailable, + isPastDue, + hasReachedMax, + }; + + return NextResponse.json(payload); } diff --git a/apps/web/src/app/api/[wsId]/[productId]/cancel/route.ts b/apps/web/src/app/api/[wsId]/[productId]/cancel/route.ts index 61cd1b6f2e..b194ec26ab 100644 --- a/apps/web/src/app/api/[wsId]/[productId]/cancel/route.ts +++ b/apps/web/src/app/api/[wsId]/[productId]/cancel/route.ts @@ -28,7 +28,7 @@ // // cancel_at_period_end: true, // This is the user-friendly way to cancel // // }); // const session = await api.customerSessions.create({ -// customerExternalId: '00000000-0000-0000-0000-000000000001', +// customerId: '00000000-0000-0000-0000-000000000001', // }); // const result = await api.customers.getExternal({ // externalId: user.id, diff --git a/apps/web/src/app/api/[wsId]/[productId]/payment/route.ts b/apps/web/src/app/api/[wsId]/[productId]/payment/route.ts index b8bcd83646..136f60ca52 100644 --- a/apps/web/src/app/api/[wsId]/[productId]/payment/route.ts +++ b/apps/web/src/app/api/[wsId]/[productId]/payment/route.ts @@ -60,7 +60,7 @@ export async function GET( const checkoutSession = await api.checkouts.create({ products: [productId], successUrl: `http://localhost:7803/${wsId}/billing/success`, - customerExternalId: user?.id || '', + customerId: user?.id || '', metadata: { wsId: wsId, }, diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index ed133bedc4..2e737acf11 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -5887,27 +5887,33 @@ export type Database = { Row: { attempt_number: number; completed_at: string | null; + duration_seconds: number | null; id: string; set_id: string; started_at: string; + submitted_at: string; total_score: number | null; user_id: string; }; Insert: { attempt_number: number; completed_at?: string | null; + duration_seconds?: number | null; id?: string; set_id: string; started_at?: string; + submitted_at?: string; total_score?: number | null; user_id: string; }; Update: { attempt_number?: number; completed_at?: string | null; + duration_seconds?: number | null; id?: string; set_id?: string; started_at?: string; + submitted_at?: string; total_score?: number | null; user_id?: string; }; @@ -5944,35 +5950,47 @@ export type Database = { }; workspace_quiz_sets: { Row: { + allow_view_old_attempts: boolean; allow_view_results: boolean; attempt_limit: number | null; + available_date: string; created_at: string; due_date: string; + explanation_mode: number; id: string; + instruction: Json | null; name: string; - release_points_immediately: boolean; + results_released: boolean; time_limit_minutes: number | null; ws_id: string | null; }; Insert: { + allow_view_old_attempts?: boolean; allow_view_results?: boolean; attempt_limit?: number | null; + available_date?: string; created_at?: string; due_date?: string; + explanation_mode?: number; id?: string; + instruction?: Json | null; name?: string; - release_points_immediately?: boolean; + results_released?: boolean; time_limit_minutes?: number | null; ws_id?: string | null; }; Update: { + allow_view_old_attempts?: boolean; allow_view_results?: boolean; attempt_limit?: number | null; + available_date?: string; created_at?: string; due_date?: string; + explanation_mode?: number; id?: string; + instruction?: Json | null; name?: string; - release_points_immediately?: boolean; + results_released?: boolean; time_limit_minutes?: number | null; ws_id?: string | null; }; @@ -5990,6 +6008,7 @@ export type Database = { Row: { created_at: string; id: string; + instruction: Json | null; question: string; score: number; ws_id: string; @@ -5997,6 +6016,7 @@ export type Database = { Insert: { created_at?: string; id?: string; + instruction?: Json | null; question: string; score?: number; ws_id: string; @@ -6004,6 +6024,7 @@ export type Database = { Update: { created_at?: string; id?: string; + instruction?: Json | null; question?: string; score?: number; ws_id?: string; @@ -7454,16 +7475,16 @@ export type Database = { }; count_search_users: { Args: + | { search_query: string } | { - role_filter?: string; search_query: string; enabled_filter?: boolean; - } - | { search_query: string }; + role_filter?: string; + }; Returns: number; }; create_ai_chat: { - Args: { model: string; title: string; message: string }; + Args: { title: string; model: string; message: string }; Returns: string; }; generate_cross_app_token: { @@ -7477,9 +7498,9 @@ export type Database = { | { p_origin_app: string; p_user_id: string; + p_session_data?: Json; p_target_app: string; p_expiry_seconds?: number; - p_session_data?: Json; }; Returns: string; }; @@ -7501,9 +7522,9 @@ export type Database = { get_daily_prompt_completion_tokens: { Args: { past_days?: number }; Returns: { - total_completion_tokens: number; - total_prompt_tokens: number; day: string; + total_prompt_tokens: number; + total_completion_tokens: number; }[]; }; get_finance_invoices_count: { @@ -7529,9 +7550,9 @@ export type Database = { get_hourly_prompt_completion_tokens: { Args: { past_hours?: number }; Returns: { - hour: string; total_completion_tokens: number; total_prompt_tokens: number; + hour: string; }[]; }; get_inventory_batches_count: { @@ -7545,21 +7566,21 @@ export type Database = { get_inventory_products: { Args: { _category_ids?: string[]; - _ws_id?: string; _warehouse_ids?: string[]; _has_unit?: boolean; + _ws_id?: string; }; Returns: { - unit: string; - id: string; - name: string; - manufacturer: string; unit_id: string; category: string; price: number; - amount: number; ws_id: string; created_at: string; + id: string; + amount: number; + name: string; + manufacturer: string; + unit: string; }[]; }; get_inventory_products_count: { @@ -7589,9 +7610,9 @@ export type Database = { get_monthly_prompt_completion_tokens: { Args: { past_months?: number }; Returns: { - total_prompt_tokens: number; - month: string; total_completion_tokens: number; + month: string; + total_prompt_tokens: number; }[]; }; get_pending_event_participants: { @@ -7601,68 +7622,68 @@ export type Database = { get_possible_excluded_groups: { Args: { _ws_id: string; included_groups: string[] }; Returns: { - amount: number; - ws_id: string; - name: string; id: string; + name: string; + ws_id: string; + amount: number; }[]; }; get_possible_excluded_tags: { - Args: { included_tags: string[]; _ws_id: string }; + Args: { _ws_id: string; included_tags: string[] }; Returns: { - amount: number; ws_id: string; - name: string; + amount: number; id: string; + name: string; }[]; }; get_session_statistics: { Args: Record; Returns: { total_count: number; - completed_count: number; - active_count: number; unique_users_count: number; latest_session_date: string; + completed_count: number; + active_count: number; }[]; }; get_session_templates: { Args: { + workspace_id: string; user_id_param: string; limit_count?: number; - workspace_id: string; }; Returns: { - category_name: string; - tags: string[]; - task_id: string; - category_id: string; - description: string; - title: string; last_used: string; + task_name: string; avg_duration: number; - usage_count: number; category_color: string; - task_name: string; + usage_count: number; + title: string; + description: string; + category_id: string; + task_id: string; + tags: string[]; + category_name: string; }[]; }; get_submission_statistics: { Args: Record; Returns: { - unique_users_count: number; latest_submission_date: string; + unique_users_count: number; total_count: number; }[]; }; get_transaction_categories_with_amount: { Args: Record; Returns: { + ws_id: string; amount: number; + id: string; name: string; is_expense: boolean; - ws_id: string; created_at: string; - id: string; }[]; }; get_user_role: { @@ -7672,34 +7693,34 @@ export type Database = { get_user_session_stats: { Args: { user_id: string }; Returns: { + current_session_age: unknown; total_sessions: number; active_sessions: number; - current_session_age: unknown; }[]; }; get_user_sessions: { Args: { user_id: string }; Returns: { - created_at: string; - updated_at: string; user_agent: string; - ip: string; - is_current: boolean; + created_at: string; session_id: string; + is_current: boolean; + ip: string; + updated_at: string; }[]; }; get_user_tasks: { Args: { _board_id: string }; Returns: { - board_id: string; - id: string; - name: string; + end_date: string; description: string; priority: number; completed: boolean; start_date: string; - end_date: string; list_id: string; + board_id: string; + id: string; + name: string; }[]; }; get_workspace_drive_size: { @@ -7715,24 +7736,24 @@ export type Database = { Returns: number; }; get_workspace_transactions_count: { - Args: { start_date?: string; end_date?: string; ws_id: string }; + Args: { end_date?: string; start_date?: string; ws_id: string }; Returns: number; }; get_workspace_user_groups: { Args: { included_tags: string[]; - excluded_tags: string[]; search_query: string; + excluded_tags: string[]; _ws_id: string; }; Returns: { created_at: string; - id: string; - name: string; - notes: string; - ws_id: string; - tags: string[]; tag_count: number; + tags: string[]; + ws_id: string; + notes: string; + name: string; + id: string; }[]; }; get_workspace_user_groups_count: { @@ -7747,6 +7768,7 @@ export type Database = { search_query: string; }; Returns: { + created_at: string; id: string; avatar_url: string; full_name: string; @@ -7765,7 +7787,6 @@ export type Database = { groups: string[]; group_count: number; linked_users: Json; - created_at: string; updated_at: string; }[]; }; @@ -7778,19 +7799,19 @@ export type Database = { Returns: number; }; get_workspace_wallets_expense: { - Args: { end_date?: string; ws_id: string; start_date?: string }; + Args: { ws_id: string; end_date?: string; start_date?: string }; Returns: number; }; get_workspace_wallets_income: { - Args: { end_date?: string; ws_id: string; start_date?: string }; + Args: { end_date?: string; start_date?: string; ws_id: string }; Returns: number; }; has_other_owner: { - Args: { _ws_id: string; _user_id: string }; + Args: { _user_id: string; _ws_id: string }; Returns: boolean; }; insert_ai_chat_message: { - Args: { chat_id: string; source: string; message: string }; + Args: { source: string; message: string; chat_id: string }; Returns: undefined; }; is_list_accessible: { @@ -7814,11 +7835,11 @@ export type Database = { Returns: boolean; }; is_nova_user_id_in_team: { - Args: { _team_id: string; _user_id: string }; + Args: { _user_id: string; _team_id: string }; Returns: boolean; }; is_org_member: { - Args: { _org_id: string; _user_id: string }; + Args: { _user_id: string; _org_id: string }; Returns: boolean; }; is_project_member: { @@ -7830,7 +7851,7 @@ 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: { @@ -7842,11 +7863,11 @@ 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: { - Args: { user_id: string; challenge_id: string }; + Args: { challenge_id: string; user_id: string }; Returns: number; }; nova_get_user_total_sessions: { @@ -7862,7 +7883,7 @@ export type Database = { Returns: number; }; revoke_user_session: { - Args: { target_user_id: string; session_id: string }; + Args: { session_id: string; target_user_id: string }; Returns: boolean; }; search_users: { @@ -7876,36 +7897,36 @@ export type Database = { enabled_filter?: boolean; }; Returns: { - allow_challenge_management: boolean; - allow_manage_all_challenges: boolean; + deleted: boolean; + avatar_url: string; + handle: string; bio: string; created_at: string; user_id: string; enabled: boolean; - team_name: string[]; - birthday: string; - new_email: string; - email: string; + allow_challenge_management: boolean; + allow_manage_all_challenges: boolean; allow_role_management: boolean; + email: string; + new_email: string; + birthday: string; + team_name: string[]; id: string; display_name: string; - deleted: boolean; - avatar_url: string; - handle: string; }[]; }; search_users_by_name: { Args: { - min_similarity?: number; - result_limit?: number; search_query: string; + result_limit?: number; + min_similarity?: number; }; Returns: { + id: string; + handle: string; display_name: string; - relevance: number; avatar_url: string; - handle: string; - id: string; + relevance: number; }[]; }; sum_quiz_scores: { @@ -7919,7 +7940,7 @@ export type Database = { Returns: boolean; }; transactions_have_same_amount: { - Args: { transaction_id_2: string; transaction_id_1: string }; + Args: { transaction_id_1: string; transaction_id_2: string }; Returns: boolean; }; update_expired_sessions: {