feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements

- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-12 14:26:39 +04:00
parent 571a08d0f7
commit 82ec3debcc
38 changed files with 2324 additions and 243 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -7,7 +7,8 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Skeleton } from "@/components/ui/skeleton";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Mic, Loader2 } from "lucide-react";
import { Mic, Loader2, Square } from "lucide-react";
import { placementService } from "@/services/placement.service";
import { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
import type { CATQuestion, CATSection } from "@/types";
import { cn } from "@/lib/utils";
@@ -324,15 +325,11 @@ export default function PlacementTest() {
)}
{question.type === "audio_recording" && (
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
<Mic className="h-12 w-12 text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center max-w-sm">
Recording will be available in a future update. Use the button below to proceed for now.
</p>
<Button type="button" variant="secondary" size="lg" disabled>
Record (placeholder)
</Button>
</div>
<PlacementSpeakingRecorder
sessionId={sessionId}
promptId={question.id}
onUploaded={() => setSingleAnswer("audio_submitted")}
/>
)}
<div className="flex justify-end pt-4">
@@ -362,3 +359,93 @@ export default function PlacementTest() {
</div>
);
}
function PlacementSpeakingRecorder({ sessionId, promptId, onUploaded }: {
sessionId: string; promptId: number; onUploaded: () => void;
}) {
const [recording, setRecording] = useState(false);
const [uploading, setUploading] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [elapsed, setElapsed] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const timerRef = useRef<number>(0);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mr = new MediaRecorder(stream, { mimeType: "audio/webm" });
chunksRef.current = [];
mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
mr.onstop = () => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
setAudioUrl(URL.createObjectURL(blob));
stream.getTracks().forEach((t) => t.stop());
uploadAudio(blob);
};
mediaRecorderRef.current = mr;
mr.start();
setRecording(true);
setElapsed(0);
timerRef.current = window.setInterval(() => setElapsed((t) => t + 1), 1000);
} catch {
// microphone permission denied
}
};
const stopRecording = () => {
mediaRecorderRef.current?.stop();
setRecording(false);
window.clearInterval(timerRef.current);
};
const uploadAudio = async (blob: Blob) => {
setUploading(true);
try {
const file = new File([blob], "speaking.webm", { type: "audio/webm" });
await placementService.uploadSpeaking(sessionId, promptId, file);
onUploaded();
} catch {
// upload error handled silently
} finally {
setUploading(false);
}
};
const mm = Math.floor(elapsed / 60);
const ss = elapsed % 60;
return (
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
{!recording && !audioUrl && (
<>
<Mic className="h-12 w-12 text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center max-w-sm">
Click the button below to start recording your speaking response.
</p>
<Button type="button" variant="secondary" size="lg" onClick={startRecording} className="gap-2">
<Mic className="h-5 w-5" /> Start Recording
</Button>
</>
)}
{recording && (
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />
<span className="font-mono text-lg tabular-nums">{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}</span>
</div>
<Button type="button" variant="destructive" size="lg" onClick={stopRecording} className="gap-2">
<Square className="h-4 w-4" /> Stop Recording
</Button>
</div>
)}
{audioUrl && (
<div className="space-y-3 w-full max-w-md">
<audio controls src={audioUrl} className="w-full" />
{uploading && <p className="text-sm text-center text-muted-foreground flex items-center justify-center gap-2"><Loader2 className="h-4 w-4 animate-spin" /> Uploading...</p>}
{!uploading && <p className="text-sm text-center text-green-600">Recording uploaded successfully</p>}
</div>
)}
</div>
);
}