From 40416d8feeac3d7a0d64697fe4645f95eae408fa Mon Sep 17 00:00:00 2001 From: Shreyas Mene Date: Sun, 12 Jul 2026 02:08:39 +0530 Subject: [PATCH 01/17] feat(peer-review/frontend): implement peer-review student and teacher interfaces (cherry picked from commit bb65284973515e849d66485fa41ec8d36a78ebae) --- .../src/app/pages/student/course-page.tsx | 1337 +++++++++++------ .../app/pages/student/peer-review/MyScore.tsx | 180 +++ .../peer-review/PeerReviewSubmissionForm.tsx | 886 +++++++++++ .../pages/student/peer-review/ReviewForm.tsx | 265 ++++ .../student/peer-review/ReviewerDashboard.tsx | 272 ++++ .../components/PeerReviewAssessmentForm.tsx | 551 +++++++ .../components/PeerReviewCohortPicker.tsx | 145 ++ .../app/pages/teacher/teacher-course-page.tsx | 1219 ++++++++++++++- frontend/src/app/routes/router.tsx | 44 +- .../components/student-sidebar/nav-items.tsx | 3 +- frontend/src/hooks/hooks.ts | 392 ++++- 11 files changed, 4803 insertions(+), 491 deletions(-) create mode 100644 frontend/src/app/pages/student/peer-review/MyScore.tsx create mode 100644 frontend/src/app/pages/student/peer-review/PeerReviewSubmissionForm.tsx create mode 100644 frontend/src/app/pages/student/peer-review/ReviewForm.tsx create mode 100644 frontend/src/app/pages/student/peer-review/ReviewerDashboard.tsx create mode 100644 frontend/src/app/pages/teacher/components/PeerReviewAssessmentForm.tsx create mode 100644 frontend/src/app/pages/teacher/components/PeerReviewCohortPicker.tsx diff --git a/frontend/src/app/pages/student/course-page.tsx b/frontend/src/app/pages/student/course-page.tsx index 1c72eaaf6..6064cd9da 100644 --- a/frontend/src/app/pages/student/course-page.tsx +++ b/frontend/src/app/pages/student/course-page.tsx @@ -1,30 +1,53 @@ -import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from "react"; ExternalLink import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Sidebar, SidebarHeader, SidebarContent, SidebarMenu, SidebarMenuItem, + SidebarMenuButton, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton, + SidebarInset, SidebarProvider, SidebarTrigger, SidebarFooter, useSidebar +} from "@/components/ui/sidebar"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup, SidebarResizablePanel } from "@/components/ui/resizable"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { useCourseVersionById, useUserProgress, useItemsBySectionId, useItemById, useGetProcotoringSettings, useSubmitFlag, enqueueNavigation, useSkipOptionalItem, useRecalculateStudentProgress, useInvites, useAcceptInvite } from "@/hooks/hooks"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { useCourseVersionById, useUserProgress, useItemsBySectionId, useItemById, useProctoringSettings, useGetProcotoringSettings, useSubmitFlag, enqueueNavigation, useSkipOptionalItem, useRecalculateStudentProgress, useInvites, useAcceptInvite } from "@/hooks/hooks"; import { useAuthStore } from "@/store/auth-store"; import { useCourseStore } from "@/store/course-store"; import { Link, Navigate, useRouter } from "@tanstack/react-router"; import StudentProjectItem from "./components/StudentProjectItem"; -import { enterFullscreen, exitFullscreen } from "@/utils/fullscreen"; +import { PeerReviewSubmissionForm } from "./peer-review/PeerReviewSubmissionForm"; +import { usePeerReviewAssessmentByItemId } from "@/hooks/hooks"; const LazyStudentTimeslotModal = lazy(() => import("@/components/course/StudentTimeslotModal")); import type { Item, ItemContainerRef } from "@/types/item-container.types"; import type { PendingStudentQuestionContext } from "@/types/student-question.types"; import { Skeleton } from "@/components/ui/skeleton"; +import { AuroraText } from "@/components/magicui/aurora-text"; import confetti from "canvas-confetti"; import { ChevronRight, BookOpen, + Play, + FileText, + HelpCircle, Target, + Home, + GraduationCap, AlertCircle, + ArrowLeft, CheckCircle, + FlagTriangleRightIcon, + FileEdit, XCircle, X, CircleCheckIcon, - Maximize2, + Headphones, + ExternalLink, Menu } from "lucide-react"; -import FloatingVideo from "@/components/floating-video"; +import FloatingVideo, { FloatingVideoPlaceholder } from "@/components/floating-video"; import type { itemref } from "@/types/course.types"; import { logout } from "@/utils/auth"; import { StudentProctoringSettings } from "@/types/video.types"; @@ -32,30 +55,34 @@ import { FlagModal } from "@/components/FlagModal"; import { EntityType } from "@/types/flag.types"; import { toast } from "sonner"; import ItemContainer from "@/components/Item-container"; +import logo from "../../../../public/img/vibe_logo_img.ico" import { registerStream, unRegisterStream } from "@/lib/MediaRegistry"; import { useModuleProgress } from "@/hooks/hooks"; import { useIsMobile } from "@/hooks/use-mobile"; import MobileFallbackScreen from "@/components/MobileFallbackScreen"; -import { EmotionType } from "@/components/EmotionSelector"; +import { EmotionSelector, EmotionType } from "@/components/EmotionSelector"; import { useSubmitEmotion } from "@/hooks/use-emotion"; import { runProctoringChecks } from "@/utils/proctoring/proctoringGuard"; import { EthicsConsentModal } from "./components/policies/EthicsConsentModal"; import { useGetEthicsConsent } from "@/hooks/system-notification-hooks"; -// Focused learn-page UI -import { FloatingBackButton } from "@/components/learn/FloatingBackButton"; -import { FloatingCameraButton } from "@/components/learn/FloatingCameraButton"; -import { AiCompanion } from "@/components/learn/AiCompanion"; -import { AiActionSheet } from "@/components/learn/AiActionSheet"; -import { InitialWebcamPopup } from "@/components/learn/InitialWebcamPopup"; -import { ProctorAlertOverlay } from "@/components/learn/ProctorAlertOverlay"; -import { NoiseIndicator } from "@/components/learn/NoiseIndicator"; -import { AwayOverlay } from "@/components/learn/AwayOverlay"; -import { CourseDrawer } from "@/components/learn/CourseDrawer"; - -// Proctoring anomalies that should block the video and surface the buttonless -// alert (with webcam) — covers "no person" (noFace) and "more than one person". -const BLOCKING_ANOMALIES = ["noFace", "faceCountDetection", "multipleFaces", "faceRecognition"]; +// Helper function to get icon for item type +const getItemIcon = (type: string) => { + switch (type.toLowerCase()) { + case 'video': + return ; + case 'blog': + case 'article': + return ; + case 'quiz': + return ; + case 'form': + return ; + default: + return ; + } +}; + // Helper function to sort items by order property const sortItemsByOrder = (items: any[]) => { @@ -66,6 +93,19 @@ const sortItemsByOrder = (items: any[]) => { }); }; +/** + * Keeps the navigation sidebar collapsed while focus mode is active. + * Collapsing (rather than unmounting) the sidebar keeps the proctoring + * camera (FloatingVideo in the sidebar footer) mounted and decoding, so + * detection keeps running even though the camera is not shown. + */ +function SidebarFocusSync({ focusMode }: { focusMode: boolean }) { + const { setOpen } = useSidebar(); + useEffect(() => { + setOpen(!focusMode); + }, [focusMode, setOpen]); + return null; +} export default function CoursePage() { useEffect(() => { @@ -102,6 +142,7 @@ export default function CoursePage() { const { mutateAsync: submitFlagAsyncMutate, isPending } = useSubmitFlag(); const { mutateAsync: skipItemAsync, isPending: isSkipping } = useSkipOptionalItem(); const { mutateAsync: recalculateStudentProgressAsync } = useRecalculateStudentProgress(); + const [closing, setClosing] = useState(false); const [allProctorsDisabled, setAllProctorsDisabled] = useState(false); const streamRef = useRef(null); @@ -201,9 +242,26 @@ export default function CoursePage() { const [selectedSectionId, setSelectedSectionId] = useState(null); const [selectedItemId, setSelectedItemId] = useState(null); const [currentItem, setCurrentItem] = useState(null); + const isPeerReviewItem = + currentItem?.type === 'PEER_REVIEW_ASSESSMENT' || + // Some endpoints return lowercase; tolerate both. + (typeof currentItem?.type === 'string' && currentItem.type.toUpperCase() === 'PEER_REVIEW_ASSESSMENT'); + const peerReviewAssessmentHook = usePeerReviewAssessmentByItemId( + isPeerReviewItem ? (currentItem as any)?._id : undefined, + ); const [expandedModules, setExpandedModules] = useState>({}); const [expandedSections, setExpandedSections] = useState>({}); const [doGesture, setDoGesture] = useState(false); + // Focus mode: video plays maximized with the sidebar + surrounding chrome hidden. + // Single control model: the immersive view is driven entirely by the video's + // fullscreen button (bottom-right). focusMode simply mirrors native fullscreen, + // so entering/leaving fullscreen (or pressing Esc) is the one way in and out. + const [focusMode, setFocusMode] = useState(false); + useEffect(() => { + const onFsChange = () => setFocusMode(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', onFsChange); + return () => document.removeEventListener('fullscreenchange', onFsChange); + }, []); const [isItemForbidden, setIsItemForbidden] = useState(false); // Time-slot / commitment gate block (distinct from linear-progression ForbiddenError). const [timeSlotBlock, setTimeSlotBlock] = useState(null); @@ -215,63 +273,18 @@ export default function CoursePage() { const [anomalies, setAnomalies] = useState([]); const [isQuizSkipped, setIsQuizSkipped] = useState(false); const [readyToDetect, setReadyToDetect] = useState(false); - - // --- Focused learn-page UI state --- - const [drawerOpen, setDrawerOpen] = useState(false); - const [aiExpanded, setAiExpanded] = useState(false); - const [aiSheet, setAiSheet] = useState<"chat" | "talk" | "discussion" | null>(null); - const [camPinned, setCamPinned] = useState(false); - const [camHover, setCamHover] = useState(false); - const [pauseSignal, setPauseSignal] = useState(0); - // Cursor stepped off the page → pause; auto-resumes on return (handled in the player). - const [awayPaused, setAwayPaused] = useState(false); - // Pause the lesson video imperatively (without the anomaly overlay) whenever a - // floating control is used. - const pauseVideoForControl = useCallback(() => setPauseSignal((n) => n + 1), []); - - // Fullscreen is entered from the "Continue" click that brings the student here. - // Exit it when they leave the learn page so fullscreen is scoped to this page only. - useEffect(() => { - return () => exitFullscreen(); - }, []); - - // Strict fullscreen enforcement. The Fullscreen API only grants a request made - // inside a user gesture, so we can't silently re-enter after a reload or an Esc. - // Instead we track fullscreen state and, when the lesson is active but we're not - // fullscreen, show a blocking overlay + pause the video until the student clicks - // to go back into fullscreen (a valid gesture). - const [isPageFullscreen, setIsPageFullscreen] = useState(!!document.fullscreenElement); - useEffect(() => { - const onChange = () => setIsPageFullscreen(!!document.fullscreenElement); - document.addEventListener("fullscreenchange", onChange); - return () => document.removeEventListener("fullscreenchange", onChange); - }, []); - // Only gate once past the consent + proctoring-declaration dialogs (they run - // their own flow); then fullscreen is required for the focused learn stage. - const needsFullscreen = consentSatisfied && !showProctorDialog && !isPageFullscreen; - - // Debounced "blocking anomaly" (no person / multiple people / identity / etc.). - // Face detection is noisy, so require the anomaly to persist briefly before we - // block + show the alert; clear instantly when it resolves to avoid flicker. - const [blockingActive, setBlockingActive] = useState(false); - // Anomaly alert is held on screen for a minimum duration once shown (no flashes). - const [alertVisible, setAlertVisible] = useState(false); - const alertShownAtRef = useRef(0); - const rawBlocking = - !showProctorDialog && - !allProctorsDisabled && - (anomalies || []).some((a) => BLOCKING_ANOMALIES.includes(a)); - useEffect(() => { - if (rawBlocking) { - const t = setTimeout(() => setBlockingActive(true), 400); - return () => clearTimeout(t); - } - setBlockingActive(false); - }, [rawBlocking]); const [isNavigatingToPrev, setIsNavigatingToPrev] = useState(false); const [pendingStudentQuestionContext, setPendingStudentQuestionContext] = useState(null); const completedItemIdsRef = useRef>(new Set()); - + // State for sidebar visibility + const [isDesktopSidebarVisible, setIsDesktopSidebarVisible] = useState(true); + + // Separate state purely for pre-loading the next section in the background. +// Must NOT share activeSectionInfo — that state drives useItemById for the current item. +const [backgroundSectionInfo, setBackgroundSectionInfo] = useState<{ + moduleId: string; + sectionId: string; +} | null>(null); const [isGoingToNext, setIsGoingToNext] = useState(false); @@ -1594,27 +1607,18 @@ export default function CoursePage() { updateCourseNavigation, ]); -const nextItemInfo = findNextItem(); - -const proctorAlertActive = - blockingActive || (!showProctorDialog && !allProctorsDisabled && (pauseVid || rewindVid)); - -// Hold the alert visible for at least 2s once it appears, even if the anomaly -// clears sooner — prevents a jarring flash. -useEffect(() => { - if (proctorAlertActive) { - if (!alertVisible) { - alertShownAtRef.current = Date.now(); - setAlertVisible(true); + // Handle going back to courses + const handleGoBack = () => { + // Stop current item before navigating away + if (itemContainerRef.current) { + console.log("Handle go back is called....") + itemContainerRef.current.stopCurrentItem(); } - return; - } - if (alertVisible) { - const remaining = Math.max(0, 2000 - (Date.now() - alertShownAtRef.current)); - const t = setTimeout(() => setAlertVisible(false), remaining); - return () => clearTimeout(t); - } -}, [proctorAlertActive, alertVisible]); + // Navigate back to courses page + window.history.back(); + }; + +const nextItemInfo = findNextItem(); const isCurrentItemCompleted = Boolean((currentItem as any)?.isCompleted); @@ -1670,12 +1674,12 @@ const handleGoToNextItem = async () => { if (versionLoading || progressLoading || proctoringLoading || ethicsConsentLoading) { return ( -
+
- +
- - + +
@@ -1689,13 +1693,13 @@ const handleGoToNextItem = async () => { return ( - +
-
- +
+
-

Error loading course data

-

Please try again later

+

Error loading course data

+

Please try again later

@@ -1782,13 +1786,13 @@ return false; } }} > - + - + 🎉 You've unlocked a new course! -

+

Congratulations on completing this course. You've earned an exclusive spot in{" "} @@ -1796,7 +1800,7 @@ return false; . Claim it now to get started.

-
+
+
+
- {/* Hidden proctoring engine — kept mounted (clipped to 1px) so the webcam - keeps decoding and anomaly detection keeps running off-screen. */} - {!showProctorDialog && ( -
- { }} - onAnomalyDetected={() => { }} - setDoGesture={setDoGesture} - settings={proctoringData || { - _id: "", - studentId: "", - versionId: "", - courseId: "", - settings: { - proctors: { - detectors: [] - }, - linearProgressionEnabled: true - } - }} - anomalies={anomalies} - readyToDetect={readyToDetect} - setReadyToDetect={setReadyToDetect} - setAnomalies={setAnomalies} - rewindVid={rewindVid} - setRewindVid={setRewindVid} - pauseVid={pauseVid} - setPauseVid={setPauseVid} - /> -
- )} + + + + {/* Enhanced Course Navigation Sidebar */} + {/* {isDesktopSidebarVisible && ( */} + +
+ {/* */} + + + {/* Vibe Logo and Brand */} +
+
+ Vibe Logo +
+
+ + ViBe + +

Learning Platform

+
+
- {/* Focused cinematic stage */} -
setAiExpanded(false)} - > - {/* Lesson content */} - {currentItem ? ( - currentItem.type === "PROJECT" ? ( -
-
- -
-
- ) : ( -
-
- setPendingStudentQuestionContext(null)} - /> -
-
- ) - ) : ( -
-
-
- -
-

Ready to learn?

-

- Open the course panel to choose a lesson and begin. -

- -
-
- )} - - {/* Floating chrome */} - { pauseVideoForControl(); setDrawerOpen(true); }} /> - { - pauseVideoForControl(); - setAiExpanded(false); - if (id === "report") { - if (isFlagSubmitted) { - toast.info("You've already flagged this item.", { position: "top-right" }); - return; - } - setIsFlagModalOpen(true); - } else { - setAiSheet(id); - } - }} - /> - {!allProctorsDisabled && !showProctorDialog && ( - { pauseVideoForControl(); setCamPinned((p) => !p); }} - onHoverChange={setCamHover} - anomaly={pauseVid || rewindVid} - /> - )} - - {/* Contextual skip / go-to-next (middle-right) */} - {currentItem && ((currentItem as any)?.isOptional || showGoToNextButton) && ( -
- {(currentItem as any)?.isOptional && ( - - )} - {showGoToNextButton && ( - - )} -
- )} - - {/* Initial webcam popup — first ~11s while the camera sets up */} - {!allProctorsDisabled && !showProctorDialog && } - - {/* Transient notifications */} -
- {isItemForbidden && ( -
-
- -
-

Lesson locked

-

- ViBe lessons unlock in order. Please finish your current lesson to continue. + + + {/* Course Info */} + {/*

+
+ +
+
+

+ {courseVersionData?.name || "Course Content"} +

+

+ {modules.length} modules • Learning Progress

- -
+
*/} + + + + + + {modules.map((module: any) => { + const moduleId = module.moduleId; + const progress = moduleProgressMap.get(moduleId); + const isModuleExpanded = expandedModules[moduleId]; + const isCurrentModule = moduleId === selectedModuleId; + + return ( + + toggleModule(moduleId)} + isActive={isCurrentModule} + aria-expanded={isModuleExpanded} + data-state={isModuleExpanded ? 'open' : 'closed'} + className="group relative h-10 px-3 w-full rounded-lg transition-all duration-200 hover:bg-gradient-to-r hover:from-accent/20 hover:to-accent/5 hover:shadow-sm data-[state=active]:bg-gradient-to-r data-[state=active]:from-primary/15 data-[state=active]:to-primary/5 data-[state=active]:text-primary data-[state=active]:shadow-sm" + > + +
+ + +
+ +
+ {module.name.length > 34 ? `${module.name.substring(0, 31)}...` : module.name} +
+
0) ? `dark:text-green-500 text-green-600 ` : ` text-muted-foreground`}`}> + {moduleProgressLoading + ? "..." + : `${progress?.completedItems ?? 0}/${progress?.totalItems ?? 0} completed` + } +
+
+
+ + {module.name} + +
+
+ {module.sections?.length || 0} sections +
+ +
+
+ + {isModuleExpanded && module.sections && ( + + {module.sections.map((section: any) => { + const sectionId = section.sectionId; + const isSectionExpanded = expandedSections[sectionId]; + const isCurrentSection = sectionId === selectedSectionId; + const isLoadingItems = activeSectionInfo?.sectionId === sectionId && itemsLoading; + + return ( + + toggleSection(moduleId, sectionId)} + isActive={isCurrentSection} + aria-expanded={isSectionExpanded} + data-state={isSectionExpanded ? 'open' : 'closed'} + className="group relative h-8 px-3 w-full rounded-md text-xs transition-all duration-200 hover:bg-accent/10 hover:text-accent-foreground data-[state=active]:bg-accent/15 data-[state=active]:text-accent-foreground" + > + +
+ + +
+ {section.name.length > 27 ? `${section.name.substring(0, 24)}...` : section.name} +
+
+ + {section.name} + +
+
+
+ {isSectionExpanded && ( + + {isLoadingItems ? ( +
+ + +
+ ) : sectionItems[sectionId] ? ( + (shouldRandomize + ? sectionItems[sectionId] + : sortItemsByOrder(sectionItems[sectionId]) + ).map((item: any) => { + const itemId = item._id; + const isCurrentItem = itemId === selectedItemId; + const locked = isItemLocked(moduleId, sectionId, itemId); + + return ( + + !locked && handleSelectItem(moduleId, sectionId, itemId)} + isActive={isCurrentItem} + className={`group relative h-8 px-3 w-full rounded-md transition-all duration-200 hover:bg-accent/10 dark:data-[state=active]:bg-primary/10 data-[state=active]:bg-primary/10 data-[state=active]:text-primary justify-start ${locked ? 'opacity-50 cursor-not-allowed pointer-events-none' : ''}`} + // Assign ref only to the selected item for autoscroll + ref={isCurrentItem ? selectedItemRef : undefined} + > +
+
+ {locked ? 🔒 : getItemIcon(item.type)} +
+
+
+ {(() => { + // Show loading state if this is the selected item and it's loading + if (selectedItemId === itemId && itemLoading) { + return 'Loading...'; + } + + // Always show the actual item name, truncated if necessary + const itemName = item?.name || item?.title || 'Untitled'; + return itemName.length > 18 ? `${itemName.substring(0, 15)}...` : itemName; + })()} +
+ {item.isCompleted && ( +
+ + Completed +
+ )} +
+
+
+
+ ); + }) + ) : ( +
+
No items found
+
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
+
+
+ + {!showProctorDialog ? + { }} + onAnomalyDetected={() => { }} + setDoGesture={setDoGesture} + settings={proctoringData || { + _id: "", + studentId: "", + versionId: "", + courseId: "", + settings: { + proctors: { + detectors: [] + }, + linearProgressionEnabled: true + } + }} + anomalies={anomalies} + readyToDetect={readyToDetect} + setReadyToDetect={setReadyToDetect} + setAnomalies={setAnomalies} + rewindVid={rewindVid} + setRewindVid={setRewindVid} + pauseVid={pauseVid} + setPauseVid={setPauseVid} + /> : + } + + {/* Navigation Footer */} + + + + + +
+ +
+ Dashboard + +
+
+ + + + +
+ +
+ Courses + +
+
+ + {(courseVersionData as any)?.supportLink && (() => { + const link = (courseVersionData as any).supportLink; + const isEmail = link.startsWith('mailto:') || (!link.startsWith('http://') && !link.startsWith('https://') && !link.startsWith('//') && link.includes('@')); + const href = link.startsWith('mailto:') + ? link + : link.startsWith('http://') || link.startsWith('https://') || link.startsWith('//') + ? link + : link.includes('@') + ? `mailto:${link}` + : link; + return ( + + + +
+ +
+ Get Support + +
+
+
+ ); + })()} + + + + + + + + + + {user?.name?.charAt(0).toUpperCase() || 'U'} + + +
+
{user?.name || 'Profile'}
+
View Profile
+
+ +
+
+
+
+
- )} - - {timeSlotBlock && ( -
-
- -
-

- {/book a time slot|choose a slot/i.test(timeSlotBlock) ? "Book a time slot" : "Outside your study window"} -

-

{timeSlotBlock}

-
- {/book a time slot|choose a slot/i.test(timeSlotBlock) && ( - - )} - + + {/* // )} */} + {/* {isDesktopSidebarVisible && */} + + {/* } */} + + {/* Main Content Area */} + + {!focusMode && ( +
+ {/* */} + + +
+
+ {currentItem ? currentItem.name : 'Select content to begin learning'}
-
-
- )} - - {doGesture && currentItem?.type !== "VIDEO" && ( -
-
- thumbs up -
-

Gesture required

-

Show a thumbs up to continue.

+
+
-
-
- )} - - {quizPassed !== 2 && quizPassed !== 3 && !isQuizSkipped && ( -
-
- {quizPassed === 1 ? : } -
-

{quizPassed === 1 ? "Quiz passed!" : "Quiz failed"}

-

- {quizPassed === 1 ? "Moving to the next video" : "Redirecting to the previous video"} -

+ + )} + + {/* Emotion Selector Bar */} + {currentItem && !focusMode && ( +
+
- -
-
- )} -
-
- - {/* Course progress / navigation drawer (opened by the back button) */} - { handleSelectItem(m, s, i); setDrawerOpen(false); }} - isItemLocked={isItemLocked} - emotion={ - currentItem - ? { - itemId: currentItem._id, - onEmotionSelect: handleEmotionSubmit, - selectedEmotion: selectedEmotion[currentItem._id] || null, - } - : null - } - /> - - {/* AI companion placeholder surfaces (chat / talk / discussion) */} - setAiSheet(null)} /> - - {/* Report (flag) — real, existing feature */} - - - {/* Time-slot picker (lazy) */} - {showTimeslotPicker && ( - - { setShowTimeslotPicker(false); setTimeSlotBlock(null); }} - courseId={COURSE_ID} - courseVersionId={VERSION_ID} - currentUserId={""} - hasAssignedTimeslot={false} - /> - - )} + )} + +
+ {/* Ambient background effect */} +
+ + {/* Notification Stack */} +
+ {/* ✅ Item Access Error Notification */} + {isItemForbidden && ( + + +
+ +
+
+ {/* + + Access Restricted + +

+ {itemError && itemErrorName === "ForbiddenError" + ? itemError + : previousValidItem + ? "Returning to previous valid content." + : "Complete current item first to access this content." + } +

+ */} + + Lesson Locked + +

+ ViBe lessons unlock in order, so you build each concept on the previous one. Please finish your current lesson to continue. +

+
+ +
+
+ )} + + {/* ⏰ Time-slot / commitment gate notice */} + {timeSlotBlock && ( + + +
+ +
+
+ + {/book a time slot|choose a slot/i.test(timeSlotBlock) ? 'Book a time slot' : 'Outside your study window'} + +

{timeSlotBlock}

+
+ {/book a time slot|choose a slot/i.test(timeSlotBlock) && ( + + )} + +
+
+
+
+ )} + + {showTimeslotPicker && ( + + { setShowTimeslotPicker(false); setTimeSlotBlock(null); }} + courseId={COURSE_ID} + courseVersionId={VERSION_ID} + currentUserId={""} + hasAssignedTimeslot={false} + /> + + )} + + {/* Gesture Notification — also shown for VIDEO in focus mode, + since the in-sidebar camera (which normally shows this) is hidden. */} + {doGesture && (currentItem?.type !== 'VIDEO' || focusMode) && ( + + +
+ +
+
+ + Gesture Required + +

+ Show a thumbs up! +

+
+
+
+ )} + + {/* Quiz Passed/Failed */} + + {quizPassed !== 2 && quizPassed !== 3 && !isQuizSkipped && ( +
+
+ {/* Close Button */} + + +
+ {/* Icon + Title */} +
+
+
+
+ {quizPassed === 1 ? ( + + ) : ( + + )} +
+
+ +
+

+ {quizPassed === 1 ? 'Quiz Passed!' : 'Quiz Failed'} +

+
+
+ + {quizPassed === 1 ? 'Great job!' : 'Keep learning'} + +
+
+
+ + {/* Redirect Indicator */} +
+
+
+
+
+
+

+ {quizPassed === 1 ? 'Moving to the next video' : 'Redirecting to the previous video'} +

+
+
+
+
+ )} - {/* Cursor left the page for 5s+ → pause + blur; auto-resumes on return */} - - - {/* Speaking / background-noise indicator — top center, non-blocking */} - - - {/* Buttonless anomaly alert — covers the video until it clears (incl. no/multiple person) */} - - - {/* Fullscreen gate — after a reload or an Esc, a request can only succeed - inside a click, so block the lesson until the student clicks to re-enter. */} - {needsFullscreen && ( -
- -
-

Fullscreen required

-

- This lesson runs in fullscreen. Your video is paused — click below to continue. -

-
- -
- )} +
+ + {currentItem ? ( +
+ {anomalies.includes("faceRecognition") && ( +
+
+
+ +
+
+

Identity Mismatch Paused

+

+ The camera detects a different face or an unknown person. Please ensure the registered student is watching the course to continue. +

+
+
+ + Verifying live via camera... +
+
+
+ )} +
+ {!isFlagSubmitted && + + } + +{(currentItem as any)?.isOptional && ( + +)} + +{showGoToNextButton && ( + +)} + + + +
+ {currentItem?.type === 'PROJECT' ? ( + + ) : isPeerReviewItem ? ( + // Peer-review assessment item. We must first fetch the + // assessment record (rubric, deadlines, cohort) by + // itemId, then render PeerReviewSubmissionForm. + // Phase 4.2.5 — student side. +
+ {peerReviewAssessmentHook.isLoading ? ( +

Loading assessment…

+ ) : peerReviewAssessmentHook.error ? ( +
+

+ Could not load this peer-review assessment: + {' '}{peerReviewAssessmentHook.error} +

+
+ ) : peerReviewAssessmentHook.data ? ( + // courseId/versionId live on the course-store + // (the items themselves don't carry them — items + // live in itemsGroup, scoped to the version). + + ) : ( +

No assessment found.

+ )} +
+ ) : ( + + setPendingStudentQuestionContext(null)} + /> + )} + +
+ ) : ( +
+
+
+
+
+ +
+
+

+ Ready to Learn? +

+

+ Select an item from the course navigation to begin your learning journey and unlock new knowledge. +

+ +
+
+ )} +
+ + + + ); }; \ No newline at end of file diff --git a/frontend/src/app/pages/student/peer-review/MyScore.tsx b/frontend/src/app/pages/student/peer-review/MyScore.tsx new file mode 100644 index 000000000..dbd345b58 --- /dev/null +++ b/frontend/src/app/pages/student/peer-review/MyScore.tsx @@ -0,0 +1,180 @@ +import React, { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { AlertCircle, CheckCircle2, Loader2, Star } from "lucide-react"; +import { toast } from "sonner"; +import { useReviewsReceived, useMySubmission } from "@/hooks/hooks"; + +/** + * MyScore. + * + * Phase 4.2.5. Lists the student's own submissions + the reviews + * received. Until all reviews are in, the reviews-shown block is + * anonymized (zero identifying data per review). + * + * Double-blind guarantees rendered here: + * - review.reviewerId NEVER shown (allow-list at the controller already + * strips it, but the UI also never reads it) + * - review.reviewerName / review.reviewerEmail NEVER shown + * - we only show review.scores + review.overallComment (safe fields) + */ + +interface Props { + /** Pass the assessment id to filter, or undefined to fetch all */ + assessmentId?: string; +} + +export function MyScore({ assessmentId }: Props) { + const reviewsHook = useReviewsReceived(assessmentId); + const submissionHook = useMySubmission(assessmentId); + + if (reviewsHook.isLoading || submissionHook.isLoading) { + return ( +
+ +
+ ); + } + if (reviewsHook.error) { + return ( +
+ + Could not load your reviews: {reviewsHook.error} +
+ ); + } + + const payload = (reviewsHook.data ?? { reviews: [], finalScore: null }) as any; + const reviews: any[] = (payload.reviews ?? []).map((r: any) => ({ + // Allow-list mirror on the client. If a future server change adds + // reviewer identity to the payload, the UI ignores it. + _id: r._id, + scores: r.scores ?? [], + overallComment: r.overallComment ?? "", + totalScore: r.totalScore ?? null, + submittedAt: r.submittedAt ?? null, + isLate: r.isLate ?? false, + teacherOverridden: r.teacherOverridden ?? false, + })) as any[]; + const finalScore = payload.finalScore ?? null; + const submission = submissionHook.data as any | null | undefined; + const reviewsCompleted = submission?.reviewsCompleted ?? 0; + const reviewsTotal = submission?.reviewsTotal ?? 0; + + return ( +
+
+

+ My peer-review grades +

+

+ Reviews are double-blind. We never show who reviewed you. +

+
+ + {submission && ( + + +
+ Submission status + {finalScore ? ( + + Final score: {finalScore} + + ) : reviewsTotal > 0 ? ( + + {reviewsCompleted} of {reviewsTotal} reviews in + + ) : null} +
+
+ + {reviewsTotal === 0 ? ( +

+ No reviews expected yet. +

+ ) : reviewsCompleted < reviewsTotal ? ( +

+ Your peer reviews are still being submitted. The final + score will appear here once all {reviewsTotal} reviews + are in. +

+ ) : ( +

+ All reviews are in. Your grade is final. +

+ )} +
+
+ )} + + {reviews.length === 0 ? ( + + + No reviews yet for this assessment. + + + ) : ( +
+ {reviews.map((r, i) => ( + + +
+ + Review #{i + 1} + +
+ {r.teacherOverridden && ( + + Teacher override + + )} + {r.isLate && Late} +
+
+
+ +
+ + Total score (this reviewer) + + + {r.totalScore ?? "—"} + +
+
+

+ Per-criterion scores +

+
    + {(r.scores ?? []).map((s: any, j: number) => ( +
  • + + {s.criterionId} + + {s.score} +
  • + ))} +
+
+ {r.overallComment && ( +
+

+ Comment +

+

+ {r.overallComment} +

+
+ )} +
+
+ ))} +
+ )} +
+ ); +} + +export default MyScore; diff --git a/frontend/src/app/pages/student/peer-review/PeerReviewSubmissionForm.tsx b/frontend/src/app/pages/student/peer-review/PeerReviewSubmissionForm.tsx new file mode 100644 index 000000000..fedbbb59a --- /dev/null +++ b/frontend/src/app/pages/student/peer-review/PeerReviewSubmissionForm.tsx @@ -0,0 +1,886 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useCourseStore } from "@/store/course-store"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { PlusCircle, X, AlertCircle, CheckCircle2, Loader2, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { + useSubmitPeerReview, + useMySubmission, + useCheckPeerReviewLink, + useStartItem, + useStopItem, + useMyPeerReviewSubmissionSummary, +} from "@/hooks/hooks"; + +/** + * Student-side submission form for a peer-review assessment item. + * + * Phase 3 + Phase 7 audit-improvement. Renders inside the student's + * course-tree view when item type === 'PEER_REVIEW_ASSESSMENT'. + * + * - 1..20 Drive-style links (kind auto-detected from URL host). + * - Re-editable up until the deadline (idempotent on the server). + * - Status pill: 'Not submitted' / 'Submitted on time' / + * 'Submitted late' / 'Past deadline' / 'Saving...' + * - Phase 7: live accessibility badge per link. Each link row + * debounces the URL by 500ms then hits GET /peer-review-links/check + * to surface a green check or a red x with reason. The backend + * caches results for 60s, so a flurry of edits is cheap. + */ + +interface Props { + courseId: string; + versionId: string; + itemId: string; + moduleId: string; + sectionId: string; + assessment: any; + submissionDeadline?: Date; + cohortId?: string; +} + +interface StudentLink { + url: string; + label: string; + kind?: "drive" | "github" | "youtube" | "oneDrive" | "dropbox" | "other"; +} + +const EMPTY_LINK: StudentLink = { url: "", label: "", kind: undefined }; + +// 500ms debounce hook (Phase 7 audit improvement). +function useDebounced(value: T, ms: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), ms); + return () => clearTimeout(t); + }, [value, ms]); + return debounced; +} + +function LinkAccessibilityBadge({ url }: { url: string }) { + const debounced = useDebounced(url, 500); + const check = useCheckPeerReviewLink(debounced); + if (!debounced || debounced.trim().length === 0) { + return null; + } + if (check.isLoading) { + return ( + + + Checking... + + ); + } + if (!check.data) return null; + if (check.data.accessible) { + return ( + + + Publicly accessible + + ); + } + const reasonText: Record = { + http_401: 'Requires sign-in (401)', + http_403: 'Forbidden (403)', + http_404: 'File not found (404) — check the link', + http_5xx: 'Server error (5xx)', + auth_required: 'Drive/OneDrive link is private', + timeout: 'Request timed out', + dns_failure: 'DNS lookup failed', + connection_refused: 'Connection refused', + invalid_url: 'Invalid URL — paste a full https://... link', + }; + const reason = reasonText[check.data.reason ?? ''] ?? (check.data.reason ?? 'Not accessible'); + return ( + + + {reason} + + ); +} + +export function PeerReviewSubmissionForm({ + courseId, + versionId, + itemId, + moduleId: moduleIdProp, + sectionId: sectionIdProp, + cohortId: cohortIdProp, + assessment, + submissionDeadline, +}: Props) { + const startItem = useStartItem(); + const stopItem = useStopItem(); + // The course-store is the canonical source of moduleId/sectionId/cohortId + // — it's set by course-page when the student navigates between items, + // same pattern the VIDEO flow uses for useStartItem/useStopItem. The + // props are passed in too but the items endpoint doesn't return these + // fields on each item, so the store is the fallback that always works. + const { currentCourse } = useCourseStore(); + const moduleId = moduleIdProp || (currentCourse?.moduleId ?? ''); + const sectionId = sectionIdProp || (currentCourse?.sectionId ?? ''); + const cohortId = cohortIdProp || (currentCourse?.cohortId ?? ''); + const submitHook = useSubmitPeerReview(); + // ALSO: assessment._id arrives over the wire as either a hex + // string OR a `{buffer:{data:[...]}}` shape (Mongo BSON's JSON + // representation of an ObjectId), depending on which backend + // endpoint returned it and whether class-transformer coerced it. + // `String()` on the POJO shape produces '[object Object]' — the + // root cause of the cross-item "Submitted on time" bleed, because + // every item's id then stringified to the same key, so the per-item + // localStorage cache and the summary-endpoint lookup both collided. + // Mongoose-shaped `_id` instances with a real `toHexString()` are + // handled by the same fallback path. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const toIdString = (v: any): string | null => { + if (v == null) return null; + if (typeof v === 'string') return v; + if (typeof v.toHexString === 'function') { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (v as any).toHexString(); + } catch { + // fall through to the generic toString path on the next + // branch; intentionally swallow the error here. + const _ignored = true; + void _ignored; + } + } + if (v && typeof v === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const buf: any = v; + if (buf.buffer && Array.isArray(buf.buffer.data)) { + return buf.buffer.data + .map((b: number) => b.toString(16).padStart(2, '0')) + .join(''); + } + if (typeof v.toString === 'function') { + const s = v.toString(); + if (s && s !== '[object Object]') return s; + } + } + return null; + }; + const rawAssessmentId = assessment?._id || assessment?.assessmentId; + const assessmentId: string | null = toIdString(rawAssessmentId); + const submissionQuery = useMySubmission(assessmentId || undefined); + // Server fetch is run for diagnostic logging only — we ignore its + // result for state purposes because openapi-fetch's querySerializer + // can't handle nested arrays in the response. localStorage is the + // sole source of truth for "did this student submit?". + void submissionQuery; + // ALSO: bulk submission-summary endpoint — returns a flat list of + // {assessmentId, submitted, submittedAt} for every peer-review + // assessment in this course. We use it as the primary source of + // truth: a peer-review item is "submitted" iff this hook reports + // submitted=true for its assessmentId. This is the canonical + // ViBe-style per-user-per-course progress lookup pattern, just + // adapted for peer-review. Because the response is flat (no + // nested arrays), openapi-fetch can deserialize it without + // crashing. + const summaryQuery = useMyPeerReviewSubmissionSummary( + courseId, + versionId, + cohortId, + ); + // Local override of the submission doc — set when our POST returns + // (before the refetch lands) or when the server refetch comes back. + // This bypasses react-query's openapi-fetch cache-key mystery and + // guarantees the form flips to the read-only view on first click. + // PERSISTED TO LOCALSTORAGE so a hard refresh stays read-only even + // if the GET fails (e.g. openapi-fetch token-refresh hiccup). + // + // IMPORTANT: the assessment prop is async — it's null on the first + // render of a hard refresh and only arrives milliseconds later via + // usePeerReviewAssessmentByItemId. We must therefore hydrate + // localStorage in a useEffect keyed on the assessmentId, NOT in + // useState's initializer (which would run with assessmentId undefined). + // + // toIdString() and assessmentId are defined at the top of the + // component (just below submitHook) so the useMySubmission hook + // call above can use the coerced id too. The storage key for + // per-item localStorage is keyed off the same coerced value. + const storageKey = assessmentId ? `peerReviewSubmission:${assessmentId}` : null; + const [localExisting, setLocalExisting] = useState(null); + // Hydrate from localStorage whenever the assessmentId becomes known + // (initial mount, hard refresh, or navigating between assessments). + // Also clear any stale "[object Object]" keys left over from the + // version before we coerced assessmentId to a string — those keys + // would otherwise shadow per-assessment keys with cross-item bleed. + // CRITICAL: localStorage is the primary source of truth here. The + // GET in this codebase can fail with "Deeply-nested arrays/objects + // aren't supported" from openapi-fetch's querySerializer when the + // server response has nested fields — which the submission doc + // does (links: [{...}]). When that happens serverExisting stays + // undefined and the form flips back to editable on revisit, which + // is the bug the user reported. localStorage is reliable, the GET + // is not, so we trust localStorage. + // + // Backup strategy: if the per-assessment key is missing (e.g. + // user submitted in an earlier session before this fix landed), + // we ALSO scan the master list under 'peerReviewSubmissions:all' + // for a matching submission. This makes the cache robust against + // missing per-key entries. + useEffect(() => { + if (typeof window === 'undefined') return; + try { + // Sweep stale broken keys from older versions of this form. + const staleKeys: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const k = window.localStorage.key(i); + if (k && k.startsWith('peerReviewSubmission:[')) staleKeys.push(k); + } + staleKeys.forEach(k => window.localStorage.removeItem(k)); + } catch {} + if (!storageKey || !assessmentId) return; + try { + // First try the per-assessment key. + const cached = window.localStorage.getItem(storageKey); + if (cached) { + const parsed = JSON.parse(cached); + setLocalExisting(parsed); + console.log('[peer-review] hydrated from per-key cache', storageKey); + return; + } + // Fall back to master list — find a submission for this assessmentId. + // Use toIdString on both sides so legacy entries whose + // assessmentId is still a `{buffer:{data:[...]}}` POJO (from + // before this fix landed) compare correctly. + const masterRaw = window.localStorage.getItem('peerReviewSubmissions:all'); + if (masterRaw) { + const master = JSON.parse(masterRaw); + const match = (master || []).find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (m: any) => toIdString(m?.assessmentId) === assessmentId, + ); + if (match) { + // Also rewrite the entry so its assessmentId is now a + // proper string going forward — prevents the same kind of + // bleed on the next reload. + const normalized = { ...match, assessmentId }; + setLocalExisting(normalized); + // Also repopulate the per-key cache for next time. + // eslint-disable-next-line no-empty + try { window.localStorage.setItem(storageKey, JSON.stringify(normalized)); } catch {} + // Rewrite the master list entry too. + try { + const idx = (master || []).findIndex( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (m: any) => toIdString(m?.assessmentId) === assessmentId, + ); + if (idx >= 0) { + const next = [...(master || [])]; + next[idx] = normalized; + window.localStorage.setItem('peerReviewSubmissions:all', JSON.stringify(next)); + } + // eslint-disable-next-line no-empty + } catch {} + console.log('[peer-review] hydrated from master list for', storageKey); + } + } + } catch (e) { + console.warn('[peer-review] localStorage parse failed', e); + } + // intentionally only react to storageKey changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + // Find the entry for THIS assessment in the bulk summary. + // summaryForThis is the canonical "did the student submit?" answer + // coming from the database via the flat summary endpoint. Done as a + // synchronous expression (not useMemo) so every render recomputes — + // cheap, but more important: it always reflects the latest + // summaryQuery.data, no stale-cache edge cases. + const summaryForThis: { assessmentId: string; submitted: boolean; submittedAt?: string } | null = + (summaryQuery.data && assessmentId) + ? (summaryQuery.data.find( + (m) => String(m.assessmentId) === assessmentId, + ) ?? null) + : null; + // Synthetic submission doc when the summary says "submitted". This + // is what the read-only view consumes. + // Wrapped in useMemo so the object reference is stable across renders + // when the underlying data hasn't changed — without this, every render + // produced a fresh `existing` object and triggered the form-mount + // writeback / notes-link sync useEffects in a loop, blowing past + // React's "Maximum update depth" limit. + const serverExisting = useMemo( + () => (summaryForThis?.submitted + ? { + _id: undefined as any, + assessmentId, + studentId: '', + courseId: '', + courseVersionId: '', + cohortId: '', + notes: '', + links: [] as any[], + submittedAt: summaryForThis.submittedAt, + isLate: false, + reviewsCompleted: 0, + reviewsTotal: 3, + reviewAssignmentIds: [] as string[], + teacherOverridden: false, + } + : null), + [summaryForThis?.submitted, summaryForThis?.submittedAt, assessmentId], + ); + // existing = server truth FIRST, localStorage optimistic SECOND. + // Server is the canonical source per ViBe's progress-tracking + // pattern; localStorage is just a fast-path while waiting for the + // next refetch to land. + // + // CRITICAL: only trust the localStorage cache when the assessment + // data has stabilized. The course-page passes `assessment` from + // usePeerReviewAssessmentByItemId, which keeps the previous item's + // data while the next item's fetch is in flight. If we read + // localStorage during that window, we'd use the previous item's + // key and could surface the wrong item's submission. Gate on + // the assessmentId being a real string (not undefined) AND on + // the assessment being loaded for THIS item (peerReviewAssessmentHook + // has data with the matching id). + // Simpler: only use localStorage if the assessment prop is non-null + // AND its _id matches the assessmentId we're keying on. course-page + // ensures this by setting `assessment` to the freshly-fetched doc. + const assessmentMatchesItemId = !!(assessment && assessment._id && assessmentId && toIdString(assessment._id) === assessmentId); + const localExistingForCurrent = (assessmentMatchesItemId) ? localExisting : null; + const existing = serverExisting ?? localExistingForCurrent; + // Belt-and-braces: keep the per-key cache in sync as a defensive + // measure, even though onSave writes synchronously. If anything + // else (a refetch landing, etc.) updates localExisting, this + // mirrors it to per-key storage so the next mount has a hit. + useEffect(() => { + if (existing && typeof window !== 'undefined' && storageKey) { + try { window.localStorage.setItem(storageKey, JSON.stringify(existing)); } catch {} + } + }, [existing, storageKey]); + console.log('[peer-review] form mount', { + storageKey, + hasLocalCached: !!localExisting, + summaryQueryStatus: summaryQuery.isLoading ? 'loading' : summaryQuery.error ? `error:${(summaryQuery.error as any)?.message ?? summaryQuery.error}` : 'ready', + summaryDataCount: summaryQuery.data?.length ?? 0, + summaryForThisMatch: summaryForThis ? { submitted: summaryForThis.submitted, submittedAt: summaryForThis.submittedAt } : null, + serverExistingIsTruthy: !!serverExisting, + finalExistingIsTruthy: !!existing, + }); + // Debug: surface ALL localStorage entries with the peerReview prefix + // so we can see if the cache is present. + if (typeof window !== 'undefined') { + const keys: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const k = window.localStorage.key(i); + if (k && k.startsWith('peerReview')) keys.push(k); + } + console.log('[peer-review] localStorage keys:', keys); + } + // Local flag — flips true on the first submit click. Combined with + // the read-only "if (existing) return" branch, this prevents the + // user from spam-clicking Submit before the refetch lands. + const [hasSubmitted, setHasSubmitted] = useState(false); + + const [notes, setNotes] = useState(existing?.notes ?? ""); + const [links, setLinks] = useState( + existing?.links?.length + ? existing.links.map((l: any) => ({ url: l.url, label: l.label, kind: l.kind })) + : [EMPTY_LINK], + ); + + useEffect(() => { + if (existing) { + setNotes(existing.notes ?? ""); + setLinks( + existing.links?.length + ? existing.links.map((l: any) => ({ url: l.url, label: l.label, kind: l.kind })) + : [EMPTY_LINK], + ); + } + }, [existing]); + // Track this item in the user's progress (mirrors the VIDEO flow's + // useStartItem call). On submit, useStopItem marks the item complete + // and the module progress counter increments. This is what makes + // the module sidebar show "1/7 completed" and unblock the next item. + const [watchItemId, setWatchItemId] = useState(null); + useEffect(() => { + // Don't start tracking if the student has already submitted — + // the existing useStartItem would be a no-op and we'd 400 on stop. + if (existing || hasSubmitted) return; + if (!courseId || !versionId || !itemId || !moduleId || !sectionId) return; + let cancelled = false; + (async () => { + try { + const result: any = await startItem.mutateAsync({ + params: { path: { courseId, courseVersionId: versionId } }, + body: { itemId, moduleId, sectionId, cohortId }, + }); + if (!cancelled && result?.watchItemId) setWatchItemId(result.watchItemId); + } catch (e) { + // Non-fatal — the item still gets marked complete on stopItem + // because the backend will start a fresh watch item if needed. + console.warn('[peer-review] useStartItem failed (non-fatal)', e); + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [courseId, versionId, itemId, moduleId, sectionId, existing?._id]); + + const deadline = useMemo(() => { + if (submissionDeadline) return submissionDeadline; + if (assessment?.submissionDeadline) return new Date(assessment.submissionDeadline); + return null; + }, [submissionDeadline, assessment]); + const isPast = deadline ? new Date() > deadline : false; + + function setLinkAt(i: number, patch: Partial) { + setLinks(prev => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l))); + } + function addLink() { + if (links.length >= 20) { + toast.error("At most 20 links allowed."); + return; + } + setLinks([...links, EMPTY_LINK]); + } + function removeLink(i: number) { + setLinks(prev => prev.filter((_, idx) => idx !== i)); + } + + const valid = + !isPast && + links.length >= 1 && + links.every(l => l.url.trim().length > 0 && l.label.trim().length > 0); + + async function onSave() { + if (!assessment) return; + if (existing || hasSubmitted) { + toast.error("You've already submitted. To update, refresh the page."); + return; + } + if (!valid) { + toast.error("Add at least one link (url + label) before submitting."); + return; + } + if (submitHook.isPending) return; + try { + // Flip the local flag IMMEDIATELY so the very next render of the + // page shows the read-only "Submitted" view. Without this, the + // form stays editable for as long as the refetch is in flight, + // and the user can click Submit again. + console.log('[peer-review] setting hasSubmitted=true BEFORE POST'); + setHasSubmitted(true); + const result = await submitHook.mutateAsync({ + params: { path: { courseId: courseId as any, versionId: versionId as any, itemId: itemId as any } }, + body: { + notes, + links: links.map(l => ({ + url: l.url.trim(), + label: l.label.trim(), + kind: l.kind, + })), + }, + }); + console.log('[peer-review] POST returned 2xx', result); + // Build the submission doc locally and set it as the + // authoritative existing doc. The form re-renders to the + // read-only view on the next paint. No refetch is needed. + const newSubmission = { + _id: (result as any)?.submissionId, + assessmentId: (assessmentId ?? String((assessment as any)?._id || (assessment as any)?.assessmentId || '')), + studentId: '', + courseId: (assessment as any).courseId, + courseVersionId: (assessment as any).courseVersionId, + cohortId: (assessment as any).cohortId, + notes: notes, + links: links.map(l => ({ + url: l.url.trim(), + label: l.label.trim(), + kind: l.kind, + })), + submittedAt: new Date().toISOString(), + isLate: new Date() > new Date((assessment as any).submissionDeadline), + reviewsCompleted: 0, + reviewsTotal: 3, + reviewAssignmentIds: [], + teacherOverridden: false, + }; + setLocalExisting(newSubmission); + // Forcefully write to localStorage synchronously — don't rely on + // the useEffect writeback chain, which only fires after the next + // render. The POST just succeeded; persist immediately so a + // navigation away from this item (or any reload) still sees + // the submitted state via localStorage even if the writeback + // effect never gets a chance to flush. + if (storageKey && typeof window !== 'undefined') { + try { + window.localStorage.setItem(storageKey, JSON.stringify(newSubmission)); + // Also append to the master list so we have a redundant + // backup under 'peerReviewSubmissions:all'. The form looks + // up by assessmentId there if the per-key cache is missing. + // Use toIdString on the comparison side so legacy entries + // with POJO-shaped assessmentIds are deduped correctly. + try { + const raw = window.localStorage.getItem('peerReviewSubmissions:all'); + const list: any[] = raw ? JSON.parse(raw) : []; + const filtered = (list || []).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (m: any) => toIdString(m?.assessmentId) !== assessmentId, + ); + filtered.push(newSubmission); + window.localStorage.setItem('peerReviewSubmissions:all', JSON.stringify(filtered)); + // eslint-disable-next-line no-empty + } catch {} + console.log('[peer-review] persisted submission to localStorage', storageKey); + } catch (e) { + console.warn('[peer-review] localStorage write failed', e); + } + } + console.log('[peer-review] setLocalExisting with submissionId', newSubmission._id); + // Mark the item complete in the user's progress (mirrors the + // VIDEO flow's stop-on-end behaviour). This is what flips the + // module sidebar counter to "1/7 completed" and lets the + // student move to the next item. + try { + await stopItem.mutateAsync({ + params: { path: { courseId, courseVersionId: versionId } }, + body: { + itemId, + moduleId, + sectionId, + cohortId, + isSkipped: false, + seekForwardEnabled: false, + watchItemId: watchItemId ?? '', + }, + }); + console.log('[peer-review] useStopItem success — item marked complete'); + } catch (e) { + console.warn('[peer-review] useStopItem failed (non-fatal)', e); + } + // Refresh the bulk summary so the next item the student + // navigates to sees the up-to-date submitted map. + try { summaryQuery.refetch(); } catch {} + toast.success("Submission saved. You'll need to review 3 of your peers next."); + } catch (e: any) { + // Roll back the local flag so the user can retry on failure. + console.log('[peer-review] POST failed, rolling back', e?.message); + setHasSubmitted(false); + toast.error(`Save failed: ${e?.message ?? "Unknown error"}`); + } + } + + let status = "Not submitted"; + if (submitHook.isPending) status = "Saving..."; + else if (existing) status = existing.isLate ? "Submitted late" : "Submitted on time"; + else if (isPast) status = "Past deadline"; + const statusColor = + status === "Submitted on time" ? "text-emerald-600" : + status === "Submitted late" ? "text-amber-600" : + status === "Past deadline" ? "text-red-600" : + status === "Saving..." ? "text-blue-600" : + "text-slate-500"; + + const navigate = useNavigate(); + + // Render the read-only "Submitted" view if we have a confirmed + // submission (existing) OR the local hasSubmitted flag is set + // (the POST is in flight, the refetch hasn't landed). The view + // must be safe for both cases — every existing.* access uses + // the optional chain so a hasSubmitted-only state doesn't crash. + if (hasSubmitted || existing) { + // Minimal "submitting" state when hasSubmitted is set but the + // server-confirmed refetch hasn't landed yet. + if (hasSubmitted && !existing) { + return ( +
+ + + {assessment?.title ?? "Peer-Review Assessment"} +

+ Status: Submission in progress… confirm with backend… +

+
+ +

+ Saving your submission. This typically takes 1–2 seconds. +

+
+
+
+ ); + } + console.log('[peer-review] rendering submitted view', { + hasSubmitted, + existingIsLate: existing?.isLate, + existingSubmittedAt: existing?.submittedAt, + }); + const submittedAt = existing?.submittedAt ? new Date(existing.submittedAt) : null; + return ( +
+ + + {assessment?.title ?? "Peer-Review Assessment"} +

+ Status: {existing?.isLate ? "Submitted late" : existing?.submittedAt ? "Submitted on time" : hasSubmitted ? "Submitting..." : "Saved"} +

+
+ {assessment?.description && ( + +

+ {assessment.description} +

+
+ )} +
+ + {assessment?.rubric && assessment.rubric.length > 0 && ( + + + Rubric + + +
    + {assessment.rubric.map((c: any, i: number) => ( +
  • + + {c.label} + {c.description && ( + — {c.description} + )} + + / {c.maxPoints} +
  • + ))} +
+
+
+ )} + + + + Your submission + + + {submittedAt && ( +

+ Submitted on {submittedAt.toLocaleString()} +

+ )} + {existing?.notes && ( +

+ {existing.notes} +

+ )} +
+ {Array.isArray(existing?.links) && (existing?.links ?? []).length > 0 ? ( + (existing?.links ?? []).map((l: any, i: number) => ( + + {l.label} — {l.url} + + )) + ) : ( +

No links.

+ )} +
+
+
+ + + + What's next? + + +

+ Your submission is in. Once the submission deadline passes, you'll be + assigned 3 peer submissions to review. +

+

+ The peer-review round unlocks automatically — until then, you can + see your queue on the Peer Reviews page. +

+ +
+
+
+ ); + } + + return ( +
+ + + {assessment?.title ?? "Peer-Review Assessment"} +

Status: {status}

+
+ {assessment?.description && ( + +

+ {assessment.description} +

+
+ )} +
+ + {assessment?.rubric && assessment.rubric.length > 0 && ( + + + Rubric + + +
    + {assessment.rubric.map((c: any, i: number) => ( +
  • + + {c.label} + {c.description && ( + — {c.description} + )} + + / {c.maxPoints} +
  • + ))} +
+
+
+ )} + + + + Notes (optional) + + + + + + + + + +
+ + +
+ `; + + body.querySelector('#__pr_cancel__')?.addEventListener('click', () => overlay.remove()); + const errEl = body.querySelector('#__pr_error__') as HTMLElement; + + body.querySelector('#__pr_save__')?.addEventListener('click', async () => { + const titleEl = body.querySelector('#__pr_title__') as HTMLInputElement; + const descEl = body.querySelector('#__pr_desc__') as HTMLTextAreaElement; + const cohortEl = body.querySelector('#__pr_cohort__') as HTMLSelectElement; + const dlEl = body.querySelector('#__pr_deadline__') as HTMLInputElement; + const reviewsEl = body.querySelector('#__pr_reviews__') as HTMLInputElement; + const windowEl = body.querySelector('#__pr_window__') as HTMLInputElement; + + const title = (titleEl?.value || '').trim() || defaultTitle; + const description = (descEl?.value || '').trim(); + const cohortId = cohortEl?.value || ''; + const submissionDeadline = dlEl?.value || ''; + const reviews = Math.max(1, Math.min(5, Number(reviewsEl?.value) || 3)); + const windowDays = Math.max(1, Math.min(60, Number(windowEl?.value) || 7)); + + if (!cohortId || !submissionDeadline) { + errEl.textContent = 'Please choose a cohort and a submission deadline.'; + errEl.style.display = 'block'; + return; + } + + errEl.textContent = 'Creating...'; + errEl.style.color = '#0369a1'; + errEl.style.display = 'block'; + + try { + // Step 4: POST the assessment. The endpoint accepts the field name + // shape that PeerReviewAssessmentService expects (rubric, deadlines, + // cohortId, etc.). Mirror what the React form would have sent. + const isoSubmissionDeadline = new Date(submissionDeadline).toISOString(); + const reviewDeadline = new Date( + new Date(submissionDeadline).getTime() + windowDays * 86400000, + ).toISOString(); + const payload = { + title, + description, + itemName: title, + itemDescription: description, + cohortId, + submissionDeadline: isoSubmissionDeadline, + reviewDeadline, + reviewWindowDays: windowDays, + teacherManualReviewEnabled: true, + notificationsEnabled: true, + latePolicy: 'penalty-only', + latePenaltyPercent: 10, + antiCollusionMode: 'circular-shift-collision-check', + reviewsPerSubmission: reviews, + reviewsPerReviewer: reviews, + rubric: [ + { label: 'Code Quality', maxPoints: 25 }, + { label: 'Functionality', maxPoints: 50 }, + { label: 'Documentation', maxPoints: 15 }, + { label: 'Creativity', maxPoints: 10 }, + ], + moduleId, + sectionId, + courseId, + courseVersionId: versionId, + }; + const res = await fetch(`${BACKEND_BASE}/peer-review-assessments`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`Create failed: ${res.status} — ${txt.slice(0, 300)}`); + } + const created = await res.json(); + // Success: tear down the modal and reload the course sections so the + // new item shows up in the sidebar. + overlay.remove(); + try { window.location.reload(); } catch {} + } catch (e: any) { + errEl.textContent = String(e?.message || e); + errEl.style.color = '#b91c1c'; + errEl.style.display = 'block'; + } + }); + } + // Add Item (handles all item types including video, quiz, article, and project) const handleAddItem = (moduleId: string, sectionId: string, type: string, videoData?: any) => { if (!versionId) return; - type ItemType = "VIDEO" | "QUIZ" | "BLOG" | "PROJECT" | "FEEDBACK"; + type ItemType = "VIDEO" | "QUIZ" | "BLOG" | "PROJECT" | "FEEDBACK" | "PEER_REVIEW_ASSESSMENT"; const typeMap: Record = { video: "VIDEO", quiz: "QUIZ", article: "BLOG", project: "PROJECT", - feedback: "FEEDBACK" + feedback: "FEEDBACK", + 'peer-review': "PEER_REVIEW_ASSESSMENT", }; + // Peer-review assessments: render an imperative DOM modal that bypasses + // React's render / portal / state cycle entirely. After hours of debugging + // a no-op "click does nothing" symptom where state setters ran but JSX + // never re-rendered, this is the only path I trust. The modal is plain + // HTML attached to document.body directly. It calls the backend REST + // endpoints we already have working (cohort list + assessment create). + if (type === 'peer-review') { + void openPeerReviewAssessmentModalImperatively({ moduleId, sectionId, courseId, versionId }); + return; + } + + // Peer-review assessments: render an imperative DOM modal that bypasses + // React's render / portal / state cycle entirely. After hours of debugging + // a no-op "click does nothing" symptom where state setters ran but JSX + // never re-rendered, this is the only path I trust. The modal is plain + // HTML attached to document.body directly. It calls the backend REST + // endpoints we already have working (cohort list + assessment create). + if (type === 'peer-review') { + void openPeerReviewAssessmentModalImperatively({ moduleId, sectionId, courseId, versionId }); + return; + } + // Handle video items if (type === "VIDEO" && videoData) { createItemAsync({ @@ -1963,16 +2236,25 @@ function TeacherCourseContent() { Hide Section - {expandedSections[section.sectionId] && ( - { - // - pendingOrderItems.current[section.sectionId] = newItemOrder; - // - }} - > + + {expandedSections[section.sectionId] && ( + + { + // + pendingOrderItems.current[section.sectionId] = newItemOrder; + // + }} + > {itemsLoading && activeSectionInfo?.sectionId === section.sectionId ? (
@@ -2005,8 +2287,19 @@ function TeacherCourseContent() { handleMoveItem(module.moduleId, section.sectionId, item._id, versionId); }} > - - attrs here instead of using the + component, because already renders an
  • + and nesting 's
  • inside it caused + the "In HTML,
  • cannot be a descendant of
  • " hydration + error that broke the whole React tree (manifesting as the + "Add Item -> Peer Review Assessment" click being a no-op). */} +
    + View student questions )} - +
    ))}
    @@ -2300,6 +2593,7 @@ function TeacherCourseContent() { > {hasExistingProject ? 'Project (Limit 1 per course)' : 'Project'} + @@ -2412,9 +2706,11 @@ function TeacherCourseContent() {
    - - - )} + + + + )} +
  • ))} @@ -3467,6 +3763,21 @@ function TeacherCourseContent() { }} /> )} + {selectedEntity.type === "item" && selectedEntity.data.type === "PEER_REVIEW_ASSESSMENT" && ( + { + refetchVersion(); + refetchItems(); + }} + onAfterDelete={() => { + refetchVersion(); + refetchItems(); + setSelectedEntity(null); + }} + /> + )}
    @@ -4005,7 +4316,11 @@ export function UserAnalytics({ - {/* Pagination (buttons should use bg-primary inside your Pagination component) */} + {/* Peer-review assessment creation: rendered imperatively via + openPeerReviewAssessmentModalImperatively() (called from + handleAddItem). The vanilla-DOM modal lives in document.body + and does NOT depend on React's render cycle. See the function + body for the full rationale + the cohorts/create flow. */} void; + onAfterDelete?: () => void; +}) { + const queryResult = usePeerReviewAssessmentByItemId(itemId); + const { data: assessment, isLoading } = queryResult as any; + const refetchAssessment = (queryResult as any).refetch; + const closeMutation = useClosePeerReviewAssessment(); + const updateMutation = useUpdatePeerReviewAssessment(); + + // Local edit state. Initialized from the loaded assessment, kept in + // sync via a useEffect when the assessment re-loads after a save. + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); + const [editRubric, setEditRubric] = useState< + Array<{ criterionId: string; label: string; maxPoints: number }> + >([]); + const [editSubmissionDeadline, setEditSubmissionDeadline] = useState(''); + const [editReviewWindowDays, setEditReviewWindowDays] = useState(7); + const [editLatePolicy, setEditLatePolicy] = useState<'penalty-only' | 'hard-exclude'>('penalty-only'); + const [editLatePenaltyPercent, setEditLatePenaltyPercent] = useState(10); + const [editTeacherManualReviewEnabled, setEditTeacherManualReviewEnabled] = useState(false); + const [editNotificationsEnabled, setEditNotificationsEnabled] = useState(true); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + if (!assessment || hydrated) return; + const a = assessment as any; + setEditTitle(a.title ?? ''); + setEditDescription(a.description ?? ''); + setEditRubric( + Array.isArray(a.rubric) + ? a.rubric.map((c: any) => ({ + criterionId: String(c.criterionId), + label: String(c.label ?? ''), + maxPoints: Number(c.maxPoints ?? 0), + })) + : [], + ); + setEditSubmissionDeadline( + a.submissionDeadline + ? new Date(a.submissionDeadline).toISOString().slice(0, 16) + : '', + ); + setEditReviewWindowDays( + Number(a.config?.reviewWindowDays ?? 7), + ); + setEditLatePolicy( + (a.config?.latePolicy as 'penalty-only' | 'hard-exclude') ?? + 'penalty-only', + ); + setEditLatePenaltyPercent(Number(a.config?.latePenaltyPercent ?? 10)); + setEditTeacherManualReviewEnabled( + !!a.config?.teacherManualReviewEnabled, + ); + setEditNotificationsEnabled(!!a.config?.notificationsEnabled); + setHydrated(true); + }, [assessment, hydrated]); + + const isClosed = !!(assessment as any)?.closedAt; + const submissionCount = (assessment as any)?.submissionsCount; + + const totalRubricPoints = editRubric.reduce( + (acc, c) => acc + (Number.isFinite(c.maxPoints) ? c.maxPoints : 0), + 0, + ); + + const addRubricRow = () => { + setEditRubric(prev => [ + ...prev, + { criterionId: `new-${Date.now()}`, label: '', maxPoints: 10 }, + ]); + }; + + const updateRubricRow = ( + idx: number, + patch: Partial<{ label: string; maxPoints: number }>, + ) => { + setEditRubric(prev => + prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)), + ); + }; + + const removeRubricRow = (idx: number) => { + setEditRubric(prev => prev.filter((_, i) => i !== idx)); + }; + + const canSaveAssessment = + editTitle.trim().length >= 3 && + editRubric.length >= 1 && + editRubric.every(c => c.label.trim().length > 0 && c.maxPoints > 0) && + editSubmissionDeadline.length > 0; + + const handleSave = async () => { + const aid = (assessment as any)?._id ?? (assessment as any)?.assessmentId; + if (!aid) { + toast.error('Assessment id missing'); + return; + } + try { + await updateMutation.mutateAsync({ + params: { path: { id: String(aid) } }, + body: { + title: editTitle.trim(), + description: editDescription, + rubric: editRubric.map(c => ({ + criterionId: c.criterionId.startsWith('new-') + ? undefined + : c.criterionId, + label: c.label, + maxPoints: c.maxPoints, + })), + submissionDeadline: new Date(editSubmissionDeadline).toISOString(), + reviewWindowDays: editReviewWindowDays, + latePolicy: editLatePolicy, + latePenaltyPercent: editLatePenaltyPercent, + teacherManualReviewEnabled: editTeacherManualReviewEnabled, + notificationsEnabled: editNotificationsEnabled, + }, + } as any); + toast.success('Assessment updated'); + setHydrated(false); // re-hydrate from server response + refetchAssessment?.(); + } catch (e: any) { + toast.error('Save failed: ' + (e?.message || 'unknown error')); + } + }; + + const handleClose = async () => { + const aid = (assessment as any)?._id ?? (assessment as any)?.assessmentId; + if (!aid) { + toast.error('Assessment id missing — cannot close'); + return; + } + if ( + !window.confirm( + `End the submission window for "${editTitle || itemName}"? This will:\n` + + ` • Stamp closedAt on the assessment\n` + + ` • Run the peer-review assignment algorithm inline\n` + + ` • Send each submitter a "Submissions closed" notification\n` + + ` • Send each reviewer a "You have N reviews to complete" notification`, + ) + ) { + return; + } + try { + await closeMutation.mutateAsync({ + params: { path: { id: String(aid) } }, + } as any); + toast.success( + 'Submissions closed. Reviewer assignments + notifications fired.', + ); + refetchAssessment?.(); + onAfterClose?.(); + } catch (e: any) { + toast.error('Failed to close: ' + (e?.message || 'unknown error')); + } + }; + + const handleDelete = async () => { + const aid = (assessment as any)?._id ?? (assessment as any)?.assessmentId; + if (!aid) { + toast.error('Assessment id missing — cannot delete'); + return; + } + if ( + !window.confirm( + `Delete the peer-review assessment "${editTitle || itemName}"?\n\n` + + `This removes the item from the section and deletes its assessment record.\n` + + `It is only allowed if no student has submitted yet — once students have submitted, ` + + `deletion would break the audit trail and the assessment can no longer be removed.`, + ) + ) { + return; + } + try { + const BACKEND_BASE = + (import.meta as any).env?.VITE_BACKEND_BASE_URL || ''; + const token = (useAuthStore.getState() as any)?.token; + const res = await fetch(`${BACKEND_BASE}/api/peer-review-assessments/${aid}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token ?? ''}` }, + }); + if (!res.ok) { + const errText = await res.text(); + throw new Error(errText || `HTTP ${res.status}`); + } + toast.success('Assessment deleted'); + onAfterDelete?.(); + onAfterClose?.(); + } catch (e: any) { + toast.error('Delete failed: ' + (e?.message || 'unknown error')); + } + }; + + if (isLoading) { + return ( +
    Loading assessment…
    + ); + } + + if (!assessment) { + return ( +
    +
    +

    + No peer-review assessment found for this item. +

    +

    + Delete this item and recreate the assessment via the "Peer Review" + add-item flow. +

    +
    + +
    + ); + } + + const submissionDeadlinePreview = editSubmissionDeadline + ? new Date(editSubmissionDeadline).toLocaleString() + : '—'; + const reviewDeadlinePreview = editSubmissionDeadline + ? new Date( + new Date(editSubmissionDeadline).getTime() + + editReviewWindowDays * 24 * 60 * 60 * 1000, + ).toLocaleString() + : '—'; + + const aid = (assessment as any)?._id ?? (assessment as any)?.assessmentId; + + return ( +
    + {/* Header — title + description */} +
    + + setEditTitle(e.target.value)} + placeholder="Peer-review assessment title" + maxLength={200} + /> +
    +
    + + - - - - - - -
    - - +

    Creating a peer-review assessment in this section. Students in the chosen cohort will be paired to review each other.

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    `; From 443becd777c15faf66596b869607cc92e280c6e8 Mon Sep 17 00:00:00 2001 From: Shreyas Mene Date: Sat, 1 Aug 2026 21:06:40 +0530 Subject: [PATCH 17/17] feat(peerReview): add teacher manual reviewer disqualification and UI layout refinements --- .../PeerReviewTeacherController.ts | 160 ++++- .../mongodb/PeerReviewAssignmentRepository.ts | 48 ++ .../mongodb/PeerReviewSubmissionRepository.ts | 49 ++ .../services/PeerReviewScoringService.ts | 15 +- .../tests/PeerReviewStudentExclusion.test.ts | 223 +++++++ backend/src/shared/interfaces/models.ts | 7 +- .../app/pages/teacher/teacher-course-page.tsx | 593 +++++++++--------- frontend/src/hooks/hooks.ts | 25 + 8 files changed, 833 insertions(+), 287 deletions(-) create mode 100644 backend/src/modules/peerReview/tests/PeerReviewStudentExclusion.test.ts diff --git a/backend/src/modules/peerReview/controllers/PeerReviewTeacherController.ts b/backend/src/modules/peerReview/controllers/PeerReviewTeacherController.ts index eeea66567..596e8970a 100644 --- a/backend/src/modules/peerReview/controllers/PeerReviewTeacherController.ts +++ b/backend/src/modules/peerReview/controllers/PeerReviewTeacherController.ts @@ -130,9 +130,14 @@ export class PeerReviewTeacherController { reassignmentCount: a.reassignmentCount, }); } - const actualReviewsTotal = assignments.length > 0 - ? assignments.length + + const activeAssignments = (assignments as any[]).filter((a: any) => a.status !== 'EXCLUDED' && a.status !== 'CANCELLED'); + const submittedAssignments = (assignments as any[]).filter((a: any) => a.status === 'SUBMITTED'); + + const actualReviewsTotal = activeAssignments.length > 0 + ? activeAssignments.length : (s.reviewsTotal || (assessment as any).config?.reviewsPerSubmission || 3); + const actualReviewsCompleted = submittedAssignments.length; let finalScore = s.teacherOverridden && typeof s.teacherOverrideScore === 'number' ? s.teacherOverrideScore @@ -140,7 +145,7 @@ export class PeerReviewTeacherController { if ( finalScore === null && - ((s.reviewsCompleted ?? 0) > 0 || (assessment as any).closedAt) + (actualReviewsCompleted > 0 || (assessment as any).closedAt) ) { try { const res = await this.scoringService.scoreSubmission( @@ -166,12 +171,15 @@ export class PeerReviewTeacherController { isLate: s.isLate, notes: s.notes, links: s.links ?? [], - reviewsCompleted: s.reviewsCompleted ?? 0, + reviewsCompleted: actualReviewsCompleted, reviewsTotal: actualReviewsTotal, finalScore, teacherOverridden: !!s.teacherOverridden, teacherOverrideScore: s.teacherOverrideScore ?? null, teacherOverrideReason: s.teacherOverrideReason ?? null, + excludedFromPeerReview: !!s.excludedFromPeerReview || !!s.reviewerExcluded, + reviewerExcluded: !!s.reviewerExcluded || !!s.excludedFromPeerReview, + teacherExcludeReason: s.teacherExcludeReason ?? null, pendingTeacherIntervention: !!s.pendingTeacherIntervention, assignmentsToReviewers: reviewerDetails, }); @@ -326,6 +334,12 @@ export class PeerReviewTeacherController { const out: any[] = []; for (const { r, studentId } of reviewsWithDetails) { const reviewerId = (r.reviewerId as any)?.toString(); + const assignmentId = (r.assignmentId as any)?.toString(); + const assignment = assignmentId ? await this.assignmentRepo.findById(assignmentId) : null; + const isExcludedAssignment = assignment?.status === 'EXCLUDED'; + if (isExcludedAssignment) { + continue; + } const isOverridden = !!r.teacherOverridden; const effectiveScores = isOverridden && r.teacherOverrideScores && r.teacherOverrideScores.length > 0 @@ -352,6 +366,7 @@ export class PeerReviewTeacherController { originalTotalScore: r.totalScore ?? 0, submittedAt: r.submittedAt, isLate: r.isLate, + isExcludedAssignment, teacherOverridden: isOverridden, teacherOverrideReason: r.teacherOverrideReason ?? null, }); @@ -465,6 +480,143 @@ export class PeerReviewTeacherController { }; } + @Post('/peer-review-assessments/submissions/:submissionId/exclude-student') + @HttpCode(200) + @Authorized(['INSTRUCTOR', 'MANAGER']) + async excludeStudentFromPeerReview( + @Req() req: any, + @CurrentUser({ required: true }) user: IUser, + @Param('submissionId') submissionId: string, + @Body() + body: { + reason?: string; + reset?: boolean; + }, + ): Promise { + const submission = await this.submissionRepo.findById(submissionId); + if (!submission || (submission as any).isDeleted) { + throw new NotFoundError('Submission not found.'); + } + + const assessmentId = (submission as any).assessmentId?.toString(); + const studentId = (submission as any).studentId?.toString(); + + if (body.reset) { + await this.submissionRepo.clearExclusion(submissionId); + const recompute = await this.scoringService.recomputeSubmission(submissionId); + return { + success: true, + reset: true, + submissionId, + excludedFromPeerReview: false, + finalScore: recompute?.totalScore ?? null, + }; + } + + if (!body.reason || body.reason.length < 20) { + throw new BadRequestError( + 'A reason of at least 20 characters is required for excluding a student from peer review.', + ); + } + + // 1. Mark submission as excluded + await this.submissionRepo.excludeFromPeerReview( + submissionId, + body.reason, + user._id!.toString(), + ); + + // 2. Mark assignments where this student was reviewer as EXCLUDED + const reviewerAssignments = await this.assignmentRepo.excludeAssignmentsByReviewer( + assessmentId, + studentId, + ); + + // 3. For target submissions losing a reviewer, attempt replacement reassignment if candidate active reviewers exist + const assessment = await this.assessmentRepo.findById(assessmentId); + const allSubmissionsInAssessment = await this.submissionRepo.findByAssessment(assessmentId); + const maxReviewsPerReviewer = (assessment as any)?.config?.reviewsPerReviewer || 3; + + for (const asn of reviewerAssignments as any[]) { + const targetSubId = (asn.submissionId as any)?.toString(); + if (!targetSubId || targetSubId === submissionId) continue; + + const targetSub = await this.submissionRepo.findById(targetSubId); + if (!targetSub) continue; + const targetStudentId = (targetSub.studentId as any)?.toString(); + + // Find existing assignments for targetSub + const existingAsns = await this.assignmentRepo.findBySubmission(targetSubId); + const assignedReviewerIds = new Set(existingAsns.map((a: any) => (a.reviewerId as any)?.toString())); + + // Look for an eligible replacement candidate in the cohort + let replacementReviewerId: string | null = null; + for (const candSub of allSubmissionsInAssessment as any[]) { + const candStudentId = (candSub.studentId as any)?.toString(); + if (!candStudentId) continue; + if (candStudentId === studentId) continue; // Exclude disqualified student + if (candStudentId === targetStudentId) continue; // Exclude submission author + if (assignedReviewerIds.has(candStudentId)) continue; // Already assigned + + // Check candidate active review load + const candAsns = await this.assignmentRepo.findByReviewer(assessmentId, candStudentId); + const activeCandAsns = candAsns.filter((a: any) => a.status !== 'EXCLUDED' && a.status !== 'CANCELLED'); + if (activeCandAsns.length < maxReviewsPerReviewer) { + replacementReviewerId = candStudentId; + break; + } + } + + if (replacementReviewerId) { + await this.assignmentRepo.create({ + assessmentId: new ObjectId(assessmentId), + submissionId: new ObjectId(targetSubId), + reviewerId: new ObjectId(replacementReviewerId), + status: 'PENDING', + assignedAt: new Date(), + reassignmentCount: ((asn as any).reassignmentCount || 0) + 1, + } as any); + } + + // Recompute target submission score from remaining valid submitted reviews + await this.scoringService.recomputeSubmission(targetSubId); + } + + // 4. Recompute student's own submission score (evaluating reviews received normally) + const recompute = await this.scoringService.recomputeSubmission(submissionId); + + // 5. Audit trail + setAuditTrail(req, { + category: AuditCategory.PEER_REVIEW, + action: AuditAction.PEER_REVIEW_TEACHER_OVERRIDE, + actor: { + id: new ObjectId(user._id!.toString()), + name: `${user.firstName} ${user.lastName}`, + email: user.email, + role: user.roles, + }, + context: { + peerReviewAssessmentId: assessmentId as any, + }, + changes: { + after: { + action: 'STUDENT_EXCLUDED_FROM_PEER_REVIEW', + studentId, + submissionId, + reason: body.reason, + }, + }, + }); + + return { + ok: true, + submissionId, + studentId, + excludedFromPeerReview: true, + reason: body.reason, + }; + } + // closeAssessment intentionally removed — it was a stub that just // stamped closedAt and never ran the assignment algorithm or fired // notifications. The real close lives in diff --git a/backend/src/modules/peerReview/repositories/providers/mongodb/PeerReviewAssignmentRepository.ts b/backend/src/modules/peerReview/repositories/providers/mongodb/PeerReviewAssignmentRepository.ts index ba9575230..ff9592125 100644 --- a/backend/src/modules/peerReview/repositories/providers/mongodb/PeerReviewAssignmentRepository.ts +++ b/backend/src/modules/peerReview/repositories/providers/mongodb/PeerReviewAssignmentRepository.ts @@ -259,4 +259,52 @@ export class PeerReviewAssignmentRepository { const docs = await this.collection.find(filter).toArray(); return docs as IPeerReviewAssignment[]; } + + async excludeAssignmentsByReviewer( + assessmentId: string, + reviewerId: string, + session?: ClientSession, + ): Promise { + await this.init(); + const filter: any = {}; + if (ObjectId.isValid(assessmentId)) { + filter.assessmentId = new ObjectId(assessmentId); + } else { + filter.assessmentId = assessmentId; + } + if (ObjectId.isValid(reviewerId)) { + filter.reviewerId = new ObjectId(reviewerId); + } else { + filter.reviewerId = reviewerId; + } + + const affected = await this.collection.find(filter).toArray(); + await this.collection.updateMany( + filter, + { $set: { status: 'EXCLUDED' as const, updatedAt: new Date() } }, + { session }, + ); + return affected as IPeerReviewAssignment[]; + } + + async excludeAssignmentsBySubmission( + submissionId: string, + session?: ClientSession, + ): Promise { + await this.init(); + const filter: any = {}; + if (ObjectId.isValid(submissionId)) { + filter.submissionId = new ObjectId(submissionId); + } else { + filter.submissionId = submissionId; + } + + const affected = await this.collection.find(filter).toArray(); + await this.collection.updateMany( + filter, + { $set: { status: 'EXCLUDED' as const, updatedAt: new Date() } }, + { session }, + ); + return affected as IPeerReviewAssignment[]; + } } \ No newline at end of file diff --git a/backend/src/modules/peerReview/repositories/providers/mongodb/PeerReviewSubmissionRepository.ts b/backend/src/modules/peerReview/repositories/providers/mongodb/PeerReviewSubmissionRepository.ts index 13b94412a..d258e1873 100644 --- a/backend/src/modules/peerReview/repositories/providers/mongodb/PeerReviewSubmissionRepository.ts +++ b/backend/src/modules/peerReview/repositories/providers/mongodb/PeerReviewSubmissionRepository.ts @@ -373,4 +373,53 @@ export class PeerReviewSubmissionRepository { { session }, ); } + + async excludeFromPeerReview( + id: string, + reason: string, + teacherId: string, + session?: ClientSession, + ): Promise { + await this.init(); + const filter = ObjectId.isValid(id) + ? { _id: new ObjectId(id) as any } + : { _id: id as any }; + await this.collection.updateOne( + filter, + { + $set: { + excludedFromPeerReview: true, + reviewerExcluded: true, + teacherExcludeReason: reason, + teacherExcludedAt: new Date(), + teacherExcludedBy: teacherId as any, + updatedAt: new Date(), + }, + }, + { session }, + ); + } + + async clearExclusion( + id: string, + session?: ClientSession, + ): Promise { + await this.init(); + const filter = ObjectId.isValid(id) + ? { _id: new ObjectId(id) as any } + : { _id: id as any }; + await this.collection.updateOne( + filter, + { + $set: { + excludedFromPeerReview: false, + teacherExcludeReason: null, + teacherExcludedAt: null, + teacherExcludedBy: null, + updatedAt: new Date(), + }, + }, + { session }, + ); + } } \ No newline at end of file diff --git a/backend/src/modules/peerReview/services/PeerReviewScoringService.ts b/backend/src/modules/peerReview/services/PeerReviewScoringService.ts index 08693f0e0..ad05c7c7c 100644 --- a/backend/src/modules/peerReview/services/PeerReviewScoringService.ts +++ b/backend/src/modules/peerReview/services/PeerReviewScoringService.ts @@ -40,6 +40,8 @@ export class PeerReviewScoringService extends BaseService { private readonly submissionRepo: PeerReviewSubmissionRepository, @inject(PEERREVIEW_TYPES.PeerReviewReviewRepo) private readonly reviewRepo: PeerReviewReviewRepository, + @inject(PEERREVIEW_TYPES.PeerReviewAssignmentRepo) + private readonly assignmentRepo: PeerReviewAssignmentRepository, @inject(PEERREVIEW_TYPES.PeerReviewNotificationService) private readonly notifier: PeerReviewNotificationService, @inject(GLOBAL_TYPES.Database) @@ -68,7 +70,18 @@ export class PeerReviewScoringService extends BaseService { (submission as any).assessmentId?.toString(), ); if (!assessment) return undefined; - const reviews = await this.reviewRepo.findBySubmission(submissionId); + const assignments = await this.assignmentRepo.findBySubmission(submissionId); + const validSubmittedAssignmentIds = new Set( + assignments + .filter((a: any) => a.status === 'SUBMITTED') + .map((a: any) => (a._id as any).toString()), + ); + const allReviews = await this.reviewRepo.findBySubmission(submissionId); + const reviews = assignments.length > 0 + ? allReviews.filter((r: any) => + validSubmittedAssignmentIds.has((r.assignmentId as any)?.toString()), + ) + : allReviews; return this.computeAndPersist( submissionId, assessment, diff --git a/backend/src/modules/peerReview/tests/PeerReviewStudentExclusion.test.ts b/backend/src/modules/peerReview/tests/PeerReviewStudentExclusion.test.ts new file mode 100644 index 000000000..a2574b5af --- /dev/null +++ b/backend/src/modules/peerReview/tests/PeerReviewStudentExclusion.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { Container } from 'inversify'; +import { InversifyAdapter } from '#root/inversify-adapter.js'; +import { useContainer } from 'routing-controllers'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { MongoClient, ObjectId } from 'mongodb'; +import { GLOBAL_TYPES } from '#root/types.js'; +import { peerReviewContainerModule } from '../container.js'; +import { PEERREVIEW_TYPES } from '../types.js'; +import { PeerReviewTeacherController } from '../controllers/PeerReviewTeacherController.js'; +import { PeerReviewAssessmentRepository } from '../repositories/providers/mongodb/PeerReviewAssessmentRepository.js'; +import { PeerReviewSubmissionRepository } from '../repositories/providers/mongodb/PeerReviewSubmissionRepository.js'; +import { PeerReviewAssignmentRepository } from '../repositories/providers/mongodb/PeerReviewAssignmentRepository.js'; +import { PeerReviewReviewRepository } from '../repositories/providers/mongodb/PeerReviewReviewRepository.js'; +import { MongoDatabase } from '#shared/database/providers/mongo/MongoDatabase.js'; +import { UserRepository } from '#shared/database/providers/mongo/repositories/UserRepository.js'; +import { USERS_TYPES } from '#users/types.js'; +import { IUser } from '#shared/interfaces/models.js'; + +let mongoServer: MongoMemoryServer; +let mongoClient: MongoClient; + +let teacherController: PeerReviewTeacherController; +let assessmentRepo: PeerReviewAssessmentRepository; +let submissionRepo: PeerReviewSubmissionRepository; +let assignmentRepo: PeerReviewAssignmentRepository; +let reviewRepo: PeerReviewReviewRepository; +let userRepo: UserRepository; +let database: MongoDatabase; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const uri = mongoServer.getUri(); + mongoClient = new MongoClient(uri); + await mongoClient.connect(); + + const c = new Container(); + await c.load(peerReviewContainerModule); + c.bind(GLOBAL_TYPES.uri).toConstantValue(uri); + c.bind(GLOBAL_TYPES.dbName).toConstantValue('vibe_exclusion_test'); + c.bind(GLOBAL_TYPES.Database).to(MongoDatabase).inSingletonScope(); + c.bind(MongoDatabase).toDynamicValue(() => c.get(GLOBAL_TYPES.Database)); + c.bind(GLOBAL_TYPES.UserRepo).to(UserRepository).inSingletonScope(); + + c.unbind(PEERREVIEW_TYPES.PeerReviewNotificationService); + c.bind(PEERREVIEW_TYPES.PeerReviewNotificationService).toConstantValue({ + notifySubmissionsClosed: async () => 'ok', + notifyAssignmentsOut: async () => 'ok', + notifyTeacherOverride: async () => 'ok', + } as any); + + c.bind(GLOBAL_TYPES.CourseRepo).toConstantValue({ + findEnrollment: async () => ({ role: 'INSTRUCTOR' }), + } as any); + c.bind(USERS_TYPES.ItemRepo).toConstantValue({ + findById: async () => ({ name: 'Test Peer Assessment', type: 'PEER_REVIEW_ASSESSMENT' }), + } as any); + + database = c.get(GLOBAL_TYPES.Database); + await database.connect(); + + useContainer(new InversifyAdapter(c)); + + assessmentRepo = c.get(PEERREVIEW_TYPES.PeerReviewAssessmentRepo); + submissionRepo = c.get(PEERREVIEW_TYPES.PeerReviewSubmissionRepo); + assignmentRepo = c.get(PEERREVIEW_TYPES.PeerReviewAssignmentRepo); + reviewRepo = c.get(PEERREVIEW_TYPES.PeerReviewReviewRepo); + userRepo = c.get(GLOBAL_TYPES.UserRepo); + + teacherController = new PeerReviewTeacherController( + assessmentRepo, + submissionRepo, + reviewRepo, + assignmentRepo, + c.get(PEERREVIEW_TYPES.PeerReviewScoringService), + c.get(PEERREVIEW_TYPES.PeerReviewNotificationService), + userRepo, + ); +}, 30000); + +afterEach(async () => { + if (mongoClient) { + const db = mongoClient.db('vibe_exclusion_test'); + await Promise.all([ + db.collection('peer_review_assessments').deleteMany({}), + db.collection('peer_review_submissions').deleteMany({}), + db.collection('peer_review_assignments').deleteMany({}), + db.collection('peer_reviews').deleteMany({}), + db.collection('users').deleteMany({}), + ]); + } +}); + +afterAll(async () => { + if (database) { + await database.disconnect(); + } + if (mongoClient) { + await mongoClient.close(); + } + if (mongoServer) { + await mongoServer.stop(); + } +}); + +describe('Peer Review Student Exclusion Tests', () => { + it('disqualifies student from reviewing peers while preserving their submission evaluation', async () => { + // 1. Seed Teacher, Student A (colluding reviewer), Student B, Student C + const teacherId = new ObjectId().toString(); + const studentAId = new ObjectId().toString(); + const studentBId = new ObjectId().toString(); + const studentCId = new ObjectId().toString(); + + const teacher: IUser = { + _id: new ObjectId(teacherId), + email: 'teacher@vibe.com', + firstName: 'Prof', + lastName: 'Oak', + roles: 'INSTRUCTOR', + } as any; + + await userRepo.create(teacher); + + // 2. Create Assessment + const assessmentId = new ObjectId().toString(); + await assessmentRepo.create({ + _id: new ObjectId(assessmentId), + title: 'Project Peer Review', + rubric: [{ criterionId: 'c1', label: 'Functionality', maxPoints: 50 }], + config: { reviewsPerSubmission: 2, reviewsPerReviewer: 2 }, + } as any); + + // 3. Create Submissions for A, B, C + const subAId = await submissionRepo.upsertForStudent(assessmentId, studentAId, { notes: 'Sub A' } as any); + const subBId = await submissionRepo.upsertForStudent(assessmentId, studentBId, { notes: 'Sub B' } as any); + const subCId = await submissionRepo.upsertForStudent(assessmentId, studentCId, { notes: 'Sub C' } as any); + + // 4. Create assignments: + // A reviews B (colluding high score) + // C reviews B (valid score) + // B reviews A (valid score for A's submission) + const asnABId = new ObjectId().toString(); + const asnCBId = new ObjectId().toString(); + const asnBAId = new ObjectId().toString(); + + await assignmentRepo.create({ + _id: new ObjectId(asnABId), + assessmentId: new ObjectId(assessmentId), + submissionId: new ObjectId(subBId), + reviewerId: new ObjectId(studentAId), + status: 'SUBMITTED', + } as any); + + await assignmentRepo.create({ + _id: new ObjectId(asnCBId), + assessmentId: new ObjectId(assessmentId), + submissionId: new ObjectId(subBId), + reviewerId: new ObjectId(studentCId), + status: 'SUBMITTED', + } as any); + + await assignmentRepo.create({ + _id: new ObjectId(asnBAId), + assessmentId: new ObjectId(assessmentId), + submissionId: new ObjectId(subAId), + reviewerId: new ObjectId(studentBId), + status: 'SUBMITTED', + } as any); + + // 5. Create reviews + await reviewRepo.create({ + assessmentId: new ObjectId(assessmentId), + assignmentId: new ObjectId(asnABId), + submissionId: new ObjectId(subBId), + reviewerId: new ObjectId(studentAId), + scores: [{ criterionId: 'c1', score: 50, comment: 'Fake perfect' }], + totalScore: 50, + } as any); + + await reviewRepo.create({ + assessmentId: new ObjectId(assessmentId), + assignmentId: new ObjectId(asnCBId), + submissionId: new ObjectId(subBId), + reviewerId: new ObjectId(studentCId), + scores: [{ criterionId: 'c1', score: 35, comment: 'Solid work' }], + totalScore: 35, + } as any); + + await reviewRepo.create({ + assessmentId: new ObjectId(assessmentId), + assignmentId: new ObjectId(asnBAId), + submissionId: new ObjectId(subAId), + reviewerId: new ObjectId(studentBId), + scores: [{ criterionId: 'c1', score: 40, comment: 'Good project' }], + totalScore: 40, + } as any); + + // 6. Teacher disqualifies Student A as a reviewer due to collusion + const excludeResult = await teacherController.excludeStudentFromPeerReview( + {}, + teacher, + subAId, + { reason: 'Collusion detected: Student A gave artificial perfect scores to friend.' }, + ); + + expect(excludeResult.ok).toBe(true); + + // 7. Verify Student A is marked disqualified as a reviewer + const updatedSubA = await submissionRepo.findById(subAId); + expect(updatedSubA!.reviewerExcluded || updatedSubA!.excludedFromPeerReview).toBe(true); + expect(updatedSubA!.teacherExcludeReason).toContain('Collusion detected'); + // Verify Student A's OWN submission STILL receives its score (40 pts) from peer B! + expect(updatedSubA!.finalScore).toBe(40); + + // 8. Verify Student A's review given to B is marked EXCLUDED + const updatedAsnAB = await assignmentRepo.findById(asnABId); + expect(updatedAsnAB!.status).toBe('EXCLUDED'); + + // 9. Verify target student B's score was recomputed without Student A's corrupt review + const updatedSubB = await submissionRepo.findById(subBId); + expect(updatedSubB!.finalScore).toBe(35); // B's score updated to 35 from valid reviewer C + }); +}); diff --git a/backend/src/shared/interfaces/models.ts b/backend/src/shared/interfaces/models.ts index 1ac4d6d8c..5a1ceb3a3 100644 --- a/backend/src/shared/interfaces/models.ts +++ b/backend/src/shared/interfaces/models.ts @@ -1170,7 +1170,8 @@ export type PeerReviewAssignmentStatus = | 'SUBMITTED' | 'OVERDUE' | 'REASSIGNED' - | 'LINK_REVOKED'; + | 'LINK_REVOKED' + | 'EXCLUDED'; export type PeerReviewAntiCollusionMode = | 'circular-shift-collision-check' @@ -1249,6 +1250,10 @@ export interface IPeerReviewSubmission { finalScoreLockedAt?: Date; teacherOverridden: boolean; teacherOverrideReason?: string; + excludedFromPeerReview?: boolean; + teacherExcludeReason?: string; + teacherExcludedAt?: Date; + teacherExcludedBy?: ID; createdAt: Date; updatedAt: Date; } diff --git a/frontend/src/app/pages/teacher/teacher-course-page.tsx b/frontend/src/app/pages/teacher/teacher-course-page.tsx index 712b18468..6d50cc2b3 100644 --- a/frontend/src/app/pages/teacher/teacher-course-page.tsx +++ b/frontend/src/app/pages/teacher/teacher-course-page.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useMemo, ChangeEvent, use } from "react"; import * as Papa from 'papaparse'; -import { useAddQuestionBankToQuiz, useAddQuestionToBank, useCreateQuestion, useCreateQuestionBank, useOverallVideoAnalytics, userParseCSVtoItems, useUpdateItemOptional, useVideoUserAnalytics, useClosePeerReviewAssessment, useDeletePeerReviewAssessment, usePeerReviewAssessmentByItemId, useUpdatePeerReviewAssessment, useTeacherSubmissionsForAssessment, useTeacherReviewsForAssessment, useTeacherOverrideReview, useTeacherOverrideSubmissionFinalScore } from '@/hooks/hooks'; +import { useAddQuestionBankToQuiz, useAddQuestionToBank, useCreateQuestion, useCreateQuestionBank, useOverallVideoAnalytics, userParseCSVtoItems, useUpdateItemOptional, useVideoUserAnalytics, useClosePeerReviewAssessment, useDeletePeerReviewAssessment, usePeerReviewAssessmentByItemId, useUpdatePeerReviewAssessment, useTeacherSubmissionsForAssessment, useTeacherReviewsForAssessment, useTeacherOverrideSubmissionFinalScore, useExcludeStudentFromPeerReview } from '@/hooks/hooks'; import { BarChart3, Download, LogOut, Upload, UserRoundCheck, Video, Clock, PlayCircle, Users, Search, LockOpen, Lock, ExternalLink } from 'lucide-react'; import { useHideItem } from '@/hooks/hooks'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" @@ -46,6 +46,8 @@ import { BarChart2, CheckCircle2, Filter, + ChevronDown, + ChevronUp, } from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; @@ -1844,7 +1846,7 @@ function TeacherCourseContent() { }, [modules]) return ( - + {/* Show loading overlay when processing CSV */} {isProcessingCSV && (
    @@ -2077,11 +2079,11 @@ function TeacherCourseContent() { defaultSize={20} minSize={20} maxSize={50} - // className={`${isMobileSidebarOpen ? 'fixed inset-y-0 left-0 z-50 w-[280px]' : 'hidden md:block'}`} + className="h-full max-h-screen overflow-hidden flex flex-col" > {/* sidebar content */} -
    - +
    +
    @@ -2825,9 +2827,9 @@ function TeacherCourseContent() { {/* {isDesktopSidebarVisible && } */} - + {/* Course Editor Area */} - +
    @@ -3962,7 +3964,7 @@ export default function TeacherCoursePage() { }, []); return ( - + ); @@ -4886,56 +4888,9 @@ function PeerReviewSubmissionsSection({ const queryClient = useQueryClient(); const { data, isLoading, error, refetch } = useTeacherSubmissionsForAssessment(assessmentId); const reviewsQueryResult = useTeacherReviewsForAssessment(assessmentId); - const overrideMutation = useTeacherOverrideReview(); const [expandedSubmissionId, setExpandedSubmissionId] = useState(null); - - // Override Form state - const [editingReviewId, setEditingReviewId] = useState(null); - const [overrideScores, setOverrideScores] = useState>({}); - const [overrideComment, setOverrideComment] = useState(''); - const [overrideReason, setOverrideReason] = useState(''); const [filterMode, setFilterMode] = useState<'all' | 'flagged'>('all'); - - const handleStartOverride = (review: any) => { - setEditingReviewId(review.reviewId); - setOverrideComment(review.overallComment || ''); - setOverrideReason(''); - const scoresMap: Record = {}; - for (const s of review.scores || []) { - scoresMap[s.criterionId] = s.score; - } - setOverrideScores(scoresMap); - }; - - const handleSaveOverride = async (reviewId: string) => { - if (overrideReason.trim().length < 20) { - toast.error('Override reason must be at least 20 characters.'); - return; - } - try { - const scoresPayload = Object.entries(overrideScores).map(([cid, val]) => ({ - criterionId: cid, - score: val, - })); - - await overrideMutation.mutateAsync({ - params: { path: { id: reviewId } }, - body: { - scores: scoresPayload, - overallComment: overrideComment.trim(), - reason: overrideReason.trim(), - }, - }); - - toast.success('Score overridden successfully'); - setEditingReviewId(null); - queryClient.invalidateQueries(); - refetch(); - reviewsQueryResult.refetch(); - } catch (e: any) { - toast.error('Override failed: ' + (e?.message || 'unknown error')); - } - }; + const [showAnalytics, setShowAnalytics] = useState(false); const overrideSubmissionMutation = useTeacherOverrideSubmissionFinalScore(); const [editingSubmissionFinalScoreId, setEditingSubmissionFinalScoreId] = useState(null); @@ -5002,8 +4957,71 @@ function PeerReviewSubmissionsSection({ } }; + const excludeStudentMutation = useExcludeStudentFromPeerReview(); + const [excludingSubmissionId, setExcludingSubmissionId] = useState(null); + const [excludeReason, setExcludeReason] = useState(''); + + const handleStartExclusion = (submissionId: string) => { + setExcludingSubmissionId(submissionId); + setExcludeReason(''); + }; + + const handleConfirmExclusion = async (submissionId: string) => { + if (excludeReason.trim().length < 20) { + toast.error('Exclusion reason must be at least 20 characters.'); + return; + } + try { + await excludeStudentMutation.mutateAsync({ + params: { path: { submissionId } }, + body: { + reason: excludeReason.trim(), + }, + }); + toast.success('Student removed from peer-based evaluation'); + setExcludingSubmissionId(null); + setExcludeReason(''); + queryClient.invalidateQueries(); + refetch(); + reviewsQueryResult.refetch(); + } catch (e: any) { + toast.error('Exclusion failed: ' + (e?.message || 'unknown error')); + } + }; + + const handleResetExclusion = async (submissionId: string) => { + try { + await excludeStudentMutation.mutateAsync({ + params: { path: { submissionId } }, + body: { + reset: true, + }, + }); + toast.success('Student reinstated for peer evaluation'); + queryClient.invalidateQueries(); + refetch(); + reviewsQueryResult.refetch(); + } catch (e: any) { + toast.error('Reset failed: ' + (e?.message || 'unknown error')); + } + }; + const submissions = useMemo(() => data?.submissions ?? [], [data]); - const reviews = useMemo(() => reviewsQueryResult.data?.reviews ?? [], [reviewsQueryResult.data]); + const rawReviews = useMemo(() => reviewsQueryResult.data?.reviews ?? [], [reviewsQueryResult.data]); + + const excludedReviewerIds = useMemo(() => { + const set = new Set(); + submissions.forEach((s: any) => { + if (s.reviewerExcluded || s.excludedFromPeerReview) { + set.add(s.studentId); + } + }); + return set; + }, [submissions]); + + const reviews = useMemo(() => { + return rawReviews.filter((r: any) => !r.isExcludedAssignment && !excludedReviewerIds.has(r.reviewerId)); + }, [rawReviews, excludedReviewerIds]); // Summary stats const scoredSubmissions = useMemo(() => submissions.filter((s: any) => typeof s.finalScore === 'number'), [submissions]); @@ -5141,121 +5159,139 @@ function PeerReviewSubmissionsSection({ return (
    - {/* Interactive Analytics & Malpractice Dashboard */} + {/* Interactive Analytics & Malpractice Dashboard (Collapsible) */} {submissions.length > 0 && ( - - - - - - Assessment Analytics & Collusion Insights - + + setShowAnalytics(prev => !prev)}> +
    + + Assessment Analytics & Insights {flaggedCount > 0 && ( - - + {flaggedCount} Malpractice Flag{flaggedCount > 1 ? 's' : ''} )} - - - Real-time summary of student performance, score distribution, rubric criterion averages, and collusion/anomaly tracking. - +
    +
    - - {/* Stat Cards Row */} -
    -
    -
    - Class Average - -
    -
    - {classAvg} / {totalMaxScore} pts -
    -
    -
    -
    - Review Completion - -
    -
    - {completionRate}% + {showAnalytics && ( + + {/* Stat Cards Row */} +
    +
    +
    + Class Average + +
    +
    + {classAvg} / {totalMaxScore} pts +
    -
    -
    -
    - Submissions - -
    -
    - {submissions.length} +
    +
    + Review Completion + +
    +
    + {completionRate}% +
    -
    -
    -
    - Malpractice Alerts - 0 ? 'text-amber-500' : 'text-emerald-500'}`} /> +
    +
    + Submissions + +
    +
    + {submissions.length} +
    -
    - {flaggedCount} + +
    +
    + Malpractice Alerts + 0 ? 'text-amber-500' : 'text-emerald-500'}`} /> +
    +
    + {flaggedCount} +
    -
    - {/* Interactive Charts Row */} -
    - {/* Score Distribution Pie Chart */} -
    -
    - - Score Distribution -
    -
    - - - - {scoreBrackets.map((entry, index) => ( - - ))} - - [`${val} student(s)`, 'Count']} /> - - - + {/* Interactive Charts Row */} +
    + {/* Score Distribution Pie Chart */} +
    +
    + + Score Distribution +
    +
    + + + + {scoreBrackets.map((entry, index) => ( + + ))} + + [`${val} student(s)`, 'Count']} /> + + + +
    -
    - {/* Rubric Criterion Breakdown Bar Chart */} -
    -
    - - Criterion Performance Averages -
    -
    - - - - - - [`${val} pts avg`, 'Average']} /> - - - + {/* Rubric Criterion Breakdown Bar Chart */} +
    +
    + + Criterion Performance Averages +
    +
    + + + + + + [`${val} pts avg`, 'Average']} /> + + + +
    -
    - + + )} )} @@ -5307,13 +5343,32 @@ function PeerReviewSubmissionsSection({
    -

    {sub.studentName}

    +

    + {sub.studentName} + {(sub.reviewerExcluded || sub.excludedFromPeerReview) && ( + + Reviewer Disqualified + + )} +

    {sub.studentEmail}

    Submitted: {new Date(sub.submittedAt).toLocaleString()} {sub.isLate && Late}

    - {subFlags && subFlags.length > 0 && ( + {(sub.reviewerExcluded || sub.excludedFromPeerReview) && sub.teacherExcludeReason && ( +
    +

    + + Disqualified from Reviewing Peers (Teacher Action): +

    +

    {sub.teacherExcludeReason}

    +

    + Note: This student's own submission is evaluated normally by peers and receives a grade. Their reviews given to others have been excluded. +

    +
    + )} + {subFlags && subFlags.length > 0 && !(sub.reviewerExcluded || sub.excludedFromPeerReview) && (

    @@ -5345,25 +5400,48 @@ function PeerReviewSubmissionsSection({ {sub.finalScore !== null ? `${sub.finalScore} pts` : 'Pending'} {teacherManualReviewEnabled !== false && (

    - {sub.teacherOverridden && ( + {(sub.reviewerExcluded || sub.excludedFromPeerReview) ? ( + ) : ( + <> + + {sub.teacherOverridden && ( + + )} + + )} -
    )}
    @@ -5474,6 +5552,61 @@ function PeerReviewSubmissionsSection({
    )} + {/* Reviewer Disqualification Confirmation Form */} + {excludingSubmissionId === sub.submissionId && ( +
    +
    +

    + + Disqualify {sub.studentName} as Reviewer +

    + Teacher Action +
    + +

    + Disqualifying this student as a reviewer will exclude their submitted reviews from affecting peer grades and cancel their pending reviews. This student's own submission will still be evaluated normally by peers and receive a grade. +

    + +
    + +